diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..002929ce --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/audio.cpp-server-frontends"] + path = external/audio.cpp-server-frontends + url = git@github.com:0xShug0/audio.cpp-server-frontends.git diff --git a/CMakeLists.txt b/CMakeLists.txt index c40d50de..51840aec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,12 +100,16 @@ option(ENGINE_ENABLE_OPENMP "Build host code with OpenMP support" ON) option(AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER "Build native model-manager tools and server download/install support" OFF) +include("${CMAKE_CURRENT_SOURCE_DIR}/app/server/server_frontends.cmake") option(AUDIOCPP_USE_SYSTEM_OPENSSL "Use system OpenSSL for native model management instead of bundled BoringSSL" OFF) -if (AUDIOCPP_USE_SYSTEM_OPENSSL AND NOT AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER) +audiocpp_server_frontends_use_library(audiocpp_cpp_httplib AUDIOCPP_SERVER_FRONTENDS_USE_CPP_HTTPLIB) +if (AUDIOCPP_USE_SYSTEM_OPENSSL AND NOT AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER AND NOT AUDIOCPP_SERVER_FRONTENDS_USE_CPP_HTTPLIB) message(FATAL_ERROR - "AUDIOCPP_USE_SYSTEM_OPENSSL requires AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON") + "AUDIOCPP_USE_SYSTEM_OPENSSL requires AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON " + "or AUDIOCPP_BUILD_SERVER_FRONTENDS=ON with AUDIOCPP_SERVER_FRONTENDS_DIR set " + "and a frontend module that uses cpp-httplib") endif() option(ENGINE_ENABLE_CPU_ALL_VARIANTS "Build CPU backends as dynamic libraries with per-ISA variants (for Docker/portable builds)" @@ -1863,8 +1867,10 @@ target_include_directories(cjson_vendor PUBLIC # builds do not configure an HTTP/TLS dependency. When enabled, package specs # remain embedded so the standalone manager and managed WebUI do not require a # source checkout or an external model_specs directory. -if (AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER) +if (AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER OR AUDIOCPP_SERVER_FRONTENDS_USE_CPP_HTTPLIB) add_subdirectory(external/cpp-httplib) +endif() +if (AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER) add_library(audiocpp_package_manager STATIC src/framework/package_manager/manager.cpp src/framework/io/json.cpp @@ -2026,6 +2032,7 @@ add_executable(audiocpp_server app/server/main.cpp app/server/base64.cpp app/server/config.cpp + app/server/frontend.cpp app/server/http.cpp app/server/model_memory.cpp app/server/multipart.cpp @@ -2038,6 +2045,7 @@ add_executable(audiocpp_server ) target_link_libraries(audiocpp_server PRIVATE engine_runtime ggml) +audiocpp_configure_server_frontends(audiocpp_server) if (AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER) target_sources(audiocpp_server PRIVATE app/server/model_installer.cpp) target_link_libraries(audiocpp_server PRIVATE audiocpp_package_manager) diff --git a/app/server/README.md b/app/server/README.md index 6304b36e..73595c1d 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -23,10 +23,36 @@ Pick the mode that matches the behavior you want: | Standalone deployed binary without local `model_specs/` | `-DAUDIOCPP_DEPLOYMENT_BUILD=ON` | `audiocpp_server --config server.json` | Binary carries compiled package specs for fallback model-spec lookup. | | Offline/reproducible native-manager build | `-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON -DAUDIOCPP_BORINGSSL_ARCHIVE=/path/to/boringssl.tar.gz` | `audiocpp_server --ui --ui-management --backend ` | Configure does not fetch BoringSSL from the network. | | Distro-packaged TLS instead of bundled BoringSSL | `-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON -DAUDIOCPP_USE_SYSTEM_OPENSSL=ON` | `audiocpp_server --ui --ui-management --backend ` | Uses system OpenSSL; useful for packagers. | +| Optional in-process frontend pipeline | `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES="audio_decode;mp3_encode"` | `audiocpp_server --config server.json` | Adds compiled-in pre/post processing modules around the stable core API. The external frontend package owns modules and private dependencies such as miniaudio and libmp3lame. The default server build includes none of these modules or dependencies. | +| Optional frontend listener | `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES=` | `audiocpp_server --config server.json --frontend-listener --frontend-option key=value` | Runs a selected frontend-owned transport listener, such as HTTPS or WebSocket, over the same in-process server handler. Listener code and private dependencies live in the external frontend package. | Native model management uses bundled BoringSSL by default. Normal server builds do not build or link that HTTP/TLS dependency. +Optional frontend modules are selected at configure time with the semicolon-separated +`AUDIOCPP_SERVER_FRONTEND_MODULES` list and an external +`AUDIOCPP_SERVER_FRONTENDS_DIR` package. If you use the bundled submodule path, +fetch it before configuring: + +```bash +git submodule update --init external/audio.cpp-server-frontends +``` + +For a fresh clone, `git clone --recurse-submodules` also fetches it. + +The server runs selected modules as an ordered pipeline: every module gets a +pre-processing pass before the core handler, then every module gets a +post-processing pass after the core handler. A module that does not need one side +leaves that method empty. Each active side declares a simple contract over the +HTTP envelope state (`method`, `path`, `request_in/request_out` for +pre-processing, or `response_in/response_out` for post-processing), and module +registration rejects incompatible adjacent transforms on the same route. + +Listener frontends are selected through the same external package but are not +part of the pre/post pipeline. The server core only knows a listener name plus +string options; the external package owns listener implementations, docs, and +dependency detection. + ## Config ```bash @@ -105,6 +131,11 @@ Set per-model `"default_request_options"` to apply request-option defaults to ev Set top-level `"max_request_body_bytes"` to bound the largest HTTP request body buffered in host RAM before routing. This protects endpoints that accept JSON or audio uploads from unbounded `Content-Length` claims. The default is `2147483648` bytes (2 GiB). Raise or lower it to match the largest upload your deployment intends to accept. Values above `2^53 - 1` are rejected because this config parser stores JSON numbers as doubles. +Set top-level `"frontend_listener"` to use an optional frontend transport +listener compiled from the external frontend package. Listener-specific string +settings go under `"frontend_options"`. The equivalent command-line options are +`--frontend-listener ` and repeated `--frontend-option key=value`. + Set top-level `"log_request_body": true` and start the server with `--log` to print full JSON request bodies for debugging. This is off by default, and both switches are required so prompt text, paths, and request options are not logged accidentally. Audio bodies are not printed; multipart uploads log filename and byte count, while raw or live/chunked audio requests log only route, content type, query, and size/stream metadata. ### Experimental CORS @@ -319,7 +350,7 @@ curl http://127.0.0.1:8080/v1/audio/speech \ }' ``` -Set `"response_format": "json"` to receive base64 WAV in a JSON response. +Set `"response_format": "json"` to receive base64 WAV in a JSON response. In builds configured with `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES=mp3_encode`, `"response_format": "mp3"` returns `audio/mpeg` MP3 bytes for non-streaming speech requests. For streaming-capable TTS models configured with `mode: "streaming"`, `stream_format` follows the OpenAI speech streaming shape: @@ -344,7 +375,7 @@ The SSE stream emits `speech.audio.delta` events with base64 PCM chunks, then `s ### `POST /v1/audio/transcriptions` -JSON transcription request using a server-local audio path. +JSON transcription request using a server-local WAV audio path. ```bash curl http://127.0.0.1:8080/v1/audio/transcriptions \ @@ -364,7 +395,7 @@ curl http://127.0.0.1:8080/v1/audio/transcriptions \ -F file=@/path/to/input.wav ``` -`file` and `model` are required; `language` is optional. Uploaded WAV bytes are decoded in memory and are not written to a temporary file. +`file` and `model` are required; `language` is optional. Uploaded WAV bytes are decoded in memory and are not written to a temporary file. In builds configured with `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES=audio_decode`, the frontend also accepts MP3 and FLAC input for this route, decodes it to a temporary WAV, and forwards that normalized request to the same core transcription handler. For streaming-capable ASR models configured with `mode: "streaming"`, pass `stream=true` to receive OpenAI-style transcription SSE: diff --git a/app/server/config.cpp b/app/server/config.cpp index 1199dd15..8b9f254e 100644 --- a/app/server/config.cpp +++ b/app/server/config.cpp @@ -249,6 +249,18 @@ ServerConfig load_server_config(const std::filesystem::path & path) { } config.voice_dir = resolve_path(base, value->as_string()); } + config.frontend_listener = engine::io::json::optional_string(root, "frontend_listener", config.frontend_listener); + if (const auto * value = root.find("frontend_options")) { + if (!value->is_object()) { + throw std::runtime_error("server frontend_options must be an object"); + } + for (const auto & [key, option] : value->as_object()) { + if (!option.is_string()) { + throw std::runtime_error("server frontend_options values must be strings"); + } + config.frontend_options[key] = option.as_string(); + } + } if (config.port <= 0 || config.port > 65535) { throw std::runtime_error("server port must be in 1..65535"); } @@ -264,6 +276,9 @@ ServerConfig load_server_config(const std::filesystem::path & path) { if (config.min_free_memory_mb < 0) { throw std::runtime_error("server min_free_memory_mb must be >= 0 (0 disables the memory guard)"); } + if (config.frontend_listener.empty() && !config.frontend_options.empty()) { + throw std::runtime_error("server frontend_options requires frontend_listener"); + } if (config.threads <= 0) { throw std::runtime_error("server threads must be positive"); } diff --git a/app/server/config.h b/app/server/config.h index 43f83f02..b27d6d0e 100644 --- a/app/server/config.h +++ b/app/server/config.h @@ -9,6 +9,7 @@ #include "engine/framework/core/backend.h" +#include "frontend.h" #include "http.h" namespace minitts::server { @@ -105,6 +106,8 @@ struct ServerConfig { // Fleet-wide bounds for incrementally delivered request bodies. The defaults are // in LiveIngestLimits; a model entry may override any subset of them. LiveIngestLimits live_ingest; + std::string frontend_listener; + ServerFrontendOptions frontend_options; std::optional model_spec_override; // Voice library shared across all TTS models: *.wav files plus a `prompt_text` // mapping file (|). A request `voice` name that is not a diff --git a/app/server/frontend.cpp b/app/server/frontend.cpp new file mode 100644 index 00000000..dc829b71 --- /dev/null +++ b/app/server/frontend.cpp @@ -0,0 +1,189 @@ +#include "frontend.h" + +#include "engine/framework/debug/trace.h" + +#include +#include +#include + +namespace minitts::server { + +namespace { + +bool contract_value_matches(std::string_view lhs, std::string_view rhs) { + return lhs == frontend_contracts::any || rhs == frontend_contracts::any || lhs == rhs; +} + +bool contract_route_overlaps(std::string_view lhs, std::string_view rhs) { + return contract_value_matches(lhs, rhs); +} + +bool validate_pre_contracts( + std::string_view previous_module, + const FrontendPreContract & previous, + std::string_view next_module, + const FrontendPreContract & next) { + if (!contract_route_overlaps(previous.method, next.method) || + !contract_route_overlaps(previous.path, next.path)) { + return false; + } + if (!contract_value_matches(previous.request_out, next.request_in)) { + throw std::runtime_error( + "incompatible frontend pre-processing order: " + std::string(previous_module) + + " outputs request state '" + std::string(previous.request_out) + + "', but " + std::string(next_module) + + " expects '" + std::string(next.request_in) + "'"); + } + return true; +} + +bool validate_post_contracts( + std::string_view previous_module, + const FrontendPostContract & previous, + std::string_view next_module, + const FrontendPostContract & next) { + if (!contract_route_overlaps(previous.method, next.method) || + !contract_route_overlaps(previous.path, next.path)) { + return false; + } + if (!contract_value_matches(previous.response_out, next.response_in)) { + throw std::runtime_error( + "incompatible frontend post-processing order: " + std::string(previous_module) + + " outputs response state '" + std::string(previous.response_out) + + "', but " + std::string(next_module) + + " expects '" + std::string(next.response_in) + "'"); + } + return true; +} + +} // namespace + +#include "server_frontend_module_declarations.inc" + +std::optional ServerFrontendModule::pre_contract() const { + return std::nullopt; +} + +std::optional ServerFrontendModule::post_contract() const { + return std::nullopt; +} + +void ServerFrontendModule::pre_process(ServerFrontendContext & context, ServerFrontendRequest & request) { + (void) context; + (void) request; +} + +void ServerFrontendModule::post_process(ServerFrontendContext & context, ServerFrontendResponse & response) { + (void) context; + (void) response; +} + +void ServerFrontendRegistry::add(ServerFrontendModuleFactory factory) { + if (factory == nullptr) { + throw std::runtime_error("server frontend registration requires a module factory"); + } + auto module = factory(); + if (!module) { + throw std::runtime_error("server frontend module factory returned null"); + } + + const auto pre = module->pre_contract(); + const auto post = module->post_contract(); + for (auto it = modules_.rbegin(); it != modules_.rend(); ++it) { + if (pre.has_value()) { + if (const auto previous = (*it)->pre_contract()) { + if (validate_pre_contracts((*it)->name(), *previous, module->name(), *pre)) { + break; + } + } + } + } + for (auto it = modules_.rbegin(); it != modules_.rend(); ++it) { + if (post.has_value()) { + if (const auto previous = (*it)->post_contract()) { + if (validate_post_contracts((*it)->name(), *previous, module->name(), *post)) { + break; + } + } + } + } + + modules_.push_back(std::move(module)); +} + +void ServerFrontendRegistry::add_listener(std::string name, ServerFrontendListenerFactory factory) { + if (name.empty()) { + throw std::runtime_error("server frontend listener registration requires a name"); + } + if (factory == nullptr) { + throw std::runtime_error("server frontend listener registration requires a factory"); + } + if (!listeners_.emplace(std::move(name), factory).second) { + throw std::runtime_error("duplicate server frontend listener registration"); + } +} + +bool ServerFrontendRegistry::empty() const { return modules_.empty(); } + +HttpResponse ServerFrontendRegistry::handle(ServerFrontendContext & context, const HttpRequest & request) const { + ServerFrontendRequest frontend_request{request, std::nullopt}; + if (engine::debug::log_enabled()) { + const auto content_type = request.headers.find("content-type"); + engine::debug::log_message( + "[SERVER_FRONTEND_DEBUG] frontend.enter method=" + request.method + + " path=" + request.path + + " content_type=" + + (content_type == request.headers.end() ? std::string("") : content_type->second) + + " body_bytes=" + std::to_string(request.body.size())); + } + for (const auto & module : modules_) { + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_FRONTEND_DEBUG] frontend.pre.begin module=" + std::string(module->name()) + + " path=" + frontend_request.request.path + + " body_bytes=" + std::to_string(frontend_request.request.body.size())); + } + module->pre_process(context, frontend_request); + if (engine::debug::log_enabled()) { + const auto content_type = frontend_request.request.headers.find("content-type"); + engine::debug::log_message( + "[SERVER_FRONTEND_DEBUG] frontend.pre.end module=" + std::string(module->name()) + + " path=" + frontend_request.request.path + + " content_type=" + + (content_type == frontend_request.request.headers.end() ? std::string("") : content_type->second) + + " body_bytes=" + std::to_string(frontend_request.request.body.size()) + + " short_circuit=" + (frontend_request.response.has_value() ? "true" : "false")); + } + if (frontend_request.response.has_value()) { + return std::move(*frontend_request.response); + } + } + + auto core_response = context.forward_to_core(frontend_request.request); + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_FRONTEND_DEBUG] frontend.core_response status=" + std::to_string(core_response.status) + + " content_type=" + core_response.content_type + + " body_bytes=" + std::to_string(core_response.body.size())); + } + ServerFrontendResponse frontend_response{request, frontend_request.request, std::move(core_response)}; + for (const auto & module : modules_) { + module->post_process(context, frontend_response); + } + return std::move(frontend_response.response); +} + +std::unique_ptr ServerFrontendRegistry::make_listener(std::string_view name) const { + const auto it = listeners_.find(std::string(name)); + if (it == listeners_.end()) { + throw std::runtime_error("server frontend listener is not available in this build: " + std::string(name)); + } + return it->second(); +} + +void register_static_server_frontends(ServerFrontendRegistry & registry) { + (void) registry; +#include "server_frontend_module_registrations.inc" +} + +} // namespace minitts::server diff --git a/app/server/frontend.h b/app/server/frontend.h new file mode 100644 index 00000000..00228112 --- /dev/null +++ b/app/server/frontend.h @@ -0,0 +1,105 @@ +#pragma once + +#include "http.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace minitts::server { + +class ServerFrontendContext { +public: + virtual ~ServerFrontendContext() = default; + + virtual HttpResponse forward_to_core(const HttpRequest & request) = 0; + virtual std::filesystem::path resolve_request_path(const std::filesystem::path & path) const = 0; + virtual std::filesystem::path make_frontend_temp_path(std::string_view filename) = 0; +}; + +struct ServerFrontendRequest { + HttpRequest request; + std::optional response; +}; + +struct ServerFrontendResponse { + const HttpRequest & original_request; + const HttpRequest & core_request; + HttpResponse response; +}; + +struct FrontendPreContract { + std::string_view method; + std::string_view path; + std::string_view request_in; + std::string_view request_out; +}; + +struct FrontendPostContract { + std::string_view method; + std::string_view path; + std::string_view response_in; + std::string_view response_out; +}; + +namespace frontend_contracts { +inline constexpr std::string_view any = "*"; +inline constexpr std::string_view client_encoded_audio_request = "client_encoded_audio_request"; +inline constexpr std::string_view core_wav_audio_request = "core_wav_audio_request"; +inline constexpr std::string_view client_mp3_speech_request = "client_mp3_speech_request"; +inline constexpr std::string_view core_wav_speech_request = "core_wav_speech_request"; +inline constexpr std::string_view core_wav_speech_response = "core_wav_speech_response"; +inline constexpr std::string_view client_mp3_speech_response = "client_mp3_speech_response"; +} // namespace frontend_contracts + +class ServerFrontendModule { +public: + virtual ~ServerFrontendModule() = default; + + virtual std::string_view name() const = 0; + virtual std::optional pre_contract() const; + virtual std::optional post_contract() const; + virtual void pre_process(ServerFrontendContext & context, ServerFrontendRequest & request); + virtual void post_process(ServerFrontendContext & context, ServerFrontendResponse & response); +}; + +using ServerFrontendModuleFactory = std::unique_ptr (*)(); + +using ServerFrontendOptions = std::unordered_map; + +class ServerFrontendListener { +public: + virtual ~ServerFrontendListener() = default; + virtual std::string_view name() const = 0; + virtual void serve( + const std::string & host, + int port, + IHttpHandler & handler, + ShutdownRequested shutdown_requested, + uint64_t max_request_body_bytes, + const ServerFrontendOptions & options) = 0; +}; + +using ServerFrontendListenerFactory = std::unique_ptr (*)(); + +class ServerFrontendRegistry { +public: + void add(ServerFrontendModuleFactory factory); + void add_listener(std::string name, ServerFrontendListenerFactory factory); + bool empty() const; + HttpResponse handle(ServerFrontendContext & context, const HttpRequest & request) const; + std::unique_ptr make_listener(std::string_view name) const; + +private: + std::vector> modules_; + std::unordered_map listeners_; +}; + +void register_static_server_frontends(ServerFrontendRegistry & registry); + +} // namespace minitts::server diff --git a/app/server/http.cpp b/app/server/http.cpp index 7e13aea2..a6f6d5c1 100644 --- a/app/server/http.cpp +++ b/app/server/http.cpp @@ -1,5 +1,6 @@ #include "http.h" +#include "engine/framework/debug/trace.h" #include "engine/framework/io/json.h" #include @@ -601,9 +602,26 @@ HttpRequest read_http_request( const bool chunked_body = transfer_encoding_it != request.headers.end() && is_chunked_only(transfer_encoding_it->second); + if (engine::debug::log_enabled()) { + const auto content_length_it = request.headers.find("content-length"); + engine::debug::log_message( + "[SERVER_HTTP_DEBUG] http.headers method=" + request.method + + " path=" + request.path + + " content_length=" + + (content_length_it == request.headers.end() ? std::string("") : content_length_it->second) + + " transfer_encoding=" + + (transfer_encoding_it == request.headers.end() ? std::string("") : transfer_encoding_it->second) + + " incremental=" + (wants_incremental_body(request) ? "true" : "false") + + " prefetched_body_bytes=" + std::to_string(data.size() - header_end - 4)); + } if (wants_incremental_body(request)) { leftover = data.substr(header_end + 4); + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_HTTP_DEBUG] http.body_deferred path=" + request.path + + " leftover_bytes=" + std::to_string(leftover.size())); + } return request; } @@ -665,6 +683,11 @@ HttpRequest read_http_request( if (request.body.size() > content_length) { request.body.resize(content_length); } + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_HTTP_DEBUG] http.body_ready path=" + request.path + + " body_bytes=" + std::to_string(request.body.size())); + } return request; } @@ -799,6 +822,9 @@ void handle_client(SocketHandle client, IHttpHandler & handler, uint64_t max_req send_all(socket.get(), serialize_response(response)); } } catch (const std::exception & ex) { + if (engine::debug::log_enabled()) { + engine::debug::log_message(std::string("[SERVER_HTTP_DEBUG] http.error ") + ex.what()); + } try { send_all(socket.get(), serialize_response(error_response(500, ex.what(), "server_error"))); } catch (const std::exception & send_error) { diff --git a/app/server/main.cpp b/app/server/main.cpp index bc4287b7..918be43d 100644 --- a/app/server/main.cpp +++ b/app/server/main.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include namespace { @@ -44,6 +46,25 @@ bool has_arg(int argc, char ** argv, const std::string & name) { return false; } +std::vector arg_values(int argc, char ** argv, const std::string & name) { + std::vector out; + for (int i = 1; i + 1 < argc; ++i) { + if (argv[i] == name) { + out.emplace_back(argv[i + 1]); + ++i; + } + } + return out; +} + +std::pair parse_key_value_arg(const std::string & text, const std::string & name) { + const auto eq = text.find('='); + if (eq == std::string::npos || eq == 0) { + throw std::runtime_error(name + " must be key=value"); + } + return {text.substr(0, eq), text.substr(eq + 1)}; +} + std::filesystem::path executable_directory(const char * argv0) { if (argv0 == nullptr || *argv0 == '\0') { return std::filesystem::current_path(); @@ -66,6 +87,7 @@ void print_help() { << " [--device ] [--list-devices] [--threads ] [--busy-timeout-ms ]\n" << " [--max-loaded-models ] [--idle-unload-ms ] [--min-free-memory-mb ]\n" << " [--model-spec-override ] [--voice-dir ]\n" + << " [--frontend-listener ] [--frontend-option key=value]\n" << " [--log] [--log-file ]\n" << " [--cors-origins ]\n" << " --version print build version, commit, compiler, platform, and enabled backends\n" @@ -89,6 +111,8 @@ void print_help() { << " least this many MiB free after the load; default 0\n" << " (guard disabled)\n" << " --voice-dir override the shared reference voice library directory\n" + << " --frontend-listener opt-in frontend transport listener, e.g. https or websocket\n" + << " --frontend-option key=value listener-specific option; may be repeated\n" << " --cors-origins \"*\" experimental; disabled by default. Allows browser\n" << " requests from any origin for trusted local demos only\n" << "\n" @@ -217,6 +241,16 @@ int main(int argc, char ** argv) { if (const auto voice_dir = arg_value(argc, argv, "--voice-dir")) { config.voice_dir = std::filesystem::path(*voice_dir); } + if (const auto frontend_listener = arg_value(argc, argv, "--frontend-listener")) { + config.frontend_listener = *frontend_listener; + } + for (const auto & option : arg_values(argc, argv, "--frontend-option")) { + auto [key, value] = parse_key_value_arg(option, "--frontend-option"); + config.frontend_options[std::move(key)] = std::move(value); + } + if (config.frontend_listener.empty() && !config.frontend_options.empty()) { + throw std::runtime_error("--frontend-option requires --frontend-listener"); + } if (!(config.cors_origins == "*" || config.cors_origins == "")) { throw std::runtime_error("--cors-origins must be '*' (allow all origins) or '' (disabled)"); } @@ -241,7 +275,23 @@ int main(int argc, char ** argv) { config, std::filesystem::current_path(), ui_resource_anchor); - minitts::server::serve_http(config.host, config.port, state, shutdown_requested, config.max_request_body_bytes); + if (!config.frontend_listener.empty()) { + auto listener = state.make_frontend_listener(config.frontend_listener); + listener->serve( + config.host, + config.port, + state, + shutdown_requested, + config.max_request_body_bytes, + config.frontend_options); + } else { + minitts::server::serve_http( + config.host, + config.port, + state, + shutdown_requested, + config.max_request_body_bytes); + } return 0; } catch (const std::exception & ex) { std::cerr << "audiocpp_server failed: " << ex.what() << "\n"; diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 2769cc0c..9aea4004 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -1058,6 +1058,7 @@ ServerState::ServerState( models_root_); } #endif + register_static_server_frontends(frontends_); load_models(); if (config_.idle_unload_ms > 0) { idle_unload_thread_ = std::thread(&ServerState::idle_unload_loop, this); @@ -1076,6 +1077,35 @@ ServerState::~ServerState() { } HttpResponse ServerState::handle(const HttpRequest & request) { + return handle_request(request, true); +} + +HttpResponse ServerState::forward_to_core(const HttpRequest & request) { + return handle_request(request, false); +} + +std::filesystem::path ServerState::resolve_request_path(const std::filesystem::path & path) const { + return resolve_path(request_base_, path); +} + +std::filesystem::path ServerState::make_frontend_temp_path(std::string_view filename) { + std::lock_guard lock(upload_root_mutex_); + if (upload_root_.empty()) { + upload_root_ = std::filesystem::temp_directory_path() / + ("audiocpp-server-" + std::to_string( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + std::filesystem::create_directories(upload_root_); + } + const auto id = next_upload_id_.fetch_add(1); + return upload_root_ / (std::to_string(id) + "-" + safe_upload_name(std::string(filename))); +} + +std::unique_ptr ServerState::make_frontend_listener(std::string_view name) const { + return frontends_.make_listener(name); +} + +HttpResponse ServerState::handle_request(const HttpRequest & request, bool use_frontends) { HttpResponse response; const std::string allowed_origin = get_allowed_origin(request); try { @@ -1086,6 +1116,9 @@ HttpResponse ServerState::handle(const HttpRequest & request) { response.headers["Access-Control-Allow-Headers"] = "*"; response.headers["Access-Control-Allow-Methods"] = "GET, POST"; } + else if (use_frontends && !frontends_.empty()) { + response = frontends_.handle(*this, request); + } else if (request.method == "GET" && (request.path == "/" || request.path == "/index.html")) { response = handle_ui_asset(); } @@ -2523,9 +2556,20 @@ HttpResponse ServerState::handle_transcription(const HttpRequest & request, bool if (const auto it = request.headers.find("content-type"); it != request.headers.end()) { content_type = it->second; } + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_TRANSCRIPTION_DEBUG] core.transcription.enter content_type=" + content_type + + " body_bytes=" + std::to_string(request.body.size())); + } if (const auto boundary = extract_multipart_boundary(content_type)) { + if (engine::debug::log_enabled()) { + engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.transcription.route multipart"); + } return handle_transcription_multipart(request.body, *boundary, detail); } + if (engine::debug::log_enabled()) { + engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.transcription.route json"); + } return handle_transcription_json(request.body, detail); } @@ -2552,6 +2596,11 @@ HttpResponse ServerState::handle_transcription_json(const std::string & body_tex HttpResponse ServerState::handle_transcription_multipart( const std::string & body_text, const std::string & boundary, bool detail) { const auto parts = parse_multipart_body(body_text, boundary); + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_TRANSCRIPTION_DEBUG] core.multipart.parts count=" + std::to_string(parts.size()) + + " body_bytes=" + std::to_string(body_text.size())); + } log_multipart_request_summary_if_enabled(config_, parts); const MultipartPart * file_part = nullptr; @@ -2592,12 +2641,20 @@ HttpResponse ServerState::handle_transcription_multipart( } } if (file_part == nullptr || file_part->data.empty()) { + if (engine::debug::log_enabled()) { + engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.multipart.missing_file"); + } throw std::runtime_error("multipart transcription request requires a non-empty 'file' field"); } if (model_id.empty()) { throw std::runtime_error("multipart transcription request requires a 'model' field"); } if (!is_wav_upload_filename(file_part->filename)) { + if (engine::debug::log_enabled()) { + engine::debug::log_message( + "[SERVER_TRANSCRIPTION_DEBUG] core.multipart.reject_non_wav filename=" + file_part->filename + + " bytes=" + std::to_string(file_part->data.size())); + } return error_response( 400, "only WAV audio uploads are currently supported for transcription; MP3 support is planned", diff --git a/app/server/runtime.h b/app/server/runtime.h index 229b27be..5efc4de6 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -2,6 +2,7 @@ #include "busy_guard.h" #include "config.h" +#include "frontend.h" #include "http.h" #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) #include "model_installer.h" @@ -27,7 +28,7 @@ namespace minitts::server { -class ServerState final : public IHttpHandler { +class ServerState final : public IHttpHandler, public ServerFrontendContext { public: ServerState( ServerConfig config, @@ -36,6 +37,10 @@ class ServerState final : public IHttpHandler { ~ServerState() override; HttpResponse handle(const HttpRequest & request) override; + HttpResponse forward_to_core(const HttpRequest & request) override; + std::filesystem::path resolve_request_path(const std::filesystem::path & path) const override; + std::filesystem::path make_frontend_temp_path(std::string_view filename) override; + std::unique_ptr make_frontend_listener(std::string_view name) const; // Server-level `live_ingest` policy with this request's model override applied. // Deliberately does not reject an unknown or non-streaming model: it runs before @@ -95,6 +100,7 @@ class ServerState final : public IHttpHandler { engine::runtime::RunMode model_run_mode(const LoadedModel & model) const; void load_models(); + HttpResponse handle_request(const HttpRequest & request, bool use_frontends); std::unique_ptr make_model(ServerModelConfig config); // Recompute the per-model, config-derived request-option flags (currently // accepts_reference_text). Called at registration and on reconfiguration. @@ -217,6 +223,7 @@ class ServerState final : public IHttpHandler { // unrelated first loads stay concurrent there. std::mutex model_load_mutex_; std::filesystem::path upload_root_; + std::mutex upload_root_mutex_; std::filesystem::path repository_root_; #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) std::filesystem::path default_models_root_; @@ -231,6 +238,7 @@ class ServerState final : public IHttpHandler { std::atomic last_activity_ms_{0}; std::atomic idle_unload_shutdown_{false}; std::thread idle_unload_thread_; + ServerFrontendRegistry frontends_; }; } // namespace minitts::server diff --git a/app/server/server_frontends.cmake b/app/server/server_frontends.cmake new file mode 100644 index 00000000..85d6bf6c --- /dev/null +++ b/app/server/server_frontends.cmake @@ -0,0 +1,193 @@ +option(AUDIOCPP_BUILD_SERVER_FRONTENDS + "Build optional in-process audiocpp_server frontend adapters" + OFF) +set(AUDIOCPP_SERVER_FRONTEND_MODULES "" + CACHE STRING "Semicolon-separated optional audiocpp_server frontend modules to build") +set(AUDIOCPP_SERVER_FRONTENDS_DIR "" + CACHE PATH "Optional external server frontend module package") + +if (NOT AUDIOCPP_BUILD_SERVER_FRONTENDS AND AUDIOCPP_SERVER_FRONTEND_MODULES) + message(FATAL_ERROR + "AUDIOCPP_SERVER_FRONTEND_MODULES requires AUDIOCPP_BUILD_SERVER_FRONTENDS=ON") +endif() + +function(audiocpp_register_server_frontend AUDIOCPP_SERVER_FRONTEND_NAME) + set(options) + set(one_value_args REGISTER_FUNCTION) + set(multi_value_args SOURCES INCLUDE_DIRS LIBRARIES COMPILE_DEFINITIONS) + cmake_parse_arguments( + AUDIOCPP_REGISTERED_FRONTEND + "${options}" + "${one_value_args}" + "${multi_value_args}" + ${ARGN}) + + set_property(GLOBAL APPEND PROPERTY + "AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES" + "${AUDIOCPP_SERVER_FRONTEND_NAME}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_REGISTER_FUNCTION" + "${AUDIOCPP_REGISTERED_FRONTEND_REGISTER_FUNCTION}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_SOURCES" + "${AUDIOCPP_REGISTERED_FRONTEND_SOURCES}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_INCLUDE_DIRS" + "${AUDIOCPP_REGISTERED_FRONTEND_INCLUDE_DIRS}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_LIBRARIES" + "${AUDIOCPP_REGISTERED_FRONTEND_LIBRARIES}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_COMPILE_DEFINITIONS" + "${AUDIOCPP_REGISTERED_FRONTEND_COMPILE_DEFINITIONS}") +endfunction() + +function(audiocpp_collect_server_frontends) + get_property(AUDIOCPP_SERVER_FRONTENDS_ALREADY_COLLECTED GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTENDS_COLLECTED" SET) + if (AUDIOCPP_SERVER_FRONTENDS_ALREADY_COLLECTED) + return() + endif() + set_property(GLOBAL PROPERTY "AUDIOCPP_SERVER_FRONTENDS_COLLECTED" TRUE) + + set(AUDIOCPP_SERVER_FRONTEND_DECLARATIONS "") + set(AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS "") + set(AUDIOCPP_SERVER_FRONTEND_SOURCES "") + set(AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS "") + set(AUDIOCPP_SERVER_FRONTEND_LIBRARIES "") + set(AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS "") + + if (AUDIOCPP_BUILD_SERVER_FRONTENDS AND AUDIOCPP_SERVER_FRONTEND_MODULES) + if (NOT AUDIOCPP_SERVER_FRONTENDS_DIR) + message(FATAL_ERROR + "AUDIOCPP_BUILD_SERVER_FRONTENDS=ON requires AUDIOCPP_SERVER_FRONTENDS_DIR=") + endif() + get_filename_component( + AUDIOCPP_SERVER_FRONTENDS_DIR_ABS + "${AUDIOCPP_SERVER_FRONTENDS_DIR}" + ABSOLUTE + BASE_DIR "${PROJECT_SOURCE_DIR}") + if (NOT EXISTS "${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}/server_frontends.cmake") + message(FATAL_ERROR + "AUDIOCPP_SERVER_FRONTENDS_DIR must contain server_frontends.cmake: ${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}") + endif() + + set_property(GLOBAL PROPERTY "AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES" "") + include("${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}/server_frontends.cmake") + get_property(AUDIOCPP_REGISTERED_SERVER_FRONTENDS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES") + + foreach(AUDIOCPP_SERVER_FRONTEND_MODULE IN LISTS AUDIOCPP_SERVER_FRONTEND_MODULES) + list(FIND AUDIOCPP_REGISTERED_SERVER_FRONTENDS "${AUDIOCPP_SERVER_FRONTEND_MODULE}" AUDIOCPP_SERVER_FRONTEND_INDEX) + if (AUDIOCPP_SERVER_FRONTEND_INDEX EQUAL -1) + message(FATAL_ERROR + "External server frontend package did not register requested module: ${AUDIOCPP_SERVER_FRONTEND_MODULE}") + endif() + + get_property(AUDIOCPP_FRONTEND_REGISTER_FUNCTION GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_REGISTER_FUNCTION") + get_property(AUDIOCPP_FRONTEND_SOURCES GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_SOURCES") + get_property(AUDIOCPP_FRONTEND_INCLUDE_DIRS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_INCLUDE_DIRS") + get_property(AUDIOCPP_FRONTEND_LIBRARIES GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_LIBRARIES") + get_property(AUDIOCPP_FRONTEND_COMPILE_DEFINITIONS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_COMPILE_DEFINITIONS") + + if (AUDIOCPP_FRONTEND_REGISTER_FUNCTION) + string(APPEND AUDIOCPP_SERVER_FRONTEND_DECLARATIONS + "void ${AUDIOCPP_FRONTEND_REGISTER_FUNCTION}(ServerFrontendRegistry & registry);\n") + string(APPEND AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS + " ${AUDIOCPP_FRONTEND_REGISTER_FUNCTION}(registry);\n") + endif() + list(APPEND AUDIOCPP_SERVER_FRONTEND_SOURCES ${AUDIOCPP_FRONTEND_SOURCES}) + list(APPEND AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS ${AUDIOCPP_FRONTEND_INCLUDE_DIRS}) + list(APPEND AUDIOCPP_SERVER_FRONTEND_LIBRARIES ${AUDIOCPP_FRONTEND_LIBRARIES}) + list(APPEND AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS ${AUDIOCPP_FRONTEND_COMPILE_DEFINITIONS}) + endforeach() + endif() + + if (AUDIOCPP_SERVER_FRONTEND_SOURCES) + list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS) + list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_LIBRARIES) + list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS) + endif() + + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_DECLARATIONS" + "${AUDIOCPP_SERVER_FRONTEND_DECLARATIONS}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_REGISTRATIONS" + "${AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_SOURCES" + "${AUDIOCPP_SERVER_FRONTEND_SOURCES}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_INCLUDE_DIRS" + "${AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_LIBRARIES" + "${AUDIOCPP_SERVER_FRONTEND_LIBRARIES}") + set_property(GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_COMPILE_DEFINITIONS" + "${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS}") +endfunction() + +function(audiocpp_server_frontends_use_library AUDIOCPP_SERVER_FRONTEND_LIBRARY AUDIOCPP_SERVER_FRONTEND_OUT) + audiocpp_collect_server_frontends() + get_property(AUDIOCPP_SERVER_FRONTEND_LIBRARIES GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_LIBRARIES") + list(FIND AUDIOCPP_SERVER_FRONTEND_LIBRARIES "${AUDIOCPP_SERVER_FRONTEND_LIBRARY}" AUDIOCPP_SERVER_FRONTEND_LIBRARY_INDEX) + if (AUDIOCPP_SERVER_FRONTEND_LIBRARY_INDEX EQUAL -1) + set(${AUDIOCPP_SERVER_FRONTEND_OUT} OFF PARENT_SCOPE) + else() + set(${AUDIOCPP_SERVER_FRONTEND_OUT} ON PARENT_SCOPE) + endif() +endfunction() + +function(audiocpp_configure_server_frontends AUDIOCPP_SERVER_TARGET) + audiocpp_collect_server_frontends() + + get_property(AUDIOCPP_SERVER_FRONTEND_DECLARATIONS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_DECLARATIONS") + get_property(AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_REGISTRATIONS") + get_property(AUDIOCPP_SERVER_FRONTEND_SOURCES GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_SOURCES") + get_property(AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_INCLUDE_DIRS") + get_property(AUDIOCPP_SERVER_FRONTEND_LIBRARIES GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_LIBRARIES") + get_property(AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS GLOBAL PROPERTY + "AUDIOCPP_SERVER_FRONTEND_SELECTED_COMPILE_DEFINITIONS") + + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated") + + if (AUDIOCPP_SERVER_FRONTEND_SOURCES) + add_library(audiocpp_server_frontends OBJECT + ${AUDIOCPP_SERVER_FRONTEND_SOURCES} + ) + target_include_directories(audiocpp_server_frontends PRIVATE + "${PROJECT_SOURCE_DIR}/app/server" + ${AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS} + ) + target_link_libraries(audiocpp_server_frontends PUBLIC engine_runtime PRIVATE + ${AUDIOCPP_SERVER_FRONTEND_LIBRARIES}) + target_compile_definitions(audiocpp_server_frontends PRIVATE + ${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS}) + target_sources(${AUDIOCPP_SERVER_TARGET} PRIVATE $) + target_link_libraries(${AUDIOCPP_SERVER_TARGET} PRIVATE + audiocpp_server_frontends + ${AUDIOCPP_SERVER_FRONTEND_LIBRARIES}) + target_compile_definitions(${AUDIOCPP_SERVER_TARGET} PRIVATE + ${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS}) + endif() + + file(WRITE + "${CMAKE_CURRENT_BINARY_DIR}/generated/server_frontend_module_declarations.inc" + "${AUDIOCPP_SERVER_FRONTEND_DECLARATIONS}") + file(WRITE + "${CMAKE_CURRENT_BINARY_DIR}/generated/server_frontend_module_registrations.inc" + "${AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS}") +endfunction() diff --git a/external/audio.cpp-server-frontends b/external/audio.cpp-server-frontends new file mode 160000 index 00000000..1c1d3e05 --- /dev/null +++ b/external/audio.cpp-server-frontends @@ -0,0 +1 @@ +Subproject commit 1c1d3e05f9fdad682542fad787397aa287f3cb96