Compare commits
2 Commits
d3749f894c
...
b58664d5f4
| Author | SHA1 | Date | |
|---|---|---|---|
| b58664d5f4 | |||
| 1241f3eb71 |
@@ -0,0 +1,105 @@
|
|||||||
|
name: publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CONAN_HOME: build/build_common/conan_cache
|
||||||
|
MODULUS_DIRECT: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
fi
|
||||||
|
if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then
|
||||||
|
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||||
|
sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||||
|
printf '%s\n' \
|
||||||
|
'Types: deb' \
|
||||||
|
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
|
||||||
|
"Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \
|
||||||
|
'Components: main universe restricted multiverse' \
|
||||||
|
'Architectures: arm64' \
|
||||||
|
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
|
||||||
|
'' \
|
||||||
|
'Types: deb' \
|
||||||
|
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
|
||||||
|
"Suites: ${VERSION_CODENAME}-security" \
|
||||||
|
'Components: main universe restricted multiverse' \
|
||||||
|
'Architectures: arm64' \
|
||||||
|
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu-arm64.sources
|
||||||
|
elif [ -f /etc/apt/sources.list ]; then
|
||||||
|
sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list
|
||||||
|
sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list
|
||||||
|
printf '%s\n' \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu-arm64.list
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
dpkg --add-architecture arm64
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
cmake \
|
||||||
|
g++-12 \
|
||||||
|
g++-aarch64-linux-gnu \
|
||||||
|
gcc-12 \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
git \
|
||||||
|
libdrm-dev \
|
||||||
|
libdrm-dev:arm64 \
|
||||||
|
libegl-dev \
|
||||||
|
libegl-dev:arm64 \
|
||||||
|
libgbm-dev \
|
||||||
|
libgbm-dev:arm64 \
|
||||||
|
libgles-dev \
|
||||||
|
libgles-dev:arm64 \
|
||||||
|
libglvnd-dev \
|
||||||
|
libglvnd-dev:arm64 \
|
||||||
|
ninja-build \
|
||||||
|
pkg-config \
|
||||||
|
python3 \
|
||||||
|
python3-venv
|
||||||
|
python3 -m venv build/build_common/ci_python
|
||||||
|
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2
|
||||||
|
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Restore Conan cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: build/build_common/conan_cache
|
||||||
|
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }}
|
||||||
|
restore-keys: conan-${{ runner.os }}-
|
||||||
|
- name: Configure native release
|
||||||
|
run: python3 modulus.py configure --target x86_64_gcc12_release
|
||||||
|
- name: Build native release
|
||||||
|
run: python3 modulus.py build --target x86_64_gcc12_release
|
||||||
|
- name: Test native release
|
||||||
|
run: python3 modulus.py test --target x86_64_gcc12_release
|
||||||
|
- name: Create and upload packages
|
||||||
|
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||||
|
env:
|
||||||
|
CONAN_USER: ${{ secrets.CONAN_USER }}
|
||||||
|
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
|
||||||
|
SONUS_CONAN_UPLOAD: "1"
|
||||||
|
MODULUS_ALLOW_PACKAGE_REPLACE: "1"
|
||||||
|
run: python3 modulus.py package --all --replace
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
build/
|
build/
|
||||||
|
test_package/CMakeUserPresets.json
|
||||||
.clangd
|
.clangd
|
||||||
.clang-format
|
.clang-format
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|||||||
+22
-1
@@ -11,6 +11,18 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
find_package(modulus CONFIG REQUIRED)
|
find_package(modulus CONFIG REQUIRED)
|
||||||
|
find_package(miniaudio CONFIG REQUIRED)
|
||||||
|
|
||||||
|
set(SONUS_MINIAUDIO_TARGET "")
|
||||||
|
if(TARGET miniaudio::miniaudio)
|
||||||
|
set(SONUS_MINIAUDIO_TARGET miniaudio::miniaudio)
|
||||||
|
elseif(TARGET miniaudio)
|
||||||
|
set(SONUS_MINIAUDIO_TARGET miniaudio)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT SONUS_MINIAUDIO_TARGET)
|
||||||
|
message(FATAL_ERROR "miniaudio package did not define a supported CMake target")
|
||||||
|
endif()
|
||||||
|
|
||||||
add_library(sonus
|
add_library(sonus
|
||||||
src/detail/errors.cpp
|
src/detail/errors.cpp
|
||||||
@@ -21,10 +33,19 @@ target_include_directories(sonus PUBLIC
|
|||||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||||
)
|
)
|
||||||
target_compile_features(sonus PUBLIC cxx_std_23)
|
target_compile_features(sonus PUBLIC cxx_std_23)
|
||||||
|
target_include_directories(sonus PRIVATE
|
||||||
|
$<BUILD_INTERFACE:$<TARGET_PROPERTY:${SONUS_MINIAUDIO_TARGET},INTERFACE_INCLUDE_DIRECTORIES>>
|
||||||
|
)
|
||||||
target_link_libraries(sonus PUBLIC
|
target_link_libraries(sonus PUBLIC
|
||||||
modulus::modulus
|
modulus::modulus
|
||||||
modulus::miniaudio
|
|
||||||
)
|
)
|
||||||
|
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
|
target_link_libraries(sonus PUBLIC
|
||||||
|
dl
|
||||||
|
m
|
||||||
|
pthread
|
||||||
|
)
|
||||||
|
endif()
|
||||||
add_library(sonus::sonus ALIAS sonus)
|
add_library(sonus::sonus ALIAS sonus)
|
||||||
|
|
||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ Sonus is a C++23 audio playback library built on miniaudio. It provides a small
|
|||||||
|
|
||||||
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
|
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
|
||||||
|
|
||||||
|
## Devices And Threading
|
||||||
|
|
||||||
|
`enumerate_playback_devices()` returns device descriptions or a Modulus error if the audio backend cannot initialize or enumerate. A successful empty list means no playback devices were reported. Copy a description's `id` to `audio_config::playback_device_id` before creating an engine. IDs are opaque tokens derived from enumeration order and metadata, intended for immediate reuse; re-enumerate after hardware or backend changes. They are not permanent hardware identifiers.
|
||||||
|
|
||||||
|
Selection tries the ID first, then an exact `playback_device_name` fallback. Duplicate names select the first match, so use the ID to distinguish them. Empty selectors preserve the backend's default device. An unresolved explicit selector returns an error. `no_device` rejects device selectors and enables manual rendering instead.
|
||||||
|
|
||||||
|
Period size may be requested in frames or milliseconds (not both), with an optional period count and performance profile. The backend may adjust these requests to hardware capabilities.
|
||||||
|
|
||||||
|
Control calls on one engine are serialized and may run concurrently with playback. `update_metronome()` preserves the running/stopped state at publication. Render callbacks use immutable click buffers and lock-free snapshot reads; reclamation runs on control threads. Idle publications reclaim previous snapshots immediately. Snapshots retired during rendering are collected by subsequent metronome controls, `collect_finished()`, `active_sound_count()`, playback, or device stop. Manual renders are serialized. Do not move or destroy an engine while another thread is calling it.
|
||||||
|
|
||||||
|
Call `collect_finished()` periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link `sonus::sonus` and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Configure, build, and test the default target:
|
Configure, build, and test the default target:
|
||||||
|
|||||||
+13
-2
@@ -37,6 +37,7 @@ class sonus_conan(conan.ConanFile):
|
|||||||
"include/sonus/detail/*.hpp",
|
"include/sonus/detail/*.hpp",
|
||||||
"src/*.cpp",
|
"src/*.cpp",
|
||||||
"src/detail/*.cpp",
|
"src/detail/*.cpp",
|
||||||
|
"src/detail/*.hpp",
|
||||||
"tests/CMakeLists.txt",
|
"tests/CMakeLists.txt",
|
||||||
"tests/*.cpp",
|
"tests/*.cpp",
|
||||||
"tests/*.hpp",
|
"tests/*.hpp",
|
||||||
@@ -56,6 +57,12 @@ class sonus_conan(conan.ConanFile):
|
|||||||
transitive_headers=True,
|
transitive_headers=True,
|
||||||
transitive_libs=True,
|
transitive_libs=True,
|
||||||
)
|
)
|
||||||
|
self.requires(
|
||||||
|
"miniaudio/0.11.22",
|
||||||
|
visible=False,
|
||||||
|
transitive_headers=False,
|
||||||
|
transitive_libs=False,
|
||||||
|
)
|
||||||
|
|
||||||
def generate(self):
|
def generate(self):
|
||||||
dependencies = conan.tools.cmake.CMakeDeps(self)
|
dependencies = conan.tools.cmake.CMakeDeps(self)
|
||||||
@@ -80,7 +87,6 @@ class sonus_conan(conan.ConanFile):
|
|||||||
"openssl",
|
"openssl",
|
||||||
"sqlite3",
|
"sqlite3",
|
||||||
"raylib",
|
"raylib",
|
||||||
"miniaudio",
|
|
||||||
):
|
):
|
||||||
if requirement in self.info.requires.pkg_names:
|
if requirement in self.info.requires.pkg_names:
|
||||||
self.info.requires.remove(requirement)
|
self.info.requires.remove(requirement)
|
||||||
@@ -101,5 +107,10 @@ class sonus_conan(conan.ConanFile):
|
|||||||
self.cpp_info.libs = ["sonus"]
|
self.cpp_info.libs = ["sonus"]
|
||||||
self.cpp_info.requires = [
|
self.cpp_info.requires = [
|
||||||
"modulus::modulus",
|
"modulus::modulus",
|
||||||
"modulus::miniaudio",
|
|
||||||
]
|
]
|
||||||
|
if self.settings.os == "Linux":
|
||||||
|
self.cpp_info.system_libs = [
|
||||||
|
"dl",
|
||||||
|
"m",
|
||||||
|
"pthread",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <sonus/config.hpp>
|
||||||
#include <sonus/detail/errors.hpp>
|
#include <sonus/detail/errors.hpp>
|
||||||
#include <sonus/engine.hpp>
|
#include <sonus/engine.hpp>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <modulus/types.hpp>
|
||||||
|
|
||||||
|
namespace sonus {
|
||||||
|
|
||||||
|
enum class performance_profile : std::uint8_t {
|
||||||
|
LOW_LATENCY,
|
||||||
|
CONSERVATIVE,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct audio_config {
|
||||||
|
std::uint32_t sample_rate{48000};
|
||||||
|
std::uint32_t channels{2};
|
||||||
|
std::float32_t master_volume{0.70F};
|
||||||
|
std::bool_t no_device{false};
|
||||||
|
std::string playback_device_id{};
|
||||||
|
std::string playback_device_name{};
|
||||||
|
std::uint32_t period_size_frames{0};
|
||||||
|
std::uint32_t period_size_milliseconds{0};
|
||||||
|
std::uint32_t period_count{0};
|
||||||
|
sonus::performance_profile performance{sonus::performance_profile::LOW_LATENCY};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct device_info {
|
||||||
|
std::string id{};
|
||||||
|
std::string name{};
|
||||||
|
std::bool_t is_default{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct metronome_config {
|
||||||
|
std::uint32_t bpm{120};
|
||||||
|
std::uint32_t beats_per_bar{4};
|
||||||
|
std::float32_t volume{0.45F};
|
||||||
|
std::float32_t beat_frequency_hz{1320.0F};
|
||||||
|
std::float32_t downbeat_frequency_hz{1760.0F};
|
||||||
|
std::chrono::milliseconds click_duration{35};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct playback_options {
|
||||||
|
std::bool_t loop{false};
|
||||||
|
std::float32_t volume{1.0F};
|
||||||
|
std::bool_t stream{true};
|
||||||
|
};
|
||||||
|
|
||||||
|
using sound_handle = std::uint64_t;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,43 +1,20 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <modulus.hpp>
|
#include <modulus.hpp>
|
||||||
#include <modulus/types.hpp>
|
#include <sonus/config.hpp>
|
||||||
|
|
||||||
namespace sonus {
|
namespace sonus {
|
||||||
|
|
||||||
struct audio_config {
|
|
||||||
std::uint32_t sample_rate{48000};
|
|
||||||
std::uint32_t channels{2};
|
|
||||||
std::float32_t master_volume{0.70F};
|
|
||||||
std::bool_t no_device{false};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct metronome_config {
|
|
||||||
std::uint32_t bpm{120};
|
|
||||||
std::uint32_t beats_per_bar{4};
|
|
||||||
std::float32_t volume{0.45F};
|
|
||||||
std::float32_t beat_frequency_hz{1320.0F};
|
|
||||||
std::float32_t downbeat_frequency_hz{1760.0F};
|
|
||||||
std::chrono::milliseconds click_duration{35};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct playback_options {
|
|
||||||
std::bool_t loop{false};
|
|
||||||
std::float32_t volume{1.0F};
|
|
||||||
std::bool_t stream{true};
|
|
||||||
};
|
|
||||||
|
|
||||||
using sound_handle = std::uint64_t;
|
|
||||||
|
|
||||||
auto validate(sonus::audio_config const& config) -> std::expected<void, modulus::error>;
|
auto validate(sonus::audio_config const& config) -> std::expected<void, modulus::error>;
|
||||||
auto validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error>;
|
auto validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error>;
|
||||||
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
|
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
|
||||||
|
auto enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error>;
|
||||||
|
|
||||||
class engine {
|
class engine {
|
||||||
public:
|
public:
|
||||||
@@ -59,6 +36,9 @@ public:
|
|||||||
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>;
|
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>;
|
||||||
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
|
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
|
||||||
auto stop_all() -> void;
|
auto stop_all() -> void;
|
||||||
|
auto is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error>;
|
||||||
|
auto collect_finished() -> std::uint64_t;
|
||||||
|
auto active_sound_count() -> std::uint64_t;
|
||||||
|
|
||||||
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>;
|
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>;
|
||||||
auto update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
|
auto update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <sonus/config.hpp>
|
||||||
|
|
||||||
|
namespace sonus::detail {
|
||||||
|
|
||||||
|
inline auto find_playback_device(std::span<device_info const> devices, std::string_view id, std::string_view name) -> std::optional<std::size_t> {
|
||||||
|
if (!id.empty()) {
|
||||||
|
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||||
|
if (devices[index].id == id) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!name.empty()) {
|
||||||
|
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||||
|
if (devices[index].name == name) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace sonus::detail {
|
||||||
|
|
||||||
|
// Publishers/collectors must be serialized; readers never lock or destroy snapshots.
|
||||||
|
template<typename T>
|
||||||
|
class render_snapshot {
|
||||||
|
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
|
||||||
|
static_assert(std::atomic<T const*>::is_always_lock_free);
|
||||||
|
|
||||||
|
public:
|
||||||
|
class read_guard {
|
||||||
|
public:
|
||||||
|
explicit read_guard(render_snapshot& owner) : owner_(owner) {
|
||||||
|
owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst);
|
||||||
|
value_ = owner_.active_.load(std::memory_order_seq_cst);
|
||||||
|
}
|
||||||
|
read_guard(read_guard const&) = delete;
|
||||||
|
auto operator=(read_guard const&) -> read_guard& = delete;
|
||||||
|
~read_guard() {
|
||||||
|
owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst);
|
||||||
|
}
|
||||||
|
auto get() const -> T const* { return value_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
render_snapshot& owner_;
|
||||||
|
T const* value_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto read() -> read_guard { return read_guard{*this}; }
|
||||||
|
auto current() const -> T const* { return current_.get(); }
|
||||||
|
|
||||||
|
auto publish(std::unique_ptr<T> next) -> void {
|
||||||
|
if (current_) {
|
||||||
|
retired_.push_back(std::move(current_));
|
||||||
|
}
|
||||||
|
current_ = std::move(next);
|
||||||
|
active_.store(current_.get(), std::memory_order_seq_cst);
|
||||||
|
collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto collect() -> void {
|
||||||
|
// SC orders publication/count observation against reader entry/pointer load:
|
||||||
|
// a reader either contributes to the count or sees the new snapshot.
|
||||||
|
if (active_render_count_.load(std::memory_order_seq_cst) == 0U) {
|
||||||
|
retired_.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unique_ptr<T> current_{};
|
||||||
|
std::vector<std::unique_ptr<T>> retired_{};
|
||||||
|
std::atomic<T const*> active_{nullptr};
|
||||||
|
std::atomic<std::uint32_t> active_render_count_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
+404
-93
@@ -6,6 +6,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
@@ -17,10 +18,13 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <modulus.hpp>
|
#include <modulus.hpp>
|
||||||
|
|
||||||
#include <sonus/detail/errors.hpp>
|
#include <sonus/detail/errors.hpp>
|
||||||
|
#include "detail/render_snapshot.hpp"
|
||||||
|
#include "detail/playback_devices.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -52,9 +56,60 @@ auto valid_frequency(std::float32_t value) -> std::bool_t {
|
|||||||
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
|
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto contains_null(std::string_view value) -> std::bool_t {
|
||||||
|
return value.find('\0') != std::string_view::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto make_playback_device_id(ma_uint32 index, ma_device_info const& info) -> std::string {
|
||||||
|
auto result = std::to_string(index);
|
||||||
|
result += ':';
|
||||||
|
result += (info.isDefault == MA_TRUE ? '1' : '0');
|
||||||
|
result += ':';
|
||||||
|
result += info.name;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto describe_playback_devices(ma_device_info const* devices, ma_uint32 count) -> std::vector<sonus::device_info> {
|
||||||
|
auto result = std::vector<sonus::device_info>{};
|
||||||
|
result.reserve(count);
|
||||||
|
for (auto index = ma_uint32{}; index < count; ++index) {
|
||||||
|
result.push_back(sonus::device_info{
|
||||||
|
.id = make_playback_device_id(index, devices[index]),
|
||||||
|
.name = devices[index].name,
|
||||||
|
.is_default = devices[index].isDefault == MA_TRUE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_profile {
|
||||||
|
switch (profile) {
|
||||||
|
case sonus::performance_profile::LOW_LATENCY:
|
||||||
|
return ma_performance_profile_low_latency;
|
||||||
|
case sonus::performance_profile::CONSERVATIVE:
|
||||||
|
return ma_performance_profile_conservative;
|
||||||
|
}
|
||||||
|
return ma_performance_profile_low_latency;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto make_click_samples(std::uint32_t sample_rate, std::uint32_t frame_count, std::float32_t frequency, std::float32_t gain) -> std::vector<std::float32_t> {
|
||||||
|
auto result = std::vector<std::float32_t>(frame_count);
|
||||||
|
auto const phase_step = (2.0 * pi * static_cast<double>(frequency)) / static_cast<double>(sample_rate);
|
||||||
|
auto phase = 0.0;
|
||||||
|
for (auto index = std::uint32_t{}; index < frame_count; ++index) {
|
||||||
|
auto const progress = static_cast<double>(index) / static_cast<double>(frame_count);
|
||||||
|
auto const envelope = (1.0 - progress) * (1.0 - progress);
|
||||||
|
result[index] = static_cast<std::float32_t>(std::sin(phase) * envelope * static_cast<double>(gain));
|
||||||
|
phase += phase_step;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
struct active_sound {
|
struct active_sound {
|
||||||
ma_sound sound{};
|
ma_sound sound{};
|
||||||
std::bool_t initialized{false};
|
std::bool_t initialized{false};
|
||||||
|
std::bool_t loop{false};
|
||||||
|
std::atomic_bool finished{false};
|
||||||
|
|
||||||
active_sound() = default;
|
active_sound() = default;
|
||||||
active_sound(active_sound const&) = delete;
|
active_sound(active_sound const&) = delete;
|
||||||
@@ -67,6 +122,37 @@ struct active_sound {
|
|||||||
ma_sound_uninit(&sound);
|
ma_sound_uninit(&sound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static auto end_callback(void* user_data, ma_sound*) -> void {
|
||||||
|
auto* self = static_cast<active_sound*>(user_data);
|
||||||
|
if (self != nullptr) {
|
||||||
|
self->finished.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct temporary_context {
|
||||||
|
ma_context context{};
|
||||||
|
std::bool_t initialized{false};
|
||||||
|
|
||||||
|
temporary_context() = default;
|
||||||
|
temporary_context(temporary_context const&) = delete;
|
||||||
|
auto operator=(temporary_context const&) -> temporary_context& = delete;
|
||||||
|
|
||||||
|
~temporary_context() {
|
||||||
|
if (initialized) {
|
||||||
|
ma_context_uninit(&context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct metronome_render_config {
|
||||||
|
std::bool_t enabled{false};
|
||||||
|
std::uint32_t generation{0};
|
||||||
|
std::uint32_t beats_per_bar{4};
|
||||||
|
std::uint64_t frames_per_beat{24000};
|
||||||
|
std::vector<std::float32_t> beat_click{};
|
||||||
|
std::vector<std::float32_t> downbeat_click{};
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -74,8 +160,11 @@ struct active_sound {
|
|||||||
struct sonus::engine::impl {
|
struct sonus::engine::impl {
|
||||||
sonus::audio_config config{};
|
sonus::audio_config config{};
|
||||||
ma_engine mixer{};
|
ma_engine mixer{};
|
||||||
|
ma_context context{};
|
||||||
ma_device device{};
|
ma_device device{};
|
||||||
|
ma_device_id playback_device_id{};
|
||||||
std::bool_t mixer_initialized{false};
|
std::bool_t mixer_initialized{false};
|
||||||
|
std::bool_t context_initialized{false};
|
||||||
std::bool_t device_initialized{false};
|
std::bool_t device_initialized{false};
|
||||||
std::bool_t started{false};
|
std::bool_t started{false};
|
||||||
mutable std::mutex mutex{};
|
mutable std::mutex mutex{};
|
||||||
@@ -83,22 +172,16 @@ struct sonus::engine::impl {
|
|||||||
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
|
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
|
||||||
sonus::sound_handle next_sound_handle{1};
|
sonus::sound_handle next_sound_handle{1};
|
||||||
|
|
||||||
|
sonus::detail::render_snapshot<metronome_render_config> metronome_configs{};
|
||||||
std::atomic_bool metronome_enabled{false};
|
std::atomic_bool metronome_enabled{false};
|
||||||
std::atomic<std::uint32_t> metronome_generation{0};
|
std::uint32_t next_metronome_generation{1};
|
||||||
std::atomic<std::uint32_t> metronome_bpm{120};
|
|
||||||
std::atomic<std::uint32_t> metronome_beats_per_bar{4};
|
|
||||||
std::atomic<std::uint32_t> metronome_click_frames{1680};
|
|
||||||
std::atomic<std::float32_t> metronome_volume{0.45F};
|
|
||||||
std::atomic<std::float32_t> metronome_beat_frequency{1320.0F};
|
|
||||||
std::atomic<std::float32_t> metronome_downbeat_frequency{1760.0F};
|
|
||||||
|
|
||||||
std::uint32_t observed_metronome_generation{0};
|
std::uint32_t observed_metronome_generation{0};
|
||||||
std::uint32_t beat_index{0};
|
std::uint32_t beat_index{0};
|
||||||
std::uint64_t frames_until_next_beat{0};
|
std::uint64_t frames_until_next_beat{0};
|
||||||
std::uint32_t click_frame_index{0};
|
std::size_t click_frame_index{0};
|
||||||
std::uint32_t click_frames_remaining{0};
|
std::size_t click_frames_remaining{0};
|
||||||
std::float32_t click_frequency{1320.0F};
|
std::bool_t click_is_downbeat{false};
|
||||||
std::float32_t click_gain{0.0F};
|
|
||||||
|
|
||||||
impl() = default;
|
impl() = default;
|
||||||
impl(impl const&) = delete;
|
impl(impl const&) = delete;
|
||||||
@@ -109,12 +192,15 @@ struct sonus::engine::impl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
|
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
|
||||||
config = value;
|
config = std::move(value);
|
||||||
|
publish_metronome_locked(make_disabled_metronome_config_locked());
|
||||||
|
|
||||||
auto engine_config = ma_engine_config_init();
|
auto engine_config = ma_engine_config_init();
|
||||||
engine_config.noDevice = MA_TRUE;
|
engine_config.noDevice = MA_TRUE;
|
||||||
engine_config.channels = config.channels;
|
engine_config.channels = config.channels;
|
||||||
engine_config.sampleRate = config.sample_rate;
|
engine_config.sampleRate = config.sample_rate;
|
||||||
|
engine_config.periodSizeInFrames = config.period_size_frames;
|
||||||
|
engine_config.periodSizeInMilliseconds = config.period_size_milliseconds;
|
||||||
engine_config.noAutoStart = MA_TRUE;
|
engine_config.noAutoStart = MA_TRUE;
|
||||||
|
|
||||||
auto result = ma_engine_init(&engine_config, &mixer);
|
auto result = ma_engine_init(&engine_config, &mixer);
|
||||||
@@ -134,10 +220,20 @@ struct sonus::engine::impl {
|
|||||||
device_config.playback.format = ma_format_f32;
|
device_config.playback.format = ma_format_f32;
|
||||||
device_config.playback.channels = config.channels;
|
device_config.playback.channels = config.channels;
|
||||||
device_config.sampleRate = config.sample_rate;
|
device_config.sampleRate = config.sample_rate;
|
||||||
|
device_config.periodSizeInFrames = config.period_size_frames;
|
||||||
|
device_config.periodSizeInMilliseconds = config.period_size_milliseconds;
|
||||||
|
device_config.periods = config.period_count;
|
||||||
|
device_config.performanceProfile = to_miniaudio_profile(config.performance);
|
||||||
device_config.dataCallback = sonus::engine::impl::device_callback;
|
device_config.dataCallback = sonus::engine::impl::device_callback;
|
||||||
device_config.pUserData = this;
|
device_config.pUserData = this;
|
||||||
|
|
||||||
result = ma_device_init(nullptr, &device_config, &device);
|
auto selected = configure_playback_device(device_config);
|
||||||
|
if (!selected) {
|
||||||
|
shutdown();
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = ma_device_init(context_initialized ? &context : nullptr, &device_config, &device);
|
||||||
if (result != MA_SUCCESS) {
|
if (result != MA_SUCCESS) {
|
||||||
shutdown();
|
shutdown();
|
||||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device"));
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device"));
|
||||||
@@ -177,37 +273,66 @@ struct sonus::engine::impl {
|
|||||||
static_cast<void>(ma_device_stop(&device));
|
static_cast<void>(ma_device_stop(&device));
|
||||||
}
|
}
|
||||||
started = false;
|
started = false;
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto shutdown() -> void {
|
auto shutdown() -> void {
|
||||||
{
|
auto lock = std::scoped_lock{mutex};
|
||||||
auto lock = std::scoped_lock{mutex};
|
if (device_initialized) {
|
||||||
if (device_initialized) {
|
if (started) {
|
||||||
if (started) {
|
static_cast<void>(ma_device_stop(&device));
|
||||||
static_cast<void>(ma_device_stop(&device));
|
started = false;
|
||||||
started = false;
|
|
||||||
}
|
|
||||||
ma_device_uninit(&device);
|
|
||||||
device_initialized = false;
|
|
||||||
}
|
}
|
||||||
sounds.clear();
|
ma_device_uninit(&device);
|
||||||
if (mixer_initialized) {
|
device_initialized = false;
|
||||||
ma_engine_uninit(&mixer);
|
|
||||||
mixer_initialized = false;
|
|
||||||
}
|
|
||||||
metronome_enabled.store(false, std::memory_order_release);
|
|
||||||
}
|
}
|
||||||
|
sounds.clear();
|
||||||
|
if (mixer_initialized) {
|
||||||
|
ma_engine_uninit(&mixer);
|
||||||
|
mixer_initialized = false;
|
||||||
|
}
|
||||||
|
if (context_initialized) {
|
||||||
|
ma_context_uninit(&context);
|
||||||
|
context_initialized = false;
|
||||||
|
}
|
||||||
|
metronome_enabled.store(false, std::memory_order_release);
|
||||||
|
reset_metronome_render_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto configure_playback_device(ma_device_config& device_config) -> std::expected<void, modulus::error> {
|
||||||
|
if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto context_config = ma_context_config_init();
|
||||||
|
auto result = ma_context_init(nullptr, 0, &context_config, &context);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio context"));
|
||||||
|
}
|
||||||
|
context_initialized = true;
|
||||||
|
|
||||||
|
ma_device_info* playback_devices = nullptr;
|
||||||
|
auto playback_device_count = ma_uint32{};
|
||||||
|
result = ma_context_get_devices(&context, &playback_devices, &playback_device_count, nullptr, nullptr);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto devices = describe_playback_devices(playback_devices, playback_device_count);
|
||||||
|
auto selected = sonus::detail::find_playback_device(devices, config.playback_device_id, config.playback_device_name);
|
||||||
|
if (selected) {
|
||||||
|
playback_device_id = playback_devices[*selected].id;
|
||||||
|
device_config.playback.pDeviceID = &playback_device_id;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto selector = config.playback_device_id.empty() ? config.playback_device_name : config.playback_device_id;
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
||||||
auto valid = sonus::validate(sonus::audio_config{
|
if (!valid_unit_interval(volume)) {
|
||||||
.sample_rate = config.sample_rate,
|
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||||
.channels = config.channels,
|
|
||||||
.master_volume = volume,
|
|
||||||
.no_device = config.no_device,
|
|
||||||
});
|
|
||||||
if (!valid) {
|
|
||||||
return valid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
@@ -233,16 +358,24 @@ struct sonus::engine::impl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto sound = std::make_unique<active_sound>();
|
auto sound = std::make_unique<active_sound>();
|
||||||
|
sound->loop = options.loop;
|
||||||
auto const path_text = path.string();
|
auto const path_text = path.string();
|
||||||
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
|
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
|
||||||
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
|
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
|
||||||
|
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
static_cast<void>(collect_finished_locked());
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
|
|
||||||
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
|
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
|
||||||
if (result != MA_SUCCESS) {
|
if (result != MA_SUCCESS) {
|
||||||
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
|
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
|
||||||
}
|
}
|
||||||
sound->initialized = true;
|
sound->initialized = true;
|
||||||
|
result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get());
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to set sound end callback"));
|
||||||
|
}
|
||||||
ma_sound_set_volume(&sound->sound, options.volume);
|
ma_sound_set_volume(&sound->sound, options.volume);
|
||||||
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
|
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
|
||||||
result = ma_sound_start(&sound->sound);
|
result = ma_sound_start(&sound->sound);
|
||||||
@@ -250,12 +383,12 @@ struct sonus::engine::impl {
|
|||||||
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to start sound file"));
|
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to start sound file"));
|
||||||
}
|
}
|
||||||
|
|
||||||
auto handle = next_sound_handle++;
|
auto handle = allocate_sound_handle_locked();
|
||||||
if (next_sound_handle == 0) {
|
if (!handle) {
|
||||||
next_sound_handle = 1;
|
return sonus::detail::fail<sonus::sound_handle>(handle.error());
|
||||||
}
|
}
|
||||||
sounds.emplace(handle, std::move(sound));
|
sounds.emplace(*handle, std::move(sound));
|
||||||
return handle;
|
return *handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
||||||
@@ -280,32 +413,158 @@ struct sonus::engine::impl {
|
|||||||
sounds.clear();
|
sounds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto is_sound_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> {
|
||||||
|
if (handle == 0) {
|
||||||
|
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "sound handle must not be zero");
|
||||||
|
}
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
auto found = sounds.find(handle);
|
||||||
|
if (found == sounds.end()) {
|
||||||
|
return sonus::detail::fail<std::bool_t>(sonus::detail::error_not_found, "sound handle not found");
|
||||||
|
}
|
||||||
|
if (sound_finished(*found->second)) {
|
||||||
|
sounds.erase(found);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ma_sound_is_playing(&found->second->sound) == MA_TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto collect_finished_sounds() -> std::uint64_t {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
auto const collected = collect_finished_locked();
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto active_sound_count() -> std::uint64_t {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
static_cast<void>(collect_finished_locked());
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
|
return sounds.size();
|
||||||
|
}
|
||||||
|
|
||||||
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
||||||
auto valid = sonus::validate(value);
|
auto valid = sonus::validate(value);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
return publish_metronome_config_locked(value, enable);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
|
||||||
|
auto valid = sonus::validate(value);
|
||||||
|
if (!valid) {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
auto const* current = metronome_configs.current();
|
||||||
|
auto const enable = current != nullptr && current->enabled;
|
||||||
|
return publish_metronome_config_locked(value, enable);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stop_metronome() -> void {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
publish_metronome_locked(make_disabled_metronome_config_locked());
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto publish_metronome_config_locked(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
||||||
|
auto next = make_metronome_config(value, enable);
|
||||||
|
if (!next) {
|
||||||
|
return sonus::detail::fail_void(next.error());
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_metronome_locked(std::move(*next));
|
||||||
|
collect_retired_metronome_configs_locked();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto make_metronome_config(sonus::metronome_config value, std::bool_t enable) -> std::expected<std::unique_ptr<metronome_render_config>, modulus::error> {
|
||||||
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
|
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
|
||||||
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
|
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
|
||||||
|
auto frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(value.bpm))));
|
||||||
|
|
||||||
metronome_bpm.store(value.bpm, std::memory_order_release);
|
auto result = std::make_unique<metronome_render_config>();
|
||||||
metronome_beats_per_bar.store(value.beats_per_bar, std::memory_order_release);
|
result->enabled = enable;
|
||||||
metronome_click_frames.store(static_cast<std::uint32_t>(click_frames), std::memory_order_release);
|
result->beats_per_bar = value.beats_per_bar;
|
||||||
metronome_volume.store(value.volume, std::memory_order_release);
|
result->frames_per_beat = frames_per_beat;
|
||||||
metronome_beat_frequency.store(value.beat_frequency_hz, std::memory_order_release);
|
|
||||||
metronome_downbeat_frequency.store(value.downbeat_frequency_hz, std::memory_order_release);
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
metronome_enabled.store(true, std::memory_order_release);
|
result->beat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.beat_frequency_hz, value.volume * 0.64F);
|
||||||
|
result->downbeat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.downbeat_frequency_hz, value.volume);
|
||||||
}
|
}
|
||||||
metronome_generation.fetch_add(1, std::memory_order_acq_rel);
|
return std::move(result);
|
||||||
return {};
|
}
|
||||||
|
|
||||||
|
auto make_disabled_metronome_config_locked() -> std::unique_ptr<metronome_render_config> {
|
||||||
|
auto result = std::make_unique<metronome_render_config>();
|
||||||
|
result->enabled = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto publish_metronome_locked(std::unique_ptr<metronome_render_config> next) -> void {
|
||||||
|
next->generation = next_metronome_generation++;
|
||||||
|
if (next_metronome_generation == 0U) {
|
||||||
|
next_metronome_generation = 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const enabled = next->enabled;
|
||||||
|
metronome_configs.publish(std::move(next));
|
||||||
|
metronome_enabled.store(enabled, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto collect_retired_metronome_configs_locked() -> void {
|
||||||
|
metronome_configs.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto allocate_sound_handle_locked() -> std::expected<sonus::sound_handle, modulus::error> {
|
||||||
|
if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<sonus::sound_handle>::max() - 1U)) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "sound handle space exhausted");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto attempts = std::size_t{};
|
||||||
|
auto const max_attempts = sounds.size() + 1U;
|
||||||
|
while (attempts < max_attempts) {
|
||||||
|
auto handle = next_sound_handle++;
|
||||||
|
if (next_sound_handle == 0U) {
|
||||||
|
next_sound_handle = 1U;
|
||||||
|
}
|
||||||
|
if (!sounds.contains(handle)) {
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
++attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "failed to allocate sound handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
static auto sound_finished(active_sound const& sound) -> std::bool_t {
|
||||||
|
return !sound.loop && (sound.finished.load(std::memory_order_acquire) || ma_sound_at_end(&sound.sound) == MA_TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto collect_finished_locked() -> std::uint64_t {
|
||||||
|
auto collected = std::uint64_t{};
|
||||||
|
auto current = sounds.begin();
|
||||||
|
while (current != sounds.end()) {
|
||||||
|
if (sound_finished(*current->second)) {
|
||||||
|
current = sounds.erase(current);
|
||||||
|
++collected;
|
||||||
|
} else {
|
||||||
|
++current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collected;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
|
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
|
||||||
if (output == nullptr || frame_count == 0U) {
|
if (output == nullptr || frame_count == 0U) {
|
||||||
return MA_SUCCESS;
|
return MA_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto guard = metronome_configs.read();
|
||||||
|
|
||||||
auto const sample_count = frame_count * config.channels;
|
auto const sample_count = frame_count * config.channels;
|
||||||
std::fill(output, output + sample_count, 0.0F);
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
||||||
@@ -313,68 +572,64 @@ struct sonus::engine::impl {
|
|||||||
std::fill(output, output + sample_count, 0.0F);
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
mix_metronome(output, frame_count);
|
mix_metronome(output, frame_count, guard.get());
|
||||||
return MA_SUCCESS;
|
return MA_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count) -> void {
|
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count, metronome_render_config const* metro) -> void {
|
||||||
auto const generation = metronome_generation.load(std::memory_order_acquire);
|
if (metro == nullptr || !metro->enabled) {
|
||||||
auto const enabled = metronome_enabled.load(std::memory_order_acquire);
|
if (metro != nullptr) {
|
||||||
if (!enabled) {
|
observed_metronome_generation = metro->generation;
|
||||||
observed_metronome_generation = generation;
|
}
|
||||||
beat_index = 0;
|
reset_metronome_render_state();
|
||||||
frames_until_next_beat = 0;
|
|
||||||
click_frame_index = 0;
|
|
||||||
click_frames_remaining = 0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (observed_metronome_generation != generation) {
|
if (observed_metronome_generation != metro->generation) {
|
||||||
observed_metronome_generation = generation;
|
observed_metronome_generation = metro->generation;
|
||||||
beat_index = 0;
|
reset_metronome_render_state();
|
||||||
frames_until_next_beat = 0;
|
|
||||||
click_frame_index = 0;
|
|
||||||
click_frames_remaining = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto const bpm = std::max<std::uint32_t>(1U, metronome_bpm.load(std::memory_order_acquire));
|
|
||||||
auto const beats_per_bar = std::max<std::uint32_t>(1U, metronome_beats_per_bar.load(std::memory_order_acquire));
|
|
||||||
auto const click_frames = std::max<std::uint32_t>(1U, metronome_click_frames.load(std::memory_order_acquire));
|
|
||||||
auto const volume = metronome_volume.load(std::memory_order_acquire);
|
|
||||||
auto const beat_frequency = metronome_beat_frequency.load(std::memory_order_acquire);
|
|
||||||
auto const downbeat_frequency = metronome_downbeat_frequency.load(std::memory_order_acquire);
|
|
||||||
auto const frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(bpm))));
|
|
||||||
|
|
||||||
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
|
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
|
||||||
if (frames_until_next_beat == 0U) {
|
if (frames_until_next_beat == 0U) {
|
||||||
auto const downbeat = beat_index == 0U;
|
auto const downbeat = beat_index == 0U;
|
||||||
click_frame_index = 0;
|
click_frame_index = 0;
|
||||||
click_frames_remaining = click_frames;
|
click_is_downbeat = downbeat;
|
||||||
click_frequency = downbeat ? downbeat_frequency : beat_frequency;
|
click_frames_remaining = downbeat ? metro->downbeat_click.size() : metro->beat_click.size();
|
||||||
click_gain = volume * (downbeat ? 1.0F : 0.64F);
|
beat_index = (beat_index + 1U) % std::max<std::uint32_t>(1U, metro->beats_per_bar);
|
||||||
beat_index = (beat_index + 1U) % beats_per_bar;
|
frames_until_next_beat = metro->frames_per_beat;
|
||||||
frames_until_next_beat = frames_per_beat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto sample = 0.0F;
|
auto sample = 0.0F;
|
||||||
if (click_frames_remaining > 0U) {
|
if (click_frames_remaining > 0U) {
|
||||||
auto const t = static_cast<double>(click_frame_index) / static_cast<double>(config.sample_rate);
|
auto const& click = click_is_downbeat ? metro->downbeat_click : metro->beat_click;
|
||||||
auto const progress = static_cast<double>(click_frame_index) / static_cast<double>(click_frames);
|
if (click_frame_index < click.size()) {
|
||||||
auto const envelope = (1.0 - progress) * (1.0 - progress);
|
sample = click[click_frame_index];
|
||||||
sample = static_cast<std::float32_t>(std::sin(2.0 * pi * static_cast<double>(click_frequency) * t) * envelope * static_cast<double>(click_gain));
|
++click_frame_index;
|
||||||
++click_frame_index;
|
--click_frames_remaining;
|
||||||
--click_frames_remaining;
|
} else {
|
||||||
|
click_frames_remaining = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sample != 0.0F) {
|
if (sample != 0.0F) {
|
||||||
auto* frame_out = output + frame * config.channels;
|
auto* frame_out = output + frame * config.channels;
|
||||||
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
|
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
|
||||||
frame_out[channel] = std::clamp(frame_out[channel] + sample, -1.0F, 1.0F);
|
auto const mixed = static_cast<std::float32_t>(frame_out[channel] + sample);
|
||||||
|
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
--frames_until_next_beat;
|
--frames_until_next_beat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto reset_metronome_render_state() -> void {
|
||||||
|
beat_index = 0;
|
||||||
|
frames_until_next_beat = 0;
|
||||||
|
click_frame_index = 0;
|
||||||
|
click_frames_remaining = 0;
|
||||||
|
click_is_downbeat = false;
|
||||||
|
}
|
||||||
|
|
||||||
static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void {
|
static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void {
|
||||||
auto* self = static_cast<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
|
auto* self = static_cast<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
|
||||||
if (self == nullptr || output == nullptr) {
|
if (self == nullptr || output == nullptr) {
|
||||||
@@ -398,9 +653,49 @@ auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, m
|
|||||||
if (!valid_unit_interval(config.master_volume)) {
|
if (!valid_unit_interval(config.master_volume)) {
|
||||||
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||||
}
|
}
|
||||||
|
if (contains_null(config.playback_device_id)) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio playback_device_id must not contain null bytes"));
|
||||||
|
}
|
||||||
|
if (contains_null(config.playback_device_name)) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio playback_device_name must not contain null bytes"));
|
||||||
|
}
|
||||||
|
if (config.no_device && (!config.playback_device_id.empty() || !config.playback_device_name.empty())) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio playback device selectors require a playback device"));
|
||||||
|
}
|
||||||
|
if (config.period_size_frames != 0U && config.period_size_milliseconds != 0U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio period_size_frames and period_size_milliseconds are mutually exclusive"));
|
||||||
|
}
|
||||||
|
if (config.period_size_frames > 262144U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio period_size_frames must be no greater than 262144"));
|
||||||
|
}
|
||||||
|
if (config.period_size_milliseconds > 1000U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio period_size_milliseconds must be no greater than 1000"));
|
||||||
|
}
|
||||||
|
if (config.period_count > 16U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio period_count must be no greater than 16"));
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> {
|
||||||
|
auto context = temporary_context{};
|
||||||
|
auto context_config = ma_context_config_init();
|
||||||
|
auto result = ma_context_init(nullptr, 0, &context_config, &context.context);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context"));
|
||||||
|
}
|
||||||
|
context.initialized = true;
|
||||||
|
|
||||||
|
ma_device_info* playback_devices = nullptr;
|
||||||
|
auto playback_device_count = ma_uint32{};
|
||||||
|
result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return describe_playback_devices(playback_devices, playback_device_count);
|
||||||
|
}
|
||||||
|
|
||||||
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
|
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
|
||||||
if (config.bpm == 0U || config.bpm > 400U) {
|
if (config.bpm == 0U || config.bpm > 400U) {
|
||||||
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
|
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
|
||||||
@@ -433,7 +728,7 @@ auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::e
|
|||||||
return sonus::detail::fail<sonus::engine>(valid.error());
|
return sonus::detail::fail<sonus::engine>(valid.error());
|
||||||
}
|
}
|
||||||
auto implementation = std::make_unique<sonus::engine::impl>();
|
auto implementation = std::make_unique<sonus::engine::impl>();
|
||||||
auto initialized = implementation->initialize(config);
|
auto initialized = implementation->initialize(std::move(config));
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
return sonus::detail::fail<sonus::engine>(initialized.error());
|
return sonus::detail::fail<sonus::engine>(initialized.error());
|
||||||
}
|
}
|
||||||
@@ -504,6 +799,21 @@ auto sonus::engine::stop_all() -> void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->is_sound_playing(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::collect_finished() -> std::uint64_t {
|
||||||
|
return implementation_ ? implementation_->collect_finished_sounds() : 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::active_sound_count() -> std::uint64_t {
|
||||||
|
return implementation_ ? implementation_->active_sound_count() : 0U;
|
||||||
|
}
|
||||||
|
|
||||||
auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
|
auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
|
||||||
if (!implementation_) {
|
if (!implementation_) {
|
||||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
@@ -515,15 +825,13 @@ auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::exp
|
|||||||
if (!implementation_) {
|
if (!implementation_) {
|
||||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
}
|
}
|
||||||
return implementation_->store_metronome(config, false);
|
return implementation_->update_metronome(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto sonus::engine::stop_metronome() -> void {
|
auto sonus::engine::stop_metronome() -> void {
|
||||||
if (!implementation_) {
|
if (implementation_) {
|
||||||
return;
|
implementation_->stop_metronome();
|
||||||
}
|
}
|
||||||
implementation_->metronome_enabled.store(false, std::memory_order_release);
|
|
||||||
implementation_->metronome_generation.fetch_add(1, std::memory_order_acq_rel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto sonus::engine::metronome_active() const -> std::bool_t {
|
auto sonus::engine::metronome_active() const -> std::bool_t {
|
||||||
@@ -540,7 +848,10 @@ auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) ->
|
|||||||
if (output == nullptr) {
|
if (output == nullptr) {
|
||||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render output must not be null");
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render output must not be null");
|
||||||
}
|
}
|
||||||
auto lock = std::scoped_lock{implementation_->render_mutex};
|
if (implementation_->config.channels != 0U && frame_count > std::numeric_limits<std::uint64_t>::max() / implementation_->config.channels) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render frame_count is too large");
|
||||||
|
}
|
||||||
|
auto lock = std::scoped_lock{implementation_->mutex, implementation_->render_mutex};
|
||||||
auto result = implementation_->render_to(output, frame_count);
|
auto result = implementation_->render_to(output, frame_count);
|
||||||
if (result != MA_SUCCESS) {
|
if (result != MA_SUCCESS) {
|
||||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio"));
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio"));
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.25)
|
||||||
|
project(sonus-consumer LANGUAGES CXX)
|
||||||
|
|
||||||
|
foreach(dependency miniaudio OpenSSL SQLite3 raylib)
|
||||||
|
set(CMAKE_DISABLE_FIND_PACKAGE_${dependency} TRUE)
|
||||||
|
endforeach()
|
||||||
|
find_package(sonus CONFIG REQUIRED)
|
||||||
|
get_target_property(sonus_links sonus::sonus INTERFACE_LINK_LIBRARIES)
|
||||||
|
if(sonus_links MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||||
|
message(FATAL_ERROR "Sonus exposes a private dependency: ${sonus_links}")
|
||||||
|
endif()
|
||||||
|
foreach(dependency modulus::miniaudio modulus::openssl modulus::sqlite modulus::raylib)
|
||||||
|
if(TARGET ${dependency})
|
||||||
|
message(FATAL_ERROR "Sonus discovered an unrelated bridge: ${dependency}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
option(SONUS_TEST_INSTALLED_CONFIG "Check installed Modulus component discovery" OFF)
|
||||||
|
if(SONUS_TEST_INSTALLED_CONFIG)
|
||||||
|
find_package(modulus CONFIG REQUIRED OPTIONAL_COMPONENTS miniaudio unsupported)
|
||||||
|
if(modulus_miniaudio_FOUND OR modulus_unsupported_FOUND)
|
||||||
|
message(FATAL_ERROR "Unavailable optional components must not be found")
|
||||||
|
endif()
|
||||||
|
find_package(modulus CONFIG QUIET COMPONENTS unsupported)
|
||||||
|
if(modulus_FOUND)
|
||||||
|
message(FATAL_ERROR "An unknown required component must reject the package")
|
||||||
|
endif()
|
||||||
|
find_package(modulus CONFIG REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(sonus-consumer main.cpp)
|
||||||
|
target_link_libraries(sonus-consumer PRIVATE sonus::sonus)
|
||||||
|
target_compile_features(sonus-consumer PRIVATE cxx_std_23)
|
||||||
|
enable_testing()
|
||||||
|
add_test(NAME sonus-consumer COMMAND sonus-consumer)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import conan
|
||||||
|
import conan.tools.build
|
||||||
|
import conan.tools.cmake
|
||||||
|
|
||||||
|
|
||||||
|
class sonus_test_package(conan.ConanFile):
|
||||||
|
settings = "os", "arch", "compiler", "build_type"
|
||||||
|
generators = "CMakeDeps", "CMakeToolchain"
|
||||||
|
test_type = "explicit"
|
||||||
|
|
||||||
|
def requirements(self):
|
||||||
|
self.requires(self.tested_reference_str)
|
||||||
|
|
||||||
|
def layout(self):
|
||||||
|
conan.tools.cmake.cmake_layout(self)
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
cmake = conan.tools.cmake.CMake(self)
|
||||||
|
cmake.configure()
|
||||||
|
cmake.build()
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
if conan.tools.build.can_run(self):
|
||||||
|
self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#include <sonus.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
auto main() -> int {
|
||||||
|
auto config = sonus::audio_config{};
|
||||||
|
config.no_device = true;
|
||||||
|
auto engine = sonus::engine::create(config);
|
||||||
|
if (!engine || !engine->set_master_volume(0.5F) || !engine->start_metronome({})) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto samples = std::vector<std::float32_t>(512U * engine->channels());
|
||||||
|
if (!engine->render(samples.data(), 512U)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (std::none_of(samples.begin(), samples.end(), [](auto value) { return value != 0.0F; })) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
engine->stop_metronome();
|
||||||
|
return engine->metronome_active() ? 4 : 0;
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
get_target_property(sonus_interface_libraries sonus INTERFACE_LINK_LIBRARIES)
|
||||||
|
if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||||
|
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
|
||||||
|
endif()
|
||||||
|
|
||||||
add_executable(sonus-test
|
add_executable(sonus-test
|
||||||
test.cpp
|
test.cpp
|
||||||
|
render_snapshot.cpp
|
||||||
|
control.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
|
||||||
)
|
)
|
||||||
target_link_libraries(sonus-test PRIVATE
|
target_link_libraries(sonus-test PRIVATE
|
||||||
sonus::sonus
|
sonus::sonus
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include <boost/test/unit_test.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <barrier>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <sonus.hpp>
|
||||||
|
#include "detail/playback_devices.hpp"
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) {
|
||||||
|
auto config = sonus::audio_config{};
|
||||||
|
config.no_device = true;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
auto rendezvous = std::barrier{2};
|
||||||
|
auto failed = std::atomic<bool>{false};
|
||||||
|
auto updater = std::thread{[&] {
|
||||||
|
for (auto index = 0; index < 1000; ++index) {
|
||||||
|
for (auto phase = 0; phase < 2; ++phase) {
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
if (!created->update_metronome({})) {
|
||||||
|
failed.store(true);
|
||||||
|
}
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
for (auto index = 0; index < 1000; ++index) {
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
if (!created->start_metronome({})) {
|
||||||
|
failed.store(true);
|
||||||
|
}
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
if (!created->metronome_active()) {
|
||||||
|
failed.store(true);
|
||||||
|
}
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
created->stop_metronome();
|
||||||
|
rendezvous.arrive_and_wait();
|
||||||
|
if (created->metronome_active()) {
|
||||||
|
failed.store(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updater.join();
|
||||||
|
BOOST_TEST(!failed.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices) {
|
||||||
|
auto devices = std::vector<sonus::device_info>{
|
||||||
|
{.id = "0:1:speaker", .name = "speaker", .is_default = true},
|
||||||
|
{.id = "1:0:speaker", .name = "speaker", .is_default = false},
|
||||||
|
};
|
||||||
|
auto select = [&](auto id, auto name) {
|
||||||
|
return sonus::detail::find_playback_device(devices, id, name);
|
||||||
|
};
|
||||||
|
auto selected = select(devices[1].id, "speaker");
|
||||||
|
BOOST_REQUIRE(selected);
|
||||||
|
BOOST_TEST(*selected == 1U);
|
||||||
|
selected = select("stale-id", "speaker");
|
||||||
|
BOOST_REQUIRE(selected);
|
||||||
|
BOOST_TEST(*selected == 0U);
|
||||||
|
selected = select("", "speaker");
|
||||||
|
BOOST_REQUIRE(selected);
|
||||||
|
BOOST_TEST(*selected == 0U);
|
||||||
|
BOOST_TEST(!select("stale-id", ""));
|
||||||
|
BOOST_TEST(!select("", ""));
|
||||||
|
BOOST_TEST(!select("", "missing"));
|
||||||
|
devices.clear();
|
||||||
|
BOOST_TEST(!select("0:1:speaker", "speaker"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#include <boost/test/unit_test.hpp>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <barrier>
|
||||||
|
#include <memory>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "detail/render_snapshot.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct snapshot {
|
||||||
|
std::atomic<int>& live;
|
||||||
|
std::array<int, 64> samples{};
|
||||||
|
explicit snapshot(std::atomic<int>& count, int value = 0) : live(count) {
|
||||||
|
++live;
|
||||||
|
samples.fill(value);
|
||||||
|
}
|
||||||
|
~snapshot() { --live; }
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_snapshots_are_reclaimed_without_rendering) {
|
||||||
|
auto live = std::atomic<int>{0};
|
||||||
|
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||||
|
for (auto index = 0; index < 1000; ++index) {
|
||||||
|
state.publish(std::make_unique<snapshot>(live, index));
|
||||||
|
BOOST_TEST(live.load() == 1);
|
||||||
|
}
|
||||||
|
state.publish(nullptr);
|
||||||
|
BOOST_TEST(live.load() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_snapshots_outlive_readers_and_are_collected_on_control_thread) {
|
||||||
|
auto live = std::atomic<int>{0};
|
||||||
|
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||||
|
state.publish(std::make_unique<snapshot>(live, 42));
|
||||||
|
{
|
||||||
|
auto first = state.read();
|
||||||
|
state.publish(std::make_unique<snapshot>(live, 43));
|
||||||
|
{
|
||||||
|
auto second = state.read();
|
||||||
|
state.publish(std::make_unique<snapshot>(live, 44));
|
||||||
|
state.collect();
|
||||||
|
BOOST_TEST(live.load() == 3);
|
||||||
|
BOOST_TEST(second.get()->samples.front() == 43);
|
||||||
|
}
|
||||||
|
state.collect();
|
||||||
|
BOOST_TEST(live.load() == 3);
|
||||||
|
BOOST_TEST(first.get()->samples.back() == 42);
|
||||||
|
}
|
||||||
|
BOOST_TEST(live.load() == 3);
|
||||||
|
state.collect();
|
||||||
|
BOOST_TEST(live.load() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_snapshot_publication_overlaps_readers) {
|
||||||
|
auto live = std::atomic<int>{0};
|
||||||
|
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||||
|
state.publish(std::make_unique<snapshot>(live));
|
||||||
|
auto start = std::barrier{3};
|
||||||
|
auto failed = std::atomic<bool>{false};
|
||||||
|
auto reader = [&] {
|
||||||
|
start.arrive_and_wait();
|
||||||
|
for (auto index = 0; index < 20000; ++index) {
|
||||||
|
auto guard = state.read();
|
||||||
|
auto const* value = guard.get();
|
||||||
|
for (auto sample : value->samples) {
|
||||||
|
if (sample != value->samples.front()) {
|
||||||
|
failed.store(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto first = std::thread{reader};
|
||||||
|
auto second = std::thread{reader};
|
||||||
|
start.arrive_and_wait();
|
||||||
|
for (auto index = 0; index < 20000; ++index) {
|
||||||
|
state.publish(std::make_unique<snapshot>(live, index));
|
||||||
|
}
|
||||||
|
first.join();
|
||||||
|
second.join();
|
||||||
|
state.collect();
|
||||||
|
BOOST_TEST(!failed.load());
|
||||||
|
BOOST_TEST(live.load() == 1);
|
||||||
|
}
|
||||||
+310
-1
@@ -1,10 +1,15 @@
|
|||||||
#include <test.hpp>
|
#include <test.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -12,14 +17,20 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
constexpr auto pi = 3.141592653589793238462643383279502884;
|
||||||
|
|
||||||
auto no_device_config() -> sonus::audio_config {
|
auto no_device_config() -> sonus::audio_config {
|
||||||
auto config = sonus::audio_config{};
|
auto config = sonus::audio_config{};
|
||||||
config.no_device = true;
|
config.no_device = true;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto test_file(std::string_view name) -> std::filesystem::path {
|
||||||
|
return std::filesystem::temp_directory_path() / name;
|
||||||
|
}
|
||||||
|
|
||||||
auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
||||||
auto result = 0.0F;
|
auto result = std::float32_t{};
|
||||||
auto const begin = begin_frame * channels;
|
auto const begin = begin_frame * channels;
|
||||||
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
||||||
for (auto index = begin; index < end; ++index) {
|
for (auto index = begin; index < end; ++index) {
|
||||||
@@ -28,6 +39,63 @@ auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, s
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto write_bytes(std::ofstream& output, std::string_view value) -> void {
|
||||||
|
output.write(value.data(), static_cast<std::streamsize>(value.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_u16_le(std::ofstream& output, std::uint16_t value) -> void {
|
||||||
|
auto bytes = std::array<char, 2>{
|
||||||
|
static_cast<char>(value & 0xFFU),
|
||||||
|
static_cast<char>((value >> 8U) & 0xFFU),
|
||||||
|
};
|
||||||
|
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_u32_le(std::ofstream& output, std::uint32_t value) -> void {
|
||||||
|
auto bytes = std::array<char, 4>{
|
||||||
|
static_cast<char>(value & 0xFFU),
|
||||||
|
static_cast<char>((value >> 8U) & 0xFFU),
|
||||||
|
static_cast<char>((value >> 16U) & 0xFFU),
|
||||||
|
static_cast<char>((value >> 24U) & 0xFFU),
|
||||||
|
};
|
||||||
|
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_test_wav(std::filesystem::path const& path, std::uint32_t sample_rate, std::uint16_t channels, std::uint32_t frame_count, double frequency_hz) -> void {
|
||||||
|
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
||||||
|
BOOST_REQUIRE(output.good());
|
||||||
|
|
||||||
|
auto const bits_per_sample = std::uint16_t{16};
|
||||||
|
auto const bytes_per_sample = bits_per_sample / 8U;
|
||||||
|
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
|
||||||
|
auto const byte_rate = sample_rate * block_align;
|
||||||
|
auto const data_size = frame_count * block_align;
|
||||||
|
|
||||||
|
write_bytes(output, "RIFF");
|
||||||
|
write_u32_le(output, 36U + data_size);
|
||||||
|
write_bytes(output, "WAVE");
|
||||||
|
write_bytes(output, "fmt ");
|
||||||
|
write_u32_le(output, 16U);
|
||||||
|
write_u16_le(output, 1U);
|
||||||
|
write_u16_le(output, channels);
|
||||||
|
write_u32_le(output, sample_rate);
|
||||||
|
write_u32_le(output, byte_rate);
|
||||||
|
write_u16_le(output, block_align);
|
||||||
|
write_u16_le(output, bits_per_sample);
|
||||||
|
write_bytes(output, "data");
|
||||||
|
write_u32_le(output, data_size);
|
||||||
|
|
||||||
|
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
|
||||||
|
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate);
|
||||||
|
auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
|
||||||
|
for (auto channel = std::uint16_t{}; channel < channels; ++channel) {
|
||||||
|
write_u16_le(output, static_cast<std::uint16_t>(sample));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_REQUIRE(output.good());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||||
@@ -42,6 +110,32 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
|||||||
audio = {};
|
audio = {};
|
||||||
audio.master_volume = 1.5F;
|
audio.master_volume = 1.5F;
|
||||||
BOOST_TEST(!sonus::validate(audio));
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.period_size_frames = 128;
|
||||||
|
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
||||||
|
audio.period_size_milliseconds = 10;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.period_size_milliseconds = 1001;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.period_count = 17;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.no_device = true;
|
||||||
|
audio.playback_device_name = "default";
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.no_device = true;
|
||||||
|
audio.playback_device_id = "0:1:default";
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
BOOST_TEST(!sonus::engine::create(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.playback_device_id.assign("a\0b", 3);
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.playback_device_name.assign("a\0b", 3);
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
|
||||||
auto metronome = sonus::metronome_config{};
|
auto metronome = sonus::metronome_config{};
|
||||||
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
|
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
|
||||||
@@ -55,6 +149,26 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
|||||||
BOOST_TEST(!sonus::validate(metronome));
|
BOOST_TEST(!sonus::validate(metronome));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
|
||||||
|
auto enumerated = sonus::enumerate_playback_devices();
|
||||||
|
if (!enumerated) {
|
||||||
|
BOOST_TEST(!enumerated.error().message().empty());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto const& device : *enumerated) {
|
||||||
|
BOOST_TEST(!device.id.empty());
|
||||||
|
BOOST_TEST(device.id.find('\0') == std::string::npos);
|
||||||
|
BOOST_TEST(device.name.find('\0') == std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enumerated->empty()) {
|
||||||
|
auto config = sonus::audio_config{};
|
||||||
|
config.playback_device_id = enumerated->front().id;
|
||||||
|
BOOST_TEST(static_cast<bool>(sonus::validate(config)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
||||||
auto created = sonus::engine::create(no_device_config());
|
auto created = sonus::engine::create(no_device_config());
|
||||||
BOOST_REQUIRE(created);
|
BOOST_REQUIRE(created);
|
||||||
@@ -68,6 +182,40 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
|
||||||
|
auto created = sonus::engine::create(no_device_config());
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto failed = std::atomic_bool{false};
|
||||||
|
auto volume_worker = [&created, &failed](std::uint32_t offset) {
|
||||||
|
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||||
|
auto const volume = static_cast<std::float32_t>(((index + offset) % 101U)) / 100.0F;
|
||||||
|
if (!created->set_master_volume(volume)) {
|
||||||
|
failed.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto render_worker = [&created, &failed] {
|
||||||
|
auto samples = std::vector<std::float32_t>(256U * created->channels());
|
||||||
|
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||||
|
if (!created->render(samples.data(), 256U) || !created->update_metronome({})) {
|
||||||
|
failed.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto first = std::thread{volume_worker, 0U};
|
||||||
|
auto second = std::thread{volume_worker, 37U};
|
||||||
|
auto renderer = std::thread{render_worker};
|
||||||
|
first.join();
|
||||||
|
second.join();
|
||||||
|
renderer.join();
|
||||||
|
|
||||||
|
BOOST_TEST(!failed.load(std::memory_order_acquire));
|
||||||
|
BOOST_TEST(!created->set_master_volume(-0.01F));
|
||||||
|
BOOST_TEST(!created->set_master_volume(1.01F));
|
||||||
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
||||||
auto config = no_device_config();
|
auto config = no_device_config();
|
||||||
config.sample_rate = 48000;
|
config.sample_rate = 48000;
|
||||||
@@ -95,6 +243,52 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
|||||||
BOOST_TEST(between_peak < 0.05F);
|
BOOST_TEST(between_peak < 0.05F);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
|
||||||
|
auto created = sonus::engine::create(no_device_config());
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 140;
|
||||||
|
BOOST_REQUIRE(created->update_metronome(metro));
|
||||||
|
BOOST_TEST(!created->metronome_active());
|
||||||
|
|
||||||
|
BOOST_REQUIRE(created->start_metronome(metro));
|
||||||
|
BOOST_TEST(created->metronome_active());
|
||||||
|
metro.bpm = 180;
|
||||||
|
BOOST_REQUIRE(created->update_metronome(metro));
|
||||||
|
BOOST_TEST(created->metronome_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
for (auto index = std::uint32_t{}; index < 500U; ++index) {
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 60U + (index % 180U);
|
||||||
|
metro.beats_per_bar = 1U + (index % 8U);
|
||||||
|
metro.click_duration = std::chrono::milliseconds{1 + static_cast<int>(index % 40U)};
|
||||||
|
BOOST_REQUIRE(created->update_metronome(metro));
|
||||||
|
BOOST_TEST(!created->metronome_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||||
|
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
||||||
|
return sample == 0.0F;
|
||||||
|
}));
|
||||||
|
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 120;
|
||||||
|
BOOST_REQUIRE(created->start_metronome(metro));
|
||||||
|
BOOST_TEST(created->metronome_active());
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||||
|
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.1F);
|
||||||
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||||
auto created = sonus::engine::create(no_device_config());
|
auto created = sonus::engine::create(no_device_config());
|
||||||
BOOST_REQUIRE(created);
|
BOOST_REQUIRE(created);
|
||||||
@@ -104,6 +298,7 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
|||||||
auto metro = sonus::metronome_config{};
|
auto metro = sonus::metronome_config{};
|
||||||
metro.bpm = 80U + ((index + offset) % 80U);
|
metro.bpm = 80U + ((index + offset) % 80U);
|
||||||
static_cast<void>(created->start_metronome(metro));
|
static_cast<void>(created->start_metronome(metro));
|
||||||
|
static_cast<void>(created->update_metronome(metro));
|
||||||
if (index % 7U == 0U) {
|
if (index % 7U == 0U) {
|
||||||
created->stop_metronome();
|
created->stop_metronome();
|
||||||
}
|
}
|
||||||
@@ -129,3 +324,117 @@ BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
|
|||||||
auto played = created->play_file(missing);
|
auto played = created->play_file(missing);
|
||||||
BOOST_TEST(!played);
|
BOOST_TEST(!played);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto path = test_file("sonus-tone-long.wav");
|
||||||
|
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
|
||||||
|
|
||||||
|
auto played = created->play_file(path);
|
||||||
|
BOOST_REQUIRE(played);
|
||||||
|
BOOST_TEST(created->active_sound_count() == 1U);
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(4096U * config.channels);
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||||
|
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
|
||||||
|
|
||||||
|
auto playing = created->is_playing(*played);
|
||||||
|
BOOST_REQUIRE(playing);
|
||||||
|
BOOST_TEST(*playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto path = test_file("sonus-tone-short.wav");
|
||||||
|
write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
|
||||||
|
|
||||||
|
auto played = created->play_file(path);
|
||||||
|
BOOST_REQUIRE(played);
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(2048U * config.channels);
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 2048U));
|
||||||
|
|
||||||
|
auto playing = created->is_playing(*played);
|
||||||
|
BOOST_REQUIRE(playing);
|
||||||
|
BOOST_TEST(!*playing);
|
||||||
|
BOOST_TEST(created->active_sound_count() == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto path = test_file("sonus-tone-repeated.wav");
|
||||||
|
write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(512U * config.channels);
|
||||||
|
auto collected = std::uint64_t{};
|
||||||
|
for (auto index = 0; index < 20; ++index) {
|
||||||
|
auto played = created->play_file(path);
|
||||||
|
BOOST_REQUIRE(played);
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
||||||
|
collected += created->collect_finished();
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_TEST(collected == 20U);
|
||||||
|
BOOST_TEST(created->active_sound_count() == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto path = test_file("sonus-tone-concurrent.wav");
|
||||||
|
write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
|
||||||
|
|
||||||
|
auto render_worker = [&created, channels = config.channels] {
|
||||||
|
auto samples = std::vector<std::float32_t>(512U * channels);
|
||||||
|
for (auto index = 0; index < 150; ++index) {
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto control_worker = [&created, &path] {
|
||||||
|
for (auto index = 0; index < 80; ++index) {
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 90U + static_cast<std::uint32_t>(index % 80);
|
||||||
|
if (index % 5 == 0) {
|
||||||
|
created->stop_metronome();
|
||||||
|
} else if (created->metronome_active()) {
|
||||||
|
static_cast<void>(created->update_metronome(metro));
|
||||||
|
} else {
|
||||||
|
static_cast<void>(created->start_metronome(metro));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto played = created->play_file(path);
|
||||||
|
if (played && index % 3 == 0) {
|
||||||
|
static_cast<void>(created->stop(*played));
|
||||||
|
}
|
||||||
|
static_cast<void>(created->collect_finished());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto renderer = std::thread{render_worker};
|
||||||
|
auto controller = std::thread{control_worker};
|
||||||
|
renderer.join();
|
||||||
|
controller.join();
|
||||||
|
|
||||||
|
created->stop_all();
|
||||||
|
created->stop_metronome();
|
||||||
|
BOOST_TEST(created->active_sound_count() == 0U);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user