diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec407f4..8932b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,16 @@ jobs: python-version: "3.13" architecture: ${{ matrix.arch }} + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: src/server -> ../../build/cargo + - name: Cache model and voices uses: actions/cache@v6 with: @@ -78,3 +88,13 @@ jobs: - name: Test run: ctest --test-dir build -C Release --output-on-failure + + - name: Lint kokoro-server + if: matrix.name == 'linux-x64' + working-directory: src/server + env: + KOKORO_LIB_DIR: ${{ github.workspace }}/build + CARGO_TARGET_DIR: ${{ github.workspace }}/build/cargo + run: | + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings diff --git a/.gitignore b/.gitignore index 89b6b52..6d88cac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Build directories build/ bin/ +# cargo, when run directly in the kokoro-server crate +src/server/target/ # Built by `uv run bootstrap.py build-ort` third_party/onnxruntime-src/ out/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13b2635..2e950ae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,6 +27,7 @@ flowchart LR scripts["scripts/"] -->|"vocab.txt, g2p_*.weights"| dict["dict/"] core["kokoro_core (static)"] --> lib["kokoro (shared, C API)"] lib --> cli["kokoro CLI"] + lib --> server["kokoro-server (HTTP)"] lib --> dotnet["Kokoro.Net (P/Invoke)"] --> nuget["NuGet packages"] core --> coretests["test_g2p / test_chunker / g2p_dump"] lib --> libtests["test_c_api / test_synthesis*"] @@ -40,6 +41,7 @@ flowchart LR | `include/kokoro/` | public C header | | `src/` | engine, G2P, C API implementation | | `src/cli/` | CLI | +| `src/server/` | HTTP server | | `src/chinese_english/`, `src/english/`, `src/spanish/`, `src/german/` | per-language G2P | | `dotnet/` | wrapper, example, tests | | `packaging/` | NuGet build scripts | @@ -216,6 +218,19 @@ These are the touch points the German addition went through, in order: opens lazily at the first clip's sample rate, and clips are never freed on the audio thread. - Commands: `/voice`, `/speed`, `/voices`, `/help`, `/quit`. +**HTTP server (`src/server/`).** A Rust crate (axum + tokio) that links `libkokoro` through hand-written C API +declarations (`ffi.rs`) and serves an OpenAI-compatible API. CMake builds it with cargo (`KOKORO_BUILD_SERVER`) and +copies `kokoro-server` next to `libkokoro`; `build.rs` links against the library and sets the rpath. +- `Engine` (`engine.rs`) owns the one context; `Audio` frees the samples with `kokoro_audio_free` on drop. +- `AppState` (`routes.rs`) keeps the engine behind a `tokio::sync::Mutex`. A request waits for the lock without a + thread, then synthesizes in `spawn_blocking`, which also reads the thread-local `kokoro_last_error()`. +- `POST /v1/audio/speech` parses the JSON body (`speech_request.rs`), then encodes the float samples as 16-bit PCM + WAV or raw PCM (`audio_encoding.rs`). Other formats get an OpenAI-style 400 error. +- `GET /v1/audio/voices` lists the loaded voices; `GET /health` answers `{"status":"ok"}`. +- The listen socket (`listener.rs`) sets `SO_REUSEADDR` (`SO_EXCLUSIVEADDRUSE` on Windows) and never + `SO_REUSEPORT`, so a second server on the same port fails instead of sharing it. +- `options.rs` ports the CLI's option parsing (`src/cli/engine_options.h` values), messages and exit codes. + **.NET (`dotnet/`).** - `KokoroNative` calls the C API through `DllImport("kokoro")`, marshalling strings as `LPUTF8Str`. Strings owned by the library stay `IntPtr`, so the marshaller never frees them. diff --git a/CMakeLists.txt b/CMakeLists.txt index 30d51b5..7742fd3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,6 +181,40 @@ elseif (UNIX) set_target_properties(kokoro_cli PROPERTIES INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}") endif() +if (CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + set(KOKORO_IS_TOP_LEVEL ON) +else() + set(KOKORO_IS_TOP_LEVEL OFF) +endif() +# HTTP server: a Rust crate in src/server, built by cargo against libkokoro and copied next to it. +option(KOKORO_BUILD_SERVER "Build kokoro-server (needs a Rust toolchain)" ${KOKORO_IS_TOP_LEVEL}) +if (KOKORO_BUILD_SERVER) + find_program(CARGO cargo HINTS "$ENV{CARGO_HOME}/bin" "$ENV{HOME}/.cargo/bin" "$ENV{USERPROFILE}/.cargo/bin") + if (NOT CARGO) + message(FATAL_ERROR + "cargo not found: install Rust (https://rustup.rs) or configure with -DKOKORO_BUILD_SERVER=OFF") + endif() + set(KOKORO_CARGO_TARGET_DIR "${CMAKE_BINARY_DIR}/cargo") + set(KOKORO_CARGO_PROFILE "$,dev,release>") + set(KOKORO_CARGO_ENV + "CARGO_TARGET_DIR=${KOKORO_CARGO_TARGET_DIR}" + "KOKORO_LIB_DIR=$" + "KOKORO_INSTALL_LIBDIR=${CMAKE_INSTALL_LIBDIR}") + set(KOKORO_SERVER_EXE "kokoro-server${CMAKE_EXECUTABLE_SUFFIX}") + add_custom_target(kokoro_server ALL + COMMAND ${CMAKE_COMMAND} -E env ${KOKORO_CARGO_ENV} + "${CARGO}" build --locked --profile ${KOKORO_CARGO_PROFILE} + --manifest-path "${CMAKE_CURRENT_SOURCE_DIR}/src/server/Cargo.toml" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${KOKORO_CARGO_TARGET_DIR}/$,debug,release>/${KOKORO_SERVER_EXE}" + "$/${KOKORO_SERVER_EXE}" + COMMENT "Building kokoro-server with cargo" + VERBATIM + ) + add_dependencies(kokoro_server kokoro) + install(PROGRAMS "$/${KOKORO_SERVER_EXE}" DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif() + # Install: headers, libkokoro and a CMake package (find_package(kokoro) -> kokoro::kokoro). install(TARGETS kokoro EXPORT kokoroTargets RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} @@ -201,11 +235,6 @@ install(FILES DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/kokoro ) -if (CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) - set(KOKORO_IS_TOP_LEVEL ON) -else() - set(KOKORO_IS_TOP_LEVEL OFF) -endif() option(KOKORO_BUILD_TESTS "Build the kokoro tests" ${KOKORO_IS_TOP_LEVEL}) if (KOKORO_BUILD_TESTS) enable_testing() diff --git a/README.md b/README.md index 557eabc..f49ff0f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A high-performance, lightweight C++ inference implementation of the [Kokoro](htt - **CMake** (3.15+) - **Ninja** (recommended; `uv run bootstrap.py build` uses it when it is in `PATH`) - **C++ compiler** (C++17 support required) +- **Rust** (1.80+, via [rustup](https://rustup.rs)): builds `kokoro-server`; configure with `-DKOKORO_BUILD_SERVER=OFF` to skip it - **[uv](https://docs.astral.sh/uv/)**: runs `bootstrap.py` and `voice_tool.py`, and installs their Python dependencies (Python 3.10+, click) into `.venv` on first use ## Setup @@ -65,7 +66,7 @@ At runtime the CUDA 12 and cuDNN 9 libraries must be loadable: on `PATH` on Wind ctest --test-dir build -C Release --output-on-failure ``` -Tests live in `tests/` (doctest, vendored in `third_party/doctest`): `c_api` checks the C API without a model, `g2p` pins the Chinese/English G2P output, and `synthesis` runs the real model from `models/` (skipped if the model or voices file is missing). Configure with `-DKOKORO_BUILD_TESTS=OFF` to skip building them. +Tests live in `tests/` (doctest, vendored in `third_party/doctest`): `c_api` checks the C API without a model, `g2p` pins the Chinese/English G2P output, `server` runs `cargo test` for `kokoro-server` (request parsing, WAV/PCM encoding, the listen socket, and an end-to-end HTTP check against the model), and `synthesis` runs the real model from `models/` (model-dependent tests are skipped if the model or voices file is missing). Configure with `-DKOKORO_BUILD_TESTS=OFF` to skip building them. The Python tools have their own tests in `tests/python/`: run `uv run pytest`. The `.pt` import tests are skipped unless PyTorch is installed; `uv run --group voice pytest` runs them too. @@ -135,6 +136,22 @@ German (voices `df_eva`, `df_victoria`, `dm_bernd`, `dm_martin`; `df_*`/`dm_*` v Output is a mono 32-bit float WAV at 24 kHz. Exit status is 0 on success, 1 on a library error, and 2 on a usage error. +### HTTP server + +`kokoro-server` loads the model once and serves an OpenAI-compatible text-to-speech API. It is written in Rust (`src/server/`, axum) on top of the C API, and `uv run bootstrap.py build` builds it with cargo next to `libkokoro`. Run it from the project root (it takes the same `-m`, `--voices`, `-d`, `--lang`, `--language`, `--device` and `--gpu-id` options as `kokoro`): + +```bash +./build/kokoro-server --host 127.0.0.1 --port 8880 -v af_maple +curl -o hello.wav -H 'Content-Type: application/json' \ + -d '{"model":"kokoro","input":"Hello world","voice":"af_maple"}' http://127.0.0.1:8880/v1/audio/speech +``` + +- `POST /v1/audio/speech`: `input` (required), `voice` (default: `-v`), `speed` (> 0, default 1.0) and `response_format` (`wav`, the default, or `pcm`). `wav` is a 16-bit PCM mono WAV at 24 kHz; `pcm` is the same samples raw, signed 16-bit little-endian. Other formats, unknown voices and invalid fields get HTTP 400 with an OpenAI-style JSON error. `model` and `instructions` are ignored. +- `GET /v1/audio/voices`: `{"voices": [...]}` with the loaded voice names. +- `GET /health`: `{"status":"ok"}`. + +Requests are synthesized one at a time on the shared model. The server has no authentication, so keep it on loopback. Exit status is 1 if the model cannot be loaded or the port is in use, and 2 on a usage error. + ## Library `libkokoro` exposes a C API in [`include/kokoro/kokoro.h`](include/kokoro/kokoro.h), so it can be used from C, C++ or any language with a C FFI (Python `ctypes`, C#, Rust, Go, ...): @@ -218,6 +235,7 @@ accent on the stressed vowel; sounds Spanish spelling lacks are written as IPA ( - `include/kokoro/kokoro.h`, `src/kokoro_c.cpp`: public C API of `libkokoro`. - `src/cli/main.cpp`: the `kokoro` command-line tool. +- `src/server/`: the `kokoro-server` OpenAI-compatible HTTP server (Rust crate). - `src/Kokoro.cpp/h`: main TTS class (internal). - `src/EnG2P.h`, `src/NeuralG2P.cpp/h`: English G2P (dictionary lookup and neural prediction). - `src/SpanishG2P.cpp/h`: rule-based Spanish G2P (espeak-ng `es` conventions). @@ -252,6 +270,7 @@ MIT - **CMake** (3.15+) - **Ninja**(推荐;`uv run bootstrap.py build` 在 `PATH` 中找到它时使用) - **C++ 编译器** (需要支持 C++17) +- **Rust**(1.80+,通过 [rustup](https://rustup.rs) 安装):用于构建 `kokoro-server`;配置时加 `-DKOKORO_BUILD_SERVER=OFF` 可跳过 - **[uv](https://docs.astral.sh/uv/)**:用于运行 `bootstrap.py` 和 `voice_tool.py`,首次运行时会将其 Python 依赖(Python 3.10+、click)安装到 `.venv` ## 准备 @@ -477,6 +496,7 @@ Implementación ligera y de alto rendimiento en C++ de la inferencia del modelo - **CMake** (3.15+) - **Ninja** (recomendado; `uv run bootstrap.py build` lo usa si está en el `PATH`) - **Compilador de C++** (con soporte de C++17) +- **Rust** (1.80+, con [rustup](https://rustup.rs)): compila `kokoro-server`; configura con `-DKOKORO_BUILD_SERVER=OFF` para omitirlo - **[uv](https://docs.astral.sh/uv/)**: ejecuta `bootstrap.py` y `voice_tool.py`, e instala sus dependencias de Python (Python 3.10+, click) en `.venv` la primera vez ## Preparación @@ -723,6 +743,7 @@ Eine schnelle, schlanke C++-Implementierung der Inferenz des TTS-Modells [Kokoro - **CMake** (3.15+) - **Ninja** (empfohlen; `uv run bootstrap.py build` verwendet es, wenn es im `PATH` liegt) - **C++-Compiler** (mit C++17-Unterstützung) +- **Rust** (1.80+, über [rustup](https://rustup.rs)): baut `kokoro-server`; mit `-DKOKORO_BUILD_SERVER=OFF` konfigurieren, um es auszulassen - **[uv](https://docs.astral.sh/uv/)**: führt `bootstrap.py` und `voice_tool.py` aus und installiert beim ersten Aufruf ihre Python-Abhängigkeiten (Python 3.10+, click) in `.venv` ## Einrichtung diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 551c98b..70d7c00 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,6 +19,21 @@ Each component remains under its own license; the full license texts are in the - License: MIT - License text: `third_party/cppjieba/deps/limonp/LICENSE` +## Compiled into kokoro-server + +kokoro-server statically links Rust crates from crates.io; the exact versions are pinned in `src/server/Cargo.lock` +and each crate's license text ships in its source package (`cargo tree -e normal -f "{p} {l}"` lists them). + +- MIT: axum, axum-core, bytes, http-body, http-body-util, hyper, hyper-util, mio, slab, tokio, tower, tower-layer, + tower-service, zmij +- MIT OR Apache-2.0: atomic-waker, futures-core, futures-task, futures-util, http, httparse, httpdate, itoa, mime, + percent-encoding, pin-project-lite, serde_core, serde_json, smallvec, socket2, windows-link, windows-sys (Windows) +- MIT AND BSD-3-Clause: matchit +- Unlicense OR MIT: memchr +- Apache-2.0: sync_wrapper +- Build-time only (proc macros): proc-macro2, quote, syn (MIT OR Apache-2.0), tokio-macros (MIT), unicode-ident + ((MIT OR Apache-2.0) AND Unicode-3.0) + ## Redistributed data (`dict/`, shipped as `kokoro-dict/` in the Larroy.Kokoro NuGet package) ### CMU Pronouncing Dictionary diff --git a/include/kokoro/kokoro.h b/include/kokoro/kokoro.h index 623b104..35b7ff3 100644 --- a/include/kokoro/kokoro.h +++ b/include/kokoro/kokoro.h @@ -128,7 +128,7 @@ KOKORO_API size_t kokoro_voice_count(const kokoro_ctx* ctx); KOKORO_API const char* kokoro_voice_name(const kokoro_ctx* ctx, size_t index); /* - * Synthesizes `text` with `voice` at `speed` (1.0 = normal, must be > 0). + * Synthesizes `text` with `voice` at `speed` (1.0 = normal, must be > 0 and < 2^31). * flags: 0 or KOKORO_INPUT_PHONEMES. * On success fills *out_audio; the caller must release it with kokoro_audio_free(). * On failure *out_audio is zeroed. diff --git a/packaging/build-natives.ps1 b/packaging/build-natives.ps1 index 0687125..4871a66 100644 --- a/packaging/build-natives.ps1 +++ b/packaging/build-natives.ps1 @@ -44,7 +44,7 @@ function Invoke-NativeBuild([hashtable]$Row) { throw 'ninja not found on PATH (install the VS "C++ CMake tools" component or Ninja)' } Invoke-Checked cmake @('-S', $root, '-B', $buildDir, '-G', 'Ninja', '-DCMAKE_BUILD_TYPE=Release', - "-DONNXRUNTIME_ROOT=$ortRoot", '-DKOKORO_BUILD_EVAL=OFF') + "-DONNXRUNTIME_ROOT=$ortRoot", '-DKOKORO_BUILD_EVAL=OFF', '-DKOKORO_BUILD_SERVER=OFF') Invoke-Checked cmake @('--build', $buildDir, '--parallel') } } diff --git a/src/cli/engine_options.h b/src/cli/engine_options.h new file mode 100644 index 0000000..200ab7c --- /dev/null +++ b/src/cli/engine_options.h @@ -0,0 +1,51 @@ +// Value parsers for the engine options of the kokoro front end (kokoro-server ports them in src/server/options.rs). +#ifndef KOKORO_CLI_ENGINE_OPTIONS_H +#define KOKORO_CLI_ENGINE_OPTIONS_H + +#include + +#include +#include +#include + +namespace kokoro_cli { + +// Maps the --lang value; returns false for anything but "auto", "en", "zh", "es" or "de". +inline bool parse_number_language(const std::string& value, kokoro_number_language& language) { + if (value == "auto") { language = KOKORO_NUMBERS_AUTO; return true; } + if (value == "en") { language = KOKORO_NUMBERS_ENGLISH; return true; } + if (value == "zh") { language = KOKORO_NUMBERS_CHINESE; return true; } + if (value == "es") { language = KOKORO_NUMBERS_SPANISH; return true; } + if (value == "de") { language = KOKORO_NUMBERS_GERMAN; return true; } + return false; +} + +// Maps the --language value; returns false for anything but "auto", "zh", "en", "es" or "de". +inline bool parse_language(const std::string& value, kokoro_language& language) { + if (value == "auto") { language = KOKORO_LANGUAGE_AUTO; return true; } + if (value == "zh") { language = KOKORO_LANGUAGE_CHINESE_ENGLISH; return true; } + if (value == "en") { language = KOKORO_LANGUAGE_ENGLISH; return true; } + if (value == "es") { language = KOKORO_LANGUAGE_SPANISH; return true; } + if (value == "de") { language = KOKORO_LANGUAGE_GERMAN; return true; } + return false; +} + +inline bool parse_device(const std::string& value, kokoro_device& device) { + if (value == "auto") { device = KOKORO_DEVICE_AUTO; return true; } + if (value == "cpu") { device = KOKORO_DEVICE_CPU; return true; } + if (value == "cuda") { device = KOKORO_DEVICE_CUDA; return true; } + return false; +} + +inline bool parse_gpu_id(const std::string& value, int& gpu_id) { + if (value.empty()) return false; + char* end = nullptr; + const long parsed = std::strtol(value.c_str(), &end, 10); + if (end != value.c_str() + value.size() || parsed < 0 || parsed > INT_MAX) return false; + gpu_id = static_cast(parsed); + return true; +} + +} // namespace kokoro_cli + +#endif // KOKORO_CLI_ENGINE_OPTIONS_H diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 7e067e0..a52281d 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -2,6 +2,7 @@ #include #include "console.h" +#include "engine_options.h" #include "interactive.h" #include "speed.h" @@ -109,42 +110,6 @@ bool usage_error(const std::string& message) { return false; } -// Maps the --lang value; returns false for anything but "auto", "en", "zh", "es" or "de". -bool parse_number_language(const std::string& value, kokoro_number_language& language) { - if (value == "auto") { language = KOKORO_NUMBERS_AUTO; return true; } - if (value == "en") { language = KOKORO_NUMBERS_ENGLISH; return true; } - if (value == "zh") { language = KOKORO_NUMBERS_CHINESE; return true; } - if (value == "es") { language = KOKORO_NUMBERS_SPANISH; return true; } - if (value == "de") { language = KOKORO_NUMBERS_GERMAN; return true; } - return false; -} - -// Maps the --language value; returns false for anything but "auto", "zh", "en", "es" or "de". -bool parse_language(const std::string& value, kokoro_language& language) { - if (value == "auto") { language = KOKORO_LANGUAGE_AUTO; return true; } - if (value == "zh") { language = KOKORO_LANGUAGE_CHINESE_ENGLISH; return true; } - if (value == "en") { language = KOKORO_LANGUAGE_ENGLISH; return true; } - if (value == "es") { language = KOKORO_LANGUAGE_SPANISH; return true; } - if (value == "de") { language = KOKORO_LANGUAGE_GERMAN; return true; } - return false; -} - -bool parse_device(const std::string& value, kokoro_device& device) { - if (value == "auto") { device = KOKORO_DEVICE_AUTO; return true; } - if (value == "cpu") { device = KOKORO_DEVICE_CPU; return true; } - if (value == "cuda") { device = KOKORO_DEVICE_CUDA; return true; } - return false; -} - -bool parse_gpu_id(const std::string& value, int& gpu_id) { - if (value.empty()) return false; - char* end = nullptr; - const long parsed = std::strtol(value.c_str(), &end, 10); - if (end != value.c_str() + value.size() || parsed < 0 || parsed > INT_MAX) return false; - gpu_id = static_cast(parsed); - return true; -} - // Returns false after printing a usage error. bool parse_args(const std::vector& args, Options& opt) { bool positional_only = false; @@ -191,23 +156,23 @@ bool parse_args(const std::vector& args, Options& opt) { case Opt::Dict: opt.dict = value; break; case Opt::Voice: opt.voice = value; break; case Opt::Lang: - if (!parse_number_language(value, opt.number_language)) { + if (!kokoro_cli::parse_number_language(value, opt.number_language)) { return usage_error("invalid number language '" + value + "' (must be auto, en, zh, es or de)"); } break; case Opt::Language: - if (!parse_language(value, opt.language)) { + if (!kokoro_cli::parse_language(value, opt.language)) { return usage_error("invalid language '" + value + "' (must be auto, zh, en, es or de)"); } break; case Opt::Output: opt.output = value; break; case Opt::Device: - if (!parse_device(value, opt.device)) { + if (!kokoro_cli::parse_device(value, opt.device)) { return usage_error("invalid device '" + value + "' (expected auto, cpu or cuda)"); } break; case Opt::GpuId: - if (!parse_gpu_id(value, opt.gpu_id)) { + if (!kokoro_cli::parse_gpu_id(value, opt.gpu_id)) { return usage_error("invalid GPU id '" + value + "' (must be an integer >= 0)"); } break; diff --git a/src/kokoro_c.cpp b/src/kokoro_c.cpp index 69f70a7..6ed2e48 100644 --- a/src/kokoro_c.cpp +++ b/src/kokoro_c.cpp @@ -21,6 +21,9 @@ namespace { thread_local std::string g_last_error; +// Integer-speed models cast speed to int; 2^31 is the first float that int cannot hold. +constexpr float kSpeedLimit = 2147483648.0f; + kokoro_status fail(kokoro_status status, const char* message) { g_last_error = message; return status; @@ -120,7 +123,9 @@ kokoro_status kokoro_synthesize(kokoro_ctx* ctx, const char* text, const char* v if (!out_audio) return fail(KOKORO_ERROR_INVALID_ARGUMENT, "out_audio is NULL"); *out_audio = kokoro_audio{nullptr, 0, 0}; if (!ctx || !text || !voice) return fail(KOKORO_ERROR_INVALID_ARGUMENT, "ctx, text and voice must not be NULL"); - if (!(speed > 0.0f) || !std::isfinite(speed)) return fail(KOKORO_ERROR_INVALID_ARGUMENT, "speed must be > 0"); + if (!(speed > 0.0f && speed < kSpeedLimit)) { + return fail(KOKORO_ERROR_INVALID_ARGUMENT, "speed must be > 0 and < 2^31"); + } if (flags & ~KOKORO_INPUT_PHONEMES) return fail(KOKORO_ERROR_INVALID_ARGUMENT, "unknown flags"); const std::vector* style = nullptr; diff --git a/src/server/Cargo.lock b/src/server/Cargo.lock new file mode 100644 index 0000000..0949fbe --- /dev/null +++ b/src/server/Cargo.lock @@ -0,0 +1,456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kokoro-server" +version = "0.1.0" +dependencies = [ + "axum", + "serde_json", + "socket2", + "tokio", + "ureq", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/server/Cargo.toml b/src/server/Cargo.toml new file mode 100644 index 0000000..a2220d5 --- /dev/null +++ b/src/server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "kokoro-server" +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +description = "OpenAI-compatible text-to-speech HTTP server on top of libkokoro (public C API only)" +license = "MIT" +publish = false +build = "build.rs" + +[lib] +name = "kokoro_server" +path = "lib.rs" + +[[bin]] +name = "kokoro-server" +path = "main.rs" + +[[test]] +name = "server_synthesis" +path = "tests/server_synthesis.rs" + +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +serde_json = "1" +socket2 = { version = "0.6", features = ["all"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] } + +[dev-dependencies] +ureq = { version = "3", default-features = false } + +[profile.release] +lto = "thin" diff --git a/src/server/audio_encoding.rs b/src/server/audio_encoding.rs new file mode 100644 index 0000000..13141e0 --- /dev/null +++ b/src/server/audio_encoding.rs @@ -0,0 +1,89 @@ +//! 16-bit PCM encodings of the float samples kokoro returns. + +const WAV_HEADER_BYTES: usize = 44; + +#[derive(Debug, PartialEq, Eq)] +pub struct WavTooLong; + +impl std::fmt::Display for WavTooLong { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("audio too long for a WAV file") + } +} + +// Clamps to [-1, 1] and rounds half away from zero, like std::lround. +fn to_pcm16(sample: f32) -> i16 { + (sample.clamp(-1.0, 1.0) * 32767.0).round() as i16 +} + +fn extend_pcm16(out: &mut Vec, samples: &[f32]) { + out.extend(samples.iter().flat_map(|&s| to_pcm16(s).to_le_bytes())); +} + +/// Raw signed 16-bit little-endian samples. +pub fn encode_pcm16(samples: &[f32]) -> Vec { + let mut out = Vec::with_capacity(samples.len() * 2); + extend_pcm16(&mut out, samples); + out +} + +/// A PCM16 mono WAV file. +pub fn encode_wav_pcm16(samples: &[f32], sample_rate_hz: u32) -> Result, WavTooLong> { + let data_size = u32::try_from(samples.len()) + .ok() + .and_then(|count| count.checked_mul(2)) + .filter(|size| size.checked_add(36).is_some()) + .ok_or(WavTooLong)?; + let mut out = Vec::with_capacity(WAV_HEADER_BYTES + data_size as usize); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_size).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size + out.extend_from_slice(&1u16.to_le_bytes()); // WAVE_FORMAT_PCM + out.extend_from_slice(&1u16.to_le_bytes()); // channels + out.extend_from_slice(&sample_rate_hz.to_le_bytes()); + out.extend_from_slice(&sample_rate_hz.wrapping_mul(2).to_le_bytes()); // byte rate + out.extend_from_slice(&2u16.to_le_bytes()); // block align + out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + out.extend_from_slice(b"data"); + out.extend_from_slice(&data_size.to_le_bytes()); + extend_pcm16(&mut out, samples); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) + } + + #[test] + fn pcm16_clamps_and_rounds_to_little_endian() { + assert_eq!(encode_pcm16(&[0.0, 1.0, -1.0, 2.0]), b"\x00\x00\xff\x7f\x01\x80\xff\x7f"); + assert_eq!(encode_pcm16(&[0.5 / 32767.0, -0.5 / 32767.0]), b"\x01\x00\xff\xff"); + } + + #[test] + fn wav_writes_a_pcm16_mono_header() { + let samples = [0.0, 0.5, -0.5]; + let wav = encode_wav_pcm16(&samples, 24000).unwrap(); + assert_eq!(wav.len(), 44 + 6); + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(u32_at(&wav, 4), 36 + 6); + assert_eq!(&wav[8..16], b"WAVEfmt "); + assert_eq!(u16_at(&wav, 20), 1); // PCM + assert_eq!(u16_at(&wav, 22), 1); // mono + assert_eq!(u32_at(&wav, 24), 24000); + assert_eq!(u32_at(&wav, 28), 48000); + assert_eq!(u16_at(&wav, 34), 16); + assert_eq!(&wav[36..40], b"data"); + assert_eq!(u32_at(&wav, 40), 6); + assert_eq!(wav[44..], encode_pcm16(&samples)); + } +} diff --git a/src/server/build.rs b/src/server/build.rs new file mode 100644 index 0000000..17de336 --- /dev/null +++ b/src/server/build.rs @@ -0,0 +1,28 @@ +// Links libkokoro, built by CMake. CMake passes KOKORO_LIB_DIR (the directory holding kokoro.lib / libkokoro.so / +// libkokoro.dylib) and KOKORO_INSTALL_LIBDIR (CMAKE_INSTALL_LIBDIR, e.g. lib or lib64). +use std::env; + +fn main() { + println!("cargo:rerun-if-env-changed=KOKORO_LIB_DIR"); + println!("cargo:rerun-if-env-changed=KOKORO_INSTALL_LIBDIR"); + let lib_dir = env::var("KOKORO_LIB_DIR") + .expect("KOKORO_LIB_DIR must name the directory containing the kokoro library (CMake sets it)"); + println!("cargo:rustc-link-search=native={lib_dir}"); + println!("cargo:rustc-link-lib=dylib=kokoro"); + emit_rpath(&lib_dir, &env::var("KOKORO_INSTALL_LIBDIR").unwrap_or_else(|_| "lib".to_owned())); +} + +// Build tree: the executable sits next to libkokoro. Install tree: bin/ and / are siblings. Test binaries stay +// in the cargo target directory, so they point at lib_dir itself. +fn emit_rpath(lib_dir: &str, install_libdir: &str) { + let origin = match env::var("CARGO_CFG_TARGET_OS").as_deref() { + Ok("macos") => "@loader_path", + Ok("windows") | Err(_) => return, + Ok(_) => "$ORIGIN", + }; + for path in [origin.to_owned(), format!("{origin}/../{install_libdir}")] { + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{path}"); + println!("cargo:rustc-link-arg-tests=-Wl,-rpath,{path}"); + } + println!("cargo:rustc-link-arg-tests=-Wl,-rpath,{lib_dir}"); +} diff --git a/src/server/engine.rs b/src/server/engine.rs new file mode 100644 index 0000000..83d4b15 --- /dev/null +++ b/src/server/engine.rs @@ -0,0 +1,163 @@ +//! Safe ownership of one kokoro context. +use crate::ffi::{ + self, kokoro_audio, kokoro_ctx, kokoro_device, kokoro_language, kokoro_number_language, kokoro_status, +}; +use std::ffi::{c_char, CStr, CString}; +use std::ptr; + +#[derive(Clone, Debug)] +pub struct EngineConfig { + pub model: String, + pub voices: String, + pub dict: String, + pub device: kokoro_device, + pub gpu_id: i32, + pub number_language: kokoro_number_language, + pub language: kokoro_language, +} + +#[derive(Debug)] +pub struct SynthesisError { + pub status: kokoro_status, + pub message: String, +} + +/// The library's version string, e.g. "0.1.0". +pub fn library_version() -> String { + // SAFETY: kokoro_version returns a static NUL-terminated string. + unsafe { owned(ffi::kokoro_version()) } +} + +// SAFETY: text must be NULL or a valid NUL-terminated string. +unsafe fn owned(text: *const c_char) -> String { + if text.is_null() { + return String::new(); + } + CStr::from_ptr(text).to_string_lossy().into_owned() +} + +// kokoro_last_error() is thread-local: call this on the thread that made the failing call, before any other. +fn last_error() -> String { + // SAFETY: kokoro_last_error returns a valid string for the calling thread. + unsafe { owned(ffi::kokoro_last_error()) } +} + +fn c_string(text: &str) -> Result { + CString::new(text).map_err(|_| format!("'{}' contains a NUL byte", text.escape_debug())) +} + +fn check(status: kokoro_status) -> Result<(), String> { + if status == kokoro_status::OK { + Ok(()) + } else { + Err(last_error()) + } +} + +/// Owns a `kokoro_ctx`. The C API forbids using one context from two threads at once; callers serialize access. +pub struct Engine { + ctx: *mut kokoro_ctx, + voices: Vec, // sorted, as returned by kokoro_voice_name +} + +// SAFETY: the context may move between threads; &mut self on synthesize keeps calls exclusive. +unsafe impl Send for Engine {} + +impl Engine { + /// Creates and configures the context; the error is the library's message. + pub fn create(config: &EngineConfig) -> Result { + let (model, voices, dict) = (c_string(&config.model)?, c_string(&config.voices)?, c_string(&config.dict)?); + // SAFETY: kokoro_default_options has no preconditions. + let mut options = unsafe { ffi::kokoro_default_options() }; + options.device = config.device; + options.gpu_id = config.gpu_id; + let mut ctx = ptr::null_mut(); + // SAFETY: all strings are NUL-terminated and live across the call; ctx is a valid out pointer. + check(unsafe { ffi::kokoro_create_ex(model.as_ptr(), voices.as_ptr(), dict.as_ptr(), &options, &mut ctx) })?; + let mut engine = Self { ctx, voices: Vec::new() }; + engine.configure(config)?; + engine.voices = engine.list_voices(); + Ok(engine) + } + + fn configure(&mut self, config: &EngineConfig) -> Result<(), String> { + // SAFETY: self.ctx is a live context owned by self. + check(unsafe { ffi::kokoro_set_number_language(self.ctx, config.number_language) })?; + // SAFETY: as above. + check(unsafe { ffi::kokoro_set_language(self.ctx, config.language) }) + } + + fn list_voices(&self) -> Vec { + // SAFETY: self.ctx is live; names are valid for the context's lifetime and copied here. + let count = unsafe { ffi::kokoro_voice_count(self.ctx) }; + (0..count).map(|i| unsafe { owned(ffi::kokoro_voice_name(self.ctx, i)) }).collect() + } + + pub fn voices(&self) -> &[String] { + &self.voices + } + + /// Where inference runs: "cpu", "cuda" or "auto". + pub fn device_name(&self) -> &'static str { + // SAFETY: self.ctx is live. + match unsafe { ffi::kokoro_context_device(self.ctx) } { + kokoro_device::CUDA => "cuda", + kokoro_device::CPU => "cpu", + _ => "auto", + } + } + + pub fn synthesize(&mut self, text: &str, voice: &str, speed: f32) -> Result { + // C strings end at the first NUL, as with the C++ server's c_str(). + let c_prefix = |s: &str| CString::new(s.split('\0').next().unwrap_or_default()).expect("prefix has no NUL"); + let (text, voice) = (c_prefix(text), c_prefix(voice)); + let mut audio = Audio(kokoro_audio { samples: ptr::null_mut(), num_samples: 0, sample_rate: 0 }); + // SAFETY: self.ctx is live and exclusively borrowed; strings outlive the call; audio.0 is a valid out value. + let status = unsafe { ffi::kokoro_synthesize(self.ctx, text.as_ptr(), voice.as_ptr(), speed, 0, &mut audio.0) }; + if status != kokoro_status::OK { + return Err(SynthesisError { status, message: last_error() }); + } + Ok(audio) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + // SAFETY: self.ctx came from kokoro_create_ex and is destroyed once. + unsafe { ffi::kokoro_destroy(self.ctx) } + } +} + +/// Mono float samples owned by the library. +pub struct Audio(kokoro_audio); + +// SAFETY: the samples buffer is exclusively owned and freed by kokoro_audio_free, which is thread-agnostic. +unsafe impl Send for Audio {} + +impl Audio { + pub fn samples(&self) -> &[f32] { + if self.0.samples.is_null() { + return &[]; + } + // SAFETY: the library returned num_samples floats at samples, valid until kokoro_audio_free. + unsafe { std::slice::from_raw_parts(self.0.samples, self.0.num_samples) } + } + + pub fn sample_rate_hz(&self) -> u32 { + u32::try_from(self.0.sample_rate).unwrap_or(0) + } + + pub fn duration_s(&self) -> f64 { + match self.sample_rate_hz() { + 0 => 0.0, + rate_hz => self.0.num_samples as f64 / f64::from(rate_hz), + } + } +} + +impl Drop for Audio { + fn drop(&mut self) { + // SAFETY: the audio came from kokoro_synthesize (or is zeroed) and is freed once. + unsafe { ffi::kokoro_audio_free(&mut self.0) } + } +} diff --git a/src/server/ffi.rs b/src/server/ffi.rs new file mode 100644 index 0000000..347c269 --- /dev/null +++ b/src/server/ffi.rs @@ -0,0 +1,79 @@ +//! Raw declarations mirroring `include/kokoro/kokoro.h`. C enums are `c_int` newtypes so an unexpected value from +//! the library is just another number, not undefined behavior. +#![allow(non_camel_case_types)] + +use std::ffi::{c_char, c_float, c_int, c_uint}; + +macro_rules! c_enum { + ($name:ident { $($variant:ident = $value:expr),* $(,)? }) => { + #[repr(transparent)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct $name(pub c_int); + impl $name { + $(pub const $variant: Self = Self($value);)* + } + }; +} + +c_enum!(kokoro_status { + OK = 0, + ERROR_INVALID_ARGUMENT = 1, + ERROR_LOAD = 2, + ERROR_VOICE_NOT_FOUND = 3, + ERROR_INFERENCE = 4, + ERROR_OUT_OF_MEMORY = 5, + ERROR_UNKNOWN = 6, +}); + +c_enum!(kokoro_number_language { AUTO = 0, ENGLISH = 1, CHINESE = 2, SPANISH = 3, GERMAN = 4 }); + +c_enum!(kokoro_language { AUTO = 0, SPANISH = 1, CHINESE_ENGLISH = 2, ENGLISH = 3, GERMAN = 4 }); + +c_enum!(kokoro_device { AUTO = 0, CPU = 1, CUDA = 2 }); + +#[repr(C)] +pub struct kokoro_ctx { + _private: [u8; 0], +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct kokoro_options { + pub device: kokoro_device, + pub gpu_id: c_int, +} + +#[repr(C)] +pub struct kokoro_audio { + pub samples: *mut c_float, + pub num_samples: usize, + pub sample_rate: c_int, +} + +extern "C" { + pub fn kokoro_version() -> *const c_char; + pub fn kokoro_last_error() -> *const c_char; + pub fn kokoro_create_ex( + model_path: *const c_char, + voices_path: *const c_char, + dict_dir: *const c_char, + options: *const kokoro_options, + out_ctx: *mut *mut kokoro_ctx, + ) -> kokoro_status; + pub fn kokoro_context_device(ctx: *const kokoro_ctx) -> kokoro_device; + pub fn kokoro_default_options() -> kokoro_options; + pub fn kokoro_destroy(ctx: *mut kokoro_ctx); + pub fn kokoro_voice_count(ctx: *const kokoro_ctx) -> usize; + pub fn kokoro_voice_name(ctx: *const kokoro_ctx, index: usize) -> *const c_char; + pub fn kokoro_synthesize( + ctx: *mut kokoro_ctx, + text: *const c_char, + voice: *const c_char, + speed: c_float, + flags: c_uint, + out_audio: *mut kokoro_audio, + ) -> kokoro_status; + pub fn kokoro_set_number_language(ctx: *mut kokoro_ctx, language: kokoro_number_language) -> kokoro_status; + pub fn kokoro_set_language(ctx: *mut kokoro_ctx, language: kokoro_language) -> kokoro_status; + pub fn kokoro_audio_free(audio: *mut kokoro_audio); +} diff --git a/src/server/lib.rs b/src/server/lib.rs new file mode 100644 index 0000000..866f4c4 --- /dev/null +++ b/src/server/lib.rs @@ -0,0 +1,21 @@ +//! kokoro-server: OpenAI-compatible text-to-speech HTTP server on top of libkokoro (public C API only). +pub mod audio_encoding; +pub mod engine; +pub mod ffi; +pub mod listener; +pub mod options; +pub mod routes; +pub mod speech_request; + +use std::future::Future; +use std::io; + +/// Serves the routes on an already bound, non-blocking listener until shutdown resolves. +pub async fn serve( + listener: std::net::TcpListener, + state: routes::AppState, + shutdown: impl Future + Send + 'static, +) -> io::Result<()> { + let listener = tokio::net::TcpListener::from_std(listener)?; + axum::serve(listener, routes::router(state)).with_graceful_shutdown(shutdown).await +} diff --git a/src/server/listener.rs b/src/server/listener.rs new file mode 100644 index 0000000..5cbe5e1 --- /dev/null +++ b/src/server/listener.rs @@ -0,0 +1,73 @@ +//! The listen socket. A second server on the same port must fail to bind instead of sharing it. +use socket2::{Domain, Protocol, Socket, Type}; +use std::io; +use std::net::{SocketAddr, TcpListener, ToSocketAddrs}; + +const BACKLOG: i32 = 1024; + +#[cfg(windows)] +fn exclusive(socket: &Socket) -> io::Result<()> { + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{setsockopt, SOCKET, SOL_SOCKET, SO_EXCLUSIVEADDRUSE}; + let enable: i32 = 1; + // SAFETY: the socket handle is live and the option value points at an i32 of the given length. + let result = unsafe { + setsockopt( + socket.as_raw_socket() as SOCKET, + SOL_SOCKET, + SO_EXCLUSIVEADDRUSE, + (&enable as *const i32).cast(), + std::mem::size_of::() as i32, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +// Lets a restarted server bind during TIME_WAIT; unlike SO_REUSEPORT it never shares a listening port. +#[cfg(not(windows))] +fn exclusive(socket: &Socket) -> io::Result<()> { + socket.set_reuse_address(true) +} + +fn bind_one(addr: SocketAddr) -> io::Result { + let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; + exclusive(&socket)?; + socket.bind(&addr.into())?; + socket.listen(BACKLOG)?; + socket.set_nonblocking(true)?; + Ok(socket.into()) +} + +/// Binds the first resolved address of host:port that accepts the socket. +pub fn bind_exclusive(host: &str, port: u16) -> io::Result { + let mut last_error = io::Error::new(io::ErrorKind::AddrNotAvailable, "host resolved to no address"); + for addr in (host, port).to_socket_addrs()? { + match bind_one(addr) { + Ok(listener) => return Ok(listener), + Err(error) => last_error = error, + } + } + Err(last_error) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A port-sharing socket (SO_REUSEADDR on Windows, SO_REUSEPORT elsewhere) must not bind next to the server. + #[test] + fn port_sharing_socket_cannot_bind_beside_the_server() { + let server = bind_exclusive("127.0.0.1", 0).unwrap(); + let addr = server.local_addr().unwrap(); + let intruder = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP)).unwrap(); + intruder.set_reuse_address(true).unwrap(); + #[cfg(unix)] + intruder.set_reuse_port(true).unwrap(); + assert!(intruder.bind(&addr.into()).is_err()); + assert!(bind_exclusive("127.0.0.1", addr.port()).is_err()); + } +} diff --git a/src/server/main.rs b/src/server/main.rs new file mode 100644 index 0000000..cfb379e --- /dev/null +++ b/src/server/main.rs @@ -0,0 +1,76 @@ +use kokoro_server::engine::{library_version, Engine, EngineConfig}; +use kokoro_server::listener::bind_exclusive; +use kokoro_server::options::{parse_args, Command, Options, USAGE}; +use kokoro_server::routes::AppState; +use std::process::ExitCode; +use std::time::Instant; + +const EXIT_FAILURE: u8 = 1; +const EXIT_USAGE: u8 = 2; + +fn failure(message: &str) -> ExitCode { + eprintln!("kokoro-server: {message}"); + ExitCode::from(EXIT_FAILURE) +} + +fn usage_failure(message: &str) -> ExitCode { + eprintln!("kokoro-server: {message}\nTry 'kokoro-server --help'."); + ExitCode::from(EXIT_USAGE) +} + +fn engine_config(opt: &Options) -> EngineConfig { + EngineConfig { + model: opt.model.clone(), + voices: opt.voices.clone(), + dict: opt.dict.clone(), + device: opt.device, + gpu_id: opt.gpu_id, + number_language: opt.number_language, + language: opt.language, + } +} + +fn serve(state: AppState, opt: &Options) -> ExitCode { + let Ok(listener) = bind_exclusive(&opt.host, opt.port) else { + return failure(&format!("cannot listen on {}:{}", opt.host, opt.port)); + }; + eprintln!("Listening on http://{}:{}", opt.host, opt.port); + let served = tokio::runtime::Runtime::new() + .and_then(|runtime| runtime.block_on(kokoro_server::serve(listener, state, std::future::pending()))); + match served { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error.to_string()), + } +} + +fn run(opt: &Options) -> ExitCode { + let init_start = Instant::now(); + let engine = match Engine::create(&engine_config(opt)) { + Ok(engine) => engine, + Err(message) => return failure(&message), + }; + let device = engine.device_name(); + let state = AppState::new(engine, &opt.voice); + if !state.has_voice(&opt.voice) { + return failure(&format!("unknown voice '{}'", opt.voice)); + } + eprintln!("Initialized engine in {:.3} s ({device})", init_start.elapsed().as_secs_f64()); + serve(state, opt) +} + +fn main() -> ExitCode { + let args: Result, _> = std::env::args_os().skip(1).map(|arg| arg.into_string()).collect(); + let Ok(args) = args else { return usage_failure("arguments must be valid Unicode") }; + match parse_args(&args) { + Err(error) => usage_failure(&error.0), + Ok(Command::Help) => { + print!("{USAGE}"); + ExitCode::SUCCESS + } + Ok(Command::Version) => { + println!("{}", library_version()); + ExitCode::SUCCESS + } + Ok(Command::Run(opt)) => run(&opt), + } +} diff --git a/src/server/options.rs b/src/server/options.rs new file mode 100644 index 0000000..482deca --- /dev/null +++ b/src/server/options.rs @@ -0,0 +1,279 @@ +//! Command-line options of kokoro-server. +use crate::ffi::{kokoro_device, kokoro_language, kokoro_number_language}; + +pub const USAGE: &str = "\ +Usage: kokoro-server [options] + +Serve OpenAI-compatible text-to-speech over HTTP: POST /v1/audio/speech (wav, pcm), GET /v1/audio/voices, GET /health. + +Options: + -m, --model ONNX model file (default: models/kokoro-v1.1-zh.onnx) + --voices voices file (default: models/voices-v1.1-zh.bin) + -d, --dict dictionary directory (default: dict) + -v, --voice voice used when a request omits \"voice\" (default: af_maple) + --lang language for reading numbers (default: auto) + --language text language; auto: es for ef_*/em_*, de for df_*/dm_*, en for \ +af_/am_/bf_/bm_* voices, else zh (Chinese/English) (default: auto) + --device auto, cpu or cuda (default: auto) + --gpu-id CUDA device to use (default: 0) + --host address to listen on (default: 127.0.0.1) + --port TCP port to listen on (default: 8880) + -h, --help print this help and exit + --version print the library version and exit +"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Options { + pub model: String, + pub voices: String, + pub dict: String, + pub voice: String, + pub number_language: kokoro_number_language, // --lang + pub language: kokoro_language, // --language + pub device: kokoro_device, + pub gpu_id: i32, + pub host: String, + pub port: u16, +} + +impl Default for Options { + fn default() -> Self { + Self { + model: "models/kokoro-v1.1-zh.onnx".to_owned(), + voices: "models/voices-v1.1-zh.bin".to_owned(), + dict: "dict".to_owned(), + voice: "af_maple".to_owned(), + number_language: kokoro_number_language::AUTO, + language: kokoro_language::AUTO, + device: kokoro_device::AUTO, + gpu_id: 0, + host: "127.0.0.1".to_owned(), + port: 8880, + } + } +} + +#[derive(Debug, PartialEq)] +pub enum Command { + Run(Options), + Help, + Version, +} + +/// A usage error message, printed as `kokoro-server: `. +#[derive(Debug, PartialEq, Eq)] +pub struct UsageError(pub String); + +#[derive(Clone, Copy)] +enum Opt { + Model, + Voices, + Dict, + Voice, + Lang, + Language, + Device, + GpuId, + Host, + Port, + Help, + Version, +} + +struct OptionSpec { + short_name: Option<&'static str>, + long_name: &'static str, + id: Opt, + takes_value: bool, +} + +const fn spec(short_name: Option<&'static str>, long_name: &'static str, id: Opt, takes_value: bool) -> OptionSpec { + OptionSpec { short_name, long_name, id, takes_value } +} + +const OPTIONS: [OptionSpec; 12] = [ + spec(Some("-m"), "--model", Opt::Model, true), + spec(None, "--voices", Opt::Voices, true), + spec(Some("-d"), "--dict", Opt::Dict, true), + spec(Some("-v"), "--voice", Opt::Voice, true), + spec(None, "--lang", Opt::Lang, true), + spec(None, "--language", Opt::Language, true), + spec(None, "--device", Opt::Device, true), + spec(None, "--gpu-id", Opt::GpuId, true), + spec(None, "--host", Opt::Host, true), + spec(None, "--port", Opt::Port, true), + spec(Some("-h"), "--help", Opt::Help, false), + spec(None, "--version", Opt::Version, false), +]; + +fn find_option(name: &str) -> Option<&'static OptionSpec> { + OPTIONS.iter().find(|spec| spec.long_name == name || spec.short_name == Some(name)) +} + +fn usage_error(message: String) -> Result { + Err(UsageError(message)) +} + +// strtol-style: leading C whitespace and a sign are accepted, trailing characters are not. +fn parse_integer(value: &str) -> Option { + value.trim_start_matches(|c: char| c == ' ' || ('\t'..='\r').contains(&c)).parse().ok() +} + +pub fn parse_number_language(value: &str) -> Option { + match value { + "auto" => Some(kokoro_number_language::AUTO), + "en" => Some(kokoro_number_language::ENGLISH), + "zh" => Some(kokoro_number_language::CHINESE), + "es" => Some(kokoro_number_language::SPANISH), + "de" => Some(kokoro_number_language::GERMAN), + _ => None, + } +} + +pub fn parse_language(value: &str) -> Option { + match value { + "auto" => Some(kokoro_language::AUTO), + "zh" => Some(kokoro_language::CHINESE_ENGLISH), + "en" => Some(kokoro_language::ENGLISH), + "es" => Some(kokoro_language::SPANISH), + "de" => Some(kokoro_language::GERMAN), + _ => None, + } +} + +pub fn parse_device(value: &str) -> Option { + match value { + "auto" => Some(kokoro_device::AUTO), + "cpu" => Some(kokoro_device::CPU), + "cuda" => Some(kokoro_device::CUDA), + _ => None, + } +} + +pub fn parse_gpu_id(value: &str) -> Option { + parse_integer(value).and_then(|id| i32::try_from(id).ok()).filter(|&id| id >= 0) +} + +pub fn parse_port(value: &str) -> Option { + parse_integer(value).and_then(|port| u16::try_from(port).ok()).filter(|&port| port >= 1) +} + +fn checked(parsed: Option, message: impl FnOnce() -> String) -> Result { + parsed.map_or_else(|| usage_error(message()), Ok) +} + +// Applies one option value to opt. +fn apply_value(id: Opt, value: String, opt: &mut Options) -> Result<(), UsageError> { + match id { + Opt::Model => opt.model = value, + Opt::Voices => opt.voices = value, + Opt::Dict => opt.dict = value, + Opt::Voice => opt.voice = value, + Opt::Host => opt.host = value, + Opt::Lang => { + opt.number_language = checked(parse_number_language(&value), || { + format!("invalid number language '{value}' (must be auto, en, zh, es or de)") + })? + } + Opt::Language => { + opt.language = checked(parse_language(&value), || { + format!("invalid language '{value}' (must be auto, zh, en, es or de)") + })? + } + Opt::Device => { + opt.device = + checked(parse_device(&value), || format!("invalid device '{value}' (expected auto, cpu or cuda)"))? + } + Opt::GpuId => { + opt.gpu_id = + checked(parse_gpu_id(&value), || format!("invalid GPU id '{value}' (must be an integer >= 0)"))? + } + Opt::Port => opt.port = checked(parse_port(&value), || format!("invalid port '{value}' (must be 1-65535)"))?, + Opt::Help | Opt::Version => {} + } + Ok(()) +} + +// Splits "--name=value"; only long options accept an inline value. +fn split_inline(arg: &str) -> (&str, Option<&str>) { + match arg.split_once('=') { + Some((name, value)) if arg.starts_with("--") => (name, Some(value)), + _ => (arg, None), + } +} + +/// Parses the arguments after the program name. Parsing stops at the first `--help` or `--version`. +pub fn parse_args(args: &[String]) -> Result { + let mut opt = Options::default(); + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + if arg.len() < 2 || !arg.starts_with('-') { + return usage_error(format!("unexpected argument '{arg}'")); + } + let (name, inline) = split_inline(arg); + let Some(spec) = find_option(name) else { return usage_error(format!("unknown option '{arg}'")) }; + match (spec.id, spec.takes_value, inline) { + (_, false, Some(_)) => return usage_error(format!("option '{name}' does not take a value")), + (Opt::Help, ..) => return Ok(Command::Help), + (Opt::Version, ..) => return Ok(Command::Version), + (id, true, Some(value)) => apply_value(id, value.to_owned(), &mut opt)?, + (id, true, None) => { + let Some(value) = rest.next() else { + return usage_error(format!("option '{name}' requires a value")); + }; + apply_value(id, value.clone(), &mut opt)? + } + (_, false, None) => {} + } + } + Ok(Command::Run(opt)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + parse_args(&args.iter().map(|arg| arg.to_string()).collect::>()) + } + + fn run(args: &[&str]) -> Options { + match parse(args) { + Ok(Command::Run(opt)) => opt, + other => panic!("expected Run, got {other:?}"), + } + } + + fn error(args: &[&str]) -> String { + parse(args).expect_err("arguments should be rejected").0 + } + + #[test] + fn values_short_and_long_inline() { + let opt = run(&["-m", "a.onnx", "--voices=v.bin", "-v", "bf_vale", "--port=9000", "--lang", "es"]); + assert_eq!((opt.model.as_str(), opt.voices.as_str(), opt.voice.as_str()), ("a.onnx", "v.bin", "bf_vale")); + assert_eq!(opt.port, 9000); + assert_eq!(opt.number_language, kokoro_number_language::SPANISH); + assert_eq!(run(&[]), Options::default()); + } + + #[test] + fn usage_errors() { + assert_eq!(error(&["x"]), "unexpected argument 'x'"); + assert_eq!(error(&["-v=x"]), "unknown option '-v=x'"); + assert_eq!(error(&["--help=1"]), "option '--help' does not take a value"); + assert_eq!(error(&["--port"]), "option '--port' requires a value"); + assert_eq!(error(&["--device", "tpu"]), "invalid device 'tpu' (expected auto, cpu or cuda)"); + assert_eq!(error(&["--gpu-id", "-1"]), "invalid GPU id '-1' (must be an integer >= 0)"); + for port in ["0", "65536", "8a", ""] { + assert_eq!(error(&["--port", port]), format!("invalid port '{port}' (must be 1-65535)")); + } + } + + #[test] + fn help_and_version_stop_parsing() { + assert_eq!(parse(&["--help", "--bogus"]), Ok(Command::Help)); + assert_eq!(parse(&["--version", "x"]), Ok(Command::Version)); + assert_eq!(error(&["--bogus", "--help"]), "unknown option '--bogus'"); + } +} diff --git a/src/server/routes.rs b/src/server/routes.rs new file mode 100644 index 0000000..51ed179 --- /dev/null +++ b/src/server/routes.rs @@ -0,0 +1,120 @@ +//! HTTP routes: POST /v1/audio/speech, GET /v1/audio/voices, GET /health. +use crate::audio_encoding::{encode_pcm16, encode_wav_pcm16, WavTooLong}; +use crate::engine::{Audio, Engine, SynthesisError}; +use crate::ffi::kokoro_status; +use crate::speech_request::{parse_speech_request, ResponseFormat, SpeechRequest}; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use serde_json::json; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::Mutex; + +const MAX_BODY_BYTES: usize = 1 << 20; + +/// What the handlers share. Synthesis is serialized by the mutex; waiting requests hold no thread. +#[derive(Clone)] +pub struct AppState { + engine: Arc>, + voices: Arc<[String]>, // sorted + default_voice: Arc, +} + +impl AppState { + pub fn new(engine: Engine, default_voice: &str) -> Self { + let voices = engine.voices().into(); + Self { engine: Arc::new(Mutex::new(engine)), voices, default_voice: default_voice.into() } + } + + pub fn has_voice(&self, voice: &str) -> bool { + self.voices.binary_search_by(|name| name.as_str().cmp(voice)).is_ok() + } +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/v1/audio/speech", post(speech)) + .route("/v1/audio/voices", get(voices)) + .route("/health", get(health)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .with_state(state) +} + +fn json_response(status: StatusCode, body: String) -> Response { + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() +} + +// OpenAI-style error body. +fn error_response(status: StatusCode, message: &str, param: Option<&str>) -> Response { + eprintln!("POST /v1/audio/speech {}: {message}", status.as_u16()); + let kind = if status.is_server_error() { "server_error" } else { "invalid_request_error" }; + let body = json!({"error": {"message": message, "type": kind, "param": param, "code": null}}); + json_response(status, body.to_string()) +} + +fn status_for(status: kokoro_status) -> StatusCode { + match status { + kokoro_status::ERROR_VOICE_NOT_FOUND | kokoro_status::ERROR_INVALID_ARGUMENT => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn encode(audio: &Audio, format: ResponseFormat) -> Result, WavTooLong> { + match format { + ResponseFormat::Pcm => Ok(encode_pcm16(audio.samples())), + ResponseFormat::Wav => encode_wav_pcm16(audio.samples(), audio.sample_rate_hz()), + } +} + +async fn synthesize(state: &AppState, request: &SpeechRequest) -> Result { + let mut engine = Arc::clone(&state.engine).lock_owned().await; + let (input, voice, speed) = (request.input.clone(), request.voice.clone(), request.speed); + tokio::task::spawn_blocking(move || engine.synthesize(&input, &voice, speed)) + .await + .unwrap_or_else(|join_error| std::panic::resume_unwind(join_error.into_panic())) +} + +async fn synthesize_into_response(state: &AppState, request: &SpeechRequest) -> Response { + let start = Instant::now(); + let audio = match synthesize(state, request).await { + Ok(audio) => audio, + Err(error) => return error_response(status_for(error.status), &error.message, None), + }; + let synth_s = start.elapsed().as_secs_f64(); + match encode(&audio, request.format) { + Ok(bytes) => { + eprintln!( + "POST /v1/audio/speech 200 voice={} format={} ({:.2} s audio, synthesis {synth_s:.3} s)", + request.voice, + request.format.name(), + audio.duration_s(), + ); + ([(header::CONTENT_TYPE, request.format.content_type())], bytes).into_response() + } + Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string(), None), + } +} + +async fn speech(State(state): State, body: Bytes) -> Response { + let request = match parse_speech_request(&body, &state.default_voice) { + Ok(request) => request, + Err(error) => return error_response(StatusCode::BAD_REQUEST, &error.message, error.param), + }; + if !state.has_voice(&request.voice) { + let message = format!("Unknown voice '{}'; GET /v1/audio/voices lists the available voices", request.voice); + return error_response(StatusCode::BAD_REQUEST, &message, Some("voice")); + } + synthesize_into_response(&state, &request).await +} + +async fn voices(State(state): State) -> Response { + json_response(StatusCode::OK, json!({"voices": &*state.voices}).to_string()) +} + +async fn health() -> Response { + json_response(StatusCode::OK, r#"{"status":"ok"}"#.to_owned()) +} diff --git a/src/server/rustfmt.toml b/src/server/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/src/server/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/src/server/speech_request.rs b/src/server/speech_request.rs new file mode 100644 index 0000000..568f632 --- /dev/null +++ b/src/server/speech_request.rs @@ -0,0 +1,189 @@ +//! Parsing of OpenAI-compatible `/v1/audio/speech` request bodies. +use serde_json::{Map, Value}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResponseFormat { + Wav, + Pcm, +} + +impl ResponseFormat { + pub fn content_type(self) -> &'static str { + match self { + Self::Wav => "audio/wav", + Self::Pcm => "audio/pcm", + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Wav => "wav", + Self::Pcm => "pcm", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SpeechRequest { + pub input: String, + pub voice: String, + pub speed: f32, + pub format: ResponseFormat, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestError { + pub message: String, + pub param: Option<&'static str>, // None when the error is not about one field +} + +type Body = Map; + +fn fail(message: impl Into, param: Option<&'static str>) -> Result { + Err(RequestError { message: message.into(), param }) +} + +// The member, or None when it is missing or JSON null. +fn optional_member<'a>(body: &'a Body, key: &str) -> Option<&'a Value> { + body.get(key).filter(|value| !value.is_null()) +} + +// C-locale isspace: space, \t, \n, \v, \f, \r. (char::is_ascii_whitespace leaves out \v.) +fn is_blank(text: &str) -> bool { + text.bytes().all(|b| b == b' ' || (b'\t'..=b'\r').contains(&b)) +} + +fn parse_input(body: &Body) -> Result { + match optional_member(body, "input").and_then(Value::as_str) { + Some(text) if !is_blank(text) => Ok(text.to_owned()), + _ => fail("'input' must be a non-empty string", Some("input")), + } +} + +fn parse_voice(body: &Body, default_voice: &str) -> Result { + match optional_member(body, "voice") { + None => Ok(default_voice.to_owned()), + Some(Value::String(voice)) => Ok(voice.clone()), + Some(_) => fail("'voice' must be a string", Some("voice")), + } +} + +fn parse_speed(body: &Body) -> Result { + let Some(speed) = optional_member(body, "speed") else { return Ok(1.0) }; + // Check the f32 the engine receives: f64 values beyond f32 overflow to infinity or underflow to 0. + match speed.as_f64().map(|value| value as f32) { + Some(value) if value.is_finite() && value > 0.0 => Ok(value), + _ => fail("'speed' must be a number > 0", Some("speed")), + } +} + +fn parse_format(body: &Body) -> Result { + let value = match optional_member(body, "response_format") { + None => "wav".to_owned(), + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + }; + match value.as_str() { + "wav" => Ok(ResponseFormat::Wav), + "pcm" => Ok(ResponseFormat::Pcm), + _ => fail( + format!("Unsupported response_format '{value}'; kokoro-server supports wav and pcm"), + Some("response_format"), + ), + } +} + +fn check_stream_format(body: &Body) -> Result<(), RequestError> { + match optional_member(body, "stream_format") { + None => Ok(()), + Some(Value::String(text)) if text == "audio" => Ok(()), + Some(_) => fail("Only stream_format 'audio' is supported", Some("stream_format")), + } +} + +/// Parses an OpenAI `/v1/audio/speech` JSON body. `model`, `instructions` and unknown keys are accepted and ignored, +/// as OpenAI clients send them. +pub fn parse_speech_request(body: &[u8], default_voice: &str) -> Result { + let Ok(Value::Object(body)) = serde_json::from_slice::(body) else { + return fail("Request body must be a JSON object", None); + }; + let request = SpeechRequest { + input: parse_input(&body)?, + voice: parse_voice(&body, default_voice)?, + speed: parse_speed(&body)?, + format: parse_format(&body)?, + }; + check_stream_format(&body)?; + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accepted(body: &str) -> SpeechRequest { + parse_speech_request(body.as_bytes(), "af_maple").expect("request should be accepted") + } + + fn rejected(body: &str) -> RequestError { + parse_speech_request(body.as_bytes(), "af_maple").expect_err("request should be rejected") + } + + #[test] + fn defaults() { + let request = accepted(r#"{"model":"tts-1","input":" hello ","instructions":"x","extra":1}"#); + assert_eq!(request.input, " hello "); + assert_eq!(request.voice, "af_maple"); + assert_eq!(request.speed, 1.0); + assert_eq!(request.format, ResponseFormat::Wav); + } + + #[test] + fn accepted_values() { + let request = + accepted(r#"{"input":"x","voice":"bf_vale","speed":2,"response_format":"pcm","stream_format":"audio"}"#); + assert_eq!(request.voice, "bf_vale"); + assert_eq!(request.speed, 2.0); + assert_eq!(request.format, ResponseFormat::Pcm); + assert_eq!(accepted(r#"{"input":"x","voice":null,"speed":null,"response_format":null}"#).voice, "af_maple"); + } + + #[test] + fn errors_name_the_offending_field() { + assert_eq!(rejected("not json").message, "Request body must be a JSON object"); + assert_eq!(rejected("[]").param, None); + assert_eq!(rejected("{}").param, Some("input")); + assert_eq!(rejected(r#"{"input":" \t"}"#).message, "'input' must be a non-empty string"); + assert_eq!(rejected(r#"{"input":3}"#).param, Some("input")); + assert_eq!(rejected(r#"{"input":"x","voice":1}"#).message, "'voice' must be a string"); + assert_eq!(rejected(r#"{"input":"x","speed":0}"#).message, "'speed' must be a number > 0"); + assert_eq!(rejected(r#"{"input":"x","speed":"fast"}"#).param, Some("speed")); + assert_eq!(rejected(r#"{"input":"x","speed":1e300}"#).param, Some("speed")); + assert_eq!(rejected(r#"{"input":"x","speed":1e-60}"#).param, Some("speed")); + let mp3 = rejected(r#"{"input":"x","response_format":"mp3"}"#); + assert_eq!(mp3.message, "Unsupported response_format 'mp3'; kokoro-server supports wav and pcm"); + assert_eq!(mp3.param, Some("response_format")); + let sse = rejected(r#"{"input":"x","stream_format":"sse"}"#); + assert_eq!(sse.message, "Only stream_format 'audio' is supported"); + assert_eq!(sse.param, Some("stream_format")); + } + + #[test] + fn vertical_tab_counts_as_blank() { + assert_eq!(rejected(r#"{"input":"\u000b\f"}"#).param, Some("input")); + } + + #[test] + fn non_string_format_is_echoed_as_json() { + assert_eq!( + rejected(r#"{"input":"x","response_format":3}"#).message, + "Unsupported response_format '3'; kokoro-server supports wav and pcm" + ); + } + + #[test] + fn fields_are_checked_in_order() { + assert_eq!(rejected(r#"{"input":"","voice":1,"speed":0}"#).param, Some("input")); + assert_eq!(rejected(r#"{"input":"x","speed":-1,"response_format":"mp3"}"#).param, Some("speed")); + } +} diff --git a/src/server/tests/server_synthesis.rs b/src/server/tests/server_synthesis.rs new file mode 100644 index 0000000..03ed160 --- /dev/null +++ b/src/server/tests/server_synthesis.rs @@ -0,0 +1,86 @@ +//! End-to-end HTTP checks against the real model. KOKORO_TEST_MODEL, KOKORO_TEST_VOICES and KOKORO_TEST_DICT_DIR +//! name the files; the test is skipped when the model or voices file is missing. +use kokoro_server::engine::{Engine, EngineConfig}; +use kokoro_server::ffi::{kokoro_device, kokoro_language, kokoro_number_language}; +use kokoro_server::listener::bind_exclusive; +use kokoro_server::routes::AppState; +use serde_json::Value; +use std::path::Path; + +fn test_config() -> Option { + let path = |key: &str| std::env::var(key).ok(); + let (model, voices, dict) = + (path("KOKORO_TEST_MODEL")?, path("KOKORO_TEST_VOICES")?, path("KOKORO_TEST_DICT_DIR")?); + if !Path::new(&model).exists() || !Path::new(&voices).exists() { + return None; + } + Some(EngineConfig { + model, + voices, + dict, + device: kokoro_device::AUTO, + gpu_id: 0, + number_language: kokoro_number_language::AUTO, + language: kokoro_language::AUTO, + }) +} + +// Starts a server on an ephemeral loopback port and returns its base URL. +fn start_server(config: &EngineConfig) -> String { + let engine = Engine::create(config).expect("engine should load"); + let listener = bind_exclusive("127.0.0.1", 0).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let state = AppState::new(engine, "af_maple"); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(kokoro_server::serve(listener, state, std::future::pending())).unwrap(); + }); + url +} + +struct Reply { + status: u16, + content_type: String, + body: Vec, +} + +fn reply(mut response: ureq::http::Response) -> Reply { + let content_type = response.headers().get("content-type").map_or("", |v| v.to_str().unwrap()).to_owned(); + Reply { status: response.status().as_u16(), content_type, body: response.body_mut().read_to_vec().unwrap() } +} + +fn post(agent: &ureq::Agent, url: &str, body: &str) -> Reply { + reply(agent.post(format!("{url}/v1/audio/speech")).header("Content-Type", "application/json").send(body).unwrap()) +} + +fn json(reply: &Reply) -> Value { + serde_json::from_slice(&reply.body).unwrap() +} + +#[test] +fn server_synthesizes_audio() { + let Some(config) = test_config() else { + eprintln!("skipped: model or voices file not found"); + return; + }; + let url = start_server(&config); + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + + let wav = post(&agent, &url, r#"{"input":"Hello world"}"#); + assert_eq!((wav.status, wav.content_type.as_str()), (200, "audio/wav")); + assert!(wav.body.len() > 44 && wav.body.starts_with(b"RIFF")); + + let pcm = post(&agent, &url, r#"{"input":"Hello world","response_format":"pcm"}"#); + assert_eq!((pcm.status, pcm.content_type.as_str()), (200, "audio/pcm")); + assert!(!pcm.body.is_empty() && pcm.body.len() % 2 == 0); + + let voice = post(&agent, &url, r#"{"input":"Hello world","voice":"nope"}"#); + assert_eq!(voice.status, 400); + assert_eq!(json(&voice)["error"]["param"], "voice"); + + assert_eq!(post(&agent, &url, r#"{"input":"Hello world","response_format":"mp3"}"#).status, 400); + + let voices = reply(agent.get(format!("{url}/v1/audio/voices")).call().unwrap()); + assert_eq!(voices.status, 200); + assert!(json(&voices)["voices"].as_array().unwrap().iter().any(|v| v == "af_maple")); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1946f21..4f66705 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,28 @@ target_link_libraries(test_synthesis PRIVATE kokoro kokoro_doctest) add_test(NAME synthesis COMMAND test_synthesis "${KOKORO_TEST_MODEL}" "${KOKORO_TEST_VOICES}" "${KOKORO_TEST_DICT_DIR}") set_tests_properties(synthesis PROPERTIES SKIP_RETURN_CODE 77) +# kokoro-server: unit tests and the end-to-end HTTP test (skipped without the model) run under cargo. +if (KOKORO_BUILD_SERVER) + set(server_test_env ${KOKORO_CARGO_ENV} + "KOKORO_TEST_MODEL=${KOKORO_TEST_MODEL}" + "KOKORO_TEST_VOICES=${KOKORO_TEST_VOICES}" + "KOKORO_TEST_DICT_DIR=${KOKORO_TEST_DICT_DIR}") + if (WIN32) + # Windows has no rpath: put kokoro.dll and the ONNX Runtime DLLs next to cargo's test executables. + add_custom_target(server_test_dlls ALL + COMMAND ${CMAKE_COMMAND} -E make_directory + "${KOKORO_CARGO_TARGET_DIR}/$,debug,release>/deps" + COMMAND ${CMAKE_COMMAND} -E copy_if_different $ ${ONNXRUNTIME_RUNTIME_DLLS} + "${KOKORO_CARGO_TARGET_DIR}/$,debug,release>/deps" + VERBATIM) + add_dependencies(server_test_dlls kokoro) + endif() + add_test(NAME server + COMMAND ${CMAKE_COMMAND} -E env ${server_test_env} + "${CARGO}" test --locked --profile ${KOKORO_CARGO_PROFILE} + --manifest-path "${PROJECT_SOURCE_DIR}/src/server/Cargo.toml") +endif() + add_executable(test_synthesis_es test_synthesis_es.cpp) target_link_libraries(test_synthesis_es PRIVATE kokoro kokoro_doctest) add_test(NAME synthesis_es COMMAND test_synthesis_es "${KOKORO_TEST_MODEL_ES}" "${KOKORO_TEST_VOICES_ES}" diff --git a/tests/test_synthesis.cpp b/tests/test_synthesis.cpp index 87d30f2..240c165 100644 --- a/tests/test_synthesis.cpp +++ b/tests/test_synthesis.cpp @@ -233,7 +233,7 @@ TEST_CASE("unknown_voice_is_reported") { } TEST_CASE("invalid_speed_and_flags_rejected") { - const float bad_speeds[] = {0.0f, -1.0f, NAN, INFINITY}; + const float bad_speeds[] = {0.0f, -1.0f, NAN, INFINITY, 2147483648.0f, 1e20f}; for (float speed : bad_speeds) { CAPTURE(speed); kokoro_audio audio{};