Compare commits
5 Commits
5600f03fc1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dc46d83207 | |||
| 3aa3a67e5d | |||
| 8bedccaa02 | |||
| 4f5f6a5312 | |||
| 81cc17cbca |
@@ -29,9 +29,9 @@ jobs:
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
cmake \
|
cmake \
|
||||||
g++-12 \
|
g++-12 \
|
||||||
g++-aarch64-linux-gnu \
|
g++-12-aarch64-linux-gnu \
|
||||||
gcc-12 \
|
gcc-12 \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-12-aarch64-linux-gnu \
|
||||||
git \
|
git \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
@@ -44,13 +44,13 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: build/build_common/conan_cache
|
path: build/build_common/conan_cache
|
||||||
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }}
|
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', '.gitea/workflows/ci.yml') }}
|
||||||
restore-keys: conan-${{ runner.os }}-
|
restore-keys: conan-${{ runner.os }}-
|
||||||
- name: Configure native release
|
- name: Configure native release
|
||||||
run: python3 modulus.py configure --target x86_64_gcc12_release --update
|
run: python3 modulus.py configure --target x86_64_gcc12_release --update
|
||||||
- name: Check code style
|
- name: Check code style
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install --no-cache-dir -r build/build_tools/docker/format-requirements.txt
|
python3 -m pip install --no-cache-dir -r build/build_tools/docker/format_requirements.txt
|
||||||
python3 modulus.py format --check
|
python3 modulus.py format --check
|
||||||
- name: Build native release
|
- name: Build native release
|
||||||
run: python3 modulus.py build --target x86_64_gcc12_release
|
run: python3 modulus.py build --target x86_64_gcc12_release
|
||||||
|
|||||||
+7
-6
@@ -29,6 +29,7 @@ add_library(sonus
|
|||||||
src/metronome.cpp
|
src/metronome.cpp
|
||||||
src/detail/errors.cpp
|
src/detail/errors.cpp
|
||||||
src/engine.cpp
|
src/engine.cpp
|
||||||
|
src/transport.cpp
|
||||||
src/sequence.cpp
|
src/sequence.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(sonus PUBLIC
|
target_include_directories(sonus PUBLIC
|
||||||
@@ -56,7 +57,7 @@ if(BUILD_TESTING)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
install(TARGETS sonus
|
install(TARGETS sonus
|
||||||
EXPORT sonus-targets
|
EXPORT sonus_targets
|
||||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
@@ -64,25 +65,25 @@ install(TARGETS sonus
|
|||||||
|
|
||||||
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||||
|
|
||||||
install(EXPORT sonus-targets
|
install(EXPORT sonus_targets
|
||||||
NAMESPACE sonus::
|
NAMESPACE sonus::
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
)
|
)
|
||||||
|
|
||||||
configure_package_config_file(
|
configure_package_config_file(
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
|
||||||
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
)
|
)
|
||||||
|
|
||||||
write_basic_package_version_file(
|
write_basic_package_version_file(
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
|
||||||
VERSION ${PROJECT_VERSION}
|
VERSION ${PROJECT_VERSION}
|
||||||
COMPATIBILITY SameMajorVersion
|
COMPATIBILITY SameMajorVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
install(FILES
|
install(FILES
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,3 +47,7 @@ python3 modulus.py package --all
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Sonus is released under the MIT License.
|
Sonus is released under the MIT License.
|
||||||
|
|
||||||
|
Recording transports combine a streamed backing file with preloaded click WAVs on one rendered-frame clock. `prepared_transport::create(config, sample_rate, channels)` performs file preparation away from the audio callback; `engine::play_transport()` publishes it atomically and returns a `transport_session`. The session exposes `frames()`, `status()` and `stop()`. Replacing a transport cancels the previous session, and `engine::stop_all()` cancels it too.
|
||||||
|
|
||||||
|
`transport_config` takes an optional `backing` path, `beat1`/`beat2` paths, ordered `click_event` frame positions and a `duration_frames` boundary. With a backing file, its complete decoded length defines completion and must cover that boundary. Click samples are cut off at the boundary, allowing a recording tail without clicks. Without backing, the boundary defines completion. WAV decoding preserves the configured channel count. Stream refill happens on miniaudio workers; the callback mixes into a preallocated buffer, reports underruns as `FAILED`, and publishes only frames actually rendered. Call `collect_finished()` on a control thread to retire finished resources.
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
include(CMakeFindDependencyMacro)
|
include(CMakeFindDependencyMacro)
|
||||||
find_dependency(modulus CONFIG REQUIRED)
|
find_dependency(modulus CONFIG REQUIRED)
|
||||||
|
|
||||||
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake")
|
include("${CMAKE_CURRENT_LIST_DIR}/sonus_targets.cmake")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <modulus.hpp>
|
#include <modulus.hpp>
|
||||||
#include <sonus/config.hpp>
|
#include <sonus/config.hpp>
|
||||||
#include <sonus/sequence.hpp>
|
#include <sonus/sequence.hpp>
|
||||||
|
#include <sonus/transport.hpp>
|
||||||
|
|
||||||
namespace sonus {
|
namespace sonus {
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ public:
|
|||||||
auto play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
auto play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
||||||
auto play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
auto play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
||||||
auto stop_sequence() -> void;
|
auto stop_sequence() -> void;
|
||||||
|
auto play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error>;
|
||||||
auto is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error>;
|
auto is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error>;
|
||||||
auto collect_finished() -> std::uint64_t;
|
auto collect_finished() -> std::uint64_t;
|
||||||
auto active_sound_count() -> std::uint64_t;
|
auto active_sound_count() -> std::uint64_t;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
#include <sonus/sequence.hpp>
|
||||||
|
|
||||||
|
|
||||||
|
namespace sonus {
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
|
||||||
|
struct transport_state;
|
||||||
|
|
||||||
|
class transport_player;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct click_event {
|
||||||
|
std::uint64_t frame{};
|
||||||
|
bool downbeat{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct transport_config {
|
||||||
|
std::filesystem::path backing{};
|
||||||
|
std::filesystem::path beat1{};
|
||||||
|
std::filesystem::path beat2{};
|
||||||
|
std::vector<sonus::click_event> clicks{};
|
||||||
|
std::uint64_t duration_frames{};
|
||||||
|
std::float32_t click_volume{0.45F};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct transport_progress {
|
||||||
|
std::atomic<sonus::playback_status> status{sonus::playback_status::PLAYING};
|
||||||
|
std::atomic<std::uint64_t> frames{};
|
||||||
|
};
|
||||||
|
|
||||||
|
class transport_session {
|
||||||
|
public:
|
||||||
|
transport_session() = default;
|
||||||
|
explicit transport_session(std::shared_ptr<sonus::transport_progress> progress);
|
||||||
|
auto status() const -> sonus::playback_status;
|
||||||
|
auto frames() const -> std::uint64_t;
|
||||||
|
auto stop() const -> void;
|
||||||
|
private:
|
||||||
|
std::shared_ptr<sonus::transport_progress> progress_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
class prepared_transport {
|
||||||
|
public:
|
||||||
|
static auto create(sonus::transport_config config, std::uint32_t sample_rate, std::uint32_t channels) -> std::expected<sonus::prepared_transport, modulus::error>;
|
||||||
|
prepared_transport(prepared_transport const &) = delete;
|
||||||
|
auto operator=(prepared_transport const &) -> prepared_transport & = delete;
|
||||||
|
prepared_transport(prepared_transport &&) noexcept;
|
||||||
|
auto operator=(prepared_transport &&) noexcept -> prepared_transport &;
|
||||||
|
~prepared_transport();
|
||||||
|
private:
|
||||||
|
friend class sonus::detail::transport_player;
|
||||||
|
explicit prepared_transport(std::unique_ptr<sonus::detail::transport_state> state);
|
||||||
|
std::unique_ptr<sonus::detail::transport_state> state_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
+9
-4
@@ -9,7 +9,7 @@ import sys
|
|||||||
|
|
||||||
PACKAGE_REF = "modulus/latest"
|
PACKAGE_REF = "modulus/latest"
|
||||||
CONAN_VERSION = "2.26.2"
|
CONAN_VERSION = "2.26.2"
|
||||||
FETCH_IMAGE = "modulus-fetch:dev"
|
FETCH_IMAGE = "modulus_fetch:dev"
|
||||||
COMMANDS = (
|
COMMANDS = (
|
||||||
"configure",
|
"configure",
|
||||||
"build",
|
"build",
|
||||||
@@ -19,6 +19,8 @@ COMMANDS = (
|
|||||||
"clangd",
|
"clangd",
|
||||||
"format",
|
"format",
|
||||||
"run",
|
"run",
|
||||||
|
"python",
|
||||||
|
"publish",
|
||||||
)
|
)
|
||||||
COMMON_DIR = pathlib.Path("build") / "build_common"
|
COMMON_DIR = pathlib.Path("build") / "build_common"
|
||||||
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
||||||
@@ -26,7 +28,7 @@ RUNTIME_DIR = COMMON_DIR / "modulus"
|
|||||||
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
||||||
DEFAULT_REMOTE_NAME = "khodo"
|
DEFAULT_REMOTE_NAME = "khodo"
|
||||||
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image"
|
DEFAULT_DOCKER_MARKER = "/opt/modulus_build_image"
|
||||||
|
|
||||||
def repo_root():
|
def repo_root():
|
||||||
root = pathlib.Path(__file__).resolve().parent
|
root = pathlib.Path(__file__).resolve().parent
|
||||||
@@ -66,6 +68,9 @@ def runtime_ready(root):
|
|||||||
and (root / TOOLS_DIR / "format.py").is_file()
|
and (root / TOOLS_DIR / "format.py").is_file()
|
||||||
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
|
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
|
||||||
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
|
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "python.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "artifacts.py").is_file()
|
||||||
|
and (root / TOOLS_DIR / "python_runner.py").is_file()
|
||||||
)
|
)
|
||||||
|
|
||||||
def env_flag(name):
|
def env_flag(name):
|
||||||
@@ -114,9 +119,9 @@ RUN apt-get update \\
|
|||||||
python3-venv \\
|
python3-venv \\
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN python3 -m venv /opt/modulus-python
|
RUN python3 -m venv /opt/modulus_python
|
||||||
|
|
||||||
ENV PATH=/opt/modulus-python/bin:$PATH
|
ENV PATH=/opt/modulus_python/bin:$PATH
|
||||||
|
|
||||||
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <sonus/transport.hpp>
|
||||||
|
#include "render_snapshot.hpp"
|
||||||
|
#include <miniaudio.h>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
|
||||||
|
namespace sonus::detail {
|
||||||
|
|
||||||
|
struct transport_state {
|
||||||
|
ma_resource_manager resources{};
|
||||||
|
mutable ma_resource_manager_data_source backing{};
|
||||||
|
bool resources_ready{};
|
||||||
|
bool backing_ready{};
|
||||||
|
std::uint32_t sample_rate{};
|
||||||
|
std::uint32_t channels{};
|
||||||
|
std::uint64_t duration{};
|
||||||
|
mutable std::uint64_t cursor{};
|
||||||
|
mutable std::size_t first_live_click{};
|
||||||
|
sonus::transport_config config{};
|
||||||
|
std::array<std::vector<std::float32_t>, 2> samples{};
|
||||||
|
mutable std::vector<std::float32_t> scratch{};
|
||||||
|
std::shared_ptr<sonus::transport_progress> progress{std::make_shared<sonus::transport_progress>()};
|
||||||
|
~transport_state();
|
||||||
|
};
|
||||||
|
|
||||||
|
class transport_player {
|
||||||
|
public:
|
||||||
|
auto play(sonus::prepared_transport transport, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::transport_session, modulus::error>;
|
||||||
|
auto stop() -> void;
|
||||||
|
auto collect() -> void;
|
||||||
|
auto fail() -> void;
|
||||||
|
auto render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void;
|
||||||
|
private:
|
||||||
|
sonus::detail::render_snapshot<sonus::detail::transport_state> snapshots_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
#include "detail/metronome_player.hpp"
|
#include "detail/metronome_player.hpp"
|
||||||
#include "detail/playback_devices.hpp"
|
#include "detail/playback_devices.hpp"
|
||||||
#include "detail/sequence_player.hpp"
|
#include "detail/sequence_player.hpp"
|
||||||
|
#include "detail/transport_player.hpp"
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -137,6 +138,7 @@ struct sonus::engine::impl {
|
|||||||
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
|
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
|
||||||
std::uint64_t next_sound_handle{1};
|
std::uint64_t next_sound_handle{1};
|
||||||
sonus::detail::sequence_player sequences{};
|
sonus::detail::sequence_player sequences{};
|
||||||
|
sonus::detail::transport_player transport{};
|
||||||
std::atomic<std::float32_t> sequence_volume{0.70F};
|
std::atomic<std::float32_t> sequence_volume{0.70F};
|
||||||
sonus::detail::metronome_player metronome{};
|
sonus::detail::metronome_player metronome{};
|
||||||
impl() = default;
|
impl() = default;
|
||||||
@@ -232,6 +234,7 @@ struct sonus::engine::impl {
|
|||||||
}
|
}
|
||||||
sounds.clear();
|
sounds.clear();
|
||||||
sequences.stop();
|
sequences.stop();
|
||||||
|
transport.stop();
|
||||||
if (mixer_initialized) {
|
if (mixer_initialized) {
|
||||||
ma_engine_uninit(&mixer);
|
ma_engine_uninit(&mixer);
|
||||||
mixer_initialized = false;
|
mixer_initialized = false;
|
||||||
@@ -338,6 +341,7 @@ struct sonus::engine::impl {
|
|||||||
auto stop_all_sounds() -> void {
|
auto stop_all_sounds() -> void {
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
sequences.stop();
|
sequences.stop();
|
||||||
|
transport.stop();
|
||||||
for (auto &entry : sounds) {
|
for (auto &entry : sounds) {
|
||||||
static_cast<void>(ma_sound_stop(&entry.second->sound));
|
static_cast<void>(ma_sound_stop(&entry.second->sound));
|
||||||
}
|
}
|
||||||
@@ -361,6 +365,7 @@ struct sonus::engine::impl {
|
|||||||
auto collect_finished_sounds() -> std::uint64_t {
|
auto collect_finished_sounds() -> std::uint64_t {
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
sequences.collect();
|
sequences.collect();
|
||||||
|
transport.collect();
|
||||||
auto const collected = collect_finished_locked();
|
auto const collected = collect_finished_locked();
|
||||||
metronome.collect();
|
metronome.collect();
|
||||||
return collected;
|
return collected;
|
||||||
@@ -430,9 +435,11 @@ struct sonus::engine::impl {
|
|||||||
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
||||||
if (result != MA_SUCCESS) {
|
if (result != MA_SUCCESS) {
|
||||||
sequences.fail();
|
sequences.fail();
|
||||||
|
transport.fail();
|
||||||
std::fill(output, output + sample_count, 0.0F);
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
transport.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||||
metronome.render(output, frame_count, config.channels);
|
metronome.render(output, frame_count, config.channels);
|
||||||
sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||||
return MA_SUCCESS;
|
return MA_SUCCESS;
|
||||||
@@ -652,3 +659,11 @@ auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) ->
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail<sonus::transport_session>(unavailable("engine is not initialized"));
|
||||||
|
}
|
||||||
|
auto lock = std::scoped_lock{implementation_->mutex};
|
||||||
|
return implementation_->transport.play(std::move(transport), sample_rate(), channels());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#include <sonus/transport.hpp>
|
||||||
|
#include <sonus/detail/errors.hpp>
|
||||||
|
#include "detail/transport_player.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
auto failure(std::string text) -> std::unexpected<modulus::error> {
|
||||||
|
return std::unexpected(sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
sonus::detail::transport_state::~transport_state() {
|
||||||
|
if (backing_ready) {
|
||||||
|
ma_resource_manager_data_source_uninit(&backing);
|
||||||
|
}
|
||||||
|
if (resources_ready) {
|
||||||
|
ma_resource_manager_uninit(&resources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sonus::prepared_transport::prepared_transport(std::unique_ptr<sonus::detail::transport_state> state)
|
||||||
|
: state_(std::move(state)) {}
|
||||||
|
|
||||||
|
sonus::prepared_transport::prepared_transport(prepared_transport &&) noexcept = default;
|
||||||
|
|
||||||
|
auto sonus::prepared_transport::operator=(prepared_transport &&) noexcept -> prepared_transport & = default;
|
||||||
|
|
||||||
|
sonus::prepared_transport::~prepared_transport() = default;
|
||||||
|
|
||||||
|
auto sonus::prepared_transport::create(sonus::transport_config config, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::prepared_transport, modulus::error> {
|
||||||
|
if (rate < 8000 || rate > 192000 || channels < 1 || channels > 2 || config.duration_frames == 0 || !std::isfinite(config.click_volume) || config.click_volume < 0 || config.click_volume > 1 || config.clicks.size() > 100000) {
|
||||||
|
return failure("invalid recording transport configuration");
|
||||||
|
}
|
||||||
|
auto state = std::make_unique<sonus::detail::transport_state>();
|
||||||
|
state->sample_rate = rate;
|
||||||
|
state->channels = channels;
|
||||||
|
state->duration = config.duration_frames;
|
||||||
|
state->scratch.resize(1024 * channels);
|
||||||
|
if (!config.backing.empty()) {
|
||||||
|
auto options = ma_resource_manager_config_init();
|
||||||
|
options.decodedFormat = ma_format_f32;
|
||||||
|
options.decodedChannels = channels;
|
||||||
|
options.decodedSampleRate = rate;
|
||||||
|
if (ma_resource_manager_init(&options, &state->resources) != MA_SUCCESS) {
|
||||||
|
return failure("cannot initialize recording stream");
|
||||||
|
}
|
||||||
|
state->resources_ready = true;
|
||||||
|
if (ma_resource_manager_data_source_init(&state->resources, config.backing.string().c_str(), MA_RESOURCE_MANAGER_DATA_SOURCE_FLAG_STREAM, nullptr, &state->backing) != MA_SUCCESS) {
|
||||||
|
return failure("cannot open backing recording");
|
||||||
|
}
|
||||||
|
state->backing_ready = true;
|
||||||
|
ma_uint64 length{};
|
||||||
|
if (ma_resource_manager_data_source_get_length_in_pcm_frames(&state->backing, &length) != MA_SUCCESS || length < config.duration_frames) {
|
||||||
|
return failure("backing recording is shorter than the performance");
|
||||||
|
}
|
||||||
|
state->duration = length;
|
||||||
|
}
|
||||||
|
if (!config.clicks.empty()) {
|
||||||
|
for (std::size_t index = 0; index < 2; ++index) {
|
||||||
|
auto decoder = ma_decoder{};
|
||||||
|
auto options = ma_decoder_config_init(ma_format_f32, channels, rate);
|
||||||
|
auto path = index == 0 ? config.beat1 : config.beat2;
|
||||||
|
if (ma_decoder_init_file(path.string().c_str(), &options, &decoder) != MA_SUCCESS) {
|
||||||
|
return failure("cannot decode metronome sample");
|
||||||
|
}
|
||||||
|
auto guard = std::unique_ptr<ma_decoder, decltype(&ma_decoder_uninit)>{&decoder, &ma_decoder_uninit};
|
||||||
|
ma_uint64 length{}, read{};
|
||||||
|
if (ma_decoder_get_length_in_pcm_frames(&decoder, &length) != MA_SUCCESS || length == 0 || length > rate * 4U) {
|
||||||
|
return failure("invalid metronome sample length");
|
||||||
|
}
|
||||||
|
state->samples[index].resize(length * channels);
|
||||||
|
auto result = ma_decoder_read_pcm_frames(&decoder, state->samples[index].data(), length, &read);
|
||||||
|
if ((result != MA_SUCCESS && result != MA_AT_END) || read != length) {
|
||||||
|
return failure("truncated metronome sample");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!std::is_sorted(config.clicks.begin(), config.clicks.end(), [](auto const &a, auto const &b) { return a.frame < b.frame; }) || config.clicks.back().frame >= config.duration_frames) {
|
||||||
|
return failure("invalid click schedule");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state->config = std::move(config);
|
||||||
|
return sonus::prepared_transport{std::move(state)};
|
||||||
|
}
|
||||||
|
|
||||||
|
sonus::transport_session::transport_session(std::shared_ptr<sonus::transport_progress> progress)
|
||||||
|
: progress_(std::move(progress)) {}
|
||||||
|
|
||||||
|
auto sonus::transport_session::frames() const -> std::uint64_t {
|
||||||
|
return progress_ ? progress_->frames.load(std::memory_order_acquire) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::transport_session::status() const -> sonus::playback_status {
|
||||||
|
return progress_ ? progress_->status.load(std::memory_order_acquire) : sonus::playback_status::STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::transport_session::stop() const -> void {
|
||||||
|
if (progress_) {
|
||||||
|
auto expected = sonus::playback_status::PLAYING;
|
||||||
|
progress_->status.compare_exchange_strong(expected, sonus::playback_status::STOPPED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::transport_player::play(sonus::prepared_transport transport, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::transport_session, modulus::error> {
|
||||||
|
if (!transport.state_ || transport.state_->sample_rate != rate || transport.state_->channels != channels) {
|
||||||
|
return failure("transport format does not match engine");
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
auto session = sonus::transport_session{transport.state_->progress};
|
||||||
|
snapshots_.publish(std::move(transport.state_));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::transport_player::stop() -> void {
|
||||||
|
if (auto state = snapshots_.current()) {
|
||||||
|
sonus::transport_session{state->progress}.stop();
|
||||||
|
}
|
||||||
|
snapshots_.publish(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::transport_player::collect() -> void {
|
||||||
|
if (auto state = snapshots_.current(); state && state->progress->status.load() != sonus::playback_status::PLAYING) {
|
||||||
|
snapshots_.publish(nullptr);
|
||||||
|
}
|
||||||
|
snapshots_.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::transport_player::fail() -> void {
|
||||||
|
auto guard = snapshots_.read();
|
||||||
|
if (auto state = guard.get()) {
|
||||||
|
state->progress->status.store(sonus::playback_status::FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::transport_player::render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void {
|
||||||
|
auto guard = snapshots_.read();
|
||||||
|
auto state = guard.get();
|
||||||
|
if (!state || channels != state->channels) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (std::uint64_t offset = 0; offset < frames && state->cursor < state->duration;) {
|
||||||
|
if (state->progress->status.load(std::memory_order_acquire) != sonus::playback_status::PLAYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto count = std::min({frames - offset, state->duration - state->cursor, std::uint64_t{1024}});
|
||||||
|
std::fill(state->scratch.begin(), state->scratch.end(), 0.F);
|
||||||
|
if (state->backing_ready) {
|
||||||
|
ma_uint64 read{};
|
||||||
|
auto result = ma_resource_manager_data_source_read_pcm_frames(&state->backing, state->scratch.data(), count, &read);
|
||||||
|
if ((result != MA_SUCCESS && result != MA_AT_END) || read != count) {
|
||||||
|
state->progress->status.store(sonus::playback_status::FAILED, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retain overlapping tails, but never revisit an expired schedule prefix.
|
||||||
|
// The cursor is owned by the render thread and needs no allocation or lock.
|
||||||
|
while (state->first_live_click < state->config.clicks.size()) {
|
||||||
|
auto const &click = state->config.clicks[state->first_live_click];
|
||||||
|
auto length = state->samples[click.downbeat ? 0 : 1].size() / channels;
|
||||||
|
if (click.frame > state->cursor || length > state->cursor - click.frame) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++state->first_live_click;
|
||||||
|
}
|
||||||
|
for (auto index = state->first_live_click; index < state->config.clicks.size(); ++index) {
|
||||||
|
auto const &click = state->config.clicks[index];
|
||||||
|
if (click.frame >= state->cursor + count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
auto const &sample = state->samples[click.downbeat ? 0 : 1];
|
||||||
|
auto start = std::max(click.frame, state->cursor);
|
||||||
|
auto end = std::min({click.frame + sample.size() / channels, state->cursor + count, state->config.duration_frames});
|
||||||
|
for (auto frame = start; frame < end; ++frame) {
|
||||||
|
for (std::uint32_t channel = 0; channel < channels; ++channel) {
|
||||||
|
state->scratch[(frame - state->cursor) * channels + channel] += sample[(frame - click.frame) * channels + channel] * state->config.click_volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (std::uint64_t index = 0; index < count * channels; ++index) {
|
||||||
|
output[offset * channels + index] += state->scratch[index] * gain;
|
||||||
|
}
|
||||||
|
state->cursor += count;
|
||||||
|
offset += count;
|
||||||
|
state->progress->frames.store(state->cursor, std::memory_order_release);
|
||||||
|
}
|
||||||
|
if (state->cursor == state->duration) {
|
||||||
|
auto expected = sonus::playback_status::PLAYING;
|
||||||
|
state->progress->status.compare_exchange_strong(expected, sonus::playback_status::COMPLETED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,23 +3,24 @@ if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|Op
|
|||||||
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
|
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_executable(sonus-test
|
add_executable(sonus_test
|
||||||
test.cpp
|
test.cpp
|
||||||
render_snapshot.cpp
|
render_snapshot.cpp
|
||||||
control.cpp
|
control.cpp
|
||||||
|
transport.cpp
|
||||||
sequence.cpp
|
sequence.cpp
|
||||||
)
|
)
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
target_include_directories(sonus-test PRIVATE
|
target_include_directories(sonus_test PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
${PROJECT_SOURCE_DIR}/src
|
${PROJECT_SOURCE_DIR}/src
|
||||||
)
|
)
|
||||||
target_link_libraries(sonus-test PRIVATE
|
target_link_libraries(sonus_test PRIVATE
|
||||||
sonus::sonus
|
sonus::sonus
|
||||||
Threads::Threads
|
Threads::Threads
|
||||||
)
|
)
|
||||||
target_compile_features(sonus-test PRIVATE cxx_std_23)
|
target_compile_features(sonus_test PRIVATE cxx_std_23)
|
||||||
add_test(NAME sonus COMMAND sonus-test)
|
add_test(NAME sonus COMMAND sonus_test)
|
||||||
set_tests_properties(sonus PROPERTIES TIMEOUT 20)
|
set_tests_properties(sonus PROPERTIES TIMEOUT 20)
|
||||||
add_custom_target(check
|
add_custom_target(check
|
||||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#include <boost/test/unit_test.hpp>
|
||||||
|
#include <sonus.hpp>
|
||||||
|
#include <array>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct recordings {
|
||||||
|
std::filesystem::path root;
|
||||||
|
recordings() {
|
||||||
|
auto path = (std::filesystem::temp_directory_path() / "sonus-recordings-XXXXXX").string();
|
||||||
|
BOOST_REQUIRE(::mkdtemp(path.data()));
|
||||||
|
root = path;
|
||||||
|
write("backing.wav", 16, 1024, -1024);
|
||||||
|
write("beat1.wav", 2, 8192, -8192);
|
||||||
|
write("beat2.wav", 2, 4096, -4096);
|
||||||
|
}
|
||||||
|
~recordings() {
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::remove_all(root, error);
|
||||||
|
}
|
||||||
|
auto write(char const *name, unsigned frames, int left, int right) -> void {
|
||||||
|
auto output = std::ofstream{root / name, std::ios::binary};
|
||||||
|
auto word = [&](unsigned value, unsigned bytes) { for (unsigned i = 0; i < bytes; ++i){ output.put(static_cast<char>(value >> (8 * i)));
|
||||||
|
} };
|
||||||
|
output.write("RIFF", 4);
|
||||||
|
word(36 + frames * 4, 4);
|
||||||
|
output.write("WAVEfmt ", 8);
|
||||||
|
word(16, 4);
|
||||||
|
word(1, 2);
|
||||||
|
word(2, 2);
|
||||||
|
word(48000, 4);
|
||||||
|
word(192000, 4);
|
||||||
|
word(4, 2);
|
||||||
|
word(16, 2);
|
||||||
|
output.write("data", 4);
|
||||||
|
word(frames * 4, 4);
|
||||||
|
for (unsigned i = 0; i < frames; ++i) {
|
||||||
|
word(static_cast<unsigned>(left), 2);
|
||||||
|
word(static_cast<unsigned>(right), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto config() const -> sonus::transport_config {
|
||||||
|
return {.backing = root / "backing.wav", .beat1 = root / "beat1.wav", .beat2 = root / "beat2.wav", .clicks = {{4, true}, {8, false}}, .duration_frames = 10, .click_volume = 1.F};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(recording_transport_preserves_stereo_and_has_one_sample_clock) {
|
||||||
|
auto files = recordings{};
|
||||||
|
auto prepared = sonus::prepared_transport::create(files.config(), 48000, 2);
|
||||||
|
BOOST_REQUIRE(prepared);
|
||||||
|
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
|
||||||
|
BOOST_REQUIRE(engine);
|
||||||
|
auto session = engine->play_transport(std::move(*prepared));
|
||||||
|
BOOST_REQUIRE(session);
|
||||||
|
std::array<float, 40> output{};
|
||||||
|
BOOST_REQUIRE(engine->render(output.data(), 20));
|
||||||
|
BOOST_TEST(session->frames() == 16U);
|
||||||
|
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||||
|
for (unsigned frame = 0; frame < 20; ++frame) {
|
||||||
|
auto value = frame < 16 ? .03125F : 0.F;
|
||||||
|
if (frame == 4 || frame == 5) {
|
||||||
|
value += .25F;
|
||||||
|
}
|
||||||
|
if (frame == 8 || frame == 9) {
|
||||||
|
value += .125F;
|
||||||
|
}
|
||||||
|
BOOST_TEST(output[frame * 2] == value);
|
||||||
|
BOOST_TEST(output[frame * 2 + 1] == -value);
|
||||||
|
}
|
||||||
|
engine->collect_finished();
|
||||||
|
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(recording_transport_replacement_cancels_old_session_and_validates_inputs) {
|
||||||
|
auto files = recordings{};
|
||||||
|
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
|
||||||
|
BOOST_REQUIRE(engine);
|
||||||
|
auto config = files.config();
|
||||||
|
auto first = sonus::prepared_transport::create(config, 48000, 2);
|
||||||
|
BOOST_REQUIRE(first);
|
||||||
|
auto session = engine->play_transport(std::move(*first));
|
||||||
|
BOOST_REQUIRE(session);
|
||||||
|
auto second = sonus::prepared_transport::create(config, 48000, 2);
|
||||||
|
BOOST_REQUIRE(second);
|
||||||
|
auto replacement = engine->play_transport(std::move(*second));
|
||||||
|
BOOST_REQUIRE(replacement);
|
||||||
|
BOOST_CHECK(session->status() == sonus::playback_status::STOPPED);
|
||||||
|
replacement->stop();
|
||||||
|
std::array<float, 16> output{};
|
||||||
|
BOOST_REQUIRE(engine->render(output.data(), 8));
|
||||||
|
BOOST_TEST(replacement->frames() == 0U);
|
||||||
|
BOOST_TEST(output.front() == 0.F);
|
||||||
|
config.duration_frames = 17;
|
||||||
|
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
|
||||||
|
config = files.config();
|
||||||
|
config.beat1 = files.root / "missing.wav";
|
||||||
|
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
|
||||||
|
config = files.config();
|
||||||
|
config.clicks = {{9, true}, {4, false}};
|
||||||
|
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
|
||||||
|
config = files.config();
|
||||||
|
config.backing.clear();
|
||||||
|
config.clicks.push_back({10, true});
|
||||||
|
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(recording_transport_long_schedule_preserves_overlaps_across_buffer_sizes) {
|
||||||
|
auto files = recordings{};
|
||||||
|
files.write("beat1.wav", 1537, 1, -1);
|
||||||
|
files.write("beat2.wav", 17, 2, -2);
|
||||||
|
auto config = files.config();
|
||||||
|
config.backing.clear();
|
||||||
|
config.duration_frames = 40000;
|
||||||
|
config.clicks.clear();
|
||||||
|
for (std::uint64_t i = 0; i < 2000; ++i) {
|
||||||
|
config.clicks.push_back({i * 20, i % 7 == 0});
|
||||||
|
}
|
||||||
|
auto expected = std::vector<float>(config.duration_frames * 2);
|
||||||
|
for (auto const &click : config.clicks) {
|
||||||
|
auto length = click.downbeat ? 1537U : 17U;
|
||||||
|
auto value = click.downbeat ? 1.F / 32768 : 2.F / 32768;
|
||||||
|
for (auto frame = click.frame; frame < std::min(click.frame + length, config.duration_frames); ++frame) {
|
||||||
|
expected[frame * 2] += value;
|
||||||
|
expected[frame * 2 + 1] -= value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto chunk : {317U, 1024U, 1701U}) {
|
||||||
|
auto prepared = sonus::prepared_transport::create(config, 48000, 2);
|
||||||
|
BOOST_REQUIRE(prepared);
|
||||||
|
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
|
||||||
|
BOOST_REQUIRE(engine);
|
||||||
|
auto session = engine->play_transport(std::move(*prepared));
|
||||||
|
BOOST_REQUIRE(session);
|
||||||
|
auto actual = std::vector<float>(expected.size());
|
||||||
|
for (std::uint64_t frame = 0; frame < config.duration_frames; frame += chunk) {
|
||||||
|
BOOST_REQUIRE(engine->render(actual.data() + frame * 2, std::min<std::uint64_t>(chunk, config.duration_frames - frame)));
|
||||||
|
}
|
||||||
|
BOOST_CHECK_EQUAL_COLLECTIONS(actual.begin(), actual.end(), expected.begin(), expected.end());
|
||||||
|
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||||
|
BOOST_TEST(session->frames() == config.duration_frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user