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.
344 lines
14 KiB
Bash
Executable File
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"
|
|
}
|