Start on live transcription and python sidecar (faster-whisper)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Server-engine sidecar dependencies (Decisions.md D-013). Only needed on
|
||||
# deployments that enable the server engine — the browser/anonymous path
|
||||
# deploys with Bun alone. Install with: python3 -m pip install -r requirements.txt
|
||||
faster-whisper>=1.1,<2
|
||||
@@ -0,0 +1,159 @@
|
||||
"""faster-whisper sidecar (Decisions.md D-013).
|
||||
|
||||
Bun spawns and supervises this process (src/server/transcribe.ts); it is the
|
||||
only place server-side inference happens. The interface is JSON-lines over
|
||||
stdio — one JSON object per line, no framing beyond that:
|
||||
|
||||
stdout <- {"type": "ready", "version": "1.1.1"} # startup handshake
|
||||
stdout <- {"type": "fatal", "message": "..."} # then exit 1
|
||||
|
||||
stdin -> {"type": "transcribe", "id": "1", "model": "tiny.en",
|
||||
"audio_path": "/tmp/...", "language": "en" | null,
|
||||
"task": "transcribe" | "translate"}
|
||||
stdout <- {"type": "segment", "id": "1", "start": 0.0, "end": 4.2,
|
||||
"text": " Hello world."} # 0..n of these
|
||||
stdout <- {"type": "done", "id": "1", "cancelled": false}
|
||||
stdout <- {"type": "error", "id": "1", "message": "..."} # instead of done
|
||||
|
||||
stdin -> {"type": "cancel", "id": "1"} # stop between segments; still "done"
|
||||
stdin -> {"type": "shutdown"} # clean exit (EOF does the same)
|
||||
|
||||
Audio arrives as a file of raw little-endian float32 mono PCM at 16 kHz — the
|
||||
exact representation the browser already produces for its own engine — so no
|
||||
container decoding happens here. Requests are processed strictly one at a
|
||||
time; a reader thread keeps consuming stdin meanwhile so `cancel` can
|
||||
interrupt the segment loop of the request in flight.
|
||||
|
||||
The last-used model stays loaded between requests (WorkBreakdown M5 warm-up:
|
||||
never reload weights per request) and is replaced — not accumulated — when a
|
||||
request names a different model, bounding memory to one model.
|
||||
|
||||
Environment:
|
||||
FASTER_WHISPER_DEVICE ctranslate2 device (default "auto")
|
||||
FASTER_WHISPER_COMPUTE compute type (default "int8" — the fast,
|
||||
low-memory CPU choice; CT2 converts on load)
|
||||
FASTER_WHISPER_DOWNLOAD_ROOT optional model download/cache directory
|
||||
|
||||
Requires Python >= 3.9 and the packages in requirements.txt. This file must
|
||||
keep working without them up to the import: a missing dependency is reported
|
||||
as a "fatal" line, which the Bun side surfaces as "server engine unavailable"
|
||||
(the anonymous/browser path never needs any of this).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
|
||||
|
||||
def emit(message):
|
||||
"""Write one JSON line to stdout. Only the main thread emits."""
|
||||
sys.stdout.write(json.dumps(message) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
import faster_whisper
|
||||
import numpy as np
|
||||
except ImportError as error:
|
||||
emit(
|
||||
{
|
||||
"type": "fatal",
|
||||
"message": "faster-whisper is not installed (%s) — "
|
||||
"see server-py/requirements.txt" % error,
|
||||
}
|
||||
)
|
||||
return 1
|
||||
|
||||
device = os.environ.get("FASTER_WHISPER_DEVICE", "auto")
|
||||
compute_type = os.environ.get("FASTER_WHISPER_COMPUTE", "int8")
|
||||
download_root = os.environ.get("FASTER_WHISPER_DOWNLOAD_ROOT") or None
|
||||
|
||||
requests = queue.Queue()
|
||||
cancelled = set() # request ids; GIL-atomic add/contains is enough here
|
||||
SHUTDOWN = object()
|
||||
|
||||
def read_stdin():
|
||||
"""Reader thread: queue work, flag cancels, shut down on EOF."""
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except ValueError:
|
||||
continue # a malformed line must not kill the sidecar
|
||||
kind = message.get("type")
|
||||
if kind == "transcribe":
|
||||
requests.put(message)
|
||||
elif kind == "cancel":
|
||||
cancelled.add(message.get("id"))
|
||||
elif kind == "shutdown":
|
||||
requests.put(SHUTDOWN)
|
||||
return
|
||||
requests.put(SHUTDOWN) # supervisor closed the pipe / died
|
||||
|
||||
threading.Thread(target=read_stdin, daemon=True).start()
|
||||
|
||||
cached_name = None
|
||||
cached_model = None
|
||||
|
||||
emit({"type": "ready", "version": getattr(faster_whisper, "__version__", "?")})
|
||||
|
||||
while True:
|
||||
request = requests.get()
|
||||
if request is SHUTDOWN:
|
||||
return 0
|
||||
|
||||
request_id = request.get("id")
|
||||
try:
|
||||
model_name = request["model"]
|
||||
audio_path = request["audio_path"]
|
||||
language = request.get("language") or None
|
||||
task = request.get("task") or "transcribe"
|
||||
|
||||
if model_name != cached_name:
|
||||
cached_model = None # release before loading the replacement
|
||||
cached_model = faster_whisper.WhisperModel(
|
||||
model_name,
|
||||
device=device,
|
||||
compute_type=compute_type,
|
||||
download_root=download_root,
|
||||
)
|
||||
cached_name = model_name
|
||||
|
||||
audio = np.fromfile(audio_path, dtype=np.float32)
|
||||
segments, _info = cached_model.transcribe(
|
||||
audio, language=language, task=task
|
||||
)
|
||||
|
||||
was_cancelled = False
|
||||
# `segments` is a generator: inference happens during iteration,
|
||||
# so each segment can stream out (and cancel can cut in) while the
|
||||
# next one is still being decoded.
|
||||
for segment in segments:
|
||||
if request_id in cancelled:
|
||||
was_cancelled = True
|
||||
break
|
||||
emit(
|
||||
{
|
||||
"type": "segment",
|
||||
"id": request_id,
|
||||
"start": round(float(segment.start), 3),
|
||||
"end": round(float(segment.end), 3),
|
||||
"text": segment.text,
|
||||
}
|
||||
)
|
||||
emit({"type": "done", "id": request_id, "cancelled": was_cancelled})
|
||||
except Exception as error: # noqa: BLE001 — one bad request must not exit
|
||||
emit({"type": "error", "id": request_id, "message": str(error)})
|
||||
finally:
|
||||
cancelled.discard(request_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user