Files
VoiceStudio/backend/api/routers/capture_ws.py
T
debpalashandClaude Opus 4.7 79d4f3b53d feat(0.2.6): tray-aware shell, hotkey customization, WS dictation dedupe
Tray + lifecycle:
- tauri-plugin-single-instance — second launch focuses existing window
  instead of racing for port 3900.
- Window close hides instead of destroying; backend shutdown moved to
  RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS)
  actually exits.
- Tray icon flips to red-dot variant during dictation recording.

Hotkey customization:
- Settings → Capture tab. Records any modifier+key combo, persists to
  app config, re-registers on launch.
- set_dictation_shortcut rolls back to the previous binding on register
  failure so a bad combo never leaves the user with no shortcut.

Dictation latency / correctness:
- WS-final treated as source of truth; HTTP POST /transcribe runs only as
  fallback (WS error / timeout / no-WS path). Audio transcribed once
  instead of twice. Server accepts an "EOF" text frame (or empty binary
  frame) so the socket stays open for `final` to be delivered before the
  client closes.
- MediaRecorder chunks queued during the WS handshake are drained in
  ws.onopen — the server's final transcript no longer drops the first
  ~250 ms of audio.
- Fallback timeout scales with recording length (max(15s, recordedMs+10s))
  so long-form dictations don't trip duplicate transcription.

Donate page:
- Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react.
- Move "Commercial License" CTA from page bottom to top-right header bar.

Docker hygiene:
- docker-compose binds 127.0.0.1 by default. README documents the LAN
  exposure trade-off + recommends a reverse proxy with auth.

CI:
- New cross-platform `tauri-cross-platform` job runs `cargo check` against
  the Tauri shell on macOS / Windows / Linux per PR. Catches platform
  cfg-gate regressions without paying the full ~15min/platform bundle
  cost (full bundling stays in release.yml on tag push).

Tests:
- tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary
  EOF, and legacy disconnect-finalize paths.

Includes the user's previously-staged 0.2.5 polish: cross-platform
desktop-prod.sh, Dockerfile base-image fix, bun.lock churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:33:22 +05:30

305 lines
10 KiB
Python

"""
Streaming ASR via WebSocket — live partial transcription results.
Client streams audio chunks (PCM/WebM) and receives partial + final
transcription JSON messages in real-time. Used by CaptureButton for
live dictation feedback.
Protocol:
→ Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
← Server sends JSON messages:
{"type": "partial", "text": "Hello wor..."} — interim result
{"type": "final", "text": "Hello world.", — committed result
"segments": [...], "language": "en",
"duration_s": 4.2, "transcription_time_s": 0.8,
"engine": "mlx-whisper"}
{"type": "error", "detail": "..."} — error
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import tempfile
import time
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
# How often (seconds) to run transcription on the accumulated buffer.
# Shorter = more responsive but more GPU load.
PARTIAL_INTERVAL_S = float(os.environ.get("OMNIVOICE_STREAM_INTERVAL", "2.0"))
# Maximum silence before we auto-finalize (seconds of no new audio).
SILENCE_TIMEOUT_S = float(os.environ.get("OMNIVOICE_STREAM_SILENCE", "3.0"))
# Minimum buffer size before first partial (bytes of raw audio).
MIN_BUFFER_BYTES = 16000 # ~0.5s of 16-bit mono 16kHz
@router.websocket("/ws/transcribe")
async def ws_transcribe(websocket: WebSocket):
"""Stream audio in, get partial + final transcription out."""
await websocket.accept()
audio_chunks: list[bytes] = []
total_bytes = 0
last_audio_time = time.monotonic()
running = True
partial_text = ""
# Track whether the client initiated the disconnect. When True the
# WebSocket is already in a closed/closing state and any attempt to
# call `send_json()` will raise "Unexpected ASGI message".
client_disconnected = False
async def receive_audio():
"""Receive audio frames from the client.
Two end-of-stream signals: (a) text frame ``"EOF"`` (preferred —
keeps the socket open so the ``final`` message can still be sent
before the client closes), or (b) socket disconnect (legacy path).
The EOF protocol exists so the client can use the WS ``final``
message as the authoritative result and skip the duplicate HTTP
POST that used to run on every dictation.
"""
nonlocal total_bytes, last_audio_time, running, client_disconnected
try:
while running:
msg = await websocket.receive()
msg_type = msg.get("type")
if msg_type == "websocket.disconnect":
client_disconnected = True
running = False
break
if msg_type != "websocket.receive":
continue
data = msg.get("bytes")
if data is not None:
if len(data) == 0:
# Empty binary frame also acts as EOF — connection stays open.
running = False
break
audio_chunks.append(data)
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if msg.get("text") == "EOF":
# Client signals end-of-audio but stays connected for `final`.
running = False
break
except WebSocketDisconnect:
client_disconnected = True
running = False
except Exception as e:
logger.debug("WS receive ended: %s", e)
client_disconnected = True
running = False
async def _safe_send(payload: dict) -> bool:
"""Send JSON to the client, returning False if the connection is gone."""
if client_disconnected:
return False
try:
await websocket.send_json(payload)
return True
except Exception:
return False
async def process_partials():
"""Periodically transcribe the accumulated buffer for partial results."""
nonlocal partial_text, running
while running:
await asyncio.sleep(PARTIAL_INTERVAL_S)
if not running:
break
# Check silence timeout
if time.monotonic() - last_audio_time > SILENCE_TIMEOUT_S and total_bytes > MIN_BUFFER_BYTES:
running = False
break
if total_bytes < MIN_BUFFER_BYTES:
continue
# Transcribe current buffer
try:
text = await _transcribe_buffer(audio_chunks[:])
if text and text != partial_text:
partial_text = text
await _safe_send({
"type": "partial",
"text": text,
})
except Exception as e:
logger.warning("Partial transcription failed: %s", e)
# Run receiver and processor concurrently
receiver_task = asyncio.create_task(receive_audio())
processor_task = asyncio.create_task(process_partials())
# Wait for either to finish (receiver ends on disconnect, processor on silence)
done, pending = await asyncio.wait(
[receiver_task, processor_task],
return_when=asyncio.FIRST_COMPLETED,
)
running = False
for task in pending:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
# Final transcription on complete buffer — skip if client already gone.
if total_bytes > MIN_BUFFER_BYTES:
try:
result = await _transcribe_buffer_full(audio_chunks)
if not await _safe_send({"type": "final", **result}):
logger.debug("Skipped final send — client already disconnected")
except Exception as e:
logger.error("Final transcription failed: %s", e)
await _safe_send({"type": "error", "detail": str(e)})
else:
await _safe_send({
"type": "final",
"text": "",
"segments": [],
"language": "unknown",
"duration_s": 0,
"transcription_time_s": 0,
"engine": "none",
})
if not client_disconnected:
try:
await websocket.close()
except Exception:
pass
async def _transcribe_buffer(chunks: list[bytes]) -> str:
"""Quick partial transcription of the current audio buffer."""
import soundfile as sf
import numpy as np
tmp = _chunks_to_wav(chunks)
if tmp is None:
return ""
try:
from services.model_manager import _gpu_pool
from services.asr_backend import get_capture_asr_backend
def _run():
backend = get_capture_asr_backend()
result = backend.transcribe(tmp, word_timestamps=False)
return result.get("text", "")
loop = asyncio.get_event_loop()
text = await loop.run_in_executor(_gpu_pool, _run)
return text.strip()
finally:
try:
os.unlink(tmp)
except OSError:
pass
async def _transcribe_buffer_full(chunks: list[bytes]) -> dict:
"""Full transcription with timing info for the final result."""
tmp = _chunks_to_wav(chunks)
if tmp is None:
return {"text": "", "segments": [], "language": "unknown",
"duration_s": 0, "transcription_time_s": 0, "engine": "none"}
try:
from services.model_manager import _gpu_pool
from services.asr_backend import get_capture_asr_backend
def _run():
backend = get_capture_asr_backend()
t0 = time.perf_counter()
result = backend.transcribe(tmp, word_timestamps=False)
elapsed = round(time.perf_counter() - t0, 2)
segments = result.get("segments", [])
full_text = result.get("text", "")
if not full_text and segments:
full_text = " ".join(s.get("text", "") for s in segments).strip()
duration = max((s.get("end", 0) for s in segments), default=0.0)
return {
"text": full_text,
"segments": [
{"start": round(s.get("start", 0), 2),
"end": round(s.get("end", 0), 2),
"text": s.get("text", "").strip()}
for s in segments
],
"language": result.get("language", "unknown"),
"duration_s": round(duration, 2),
"transcription_time_s": elapsed,
"engine": backend.id,
}
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_gpu_pool, _run)
finally:
try:
os.unlink(tmp)
except OSError:
pass
def _chunks_to_wav(chunks: list[bytes]) -> str | None:
"""Concatenate audio chunks and write to a temp WAV file.
Handles both raw PCM (from AudioWorklet) and WebM/Opus blobs
(from MediaRecorder) by converting through ffmpeg.
"""
if not chunks:
return None
blob = b"".join(chunks)
if len(blob) < 100:
return None
# Write blob to temp file
tmp_in = tempfile.NamedTemporaryFile(delete=False, suffix=".webm")
tmp_in.write(blob)
tmp_in.close()
tmp_out = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
tmp_out.close()
try:
from services.ffmpeg_utils import find_ffmpeg
import subprocess
subprocess.run(
[find_ffmpeg(), "-y", "-i", tmp_in.name,
"-ar", "16000", "-ac", "1", "-f", "wav", tmp_out.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
check=True,
)
return tmp_out.name
except Exception as e:
logger.warning("ffmpeg conversion failed: %s", e)
try:
os.unlink(tmp_out.name)
except OSError:
pass
return None
finally:
try:
os.unlink(tmp_in.name)
except OSError:
pass