Skip to content

irodori_tts: accept Speaker Inversion embeddings as the speaker condition - #829

Merged
0xShug0 merged 1 commit into
0xShug0:mainfrom
KKTTSPJ:pr/irodori-speaker-inversion
Oct 8, 2026
Merged

0xShug0 merged 1 commit into
0xShug0:mainfrom
KKTTSPJ:pr/irodori-speaker-inversion

Conversation

@KKTTSPJ

@KKTTSPJ KKTTSPJ commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Irodori-TTS can learn a reusable voice with Speaker Inversion: a few speaker tokens are trained while the base model stays frozen, and saved as *.speaker.safetensors (one tensor, speaker_embedding, [tokens, speaker_dim]). The Python runtime uses such a file in place of reference audio with infer.py --ref-embed. audio.cpp's irodori_tts could not take one, so an embedding-based voice had to go back to a reference clip.

This adds it to the irodori_tts session only. No framework, server or CLI change.

How a request selects an embedding

Following PocketTTS's layout for precomputed voices:

  • By path: request option speaker_embedding_path (--request-option speaker_embedding_path=..., or "options" in a server request).
  • By name: <model dir>/embeddings/<name>.safetensors, selected with the voice id (--voice-id <name>, server "voice": "<name>"). The server's GET /v1/audio/voices already lists that directory for any family, so before this PR it offered such names for irodori_tts too, but selecting one silently gave no-reference speech. A voice id with no such file is ignored, as before, so clients that always send an OpenAI voice name are unaffected.
audiocpp_cli --task tts --family irodori_tts \
  --model models/Irodori-TTS-v4-Small-GGUF/irodori-tts-v4-small-q8_0.gguf \
  --backend cuda --language ja --text "こんにちは、これは学習した話者埋め込みを使った推論です。" \
  --request-option speaker_embedding_path=path/to/name.speaker.safetensors --out out.wav

Behaviour

  • The tokens become the speaker condition as they are — no speaker encoder, normalization or prepended mean token, all positions unmasked — which is what InferenceRuntime._load_speaker_embedding_condition / speaker_inversion_batch_tensors do. The duration predictor reads the first token, as _speaker_vec does. The reference encode and the reference cache are skipped.
  • One speaker source per request, as in Python: an embedding together with reference audio or an explicit no_ref=true is an error, and so is a checkpoint without speaker conditioning.
  • F32, F16 and BF16 load; [1, tokens, dim] is accepted like normalize_speaker_embedding_tensor. A wrong tensor name, dim or data size names the file.
  • No embedding → the existing no-reference and reference-audio paths are unchanged (byte-identical outputs below).

Two implementation notes:

  • IrodoriTTSAssets gains model_dir (the model path as given, or its directory). resources.model_root() cannot be used for embeddings/: for a standalone GGUF with embedded sidecars it points at the extracted sidecar directory.
  • Published packages take the option too. The shipped GGUFs embed a contract written before speaker_embedding_path existed, so it is dropped from the validation copy when the contract does not declare it — the same pattern as instruction a few lines above and phonemes in kokoro_tts: accept a supplied phoneme stream, bypassing the built-in G2P #577. Unrelated unknown options still fail.

Validation

Windows 10, MSVC 14.51, CUDA 12.8, RTX 5060 Ti (sm_120). Built with -DAUDIOCPP_MODEL_SET=custom -DAUDIOCPP_MODELS=irodori_tts, CPU (AVX2) and CUDA. (The local CUDA build also needed a workaround for MSVC 14.51 being newer than CUDA 12.8 supports; it is not part of this PR.) Model: irodori-tts-v4-small-q8_0.gguf; embedding: a 16-token v4 Small Speaker Inversion checkpoint; seed 42, 48 steps. All requests ran through one long-lived audiocpp_server per configuration.

Parity with the Python runtime (infer.py, fp32, CUDA, same text/seed/steps; log-mel over the common length):

path duration (audio.cpp / Python) mel cosine mel L1
embedding (--ref-embed) 6.64 s / 6.64 s 0.9975 0.159
no reference (existing path) 6.88 s / 6.88 s 0.9964 0.289
reference WAV (existing path) 7.08 s / 7.04 s 0.9724 0.911
embedding vs Python no-reference (control) 6.64 s / 6.88 s 0.7766 4.127

The predicted duration matches Python to the frame, and the embedding path tracks Python at least as closely as the existing paths do (Q8 weights here vs fp32 in Python).

Server / CLI (CUDA unless noted; "identical" = byte-identical WAV output):

case result
speaker_embedding_path 200
same, long text (several chunks) 200
"voice": "mio" with embeddings/mio.safetensors 200, identical to the by-path output
F16 copy of the embedding 200 (close to, not identical with, F32 — expected)
CLI --request-option speaker_embedding_path / --voice-id mio identical to the server output
CPU backend, by path / by name 200, identical to each other
GET /v1/audio/voices {"voices":["mio"]}
reference WAV, no embedding 200, identical to v0.9.1 without this PR
no reference 200, identical to v0.9.1 without this PR
"voice": "alloy" (no such file) 200, identical to no reference, as before

Errors (HTTP 500 with these messages):

with reference audio   -> Irodori-TTS speaker embedding cannot be combined with reference audio
with no_ref=true       -> Irodori-TTS speaker embedding cannot be combined with no_ref=true
missing file           -> Irodori-TTS speaker_embedding_path does not exist: ...
wrong tensor name      -> Irodori-TTS speaker embedding has no speaker_embedding tensor: ...
wrong dim              -> Irodori-TTS speaker embedding must have shape [tokens, 768]: ...
token count / data size mismatch -> Irodori-TTS speaker embedding data does not match its header: ...

Both branches of the contract check ran, with identical outputs:

  • with the PR's model_specs/, where the option is declared;
  • from a directory without model_specs/, so the GGUF's embedded contract applied and the option is undeclared.

The embedding request is also faster than a reference clip (about 1.2 s vs 2.0 s for this text on CUDA), since there is no reference encode.

Known limitations

  • An embedding is tied to the checkpoint it was trained on. One trained on another checkpoint with the same speaker_dim loads, but the voice may not match; the docs say so, as the upstream Irodori-TTS README does.
  • GET /v1/audio/voices lists embeddings/ only when the server config's model path is the directory, not the .gguf file (existing server behaviour); lookup by name works either way.

🤖 Generated with Claude Code

… condition

Irodori-TTS's Speaker Inversion training saves a few learned speaker tokens
as *.speaker.safetensors (tensor speaker_embedding, [tokens, speaker_dim]).
The Python runtime takes them with infer.py --ref-embed in place of
reference audio. This adds the same to the irodori_tts session, model-local:

- Request option speaker_embedding_path, or a voice id naming
  <model dir>/embeddings/<id>.safetensors (the PocketTTS layout, which the
  server's GET /v1/audio/voices already lists). A voice id with no such file
  is ignored as before.
- The tokens become the speaker condition as they are (no speaker encoder,
  normalization or mean token), all unmasked, as in the Python runtime; the
  reference encode and its cache are skipped. F32, F16 and BF16 load.
- One speaker source per request: an embedding with reference audio or an
  explicit no_ref=true is an error, as is a checkpoint without speaker
  conditioning.
- IrodoriTTSAssets keeps the model directory as given, because
  resources.model_root() points at the extracted sidecars of a standalone
  GGUF.
- Published GGUFs embed a contract without the new option; it is dropped
  from the validation copy in that case, like instruction above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@0xShug0
0xShug0 merged commit 4eecd73 into 0xShug0:main Oct 8, 2026
6 checks passed
@0xShug0

0xShug0 commented Oct 8, 2026

Copy link
Copy Markdown
Owner

@KKTTSPJ Thanks for adding this feature to Irodori-TTS! PR merged.

@KKTTSPJ
KKTTSPJ deleted the pr/irodori-speaker-inversion branch October 8, 2026 02:04
0xShug0 pushed a commit that referenced this pull request Oct 10, 2026
* webui: let a studio panel run the page's request

Model panels are registered per family, and the page always builds and
sends the request itself. A conversation view has to send its own
request and show the reply as it streams in, while it keeps the page's
Run button, Ctrl+Enter, Cancel, elapsed time and status line.

This adds that hook without changing what any existing panel does:

- A panel entry may list the tasks it covers, such as tasks: ['s2s'].
  An entry without the list covers every task of its family, as before.
- With requestMode 'panel', run() hands the request to the runner the
  panel registers through the page's registerPanelRunner, which the
  next commit passes to panels as setPanelRunner; registering returns
  the function that removes it. It passes the source audio, the
  resolved seed, Max tokens, Language, the request options and the
  abort signal that Cancel uses. The hand-off comes before anything is
  awaited, so the panel can start audio inside the Run click or key
  press. The reply the panel returns is shown in the Result column;
  these runs are not added to Run history. Missing source audio, a
  recording still running, or Run from a tab other than Studio is
  reported as a warning, like the page's other checks before a run.
- With UI management the panel can ask for the entry in a given mode.
  This uses the page's ensureLoadedMode unchanged, after reloading an
  entry that is resident with another package or with imported
  settings that differ, as ensureLoaded does for the other runs.
- api.ts gets taskStreamEvents, a reader for /v1/tasks/stream with
  "stream_format": "sse". It yields each event and then the result,
  throws the server's error message, and throws TaskStreamClosedError
  when the stream ends before task.stream.done. The done message
  carries the whole reply again and comes over many reads, so the
  reader keeps its pieces and joins them once, when the message ends.

No panel uses requestMode 'panel' yet.

* webui: LFM2.5-Audio conversation panel for speech-to-speech

The LFM2.5-Audio speech-to-speech entries ran one question per Run, as a
new conversation each time, and showed the reply only when it was done.
The server can now carry a conversation (earlier turns sent as request
artifacts) and stream a reply as server-sent events, so this gives the
two S2S entries a page of their own, as asked on #828.

The panel covers only the S2S entries; ASR and TTS keep the generic
controls. It adds a conversation above the page's own Language, Seed,
Max tokens, Source audio and Model parameters, which it uses as they
are. Each Run:

- uploads the recorded or chosen question, and sends it to
  /v1/tasks/stream with "stream_format": "sse" and return_codes, with
  each earlier turn as its question's upload path and the reply
  artifact that turn returned;
- plays the reply as it streams in, its 80 ms chunks scheduled back to
  back on one Web Audio clock, and shows the reply text as it arrives;
- adds the turn to a list with the question, the reply text and the
  reply audio, and clears the source picker for the next question.

New conversation starts over, and Leave out drops an old turn from the
next request, which is what the server asks for past its 8192-step
limit. A failed or stopped turn, or one whose stream closed before its
result, is never sent as history; the next Run takes its place.
Starting a turn stops the previous reply, and Stop audio stops a reply
that is still playing. If the browser does not let live audio start,
the panel says so and the reply is in its player when it finishes.
Finished turns are kept when the user opens another tab or entry and
comes back (leaving the panel stops a turn still running), and a turn
on the other checkpoint starts a new conversation. With UI management
the entry is loaded in streaming mode when needed; on a server with a
config file, an entry in offline mode is run through /v1/tasks/run and
its reply shows when it is done. An S2S entry configured under an id
that is not the catalog's gets the family's ASR parameters in Model
parameters; turns leave those out and send the page's Max tokens.

Each turn's players and its Leave out button are named with the turn
number for screen readers, and its state badge is a polite live
region.

The page passes panels three more props for this: modelId,
setPanelRunner, which is its registerPanelRunner, and clearSource, its
own clearSourceFile. The other panels do not declare them and ignore
them. model_params.json gets text_temperature and text_top_k for the
two S2S entries, with the server's defaults, and the S2S hints and the
model docs describe the conversation.

* webui: rebuild the page

Rebuilt from this branch's source with the lockfile (node 24.19.0,
npm 11.17.0):

  cd webui/native
  npm ci
  npm run build

The page on main was built from an older tree. It lacks changes to the
model specs or the page from #824, #829, #830, #833, #834, #835, #846,
#851, #853 and #854, so this rebuild also brings those into the page.
Rebuilding main's own source the same way gives a page that differs
from this one only in this branch's changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants