refactor(backend): 启动环境初始化与资源项目化(零跨项目依赖)

- main.py:uv run main.py 一键启动——读取 backend/.env、TORCH_HOME/NLTK_DATA 指向项目内缓存、certifi SSL + NO_PROXY 网络直连、默认开启 s2s,启动横幅
- vendor/:补丁后 s2s 云化栈移入项目(backend/vendor/s2s-cloud),pyproject sources 改相对路径
- .gitignore:忽略 .torch-cache / nltk_data 缓存
- AGENTS.md:进程管理铁律(AI 不启动/重启服务,由用户操作)
This commit is contained in:
Pine
2026-08-18 01:37:57 +08:00
parent 41e9681aa0
commit 7f25c8f6bb
198 changed files with 44792 additions and 4 deletions
@@ -0,0 +1,163 @@
# STT Summary
This document summarizes the Speech-to-Text (STT) implementations in the `STT/` folder, including language support, language abbreviations, and usage in `s2s_pipeline.py`.
## Available STT Modes (`--stt`)
- `whisper` → `STT/whisper_stt_handler.py`
- `whisper-mlx` → `STT/lightning_whisper_mlx_handler.py`
- `mlx-audio-whisper` → `STT/mlx_audio_whisper_handler.py`
- `faster-whisper` → `STT/faster_whisper_handler.py`
- `parakeet-tdt` → `STT/parakeet_tdt_handler.py`
- `paraformer` → `STT/paraformer_handler.py`
## Language Support by Handler
### 1) Whisper (`--stt whisper`)
- Handler: `WhisperSTTHandler`
- Language input flag: `--language` (from shared Whisper args)
- Supports fixed language (e.g. `en`) or `auto`
- Internal supported language list:
- `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
- Behavior:
- Detects language from token output
- If detected language is outside the supported list, it falls back to the previous language
### 2) Lightning Whisper MLX (`--stt whisper-mlx`)
- Handler: `LightningWhisperSTTHandler`
- Uses same shared `--language` argument as Whisper
- Internal supported language list:
- `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
- Behavior:
- If `--language auto`, model auto-detects each utterance
- If detected language is unsupported, falls back to last supported language
### 3) MLX Audio Whisper (`--stt mlx-audio-whisper`)
- Handler: `MLXAudioWhisperSTTHandler`
- Model flag: `--mlx_audio_whisper_model_name`
- Language still comes from shared `--language` flag (wired in pipeline)
- Internal supported language list:
- `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
- Behavior:
- Uses fixed language unless `--language auto`
- Falls back to last known supported language when needed
### 4) Faster-Whisper (`--stt faster-whisper`)
- Handler: `FasterWhisperSTTHandler`
- Language flag: `--faster_whisper_stt_gen_language`
- Default language: `en`
- Note:
- This handler passes generation kwargs directly to `faster_whisper.WhisperModel.transcribe(...)`
- Effective language coverage depends on selected Faster-Whisper/OpenAI Whisper model
### 5) Parakeet TDT (`--stt parakeet-tdt`)
- Handler: `ParakeetTDTSTTHandler`
- Language flag: `--parakeet_tdt_language` (optional)
- Supports auto language detection when language not specified
- Declared supported language list (25 European languages):
- `en`, `de`, `fr`, `es`, `it`, `pt`, `nl`, `pl`, `ru`, `uk`, `cs`, `sk`, `hu`, `ro`, `bg`, `hr`, `sl`, `sr`, `da`, `no`, `sv`, `fi`, `et`, `lv`, `lt`
- Backend behavior:
- On macOS/MPS: MLX (`mlx-community/parakeet-tdt-0.6b-v3`)
- On CUDA/CPU: nano-parakeet (`nvidia/parakeet-tdt-0.6b-v3`)
### 6) Paraformer (`--stt paraformer`)
- Handler: `ParaformerSTTHandler`
- Model flag: `--paraformer_stt_model_name`
- Default model: `paraformer-zh`
- No dedicated language flag in current args class
- Practical support:
- Depends on selected FunASR model checkpoint
- Default setup is Chinese-oriented (`zh`)
## Language Abbreviations (ISO-style codes seen in STT handlers)
| Code | Language |
|---|---|
| `en` | English |
| `fr` | French |
| `es` | Spanish |
| `zh` | Chinese |
| `ja` | Japanese |
| `ko` | Korean |
| `hi` | Hindi |
| `de` | German |
| `pt` | Portuguese |
| `pl` | Polish |
| `it` | Italian |
| `nl` | Dutch |
| `ru` | Russian |
| `uk` | Ukrainian |
| `cs` | Czech |
| `sk` | Slovak |
| `hu` | Hungarian |
| `ro` | Romanian |
| `bg` | Bulgarian |
| `hr` | Croatian |
| `sl` | Slovenian |
| `sr` | Serbian |
| `da` | Danish |
| `no` | Norwegian |
| `sv` | Swedish |
| `fi` | Finnish |
| `et` | Estonian |
| `lv` | Latvian |
| `lt` | Lithuanian |
| `auto` | Per-utterance automatic language detection |
## Usage Examples
### Whisper (Transformers)
```bash
python s2s_pipeline.py --stt whisper --language en
python s2s_pipeline.py --stt whisper --language auto
```
### Whisper MLX (LightningWhisperMLX)
```bash
python s2s_pipeline.py --stt whisper-mlx --language auto --device mps
```
### MLX Audio Whisper
```bash
python s2s_pipeline.py --stt mlx-audio-whisper \
--mlx_audio_whisper_model_name mlx-community/whisper-large-v3-turbo \
--language auto
```
### Faster-Whisper
```bash
python s2s_pipeline.py --stt faster-whisper \
--faster_whisper_stt_model_name large-v3 \
--faster_whisper_stt_gen_language en
```
### Parakeet TDT
```bash
python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_device auto
python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_language de
```
With live transcription (MLX or CUDA/nano-parakeet backend):
```bash
python s2s_pipeline.py --stt parakeet-tdt \
--enable_live_transcription \
--live_transcription_update_interval 0.25
```
### Paraformer
```bash
python s2s_pipeline.py --stt paraformer --paraformer_stt_model_name paraformer-zh
```
@@ -0,0 +1 @@
@@ -0,0 +1,212 @@
from __future__ import annotations
import logging
from collections import Counter, OrderedDict
from time import perf_counter
from typing import Any
from speech_to_speech.baseHandler import BaseHandler
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription, VADAudio
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
logger = logging.getLogger(__name__)
class BaseSTTHandler(BaseHandler[STTIn, STTOut]):
"""Base STT handler with speculative-turn stale input filtering."""
_MAX_COMPLETED_FINAL_REVISIONS = 2048
speculative_turns: SpeculativeTurnTracker | None = None
final_revision_settle_s: float = 0.0
def should_process_input(self, item: STTIn) -> bool:
mode = getattr(item, "mode", None)
turn_id = getattr(item, "turn_id", None)
turn_revision = getattr(item, "turn_revision", None)
if self._is_completed_final_revision(item):
queued_drops = self._drop_stale_queued_inputs()
self._log_stale_turn_item(item, "input-after-final", queued_drops=queued_drops)
return False
if mode == "progressive" and self._has_queued_final_for_revision(item):
self._log_stale_turn_item(item, "progressive-before-final")
return False
wait_for_stability = mode == "final"
gate_start = perf_counter()
is_latest = self._is_latest_turn_item(
item,
wait_for_pending_reopen=True,
wait_for_stability=wait_for_stability,
)
gate_wait_s = perf_counter() - gate_start
if gate_wait_s >= 0.05:
logger.info(
"%s: STT input gate waited %.3fs for turn=%s rev=%s mode=%s latest=%s age=%.3fs queue=%s",
self.__class__.__name__,
gate_wait_s,
turn_id,
turn_revision,
mode,
is_latest,
self._item_age_s(item),
self._safe_qsize(),
)
if not is_latest:
queued_drops = self._drop_stale_queued_inputs()
self._log_stale_turn_item(item, "input", queued_drops=queued_drops)
return False
return True
def should_emit_output(self, output: STTOut) -> bool:
if isinstance(output, PartialTranscription) and self._is_completed_final_revision(output):
self._log_stale_turn_item(output, "output-after-final")
return False
if not self._is_latest_turn_item(output, wait_for_pending_reopen=True, wait_for_stability=False):
self._log_stale_turn_item(output, "output")
return False
return True
def before_emit_output(self, output: STTOut) -> None:
if isinstance(output, Transcription):
self._mark_completed_final_revision(output)
def _is_latest_turn_item(
self,
item: object,
*,
wait_for_pending_reopen: bool,
wait_for_stability: bool,
) -> bool:
if self.speculative_turns is None:
return True
turn_id = getattr(item, "turn_id", None)
turn_revision = getattr(item, "turn_revision", None)
if turn_id is None or turn_revision is None:
return True
if wait_for_stability:
is_latest = self.speculative_turns.is_latest_after_stability_window(
turn_id,
turn_revision,
self.final_revision_settle_s,
)
elif wait_for_pending_reopen:
is_latest = self.speculative_turns.is_latest_after_pending_reopen(turn_id, turn_revision)
else:
is_latest = self.speculative_turns.is_latest(turn_id, turn_revision)
return is_latest
def _drop_stale_queued_inputs(self) -> int:
if self.speculative_turns is None or not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"):
return 0
dropped = 0
with self.queue_in.mutex:
kept: list[Any] = []
while self.queue_in.queue:
queued_item = self.queue_in.queue.popleft()
if isinstance(queued_item, VADAudio) and (
self._is_completed_final_revision(queued_item)
or (queued_item.mode == "progressive" and self._has_queued_final_for_revision_locked(queued_item))
or not self._is_latest_turn_item(
queued_item,
wait_for_pending_reopen=False,
wait_for_stability=False,
)
):
dropped += 1
else:
kept.append(queued_item)
self.queue_in.queue.extend(kept)
if dropped:
self.queue_in.not_full.notify_all()
return dropped
def _log_stale_turn_item(self, item: object, stage: str, *, queued_drops: int = 0) -> None:
turn_id = getattr(item, "turn_id", None)
turn_revision = getattr(item, "turn_revision", None)
if turn_id is None or turn_revision is None:
return
if not hasattr(self, "_stale_drop_counts"):
self._stale_drop_counts: Counter[tuple[str, str, int]] = Counter()
key = (stage, turn_id, turn_revision)
self._stale_drop_counts[key] += 1
message = "%s: dropping stale STT %s for turn=%s rev=%s age=%.3fs"
args: tuple[object, ...] = (
self.__class__.__name__,
stage,
turn_id,
turn_revision,
self._item_age_s(item),
)
if queued_drops:
message += " (+%d queued)"
args = (*args, queued_drops)
if self._stale_drop_counts[key] == 1:
logger.info(message, *args)
else:
logger.debug(message, *args)
def _item_age_s(self, item: object) -> float:
created_at_s = getattr(item, "created_at_s", None)
if not isinstance(created_at_s, float):
return 0.0
return max(0.0, perf_counter() - created_at_s)
def _safe_qsize(self) -> int | str:
try:
return self.queue_in.qsize()
except NotImplementedError:
return "unknown"
def _has_queued_final_for_revision(self, item: object) -> bool:
if not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"):
return False
with self.queue_in.mutex:
return self._has_queued_final_for_revision_locked(item)
def _has_queued_final_for_revision_locked(self, item: object) -> bool:
key = self._revision_key(item)
if key is None:
return False
return any(
isinstance(queued_item, VADAudio) and queued_item.mode == "final" and self._revision_key(queued_item) == key
for queued_item in self.queue_in.queue
)
def _revision_key(self, item: object) -> tuple[str, int] | None:
turn_id = getattr(item, "turn_id", None)
turn_revision = getattr(item, "turn_revision", None)
if not isinstance(turn_id, str) or not isinstance(turn_revision, int):
return None
return (turn_id, turn_revision)
def _completed_final_revisions(self) -> OrderedDict[tuple[str, int], None]:
if not hasattr(self, "_completed_final_revision_keys"):
self._completed_final_revision_keys: OrderedDict[tuple[str, int], None] = OrderedDict()
return self._completed_final_revision_keys
def _is_completed_final_revision(self, item: object) -> bool:
key = self._revision_key(item)
return key is not None and key in self._completed_final_revisions()
def _mark_completed_final_revision(self, output: Transcription) -> None:
key = self._revision_key(output)
if key is None:
return
completed = self._completed_final_revisions()
completed[key] = None
completed.move_to_end(key)
while len(completed) > self._MAX_COMPLETED_FINAL_REVISIONS:
completed.popitem(last=False)
def on_session_end(self) -> None:
if hasattr(self, "_completed_final_revision_keys"):
self._completed_final_revision_keys.clear()
@@ -0,0 +1,188 @@
"""DashScope (Aliyun Bailian) streaming ASR STT handler — Omni realtime API.
Uses dashscope.audio.qwen_omni.OmniRealtimeConversation in ASR-only mode
(output_modalities=[TEXT] + input audio transcription) with the
qwen3-asr-flash-realtime model. Each VAD segment is uploaded (streamed in
chunks) and committed; partial/final transcriptions are yielded to the
pipeline. VAD stays local, exactly as faster-whisper used to behave.
"""
from __future__ import annotations
import base64
import logging
import os
import queue
import threading
import time
from typing import Any, Iterator
import numpy as np
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "qwen3-asr-flash-realtime"
DEFAULT_SAMPLE_RATE = 16000
CONNECT_RETRIES = 4
CONNECT_BACKOFF_S = 2.0
EVENT_TIMEOUT_S = 15.0
APPEND_CHUNK_SAMPLES = 16000 * 2 # 2s chunks
class DashScopeASRSTTHandler(BaseSTTHandler):
"""Streaming Chinese ASR via DashScope Omni realtime conversation (ASR-only)."""
def setup(
self,
api_key: str = "",
model: str = DEFAULT_MODEL,
language: str = "zh",
sample_rate: int = DEFAULT_SAMPLE_RATE,
gen_kwargs: dict[str, Any] | None = None,
) -> None:
import dashscope # pip install dashscope>=1.20 (lazy: only when this backend is used)
dashscope.api_key = api_key or os.environ.get("DASHSCOPE_API_KEY", "")
if not dashscope.api_key:
raise ValueError("DASHSCOPE_API_KEY is not set (pass --dashscope_asr_api_key or set env).")
self.model = model
self.language = language
self.sample_rate = int(sample_rate)
logger.info(
"DashScopeASRSTTHandler ready (model=%s, lang=%s, sr=%d)",
self.model,
self.language,
self.sample_rate,
)
@staticmethod
def _float32_to_pcm16(audio: np.ndarray) -> bytes:
if audio.dtype == np.int16:
return audio.tobytes()
return (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
def _connect_with_retry(self, conversation: Any) -> None:
last_exc: Exception | None = None
for attempt in range(CONNECT_RETRIES):
try:
conversation.connect()
return
except Exception as e: # noqa: BLE001
last_exc = e
logger.warning("DashScopeASR connect attempt %d failed: %s", attempt + 1, e)
time.sleep(CONNECT_BACKOFF_S)
raise RuntimeError(f"DashScopeASR could not connect after {CONNECT_RETRIES} attempts: {last_exc}")
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
# v1: cloud ASR opens one websocket session per utterance. Progressive
# (live) segments are skipped — transcribing every 0.5s revision would
# re-open sessions serially and stall the pipeline. Launch with
# --enable_live_transcription false so VAD emits one final segment per
# utterance; keep this guard as defense-in-depth.
if vad_audio.mode == "progressive":
return
from dashscope.audio.qwen_omni import MultiModality, OmniRealtimeConversation
from dashscope.audio.qwen_omni.omni_realtime import TranscriptionParams
pcm = self._float32_to_pcm16(vad_audio.audio)
events: "queue.Queue[tuple[str, str]]" = queue.Queue()
partial_text = ""
final_text = ""
class Callback:
def on_open(self) -> None:
events.put(("open", ""))
def on_close(self, *args: Any) -> None:
events.put(("close", ""))
def on_error(self, error: Any) -> None:
events.put(("error", str(error)))
def on_event(self, message: Any) -> None:
event_type = message.get("type") if isinstance(message, dict) else str(message)
if event_type == "conversation.item.input_audio_transcription.text":
stash = message.get("stash", "") if isinstance(message, dict) else ""
events.put(("partial", str(stash)))
elif event_type == "conversation.item.input_audio_transcription.completed":
transcript = message.get("transcript", "") if isinstance(message, dict) else ""
events.put(("final", str(transcript)))
conversation = OmniRealtimeConversation(
model=self.model,
url="wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
callback=Callback(),
)
self._connect_with_retry(conversation)
try:
conversation.update_session(
output_modalities=[MultiModality.TEXT],
enable_input_audio_transcription=True,
# Disable the server-side VAD: it races our explicit commit()
# (auto-consuming the audio buffer can make commit fail with
# "input messages do not contain elements with the role of user").
enable_turn_detection=False,
transcription_params=TranscriptionParams(
language=self.language,
sample_rate=self.sample_rate,
input_audio_format="pcm",
),
)
chunk_bytes = APPEND_CHUNK_SAMPLES * 2
for i in range(0, len(pcm), chunk_bytes):
conversation.append_audio(base64.b64encode(pcm[i : i + chunk_bytes]).decode())
time.sleep(0.05)
time.sleep(1.0) # let the server ingest the trailing audio before commit
conversation.commit()
deadline = time.monotonic() + EVENT_TIMEOUT_S
while time.monotonic() < deadline:
try:
kind, payload = events.get(timeout=1.0)
except queue.Empty:
continue
if kind == "partial":
partial_text = payload
if vad_audio.mode == "progressive":
yield PartialTranscription(
text=payload,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
)
elif kind == "final":
final_text = payload
if not vad_audio.mode == "progressive":
yield Transcription(
text=payload,
language_code=self.language,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
return
elif kind == "error":
logger.error("DashScope ASR error: %s", payload)
return
elif kind == "close":
break
finally:
try:
conversation.close()
except Exception: # noqa: BLE001
pass
# Fallback: progressive mode never got a final event.
if vad_audio.mode != "progressive" and final_text:
yield Transcription(
text=final_text,
language_code=self.language,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
@@ -0,0 +1,75 @@
from __future__ import annotations
import logging
import os
from typing import Any, Iterator
from faster_whisper import WhisperModel
from rich.console import Console
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
console = Console()
logger = logging.getLogger(__name__)
class FasterWhisperSTTHandler(BaseSTTHandler):
"""
Handles the Speech To Text generation using a Whisper model.
"""
def setup(
self,
model_name: str = "tiny.en",
device: str = "auto",
compute_type: str = "auto",
gen_kwargs: dict[str, Any] = {},
) -> None:
self.gen_kwargs = self.adapt_gen_kwargs(gen_kwargs)
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
self.model = WhisperModel(model_name, device=device, compute_type=compute_type)
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
logger.debug("infering faster whisper...")
segments, info = self.model.transcribe(vad_audio.audio, **self.gen_kwargs)
output_text = []
for segment in segments:
logger.debug("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text))
output_text.append(segment.text)
pred_text = " ".join(output_text).strip()
logger.debug("finished whisper inference")
if pred_text:
console.print(f"[yellow]USER: {pred_text}")
if vad_audio.mode == "progressive":
yield PartialTranscription(
text=pred_text,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
)
else:
yield Transcription(
text=pred_text,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
else:
logger.debug("no text detected. skipping...")
def cleanup(self) -> None:
print("Stopping FasterWhisperSTTHandler")
del self.model
def adapt_gen_kwargs(self, gen_kwargs: dict[str, Any]) -> dict[str, Any]:
gen_kwargs["without_timestamps"] = not gen_kwargs.pop("return_timestamps", True)
return gen_kwargs
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from typing import Any, Iterator, Optional
import numpy as np
import torch
from lightning_whisper_mlx import LightningWhisperMLX
from rich.console import Console
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
logger = logging.getLogger(__name__)
console = Console()
SUPPORTED_LANGUAGES = [
"en",
"fr",
"es",
"zh",
"ja",
"ko",
"hi",
"de",
"pt",
"pl",
"it",
"nl",
]
class LightningWhisperSTTHandler(BaseSTTHandler):
"""
Handles the Speech To Text generation using a Whisper model.
"""
def setup(
self,
model_name: str = "distil-large-v3",
device: str = "mps",
torch_dtype: str = "float16",
compile_mode: Optional[str] = None,
language: Optional[str] = None,
gen_kwargs: dict[str, Any] = {},
) -> None:
if len(model_name.split("/")) > 1:
model_name = model_name.split("/")[-1]
self.device = device
self.model = LightningWhisperMLX(model=model_name, batch_size=6, quant=None)
self.start_language = language
self.last_language = language
self.warmup()
def warmup(self) -> None:
logger.info(f"Warming up {self.__class__.__name__}")
# 2 warmup steps for no compile or compile mode with CUDA graphs capture
n_steps = 1
dummy_input = np.array([0] * 512)
for _ in range(n_steps):
_ = self.model.transcribe(dummy_input)["text"].strip()
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
logger.debug("infering whisper...")
audio = vad_audio.audio
if self.start_language != "auto":
transcription_dict = self.model.transcribe(audio, language=self.start_language)
else:
transcription_dict = self.model.transcribe(audio)
language_code = transcription_dict["language"]
if language_code not in SUPPORTED_LANGUAGES:
logger.warning(f"Whisper detected unsupported language: {language_code}")
if self.last_language in SUPPORTED_LANGUAGES: # reprocess with the last language
transcription_dict = self.model.transcribe(audio, language=self.last_language)
else:
transcription_dict = {"text": "", "language": "en"}
else:
self.last_language = language_code
pred_text = transcription_dict["text"].strip()
language_code = transcription_dict["language"]
torch.mps.empty_cache()
logger.debug("finished whisper inference")
console.print(f"[yellow]USER: {pred_text}")
logger.debug(f"Language Code Whisper: {language_code}")
if self.start_language == "auto":
language_code += "-auto"
yield Transcription(
text=pred_text,
language_code=language_code,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
@@ -0,0 +1,155 @@
from __future__ import annotations
import logging
from typing import Any, Iterator, Optional
import numpy as np
from rich.console import Console
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
logger = logging.getLogger(__name__)
console = Console()
SUPPORTED_LANGUAGES = [
"en",
"fr",
"es",
"zh",
"ja",
"ko",
"hi",
"de",
"pt",
"pl",
"it",
"nl",
]
class MLXAudioWhisperSTTHandler(BaseSTTHandler):
"""
Handles the Speech To Text generation using MLX Audio's Whisper implementation.
Optimized for Apple Silicon using the MLX framework.
"""
def setup(
self,
model_name: str = "mlx-community/whisper-large-v3-turbo",
language: Optional[str] = None,
gen_kwargs: dict[str, Any] = {},
) -> None:
from mlx_audio.stt.generate import load_model
from transformers import WhisperProcessor
self.model_name = model_name
self.start_language = language
self.last_language = language
self.gen_kwargs = gen_kwargs
# Load the model directly
logger.info(f"Loading model {model_name}...")
self.model = load_model(model_name)
# Check if processor was loaded, if not, load it manually from original model
if self.model._processor is None:
logger.info("Processor not found in MLX model, loading from original Whisper model...")
# Map MLX model names to their original Whisper counterparts
processor_model_map = {
"mlx-community/whisper-large-v3-turbo": "openai/whisper-large-v3",
"mlx-community/whisper-large-v3": "openai/whisper-large-v3",
"mlx-community/whisper-medium": "openai/whisper-medium",
"mlx-community/whisper-small": "openai/whisper-small",
"mlx-community/whisper-base": "openai/whisper-base",
"mlx-community/whisper-tiny": "openai/whisper-tiny",
}
# Get the appropriate processor model name
processor_model = processor_model_map.get(model_name, "openai/whisper-large-v3")
logger.info(f"Loading processor from {processor_model}...")
try:
self.model._processor = WhisperProcessor.from_pretrained(processor_model)
logger.info("Processor loaded successfully")
except Exception as e:
logger.error(f"Failed to load processor: {e}")
raise
logger.info(f"Model {model_name} loaded successfully")
self.warmup()
def warmup(self) -> None:
logger.info(f"Warming up {self.__class__.__name__}")
# Warmup with a dummy input
dummy_audio = np.zeros(16000, dtype=np.float32)
try:
# Pre-warm the model by running a transcription
_ = self.model.generate(dummy_audio, verbose=False)
logger.info("Model warmed up and ready")
except Exception as e:
logger.warning(f"Warmup failed: {e}")
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
logger.debug("inferring mlx-audio whisper...")
assert isinstance(vad_audio.audio, np.ndarray), "Audio must be a numpy array"
audio_input = vad_audio.audio.astype(np.float32)
# Prepare generation kwargs - only pass valid parameters
gen_kwargs = {}
# Add language if specified
if self.start_language and self.start_language != "auto":
gen_kwargs["language"] = self.start_language
try:
# Generate transcription directly using model.generate
result = self.model.generate(audio_input, verbose=False, **gen_kwargs)
# Extract text from result
pred_text = result.text.strip() if hasattr(result, "text") else str(result).strip()
# Try to detect language from result if available
if hasattr(result, "language"):
language_code = result.language
elif self.start_language and self.start_language != "auto":
language_code = self.start_language
else:
# Default to last known language or English
language_code = self.last_language if self.last_language else "en"
# Validate language code
if language_code not in SUPPORTED_LANGUAGES:
logger.warning(f"Detected unsupported language: {language_code}")
if self.last_language in SUPPORTED_LANGUAGES:
language_code = self.last_language
else:
language_code = "en"
else:
self.last_language = language_code
except Exception as e:
logger.error(f"MLX Audio Whisper inference failed: {e}")
pred_text = ""
language_code = self.last_language if self.last_language else "en"
logger.debug("finished mlx-audio whisper inference")
console.print(f"[yellow]USER: {pred_text}")
logger.debug(f"Language Code: {language_code}")
if self.start_language == "auto":
language_code += "-auto"
yield Transcription(
text=pred_text,
language_code=language_code,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
@@ -0,0 +1,79 @@
from __future__ import annotations
import logging
from typing import Any, Iterator
import numpy as np
import torch
from rich.console import Console
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
console = Console()
class ParaformerSTTHandler(BaseSTTHandler):
"""
Handles the Speech To Text generation using a Paraformer model.
The default for this model is set to Chinese.
This model was contributed by @wuhongsheng.
"""
def setup(
self,
model_name: str = "paraformer-zh",
device: str = "cuda",
gen_kwargs: dict[str, Any] = {},
) -> None:
print(model_name)
if len(model_name.split("/")) > 1:
model_name = model_name.split("/")[-1]
self.device = device
try:
from funasr import AutoModel
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
"Paraformer STT requires the optional 'paraformer' extra. "
"Install it with `pip install speech-to-speech[paraformer]`."
) from exc
self.model = AutoModel(model=model_name, device=device)
self.warmup()
def warmup(self) -> None:
logger.info(f"Warming up {self.__class__.__name__}")
# 2 warmup steps for no compile or compile mode with CUDA graphs capture
n_steps = 1
dummy_input = np.array([0] * 512, dtype=np.float32)
for _ in range(n_steps):
_ = self.model.generate(dummy_input)[0]["text"].strip().replace(" ", "")
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
logger.debug("infering paraformer...")
pred_text = self.model.generate(vad_audio.audio)[0]["text"].strip().replace(" ", "")
torch.mps.empty_cache()
logger.debug("finished paraformer inference")
console.print(f"[yellow]USER: {pred_text}")
if vad_audio.mode == "progressive":
yield PartialTranscription(
text=pred_text,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
)
else:
yield Transcription(
text=pred_text,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
@@ -0,0 +1,648 @@
"""
Parakeet TDT Speech-to-Text Handler
Supports NVIDIA Parakeet TDT model for high-quality multilingual ASR.
- On Apple Silicon (MPS): Uses mlx-audio with mlx-community/parakeet-tdt-0.6b-v3
- On CUDA/CPU: Uses nano-parakeet (pure PyTorch) with nvidia/parakeet-tdt-0.6b-v3
Model supports 25 European languages with automatic language detection.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from sys import platform
from threading import Lock
from time import perf_counter
from typing import Any, Iterator, Optional
import numpy as np
from rich.console import Console
from rich.text import Text
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
from speech_to_speech.STT.smart_progressive_streaming import PartialTranscription as ProgressiveStreamPartial
from speech_to_speech.utils.mlx_lock import MLXLockContext
try:
from lingua import Language, LanguageDetectorBuilder
LINGUA_AVAILABLE = True
except ImportError:
LINGUA_AVAILABLE = False
logger = logging.getLogger(__name__)
console = Console()
# Parakeet TDT v3 supports 25 European languages
SUPPORTED_LANGUAGES = [
"en",
"de",
"fr",
"es",
"it",
"pt",
"nl",
"pl",
"ru",
"uk",
"cs",
"sk",
"hu",
"ro",
"bg",
"hr",
"sl",
"sr",
"da",
"no",
"sv",
"fi",
"et",
"lv",
"lt",
]
# Lingua uses "nb" (Bokmål) for Norwegian instead of "no"
_LINGUA_CODE_MAP = {"no": "nb"}
if LINGUA_AVAILABLE:
_lingua_iso_to_code = {
lang.iso_code_639_1.name.lower(): lang for lang in Language.all() if lang.iso_code_639_1 is not None
}
_lingua_languages = [
_lingua_iso_to_code[_LINGUA_CODE_MAP.get(code, code)]
for code in SUPPORTED_LANGUAGES
if _LINGUA_CODE_MAP.get(code, code) in _lingua_iso_to_code
]
def _build_lingua_detector():
# Preloading can take multiple seconds on some hardware, including the
# deployed server. Pay that cost at startup instead of on the first user
# request, where it would look like slow STT.
return LanguageDetectorBuilder.from_languages(*_lingua_languages).with_preloaded_language_models().build()
_lingua_detector = _build_lingua_detector()
class ParakeetTDTSTTHandler(BaseSTTHandler):
"""
Handles Speech-to-Text using NVIDIA Parakeet TDT model.
On Apple Silicon (MPS): Uses mlx-audio with the MLX-converted model.
On CUDA/CPU: Uses nano-parakeet (pure PyTorch) for NeMo-free inference.
Parakeet TDT 0.6B v3 is a 600M parameter multilingual ASR model
supporting 25 European languages with automatic language detection.
"""
def setup(
self,
model_name: Optional[str] = None,
device: str = "auto",
compute_type: str = "float16",
language: Optional[str] = None,
gen_kwargs: dict[str, Any] = {},
enable_live_transcription: bool = False,
live_transcription_update_interval: float = 0.5,
) -> None:
"""
Initialize the Parakeet TDT model.
Args:
model_name: Model identifier. Defaults are:
- MPS: "mlx-community/parakeet-tdt-0.6b-v3"
- CUDA/CPU: "nvidia/parakeet-tdt-0.6b-v3"
device: Device to use ("auto", "cuda", "mps", "cpu")
compute_type: Compute precision ("float16", "float32")
language: Target language code (optional, model auto-detects)
gen_kwargs: Additional generation kwargs
"""
self.gen_kwargs = gen_kwargs
self.start_language = language
self.last_language = language if language else "en"
self.enable_live_transcription = enable_live_transcription
self.live_transcription_update_interval = live_transcription_update_interval
self.compute_lock = Lock()
self.sample_rate = 16000
# Determine device
if device == "auto":
if platform == "darwin":
self.device = "mps"
else:
import torch
self.device = "cuda" if torch.cuda.is_available() else "cpu"
else:
self.device = device
# Set default model based on device
if model_name is None:
if self.device == "mps":
model_name = "mlx-community/parakeet-tdt-0.6b-v3"
else:
model_name = "nvidia/parakeet-tdt-0.6b-v3"
self.model_name = model_name
self.compute_type = compute_type
logger.info(f"Loading Parakeet TDT model: {model_name} on {self.device}")
if self.device == "mps":
self._setup_mlx(model_name)
else:
self._setup_nano_parakeet(model_name)
# Setup streaming handler if live transcription is enabled
self.streaming_handler = None
if self.enable_live_transcription:
from speech_to_speech.STT.smart_progressive_streaming import (
SmartProgressiveStreamingHandler,
)
self.streaming_handler = SmartProgressiveStreamingHandler(
self.model,
emission_interval=self.live_transcription_update_interval,
max_window_size=15.0,
sentence_buffer=2.0,
)
self.processing_final = False # Track if we're processing final audio
logger.info(f"Live transcription enabled for Parakeet TDT ({self.backend})")
self._live_transcription_active = False
self._live_turn_key: tuple[str | None, int | None] | None = None
self.warmup()
def _setup_mlx(self, model_name: str) -> None:
"""Setup for Apple Silicon using mlx-audio."""
try:
from mlx_audio.stt.generate import load_model
self.backend = "mlx"
self.model = load_model(model_name)
logger.info("MLX Audio Parakeet model loaded successfully")
except ImportError as e:
raise ImportError(
"mlx-audio is required for Parakeet TDT on Apple Silicon. Install with: pip install mlx-audio"
) from e
def _setup_nano_parakeet(self, model_name: str) -> None:
"""Setup for CUDA/CPU using nano-parakeet."""
try:
import torch
from nano_parakeet import from_pretrained
self.backend = "nano_parakeet"
if self.device == "cuda" and not torch.cuda.is_available():
logger.warning("CUDA requested but not available. Falling back to CPU for nano-parakeet.")
self.device = "cpu"
self.model = from_pretrained(model_name=model_name, device=self.device)
logger.info(f"nano-parakeet model loaded successfully on {self.device}")
except ImportError as e:
raise ImportError(
"nano-parakeet is required for Parakeet TDT on CUDA/CPU. Install with: pip install nano-parakeet"
) from e
def warmup(self) -> None:
"""Warm up the model with a dummy input."""
logger.info(f"Warming up {self.__class__.__name__}")
# Create 1 second of silence at 16kHz
dummy_audio = np.zeros(16000, dtype=np.float32)
try:
if self.backend == "mlx":
import mlx.core as mx
# Convert to mx.array and call decode_chunk directly
audio_mx = mx.array(dummy_audio, dtype=mx.float32)
_ = self.model.decode_chunk(audio_mx, verbose=False)
elif self.backend == "nano_parakeet":
_ = self.model.transcribe(dummy_audio)
else:
_ = self.model.transcribe([dummy_audio], batch_size=1, verbose=False)
logger.info("Model warmed up and ready")
except Exception as e:
logger.warning(f"Warmup failed: {e}")
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
"""
Process audio and generate transcription.
Yields:
:class:`PartialTranscription` or :class:`Transcription`
"""
process_start_s = perf_counter()
is_progressive = vad_audio.mode == "progressive"
audio_input = vad_audio.audio
# Ensure audio is float32 numpy array
if not isinstance(audio_input, np.ndarray):
audio_input = np.array(audio_input, dtype=np.float32)
else:
audio_input = audio_input.astype(np.float32)
audio_duration_s = len(audio_input) / getattr(self, "sample_rate", 16000)
item_age_s = self._item_age_s(vad_audio)
self._prepare_live_transcription_turn(vad_audio.turn_id, vad_audio.turn_revision)
# Handle progressive updates: yield tagged partial for TranscriptionNotifier
if self.enable_live_transcription and is_progressive:
# Ignore progressive updates if we're already processing final audio
if self.processing_final:
logger.debug("Skipping stale progressive update (final audio already received)")
return
# Try to acquire lock with short timeout - skip if busy
lock_scope_start_s = perf_counter()
with self._compute_lock_context(handler_name="ParakeetSTT-Progressive", timeout=0.01) as acquired:
if acquired:
try:
inference_start_s = perf_counter()
progressive_text = self._show_progressive_transcription(audio_input)
inference_s = perf_counter() - inference_start_s
if inference_s >= 0.25:
logger.info(
"Parakeet progressive STT timing turn=%s rev=%s audio=%.3fs age=%.3fs "
"lock_scope=%.3fs inference=%.3fs chars=%d",
vad_audio.turn_id,
vad_audio.turn_revision,
audio_duration_s,
item_age_s,
perf_counter() - lock_scope_start_s,
inference_s,
len(progressive_text),
)
if progressive_text:
yield PartialTranscription(
text=progressive_text,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
)
return
except Exception as e:
logger.debug(f"Progressive transcription failed: {e}")
else:
logger.debug("Skipping progressive update (compute busy)")
return
# Handle final transcription (send to LLM)
logger.info(
"Parakeet final STT start turn=%s rev=%s audio=%.3fs age=%.3fs",
vad_audio.turn_id,
vad_audio.turn_revision,
audio_duration_s,
item_age_s,
)
inference_s = 0.0
lock_scope_s = 0.0
try:
if self.enable_live_transcription:
# Mark that we're processing final audio (ignore stale progressive updates)
self.processing_final = True
# Acquire lock with longer timeout for final transcription
lock_scope_start_s = perf_counter()
with self._compute_lock_context(handler_name="ParakeetSTT-Final", timeout=5.0) as acquired:
lock_scope_s = perf_counter() - lock_scope_start_s
if not acquired:
logger.error("Failed to acquire compute lock for final transcription")
pred_text = ""
language_code = self.last_language
else:
inference_start_s = perf_counter()
if self.backend == "mlx":
pred_text, language_code = self._process_mlx_final(audio_input)
else:
pred_text, language_code = self._process_nano_parakeet(audio_input)
inference_s = perf_counter() - inference_start_s
lock_scope_s = perf_counter() - lock_scope_start_s
# Validate and update language
if language_code and language_code in SUPPORTED_LANGUAGES:
self.last_language = language_code
else:
language_code = self.last_language
except Exception as e:
logger.error(f"Parakeet TDT inference failed: {e}")
pred_text = ""
language_code = self.last_language
total_s = perf_counter() - process_start_s
logger.info(
"Parakeet final STT done turn=%s rev=%s total=%.3fs lock_scope=%.3fs inference=%.3fs chars=%d",
vad_audio.turn_id,
vad_audio.turn_revision,
total_s,
lock_scope_s,
inference_s,
len(pred_text),
)
logger.debug("Finished Parakeet TDT inference")
self._clear_live_transcription_line()
if pred_text.strip():
console.print(f"[yellow]USER: {pred_text.strip()}")
if language_code:
console.print(f"[dim]Language: {language_code}[/dim]")
# Reset per-utterance live transcription state only after final STT
# completes. The streaming handler carries fixed sentence timing within
# an utterance, and stale timing must not leak into the next turn.
if self.enable_live_transcription:
self.processing_final = False
self._reset_live_transcription_state(clear_turn=True)
yield Transcription(
text=pred_text,
language_code=language_code,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)
@property
def timing_log_level(self) -> int:
return logging.INFO
def should_log_timing(self, output: STTOut) -> bool:
return isinstance(output, Transcription) and self.last_time > self.min_time_to_debug
def _detect_language_from_text(self, text: str) -> Optional[str]:
"""
Detect language from transcribed text using lingua-py.
Args:
text: Transcribed text string
Returns:
Detected language code or None if detection fails
"""
if not LINGUA_AVAILABLE:
logger.warning("lingua-py not available, cannot detect language from text")
return None
# Skip very short utterances where language ID is still too noisy.
if not text or len(text.strip()) < 20:
return None
detected = _lingua_detector.detect_language_of(text)
if detected is None:
return None
code = detected.iso_code_639_1.name.lower()
# Map back lingua-specific codes to our supported codes
return {v: k for k, v in _LINGUA_CODE_MAP.items()}.get(code, code)
@contextmanager
def _compute_lock_context(self, handler_name: str, timeout: float) -> Iterator[bool]:
if self.backend == "mlx":
with MLXLockContext(handler_name=handler_name, timeout=timeout) as acquired:
yield acquired
return
lock_start_s = perf_counter()
acquired = self.compute_lock.acquire(timeout=timeout)
wait_s = perf_counter() - lock_start_s
hold_start_s: float | None = None
if acquired:
if wait_s >= 0.25:
logger.info("%s: compute lock acquired after %.2fs", handler_name, wait_s)
else:
logger.debug("%s: compute lock acquired after %.3fs", handler_name, wait_s)
hold_start_s = perf_counter()
else:
logger.warning("%s: Failed to acquire compute lock after %.3fs (timeout=%s)", handler_name, wait_s, timeout)
try:
yield acquired
finally:
if acquired:
assert hold_start_s is not None
self.compute_lock.release()
hold_s = perf_counter() - hold_start_s
if hold_s >= 0.25:
logger.info("%s: compute lock released after holding %.2fs", handler_name, hold_s)
else:
logger.debug("%s: compute lock released after holding %.3fs", handler_name, hold_s)
def _show_progressive_transcription(self, audio_input: np.ndarray) -> str:
"""Run progressive transcription, print to console, and return the text."""
result = self.streaming_handler.transcribe_incremental(audio_input)
rich_text = Text()
if result.fixed_text:
rich_text.append("Live: ", style="dim")
rich_text.append(result.fixed_text, style="yellow")
if result.active_text:
rich_text.append(" ", style="dim")
if result.active_text:
if not result.fixed_text:
rich_text.append("Live: ", style="dim")
rich_text.append(result.active_text, style="cyan dim")
progressive_text = self._build_progressive_text(result)
if progressive_text:
self._print_live_transcription(rich_text, progressive_text)
return progressive_text
def _print_live_transcription(self, rich_text: Text, progressive_text: str) -> None:
is_terminal = bool(getattr(console, "is_terminal", False))
if is_terminal:
self._write_live_control("\r\x1b[2K")
if rich_text:
console.print(self._truncate_live_transcription(rich_text), end="")
else:
fallback = Text("Live: ", style="dim")
fallback.append(progressive_text, style="cyan dim")
console.print(self._truncate_live_transcription(fallback), end="")
self._write_live_control("\r")
self._live_transcription_active = True
return
if rich_text:
console.print(rich_text)
else:
console.print(f"[dim]Live: [/dim]{progressive_text}")
def _clear_live_transcription_line(self) -> None:
if not getattr(self, "_live_transcription_active", False):
return
self._write_live_control("\r\x1b[2K")
self._live_transcription_active = False
def _write_live_control(self, sequence: str) -> None:
file = getattr(console, "file", None)
if file is None:
return
file.write(sequence)
file.flush()
def _truncate_live_transcription(self, text: Text) -> Text:
text = text.copy()
width = getattr(console, "width", 80)
try:
max_width = max(1, int(width) - 1)
except (TypeError, ValueError):
max_width = 79
text.truncate(max_width, overflow="ellipsis")
return text
def _prepare_live_transcription_turn(self, turn_id: str | None, turn_revision: int | None) -> None:
if not self.enable_live_transcription:
return
turn_key = (turn_id, turn_revision)
if getattr(self, "_live_turn_key", None) == turn_key:
return
self._reset_live_transcription_state(clear_turn=False)
self._live_turn_key = turn_key
def _reset_live_transcription_state(self, clear_turn: bool) -> None:
self._clear_live_transcription_line()
streaming_handler = getattr(self, "streaming_handler", None)
if streaming_handler is not None:
streaming_handler.reset()
if clear_turn:
self._live_turn_key = None
def _build_progressive_text(self, result: ProgressiveStreamPartial) -> str:
parts = []
if result.fixed_text:
parts.append(result.fixed_text.strip())
if result.active_text:
parts.append(result.active_text.strip())
return " ".join(part for part in parts if part).strip()
def _process_mlx_final(self, audio_input: np.ndarray) -> tuple[str, str]:
"""Process final audio using MLX backend with streaming handler."""
# If we have fixed sentences from progressive updates, only transcribe the new part
if (
self.streaming_handler is not None
and hasattr(self.streaming_handler, "fixed_sentences")
and self.streaming_handler.fixed_sentences
):
self._clear_live_transcription_line()
# Get fixed text from previous progressive updates
fixed_text = " ".join(self.streaming_handler.fixed_sentences).strip()
# Calculate where fixed part ends in audio
fixed_end_time = self.streaming_handler.fixed_end_time
sample_rate = 16000
fixed_end_sample = int(fixed_end_time * sample_rate)
if fixed_end_sample > len(audio_input):
logger.warning(
"Ignoring stale progressive fixed text: fixed_end_sample=%d exceeds final audio samples=%d",
fixed_end_sample,
len(audio_input),
)
pred_text, language_code = self._process_mlx(audio_input)
return pred_text, language_code
# Only transcribe the part after fixed sentences
if fixed_end_sample < len(audio_input):
remaining_audio = audio_input[fixed_end_sample:]
import mlx.core as mx
audio_mx = mx.array(remaining_audio, dtype=mx.float32)
result = self.model.decode_chunk(audio_mx, verbose=False)
if hasattr(result, "text"):
new_text = result.text.strip()
else:
new_text = str(result).strip()
# Combine fixed + new
pred_text = f"{fixed_text} {new_text}".strip() if new_text else fixed_text
else:
# All audio already transcribed in progressive updates
pred_text = fixed_text
# Reset streaming handler for next utterance
self.streaming_handler.reset()
else:
# No progressive updates, transcribe everything
pred_text, language_code = self._process_mlx(audio_input)
return pred_text, language_code
# Determine language
if self.start_language and self.start_language != "auto":
language_code = self.start_language
else:
detected_lang = self._detect_language_from_text(pred_text)
if detected_lang:
language_code = detected_lang
else:
language_code = self.last_language
return pred_text, language_code
def _process_mlx(self, audio_input: np.ndarray) -> tuple[str, str]:
"""Process audio using MLX backend."""
import mlx.core as mx
# Convert numpy array to mx.array
audio_mx = mx.array(audio_input, dtype=mx.float32)
# Call decode_chunk directly with the audio array
result = self.model.decode_chunk(audio_mx, verbose=False)
# Extract text from result
if hasattr(result, "text"):
pred_text = result.text.strip()
else:
pred_text = str(result).strip()
# Determine language:
# 1. Use fixed language if specified by user
# 2. Try to detect from transcribed text using langdetect
# 3. Fall back to last known language
if self.start_language and self.start_language != "auto":
language_code = self.start_language
else:
# Detect language from transcribed text
detected_lang = self._detect_language_from_text(pred_text)
if detected_lang:
language_code = detected_lang
else:
language_code = self.last_language
return pred_text, language_code
def _process_nano_parakeet(self, audio_input: np.ndarray) -> tuple[str, str]:
"""Process audio using nano-parakeet backend."""
pred_text = self.model.transcribe(audio_input).strip()
if self.start_language and self.start_language != "auto":
language_code = self.start_language
else:
detected_lang = self._detect_language_from_text(pred_text)
if detected_lang:
language_code = detected_lang
else:
language_code = self.last_language
return pred_text, language_code
def cleanup(self) -> None:
"""Clean up model resources."""
logger.info(f"Cleaning up {self.__class__.__name__}")
if hasattr(self, "model"):
del self.model
def on_session_end(self) -> None:
super().on_session_end()
self.last_language = self.start_language if self.start_language else "en"
if self.enable_live_transcription:
self.processing_final = False
self._reset_live_transcription_state(clear_turn=True)
logger.debug("Parakeet TDT session state reset")
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Smart Progressive Streaming Handler
Provides frequent partial transcriptions (every 500ms) with:
- Growing window up to 15s for accuracy
- Sentence-boundary-aware window sliding for audio > 15s
- Fixed sentences + active transcription
"""
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Generator
import numpy as np
@dataclass
class PartialTranscription:
"""Result from progressive streaming."""
fixed_text: str # Sentences that won't change
active_text: str # Current partial transcription
timestamp: float # Current position in audio
is_final: bool # True if this is the last update
class SmartProgressiveStreamingHandler:
"""
Smart progressive streaming with sentence-aware window management.
Strategy:
1. Emit partial transcriptions every 500ms
2. Use growing window (up to 15s) for better accuracy
3. When audio > 15s, slide window using sentence boundaries:
- Keep completed sentences as "fixed"
- Only re-transcribe the "active" portion
"""
def __init__(
self,
model: Any,
emission_interval: float = 0.5,
max_window_size: float = 15.0,
sentence_buffer: float = 2.0,
) -> None:
"""
Args:
model: Parakeet model with sentence alignment
emission_interval: Emit partial transcription every N seconds (default 500ms)
max_window_size: Maximum window size before sliding (default 15s)
sentence_buffer: Keep last N seconds of sentences in active window (default 2s)
"""
self.model = model
self.emission_interval = emission_interval
self.max_window_size = max_window_size
self.sentence_buffer = sentence_buffer
self.sample_rate = 16000
# State for incremental streaming
self.reset()
def reset(self) -> None:
"""Reset state for new streaming session."""
self.fixed_sentences: list[str] = []
self.fixed_end_time: float = 0.0
self.last_transcribed_length: int = 0
def _decode_window(self, audio_window: np.ndarray) -> Any:
"""
Decode an audio window and return an object with:
- text: full transcript for the window
- sentences: list of objects with .text and .end (seconds)
"""
if hasattr(self.model, "decode_chunk"):
import mlx.core as mx
audio_mx = mx.array(audio_window, dtype=mx.float32)
return self.model.decode_chunk(audio_mx, verbose=False)
# nano-parakeet: use timestamps from transcribe
result = self.model.transcribe(audio_window, timestamps=True)
segments = result.timestamp.get("segment", []) if hasattr(result, "timestamp") else []
sentences = [SimpleNamespace(text=seg.get("segment", "").strip(), end=seg.get("end", 0.0)) for seg in segments]
return SimpleNamespace(text=getattr(result, "text", ""), sentences=sentences)
def transcribe_incremental(self, audio: np.ndarray) -> PartialTranscription:
"""
Transcribe audio incrementally (for live streaming).
Call this repeatedly with growing audio buffer.
Returns a single PartialTranscription for current state.
"""
# Skip if not enough new audio
current_length = len(audio)
if current_length < self.sample_rate * 0.5: # Need at least 500ms
return PartialTranscription(
fixed_text=" ".join(self.fixed_sentences),
active_text="",
timestamp=current_length / self.sample_rate,
is_final=False,
)
# Skip if no new audio since last transcription
if current_length == self.last_transcribed_length:
return PartialTranscription(
fixed_text=" ".join(self.fixed_sentences),
active_text="",
timestamp=current_length / self.sample_rate,
is_final=False,
)
self.last_transcribed_length = current_length
# Extract window for transcription (from last fixed sentence to end)
window_start_samples = int(self.fixed_end_time * self.sample_rate)
audio_window = audio[window_start_samples:]
# Transcribe current window
result = self._decode_window(audio_window)
# Check if window exceeds max_window_size
window_duration = len(audio_window) / self.sample_rate
if window_duration >= self.max_window_size and len(result.sentences) > 1:
# Window is too large - fix some sentences
cutoff_time = window_duration - self.sentence_buffer
# Find sentences to fix
new_fixed_sentences = []
new_fixed_end_time = self.fixed_end_time
for sentence in result.sentences:
sentence_abs_time = self.fixed_end_time + sentence.end
if sentence.end < cutoff_time:
# Fix this sentence
new_fixed_sentences.append(sentence.text.strip())
new_fixed_end_time = sentence_abs_time
else:
break
if new_fixed_sentences:
self.fixed_sentences.extend(new_fixed_sentences)
self.fixed_end_time = new_fixed_end_time
# Re-transcribe from new fixed point
window_start_samples = int(self.fixed_end_time * self.sample_rate)
audio_window = audio[window_start_samples:]
result = self._decode_window(audio_window)
# Build output
fixed_text = " ".join(self.fixed_sentences)
active_text = result.text.strip()
timestamp = len(audio) / self.sample_rate
return PartialTranscription(fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=False)
def transcribe_progressive(self, audio: np.ndarray) -> Generator[PartialTranscription, None, None]:
"""
Transcribe audio with smart progressive emissions.
Yields PartialTranscription with:
- fixed_text: Completed sentences (won't change)
- active_text: Current partial transcription
- timestamp: Current position
- is_final: True for last update
"""
position = 0 # Start of current window (in samples)
fixed_sentences = [] # List of completed sentence texts
fixed_end_time = 0.0 # End time of last fixed sentence
while position < len(audio):
# Determine current window end
remaining = (len(audio) - position) / self.sample_rate
if remaining <= self.emission_interval:
# Last chunk - process everything remaining
window_end = len(audio)
is_final = True
else:
# Regular chunk
window_end = min(len(audio), position + int(self.emission_interval * self.sample_rate))
is_final = False
# Extract window for transcription
# Window includes: fixed_end_time to current position
# (we re-transcribe from last fixed sentence to get better context)
window_start_samples = int(fixed_end_time * self.sample_rate)
audio_window = audio[window_start_samples:window_end]
# Transcribe current window
result = self._decode_window(audio_window)
# Check if window exceeds max_window_size
window_duration = (window_end - window_start_samples) / self.sample_rate
if window_duration >= self.max_window_size and len(result.sentences) > 1:
# Window is too large - need to fix some sentences
# Strategy: Keep sentences up to (max_window - sentence_buffer) as fixed
cutoff_time = window_duration - self.sentence_buffer
# Find sentences that are completed and before cutoff
new_fixed_sentences = []
new_fixed_end_time = fixed_end_time
for sentence in result.sentences:
sentence_abs_time = fixed_end_time + sentence.end
if sentence.end < cutoff_time:
# This sentence is complete and old enough to fix
new_fixed_sentences.append(sentence.text.strip())
new_fixed_end_time = sentence_abs_time
else:
# Stop here - keep remaining sentences as active
break
if new_fixed_sentences:
fixed_sentences.extend(new_fixed_sentences)
fixed_end_time = new_fixed_end_time
# Re-transcribe from new fixed_end_time
window_start_samples = int(fixed_end_time * self.sample_rate)
audio_window = audio[window_start_samples:window_end]
result = self._decode_window(audio_window)
# Build output
fixed_text = " ".join(fixed_sentences)
active_text = result.text.strip()
timestamp = window_end / self.sample_rate
yield PartialTranscription(
fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=is_final
)
# Move to next position
position = window_end
if is_final:
break
def demo_smart_progressive() -> None:
"""Demonstrate smart progressive streaming."""
import time
import soundfile as sf
from mlx_audio.stt.generate import load_model
print("=" * 80)
print("SMART PROGRESSIVE STREAMING DEMO")
print("=" * 80)
print("\nFeatures:")
print(" • Partial updates every 500ms")
print(" • Growing window up to 15s")
print(" • Sentence-aware window sliding for long audio")
print()
# Load model
print("Loading model...")
model = load_model("mlx-community/parakeet-tdt-0.6b-v3")
# Load audio
audio, sr = sf.read("reachy-voice-test.wav")
if len(audio.shape) > 1:
audio = audio.mean(axis=1)
if sr != 16000:
from scipy import signal
audio = signal.resample(audio, int(len(audio) * 16000 / sr))
audio = audio.astype(np.float32)
# Test cases
test_cases = [
(audio, "Short (5.8s)"),
(np.concatenate([audio] * 3), "Medium (17.5s) - exceeds 15s window"),
(np.concatenate([audio] * 5), "Long (29.1s) - multiple sentence fixings"),
]
for test_audio, label in test_cases:
duration = len(test_audio) / 16000
print(f"\n{'=' * 80}")
print(f"TEST: {label} - Duration: {duration:.1f}s")
print("=" * 80)
handler = SmartProgressiveStreamingHandler(
model,
emission_interval=0.5, # 500ms updates
max_window_size=15.0, # Max 15s window
sentence_buffer=2.0, # Keep 2s of sentences in active window
)
print("\nProgressive transcriptions (showing every 4th update for readability):")
print()
update_count = 0
start_time = time.perf_counter()
for result in handler.transcribe_progressive(test_audio):
update_count += 1
# Show every other update (every 1s) to keep output manageable
if update_count % 2 == 0 or result.is_final:
elapsed = time.perf_counter() - start_time
marker = "FINAL" if result.is_final else f"Update {update_count}"
print(f"[{result.timestamp:5.1f}s | {elapsed:.3f}s elapsed] {marker}:")
if result.fixed_text:
print(f" Fixed: {result.fixed_text[:70]}...")
print(f" Active: {result.active_text[:70]}...")
print()
total_time = time.perf_counter() - start_time
print(f"Total updates: {update_count}")
print(f"Total time: {total_time:.3f}s ({total_time / duration:.3f}s per audio second)")
print("\n" + "=" * 80)
print("KEY INSIGHTS")
print("=" * 80)
print("""
1. Frequent updates (500ms):
- User sees transcription building in real-time
- Low latency feel
2. Growing window (up to 15s):
- Better accuracy with more context
- No arbitrary chunking for short audio
3. Sentence-aware sliding (for audio > 15s):
- Completed sentences become "fixed"
- Only active portion re-transcribed
- Maintains accuracy while managing window size
4. Performance:
- Processing happens DURING user speaking
- Final result available immediately when user stops
""")
if __name__ == "__main__":
demo_smart_progressive()
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from queue import Queue
from threading import Event
from typing import Iterator, Union
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
from speech_to_speech.baseHandler import BaseHandler
from speech_to_speech.LLM.chat import make_user_message
from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent
from speech_to_speech.pipeline.handler_types import LLMIn, STTOut
from speech_to_speech.pipeline.messages import GenerateResponseRequest, PartialTranscription, Transcription
from speech_to_speech.pipeline.queue_types import TextEventItem
logger = logging.getLogger(__name__)
class TranscriptionNotifier(BaseHandler[STTOut, Union[STTOut, LLMIn]]):
"""Sits between STT and LLM.
For **realtime mode** (no ``runtime_config``): emits transcription events
on ``text_output_queue`` for protocol translation but yields nothing -- the
``RealtimeService`` builds ``GenerateResponseRequest`` directly.
For **legacy mode** (``runtime_config`` provided): appends the user
message to ``runtime_config.chat`` and yields a
``GenerateResponseRequest`` so the LLM handler receives a uniform input
type regardless of pipeline mode.
"""
def setup(
self,
text_output_queue: Queue[TextEventItem] | None = None,
runtime_config: RuntimeConfig | None = None,
should_listen: Event | None = None,
) -> None:
self.text_output_queue = text_output_queue
self.runtime_config = runtime_config
self.should_listen = should_listen
def process(self, transcription: STTOut) -> Iterator[Union[STTOut, LLMIn]]:
if isinstance(transcription, PartialTranscription):
if self.text_output_queue and transcription.text:
self.text_output_queue.put(
PartialTranscriptionEvent(
delta=str(transcription.text),
turn_id=transcription.turn_id,
turn_revision=transcription.turn_revision,
)
)
logger.debug("Partial transcription: %s", str(transcription.text)[:80])
return
if isinstance(transcription, Transcription):
text = transcription.text
language_code = transcription.language_code
turn_id = transcription.turn_id
turn_revision = transcription.turn_revision
speech_stopped_at_s = transcription.speech_stopped_at_s
else:
text = transcription
language_code = None
turn_id = None
turn_revision = None
speech_stopped_at_s = None
transcript = str(text)
# Always close the client-visible transcription item. Empty final STT
# results should not trigger the LLM, but clients may already have
# received partial deltas and still need a completed event.
if self.text_output_queue is not None:
self.text_output_queue.put(
TranscriptionCompletedEvent(
transcript=transcript,
language_code=language_code,
turn_id=turn_id,
turn_revision=turn_revision,
speech_stopped_at_s=speech_stopped_at_s,
)
)
if not transcript:
logger.debug("Transcription completed with empty transcript")
if self.should_listen is not None:
self.should_listen.set()
logger.debug("Empty transcription completed; listening re-enabled")
return
if language_code:
logger.info("Transcription completed (language=%s): %s", language_code, transcript)
else:
logger.info("Transcription completed: %s", transcript)
if self.runtime_config is not None:
self.runtime_config.chat.add_item(make_user_message(transcript))
yield GenerateResponseRequest(
runtime_config=self.runtime_config,
language_code=language_code,
turn_id=turn_id,
turn_revision=turn_revision,
speech_stopped_at_s=speech_stopped_at_s,
)
@@ -0,0 +1,147 @@
from __future__ import annotations
import logging
from copy import copy
from typing import Any, Iterator, Optional
import numpy as np
import torch
from rich.console import Console
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
from speech_to_speech.pipeline.handler_types import STTIn, STTOut
from speech_to_speech.pipeline.messages import Transcription
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
logger = logging.getLogger(__name__)
console = Console()
SUPPORTED_LANGUAGES = [
"en",
"fr",
"es",
"zh",
"ja",
"ko",
"hi",
"de",
"pt",
"pl",
"it",
"nl",
]
class WhisperSTTHandler(BaseSTTHandler):
"""
Handles the Speech To Text generation using a Whisper model.
"""
def setup(
self,
model_name: str = "distil-whisper/distil-large-v3",
device: str = "cuda",
torch_dtype: str = "float16",
compile_mode: Optional[str] = None,
language: Optional[str] = None,
gen_kwargs: dict[str, Any] = {},
) -> None:
self.device = device
self.torch_dtype = getattr(torch, torch_dtype)
self.compile_mode = compile_mode
self.gen_kwargs = gen_kwargs
self.start_language = language
self.last_language = language if language != "auto" else None
if self.last_language is not None:
self.gen_kwargs["language"] = self.last_language
self.processor = AutoProcessor.from_pretrained(model_name)
self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_name,
torch_dtype=self.torch_dtype,
).to(device)
# compile
if self.compile_mode:
self.model.generation_config.cache_implementation = "static"
self.model.forward = torch.compile(self.model.forward, mode=self.compile_mode, fullgraph=True)
self.warmup()
def prepare_model_inputs(self, audio: np.ndarray) -> torch.Tensor:
input_features = self.processor(audio, sampling_rate=16000, return_tensors="pt").input_features
input_features = input_features.to(self.device, dtype=self.torch_dtype)
return input_features
def warmup(self) -> None:
logger.info(f"Warming up {self.__class__.__name__}")
# 2 warmup steps for no compile or compile mode with CUDA graphs capture
n_steps = 1 if self.compile_mode == "default" else 2
dummy_input = torch.randn(
(1, self.model.config.num_mel_bins, 3000),
dtype=self.torch_dtype,
device=self.device,
)
if self.compile_mode not in (None, "default"):
# generating more tokens than previously will trigger CUDA graphs capture
# one should warmup with a number of generated tokens above max tokens targeted for subsequent generation
# hence, having min_new_tokens < max_new_tokens in the future doesn't make sense
warmup_gen_kwargs = {
"min_new_tokens": self.gen_kwargs["max_new_tokens"], # Yes, assign max_new_tokens to min_new_tokens
"max_new_tokens": self.gen_kwargs["max_new_tokens"],
**self.gen_kwargs,
}
else:
warmup_gen_kwargs = self.gen_kwargs
if self.device == "cuda":
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
torch.cuda.synchronize()
start_event.record()
for _ in range(n_steps):
_ = self.model.generate(dummy_input, **warmup_gen_kwargs)
if self.device == "cuda":
end_event.record()
torch.cuda.synchronize()
logger.info(
f"{self.__class__.__name__}: warmed up! time: {start_event.elapsed_time(end_event) * 1e-3:.3f} s"
)
def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
logger.debug("infering whisper...")
input_features = self.prepare_model_inputs(vad_audio.audio)
pred_ids = self.model.generate(input_features, **self.gen_kwargs)
language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>"
if language_code not in SUPPORTED_LANGUAGES: # reprocess with the last language
logger.warning("Whisper detected unsupported language: %s", language_code)
gen_kwargs = copy(self.gen_kwargs)
gen_kwargs["language"] = self.last_language
language_code = self.last_language
pred_ids = self.model.generate(input_features, **gen_kwargs)
else:
self.last_language = language_code
pred_text = self.processor.batch_decode(pred_ids, skip_special_tokens=True, decode_with_timestamps=False)[0]
language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>"
logger.debug("finished whisper inference")
console.print(f"[yellow]USER: {pred_text}")
logger.debug(f"Language Code Whisper: {language_code}")
if self.start_language == "auto":
language_code += "-auto"
yield Transcription(
text=pred_text,
language_code=language_code,
turn_id=vad_audio.turn_id,
turn_revision=vad_audio.turn_revision,
speech_stopped_at_s=vad_audio.created_at_s,
)