Skip to content

Pocket-TTS adapter

The Pocket adapter is a private, fail-closed production binding. It is not an online model manager: it downloads nothing, compiles nothing, and reads only a manifest that provisioning wrote.

pocket-tts==2.1.0 is a required dependency and needs no separate install. Installing it does not fetch or authorize any model or voice.

Single-branch render path

Every renderable voice asset is a compiled .safetensors speaker embedding. WAV prompts are compiled during provisioning, so the adapter has no audio-encoding branch: preflight_pocket validates a bounded safetensors header, and synthesize loads a conditioning state and nothing else.

The conditioning state is derived once per engine, not once per segment. A worker holds a reusable engine and processes its batch serially, so re-deriving per segment repeated a file load for every chunk of text.

The path passed to get_state_for_audio_prompt is always a Path, never a str. Upstream calls download_if_necessary only for str input, so a Path cannot reach the network even before the allowlist intervenes.

Token budgets

Pocket-TTS 2.1.0's punctuation splitter can leave long clauses above its 50-token limit and only log a warning that generation may skip words. The adapter installs a bounded splitter for the owned worker engine's lifetime and restores the original on cleanup, including failed construction.

The splitter uses the loaded engine's actual SentencePiece tokenizer. It bounds both the raw chunk consumed by short inference and the prompt produced by prepare_text_prompt, because capitalization, terminal punctuation and padding can change the token count. Sentence and clause edges are preferred; a complete word boundary is the fallback. An indivisible word that cannot fit fails with the normal sanitized inference error instead of being spoken as fragments. Upstream streaming, voice conditioning and audio assembly remain in use.

The chunking recipe is part of the engine's semantic identity, so neither segment audio nor chapter checkpoints made by an earlier splitter can bypass the new bound. This enforces the model input contract; acoustic accuracy still requires checking the generated speech.

The local manifest

Every render reads a local manifest. Normally load_voice() writes it; an operator can instead point KENKUI_POCKET_MANIFEST at one they maintain. It contains:

  • canonical absolute owner-controlled model root and selected local YAML path;
  • immutable model revision and exact package version 2.1.0;
  • every model-tree file as a safe relative POSIX path, exact positive byte size, and lowercase SHA-256 (no extra or missing tree entries);
  • canonical absolute compiled speaker-embedding path and its SHA-256;
  • voice provenance, license identifier, rights statement, and explicit commercial_use_allowed boolean;
  • expected sample rate, CPU device, and bounded timeout; and
  • matching public voice content fingerprint, metadata, and compatible model revision.

No environment variable, cache entry, provider default, language shortcut, URL, or Hugging Face identifier can substitute for this declaration. The selected YAML must itself be in the manifest. Unsafe roots/files, symlinks, hardlinks, permissions, hashes, sizes, remote markers, path forms, extra files, malformed embedding headers, mismatched metadata, missing package, or wrong package version fail closed with stable Pocket model/voice errors.

Offline worker behavior

Preflight reads and verifies every declared byte without importing the inference package. Actual construction is allowed only after the scheduler's spawned-worker handshake. The child repeats preflight, copies a read-only private snapshot, sets HF_HUB_OFFLINE=1, HF_HUB_DISABLE_TELEMETRY=1, and TRANSFORMERS_OFFLINE=1, denies Python socket audit events, and replaces the provider downloader with an allow-list that accepts only declared snapshot files. The voice is passed as a local Path.

Each bounded spawned worker creates one private model snapshot and model instance, then reuses them serially for its assigned segment batch. Each voice's conditioning state is loaded once on first use and reused for that worker's later segments. No model is constructed in the parent, workers do not share model state, and each worker removes its snapshot when it exits. Provider exceptions are sanitized as stable load, voice, inference, or invalid-audio errors.

Immediately before inference, the adapter replaces U+2018 and U+2019 with the ASCII apostrophe only when the mark occurs between alphabetic characters. The pinned tokenizer represents these curly apostrophes as byte fallbacks; normalizing word-internal marks gives contractions and possessives the trained apostrophe token. Canonical source text, inspection, and speech scripts retain their original typography. Other characters, quotation delimiters, and math primes are unchanged. This model-prompt recipe has its own semantic identity, so segment and chapter audio cached before normalization are not reused.

Package inspection basis

The adapter targets these inspected 2.1.0 interfaces:

  • TTSModel.load_model(config=<local Path>, quantize=False);
  • model.sample_rate and model.device;
  • model.get_state_for_audio_prompt(<local Path>); and
  • model.generate_audio(state, text) returning the tensor subsequently checked and converted to bounded mono PCM.

The inspected PyPI wheel SHA-256 was 7b8f01d3e52aa7df84887b711994586bdc875e024a8b40a15f757feeeb29f752. Wheel/source inspection is not inference acceptance.

Provisioning boundary

Provisioning downloads model and voice assets explicitly. Character analysis has a separate LiteLLM network boundary, but neither asset downloads nor character analysis run in synthesis workers. tests/test_import_boundaries.py statically asserts that no module under _tts, _execution, _audio, _domain, or _epub imports the provisioning module.

Because the renderer replaces download_if_necessary with an allowlist accepting only local absolute paths, a stock pocket-tts config full of hf:// URLs would be rejected at load time. Provisioning therefore writes a derived config with every weight reference rewritten to a local path, and a test asserts no remote scheme survives into it.

Verification

CI exercises the adapter with test doubles, and native FFmpeg acceptance with generated audio. Real inference, which downloads assets and renders a real M4B, is an opt-in local test behind KENKUI_RUN_PROVISIONING_REAL=1, because it needs hundreds of megabytes of model weights.

A gated-compiled embedding is valid under the ungated model, confirmed by transcription against a negative control. Incompatibility here fails silently — a mismatched pairing still yields well-formed audio — so the discriminating signal is whether the output contains words. A genuinely mismatched control (italian embedding, english weights) ran to the generation limit without EOS and transcribed to nothing; the gated-compiled embedding under ungated weights transcribed the full sentence verbatim. Speaker-identity fidelity was not assessed.

Kenkui still pins conservatively: the compiling engine's revision goes into the voice's compatible_model_revisions and the revision check rejects a mismatch. Relaxing that is now an evidenced follow-up.

One engine, many voices

An engine config carries a collection of voice assets rather than one. The engine constructs a single model and derives a conditioning state per voice on first use, keyed by asset digest.

That is what makes a cast affordable: a language engine is roughly 225 MB of weights against roughly 6.5 MB per speaker embedding, so a ten-voice cast costs about 65 MB more than a single voice, not ten models. One model per worker is also required — the scheduler rejects any worker reporting more than one engine initialisation.

Each synthesis task names the digest of the voice that renders it. A worker that had to guess would produce well-formed audio in the wrong voice, which no automated check would catch.

Every voice in one cast must share an engine and revision. A cast spanning languages is refused rather than silently loading a second engine per worker.