mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
speech: a transcript that grows, metered by the audio it received
Transcription only existed as a batch call, so a microphone had to stop before anything could be decoded. This adds the streaming sibling: audio arrives in 250 ms pushes and the response to each push carries the transcript so far. Half duplex over ordinary HTTP, because that is the transport this service actually has. `ai` is a child process reached over ZAP, which builds a request as ONE frame — there is no request-head frame and no upgrade path, so neither a WebSocket nor a streamed request body can reach here. A chunk that fits in one frame is the shape the wire already carries, and it makes backpressure structural: chunk n+1 cannot be sent until n is acked. Whisper decodes a span, not a sample, so the window is re-decoded as it fills and segments that ended a guard-length before the audio did are committed and their bytes freed. That is what keeps the window bounded and the committed text stable. The ack never waits for a decode; one decode runs per session at a time. `duration` on each ack is the seconds THAT push carried, named as the batch route names it, so a push and a batch transcription are metered by one rule and every second is billed once. It is arithmetic on the bytes received, so it does not depend on the decoder returning anything. The suite gains a second gate: kokoro speaks a sentence and whisper has to say it back, against the real weights. The fast suite replaces faster-whisper with a stand-in — which is what makes the window arithmetic checkable exactly, and also what would let a broken decoder pass unnoticed. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -36,6 +36,15 @@ jobs:
|
||||
- name: Test
|
||||
run: docker build --target test -t speech-test .
|
||||
|
||||
# The same code against the REAL models: kokoro speaks a sentence and
|
||||
# whisper has to say it back. The suite above replaces faster-whisper with a
|
||||
# stand-in — which is what makes the window arithmetic checkable exactly, and
|
||||
# also what makes it blind to a decoder that stopped working. This is the
|
||||
# only step that would notice. It reuses the weight layer the image build
|
||||
# needs anyway, so it costs a decode and not a download.
|
||||
- name: Test against the real models
|
||||
run: docker build --target live -t speech-live .
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
||||
+16
-2
@@ -4,7 +4,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
COPY main.py stt.py tts.py ./
|
||||
COPY main.py stt.py tts.py transcript.py ./
|
||||
|
||||
# The gate, as a build stage. `docker build` is the ONE thing this lane is
|
||||
# already known to do, so the test needs no uv on the runner and no bind mount
|
||||
@@ -16,10 +16,24 @@ FROM base AS test
|
||||
COPY test_speech.py ./
|
||||
RUN uv sync --frozen --group dev && uv run pytest -q
|
||||
|
||||
FROM base AS final
|
||||
# Weights bake at BUILD: a deterministic image that boots without the network.
|
||||
FROM base AS weights
|
||||
RUN uv run python -c "import stt; [stt.load(m) for m in stt.MODELS]" \
|
||||
&& curl -fsSLo kokoro-v1.0.onnx https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx \
|
||||
&& curl -fsSLo voices-v1.0.bin https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin
|
||||
|
||||
# The second gate: the same code against the REAL models. The suite above replaces
|
||||
# faster-whisper with a stand-in so the window arithmetic can be checked exactly,
|
||||
# and a suite that only ever sees a stand-in cannot tell a working decoder from a
|
||||
# deleted one. Here kokoro speaks a sentence and whisper has to say it back, so
|
||||
# the weights, the raw-audio path and the sample scaling are all load-bearing.
|
||||
#
|
||||
# It sits above `final` and below nothing: `final` does not depend on it, so an
|
||||
# untargeted `docker build` still produces the image WITHOUT dev dependencies.
|
||||
FROM weights AS live
|
||||
COPY test_speech.py test_live.py ./
|
||||
RUN uv sync --frozen --group dev && uv run pytest -q
|
||||
|
||||
FROM weights AS final
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -5,10 +5,14 @@ and meters every caller before a request reaches this service; a provider row
|
||||
pointing at http://speech.hanzo.svc/v1 is the whole integration.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
import stt
|
||||
import transcript
|
||||
import tts
|
||||
|
||||
app = FastAPI(title="speech", docs_url=None, redoc_url=None)
|
||||
@@ -45,6 +49,83 @@ async def transcriptions(
|
||||
return JSONResponse({"text": text})
|
||||
|
||||
|
||||
class Open(BaseModel):
|
||||
model: str = "whisper"
|
||||
language: str | None = None
|
||||
format: str = "pcm16"
|
||||
rate: int = stt.RATE
|
||||
channels: int = 1
|
||||
|
||||
|
||||
# ── the streaming sibling of /v1/audio/transcriptions ───────────────────────
|
||||
#
|
||||
# Half duplex over ordinary HTTP: the client POSTs a chunk of audio and the
|
||||
# RESPONSE to that POST carries the transcript. No socket, no second leg.
|
||||
#
|
||||
# That is not a preference. `ai` is a child process reached over ZAP, and ZAP
|
||||
# builds a request as ONE frame — there is no request-head frame and no upgrade
|
||||
# path, so neither a WebSocket nor a streamed request body can reach this service.
|
||||
# A chunk small enough to fit one frame is the shape the transport actually has.
|
||||
# It also makes backpressure structural: chunk n+1 cannot be sent until n is
|
||||
# acked, so a slow server slows the microphone instead of queueing audio.
|
||||
|
||||
|
||||
@app.post("/v1/audio/transcript", status_code=201)
|
||||
def transcript_open(ask: Open):
|
||||
if ask.model not in stt.MODELS:
|
||||
raise HTTPException(404, f"unknown model {ask.model!r}")
|
||||
# Raw audio carries no header saying what it is, so a mismatch here is silent
|
||||
# noise rather than an error: 8 kHz read as 16 kHz transcribes as gibberish.
|
||||
# Refuse it by name instead of resampling something we were told is correct.
|
||||
if (ask.format, ask.rate, ask.channels) != ("pcm16", stt.RATE, 1):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"expected pcm16 mono at {stt.RATE} Hz; "
|
||||
f"got {ask.format!r} {ask.channels}ch at {ask.rate} Hz",
|
||||
)
|
||||
live = transcript.begin(ask.model, ask.language)
|
||||
return {
|
||||
"id": live.id,
|
||||
"model": live.model,
|
||||
"format": "pcm16",
|
||||
"rate": stt.RATE,
|
||||
"channels": 1,
|
||||
"chunk_ms": round(transcript.CHUNK / stt.SECOND * 1000),
|
||||
"max_bytes": transcript.CEILING,
|
||||
"max_seconds": transcript.LIMIT,
|
||||
"idle_seconds": transcript.IDLE,
|
||||
"expires_at": int(time.time() + transcript.IDLE),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/audio/transcript/{tid}")
|
||||
async def transcript_push(tid: str, request: Request):
|
||||
"""Take a chunk, answer with the newest state. `duration` is what this push
|
||||
consumed — the field the plane meters, named as the batch route names it."""
|
||||
live = transcript.find(tid)
|
||||
if live is None:
|
||||
raise HTTPException(404, f"no transcript {tid!r}")
|
||||
pcm = await request.body()
|
||||
if len(pcm) > transcript.CEILING:
|
||||
raise HTTPException(413, f"chunk is {len(pcm)} bytes; limit {transcript.CEILING}")
|
||||
if len(pcm) % stt.WIDTH:
|
||||
raise HTTPException(400, f"{len(pcm)} bytes is not whole int16 frames")
|
||||
if live.full:
|
||||
raise HTTPException(409, f"transcript is at its {transcript.LIMIT}s limit; close it")
|
||||
return live.state(live.push(pcm))
|
||||
|
||||
|
||||
@app.delete("/v1/audio/transcript/{tid}")
|
||||
def transcript_close(tid: str):
|
||||
"""Close: decode the remainder and commit it. No audio arrives, so nothing is
|
||||
billed here — every second was already metered by the push that carried it."""
|
||||
live = transcript.drop(tid)
|
||||
if live is None:
|
||||
raise HTTPException(404, f"no transcript {tid!r}")
|
||||
live.close()
|
||||
return live.state()
|
||||
|
||||
|
||||
@app.post("/v1/audio/speech")
|
||||
def speech(ask: tts.Ask):
|
||||
if ask.model not in tts.MODELS:
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "speech"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "CPU speech: STT (faster-whisper) + TTS (kokoro) behind one OpenAI-shaped surface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -10,6 +10,9 @@ dependencies = [
|
||||
"faster-whisper>=1.1",
|
||||
"kokoro-onnx>=0.4",
|
||||
"soundfile>=0.13",
|
||||
# Raw audio is handed to the model as an array, so this is imported directly
|
||||
# rather than reached through faster-whisper.
|
||||
"numpy>=2.0",
|
||||
]
|
||||
|
||||
# The suite stubs the two model calls, so it needs neither weights nor a GPU and
|
||||
|
||||
@@ -7,6 +7,7 @@ cache: lazy, once per process, kept for its life.
|
||||
import io
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
# Request `model` → weight. `whisper` is the routed name the ai plane sends;
|
||||
@@ -16,12 +17,47 @@ MODELS = {
|
||||
"whisper-small": "small",
|
||||
}
|
||||
|
||||
# Whisper's native rate, and the one faster_whisper.audio resamples everything to
|
||||
# (audio.py: sampling_rate = 16000). Raw audio arrives already at this rate, mono,
|
||||
# little-endian int16 — so there is nothing to resample and nothing to demux.
|
||||
RATE = 16000
|
||||
WIDTH = 2
|
||||
SECOND = RATE * WIDTH # bytes of raw audio per second
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def load(model: str) -> WhisperModel:
|
||||
return WhisperModel(MODELS[model], device="cpu", compute_type="int8")
|
||||
|
||||
|
||||
def samples(pcm: bytes) -> numpy.ndarray:
|
||||
"""Raw little-endian int16 → the float32 in [-1, 1) the model consumes.
|
||||
|
||||
Handing the model an array skips faster-whisper's demux entirely: a container
|
||||
goes through ffmpeg, an array does not. Raw audio has no container, so this is
|
||||
the shorter path AND the exact one.
|
||||
"""
|
||||
return numpy.frombuffer(pcm, dtype="<i2").astype("float32") / 32768.0
|
||||
|
||||
|
||||
def _hear(model: str, audio, language: str | None):
|
||||
"""The one call into the model. Both entries below differ only in what they
|
||||
hand it — a container to demux, or samples already in hand."""
|
||||
return load(model).transcribe(audio, language=language, vad_filter=True)
|
||||
|
||||
|
||||
def segments(model: str, pcm: bytes, language: str | None) -> list:
|
||||
"""Decode raw audio, returning the model's segments WITH their timings.
|
||||
|
||||
A growing transcript needs the timings, not just the text: a segment that
|
||||
ended well before the audio does is settled and can be committed, while the
|
||||
tail is still moving. `transcribe` throws the timings away because a batch
|
||||
caller has nothing to do with them.
|
||||
"""
|
||||
heard, _ = _hear(model, samples(pcm), language)
|
||||
return list(heard)
|
||||
|
||||
|
||||
def transcribe(model: str, audio: bytes, language: str | None) -> tuple[str, float]:
|
||||
"""Transcribe, returning the text AND the audio's duration in seconds.
|
||||
|
||||
@@ -33,8 +69,6 @@ def transcribe(model: str, audio: bytes, language: str | None) -> tuple[str, flo
|
||||
is charged for what they asked us to listen to, not for how much of it turned
|
||||
out to be talking.
|
||||
"""
|
||||
segments, info = load(model).transcribe(
|
||||
io.BytesIO(audio), language=language, vad_filter=True
|
||||
)
|
||||
text = " ".join(segment.text.strip() for segment in segments).strip()
|
||||
heard, info = _hear(model, io.BytesIO(audio), language)
|
||||
text = " ".join(segment.text.strip() for segment in heard).strip()
|
||||
return text, info.duration
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
"""The end-to-end test: real weights, real audio, a real decode.
|
||||
|
||||
Everything in test_speech.py replaces faster-whisper with a stand-in, because the
|
||||
window arithmetic is what those tests are about and a stand-in makes it exact.
|
||||
The cost of that is real: a suite where the decoder is only ever a stub cannot
|
||||
tell a working decoder from a deleted one. This file closes that hole — nothing
|
||||
here is stubbed, so it fails if the model is not loaded, if raw audio is handed
|
||||
to it wrongly, or if the samples are scaled wrong.
|
||||
|
||||
The audio is genuine speech, spoken by this service's own other half: kokoro says
|
||||
a sentence, ffmpeg renders it to exactly the shape a browser would send, and
|
||||
whisper has to say it back. A round trip through both halves needs no fixture in
|
||||
the tree and no recording of anyone's voice.
|
||||
|
||||
It runs where the weights are, which is the finished image — the fast suite runs
|
||||
in a stage above the weight bake and never sees this file.
|
||||
"""
|
||||
|
||||
import io
|
||||
import time
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
import soundfile
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
import stt
|
||||
import transcript
|
||||
import tts
|
||||
|
||||
SAID = (
|
||||
"The quick brown fox jumps over the lazy dog. "
|
||||
"Pack my box with five dozen liquor jugs."
|
||||
)
|
||||
WORDS = ("quick", "brown", "fox", "lazy", "dog", "five", "dozen")
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
|
||||
def speech(text: str) -> bytes:
|
||||
"""Say it, then render it to what the wire carries: raw little-endian int16,
|
||||
mono, 16 kHz — no container, no header, exactly what a browser sends.
|
||||
|
||||
Kokoro speaks at its own rate, so this resamples once, which is the same step
|
||||
the browser takes before it pushes.
|
||||
"""
|
||||
samples, rate = tts.load("kokoro").create(text, voice="af_heart")
|
||||
want = int(len(samples) * stt.RATE / rate)
|
||||
at = numpy.linspace(0, len(samples) - 1, want)
|
||||
flat = numpy.interp(at, numpy.arange(len(samples)), samples)
|
||||
return (numpy.clip(flat, -1.0, 1.0) * 32767).astype("<i2").tobytes()
|
||||
|
||||
|
||||
def settle(live, timeout=180.0):
|
||||
"""Wait for the background decode to stand down. A real decode on CPU takes
|
||||
seconds, which is exactly why the ack does not wait for one."""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
with live._lock:
|
||||
if not live._decoding:
|
||||
return
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("decode never settled")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def said() -> bytes:
|
||||
pcm = speech(SAID)
|
||||
assert len(pcm) > 4 * stt.SECOND, "the fixture must be several seconds of real speech"
|
||||
return pcm
|
||||
|
||||
|
||||
def test_a_real_voice_becomes_a_real_transcript(said):
|
||||
"""Push real speech through the real endpoint in real chunks and read it back.
|
||||
|
||||
A decoder that is removed, never loaded, or handed the wrong sample scale
|
||||
returns nothing here — there is no stub to answer in its place.
|
||||
"""
|
||||
live = client.post("/v1/audio/transcript", json={"model": "whisper", "language": "en"})
|
||||
assert live.status_code == 201, live.text
|
||||
tid = live.json()["id"]
|
||||
|
||||
for at in range(0, len(said), transcript.CHUNK):
|
||||
r = client.post(f"/v1/audio/transcript/{tid}", content=said[at:at + transcript.CHUNK])
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
shut = client.delete(f"/v1/audio/transcript/{tid}")
|
||||
assert shut.status_code == 200, shut.text
|
||||
|
||||
heard = shut.json()["text"].lower()
|
||||
missing = [w for w in WORDS if w not in heard]
|
||||
assert not missing, f"whisper did not return {missing} from real speech; heard {heard!r}"
|
||||
|
||||
|
||||
def test_it_decodes_while_the_transcript_is_still_open(said):
|
||||
"""The point of the endpoint is words BEFORE the end. Push part of the audio,
|
||||
let the background decode finish, and read what it has — this is the live
|
||||
path, not the flush that close performs."""
|
||||
live = transcript.begin("whisper", "en")
|
||||
half = len(said) // 2 // stt.WIDTH * stt.WIDTH
|
||||
for at in range(0, half, transcript.CHUNK):
|
||||
live.push(said[at:at + transcript.CHUNK])
|
||||
|
||||
settle(live)
|
||||
heard = (live.text + " " + live.pending).lower()
|
||||
assert any(w in heard for w in WORDS), f"nothing decoded mid-stream; heard {heard!r}"
|
||||
|
||||
|
||||
def test_the_seconds_billed_are_the_seconds_spoken(said):
|
||||
"""Metering, measured against audio whose length is known independently: the
|
||||
session must account for the whole utterance and no more."""
|
||||
live = transcript.begin("whisper", "en")
|
||||
billed = sum(
|
||||
live.push(said[at:at + transcript.CHUNK])
|
||||
for at in range(0, len(said), transcript.CHUNK)
|
||||
)
|
||||
spoken = len(said) / stt.SECOND
|
||||
assert billed == pytest.approx(spoken)
|
||||
assert live.seconds == pytest.approx(spoken)
|
||||
assert spoken > 4.0, "a real sentence, not a click"
|
||||
|
||||
|
||||
def test_the_batch_route_still_reports_a_real_duration(said):
|
||||
"""The quantity the plane already meters, from the real model rather than a
|
||||
stub: a wav of known length must come back with that length."""
|
||||
wav = io.BytesIO()
|
||||
soundfile.write(wav, numpy.frombuffer(said, dtype="<i2"), stt.RATE, format="WAV")
|
||||
r = client.post(
|
||||
"/v1/audio/transcriptions",
|
||||
files={"file": ("a.wav", wav.getvalue(), "audio/wav")},
|
||||
data={"model": "whisper", "response_format": "verbose_json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["duration"] == pytest.approx(len(said) / stt.SECOND, abs=0.1)
|
||||
assert "fox" in r.json()["text"].lower()
|
||||
+254
@@ -6,11 +6,15 @@ faster-whisper and kokoro rather than this service. What IS this service's own
|
||||
is the shape of what it returns — which is exactly where both defects lived.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
import stt
|
||||
import transcript
|
||||
import tts
|
||||
|
||||
client = TestClient(main.app)
|
||||
@@ -199,3 +203,253 @@ def test_transcribe_forwards_the_language_hint(monkeypatch):
|
||||
stt.transcribe("whisper", b"x", "de")
|
||||
assert model.called_with["language"] == "de"
|
||||
assert model.called_with["vad_filter"] is True
|
||||
|
||||
|
||||
# ── the growing transcript ──────────────────────────────────────────────────
|
||||
#
|
||||
# These stub the MODEL, never `stt.segments` or `Transcript` — so the real
|
||||
# decode-window arithmetic runs. Stubbing the layer under test is how the first
|
||||
# duration defect survived its own suite.
|
||||
#
|
||||
# The stand-in reads the audio it is HANDED and names each segment after the
|
||||
# sample values inside it, so the text is a function of which bytes are in the
|
||||
# window. A trim that drops the wrong bytes, drops too many, or drops none does
|
||||
# not merely change a length here — it changes the words.
|
||||
|
||||
|
||||
def tone(value: int, seconds: float) -> bytes:
|
||||
"""PCM16 whose every sample is `value`: audio that says its own name."""
|
||||
return numpy.full(int(seconds * stt.RATE), value, dtype="<i2").tobytes()
|
||||
|
||||
|
||||
class _Seg:
|
||||
def __init__(self, text, start, end):
|
||||
self.text, self.start, self.end = text, start, end
|
||||
|
||||
|
||||
class _Span:
|
||||
def __init__(self, span):
|
||||
self.duration = span
|
||||
self.duration_after_vad = span / 2
|
||||
|
||||
|
||||
class _Ear:
|
||||
"""faster-whisper's shape: cuts what it is given on a fixed grid and reads
|
||||
each piece's own samples back as its text."""
|
||||
|
||||
STEP = 2.0
|
||||
|
||||
def __init__(self):
|
||||
self.spans = []
|
||||
|
||||
def transcribe(self, audio, language=None, vad_filter=None):
|
||||
span = len(audio) / stt.RATE
|
||||
self.spans.append(span)
|
||||
segs, at = [], 0.0
|
||||
while at < span - 1e-9:
|
||||
end = min(at + self.STEP, span)
|
||||
mid = int((at + end) / 2 * stt.RATE)
|
||||
segs.append(_Seg(str(round(float(audio[mid]) * 32768)), at, end))
|
||||
at = end
|
||||
return iter(segs), _Span(span)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ear(monkeypatch):
|
||||
"""Put the stand-in under `stt.load`, so `stt.segments` and the whole window
|
||||
are the real code — only faster-whisper itself is replaced."""
|
||||
heard = _Ear()
|
||||
monkeypatch.setattr(stt, "load", lambda m: heard)
|
||||
transcript._live.clear()
|
||||
yield heard
|
||||
transcript._live.clear()
|
||||
|
||||
|
||||
def settle(live, timeout=10.0):
|
||||
"""Wait for the background decode to stand down — the ack deliberately does
|
||||
not, so a test that reads the text must."""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
with live._lock:
|
||||
if not live._decoding:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError("decode never settled")
|
||||
|
||||
|
||||
def start(**over):
|
||||
body = {"model": "whisper", "language": "en"} | over
|
||||
r = client.post("/v1/audio/transcript", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── metering: the reason this endpoint can exist at all ─────────────────────
|
||||
|
||||
def test_push_meters_the_audio_it_carried(ear):
|
||||
"""`duration` is the seconds THIS push brought, named as the batch route
|
||||
names it so the plane meters both by one rule. A push that reports 0 is
|
||||
audio served free."""
|
||||
live = start()
|
||||
chunk = tone(7, 0.25) # 8000 bytes
|
||||
r = client.post(f"/v1/audio/transcript/{live['id']}", content=chunk)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["duration"] == 0.25
|
||||
assert r.json()["seconds"] == 0.25
|
||||
|
||||
|
||||
def test_a_session_bills_every_second_once_and_only_once(ear):
|
||||
"""The sum of what the pushes reported must equal the audio submitted —
|
||||
under-counting serves audio free, over-counting bills for silence that was
|
||||
never sent. Close adds no audio, so it must add no charge."""
|
||||
live = start()
|
||||
sent = 0.0
|
||||
billed = 0.0
|
||||
for n in range(12): # 3 s in 250 ms pushes
|
||||
r = client.post(f"/v1/audio/transcript/{live['id']}", content=tone(n + 1, 0.25))
|
||||
billed += r.json()["duration"]
|
||||
sent += 0.25
|
||||
shut = client.delete(f"/v1/audio/transcript/{live['id']}")
|
||||
assert shut.status_code == 200, shut.text
|
||||
assert shut.json()["duration"] == 0.0, "close carries no audio, so it bills none"
|
||||
assert billed == pytest.approx(sent), "every second submitted, billed exactly once"
|
||||
assert shut.json()["seconds"] == pytest.approx(sent)
|
||||
|
||||
|
||||
def test_seconds_come_from_the_bytes_not_from_the_decoder(ear):
|
||||
"""Metering must not depend on the model returning anything. A decoder that
|
||||
hears silence still consumed the audio it was sent."""
|
||||
live = transcript.begin("whisper", None)
|
||||
assert live.push(tone(0, 1.5)) == 1.5
|
||||
assert live.seconds == 1.5
|
||||
|
||||
|
||||
# ── the window: what is settled commits, what is still moving does not ──────
|
||||
|
||||
def test_settled_words_commit_and_their_audio_is_freed(ear):
|
||||
"""A segment that ended a guard-length before the audio did cannot change, so
|
||||
it is committed and its bytes are dropped. The text names the samples, so a
|
||||
wrong trim shows up as wrong words."""
|
||||
live = transcript.begin("whisper", None)
|
||||
for value in (1, 2, 3):
|
||||
live.push(tone(value, 2.0))
|
||||
settle(live)
|
||||
|
||||
assert live.text == "1 2", "the first two tones are settled"
|
||||
assert live.pending == "3", "the last is still open and stays revisable"
|
||||
assert len(live._window) == int(2.0 * stt.SECOND), "committed audio is freed"
|
||||
|
||||
|
||||
def test_the_open_tail_is_never_committed_early(ear):
|
||||
"""Everything within GUARD of the end stays pending: committing it would
|
||||
freeze a word that the next chunk may still change."""
|
||||
live = transcript.begin("whisper", None)
|
||||
live.push(tone(9, 1.5))
|
||||
settle(live)
|
||||
assert live.text == "", "nothing is settled yet"
|
||||
assert live.pending == "9"
|
||||
|
||||
|
||||
def test_close_commits_the_remainder(ear):
|
||||
"""Nothing follows a close, so the guard has nothing to protect — the tail is
|
||||
decoded and committed, or the last words are lost."""
|
||||
live = transcript.begin("whisper", None)
|
||||
for value in (1, 2, 3):
|
||||
live.push(tone(value, 2.0))
|
||||
settle(live)
|
||||
live.close()
|
||||
assert live.text == "1 2 3"
|
||||
assert live.pending == ""
|
||||
|
||||
|
||||
def test_the_window_stays_bounded(ear):
|
||||
"""Committed audio is dropped, so a long session decodes a window that stops
|
||||
growing instead of one that grows with the call."""
|
||||
live = transcript.begin("whisper", None)
|
||||
for value in range(1, 31): # 60 s
|
||||
live.push(tone(value, 2.0))
|
||||
settle(live)
|
||||
assert len(live._window) <= int(transcript.WINDOW * stt.SECOND)
|
||||
assert max(ear.spans) <= transcript.WINDOW
|
||||
|
||||
|
||||
def test_text_is_never_duplicated_across_trims(ear):
|
||||
"""A commit that failed to free its audio would decode the same words again
|
||||
and say them twice. Each tone must appear exactly once."""
|
||||
live = transcript.begin("whisper", None)
|
||||
for value in range(1, 11):
|
||||
live.push(tone(value, 2.0))
|
||||
settle(live)
|
||||
live.close()
|
||||
assert live.text.split() == [str(v) for v in range(1, 11)]
|
||||
|
||||
|
||||
# ── the surface ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_open_states_the_shape_it_wants(ear):
|
||||
live = start()
|
||||
assert live["rate"] == 16000 and live["channels"] == 1 and live["format"] == "pcm16"
|
||||
assert live["id"].startswith("atr_")
|
||||
assert live["chunk_ms"] == 256
|
||||
|
||||
|
||||
def test_a_different_rate_is_refused_not_resampled(ear):
|
||||
"""Raw audio carries no header, so the wrong rate is not an error downstream
|
||||
— it is gibberish that transcribes successfully."""
|
||||
r = client.post("/v1/audio/transcript", json={"model": "whisper", "rate": 8000})
|
||||
assert r.status_code == 400
|
||||
assert "16000" in r.text
|
||||
|
||||
|
||||
def test_unknown_model_is_404_on_open(ear):
|
||||
r = client.post("/v1/audio/transcript", json={"model": "not-a-model"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_unknown_transcript_is_404(ear):
|
||||
r = client.post("/v1/audio/transcript/atr_nope", content=tone(1, 0.25))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_oversize_chunk_is_refused_not_truncated(ear):
|
||||
live = start()
|
||||
r = client.post(f"/v1/audio/transcript/{live['id']}", content=tone(1, 3.0))
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
def test_half_a_sample_is_refused(ear):
|
||||
"""An odd byte count is not int16 frames; accepting it shifts every sample
|
||||
after it by one byte and turns the rest of the stream into noise."""
|
||||
live = start()
|
||||
r = client.post(f"/v1/audio/transcript/{live['id']}", content=b"\x01\x02\x03")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_a_full_transcript_stops_accepting_audio(ear):
|
||||
live = transcript.begin("whisper", None)
|
||||
live.seconds = transcript.LIMIT
|
||||
r = client.post(f"/v1/audio/transcript/{live.id}", content=tone(1, 0.25))
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_closing_twice_is_404(ear):
|
||||
live = start()
|
||||
assert client.delete(f"/v1/audio/transcript/{live['id']}").status_code == 200
|
||||
assert client.delete(f"/v1/audio/transcript/{live['id']}").status_code == 404
|
||||
|
||||
|
||||
def test_abandoned_transcripts_are_collected(ear):
|
||||
"""A client that stops talking must not pin its audio in memory forever."""
|
||||
live = transcript.begin("whisper", None)
|
||||
live.push(tone(1, 0.5))
|
||||
live._touched -= transcript.IDLE + 1
|
||||
transcript.begin("whisper", None) # collection happens on the way in
|
||||
assert transcript.find(live.id) is None
|
||||
|
||||
|
||||
def test_samples_map_int16_onto_the_unit_range(ear):
|
||||
"""The model wants float in [-1, 1). Getting this wrong does not fail — it
|
||||
transcribes quiet audio as silence and loud audio as noise."""
|
||||
got = stt.samples(numpy.array([0, 16384, -32768], dtype="<i2").tobytes())
|
||||
assert got.dtype == numpy.float32
|
||||
assert list(got) == [0.0, 0.5, -1.0]
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
"""A transcript that grows: audio arrives in pieces, text comes back on the ack.
|
||||
|
||||
Whisper is not a streaming model — it decodes a span of audio, not a sample at a
|
||||
time. So a growing transcript is a window that is re-decoded as it fills, and the
|
||||
art is deciding which of the words it returns are settled. A segment that ended
|
||||
well before the audio did will not change when more audio arrives, so it is
|
||||
committed and its audio is dropped; the tail stays open and is decoded again next
|
||||
time. That is what keeps the window bounded and the committed text stable.
|
||||
|
||||
The ack never waits for a decode. A push appends its audio and returns the newest
|
||||
text the session has, so the answer costs one memcpy and the round trip — decode
|
||||
latency lands on a later ack instead of on this one. One decode runs per session
|
||||
at a time, which is also what stops a fast pusher from queueing work faster than
|
||||
the CPU retires it.
|
||||
|
||||
`seconds` is arithmetic on the bytes received, so it is the audio submitted,
|
||||
exactly, and it cannot silently become zero: it is the same quantity `transcribe`
|
||||
reports as `duration`, measured the same way, and it is what the call is billed on.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
import stt
|
||||
|
||||
# 250 ms per push at 16 kHz mono int16. Small enough to sit inside one ZAP frame,
|
||||
# which the transport requires: a request is built as a single frame, so audio
|
||||
# that does not fit in one cannot be sent at all.
|
||||
CHUNK = 8 * 1024
|
||||
CEILING = 64 * 1024 # 2 s; a larger push is refused rather than truncated
|
||||
|
||||
FLOOR = 1.0 # audio below this is not worth a decode pass
|
||||
GUARD = 1.0 # trailing seconds never committed: the last word may still be moving
|
||||
WINDOW = 12.0 # past this the guard is dropped, so the window cannot grow forever
|
||||
IDLE = 30.0 # a session untouched this long is collectable
|
||||
LIMIT = 600.0 # audio one session will accept, in seconds
|
||||
|
||||
|
||||
class Transcript:
|
||||
"""One growing transcript. Everything mutable is under `_lock`."""
|
||||
|
||||
def __init__(self, model: str, language: str | None):
|
||||
self.id = "atr_" + secrets.token_hex(12)
|
||||
self.model = model
|
||||
self.language = language
|
||||
self.text = "" # committed: settled, never revised
|
||||
self.pending = "" # the open tail: newest decode, may still change
|
||||
self.seconds = 0.0 # audio received — the billable quantity
|
||||
self._window = bytearray()
|
||||
self._got = 0 # bytes ever received; marks progress for the worker
|
||||
self._lock = threading.Lock()
|
||||
self._decoding = False
|
||||
self._closed = False
|
||||
self._touched = time.monotonic()
|
||||
|
||||
@property
|
||||
def full(self) -> bool:
|
||||
return self.seconds >= LIMIT
|
||||
|
||||
def stale(self, now: float) -> bool:
|
||||
return now - self._touched > IDLE
|
||||
|
||||
def push(self, pcm: bytes) -> float:
|
||||
"""Accept audio; return the seconds THIS push carried.
|
||||
|
||||
The return value is the metered quantity for this call — per push, not
|
||||
cumulative, so summing the pushes of a session yields the audio submitted
|
||||
once and only once.
|
||||
"""
|
||||
with self._lock:
|
||||
self._window += pcm
|
||||
self._got += len(pcm)
|
||||
self.seconds += len(pcm) / stt.SECOND
|
||||
self._touched = time.monotonic()
|
||||
start = not self._decoding and len(self._window) >= FLOOR * stt.SECOND
|
||||
if start:
|
||||
self._decoding = True
|
||||
if start:
|
||||
threading.Thread(target=self._work, daemon=True).start()
|
||||
return len(pcm) / stt.SECOND
|
||||
|
||||
def close(self) -> None:
|
||||
"""Decode what is left and commit all of it — nothing follows, so nothing
|
||||
is held back. Blocks: the final text is the point of closing."""
|
||||
with self._lock:
|
||||
self._closed = True # stops an in-flight decode from committing twice
|
||||
rest, self._window = bytes(self._window), bytearray()
|
||||
if not rest:
|
||||
return
|
||||
tail = " ".join(s.text.strip() for s in stt.segments(self.model, rest, self.language))
|
||||
with self._lock:
|
||||
self.text = (self.text + " " + tail).strip()
|
||||
self.pending = ""
|
||||
|
||||
def _work(self) -> None:
|
||||
"""Decode the window until it stops growing, then stand down.
|
||||
|
||||
`_decoding` is cleared under the same lock that reads progress, so a push
|
||||
that arrives at the moment the worker gives up still starts a new one.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
with self._lock:
|
||||
window, mark = bytes(self._window), self._got
|
||||
if self._closed or len(window) < FLOOR * stt.SECOND:
|
||||
self._decoding = False
|
||||
return
|
||||
self._absorb(window)
|
||||
with self._lock:
|
||||
if self._got == mark:
|
||||
self._decoding = False
|
||||
return
|
||||
except BaseException:
|
||||
with self._lock:
|
||||
self._decoding = False
|
||||
raise
|
||||
|
||||
def _absorb(self, window: bytes) -> None:
|
||||
heard = stt.segments(self.model, window, self.language)
|
||||
span = len(window) / stt.SECOND
|
||||
# Past WINDOW the guard is dropped: holding the tail back forever would let
|
||||
# the window — and every decode over it — grow without bound.
|
||||
edge = span if span > WINDOW else span - GUARD
|
||||
|
||||
done, cut = [], 0.0
|
||||
for s in heard:
|
||||
if s.end > edge:
|
||||
break # segments are in time order: once one is open, the rest are
|
||||
done.append(s.text.strip())
|
||||
cut = s.end
|
||||
open_tail = " ".join(s.text.strip() for s in heard if s.end > cut)
|
||||
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
if done:
|
||||
self.text = (self.text + " " + " ".join(done)).strip()
|
||||
del self._window[: int(cut * stt.RATE) * stt.WIDTH]
|
||||
self.pending = open_tail
|
||||
|
||||
def state(self, billed: float = 0.0) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"id": self.id,
|
||||
"text": self.text,
|
||||
"pending": self.pending,
|
||||
"seconds": round(self.seconds, 3),
|
||||
# What THIS call consumed, named as `transcribe` names it, so the
|
||||
# plane meters a push and a batch transcription by one rule.
|
||||
"duration": round(billed, 3),
|
||||
}
|
||||
|
||||
|
||||
_live: dict[str, Transcript] = {}
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
def begin(model: str, language: str | None) -> Transcript:
|
||||
"""Open a transcript, collecting abandoned ones on the way in.
|
||||
|
||||
Sweeping here rather than on a timer means there is no clock to run and no
|
||||
task to supervise: sessions are only worth collecting when new ones arrive.
|
||||
"""
|
||||
live = Transcript(model, language)
|
||||
now = time.monotonic()
|
||||
with _guard:
|
||||
for tid in [tid for tid, t in _live.items() if t.stale(now)]:
|
||||
del _live[tid]
|
||||
_live[live.id] = live
|
||||
return live
|
||||
|
||||
|
||||
def find(tid: str) -> Transcript | None:
|
||||
with _guard:
|
||||
return _live.get(tid)
|
||||
|
||||
|
||||
def drop(tid: str) -> Transcript | None:
|
||||
with _guard:
|
||||
return _live.pop(tid, None)
|
||||
@@ -1100,12 +1100,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "speech"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "kokoro-onnx" },
|
||||
{ name = "numpy" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "soundfile" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -1122,6 +1123,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "faster-whisper", specifier = ">=1.1" },
|
||||
{ name = "kokoro-onnx", specifier = ">=0.4" },
|
||||
{ name = "numpy", specifier = ">=2.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "soundfile", specifier = ">=0.13" },
|
||||
{ name = "uvicorn", specifier = ">=0.34" },
|
||||
|
||||
Reference in New Issue
Block a user