Init
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
build/
|
||||||
|
.clangd
|
||||||
|
.clang-format
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.log
|
||||||
|
|
||||||
|
package/CMakeCache.txt
|
||||||
|
package/CMakeFiles/
|
||||||
|
package/metadata/
|
||||||
|
package/.ninja_*
|
||||||
|
package/*.json
|
||||||
|
package/*.cmake
|
||||||
|
package/*.ninja
|
||||||
|
package/*.sh
|
||||||
|
package/*-smoke
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.25)
|
||||||
|
|
||||||
|
project(sonus VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
include(CMakePackageConfigHelpers)
|
||||||
|
include(CTest)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
find_package(modulus CONFIG REQUIRED)
|
||||||
|
|
||||||
|
add_library(sonus
|
||||||
|
src/detail/errors.cpp
|
||||||
|
src/engine.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(sonus PUBLIC
|
||||||
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||||
|
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||||
|
)
|
||||||
|
target_compile_features(sonus PUBLIC cxx_std_23)
|
||||||
|
target_link_libraries(sonus PUBLIC
|
||||||
|
modulus::modulus
|
||||||
|
modulus::miniaudio
|
||||||
|
)
|
||||||
|
add_library(sonus::sonus ALIAS sonus)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS sonus
|
||||||
|
EXPORT sonus-targets
|
||||||
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||||
|
|
||||||
|
install(EXPORT sonus-targets
|
||||||
|
NAMESPACE sonus::
|
||||||
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_package_config_file(
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
||||||
|
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
|
)
|
||||||
|
|
||||||
|
write_basic_package_version_file(
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
||||||
|
VERSION ${PROJECT_VERSION}
|
||||||
|
COMPATIBILITY SameMajorVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
install(FILES
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
||||||
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Khodo
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Sonus
|
||||||
|
|
||||||
|
Sonus is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback and generated metronome clicks, with `std::expected`-based error reporting through Modulus.
|
||||||
|
|
||||||
|
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Configure, build, and test the default target:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py configure
|
||||||
|
python3 modulus.py build
|
||||||
|
python3 modulus.py test
|
||||||
|
```
|
||||||
|
|
||||||
|
Create packages:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py package --all
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Sonus is released under the MIT License.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@PACKAGE_INIT@
|
||||||
|
|
||||||
|
include(CMakeFindDependencyMacro)
|
||||||
|
find_dependency(modulus CONFIG REQUIRED)
|
||||||
|
|
||||||
|
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake")
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import conan
|
||||||
|
import conan.tools.build
|
||||||
|
import conan.tools.cmake
|
||||||
|
|
||||||
|
class sonus_conan(conan.ConanFile):
|
||||||
|
name = "sonus"
|
||||||
|
version = "latest"
|
||||||
|
description = "C++23 audio playback and metronome library"
|
||||||
|
license = "MIT"
|
||||||
|
package_type = "library"
|
||||||
|
settings = "os", "arch", "compiler", "build_type"
|
||||||
|
options = {
|
||||||
|
"shared": [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
],
|
||||||
|
"fPIC": [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
],
|
||||||
|
"with_tests": [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
default_options = {
|
||||||
|
"shared": False,
|
||||||
|
"fPIC": True,
|
||||||
|
"with_tests": False,
|
||||||
|
}
|
||||||
|
exports = "modulus.json"
|
||||||
|
exports_sources = (
|
||||||
|
"CMakeLists.txt",
|
||||||
|
"cmake/*.in",
|
||||||
|
"include/*.hpp",
|
||||||
|
"include/sonus/*.hpp",
|
||||||
|
"include/sonus/detail/*.hpp",
|
||||||
|
"src/*.cpp",
|
||||||
|
"src/detail/*.cpp",
|
||||||
|
"tests/CMakeLists.txt",
|
||||||
|
"tests/*.cpp",
|
||||||
|
"tests/*.hpp",
|
||||||
|
)
|
||||||
|
|
||||||
|
def config_options(self):
|
||||||
|
if self.settings.os == "Windows":
|
||||||
|
del self.options.fPIC
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
if self.options.shared:
|
||||||
|
del self.options.fPIC
|
||||||
|
|
||||||
|
def requirements(self):
|
||||||
|
self.requires(
|
||||||
|
"modulus/latest",
|
||||||
|
transitive_headers=True,
|
||||||
|
transitive_libs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
dependencies = conan.tools.cmake.CMakeDeps(self)
|
||||||
|
dependencies.generate()
|
||||||
|
toolchain = conan.tools.cmake.CMakeToolchain(self)
|
||||||
|
if hasattr(toolchain, "user_presets_path"):
|
||||||
|
toolchain.user_presets_path = False
|
||||||
|
toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared)
|
||||||
|
toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests)
|
||||||
|
toolchain.generate()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
cmake = conan.tools.cmake.CMake(self)
|
||||||
|
cmake.configure()
|
||||||
|
cmake.build()
|
||||||
|
if self.options.with_tests and conan.tools.build.can_run(self):
|
||||||
|
cmake.test(target="check")
|
||||||
|
|
||||||
|
def package_id(self):
|
||||||
|
self.info.options.rm_safe("with_tests")
|
||||||
|
for requirement in (
|
||||||
|
"openssl",
|
||||||
|
"sqlite3",
|
||||||
|
"raylib",
|
||||||
|
"miniaudio",
|
||||||
|
):
|
||||||
|
if requirement in self.info.requires.pkg_names:
|
||||||
|
self.info.requires.remove(requirement)
|
||||||
|
|
||||||
|
def package(self):
|
||||||
|
cmake = conan.tools.cmake.CMake(self)
|
||||||
|
cmake.install()
|
||||||
|
|
||||||
|
def package_info(self):
|
||||||
|
self.cpp_info.set_property(
|
||||||
|
"cmake_file_name",
|
||||||
|
"sonus",
|
||||||
|
)
|
||||||
|
self.cpp_info.set_property(
|
||||||
|
"cmake_target_name",
|
||||||
|
"sonus::sonus",
|
||||||
|
)
|
||||||
|
self.cpp_info.libs = ["sonus"]
|
||||||
|
self.cpp_info.requires = [
|
||||||
|
"modulus::modulus",
|
||||||
|
"modulus::miniaudio",
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <sonus/detail/errors.hpp>
|
||||||
|
#include <sonus/engine.hpp>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
namespace sonus::detail {
|
||||||
|
|
||||||
|
constexpr std::int32_t error_invalid_argument{1};
|
||||||
|
constexpr std::int32_t error_not_found{2};
|
||||||
|
constexpr std::int32_t error_unavailable{3};
|
||||||
|
constexpr std::int32_t error_internal{4};
|
||||||
|
|
||||||
|
auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error;
|
||||||
|
auto fail_void(modulus::error error) -> std::expected<void, modulus::error>;
|
||||||
|
auto fail_void(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<void, modulus::error>;
|
||||||
|
auto fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>;
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error>;
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<VALUE, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto sonus::detail::fail(modulus::error error) -> std::expected<VALUE, modulus::error> {
|
||||||
|
return modulus::fail<VALUE>(std::move(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto sonus::detail::fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error> {
|
||||||
|
assert(!result);
|
||||||
|
return modulus::fail<VALUE>(result.error());
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto sonus::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<VALUE, modulus::error> {
|
||||||
|
return sonus::detail::fail<VALUE>(sonus::detail::make_error(code, std::move(message), location));
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
#include <modulus/types.hpp>
|
||||||
|
|
||||||
|
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::metronome_config const& config) -> std::expected<void, modulus::error>;
|
||||||
|
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
class engine {
|
||||||
|
public:
|
||||||
|
static auto create(sonus::audio_config config = {}) -> std::expected<sonus::engine, modulus::error>;
|
||||||
|
|
||||||
|
engine(sonus::engine const&) = delete;
|
||||||
|
auto operator=(sonus::engine const&) -> sonus::engine& = delete;
|
||||||
|
engine(sonus::engine&& other) noexcept;
|
||||||
|
auto operator=(sonus::engine&& other) noexcept -> sonus::engine&;
|
||||||
|
~engine();
|
||||||
|
|
||||||
|
auto start() -> std::expected<void, modulus::error>;
|
||||||
|
auto stop() -> void;
|
||||||
|
auto started() const -> std::bool_t;
|
||||||
|
auto sample_rate() const -> std::uint32_t;
|
||||||
|
auto channels() const -> std::uint32_t;
|
||||||
|
auto set_master_volume(std::float32_t volume) -> std::expected<void, 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_all() -> void;
|
||||||
|
|
||||||
|
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 stop_metronome() -> void;
|
||||||
|
auto metronome_active() const -> std::bool_t;
|
||||||
|
|
||||||
|
auto render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct impl;
|
||||||
|
|
||||||
|
explicit engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept;
|
||||||
|
|
||||||
|
std::unique_ptr<sonus::engine::impl> implementation_;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"package": {
|
||||||
|
"name": "sonus"
|
||||||
|
},
|
||||||
|
"tooling": {
|
||||||
|
"remote": {
|
||||||
|
"name": "khodo",
|
||||||
|
"url": "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+367
@@ -0,0 +1,367 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PACKAGE_REF = "modulus/latest"
|
||||||
|
CONAN_VERSION = "2.26.2"
|
||||||
|
FETCH_IMAGE = "modulus-fetch:dev"
|
||||||
|
COMMANDS = (
|
||||||
|
"configure",
|
||||||
|
"build",
|
||||||
|
"test",
|
||||||
|
"package",
|
||||||
|
"lsp",
|
||||||
|
)
|
||||||
|
COMMON_DIR = pathlib.Path("build") / "build_common"
|
||||||
|
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
||||||
|
RUNTIME_DIR = COMMON_DIR / "modulus"
|
||||||
|
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
||||||
|
DEFAULT_REMOTE_NAME = "khodo"
|
||||||
|
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
|
DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image"
|
||||||
|
|
||||||
|
def repo_root():
|
||||||
|
root = pathlib.Path(__file__).resolve().parent
|
||||||
|
if not (root / "modulus.json").is_file():
|
||||||
|
raise SystemExit("modulus.py must be run from a Modulus-managed repository root")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise SystemExit(f"failed to read {path}: {error}") from error
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} must contain a JSON object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def remote_config(root):
|
||||||
|
config = read_json(root / "modulus.json")
|
||||||
|
remote = config.get(
|
||||||
|
"tooling",
|
||||||
|
{},
|
||||||
|
).get("remote", {})
|
||||||
|
if not isinstance(remote, dict):
|
||||||
|
remote = {}
|
||||||
|
return (
|
||||||
|
str(remote.get("name") or DEFAULT_REMOTE_NAME),
|
||||||
|
str(remote.get("url") or DEFAULT_REMOTE_URL),
|
||||||
|
)
|
||||||
|
|
||||||
|
def runtime_ready(root):
|
||||||
|
return (
|
||||||
|
(root / TOOLS_DIR / "configure.py").is_file()
|
||||||
|
and (root / TOOLS_DIR / "lsp.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "export.py").is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
def optional_credentials():
|
||||||
|
user = os.environ.get("CONAN_USER")
|
||||||
|
token = os.environ.get("CONAN_TOKEN")
|
||||||
|
if bool(user) != bool(token):
|
||||||
|
raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset")
|
||||||
|
return user, token
|
||||||
|
|
||||||
|
def env_flag(name):
|
||||||
|
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
def docker_marker(root):
|
||||||
|
config = read_json(root / "modulus.json")
|
||||||
|
marker = config.get(
|
||||||
|
"tooling",
|
||||||
|
{},
|
||||||
|
).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER)
|
||||||
|
return pathlib.Path(str(marker))
|
||||||
|
|
||||||
|
def direct_mode(root):
|
||||||
|
return env_flag("MODULUS_DIRECT") or docker_marker(root).exists()
|
||||||
|
|
||||||
|
def run(command, input_text=None, capture=False, display_command=None, env=None, check=True):
|
||||||
|
visible = display_command if display_command is not None else command
|
||||||
|
print("+", " ".join(shlex.quote(str(part)) for part in visible))
|
||||||
|
completed = subprocess.run(
|
||||||
|
[str(part) for part in command],
|
||||||
|
input=input_text,
|
||||||
|
stdout=subprocess.PIPE if capture else None,
|
||||||
|
stderr=subprocess.PIPE if capture else None,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if check and completed.returncode != 0:
|
||||||
|
if capture and completed.stdout:
|
||||||
|
print(completed.stdout, end="")
|
||||||
|
if capture and completed.stderr:
|
||||||
|
print(completed.stderr, end="", file=sys.stderr)
|
||||||
|
raise SystemExit(completed.returncode)
|
||||||
|
return completed
|
||||||
|
|
||||||
|
def fetch_dockerfile():
|
||||||
|
return f"""FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update \\
|
||||||
|
&& apt-get install -y --no-install-recommends \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-venv \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/modulus-python
|
||||||
|
|
||||||
|
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ensure_fetch_image():
|
||||||
|
inspected = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"image",
|
||||||
|
"inspect",
|
||||||
|
FETCH_IMAGE,
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if inspected.returncode == 0:
|
||||||
|
return
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--tag",
|
||||||
|
FETCH_IMAGE,
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
input_text=fetch_dockerfile(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_container():
|
||||||
|
return pathlib.Path("/.dockerenv").exists()
|
||||||
|
|
||||||
|
def docker_workspace_args(root):
|
||||||
|
if in_container():
|
||||||
|
container = os.environ.get("HOSTNAME", "").strip()
|
||||||
|
if not container:
|
||||||
|
raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container")
|
||||||
|
return [
|
||||||
|
"--volumes-from",
|
||||||
|
container,
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
"--volume",
|
||||||
|
f"{root}:{root}",
|
||||||
|
]
|
||||||
|
|
||||||
|
def fetch_env(root):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CONAN_HOME"] = str(root / CONAN_HOME_DIR)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def run_fetch(root, command, capture=False, display_command=None, check=True):
|
||||||
|
if direct_mode(root):
|
||||||
|
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||||
|
ensure_fetch_image()
|
||||||
|
docker_command = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--interactive",
|
||||||
|
"--user",
|
||||||
|
f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--env",
|
||||||
|
"HOME=/tmp",
|
||||||
|
"--env",
|
||||||
|
"CONAN_HOME",
|
||||||
|
"--env",
|
||||||
|
"CONAN_USER",
|
||||||
|
"--env",
|
||||||
|
"CONAN_TOKEN",
|
||||||
|
*docker_workspace_args(root),
|
||||||
|
"--workdir",
|
||||||
|
str(root),
|
||||||
|
FETCH_IMAGE,
|
||||||
|
*command,
|
||||||
|
]
|
||||||
|
return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||||
|
|
||||||
|
def configure_remote(root, remote_name, remote_url):
|
||||||
|
user, token = optional_credentials()
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"-f",
|
||||||
|
remote_name,
|
||||||
|
remote_url,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if not user and not token:
|
||||||
|
return
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"login",
|
||||||
|
remote_name,
|
||||||
|
user,
|
||||||
|
"-p",
|
||||||
|
token,
|
||||||
|
],
|
||||||
|
display_command=[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"login",
|
||||||
|
remote_name,
|
||||||
|
user,
|
||||||
|
"-p",
|
||||||
|
"<hidden>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def package_refs(data):
|
||||||
|
refs = []
|
||||||
|
for location_data in data.values():
|
||||||
|
if not isinstance(location_data, dict):
|
||||||
|
continue
|
||||||
|
for ref, ref_data in location_data.items():
|
||||||
|
if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict):
|
||||||
|
continue
|
||||||
|
revisions = ref_data.get(
|
||||||
|
"revisions",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if not isinstance(revisions, dict):
|
||||||
|
continue
|
||||||
|
for recipe_revision, revision_data in revisions.items():
|
||||||
|
if not isinstance(revision_data, dict):
|
||||||
|
continue
|
||||||
|
packages = revision_data.get(
|
||||||
|
"packages",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
continue
|
||||||
|
for package_id, package_data in packages.items():
|
||||||
|
package_revisions = package_data.get(
|
||||||
|
"revisions",
|
||||||
|
{},
|
||||||
|
)if isinstance(package_data, dict) else {}
|
||||||
|
if isinstance(package_revisions, dict) and package_revisions:
|
||||||
|
for package_revision in package_revisions:
|
||||||
|
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}")
|
||||||
|
else:
|
||||||
|
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
|
||||||
|
return sorted(refs)
|
||||||
|
|
||||||
|
def remove_cached_modulus(root):
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remove",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-c",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_modulus(root, remote_name):
|
||||||
|
remove_cached_modulus(root)
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"download",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-r",
|
||||||
|
remote_name,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
completed = run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"list",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-c",
|
||||||
|
"-f",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
refs = package_refs(json.loads(completed.stdout))
|
||||||
|
if not refs:
|
||||||
|
raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache")
|
||||||
|
package_ref = refs[-1]
|
||||||
|
completed = run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"cache",
|
||||||
|
"path",
|
||||||
|
package_ref,
|
||||||
|
],
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
package_root = pathlib.Path(completed.stdout.strip())
|
||||||
|
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
|
||||||
|
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
|
||||||
|
return package_root
|
||||||
|
|
||||||
|
def materialize_runtime(root):
|
||||||
|
remote_name, remote_url = remote_config(root)
|
||||||
|
configure_remote(root, remote_name, remote_url)
|
||||||
|
package_root = download_modulus(root, remote_name)
|
||||||
|
sys.path.insert(0, str(package_root))
|
||||||
|
import modulus.export
|
||||||
|
modulus.export.sync_project(root, package_root)
|
||||||
|
|
||||||
|
def ensure_runtime(root):
|
||||||
|
if runtime_ready(root):
|
||||||
|
return
|
||||||
|
materialize_runtime(root)
|
||||||
|
|
||||||
|
def delegate(command, argv):
|
||||||
|
root = repo_root()
|
||||||
|
ensure_runtime(root)
|
||||||
|
sys.path.insert(0, str(root / RUNTIME_DIR))
|
||||||
|
import modulus.cli
|
||||||
|
previous_argv = sys.argv
|
||||||
|
previous_cwd = pathlib.Path.cwd()
|
||||||
|
sys.argv = [
|
||||||
|
str(root / "modulus.py"),
|
||||||
|
*argv,
|
||||||
|
]
|
||||||
|
os.chdir(root)
|
||||||
|
try:
|
||||||
|
return modulus.cli.main(command)
|
||||||
|
finally:
|
||||||
|
os.chdir(previous_cwd)
|
||||||
|
sys.argv = previous_argv
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if not argv or argv[0] in ("-h", "--help"):
|
||||||
|
print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...")
|
||||||
|
return 0 if argv else 2
|
||||||
|
command = argv[0]
|
||||||
|
if command not in COMMANDS:
|
||||||
|
raise SystemExit(f"unknown Modulus command: {command}")
|
||||||
|
return delegate(command, argv[1:])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#include <sonus/detail/errors.hpp>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
auto sonus::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error {
|
||||||
|
return modulus::make_error("sonus", code, std::move(message), location);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::fail_void(modulus::error error) -> std::expected<void, modulus::error> {
|
||||||
|
return std::unexpected(std::move(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::fail_void(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<void, modulus::error> {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::make_error(code, std::move(message), location));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::detail::fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error> {
|
||||||
|
assert(!result);
|
||||||
|
return sonus::detail::fail_void(result.error());
|
||||||
|
}
|
||||||
+549
@@ -0,0 +1,549 @@
|
|||||||
|
#include <sonus/engine.hpp>
|
||||||
|
|
||||||
|
#define MINIAUDIO_IMPLEMENTATION
|
||||||
|
#include <miniaudio.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <expected>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <sonus/detail/errors.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr auto pi = 3.141592653589793238462643383279502884;
|
||||||
|
|
||||||
|
auto invalid(std::string message) -> modulus::error {
|
||||||
|
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto unavailable(std::string message) -> modulus::error {
|
||||||
|
return sonus::detail::make_error(sonus::detail::error_unavailable, std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto miniaudio_error(ma_result result, std::string action) -> modulus::error {
|
||||||
|
auto const* description = ma_result_description(result);
|
||||||
|
auto message = std::move(action);
|
||||||
|
if (description != nullptr && std::string_view{description} != "No error") {
|
||||||
|
message += ": ";
|
||||||
|
message += description;
|
||||||
|
}
|
||||||
|
return unavailable(std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto valid_unit_interval(std::float32_t value) -> std::bool_t {
|
||||||
|
return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto valid_frequency(std::float32_t value) -> std::bool_t {
|
||||||
|
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct active_sound {
|
||||||
|
ma_sound sound{};
|
||||||
|
std::bool_t initialized{false};
|
||||||
|
|
||||||
|
active_sound() = default;
|
||||||
|
active_sound(active_sound const&) = delete;
|
||||||
|
auto operator=(active_sound const&) -> active_sound& = delete;
|
||||||
|
active_sound(active_sound&&) = delete;
|
||||||
|
auto operator=(active_sound&&) -> active_sound& = delete;
|
||||||
|
|
||||||
|
~active_sound() {
|
||||||
|
if (initialized) {
|
||||||
|
ma_sound_uninit(&sound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct sonus::engine::impl {
|
||||||
|
sonus::audio_config config{};
|
||||||
|
ma_engine mixer{};
|
||||||
|
ma_device device{};
|
||||||
|
std::bool_t mixer_initialized{false};
|
||||||
|
std::bool_t device_initialized{false};
|
||||||
|
std::bool_t started{false};
|
||||||
|
mutable std::mutex mutex{};
|
||||||
|
mutable std::mutex render_mutex{};
|
||||||
|
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
|
||||||
|
sonus::sound_handle next_sound_handle{1};
|
||||||
|
|
||||||
|
std::atomic_bool metronome_enabled{false};
|
||||||
|
std::atomic<std::uint32_t> metronome_generation{0};
|
||||||
|
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 beat_index{0};
|
||||||
|
std::uint64_t frames_until_next_beat{0};
|
||||||
|
std::uint32_t click_frame_index{0};
|
||||||
|
std::uint32_t click_frames_remaining{0};
|
||||||
|
std::float32_t click_frequency{1320.0F};
|
||||||
|
std::float32_t click_gain{0.0F};
|
||||||
|
|
||||||
|
impl() = default;
|
||||||
|
impl(impl const&) = delete;
|
||||||
|
auto operator=(impl const&) -> impl& = delete;
|
||||||
|
|
||||||
|
~impl() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
|
||||||
|
config = value;
|
||||||
|
|
||||||
|
auto engine_config = ma_engine_config_init();
|
||||||
|
engine_config.noDevice = MA_TRUE;
|
||||||
|
engine_config.channels = config.channels;
|
||||||
|
engine_config.sampleRate = config.sample_rate;
|
||||||
|
engine_config.noAutoStart = MA_TRUE;
|
||||||
|
|
||||||
|
auto result = ma_engine_init(&engine_config, &mixer);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio engine"));
|
||||||
|
}
|
||||||
|
mixer_initialized = true;
|
||||||
|
|
||||||
|
result = ma_engine_set_volume(&mixer, config.master_volume);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
shutdown();
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.no_device) {
|
||||||
|
auto device_config = ma_device_config_init(ma_device_type_playback);
|
||||||
|
device_config.playback.format = ma_format_f32;
|
||||||
|
device_config.playback.channels = config.channels;
|
||||||
|
device_config.sampleRate = config.sample_rate;
|
||||||
|
device_config.dataCallback = sonus::engine::impl::device_callback;
|
||||||
|
device_config.pUserData = this;
|
||||||
|
|
||||||
|
result = ma_device_init(nullptr, &device_config, &device);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
shutdown();
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device"));
|
||||||
|
}
|
||||||
|
device_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto start() -> std::expected<void, modulus::error> {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
if (started) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (config.no_device) {
|
||||||
|
started = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!device_initialized) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_unavailable, "playback device is not initialized");
|
||||||
|
}
|
||||||
|
auto result = ma_device_start(&device);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to start playback device"));
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stop_device() -> void {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
if (!started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (device_initialized) {
|
||||||
|
static_cast<void>(ma_device_stop(&device));
|
||||||
|
}
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto shutdown() -> void {
|
||||||
|
{
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
if (device_initialized) {
|
||||||
|
if (started) {
|
||||||
|
static_cast<void>(ma_device_stop(&device));
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
ma_device_uninit(&device);
|
||||||
|
device_initialized = false;
|
||||||
|
}
|
||||||
|
sounds.clear();
|
||||||
|
if (mixer_initialized) {
|
||||||
|
ma_engine_uninit(&mixer);
|
||||||
|
mixer_initialized = false;
|
||||||
|
}
|
||||||
|
metronome_enabled.store(false, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
||||||
|
auto valid = sonus::validate(sonus::audio_config{
|
||||||
|
.sample_rate = config.sample_rate,
|
||||||
|
.channels = config.channels,
|
||||||
|
.master_volume = volume,
|
||||||
|
.no_device = config.no_device,
|
||||||
|
});
|
||||||
|
if (!valid) {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
auto result = ma_engine_set_volume(&mixer, volume);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
|
||||||
|
}
|
||||||
|
config.master_volume = volume;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
|
||||||
|
if (path.empty()) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "sound path must not be empty");
|
||||||
|
}
|
||||||
|
auto valid_options = sonus::validate(options);
|
||||||
|
if (!valid_options) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(valid_options.error());
|
||||||
|
}
|
||||||
|
auto file_error = std::error_code{};
|
||||||
|
if (!std::filesystem::is_regular_file(path, file_error)) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sound = std::make_unique<active_sound>();
|
||||||
|
auto const path_text = path.string();
|
||||||
|
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
|
||||||
|
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
|
||||||
|
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
|
||||||
|
}
|
||||||
|
sound->initialized = true;
|
||||||
|
ma_sound_set_volume(&sound->sound, options.volume);
|
||||||
|
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
|
||||||
|
result = ma_sound_start(&sound->sound);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to start sound file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto handle = next_sound_handle++;
|
||||||
|
if (next_sound_handle == 0) {
|
||||||
|
next_sound_handle = 1;
|
||||||
|
}
|
||||||
|
sounds.emplace(handle, std::move(sound));
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
||||||
|
if (handle == 0) {
|
||||||
|
return sonus::detail::fail_void(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_void(sonus::detail::error_not_found, "sound handle not found");
|
||||||
|
}
|
||||||
|
static_cast<void>(ma_sound_stop(&found->second->sound));
|
||||||
|
sounds.erase(found);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stop_all_sounds() -> void {
|
||||||
|
auto lock = std::scoped_lock{mutex};
|
||||||
|
for (auto& entry : sounds) {
|
||||||
|
static_cast<void>(ma_sound_stop(&entry.second->sound));
|
||||||
|
}
|
||||||
|
sounds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
||||||
|
auto valid = sonus::validate(value);
|
||||||
|
if (!valid) {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
metronome_bpm.store(value.bpm, std::memory_order_release);
|
||||||
|
metronome_beats_per_bar.store(value.beats_per_bar, std::memory_order_release);
|
||||||
|
metronome_click_frames.store(static_cast<std::uint32_t>(click_frames), std::memory_order_release);
|
||||||
|
metronome_volume.store(value.volume, std::memory_order_release);
|
||||||
|
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) {
|
||||||
|
metronome_enabled.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
metronome_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
|
||||||
|
if (output == nullptr || frame_count == 0U) {
|
||||||
|
return MA_SUCCESS;
|
||||||
|
}
|
||||||
|
auto const sample_count = frame_count * config.channels;
|
||||||
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
|
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
mix_metronome(output, frame_count);
|
||||||
|
return MA_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count) -> void {
|
||||||
|
auto const generation = metronome_generation.load(std::memory_order_acquire);
|
||||||
|
auto const enabled = metronome_enabled.load(std::memory_order_acquire);
|
||||||
|
if (!enabled) {
|
||||||
|
observed_metronome_generation = generation;
|
||||||
|
beat_index = 0;
|
||||||
|
frames_until_next_beat = 0;
|
||||||
|
click_frame_index = 0;
|
||||||
|
click_frames_remaining = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (observed_metronome_generation != generation) {
|
||||||
|
observed_metronome_generation = generation;
|
||||||
|
beat_index = 0;
|
||||||
|
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) {
|
||||||
|
if (frames_until_next_beat == 0U) {
|
||||||
|
auto const downbeat = beat_index == 0U;
|
||||||
|
click_frame_index = 0;
|
||||||
|
click_frames_remaining = click_frames;
|
||||||
|
click_frequency = downbeat ? downbeat_frequency : beat_frequency;
|
||||||
|
click_gain = volume * (downbeat ? 1.0F : 0.64F);
|
||||||
|
beat_index = (beat_index + 1U) % beats_per_bar;
|
||||||
|
frames_until_next_beat = frames_per_beat;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sample = 0.0F;
|
||||||
|
if (click_frames_remaining > 0U) {
|
||||||
|
auto const t = static_cast<double>(click_frame_index) / static_cast<double>(config.sample_rate);
|
||||||
|
auto const progress = static_cast<double>(click_frame_index) / static_cast<double>(click_frames);
|
||||||
|
auto const envelope = (1.0 - progress) * (1.0 - progress);
|
||||||
|
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_frames_remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sample != 0.0F) {
|
||||||
|
auto* frame_out = output + frame * config.channels;
|
||||||
|
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
|
||||||
|
frame_out[channel] = std::clamp(frame_out[channel] + sample, -1.0F, 1.0F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
--frames_until_next_beat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (self == nullptr || output == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto result = self->render_to(static_cast<std::float32_t*>(output), frame_count);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
auto const sample_count = static_cast<std::uint64_t>(frame_count) * self->config.channels;
|
||||||
|
std::memset(output, 0, static_cast<std::size_t>(sample_count * sizeof(std::float32_t)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, modulus::error> {
|
||||||
|
if (config.sample_rate < 8000U || config.sample_rate > 384000U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000"));
|
||||||
|
}
|
||||||
|
if (config.channels == 0U || config.channels > 8U) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8"));
|
||||||
|
}
|
||||||
|
if (!valid_unit_interval(config.master_volume)) {
|
||||||
|
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
|
||||||
|
if (config.bpm == 0U || config.bpm > 400U) {
|
||||||
|
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
|
||||||
|
}
|
||||||
|
if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) {
|
||||||
|
return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16"));
|
||||||
|
}
|
||||||
|
if (!valid_unit_interval(config.volume)) {
|
||||||
|
return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1"));
|
||||||
|
}
|
||||||
|
if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) {
|
||||||
|
return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz"));
|
||||||
|
}
|
||||||
|
if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) {
|
||||||
|
return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds"));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::validate(sonus::playback_options const& options) -> std::expected<void, modulus::error> {
|
||||||
|
if (!valid_unit_interval(options.volume)) {
|
||||||
|
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
|
||||||
|
auto valid = sonus::validate(config);
|
||||||
|
if (!valid) {
|
||||||
|
return sonus::detail::fail<sonus::engine>(valid.error());
|
||||||
|
}
|
||||||
|
auto implementation = std::make_unique<sonus::engine::impl>();
|
||||||
|
auto initialized = implementation->initialize(config);
|
||||||
|
if (!initialized) {
|
||||||
|
return sonus::detail::fail<sonus::engine>(initialized.error());
|
||||||
|
}
|
||||||
|
return sonus::engine{std::move(implementation)};
|
||||||
|
}
|
||||||
|
|
||||||
|
sonus::engine::engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept : implementation_{std::move(implementation)} {}
|
||||||
|
|
||||||
|
sonus::engine::engine(sonus::engine&& other) noexcept = default;
|
||||||
|
|
||||||
|
auto sonus::engine::operator=(sonus::engine&& other) noexcept -> sonus::engine& = default;
|
||||||
|
|
||||||
|
sonus::engine::~engine() = default;
|
||||||
|
|
||||||
|
auto sonus::engine::start() -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::stop() -> void {
|
||||||
|
if (implementation_) {
|
||||||
|
implementation_->stop_device();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::started() const -> std::bool_t {
|
||||||
|
if (!implementation_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto lock = std::scoped_lock{implementation_->mutex};
|
||||||
|
return implementation_->started;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::sample_rate() const -> std::uint32_t {
|
||||||
|
return implementation_ ? implementation_->config.sample_rate : 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::channels() const -> std::uint32_t {
|
||||||
|
return implementation_ ? implementation_->config.channels : 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->set_master_volume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->play_file(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::stop(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->stop_sound(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::stop_all() -> void {
|
||||||
|
if (implementation_) {
|
||||||
|
implementation_->stop_all_sounds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->store_metronome(config, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
return implementation_->store_metronome(config, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::stop_metronome() -> void {
|
||||||
|
if (!implementation_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return implementation_ && implementation_->metronome_enabled.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||||
|
}
|
||||||
|
if (!implementation_->config.no_device) {
|
||||||
|
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render requires no_device audio_config");
|
||||||
|
}
|
||||||
|
if (output == nullptr) {
|
||||||
|
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};
|
||||||
|
auto result = implementation_->render_to(output, frame_count);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio"));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
add_executable(sonus-test
|
||||||
|
test.cpp
|
||||||
|
)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_include_directories(sonus-test PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
|
target_link_libraries(sonus-test PRIVATE
|
||||||
|
sonus::sonus
|
||||||
|
Threads::Threads
|
||||||
|
)
|
||||||
|
target_compile_features(sonus-test PRIVATE cxx_std_23)
|
||||||
|
add_test(NAME sonus COMMAND sonus-test)
|
||||||
|
set_tests_properties(sonus PROPERTIES TIMEOUT 20)
|
||||||
|
add_custom_target(check
|
||||||
|
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||||
|
--test-dir ${CMAKE_BINARY_DIR}
|
||||||
|
-R ^sonus$
|
||||||
|
--output-on-failure
|
||||||
|
--timeout 20
|
||||||
|
USES_TERMINAL
|
||||||
|
)
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#include <test.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <sonus.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
auto no_device_config() -> sonus::audio_config {
|
||||||
|
auto config = sonus::audio_config{};
|
||||||
|
config.no_device = true;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 const begin = begin_frame * channels;
|
||||||
|
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
||||||
|
for (auto index = begin; index < end; ++index) {
|
||||||
|
result = std::max(result, std::abs(samples[index]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||||
|
auto audio = sonus::audio_config{};
|
||||||
|
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
||||||
|
|
||||||
|
audio.sample_rate = 0;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.channels = 0;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
audio = {};
|
||||||
|
audio.master_volume = 1.5F;
|
||||||
|
BOOST_TEST(!sonus::validate(audio));
|
||||||
|
|
||||||
|
auto metronome = sonus::metronome_config{};
|
||||||
|
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
|
||||||
|
metronome.bpm = 0;
|
||||||
|
BOOST_TEST(!sonus::validate(metronome));
|
||||||
|
metronome = {};
|
||||||
|
metronome.beats_per_bar = 0;
|
||||||
|
BOOST_TEST(!sonus::validate(metronome));
|
||||||
|
metronome = {};
|
||||||
|
metronome.volume = -0.1F;
|
||||||
|
BOOST_TEST(!sonus::validate(metronome));
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
||||||
|
auto created = sonus::engine::create(no_device_config());
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(2048U * created->channels(), 1.0F);
|
||||||
|
auto rendered = created->render(samples.data(), 2048U);
|
||||||
|
BOOST_REQUIRE(rendered);
|
||||||
|
|
||||||
|
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
||||||
|
return sample == 0.0F;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
||||||
|
auto config = no_device_config();
|
||||||
|
config.sample_rate = 48000;
|
||||||
|
config.channels = 2;
|
||||||
|
auto created = sonus::engine::create(config);
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 120;
|
||||||
|
metro.beats_per_bar = 4;
|
||||||
|
metro.volume = 0.70F;
|
||||||
|
metro.click_duration = std::chrono::milliseconds{25};
|
||||||
|
BOOST_REQUIRE(created->start_metronome(metro));
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(config.sample_rate * config.channels);
|
||||||
|
BOOST_REQUIRE(created->render(samples.data(), config.sample_rate));
|
||||||
|
|
||||||
|
auto downbeat_peak = peak(samples, 0, 2400, config.channels);
|
||||||
|
auto beat_peak = peak(samples, config.sample_rate / 2, 2400, config.channels);
|
||||||
|
auto between_peak = peak(samples, config.sample_rate / 4, 2400, config.channels);
|
||||||
|
|
||||||
|
BOOST_TEST(downbeat_peak > 0.2F);
|
||||||
|
BOOST_TEST(beat_peak > 0.1F);
|
||||||
|
BOOST_TEST(downbeat_peak > beat_peak * 1.2F);
|
||||||
|
BOOST_TEST(between_peak < 0.05F);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||||
|
auto created = sonus::engine::create(no_device_config());
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto worker = [&created](std::uint32_t offset) {
|
||||||
|
for (auto index = std::uint32_t{}; index < 100U; ++index) {
|
||||||
|
auto metro = sonus::metronome_config{};
|
||||||
|
metro.bpm = 80U + ((index + offset) % 80U);
|
||||||
|
static_cast<void>(created->start_metronome(metro));
|
||||||
|
if (index % 7U == 0U) {
|
||||||
|
created->stop_metronome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto first = std::thread{worker, 0U};
|
||||||
|
auto second = std::thread{worker, 11U};
|
||||||
|
auto third = std::thread{worker, 23U};
|
||||||
|
first.join();
|
||||||
|
second.join();
|
||||||
|
third.join();
|
||||||
|
|
||||||
|
auto samples = std::vector<std::float32_t>(4096U * created->channels());
|
||||||
|
BOOST_TEST(static_cast<bool>(created->render(samples.data(), 4096U)));
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
|
||||||
|
auto created = sonus::engine::create(no_device_config());
|
||||||
|
BOOST_REQUIRE(created);
|
||||||
|
|
||||||
|
auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"};
|
||||||
|
auto played = created->play_file(missing);
|
||||||
|
BOOST_TEST(!played);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define BOOST_TEST_MODULE sonus
|
||||||
|
#include <boost/test/included/unit_test.hpp>
|
||||||
Reference in New Issue
Block a user