Files
serverAppConfigs/tests/frank_tts_unit.bats
Frank 31266fb7f3 feat(frank-tts): cloned-voice speech endpoint — OpenAI-shaped facade, voice packs by name
Phase 1 of the voice-over goal (Gitea issue #2). One stdlib-only HTTP facade in
front of whichever TTS engine wins the engine decision: POST /v1/audio/speech
with voice=<name> answers audio in THAT voice, where a name is real exactly when
/voices/<name>/reference.wav exists — an unknown name is a 400 listing the
voices that are, never a silent fallback (a wrong-voice endpoint stays wrong for
weeks). voice_id works as the alias the omp harness sends; response_format=mp3
is really mp3 (ffmpeg re-encode); oversized input is refused with 414 instead of
half-narrated; /health reports the backend and the resolved voice list.

Two backends behind the one surface, chosen by TTS_BACKEND: omni-http forwards to
an OpenAI-shaped upstream carrying the voice as base64 reference_audio, and
llama-tts-cli runs a local binary per request with --tts-speaker-file at nice 19
so narration never starves the text endpoints. Both are driven in the seam
through stubs — no GPU, no containers.

Named frank-tts, a first-class fleet model endpoint: port 7772 in the 7770/7771
family, alias naming per the StarCraft law, rootless boot-persistent self-healing
quadlet with a read-only /home/dank/voices mount. Voice-pack law recorded in the
env header: reference.wav PLUS the engine's own speaker artifact (required, so a
voice is reusable in other runtimes), plus manifest.json provenance with offsets
always past the first 10 s of the source file.

Not deployed: the engine decision, the pack builder, deployer/homepage/Prometheus
registration and the omp models.yml runner row are phase 2.
2026-10-18 14:06:06 -07:00

344 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: frank-tts, the cloned-voice speech façade on Dankclaw :7772.
#
# The contract this pins is the one every consumer (omp, OpenWebUI,
# open-notebook) codes against: POST /v1/audio/speech with voice=<name> is
# answered with audio in THAT voice pack, where "that voice pack" is exactly
# $VOICES_DIR/<name>/reference.wav — the file that makes a name real. A name
# without a reference must come back as a 400 naming the voices that do exist
# (a silent fallback voice is how a wrong-voice endpoint stays wrong for
# weeks), the harness' voice_id must work as an alias for voice, oversized text
# must be refused rather than half-narrated, and the mp3 response_format must
# really be mp3. Whatever engine sits behind it is a separate decision — so
# both backends are driven here through stubs, never against a GPU.
#
# Everything runs against the real app.py over 127.0.0.1 ephemeral ports with
# a temp VOICES_DIR: no Dankclaw, no network, no containers, no pkill — the
# processes this file starts are recorded by pid and killed in teardown.
load helpers
setup() {
APP="$REPO_ROOT/dankclaw/voiceservice/app.py"
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
ENV_FILE="$REPO_ROOT/scripts/frank-tts.env"
TEST_TMP="$(mktemp -d)"
PIDS="$TEST_TMP/pids"
: > "$PIDS"
# One real voice pack and one directory that LOOKS like a pack but has no
# reference.wav — it must never be offered as a voice.
VOICES="$TEST_TMP/voices"
mkdir -p "$VOICES/narrator1" "$VOICES/nopack"
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(256)) * 4)' \
> "$VOICES/narrator1/reference.wav"
printf 'a manifest is not a voice\n' > "$VOICES/nopack/manifest.json"
# The audio the stub upstream "synthesises": distinct bytes, so a response
# can be proven to be the engine's output and not something the shim made
# up on its way past.
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(reversed(range(128))) * 3)' \
> "$TEST_TMP/upstream.wav"
write_stub
}
teardown() {
# Kill only what this file started, by recorded pid.
while IFS= read -r pid; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done < "$PIDS"
wait 2>/dev/null || true
rm -rf "$TEST_TMP"
}
# write_stub — the upstream TTS server arm A forwards to: records the JSON body
# it was handed (so "did the reference voice actually travel?" is observable)
# and answers with $STUB_WAV. Prints its bound port, chosen by the kernel.
write_stub() {
cat > "$TEST_TMP/stub_upstream.py" <<'PY'
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
class Stub(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('content-length') or 0)
with open(os.environ['STUB_CAPTURE'], 'w') as fh:
json.dump(json.loads(self.rfile.read(length) or b'{}'), fh)
with open(os.environ['STUB_WAV'], 'rb') as fh:
audio = fh.read()
self.send_response(200)
self.send_header('content-type', 'audio/wav')
self.send_header('content-length', str(len(audio)))
self.end_headers()
self.wfile.write(audio)
def log_message(self, *args):
pass
server = HTTPServer(('127.0.0.1', 0), Stub)
print(server.server_address[1], flush=True)
server.serve_forever()
PY
}
start_stub() {
: > "$TEST_TMP/stub.log"
STUB_WAV="$TEST_TMP/upstream.wav" STUB_CAPTURE="$TEST_TMP/capture.json" \
python3 "$TEST_TMP/stub_upstream.py" > "$TEST_TMP/stub.log" 2>&1 &
echo "$!" >> "$PIDS"
local waited=0
while [ "$waited" -lt 100 ]; do
STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")"
[ -n "$STUB_PORT" ] && return 0
sleep 0.05
waited=$((waited + 1))
done
echo "stub upstream never bound a port:"
cat "$TEST_TMP/stub.log"
return 1
}
# start_app — boot app.py on 127.0.0.1 with an ephemeral port and read that
# port back off its own startup line: no port guessing, no race. The caller
# exports the env the run needs first; app.py reads its knobs at request time.
start_app() {
: > "$TEST_TMP/app.log"
python3 "$APP" --host 127.0.0.1 --port 0 > "$TEST_TMP/app.log" 2>&1 &
echo "$!" >> "$PIDS"
local waited=0
while [ "$waited" -lt 200 ]; do
APP_PORT="$(sed -nE 's|.*listening on http://127\.0\.0\.1:([0-9]+)|\1|p' "$TEST_TMP/app.log" | head -n1)"
[ -n "$APP_PORT" ] && return 0
sleep 0.05
waited=$((waited + 1))
done
echo "frank-tts never came up:"
cat "$TEST_TMP/app.log"
return 1
}
# arm_omni — the shim forwarding to an OpenAI-shaped upstream (arm A).
arm_omni() {
start_stub
export VOICES_DIR="$VOICES"
export TTS_BACKEND=omni-http
export TTS_UPSTREAM="http://127.0.0.1:$STUB_PORT"
}
speech() {
# speech <json-payload-inline> — the HTTP code lands in $output, the body
# in $TEST_TMP/out.bin and the response headers in head.txt.
local data="$1"
run curl -sS -o "$TEST_TMP/out.bin" -D "$TEST_TMP/head.txt" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "$data" \
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
}
@test "app.py parses as python the runtime can import" {
run env PYTHONPYCACHEPREFIX="$TEST_TMP/pycache" python3 -m py_compile "$APP"
[ "$status" -eq 0 ] || { echo "$output"; false; }
}
@test "voice=narrator1 returns the engine's audio with the pack's reference attached" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello world","voice":"narrator1"}'
[ "$status" -eq 0 ] || { cat "$TEST_TMP/app.log"; false; }
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
# The voice only arrives at the engine as base64 reference_audio — prove it
# is the pack's bytes, not a path and not the engine's own default voice.
python3 - "$TEST_TMP/capture.json" "$VOICES/narrator1/reference.wav" <<'PY'
import base64, json, sys
body = json.load(open(sys.argv[1]))
reference = open(sys.argv[2], 'rb').read()
assert body['voice'] == 'narrator1', body
assert body['input'] == 'hello world', body
assert body['response_format'] == 'wav', body
assert base64.b64decode(body['reference_audio']) == reference, 'reference_audio is not the pack wav'
PY
# What a consumer discovers before it speaks: /health and the voice list.
# A directory with a manifest but no reference.wav is not a voice.
run curl -sS "http://127.0.0.1:$APP_PORT/v1/audio/voices"
[ "$output" = '["narrator1"]' ]
run curl -sS "http://127.0.0.1:$APP_PORT/health"
[[ "$output" == *'"status": "ok"'* ]]
[[ "$output" == *'"backend": "omni-http"'* ]]
[[ "$output" == *'"voices": ["narrator1"]'* ]]
}
@test "voice_id works as the alias the omp harness sends" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice_id":"narrator1"}'
[ "$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', 'voice_id never became the upstream voice'
assert 'voice_id' not in body, body
PY
}
@test "an unknown voice is a 400 naming the voices that exist" {
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice":"nosuch"}'
[ "$output" = "400" ]
grep -q 'narrator1' "$TEST_TMP/out.bin"
# Refused before the engine saw anything: no audio, no upstream call.
[ ! -s "$TEST_TMP/capture.json" ]
}
@test "oversized input is refused outright, not narrated half-way" {
arm_omni
# The limit is a knob the operator sets, not a constant in the seam: the
# app reads its knobs at request time, so exporting it before boot works.
export SHIM_MAX_CHARS=10
start_app
python3 - "$TEST_TMP/payload.json" <<'PY'
import json, sys
json.dump({'model': 'tts-1', 'input': 'A' * 11, 'voice': 'narrator1'},
open(sys.argv[1], 'w'))
PY
run curl -sS -o "$TEST_TMP/out.bin" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "@$TEST_TMP/payload.json" \
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
[ "$output" = "414" ]
[ ! -s "$TEST_TMP/capture.json" ]
}
@test "every other route is a 404, GET or POST" {
arm_omni
start_app
run curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$APP_PORT/v1/audio/speeck"
[ "$output" = "404" ]
run curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' -d '{"input":"hi","voice":"narrator1"}' \
"http://127.0.0.1:$APP_PORT/v1/speech"
[ "$output" = "404" ]
}
@test "an unrouted POST does not poison a reused connection" {
# The shim speaks HTTP/1.1, so a client may keep one TCP connection alive
# across requests. A body left unread on the way to a 404 is parsed as the
# NEXT request line — the consumer would blame the following, perfectly
# valid call. So: 404 first, then /health on the same connection.
arm_omni
start_app
python3 - "$APP_PORT" <<'PY'
import http.client, json, sys
conn = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=15)
conn.request("POST", "/v1/audio/speeck",
body=json.dumps({"input": "hi", "voice": "narrator1"}),
headers={"Content-Type": "application/json"})
first = conn.getresponse()
assert first.status == 404, first.status
first.read()
conn.request("GET", "/health")
second = conn.getresponse()
assert second.status == 200, second.status
assert json.loads(second.read())["status"] == "ok"
conn.close()
PY
}
@test "the CLI backend gets the resolved reference as --tts-speaker-file" {
# A fake binary standing in for llama-tts: it records its argv, writes a
# WAV where --output points, and reports the temp dir it was asked to work
# in so the cleanup promise is observable too.
cat > "$TEST_TMP/fake-tts" <<'FAKE'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$ARGS_FILE"
prev=""
for arg in "$@"; do
if [ "$prev" = "--output" ]; then
printf 'CLI-AUDIO' > "$arg"
dirname "$arg" > "$WORKDIR_FILE"
fi
prev="$arg"
done
exit 0
FAKE
chmod +x "$TEST_TMP/fake-tts"
printf 'CLI-AUDIO' > "$TEST_TMP/expected.bin"
export VOICES_DIR="$VOICES"
export TTS_BACKEND=llama-tts-cli
export LLAMA_TTS_BIN="$TEST_TMP/fake-tts"
export LLAMA_TTS_MODEL="$TEST_TMP/model.bin"
export ARGS_FILE="$TEST_TMP/argv.txt"
export WORKDIR_FILE="$TEST_TMP/workdir.txt"
start_app
speech '{"model":"tts-1","input":"narrate this","voice":"narrator1"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected.bin"
# The speaker file must be the pack's reference under VOICES_DIR — that is
# the whole product; anything else and the clone never happens.
speaker="$(awk 'p { print; exit } $0 == "--tts-speaker-file" { p = 1 }' "$TEST_TMP/argv.txt")"
[ "$speaker" = "$VOICES/narrator1/reference.wav" ]
prompt="$(awk 'p { print; exit } $0 == "-p" { p = 1 }' "$TEST_TMP/argv.txt")"
[ "$prompt" = "narrate this" ]
grep -qx -- '--tts-lang' "$TEST_TMP/argv.txt"
# A per-request temp dir that survives is how a TTS box fills its /tmp.
workdir="$(cat "$TEST_TMP/workdir.txt")"
[ -n "$workdir" ]
[ ! -d "$workdir" ]
}
@test "response_format=mp3 ships mp3, converted from the engine's wav" {
arm_omni
# A fake ffmpeg on PATH: whatever wav arrives on stdin is tapped (so the
# conversion provably ran on the engine's audio) and mp3 bytes come out.
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/ffmpeg" <<'FF'
#!/usr/bin/env bash
cat > "$STDIN_TAP"
printf 'MP3-BYTES'
FF
chmod +x "$TEST_TMP/bin/ffmpeg"
export STDIN_TAP="$TEST_TMP/ffmpeg-in.wav"
# The fake must reach the APP's PATH: the shim runs `ffmpeg` by name.
export PATH="$TEST_TMP/bin:$PATH"
start_app
speech '{"model":"tts-1","input":"hello","voice":"narrator1","response_format":"mp3"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
grep -qi '^content-type: audio/mpeg' "$TEST_TMP/head.txt"
printf 'MP3-BYTES' > "$TEST_TMP/expected-mp3.bin"
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected-mp3.bin"
cmp "$TEST_TMP/ffmpeg-in.wav" "$TEST_TMP/upstream.wav"
# The upstream is still asked for wav: mp3 is this shim's re-encode.
python3 - "$TEST_TMP/capture.json" <<'PY'
import json, sys
assert json.load(open(sys.argv[1]))['response_format'] == 'wav'
PY
}
@test "the shipped env file boots the shim with its declared backend" {
# Not a grep of the env file: source it the way systemd's EnvironmentFile
# would, point the two machine-local values at the sandbox, and require the
# endpoint to actually speak. A typo, a shell-ism or an expansion systemd
# cannot parse shows up here as a dead process, not a green grep.
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
arm_omni
start_app
speech '{"model":"tts-1","input":"hello","voice":"narrator1"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
}
@test "the quadlet unit keeps boot persistence and a read-only voice mount" {
grep -qE '^Restart=always$' "$UNIT"
grep -qE '^RestartSec=[0-9]+$' "$UNIT"
grep -qE '^WantedBy=default.target$' "$UNIT"
grep -qE '^PublishPort=([0-9.]+:)?7772:7772$' "$UNIT"
grep -qE '^Volume=/home/dank/voices:/voices:ro$' "$UNIT"
}