Skip to content
Closed
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
27 changes: 27 additions & 0 deletions docs/c_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,33 @@ if ((audiocpp_abi_version() >> 16) != AUDIOCPP_ABI_VERSION_MAJOR) {
}
```

**minor** increments when entry points are added. Nothing is removed or changed
by such a release, so a caller built against a lower minor keeps working
untouched — but a caller that needs a newer entry point can say so, which is
the only reason the field carries information:

```c
/* audiocpp_request_set_option_array arrived in 0.2. */
if ((audiocpp_abi_version() & 0xffff) < 0x0200) {
/* fall back, or refuse, rather than resolving a symbol that is not there */
}
```

A C caller can also just resolve the symbol and test for NULL, which is exact
and needs no number at all. The version is for the callers that cannot: a
binding that declares its imports up front — C#, JNA, ctypes with prototypes —
binds on first use and raises a missing-symbol error from inside the call, which
is a poor way to discover that a library is too old.

**patch** is for behaviour fixes that add and change no surface. Do not gate on
it; it tells a caller nothing about what it may call.

⚠ `0.1` covers two different surfaces. The four entry points added in #544
(`audiocpp_task_count`, `audiocpp_task_name`, `audiocpp_task_from_spec_name`,
`audiocpp_request_set_text_language`) shipped without a bump, before this rule
existed, so a library reporting `0.1` may or may not have them. From `0.2`
onward the minor is the answer.

## Usage

```c
Expand Down
22 changes: 20 additions & 2 deletions include/audiocpp.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,14 @@ extern "C" {
/* ------------------------------------------------------------------ */

#define AUDIOCPP_ABI_VERSION_MAJOR 0
#define AUDIOCPP_ABI_VERSION_MINOR 1
#define AUDIOCPP_ABI_VERSION_MINOR 2
#define AUDIOCPP_ABI_VERSION_PATCH 0

/* Packed as (major << 16) | (minor << 8) | patch. A caller built against a
* different MAJOR must not use the library. */
* different MAJOR must not use the library. MINOR increments when entry points
* are added -- nothing is removed or changed -- so a caller needing a newer one
* can require a minimum; PATCH is behaviour only and must not be gated on. See
* docs/c_api.md. */
AUDIOCPP_API uint32_t audiocpp_abi_version(void);

/* audio.cpp's own build version, e.g. "0.2.1". Borrowed, static lifetime. */
Expand Down Expand Up @@ -370,6 +373,21 @@ AUDIOCPP_API audiocpp_status audiocpp_request_set_option(audiocpp_request * requ
const char * key,
const char * value);

/* Sets a list-valued request option -- the transport for the `*_list` option
* types the model spec already declares. `values` is `count` UTF-8 strings,
* copied into the request, so neither the array nor the strings need outlive
* the call. A second call with the same key REPLACES the list rather than
* appending, matching set_option's assignment semantics.
*
* List options live in their own map, so a key set here is not visible to a
* family reading single-valued options and vice versa; a family declares which
* one it wants by the type it puts in its spec. `count` may be 0, which sets an
* empty list -- distinct from never setting the key at all. */
AUDIOCPP_API audiocpp_status audiocpp_request_set_option_array(audiocpp_request * request,
const char * key,
const char * const * values,
size_t count);

/* ------------------------------------------------------------------ */
/* Result */
/* ------------------------------------------------------------------ */
Expand Down
7 changes: 7 additions & 0 deletions include/engine/framework/runtime/session.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ struct TaskRequest {
std::optional<VoiceCondition> voice = std::nullopt;
std::vector<VoiceArtifact> input_artifacts;
std::unordered_map<std::string, std::string> options;
/// List-valued options, kept apart from the single-valued ones so a family
/// reading either cannot silently see the other half-formed. The `*_list`
/// option types the spec schema already declares are carried here.
std::unordered_map<std::string, std::vector<std::string>> option_arrays;
};

struct AudioPreparationContract {
Expand All @@ -176,6 +180,9 @@ struct SessionPreparationRequest {
std::optional<Transcript> text = std::nullopt;
std::optional<VoiceCondition> voice = std::nullopt;
std::unordered_map<std::string, std::string> options;
/// See TaskRequest::option_arrays. Carried through preparation so a session
/// can size its graphs for what run() will actually be handed.
std::unordered_map<std::string, std::vector<std::string>> option_arrays;
};

struct VoiceActivityEvent {
Expand Down
17 changes: 17 additions & 0 deletions include/engine/framework/runtime/spec_backed_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include <string_view>
#include <unordered_map>
#include <utility>
Expand Down Expand Up @@ -67,6 +68,22 @@ inline void validate_spec_backed_request_options(
}
}

/// The same check for list-valued options. An overload rather than a second
/// name: a family that gained a list option should not have to remember to call
/// something different, and every existing call site keeps working untouched.
inline void validate_spec_backed_request_options(
const std::unordered_map<std::string, std::string> & options,
const std::unordered_map<std::string, std::vector<std::string>> & option_arrays,
const engine::model_spec::ModelContract & contract,
std::string_view model_name) {
validate_spec_backed_request_options(options, contract, model_name);
for (const auto & [key, _] : option_arrays) {
if (contract.request_option_keys.find(key) == contract.request_option_keys.end()) {
throw std::runtime_error("unknown " + std::string(model_name) + " request option: " + key);
}
}
}

inline std::unordered_map<std::string, std::string> apply_option_v1_compatibility(
std::unordered_map<std::string, std::string> options,
std::initializer_list<OptionV1CompatibilityAlias> aliases,
Expand Down
11 changes: 9 additions & 2 deletions include/engine/models/kokoro_tts/frontend.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state(
const std::optional<runtime::VoiceCondition> & voice,
const KokoroAssets & assets);

// `phoneme_override`, when non-empty, is synthesized as-is INSTEAD of running the built-in
// G2P over `text`. It lets a caller with its own grapheme-to-phoneme stage — a lexicon the
// engine does not carry, a language it does not cover, a pronunciation the application has
// already shown its user — drive the model directly. `text` is still required and its
// language must still agree with the voice; only the phonemization is replaced.
KokoroSynthesisInput build_kokoro_synthesis_input(
const runtime::Transcript & text,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets);
const KokoroAssets & assets,
const std::string & phoneme_override = std::string());

int64_t estimate_kokoro_request_tokens(
const runtime::SessionPreparationRequest & request,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets);
const KokoroAssets & assets,
const std::string & phoneme_override = std::string());

} // namespace engine::models::kokoro_tts
6 changes: 6 additions & 0 deletions model_specs/kokoro_tts.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
"required": false,
"min": 0
},
{
"name": "phonemes",
"type": "string_list",
"description": "Kokoro-vocabulary phonemes to synthesize directly, bypassing the built-in eSpeak-ng G2P. One entry per chunk: entries are rendered in order and merged into one result, so the caller chooses the split points that only its own G2P knows. Each entry must be at most 510 symbols. Text is still required and its language must still match the voice.",
"required": false
},
{
"name": "text_chunk_size",
"type": "int",
Expand Down
28 changes: 28 additions & 0 deletions src/capi/audiocpp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,34 @@ audiocpp_status audiocpp_request_set_option(audiocpp_request * request, const ch
});
}

audiocpp_status audiocpp_request_set_option_array(audiocpp_request * request,
const char * key,
const char * const * values,
size_t count) {
if (request == nullptr || key == nullptr) {
return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "request and key must be non-null");
}
if (values == nullptr && count != 0) {
return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "values must be non-null when count is not 0");
}
for (size_t i = 0; i < count; ++i) {
// Checked before anything is written, so a bad element cannot leave the
// option half-assigned.
if (values[i] == nullptr) {
return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "option array values must be non-null");
}
}
return guard([&] {
std::vector<std::string> copied;
copied.reserve(count);
for (size_t i = 0; i < count; ++i) {
copied.emplace_back(values[i]);
}
request->request.option_arrays[key] = std::move(copied);
return AUDIOCPP_OK;
});
}

/* ------------------------------------------------------------------ */
/* Result */
/* ------------------------------------------------------------------ */
Expand Down
1 change: 1 addition & 0 deletions src/framework/model_spec/options.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const std::unordered_map<std::string, std::unordered_set<std::string>> & shared_
{"min_p", {"float"}},
{"matmul_weight_type", {"enum"}},
{"negative_prompt", {"string"}},
{"phonemes", {"string_list"}},
{"num_beams", {"int"}},
{"num_inference_steps", {"int"}},
{"output_sample_rate", {"int"}},
Expand Down
68 changes: 59 additions & 9 deletions src/models/kokoro_tts/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include "engine/models/kokoro_tts/g2p_multilingual.h"

#include "engine/framework/debug/trace.h"

#include <algorithm>
#include <cctype>
#include <cstring>
Expand Down Expand Up @@ -115,9 +117,22 @@ struct EncodedInputIds {
size_t phoneme_count = 0;
};

/// `reject_unknown` decides what an out-of-vocabulary symbol means, and the answer depends
/// entirely on who produced the stream.
///
/// ⚠ FALSE for our own G2P, TRUE for a caller's. The reference implementation (hexgrad/Kokoro's
/// KModel, `filter(None, map(vocab.get, phonemes))`) drops what it cannot tokenize, and for our
/// own output that is the only sane answer: eSpeak emits a syllabic mark for "button" that this
/// vocabulary has no id for, and nobody downstream can do anything about it.
///
/// A CALLER'S stream is the opposite case. They can fix it, so telling them is strictly more
/// useful than guessing -- and guessing is not harmless here. Canonical IPA writes a diphthong
/// as two symbols, and Kokoro writes it as one, so dropping the off-glide silently turns
/// `lˈaᶦk` into `lˈak`: "like" becomes "lack", with no error and audio that sounds deliberate.
EncodedInputIds encode_input_ids_and_count(
const std::string & phonemes,
const KokoroAssets & assets) {
const KokoroAssets & assets,
bool reject_unknown) {
EncodedInputIds encoded;
encoded.ids.reserve(phonemes.size() + 2);
encoded.ids.push_back(0);
Expand All @@ -144,9 +159,30 @@ EncodedInputIds encode_input_ids_and_count(
throw std::runtime_error("invalid UTF-8 continuation byte in Kokoro phoneme string");
}
}
const auto it = assets.vocab.find(phonemes.substr(i, width));
const std::string symbol = phonemes.substr(i, width);
const auto it = assets.vocab.find(symbol);
if (it == assets.vocab.end()) {
throw std::runtime_error("Kokoro vocab is missing phoneme symbol: " + phonemes.substr(i, width));
if (reject_unknown) {
throw std::runtime_error(
"Kokoro vocab is missing phoneme symbol: " + symbol +
"; supplied phonemes must be in Kokoro's own " + std::to_string(assets.vocab.size()) +
"-symbol vocabulary, which is not canonical IPA -- a diphthong is one symbol there"
" and two in IPA, so an off-glide is a common cause");
}
// Skipped, not fatal, matching the reference implementation: hexgrad/Kokoro's KModel
// tokenizes with `filter(None, map(vocab.get, phonemes))`, which drops any phoneme the
// 114-entry vocab has no id for.
//
// This matters because our OWN G2P produces such symbols for ordinary words: eSpeak-ng
// glottalises /t/ before a syllabic nasal, so "button" is `b'V?n` with a U+0329
// syllabic mark the vocab does not carry. Throwing there loses the whole request;
// dropping the mark gives a correct reading of the word.
//
// Malformed UTF-8 above still throws — that is a real error. An unknown but
// well-formed phoneme is not.
engine::debug::trace_log_scalar("kokoro.skipped_phoneme", std::string_view(symbol));
i += width;
continue;
}
encoded.ids.push_back(it->second);
++encoded.phoneme_count;
Expand Down Expand Up @@ -215,15 +251,28 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state(
KokoroSynthesisInput build_kokoro_synthesis_input(
const runtime::Transcript & text,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets) {
const KokoroAssets & assets,
const std::string & phoneme_override) {
if (state.voice_pack == nullptr) {
throw std::runtime_error("Kokoro frontend session voice pack was not prepared");
}
const std::string phonemes = phonemize_text(text, state.language_code, assets);
const EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets);
const bool supplied = !phoneme_override.empty();
const std::string phonemes =
supplied ? phoneme_override : phonemize_text(text, state.language_code, assets);
// Strict for a caller's stream, lenient for our own -- see encode_input_ids_and_count.
const EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets, supplied);
if (encoded.phoneme_count > 510) {
// Two different failures wearing one message helps nobody: the caller who supplied the
// phonemes can fix this by sending less, and is told so; the caller who supplied text
// is hitting an engine limitation and is told that instead.
throw std::runtime_error(
"Kokoro phoneme string exceeds 510 symbols; segmenting is not implemented in the framework path yet");
supplied
? "Kokoro phoneme chunk exceeds 510 symbols; split the supplied phonemes across "
"more list entries (each entry is rendered separately and the audio is merged)"
: "Kokoro phoneme string exceeds 510 symbols; segmenting is not implemented in the framework path yet");
}
if (supplied) {
engine::debug::trace_log_scalar("kokoro.supplied_phoneme_count", static_cast<int64_t>(encoded.phoneme_count));
}
KokoroSynthesisInput input;
input.voice_id = state.voice_id;
Expand All @@ -241,11 +290,12 @@ KokoroSynthesisInput build_kokoro_synthesis_input(
int64_t estimate_kokoro_request_tokens(
const runtime::SessionPreparationRequest & request,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets) {
const KokoroAssets & assets,
const std::string & phoneme_override) {
if (!request.text.has_value()) {
return 0;
}
const auto input = build_kokoro_synthesis_input(*request.text, state, assets);
const auto input = build_kokoro_synthesis_input(*request.text, state, assets, phoneme_override);
return static_cast<int64_t>(input.input_ids.size());
}

Expand Down
Loading