feat(audio-cpp): implement runtime lifecycle

Add protocol-neutral model configuration and runtime ownership against the pinned audio.cpp interfaces. Validate task capabilities, preserve the active model on replacement failures, serialize inference calls, and guarantee session-first teardown.

Assisted-by: Codex:gpt-5
This commit is contained in:
Ettore Di Giacinto
2026-07-25 07:13:24 +00:00
committed by localai-org-maint-bot
parent 034ed4223c
commit bc1965ad0b
8 changed files with 977 additions and 47 deletions

View File

@@ -15,40 +15,10 @@ set(LOCALAI_BACKEND_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto"
option(ENGINE_ENABLE_CUDA "Build audio.cpp with CUDA support" OFF)
option(ENGINE_ENABLE_VULKAN "Build audio.cpp with Vulkan support" OFF)
option(ENGINE_ENABLE_METAL "Build audio.cpp with Metal support" OFF)
option(AUDIO_CPP_BUILD_TESTS "Build LocalAI audio.cpp unit tests" OFF)
option(AUDIO_CPP_BUILD_GRPC "Build the LocalAI gRPC server" ON)
find_package(Protobuf CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)
find_program(PROTOC_EXECUTABLE NAMES protoc REQUIRED)
find_program(GRPC_CPP_PLUGIN_EXECUTABLE NAMES grpc_cpp_plugin REQUIRED)
get_filename_component(LOCALAI_BACKEND_PROTO_DIR
"${LOCALAI_BACKEND_PROTO}" DIRECTORY)
set(LOCALAI_PROTO_SOURCES
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
set(LOCALAI_PROTO_HEADERS
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
add_custom_command(
OUTPUT ${LOCALAI_PROTO_SOURCES} ${LOCALAI_PROTO_HEADERS}
COMMAND "${PROTOC_EXECUTABLE}"
ARGS
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
--grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
-I "${LOCALAI_BACKEND_PROTO_DIR}"
--plugin=protoc-gen-grpc="${GRPC_CPP_PLUGIN_EXECUTABLE}"
"${LOCALAI_BACKEND_PROTO}"
DEPENDS "${LOCALAI_BACKEND_PROTO}"
VERBATIM)
add_library(localai_backend_proto STATIC
${LOCALAI_PROTO_SOURCES}
${LOCALAI_PROTO_HEADERS})
target_include_directories(localai_backend_proto
PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(localai_backend_proto
PUBLIC protobuf::libprotobuf gRPC::grpc++)
find_package(Threads REQUIRED)
if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
message(FATAL_ERROR
@@ -56,15 +26,70 @@ if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
endif()
add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio.cpp")
# Task 2 replaces this generated entry point with the LocalAI service.
set(AUDIO_CPP_SERVER_PLACEHOLDER
"${CMAKE_CURRENT_BINARY_DIR}/audio-cpp-grpc-server-placeholder.cpp")
file(GENERATE OUTPUT "${AUDIO_CPP_SERVER_PLACEHOLDER}"
CONTENT "int main() { return 0; }\n")
add_library(localai_audio_cpp_runtime STATIC
audio_cpp_runtime.cpp
model_config.cpp)
target_include_directories(localai_audio_cpp_runtime
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(localai_audio_cpp_runtime PUBLIC engine_runtime)
add_executable(audio-cpp-grpc-server "${AUDIO_CPP_SERVER_PLACEHOLDER}")
target_link_libraries(audio-cpp-grpc-server PRIVATE
engine_runtime
localai_backend_proto
gRPC::grpc++
gRPC::grpc++_reflection)
if(AUDIO_CPP_BUILD_GRPC)
find_package(Protobuf CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)
find_program(PROTOC_EXECUTABLE NAMES protoc REQUIRED)
find_program(GRPC_CPP_PLUGIN_EXECUTABLE NAMES grpc_cpp_plugin REQUIRED)
get_filename_component(LOCALAI_BACKEND_PROTO_DIR
"${LOCALAI_BACKEND_PROTO}" DIRECTORY)
set(LOCALAI_PROTO_SOURCES
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
set(LOCALAI_PROTO_HEADERS
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h"
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
add_custom_command(
OUTPUT ${LOCALAI_PROTO_SOURCES} ${LOCALAI_PROTO_HEADERS}
COMMAND "${PROTOC_EXECUTABLE}"
ARGS
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
--grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
-I "${LOCALAI_BACKEND_PROTO_DIR}"
--plugin=protoc-gen-grpc="${GRPC_CPP_PLUGIN_EXECUTABLE}"
"${LOCALAI_BACKEND_PROTO}"
DEPENDS "${LOCALAI_BACKEND_PROTO}"
VERBATIM)
add_library(localai_backend_proto STATIC
${LOCALAI_PROTO_SOURCES}
${LOCALAI_PROTO_HEADERS})
target_include_directories(localai_backend_proto
PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(localai_backend_proto
PUBLIC protobuf::libprotobuf gRPC::grpc++)
# Task 2 replaces this generated entry point with the LocalAI service.
set(AUDIO_CPP_SERVER_PLACEHOLDER
"${CMAKE_CURRENT_BINARY_DIR}/audio-cpp-grpc-server-placeholder.cpp")
file(GENERATE OUTPUT "${AUDIO_CPP_SERVER_PLACEHOLDER}"
CONTENT "int main() { return 0; }\n")
add_executable(audio-cpp-grpc-server "${AUDIO_CPP_SERVER_PLACEHOLDER}")
target_link_libraries(audio-cpp-grpc-server PRIVATE
localai_audio_cpp_runtime
engine_runtime
localai_backend_proto
gRPC::grpc++
gRPC::grpc++_reflection)
endif()
if(AUDIO_CPP_BUILD_TESTS)
enable_testing()
add_executable(audio-cpp-runtime-test tests/runtime_test.cpp)
target_link_libraries(audio-cpp-runtime-test PRIVATE
localai_audio_cpp_runtime
Threads::Threads)
add_test(
NAME audio-cpp-runtime-test
COMMAND audio-cpp-runtime-test "${AUDIO_CPP_DIR}")
endif()

View File

@@ -24,7 +24,7 @@ else ifeq ($(UNAME_S),Darwin)
CMAKE_ARGS += -DENGINE_ENABLE_METAL=ON
endif
.PHONY: all grpc-server test clean purge
.PHONY: all grpc-server test test-unit clean purge
all: grpc-server
@@ -51,8 +51,17 @@ grpc-server: audio.cpp
test:
bash tests/build_contract_test.sh
test-unit: audio.cpp
mkdir -p $(BUILD_DIR)-unit
cd $(BUILD_DIR)-unit && cmake $(CMAKE_ARGS) \
-DAUDIO_CPP_BUILD_TESTS=ON -DAUDIO_CPP_BUILD_GRPC=OFF \
$(CURRENT_MAKEFILE_DIR)
cmake --build $(BUILD_DIR)-unit --config Release \
--target audio-cpp-runtime-test -j $(JOBS)
ctest --test-dir $(BUILD_DIR)-unit --output-on-failure
clean:
rm -rf $(BUILD_DIR) grpc-server
rm -rf $(BUILD_DIR) $(BUILD_DIR)-unit grpc-server
purge: clean
rm -rf audio.cpp

View File

@@ -0,0 +1,178 @@
// SPDX-License-Identifier: MIT
#include "audio_cpp_runtime.h"
#include <algorithm>
#include <stdexcept>
#include <string>
#include <utility>
namespace audio_cpp {
namespace {
using engine::runtime::CapabilitySet;
using engine::runtime::RunMode;
using engine::runtime::TaskSpec;
const engine::runtime::TaskCapability * find_task_capability(
const CapabilitySet & capabilities,
const TaskSpec & task) {
const auto it = std::find_if(
capabilities.supported_tasks.begin(),
capabilities.supported_tasks.end(),
[&](const engine::runtime::TaskCapability & capability) {
return capability.task == task.task;
});
return it == capabilities.supported_tasks.end() ? nullptr : &*it;
}
void validate_capability(
const engine::runtime::ILoadedVoiceModel & model,
const AudioCppModelConfig & config) {
if (model.metadata().family != config.family) {
throw std::runtime_error(
"loaded audio.cpp model family '" + model.metadata().family +
"' does not match requested family '" + config.family + "'");
}
const auto * capability = find_task_capability(
model.capabilities(),
config.task);
if (capability == nullptr) {
throw std::runtime_error(
"loaded audio.cpp model does not support requested task '" +
std::string(engine::runtime::to_string(config.task.task)) + "'");
}
if (std::find(
capability->modes.begin(),
capability->modes.end(),
config.task.mode) == capability->modes.end()) {
throw std::runtime_error(
"loaded audio.cpp model does not support requested mode '" +
std::string(engine::runtime::to_string(config.task.mode)) +
"' for task '" +
std::string(engine::runtime::to_string(config.task.task)) + "'");
}
}
void validate_session(
const engine::runtime::IVoiceTaskSession & session,
const AudioCppModelConfig & config) {
if (session.family() != config.family) {
throw std::runtime_error("audio.cpp session returned the wrong family");
}
if (session.task_kind() != config.task.task) {
throw std::runtime_error("audio.cpp session returned the wrong task");
}
if (session.run_mode() != config.task.mode) {
throw std::runtime_error("audio.cpp session returned the wrong mode");
}
if (config.task.mode == RunMode::Offline &&
dynamic_cast<const engine::runtime::IOfflineVoiceTaskSession *>(&session) == nullptr) {
throw std::runtime_error("audio.cpp session does not implement offline execution");
}
if (config.task.mode == RunMode::Streaming &&
dynamic_cast<const engine::runtime::IStreamingVoiceTaskSession *>(&session) == nullptr) {
throw std::runtime_error("audio.cpp session does not implement streaming execution");
}
}
} // namespace
AudioCppRuntime::AudioCppRuntime()
: registry_(engine::runtime::make_default_registry()) {}
AudioCppRuntime::AudioCppRuntime(engine::runtime::ModelRegistry registry)
: registry_(std::move(registry)) {}
AudioCppRuntime::~AudioCppRuntime() {
free();
}
void AudioCppRuntime::load(const AudioCppModelConfig & config) {
std::lock_guard<std::mutex> lock(mutex_);
auto candidate_model = registry_.load(config.load);
if (candidate_model == nullptr) {
throw std::runtime_error("audio.cpp registry returned a null model");
}
validate_capability(*candidate_model, config);
auto candidate_session = candidate_model->create_task_session(
config.task,
config.session);
if (candidate_session == nullptr) {
throw std::runtime_error("audio.cpp model returned a null session");
}
validate_session(*candidate_session, config);
free_locked();
model_ = std::move(candidate_model);
session_ = std::move(candidate_session);
}
void AudioCppRuntime::free() {
std::lock_guard<std::mutex> lock(mutex_);
free_locked();
}
engine::runtime::TaskResult AudioCppRuntime::run(
const engine::runtime::TaskRequest & request) {
std::lock_guard<std::mutex> lock(mutex_);
auto & session = require_session_locked();
session.prepare(engine::runtime::build_preparation_request(request));
return require_offline_locked().run(request);
}
void AudioCppRuntime::start_stream(
const engine::runtime::TaskRequest & request) {
std::lock_guard<std::mutex> lock(mutex_);
auto & session = require_session_locked();
session.prepare(engine::runtime::build_preparation_request(request));
require_streaming_locked().start_stream(request);
}
engine::runtime::StreamEvent AudioCppRuntime::process_audio_chunk(
const engine::runtime::AudioChunk & chunk) {
std::lock_guard<std::mutex> lock(mutex_);
return require_streaming_locked().process_audio_chunk(chunk);
}
engine::runtime::TaskResult AudioCppRuntime::finish_stream() {
std::lock_guard<std::mutex> lock(mutex_);
return require_streaming_locked().finish_stream();
}
engine::runtime::IVoiceTaskSession & AudioCppRuntime::require_session_locked() {
if (session_ == nullptr) {
throw std::runtime_error("audio.cpp runtime has no loaded session");
}
return *session_;
}
engine::runtime::IOfflineVoiceTaskSession &
AudioCppRuntime::require_offline_locked() {
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(
&require_session_locked());
if (offline == nullptr) {
throw std::runtime_error("loaded audio.cpp session is not offline");
}
return *offline;
}
engine::runtime::IStreamingVoiceTaskSession &
AudioCppRuntime::require_streaming_locked() {
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(
&require_session_locked());
if (streaming == nullptr) {
throw std::runtime_error("loaded audio.cpp session is not streaming");
}
return *streaming;
}
void AudioCppRuntime::free_locked() {
session_.reset();
model_.reset();
}
} // namespace audio_cpp

View File

@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "model_config.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <memory>
#include <mutex>
namespace audio_cpp {
class AudioCppRuntime {
public:
AudioCppRuntime();
explicit AudioCppRuntime(engine::runtime::ModelRegistry registry);
~AudioCppRuntime();
AudioCppRuntime(const AudioCppRuntime &) = delete;
AudioCppRuntime & operator=(const AudioCppRuntime &) = delete;
void load(const AudioCppModelConfig & config);
void free();
engine::runtime::TaskResult run(
const engine::runtime::TaskRequest & request);
void start_stream(const engine::runtime::TaskRequest & request);
engine::runtime::StreamEvent process_audio_chunk(
const engine::runtime::AudioChunk & chunk);
engine::runtime::TaskResult finish_stream();
private:
engine::runtime::IVoiceTaskSession & require_session_locked();
engine::runtime::IOfflineVoiceTaskSession & require_offline_locked();
engine::runtime::IStreamingVoiceTaskSession & require_streaming_locked();
void free_locked();
std::mutex mutex_;
engine::runtime::ModelRegistry registry_;
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model_;
std::unique_ptr<engine::runtime::IVoiceTaskSession> session_;
};
} // namespace audio_cpp

View File

@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
#include "model_config.h"
#include <limits>
#include <stdexcept>
#include <string>
namespace audio_cpp {
namespace {
using engine::core::BackendType;
using engine::runtime::RunMode;
using engine::runtime::VoiceTaskKind;
std::string require_option(
const std::unordered_map<std::string, std::string> & options,
const std::string & name) {
const auto it = options.find(name);
if (it == options.end() || it->second.empty()) {
throw std::invalid_argument("audio.cpp model config requires " + name);
}
return it->second;
}
VoiceTaskKind parse_task(const std::string & value) {
static const std::unordered_map<std::string, VoiceTaskKind> tasks = {
{"vad", VoiceTaskKind::Vad},
{"asr", VoiceTaskKind::Asr},
{"diarization", VoiceTaskKind::Diarization},
{"source-separation", VoiceTaskKind::SourceSeparation},
{"audio-generation", VoiceTaskKind::AudioGeneration},
{"tts", VoiceTaskKind::Tts},
{"voice-cloning", VoiceTaskKind::VoiceCloning},
{"voice-conversion", VoiceTaskKind::VoiceConversion},
{"speech-to-speech", VoiceTaskKind::SpeechToSpeech},
{"alignment", VoiceTaskKind::Alignment},
{"voice-design", VoiceTaskKind::VoiceDesign},
{"speaker-recognition", VoiceTaskKind::SpeakerRecognition},
{"svc", VoiceTaskKind::Svc},
};
const auto it = tasks.find(value);
if (it == tasks.end()) {
throw std::invalid_argument("unsupported audio.cpp task: " + value);
}
return it->second;
}
RunMode parse_mode(const std::string & value) {
if (value == "offline") {
return RunMode::Offline;
}
if (value == "streaming") {
return RunMode::Streaming;
}
throw std::invalid_argument("unsupported audio.cpp mode: " + value);
}
BackendType parse_backend(const std::string & value) {
if (value == "cpu") {
return BackendType::Cpu;
}
if (value == "cuda") {
return BackendType::Cuda;
}
if (value == "vulkan") {
return BackendType::Vulkan;
}
if (value == "metal") {
return BackendType::Metal;
}
if (value == "best") {
return BackendType::BestAvailable;
}
throw std::invalid_argument("unsupported audio.cpp backend: " + value);
}
int parse_integer(
const std::string & name,
const std::string & value,
int minimum) {
size_t parsed = 0;
long result = 0;
try {
result = std::stol(value, &parsed);
} catch (const std::exception &) {
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
}
if (parsed != value.size() ||
result < minimum ||
result > std::numeric_limits<int>::max()) {
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
}
return static_cast<int>(result);
}
void copy_namespaced_option(
const std::string & key,
const std::string & prefix,
const std::string & value,
std::unordered_map<std::string, std::string> & destination) {
const std::string name = key.substr(prefix.size());
if (name.empty()) {
throw std::invalid_argument("audio.cpp option namespace requires a name: " + key);
}
destination[name] = value;
}
} // namespace
AudioCppModelConfig parse_model_config(
const std::filesystem::path & model_path,
const std::unordered_map<std::string, std::string> & options) {
AudioCppModelConfig config;
config.model_path = model_path;
config.family = require_option(options, "family");
config.task.task = parse_task(require_option(options, "task"));
config.task.mode = RunMode::Offline;
config.load.model_path = model_path;
config.load.family_hint = config.family;
config.session.backend.type = BackendType::Cpu;
if (const auto it = options.find("mode"); it != options.end()) {
config.task.mode = parse_mode(it->second);
}
if (const auto it = options.find("backend"); it != options.end()) {
config.session.backend.type = parse_backend(it->second);
}
if (const auto it = options.find("device"); it != options.end()) {
config.session.backend.device = parse_integer("device", it->second, 0);
}
if (const auto it = options.find("threads"); it != options.end()) {
config.session.backend.threads = parse_integer("threads", it->second, 1);
}
if (const auto it = options.find("model_spec"); it != options.end()) {
config.load.model_spec_override = std::filesystem::path(it->second);
}
if (const auto it = options.find("config_id"); it != options.end()) {
config.load.config_id = it->second;
}
if (const auto it = options.find("weight_id"); it != options.end()) {
config.load.weight_id = it->second;
}
for (const auto & [key, value] : options) {
if (key.rfind("load.", 0) == 0) {
copy_namespaced_option(key, "load.", value, config.load.options);
} else if (key.rfind("session.", 0) == 0) {
copy_namespaced_option(key, "session.", value, config.session.options);
}
}
return config;
}
} // namespace audio_cpp

View File

@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/session.h"
#include <filesystem>
#include <string>
#include <unordered_map>
namespace audio_cpp {
struct AudioCppModelConfig {
std::filesystem::path model_path;
std::string family;
engine::runtime::TaskSpec task;
engine::runtime::ModelLoadRequest load;
engine::runtime::SessionOptions session;
};
AudioCppModelConfig parse_model_config(
const std::filesystem::path & model_path,
const std::unordered_map<std::string, std::string> & options);
} // namespace audio_cpp

View File

@@ -139,7 +139,7 @@ audio_cpp_version="$(
}
fetch_plan="${tmp_dir}/fetch-plan"
make -C "${backend_dir}" -n audio.cpp >"${fetch_plan}"
make -C "${backend_dir}" -Bn audio.cpp >"${fetch_plan}"
grep -q 'github.com/0xShug0/audio.cpp' "${fetch_plan}"
grep -q "${audio_cpp_version}" "${fetch_plan}"

View File

@@ -0,0 +1,490 @@
// SPDX-License-Identifier: MIT
#include "audio_cpp_runtime.h"
#include "model_config.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <exception>
#include <filesystem>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace {
using engine::runtime::AudioChunk;
using engine::runtime::CapabilitySet;
using engine::runtime::ILoadedVoiceModel;
using engine::runtime::IOfflineVoiceTaskSession;
using engine::runtime::IStreamingVoiceTaskSession;
using engine::runtime::IVoiceModelLoader;
using engine::runtime::IVoiceTaskSession;
using engine::runtime::ModelInspection;
using engine::runtime::ModelLoadRequest;
using engine::runtime::ModelMetadata;
using engine::runtime::RunMode;
using engine::runtime::SessionOptions;
using engine::runtime::SessionPreparationRequest;
using engine::runtime::StreamEvent;
using engine::runtime::TaskCapability;
using engine::runtime::TaskRequest;
using engine::runtime::TaskResult;
using engine::runtime::TaskSpec;
using engine::runtime::VoiceTaskKind;
void require(bool condition, const std::string & message) {
if (!condition) {
throw std::runtime_error(message);
}
}
template <typename Function>
void require_throws(Function && function, const std::string & expected) {
try {
function();
} catch (const std::exception & error) {
require(
std::string(error.what()).find(expected) != std::string::npos,
"expected error containing '" + expected + "', got '" + error.what() + "'");
return;
}
throw std::runtime_error("expected exception containing '" + expected + "'");
}
struct SessionGate {
std::mutex mutex;
std::condition_variable condition;
bool first_entered = false;
bool release_first = false;
std::atomic<int> entries{0};
};
struct FakeState {
std::mutex mutex;
std::vector<std::string> events;
CapabilitySet capabilities;
bool fail_load = false;
bool fail_session = false;
int generation = 0;
std::shared_ptr<SessionGate> gate;
void record(std::string event) {
std::lock_guard<std::mutex> lock(mutex);
events.push_back(std::move(event));
}
};
class FakeSession final
: public IOfflineVoiceTaskSession,
public IStreamingVoiceTaskSession {
public:
FakeSession(
std::shared_ptr<FakeState> state,
int generation,
TaskSpec task,
SessionOptions options)
: state_(std::move(state)),
generation_(generation),
task_(task),
options_(std::move(options)) {}
~FakeSession() override {
state_->record("session-" + std::to_string(generation_) + "-destroyed");
}
std::string family() const override { return "fake-family"; }
VoiceTaskKind task_kind() const override { return task_.task; }
RunMode run_mode() const override { return task_.mode; }
void prepare(const SessionPreparationRequest & request) override {
prepared_ = request;
}
TaskResult run(const TaskRequest &) override {
if (state_->gate != nullptr) {
const int entry = ++state_->gate->entries;
if (entry == 1) {
std::unique_lock<std::mutex> lock(state_->gate->mutex);
state_->gate->first_entered = true;
state_->gate->condition.notify_all();
state_->gate->condition.wait(
lock,
[&] { return state_->gate->release_first; });
}
}
TaskResult result;
result.text_output = engine::runtime::Transcript{
"generation-" + std::to_string(generation_),
"en",
};
return result;
}
engine::runtime::StreamingPolicy streaming_policy() const override {
engine::runtime::StreamingPolicy policy;
policy.input = engine::runtime::StreamingInputKind::AudioChunks;
policy.output = engine::runtime::StreamingOutputKind::PullEvents;
policy.preferred_audio_chunk_samples = 160;
return policy;
}
void start_stream(const TaskRequest &) override {
streaming_ = true;
}
std::optional<StreamEvent> next_stream_event() override {
return std::nullopt;
}
void set_stream_event_sink(engine::runtime::StreamEventCallback sink) override {
sink_ = std::move(sink);
}
TaskResult finish_stream() override {
streaming_ = false;
return run({});
}
void reset() override {
streaming_ = false;
}
StreamEvent process_audio_chunk(const AudioChunk & chunk) override {
require(streaming_, "stream was not started");
StreamEvent event;
event.audio_output = engine::runtime::AudioBuffer{
chunk.sample_rate,
chunk.channels,
chunk.samples,
};
if (sink_) {
sink_(event);
}
return event;
}
TaskResult finalize() override {
streaming_ = false;
return run({});
}
private:
std::shared_ptr<FakeState> state_;
int generation_;
TaskSpec task_;
SessionOptions options_;
SessionPreparationRequest prepared_;
engine::runtime::StreamEventCallback sink_;
bool streaming_ = false;
};
class FakeLoadedModel final : public ILoadedVoiceModel {
public:
FakeLoadedModel(std::shared_ptr<FakeState> state, int generation)
: state_(std::move(state)),
generation_(generation) {
metadata_.family = "fake-family";
metadata_.variant = "complete-fake";
metadata_.description = "complete test implementation";
metadata_.config_candidates = {"config.json"};
metadata_.weight_candidates = {"weights.gguf"};
}
~FakeLoadedModel() override {
state_->record("model-" + std::to_string(generation_) + "-destroyed");
}
const ModelMetadata & metadata() const noexcept override {
return metadata_;
}
const CapabilitySet & capabilities() const noexcept override {
return state_->capabilities;
}
std::unique_ptr<IVoiceTaskSession> create_task_session(
const TaskSpec & task,
const SessionOptions & options) const override {
if (state_->fail_session) {
throw std::runtime_error("session creation failed");
}
return std::make_unique<FakeSession>(state_, generation_, task, options);
}
private:
std::shared_ptr<FakeState> state_;
int generation_;
ModelMetadata metadata_;
};
class FakeLoader final : public IVoiceModelLoader {
public:
explicit FakeLoader(std::shared_ptr<FakeState> state)
: state_(std::move(state)) {}
std::string family() const override { return "fake-family"; }
bool can_load(const ModelLoadRequest & request) const override {
return request.family_hint == family();
}
ModelInspection inspect(const ModelLoadRequest & request) const override {
ModelInspection inspection;
inspection.metadata.family = family();
inspection.metadata.variant = "complete-fake";
inspection.metadata.description = "complete test loader";
inspection.metadata.config_candidates = {"config.json"};
inspection.metadata.weight_candidates = {"weights.gguf"};
inspection.capabilities = state_->capabilities;
inspection.model_root = request.model_path;
return inspection;
}
std::unique_ptr<ILoadedVoiceModel> load(
const ModelLoadRequest &) const override {
if (state_->fail_load) {
throw std::runtime_error("model load failed");
}
const int generation = ++state_->generation;
return std::make_unique<FakeLoadedModel>(state_, generation);
}
CapabilitySet advertised_capabilities() const override {
return state_->capabilities;
}
std::string advertised_instructions_policy() const override {
return "explicit";
}
std::vector<std::string> advertised_api_endpoints() const override {
return {"/v1/audio/transcriptions"};
}
private:
std::shared_ptr<FakeState> state_;
};
audio_cpp::AudioCppModelConfig offline_asr_config(
const std::filesystem::path & model_path) {
return audio_cpp::parse_model_config(
model_path,
{
{"family", "fake-family"},
{"task", "asr"},
{"mode", "offline"},
{"backend", "cpu"},
{"device", "2"},
{"threads", "3"},
{"load.cache", "memory"},
{"session.language", "en"},
});
}
std::unique_ptr<audio_cpp::AudioCppRuntime> make_runtime(
const std::shared_ptr<FakeState> & state) {
engine::runtime::ModelRegistry registry;
registry.register_loader(std::make_shared<FakeLoader>(state));
return std::make_unique<audio_cpp::AudioCppRuntime>(std::move(registry));
}
void test_model_config(const std::filesystem::path & model_path) {
const auto config = offline_asr_config(model_path);
require(config.model_path == model_path, "model path was not preserved");
require(config.family == "fake-family", "family was not parsed");
require(config.task.task == VoiceTaskKind::Asr, "task was not parsed");
require(config.task.mode == RunMode::Offline, "mode was not parsed");
require(
config.session.backend.type == engine::core::BackendType::Cpu,
"backend was not parsed");
require(config.session.backend.device == 2, "device was not parsed");
require(config.session.backend.threads == 3, "threads were not parsed");
require(config.load.options.at("cache") == "memory", "load option prefix was not stripped");
require(
config.session.options.at("language") == "en",
"session option prefix was not stripped");
require_throws(
[&] { audio_cpp::parse_model_config(model_path, {{"task", "asr"}}); },
"family");
require_throws(
[&] { audio_cpp::parse_model_config(model_path, {{"family", "fake-family"}}); },
"task");
require_throws(
[&] {
audio_cpp::parse_model_config(
model_path,
{{"family", "fake-family"}, {"task", "asr"}, {"mode", "batch"}});
},
"mode");
require_throws(
[&] {
audio_cpp::parse_model_config(
model_path,
{{"family", "fake-family"}, {"task", "asr"}, {"backend", "tpu"}});
},
"backend");
}
void test_capability_validation(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Tts, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"task");
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Streaming}},
};
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"mode");
}
void test_atomic_replacement(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
state->fail_load = true;
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"model load failed");
require(
runtime->run({}).text_output->text == "generation-1",
"old model was not retained after load failure");
state->fail_load = false;
state->fail_session = true;
require_throws(
[&] { runtime->load(offline_asr_config(model_path)); },
"session creation failed");
require(
runtime->run({}).text_output->text == "generation-1",
"old model was not retained after session creation failure");
}
void test_teardown_order(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
runtime->free();
std::lock_guard<std::mutex> lock(state->mutex);
require(state->events.size() == 2, "expected one session and one model teardown");
require(
state->events[0] == "session-1-destroyed",
"session was not destroyed before model");
require(
state->events[1] == "model-1-destroyed",
"model teardown event was not second");
}
void test_runtime_serializes_calls(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Offline}},
};
state->gate = std::make_shared<SessionGate>();
auto runtime = make_runtime(state);
runtime->load(offline_asr_config(model_path));
auto first = std::async(std::launch::async, [&] { return runtime->run({}); });
{
std::unique_lock<std::mutex> lock(state->gate->mutex);
state->gate->condition.wait(
lock,
[&] { return state->gate->first_entered; });
}
std::promise<void> release_second;
std::shared_future<void> second_barrier = release_second.get_future().share();
std::promise<void> second_attempted_promise;
auto second_attempted = second_attempted_promise.get_future();
auto second = std::async(std::launch::async, [&] {
second_barrier.wait();
second_attempted_promise.set_value();
return runtime->run({});
});
release_second.set_value();
second_attempted.wait();
require(
second.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout,
"second call completed while first call held the runtime");
require(
state->gate->entries.load() == 1,
"second call entered the upstream session concurrently");
{
std::lock_guard<std::mutex> lock(state->gate->mutex);
state->gate->release_first = true;
}
state->gate->condition.notify_all();
first.get();
second.get();
require(state->gate->entries.load() == 2, "second call never reached the session");
}
void test_streaming_surface(const std::filesystem::path & model_path) {
auto state = std::make_shared<FakeState>();
state->capabilities.supported_tasks = {
{VoiceTaskKind::Asr, {RunMode::Streaming}},
};
auto runtime = make_runtime(state);
auto config = offline_asr_config(model_path);
config.task.mode = RunMode::Streaming;
runtime->load(config);
runtime->start_stream({});
const auto event = runtime->process_audio_chunk({16000, 1, 0, {0.25f}});
require(event.audio_output.has_value(), "streaming chunk result was lost");
require(
event.audio_output->samples == std::vector<float>{0.25f},
"streaming chunk samples changed");
require(
runtime->finish_stream().text_output->text == "generation-1",
"streaming final result was lost");
}
} // namespace
int main(int argc, char ** argv) {
try {
require(argc == 2, "runtime_test requires an existing model path argument");
const std::filesystem::path model_path(argv[1]);
test_model_config(model_path);
test_capability_validation(model_path);
test_atomic_replacement(model_path);
test_teardown_order(model_path);
test_runtime_serializes_calls(model_path);
test_streaming_surface(model_path);
std::cout << "audio.cpp runtime unit tests: PASS\n";
return 0;
} catch (const std::exception & error) {
std::cerr << "audio.cpp runtime unit tests: FAIL: " << error.what() << '\n';
return 1;
}
}