feat: support manual audio streaming from any source

This commit is contained in:
Quang Tran
2026-04-21 11:41:05 +07:00
parent d9459ebf2d
commit f4f1b1d8be
3 changed files with 349 additions and 0 deletions
+58
View File
@@ -23,6 +23,7 @@ input from microphone and pre-recorded audio files.
- [Speaker Diarization](#speaker-diarization) - [Speaker Diarization](#speaker-diarization)
- [Batch Inference](#batch-inference) - [Batch Inference](#batch-inference)
- [Raw PCM Input](#raw-pcm-input) - [Raw PCM Input](#raw-pcm-input)
- [Streaming Client (Manual Audio Chunking)](#streaming-client-manual-audio-chunking)
- [Browser Extensions](#browser-extensions) - [Browser Extensions](#browser-extensions)
- [Whisper Live Server in Docker](#whisper-live-server-in-docker) - [Whisper Live Server in Docker](#whisper-live-server-in-docker)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
@@ -260,6 +261,63 @@ python3 run_server.py --port 9090 --backend faster_whisper --raw_pcm_input
``` ```
Audio is automatically normalized to float32 range [-1.0, 1.0]. Clients can also set `audio_format` in the initial websocket options to `float32` (default), `int16`, or `uint8`. Audio is automatically normalized to float32 range [-1.0, 1.0]. Clients can also set `audio_format` in the initial websocket options to `float32` (default), `int16`, or `uint8`.
## Streaming Client (manual audio streaming from any source)
`StreamingTranscriptionClient` lets you push raw PCM audio bytes from any source — a live microphone capture loop, a network stream, an audio pipeline — and receive transcripts via callbacks as speech is detected. Unlike `TranscriptionClient`, it does not manage audio capture internally; you control when and how audio is fed.
A runnable example that reads from an audio file and stream the chunks is at [`examples/manual_audio_chunking.py`](examples/manual_audio_chunking.py):
```bash
python examples/manual_audio_chunking.py --file assets/jfk.flac
```
Example usage:
```python
from whisper_live.client import StreamingTranscriptionClient
client = StreamingTranscriptionClient(
"localhost", 9090,
lang="en",
model="small",
on_session_started=lambda: print("Server ready"),
on_partial_transcript=lambda text, segs: print(f"{text}", end="\r"),
on_committed_transcript=lambda text, segs: print(f"{text}"),
on_error=lambda e: print(f"Error: {e}"),
on_close=lambda: print("Closed"),
)
with client:
for chunk in my_audio_source: # any cadence, any chunk size
client.send(chunk, pcm_format="int16")
```
Audio must be **mono, 16 kHz PCM**. Two formats are accepted:
| `pcm_format` | Description |
|---|---|
| `"int16"` (default for raw microphone data) | 16-bit signed integers, normalized internally |
| `"float32"` | 32-bit floats in `[-1, 1]`, passed through directly |
NumPy arrays can be sent with `send_array()`:
```python
import numpy as np
samples = np.frombuffer(raw_bytes, dtype=np.int16)
client.send_array(samples)
```
**Callbacks**
| Callback | Signature | When fired |
|---|---|---|
| `on_session_started` | `() -> None` | Server handshake complete, ready to receive audio |
| `on_partial_transcript` | `(text, segments) -> None` | In-progress segment updated |
| `on_committed_transcript` | `(text, segments) -> None` | Segment finalized |
| `on_translation` | `(text, segments) -> None` | Translated segment ready (requires `enable_translation=True`) |
| `on_error` | `(error) -> None` | WebSocket error |
| `on_close` | `() -> None` | Connection closed |
## Browser Extensions ## Browser Extensions
- Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server). - Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server).
- Transcribe audio directly from your browser using our Chrome or Firefox extensions. Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) and https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md - Transcribe audio directly from your browser using our Chrome or Firefox extensions. Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) and https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md
+78
View File
@@ -0,0 +1,78 @@
"""
Manual audio chunking example for WhisperLive.
Streams an audio file to a running WhisperLive server in real-time sized chunks,
printing partial transcripts when speech is detected and committed transcripts
when each segment is finalized.
Usage:
python examples/manual_audio_chunking.py --file assets/jfk.flac
"""
import argparse
import os
import sys
import time
import wave
try:
from whisper_live.client import StreamingTranscriptionClient
from whisper_live.utils import resample
except ImportError: # just in case whisper_live isn't installed.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
print("[INFO] whisper_live not installed or the current version does not have StreamingTranscriptionClient. Will attempt to import from local source.")
from whisper_live.client import StreamingTranscriptionClient
from whisper_live.utils import resample
SAMPLE_RATE = 16000
def stream_audio_file(path: str, client: StreamingTranscriptionClient, chunk_ms: int = 50) -> None:
"""Read an audio file, resample to 16 kHz mono if needed, and pace chunks in real time."""
resampled_path = resample(path)
try:
with wave.open(resampled_path, "rb") as wf:
frames_per_chunk = SAMPLE_RATE * chunk_ms // 1000
chunk_duration = frames_per_chunk / SAMPLE_RATE
while chunk := wf.readframes(frames_per_chunk):
client.send(chunk, pcm_format="int16")
time.sleep(chunk_duration)
finally:
os.remove(resampled_path)
def main():
parser = argparse.ArgumentParser(description="Stream an audio file to WhisperLive.")
parser.add_argument("--file", "-f", required=True, help="Audio file to transcribe (any format supported by ffmpeg).")
parser.add_argument("--server", "-s", default="localhost")
parser.add_argument("--port", "-p", type=int, default=9090)
parser.add_argument("--model", "-m", default="small")
parser.add_argument("--lang", "-l", default="en")
parser.add_argument("--chunk_ms", type=int, default=50, help="Chunk size in ms.")
args = parser.parse_args()
client = StreamingTranscriptionClient(
args.server, args.port,
lang=args.lang,
model=args.model,
on_session_started=lambda: print("[INFO] Server ready.\n"),
on_partial_transcript=lambda text, _: print(f"\r{text:<80}", end="", flush=True),
on_committed_transcript=lambda text, _: print(f"\r{text:<80}"),
on_error=lambda e: print(f"\n[ERROR] {e}"),
on_close=lambda: print("\n[INFO] Connection closed."),
)
with client:
print(f"[INFO] Streaming {args.file} in {args.chunk_ms} ms chunks.")
stream_audio_file(args.file, client, chunk_ms=args.chunk_ms)
print("\n[INFO] Final transcript:")
for seg in client.transcript:
print(f" [{float(seg['start']):.2f}s → {float(seg['end']):.2f}s] {seg['text'].strip()}")
if client.last_partial:
seg = client.last_segment
print(f" [{float(seg['start']):.2f}s → {float(seg['end']):.2f}s] {seg['text'].strip()} (partial)")
if __name__ == "__main__":
main()
+213
View File
@@ -11,6 +11,7 @@ import websocket
import uuid import uuid
import time import time
import av import av
from typing import Callable, Literal, Optional
import whisper_live.utils as utils import whisper_live.utils as utils
@@ -911,3 +912,215 @@ class TranscriptionClient(TranscriptionTeeClient):
output_recording_filename=output_recording_filename, output_recording_filename=output_recording_filename,
mute_audio_playback=mute_audio_playback mute_audio_playback=mute_audio_playback
) )
PcmFormat = Literal["float32", "int16"]
class _HookedClient(Client):
"""Client subclass that exposes lifecycle callbacks not available on the base class."""
def __init__(self, *args, on_session_started=None, on_error_hook=None, on_close_hook=None, **kwargs):
self._on_session_started = on_session_started
self._on_error_hook = on_error_hook
self._on_close_hook = on_close_hook
super().__init__(*args, **kwargs)
def on_message(self, ws, message):
data = json.loads(message)
if data.get("message") == "SERVER_READY" and self._on_session_started:
self._on_session_started()
super().on_message(ws, message)
def on_error(self, ws, error):
if self._on_error_hook:
self._on_error_hook(error)
super().on_error(ws, error)
def on_close(self, ws, close_status_code, close_msg):
if self._on_close_hook:
self._on_close_hook()
super().on_close(ws, close_status_code, close_msg)
class StreamingTranscriptionClient:
"""Feed raw PCM audio in chunks; receive partial and committed transcripts via callbacks.
Args:
host: WhisperLive server hostname.
port: WhisperLive server port.
lang: Language code (e.g. ``"en"``). ``None`` enables auto-detection.
model: Whisper model size (``"tiny"``, ``"base"``, ``"small"``, ``"medium"``, ``"large"``).
use_vad: Enable server-side voice activity detection.
use_wss: Use ``wss://`` instead of ``ws://``.
send_last_n_segments: How many recent segments the server echoes per update.
no_speech_thresh: Segments with no-speech probability above this are discarded.
clip_audio: Drop audio with no valid segments.
same_output_threshold: Repeated identical outputs before a segment is committed.
enable_translation: Enable post-transcription translation.
target_language: Target language for translation (e.g. ``"fr"``).
ready_timeout: Seconds to wait for ``SERVER_READY`` before raising ``TimeoutError``.
on_session_started: Called once when the server is ready to receive audio.
on_partial_transcript: Called on each in-progress segment update with ``(text, segments)``.
on_committed_transcript: Called for each finalized segment with ``(text, segments)``.
on_translation: Called for each translated segment with ``(text, segments)``.
on_error: Called on WebSocket errors with the exception.
on_close: Called when the connection closes.
Example::
client = StreamingTranscriptionClient(
"localhost", 9090,
lang="en",
on_partial_transcript=lambda text, _: print(f"{text}", end="\\r"),
on_committed_transcript=lambda text, _: print(f"{text}"),
)
with client:
for chunk in my_audio_source:
client.send(chunk, pcm_format="int16")
"""
def __init__(
self,
host: str,
port: int,
*,
lang: Optional[str] = None,
model: str = "small",
use_vad: bool = True,
use_wss: bool = False,
send_last_n_segments: int = 10,
no_speech_thresh: float = 0.45,
clip_audio: bool = False,
same_output_threshold: int = 10,
enable_translation: bool = False,
target_language: str = "fr",
ready_timeout: float = 30.0,
on_session_started: Optional[Callable[[], None]] = None,
on_partial_transcript: Optional[Callable[[str, list], None]] = None,
on_committed_transcript: Optional[Callable[[str, list], None]] = None,
on_translation: Optional[Callable[[str, list], None]] = None,
on_error: Optional[Callable[[Exception], None]] = None,
on_close: Optional[Callable[[], None]] = None,
):
self._on_partial_transcript = on_partial_transcript
self._on_committed_transcript = on_committed_transcript
self._ready_timeout = ready_timeout
self._closed = False
self._last_committed_count = 0
self._client = _HookedClient(
host=host,
port=port,
lang=lang,
model=model,
use_vad=use_vad,
use_wss=use_wss,
log_transcription=False,
send_last_n_segments=send_last_n_segments,
no_speech_thresh=no_speech_thresh,
clip_audio=clip_audio,
same_output_threshold=same_output_threshold,
enable_translation=enable_translation,
target_language=target_language,
transcription_callback=self._dispatch_transcript,
translation_callback=on_translation,
on_session_started=on_session_started,
on_error_hook=on_error,
on_close_hook=on_close,
)
def _dispatch_transcript(self, text: str, segments: list) -> None:
new_committed = self._client.transcript[self._last_committed_count:]
for seg in new_committed:
if self._on_committed_transcript:
self._on_committed_transcript(seg["text"].strip(), [seg])
self._last_committed_count = len(self._client.transcript)
last = segments[-1] if segments else None
if last and not last.get("completed", False) and self._on_partial_transcript:
self._on_partial_transcript(last["text"].strip(), [last])
def connect(self) -> "StreamingTranscriptionClient":
"""Block until the server is ready. Returns self for use as a context manager."""
deadline = time.time() + self._ready_timeout
while not self._client.recording:
if self._client.server_error:
raise RuntimeError(getattr(self._client, "error_message", "Server reported an error."))
if self._client.waiting:
raise RuntimeError("Server is full.")
if time.time() > deadline:
raise TimeoutError("Timed out waiting for server ready.")
time.sleep(0.05)
return self
def send(self, audio_bytes: bytes, pcm_format: PcmFormat = "float32") -> None:
"""Send one PCM chunk. Any chunk size is fine; must be mono 16 kHz.
Args:
audio_bytes: Raw PCM payload.
pcm_format: ``"float32"`` passes through; ``"int16"`` is normalized to float32.
"""
if self._closed:
raise RuntimeError("Client is already closed.")
if not audio_bytes:
return
if pcm_format == "float32":
payload = audio_bytes
elif pcm_format == "int16":
samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
payload = samples.tobytes()
else:
raise ValueError(f"Unsupported pcm_format: {pcm_format!r}")
self._client.send_packet_to_server(payload)
def send_array(self, samples: np.ndarray) -> None:
"""Send a numpy array (any numeric dtype, mono, 16 kHz).
Args:
samples: 1-D numpy array of audio samples.
"""
if samples.ndim != 1:
raise ValueError("Expected mono (1-D) array.")
if np.issubdtype(samples.dtype, np.integer):
info = np.iinfo(samples.dtype)
samples = samples.astype(np.float32) / max(abs(info.min), info.max)
elif samples.dtype != np.float32:
samples = samples.astype(np.float32)
self._client.send_packet_to_server(samples.tobytes())
@property
def transcript(self) -> list:
"""All committed segments received so far."""
return self._client.transcript
@property
def last_partial(self) -> Optional[dict]:
"""The most recent in-progress segment, or ``None`` if none pending."""
return self._client.last_segment
@property
def last_segment(self) -> Optional[dict]:
"""The most recent in-progress segment, or ``None`` if none pending."""
return self._client.last_segment
def close(self, drain_seconds: float = 2.0) -> None:
"""Signal end-of-stream, wait briefly for final transcripts, then close.
Args:
drain_seconds: Seconds to wait for the server to flush remaining audio.
"""
if self._closed:
return
self._closed = True
try:
self._client.send_packet_to_server(Client.END_OF_AUDIO.encode("utf-8"))
time.sleep(drain_seconds)
finally:
self._client.close_websocket()
def __enter__(self) -> "StreamingTranscriptionClient":
return self.connect()
def __exit__(self, exc_type, exc, tb) -> None:
self.close()