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:
2026-08-08 14:59:28 -07:00
co-authored by Hanzo Dev
parent 7dc4714dcc
commit 81071b1d83
9 changed files with 723 additions and 9 deletions
+9
View File
@@ -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
View File
@@ -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"]
+82 -1
View File
@@ -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
View File
@@ -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
+38 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
Generated
+3 -1
View File
@@ -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" },