From b91873578b8c22ded0bf8419a13421ac2b140ee0 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 19:21:28 -0700 Subject: [PATCH] fix(frank-tts): answer a voice-less request with the declared default The omp speech device forwards a voice only when the call passes voice_id - there is no setting that feeds one (speech.voice is the TUI speak-aloud Kokoro enum, tts.localVoice belongs to the local backend) - so every harness voiceover arrives with no voice key and the shim answered 400 "unknown voice None". Measured on the deployed endpoint 2026-10-18, from a restarted session with the speech role pointed here. DEFAULT_VOICE makes the absent case a spoken operator choice instead of a code surprise: the pack named there answers a voice-less request and the journal line is marked (default), so an unrequested voice never reads later like a requested one. An UNKNOWN name still 400s with the list that exists - the no-silent-fallback law that this endpoint was built on is untouched, and the strict shape stays the code default (empty DEFAULT_VOICE), which the second new seam test pins. Ships narrator1 in scripts/frank-tts.env. --- dankclaw/voiceservice/app.py | 23 +++++++++++++++++------ scripts/frank-tts.env | 10 ++++++++++ tests/frank_tts_unit.bats | 31 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/dankclaw/voiceservice/app.py b/dankclaw/voiceservice/app.py index f6e9416..0132198 100755 --- a/dankclaw/voiceservice/app.py +++ b/dankclaw/voiceservice/app.py @@ -7,7 +7,10 @@ Consumers (the omp harness, OpenWebUI, open-notebook) speak the OpenAI directory under ``$VOICES_DIR`` and ``reference.wav`` inside it is what makes the name real. A name that has no reference is not a voice — the shim answers 400 with the list that is, instead of silently narrating in a fallback voice -(silent fallback is how a wrong-voice endpoint stays wrong for weeks). +(silent fallback is how a wrong-voice endpoint stays wrong for weeks). A request +that names NO voice at all takes ``$DEFAULT_VOICE``: that is a spoken choice in +the env file rather than a surprise in the code, and the journal marks it +``(default)`` so a name nobody asked for is never mistaken for one that was. Two backends sit behind the one surface, chosen by ``TTS_BACKEND``: @@ -70,7 +73,7 @@ class Config: def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin, llama_model, llama_mmproj, llama_lang, llama_threads, port, - max_chars, timeout): + max_chars, timeout, default_voice=""): self.voices_dir = Path(voices_dir) self.backend = backend self.upstream = upstream @@ -83,6 +86,7 @@ class Config: self.port = port self.max_chars = max_chars self.timeout = timeout + self.default_voice = default_voice @classmethod def from_env(cls, environ=None): @@ -100,6 +104,7 @@ class Config: port=_int(env, "SHIM_PORT", 7772), max_chars=_int(env, "SHIM_MAX_CHARS", 15000), timeout=float(_int(env, "SHIM_TIMEOUT", 600)), + default_voice=(env.get("DEFAULT_VOICE") or "").strip(), ) @@ -271,8 +276,13 @@ class SpeechHandler(BaseHTTPRequestHandler): "endpoint serves wav and mp3" % fmt)) return - # `voice_id` is the alias the omp harness forwards for `voice`. - voice = body.get("voice") or body.get("voice_id") + # `voice_id` is the alias the omp harness forwards for `voice`. A request + # that names nothing takes the declared default — the harness has no + # setting that feeds a voice, so voice-less calls are the normal shape + # there, not a client bug. + requested = body.get("voice") or body.get("voice_id") + defaulted = not requested and bool(cfg.default_voice) + voice = requested or cfg.default_voice reference = resolve_voice(cfg.voices_dir, voice) if reference is None: available = list_voices(cfg.voices_dir) @@ -297,8 +307,9 @@ class SpeechHandler(BaseHTTPRequestHandler): content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE self._bytes(200, audio, content_type) - self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s", - voice, fmt, len(text), len(audio), content_type) + self.log_message("speech voice=%s%s format=%s chars=%d -> %d bytes %s", + voice, " (default)" if defaulted else "", fmt, + len(text), len(audio), content_type) # -- backends ---------------------------------------------------------- def _speech_http(self, cfg, model, text, voice, reference): diff --git a/scripts/frank-tts.env b/scripts/frank-tts.env index d836fc8..008183b 100644 --- a/scripts/frank-tts.env +++ b/scripts/frank-tts.env @@ -57,6 +57,16 @@ TTS_BACKEND=omni-http TTS_UPSTREAM=http://frank-tts-engine:8880 TTS_UPSTREAM_MODEL=voxcpm2 +# THE DEFAULT VOICE — what a request that names NO voice speaks. The omp harness +# has no setting that feeds a voice to its speech device (`voice_id` is a +# per-call argument; `speech.voice` belongs to the TUI's Kokoro speak-aloud and +# never reaches an HTTP request), so a harness voiceover arrives voice-less and +# would otherwise be a 400 for every caller that does not name one. An UNKNOWN +# name still 400s with the list that exists — this covers the absent case only — +# and the journal marks such a line `(default)` so an unrequested voice never +# reads as a requested one. Any pack directory in /home/dank/voices is legal. +DEFAULT_VOICE=narrator1 + # CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not # configured": the endpoint answers a 500 that says so instead of running # `nice env -m ...` with an empty binary name. diff --git a/tests/frank_tts_unit.bats b/tests/frank_tts_unit.bats index f4ae062..15691f3 100755 --- a/tests/frank_tts_unit.bats +++ b/tests/frank_tts_unit.bats @@ -183,6 +183,37 @@ assert 'voice_id' not in body, body PY } +@test "a voice-less request speaks the declared default and marks the log" { + # The omp harness has no setting that feeds a voice to its speech device, so + # a voice-less call is its NORMAL shape rather than a client bug. With the + # operator's default armed the call must produce that pack's audio, and the + # journal line must say (default) — otherwise an unrequested voice reads, + # weeks later, exactly like one somebody asked for. + arm_omni + export DEFAULT_VOICE=narrator1 + start_app + speech '{"model":"tts-1","input":"hello"}' + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav" + python3 - "$TEST_TMP/capture.json" <<'PY' +import json, sys +body = json.load(open(sys.argv[1])) +assert body['voice'] == 'narrator1', 'the declared default never reached the engine' +PY + grep -q 'speech voice=narrator1 (default) format=wav' "$TEST_TMP/app.log" \ + || { cat "$TEST_TMP/app.log"; false; } +} + +@test "with no default declared a voice-less request stays a 400" { + # Strictness is still the code default: an endpoint that invents a voice + # nobody configured is exactly how a wrong-voice deployment stays wrong. + arm_omni + start_app + speech '{"model":"tts-1","input":"hello"}' + [ "$output" = "400" ] || { cat "$TEST_TMP/app.log"; false; } + grep -q 'narrator1' "$TEST_TMP/out.bin" +} + @test "an unknown voice is a 400 naming the voices that exist" { arm_omni start_app -- 2.54.0