Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b91873578b | ||
|
|
d2ce6693da |
@@ -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
|
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
|
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
|
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``:
|
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,
|
def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin,
|
||||||
llama_model, llama_mmproj, llama_lang, llama_threads, port,
|
llama_model, llama_mmproj, llama_lang, llama_threads, port,
|
||||||
max_chars, timeout):
|
max_chars, timeout, default_voice=""):
|
||||||
self.voices_dir = Path(voices_dir)
|
self.voices_dir = Path(voices_dir)
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.upstream = upstream
|
self.upstream = upstream
|
||||||
@@ -83,6 +86,7 @@ class Config:
|
|||||||
self.port = port
|
self.port = port
|
||||||
self.max_chars = max_chars
|
self.max_chars = max_chars
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.default_voice = default_voice
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, environ=None):
|
def from_env(cls, environ=None):
|
||||||
@@ -100,6 +104,7 @@ class Config:
|
|||||||
port=_int(env, "SHIM_PORT", 7772),
|
port=_int(env, "SHIM_PORT", 7772),
|
||||||
max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
|
max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
|
||||||
timeout=float(_int(env, "SHIM_TIMEOUT", 600)),
|
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))
|
"endpoint serves wav and mp3" % fmt))
|
||||||
return
|
return
|
||||||
|
|
||||||
# `voice_id` is the alias the omp harness forwards for `voice`.
|
# `voice_id` is the alias the omp harness forwards for `voice`. A request
|
||||||
voice = body.get("voice") or body.get("voice_id")
|
# 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)
|
reference = resolve_voice(cfg.voices_dir, voice)
|
||||||
if reference is None:
|
if reference is None:
|
||||||
available = list_voices(cfg.voices_dir)
|
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
|
content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE
|
||||||
self._bytes(200, audio, content_type)
|
self._bytes(200, audio, content_type)
|
||||||
self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s",
|
self.log_message("speech voice=%s%s format=%s chars=%d -> %d bytes %s",
|
||||||
voice, fmt, len(text), len(audio), content_type)
|
voice, " (default)" if defaulted else "", fmt,
|
||||||
|
len(text), len(audio), content_type)
|
||||||
|
|
||||||
# -- backends ----------------------------------------------------------
|
# -- backends ----------------------------------------------------------
|
||||||
def _speech_http(self, cfg, model, text, voice, reference):
|
def _speech_http(self, cfg, model, text, voice, reference):
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ TTS_BACKEND=omni-http
|
|||||||
TTS_UPSTREAM=http://frank-tts-engine:8880
|
TTS_UPSTREAM=http://frank-tts-engine:8880
|
||||||
TTS_UPSTREAM_MODEL=voxcpm2
|
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
|
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
||||||
# configured": the endpoint answers a 500 that says so instead of running
|
# configured": the endpoint answers a 500 that says so instead of running
|
||||||
# `nice env -m ...` with an empty binary name.
|
# `nice env -m ...` with an empty binary name.
|
||||||
|
|||||||
@@ -183,6 +183,37 @@ assert 'voice_id' not in body, body
|
|||||||
PY
|
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" {
|
@test "an unknown voice is a 400 naming the voices that exist" {
|
||||||
arm_omni
|
arm_omni
|
||||||
start_app
|
start_app
|
||||||
|
|||||||
Reference in New Issue
Block a user