Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ option(ENGINE_HIP_STRIX_HALO_OPTIMIZATIONS
OFF)
option(ENGINE_ENABLE_NATIVE_CPU "Build ggml CPU kernels with native host ISA flags" ${ENGINE_DEFAULT_ENABLE_NATIVE_CPU})
option(ENGINE_ENABLE_OPENMP "Build host code with OpenMP support" ON)
option(AUDIOCPP_STATIC_ESPEAK "Build and statically link GPL-3.0-or-later eSpeak-ng; keep data separate" OFF)
option(AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER
"Build native model-manager tools and server download/install support"
OFF)
Expand Down Expand Up @@ -412,6 +413,8 @@ add_library(engine_core OBJECT
src/framework/runtime/workspace.cpp
src/framework/midi/midi_file.cpp
src/framework/io/filesystem.cpp
src/framework/audio/espeak_phonemizer.cpp
src/framework/audio/espeak_data.cpp
src/framework/io/config.cpp
src/framework/io/json.cpp
src/framework/io/text.cpp
Expand Down Expand Up @@ -1879,7 +1882,12 @@ target_include_directories(engine_runtime PRIVATE
)

target_link_libraries(engine_runtime PUBLIC ggml)
find_package(Threads REQUIRED)
target_link_libraries(engine_runtime PRIVATE Threads::Threads ${CMAKE_DL_LIBS})
target_link_libraries(engine_runtime PRIVATE sentencepiece cjson_vendor yaml_vendor)
if(AUDIOCPP_STATIC_ESPEAK)
include(tools/cmake/espeak_static.cmake)
endif()
if (AUDIOCPP_HIP_STRIX_HALO_OPTIMIZATIONS_ACTIVE)
target_compile_definitions(engine_runtime PRIVATE ENGINE_HIP_STRIX_HALO_OPTIMIZATIONS=1)
endif()
Expand Down Expand Up @@ -1948,6 +1956,9 @@ add_executable(audiocpp_cli
)

target_link_libraries(audiocpp_cli PRIVATE engine_runtime ggml)
if(AUDIOCPP_STATIC_ESPEAK)
audiocpp_stage_espeak(audiocpp_cli)
endif()
target_include_directories(audiocpp_cli PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
# MinGW selects the wmain() entry point only when linked with -municode;
# without it the CLI fails to link (undefined reference / ld error 5).
Expand Down Expand Up @@ -2021,6 +2032,9 @@ add_executable(audiocpp_server
)

target_link_libraries(audiocpp_server PRIVATE engine_runtime ggml)
if(AUDIOCPP_STATIC_ESPEAK)
audiocpp_stage_espeak(audiocpp_server)
endif()
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)
Expand Down Expand Up @@ -2143,6 +2157,19 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST
endfunction()

if (ENGINE_BUILD_TESTS)
add_engine_unittest(espeak_data_test tests/unittests/test_espeak_data.cpp)
target_link_libraries(espeak_data_test PRIVATE Threads::Threads)
add_test(NAME espeak_data_test COMMAND espeak_data_test)
add_library(espeak_test_library SHARED tests/unittests/espeak_test_library.cpp)
add_library(espeak_test_missing_symbol SHARED tests/unittests/espeak_test_library.cpp)
target_link_libraries(espeak_test_library PRIVATE Threads::Threads)
target_link_libraries(espeak_test_missing_symbol PRIVATE Threads::Threads)
target_compile_definitions(espeak_test_missing_symbol PRIVATE ESPEAK_TEST_MISSING_SYMBOL=1)
add_engine_unittest(espeak_phonemizer_test tests/unittests/test_espeak_phonemizer.cpp)
target_link_libraries(espeak_phonemizer_test PRIVATE Threads::Threads)
add_dependencies(espeak_phonemizer_test espeak_test_library espeak_test_missing_symbol)
add_test(NAME espeak_phonemizer_test COMMAND espeak_phonemizer_test
$<TARGET_FILE:espeak_test_library> $<TARGET_FILE:espeak_test_missing_symbol>)
add_executable(sentencepiece_tokenizer1_test
tests/unittests/test_sentencepiece_tokenizer1.cpp
)
Expand Down Expand Up @@ -2402,6 +2429,16 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST
endif()

if (ENGINE_BUILD_MODEL_TESTS)
if (sanotts IN_LIST AUDIOCPP_LINKED_MODELS AND inflect_v2 IN_LIST AUDIOCPP_LINKED_MODELS)
add_engine_unittest(espeak_frontend_probe tests/unittests/espeak_frontend_probe.cpp)
if(AUDIOCPP_STATIC_ESPEAK)
audiocpp_stage_espeak(espeak_frontend_probe)
add_test(NAME espeak_static_frontend_test COMMAND espeak_frontend_probe - - concurrent)
endif()
if (MSVC)
target_compile_options(espeak_frontend_probe PRIVATE /utf-8)
endif()
endif()
# Model-specific tests and probes.
if (f5_tts IN_LIST AUDIOCPP_LINKED_MODELS)
target_compile_definitions(engine_model_f5_tts PRIVATE F5_MEL_TEST=1)
Expand Down
7 changes: 6 additions & 1 deletion docs/community_models/inflect_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ the official ONNX exports for manual testing.

## Install

Install eSpeak-ng and its English voice data first. On Debian or Ubuntu:
Inflect v2 uses the [shared eSpeak-ng phonemizer](../espeak_phonemizer.md),
including shared synchronization with other model frontends.

With `AUDIOCPP_STATIC_ESPEAK=ON`, the build includes eSpeak code and stages its
data beside the CLI/server; no separate installation is needed.
Otherwise install eSpeak-ng and its English voice data first. On Debian or Ubuntu:

```bash
sudo apt install espeak-ng libespeak-ng1
Expand Down
16 changes: 12 additions & 4 deletions docs/community_models/sanotts.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Two graphs share one family:

## Install

SanoTTS uses the [shared eSpeak-ng phonemizer](../espeak_phonemizer.md),
including shared synchronization with other model frontends.

Install eSpeak-ng and its voice data first. On Debian or Ubuntu:

```bash
Expand Down Expand Up @@ -85,7 +88,9 @@ voice was not trained on. Every voice drives the eSpeak-ng voice its Piper
teacher was trained against — `pt` uses `pt-br`, the rest use the bare
language code — so eSpeak-ng must have that language's data installed.

eSpeak-ng is loaded dynamically at runtime, never linked. If it is not on the
By default eSpeak-ng is loaded dynamically. Static builds with
`AUDIOCPP_STATIC_ESPEAK=ON` instead include its code and use executable-local data.
For a dynamic build, if eSpeak-ng is not on the
default library path:

```bash
Expand Down Expand Up @@ -274,6 +279,9 @@ either.

## Licensing

The sanoTTS runtimes and weights are MIT-licensed. eSpeak-ng is GPL-3.0 and
is therefore opened with `dlopen` at runtime and never linked, matching how
`inflect_v2` treats it.
The sanoTTS runtimes and weights are MIT-licensed. eSpeak-ng is GPL-3.0-or-later.
The default build loads an external library; `AUDIOCPP_STATIC_ESPEAK=ON`
statically links it. Distributors must comply with the applicable license terms
for their build, including corresponding-source requirements for combined static
builds. Dynamic loading does not itself waive license obligations. See the
[shared component documentation](../espeak_phonemizer.md).
153 changes: 153 additions & 0 deletions docs/espeak_phonemizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Shared eSpeak-ng phonemizer

`engine::audio::EspeakPhonemizer` is a reusable, optional runtime adapter for
eSpeak-ng. SanoTTS (E2M and Piper frontends) and Inflect v2 use it. Other models,
including the separate Kokoro preview, can use the same adapter without copying
dynamic-library loading or process-global state management.

By default users provide an installed shared library and its matching data.
Alternatively, `AUDIOCPP_STATIC_ESPEAK=ON` builds the pinned eSpeak-ng 1.52.0 source
and statically links its code into both CLI and server. No eSpeak DLL or `.so` is
required in that mode. Existing explicit library/data session options still work.

## Static build with separate data

```sh
cmake -S . -B build/static-espeak -DAUDIOCPP_STATIC_ESPEAK=ON
cmake --build build/static-espeak --config Release --target audiocpp_cli audiocpp_server
```

The first build downloads a SHA-256-verified upstream source archive. eSpeak is
built in an isolated CMake project; it does not change audio.cpp's shared-library
settings. Sonic, audio playback, MBROLA, asynchronous synthesis and speechPlayer
are disabled because this adapter only needs phonemization. The upstream tool
compiles the phoneme tables and all 114 language dictionaries. Cross-compiling
this option is currently rejected because generating data requires a host tool.

The build packs the data automatically. The executable output directory contains:

```text
audiocpp_cli[.exe]
audiocpp_server[.exe]
espeak-ng-data.bin
licenses/espeak-ng/
```

The code is linked separately into each executable. With no explicit session
paths, the static adapter finds `espeak-ng-data.bin` relative to the executable,
not the working directory. It also accepts the legacy `espeak-ng-data.gguf` name,
then an unpacked `espeak-ng-data` folder as fallbacks. Explicit model `espeak_data_path`
options accept a folder or a data package (`.bin` or `.gguf`), including in dynamic-library mode.
Keep the data package when moving the executables. Missing/invalid data produces an error;
it is not silently substituted with another installed version.

The `.bin` package internally uses data-only GGUF with zero tensors: dictionaries and phoneme tables, not
neural-model weights or audio recordings. It uses audio.cpp's binary embedded-file
metadata layout, format version 1, pinned to eSpeak-ng 1.52.0. The current package
is 18,384,736 bytes (about 17.5 MiB). It is not compressed. This does not add a
model-manager download package.

### Extraction cache

eSpeak still needs ordinary files. The adapter extracts into a per-user cache:

- Windows: `%LOCALAPPDATA%/audio.cpp/espeak-data/`
- Linux/macOS: `$XDG_CACHE_HOME/audio.cpp/espeak-data/`, or
`$HOME/.cache/audio.cpp/espeak-data/` when XDG_CACHE_HOME is unavailable.

CLI and server share content-keyed cache entries. On reuse, every file is checked
against the package without rewriting unchanged files. Extraction publishes a
complete directory atomically, so concurrent processes never use half-written
files. Changed packages and damaged caches get new entries; existing entries are
not modified while another engine may be using them. Unsafe paths, duplicate or
case-colliding names, invalid byte ranges and unsupported versions are rejected.
The package and extracted files each occupy disk space. Old cache entries can be
removed manually when no audio.cpp process is using them; automatic eviction is
not implemented.

To repack a compatible 1.52.0 data directory manually:

```sh
audiocpp_espeak_pack /path/to/espeak-ng-data /path/to/espeak-ng-data.bin
```

The license directory includes upstream COPYING, the original source archive,
and our CMake integration/patch scripts. eSpeak-ng retains GPL-3.0-or-later terms.
Static builds are opt-in and distributors must meet the applicable requirements
for the combined work, including corresponding source; copying only the license
notice is not sufficient. This option does not relicense the upstream dependency.

Validated on Windows x64/MSVC: static CLI/server builds, no eSpeak DLL import,
21/21 frontend token sequences identical to dynamic eSpeak-ng 1.52.0 using the
same generated data, including an executable-plus-data-package-only portable directory,
and 200 concurrent frontend requests. Five focused tests pass, including binary
packing/extraction, cache reuse, recovery, concurrent extraction and traversal
rejection. Linux/macOS build
paths are provided but have not been validated locally.

## Model integration

```cpp
#include "engine/framework/audio/espeak_phonemizer.h"

engine::audio::EspeakPhonemizer phonemizer(
library_path, // empty: static engine if enabled, else library search
espeak_data_directory, // empty: executable-local in static mode
{"en-us"}); // ordered voice candidates, chosen by the model

const auto ipa = phonemizer.phonemize(text, 2);
```

The adapter accepts eSpeak's phoneme-mode integer, including IPA ties and
separators. An optional third argument to `phonemize` controls how clauses are
joined (default: one space). Model-specific normalization, punctuation handling,
voice fallback policy, IPA cleanup and token mapping stay in the model frontend.
For example, Kokoro can request its caret-tied IPA mode; SanoTTS keeps its
regional voice preference and different E2M/Piper modes.

The constructor validates paths, required exports and voice availability. Calls
throw exceptions for unavailable dependencies instead of silently substituting
another phonemizer. Initialization requests eSpeak's `DONT_EXIT` behavior.

## Lifetime and concurrency

eSpeak owns a process-global translator and output buffer. A single shared
service serializes initialization, voice selection, clause processing and copying
the returned text. Each request reselects its client's voice. Creating or
destroying another frontend cannot terminate a currently running request.

The service caches one runtime for the active library/data path pair. Changing
either closes the old runtime and initializes the requested one under the same
lock; failed switches can be retried. Relative explicit paths are resolved when
the client is constructed. Keep the library and data files available while clients
use them. No neural-model weights or audio buffers are cached here.

All in-process eSpeak consumers must use this service: independent direct eSpeak
calls cannot participate in its lock. Kokoro migration is intentionally left to
its separate preview PR rather than including a model port in this refactor.

## Validation

Configure with `ENGINE_BUILD_TESTS=ON` for `espeak_phonemizer_test`. Its small mock
shared libraries exercise missing dependencies/symbols/voices, failure recovery,
clause joining, modes, cursor progress, client destruction and 1,000 concurrent
calls with different voices. The mock is test-only and requires no eSpeak install.

For frontend tests and the optional real-library probe:

```sh
cmake -S . -B build/espeak-tests -DAUDIOCPP_MODEL_SET=custom \
-DAUDIOCPP_MODELS="sanotts;inflect_v2" -DENGINE_BUILD_TESTS=ON \
-DENGINE_BUILD_MODEL_TESTS=ON
cmake --build build/espeak-tests --target espeak_phonemizer_test \
sanotts_frontend_test inflect_v2_frontend_test espeak_frontend_probe
ctest --test-dir build/espeak-tests --output-on-failure \
-R '^(espeak_phonemizer|sanotts_frontend|inflect_v2_frontend)_test$'
```

Run `espeak_frontend_probe <library> <espeak-ng-data> <mode>` using modes
`sanotts`, `inflect`, `piper`, or `concurrent`. The first three print deterministic
token sequences for comparison with pre-refactor frontends. `piper` covers eleven
languages using a synthetic IPA-range vocabulary, not downloaded model weights.
`concurrent` checks 200 interleaved SanoTTS/Inflect requests against serial results.
These are frontend tests, not end-to-end audio quality or GPU performance tests.
4 changes: 2 additions & 2 deletions include/engine/community_models/sanotts/frontend.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ struct SanoTtsEncoded {
* the project's own JavaScript and Python front ends so the three agree
* symbol for symbol.
*
* eSpeak-ng is opened at runtime and never linked, matching how inflect_v2
* treats it: it is GPL-3.0 and must not be embedded in this project.
* The shared adapter uses an external library by default. Opt-in static
* builds link eSpeak-ng (GPL-3.0-or-later); its data remains separate.
*/
class SanoTtsFrontend {
public:
Expand Down
10 changes: 10 additions & 0 deletions include/engine/framework/audio/espeak_data.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#pragma once
#include <filesystem>

namespace engine::audio {
// A data-only GGUF, using audio.cpp's existing embedded binary file layout.
void pack_espeak_data(const std::filesystem::path & directory,
const std::filesystem::path & output);
// Validates and atomically publishes an immutable, content-checked cache entry.
std::filesystem::path materialize_espeak_data(const std::filesystem::path & package);
}
27 changes: 27 additions & 0 deletions include/engine/framework/audio/espeak_phonemizer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#pragma once

#include <filesystem>
#include <string>
#include <vector>

namespace engine::audio {

// Raw eSpeak-ng phonemization only. Normalization, voice fallback policy,
// punctuation restoration and token mapping belong to each model frontend.
class EspeakPhonemizer {
public:
// data_directory accepts espeak-ng-data itself or an eSpeak data package.
// Empty paths use static/executable-local or dynamic/system defaults.
// Voices are tried in order.
EspeakPhonemizer(std::filesystem::path library,
std::filesystem::path data_directory,
std::vector<std::string> voices);
std::string phonemize(const std::string & text, int phonemes_mode,
const std::string & clause_separator = " ") const;
private:
std::filesystem::path library_;
std::filesystem::path data_;
std::vector<std::string> voices_;
};

} // namespace engine::audio
Loading
Loading