28 Commits

Author SHA1 Message Date
cjones 955665401a Override get_segment_end() in ServeClientOpenVINO to match OpenVINO's WhisperDecodedResultChunk class. 2026-07-24 23:16:42 -04:00
cjones 17b1639a9b Get ready to add diarization to openvino backend. 2026-07-23 00:05:02 -04:00
Vineet Suryan 06ec02445d Merge pull request #480 from Kokkini/feature/streaming-transcription-client
Add StreamingTranscriptionClient for streaming from any source
2026-07-17 12:24:02 +02:00
Vineet Suryan daf3da8633 Merge pull request #523 from Kaihui-AMD/faster-whisper-rocm
Add AMD ROCm GPU support for faster_whisper backend
2026-07-15 15:43:46 +02:00
Kaihui-AMD 2e466b765a Add AMD ROCm GPU support for faster_whisper backend
Add ROCm_whisper.md (Docker + native install guide) and docker/Dockerfile.rocm
based on rocm/pytorch:rocm7.2.4 (PyTorch 2.10.0) that installs the official
CTranslate2 v4.8.0 ROCm wheel. The default faster_whisper backend runs on AMD
GPUs out of the box with no code changes.

Tested on Radeon AI PRO R9700 (gfx1201) and Ryzen AI Max+ 395 / Radeon 8060S
(gfx1151) with ROCm 7.2.4.

Addresses #520.
2026-07-15 16:13:37 +08:00
Vineet Suryan dfef376086 Merge pull request #524 from nightcityblade/fix/issue-521
fix: allow NumPy 2 for pyannote audio
2026-07-15 09:53:12 +02:00
nightcityblade 26845cabe9 fix: allow NumPy 2 for pyannote audio 2026-07-15 11:13:12 +08:00
Vineet Suryan c0f101f0d1 Merge pull request #516 from nightcityblade/fix/issue-324
fix: improve subtitle readability
2026-07-14 14:59:11 +02:00
Quang Tran ec1dc7c6aa fix: clean shutdown for StreamingTranscriptionClient 2026-07-08 23:13:20 +07:00
Quang Tran a71c578570 fix: address PR review comments on StreamingTranscriptionClient 2026-07-08 22:49:23 +07:00
Quang Tran f4f1b1d8be feat: support manual audio streaming from any source 2026-07-08 22:46:34 +07:00
nightcityblade ad4d314b79 test: cover subtitle wrapping edge cases 2026-07-07 23:04:01 +08:00
nightcityblade 8c0caf1be0 fix: improve subtitle readability 2026-07-07 23:04:01 +08:00
Vineet Suryan d9459ebf2d Merge pull request #517 from dmaier-ef/fix/idle-transcription-thread-cpu-contention
Fix idle-client busy-wait before first audio frame
2026-07-06 11:37:35 +05:30
David Maier 5b577b34e4 Add configurable timeout for first-frame wait and improve thread-safety 2026-07-03 14:45:09 +02:00
Aaron Boxer 2debc0ee80 Make initial_prompt and vad_parameters accessible from the client
Expose initial_prompt and vad_parameters as flat client parameters
(consistent with hotwords, send_last_n_segments, etc.), send them as
flat handshake keys, and read them server-side with null-safe
options.get(...). Supersedes #283; avoids the options-bag None.get crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:36:26 -04:00
Aaron Boxer 9f7a043d8b ctranslate2: add missing libcublas dep 2026-06-26 10:38:56 -04:00
Alessandro Griseta ee64458194 Use command -v dnf instead 2026-06-26 10:28:41 -04:00
David Maier 056774ea50 Fix idle-client busy-wait before first audio frame 2026-06-26 14:57:03 +02:00
Vineet Suryan e4160d2d06 Merge pull request #513 from SuperCowProducts/clarify-installation-instructions
Make install instructions clearer
2026-06-26 16:03:33 +05:30
nightcityblade 471c3fd6b4 docs: add macOS OpenMP workaround 2026-06-24 11:14:48 -04:00
makaveli10 ac7a9f849c fix(batch): add temperature fallback to prevent decoder runaway
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2026-06-23 10:52:02 -04:00
makaveli10 ecb052c873 Enable single-model mode for stock models when batch_inference is set
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2026-06-23 10:52:02 -04:00
nightcityblade ee3113a507 docs: clarify iOS project setup 2026-06-23 10:50:31 -04:00
Alessandro Griseta 81ff199a70 First venv activate, then pip install 2026-06-18 12:29:43 +02:00
Aaron Boxer d0ad362b20 chrome extension: point to Modal transcription service
- add unit test
2026-06-14 12:18:36 -04:00
Aaron Boxer 44940e2834 Support known speaker hints in REST API 2026-06-14 12:17:44 -04:00
nightcityblade 32c1b18c9f fix: support uint8 websocket audio format 2026-06-04 16:04:38 -04:00
28 changed files with 1534 additions and 103 deletions
+2
View File
@@ -21,3 +21,5 @@ transcript*.srt
translation*.srt translation*.srt
*.wav *.wav
docs/site/ docs/site/
Audio-Transcription-Chrome/node_modules/
Audio-Transcription-Chrome/package-lock.json
@@ -0,0 +1,242 @@
'use strict';
// ---------------------------------------------------------------------------
// Chrome API mock — defined before any extension script is loaded
// ---------------------------------------------------------------------------
const storageData = {};
global.chrome = {
storage: {
local: {
get: jest.fn((keys, cb) => {
if (typeof keys === 'string') {
cb({ [keys]: storageData[keys] });
} else if (Array.isArray(keys)) {
const result = {};
keys.forEach(k => { result[k] = storageData[k]; });
cb(result);
} else {
const result = {};
Object.keys(keys).forEach(k => {
result[k] = k in storageData ? storageData[k] : keys[k];
});
cb(result);
}
}),
set: jest.fn((obj, cb) => {
Object.assign(storageData, obj);
if (cb) cb();
}),
},
},
runtime: {
sendMessage: jest.fn(),
onMessage: { addListener: jest.fn() },
getURL: jest.fn(path => `chrome-extension://fake-id/${path}`),
id: 'fake-extension-id',
},
tabs: {
query: jest.fn(),
get: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
sendMessage: jest.fn(),
},
tabCapture: { capture: jest.fn() },
scripting: { executeScript: jest.fn() },
};
// Flush all pending microtasks and one macrotask round so async click
// handlers (which await at least one Promise inside) complete fully.
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
function resetStorage() {
Object.keys(storageData).forEach(k => delete storageData[k]);
}
function buildPopupDOM() {
document.body.innerHTML = `
<div id="startCapture"></div>
<div id="stopCapture"></div>
<input type="checkbox" id="useServerCheckbox">
<input type="checkbox" id="useVadCheckbox">
<input type="checkbox" id="saveCaptionsCheckbox">
<select id="languageDropdown">
<option value="" selected></option>
<option value="en">English</option>
</select>
<select id="taskDropdown">
<option value="transcribe" selected>Transcribe</option>
</select>
<select id="modelSizeDropdown">
<option value="small" selected>Small</option>
<option value="large-v3">Large-v3</option>
</select>
<select id="captionLinesDropdown">
<option value="3" selected>3</option>
</select>
`;
}
function loadPopup() {
jest.resetModules();
require('../popup.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
}
function clickStart(tabId = 1) {
chrome.tabs.query.mockImplementation((_, cb) => cb([{ id: tabId }]));
document.getElementById('startCapture').click();
}
// ---------------------------------------------------------------------------
// 1. WebSocket URL construction — pure logic extracted from options.js
// ---------------------------------------------------------------------------
describe('WebSocket URL construction', () => {
function buildWsUrl(host, port) {
return port
? `ws://${host}:${port}/`
: `wss://${host}/ws`;
}
test('local server: ws:// with port and trailing slash', () => {
expect(buildWsUrl('localhost', '9090')).toBe('ws://localhost:9090/');
});
test('Modal service (empty port): wss:// with /ws path', () => {
expect(buildWsUrl('boxerab--aavaaz-live-livetranscriber-web.modal.run', '')).toBe(
'wss://boxerab--aavaaz-live-livetranscriber-web.modal.run/ws'
);
});
test('custom host+port stays on ws://', () => {
expect(buildWsUrl('my-server.example.com', '7090')).toBe(
'ws://my-server.example.com:7090/'
);
});
});
// ---------------------------------------------------------------------------
// 2. popup.js — host/port selection based on checkbox
// ---------------------------------------------------------------------------
describe('popup.js host/port selection', () => {
beforeEach(() => {
resetStorage();
buildPopupDOM();
jest.clearAllMocks();
});
test('checkbox unchecked → localhost:9090', async () => {
document.getElementById('useServerCheckbox').checked = false;
loadPopup();
clickStart();
await flushPromises();
const call = chrome.runtime.sendMessage.mock.calls.find(
c => c[0] && c[0].action === 'startCapture'
);
expect(call).toBeDefined();
expect(call[0].host).toBe('localhost');
expect(call[0].port).toBe('9090');
});
test('checkbox checked → Modal host with empty port', async () => {
document.getElementById('useServerCheckbox').checked = true;
loadPopup();
clickStart();
await flushPromises();
const call = chrome.runtime.sendMessage.mock.calls.find(
c => c[0] && c[0].action === 'startCapture'
);
expect(call).toBeDefined();
expect(call[0].host).toBe('boxerab--aavaaz-live-livetranscriber-web.modal.run');
expect(call[0].port).toBe('');
});
test('startCapture message carries language, task, and modelSize', async () => {
document.getElementById('languageDropdown').value = 'en';
document.getElementById('taskDropdown').value = 'transcribe';
document.getElementById('modelSizeDropdown').value = 'large-v3';
loadPopup();
clickStart();
await flushPromises();
const call = chrome.runtime.sendMessage.mock.calls.find(
c => c[0] && c[0].action === 'startCapture'
);
expect(call).toBeDefined();
expect(call[0].task).toBe('transcribe');
expect(call[0].modelSize).toBe('large-v3');
});
});
// ---------------------------------------------------------------------------
// 3. popup.js — button state management
// ---------------------------------------------------------------------------
describe('popup.js button state', () => {
beforeEach(() => {
resetStorage();
buildPopupDOM();
jest.clearAllMocks();
});
test('start button enabled and stop button disabled on load (not capturing)', () => {
storageData.capturingState = { isCapturing: false };
loadPopup();
expect(document.getElementById('startCapture').disabled).toBeFalsy();
expect(document.getElementById('stopCapture').disabled).toBe(true);
});
test('stop button enabled on load when already capturing', () => {
storageData.capturingState = { isCapturing: true };
loadPopup();
expect(document.getElementById('stopCapture').disabled).toBe(false);
});
test('clicking stop sends stopCapture action to runtime', () => {
storageData.capturingState = { isCapturing: true };
loadPopup();
// toggleCaptureButtons(true) disables startCapture and enables stopCapture
document.getElementById('stopCapture').click();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ action: 'stopCapture' }),
expect.any(Function)
);
});
});
// ---------------------------------------------------------------------------
// 4. popup.js — storage state restoration on open
// ---------------------------------------------------------------------------
describe('popup.js storage restoration', () => {
beforeEach(() => {
resetStorage();
buildPopupDOM();
jest.clearAllMocks();
});
test('restores useServerCheckbox from storage', () => {
storageData.useServerState = true;
loadPopup();
expect(document.getElementById('useServerCheckbox').checked).toBe(true);
});
test('restores language selection from storage', () => {
storageData.selectedLanguage = 'en';
loadPopup();
expect(document.getElementById('languageDropdown').value).toBe('en');
});
test('restores model size from storage', () => {
storageData.selectedModelSize = 'large-v3';
loadPopup();
expect(document.getElementById('modelSizeDropdown').value).toBe('large-v3');
});
});
+2 -2
View File
@@ -96,11 +96,11 @@ function init_element(lines = 3) {
elem_container = document.createElement('div'); elem_container = document.createElement('div');
elem_container.id = "transcription"; elem_container.id = "transcription";
elem_container.style.cssText = 'padding-top:16px;font-size:18px;position: fixed; top: 85%; left: 50%; transform: translate(-50%, -50%);line-height:18px;width:500px;height:' + (captionLineCount * 30) + 'px;opacity:0.9;z-index:100;background:black;border-radius:10px;color:white;'; elem_container.style.cssText = 'padding:0 24px;font-family:Arial,Helvetica,sans-serif;font-size:22px;font-weight:600;line-height:30px;position:fixed;top:85%;left:50%;transform:translate(-50%,-50%);width:min(80vw,900px);min-height:' + (captionLineCount * 30) + 'px;z-index:2147483647;color:white;text-align:center;letter-spacing:0.01em;text-shadow:0 0 2px #000,0 2px 4px rgba(0,0,0,0.95);cursor:move;';
for (var i = 0; i <= captionLineCount; i++) { for (var i = 0; i <= captionLineCount; i++) {
elem_text = document.createElement('span'); elem_text = document.createElement('span');
elem_text.style.cssText = 'position: absolute;padding-left:16px;padding-right:16px;'; elem_text.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);max-width:100%;padding:2px 14px;background:rgba(0,0,0,0.72);border-radius:6px;box-decoration-break:clone;-webkit-box-decoration-break:clone;';
elem_text.id = "t" + i; elem_text.id = "t" + i;
elem_container.appendChild(elem_text); elem_container.appendChild(elem_text);
+4 -1
View File
@@ -129,7 +129,10 @@ async function startRecord(option) {
return; return;
} }
socket = new WebSocket(`ws://${option.host}:${option.port}/`); const wsUrl = option.port
? `ws://${option.host}:${option.port}/`
: `wss://${option.host}/ws`;
socket = new WebSocket(wsUrl);
isServerReady = false; isServerReady = false;
let language = option.language; let language = option.language;
+19
View File
@@ -0,0 +1,19 @@
{
"name": "audio-transcription-chrome",
"version": "1.0.0",
"description": "Audio Transcription is a Chrome extension that allows users to capture any audio playing on the current tab and transcribe it using OpenAI-whisper in real time. Users will have the option to do voice activity detection as well to not send audio to server when there is no speech.",
"main": "audiopreprocessor.js",
"scripts": {
"test": "jest"
},
"jest": {
"testEnvironment": "jsdom"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1"
}
}
+2 -2
View File
@@ -90,8 +90,8 @@ document.addEventListener("DOMContentLoaded", function () {
let port = "9090"; let port = "9090";
const useCollaboraServer = useServerCheckbox.checked; const useCollaboraServer = useServerCheckbox.checked;
if (useCollaboraServer){ if (useCollaboraServer){
host = "transcription.kurg.org" host = "boxerab--aavaaz-live-livetranscriber-web.modal.run"
port = "7090" port = ""
} }
chrome.runtime.sendMessage( chrome.runtime.sendMessage(
+2 -2
View File
@@ -218,11 +218,11 @@ function init_element(lines = 3) {
elem_container = document.createElement('div'); elem_container = document.createElement('div');
elem_container.id = "transcription"; elem_container.id = "transcription";
elem_container.style.cssText = 'padding-top:16px;font-size:18px;line-height:18px;position:fixed;top:85%;left:50%;transform:translate(-50%,-50%);width:500px;height:' + (captionLineCount * 30) + 'px;opacity:0.9;z-index:100;background:black;border-radius:10px;color:white;'; elem_container.style.cssText = 'padding:0 24px;font-family:Arial,Helvetica,sans-serif;font-size:22px;font-weight:600;line-height:30px;position:fixed;top:85%;left:50%;transform:translate(-50%,-50%);width:min(80vw,900px);min-height:' + (captionLineCount * 30) + 'px;z-index:2147483647;color:white;text-align:center;letter-spacing:0.01em;text-shadow:0 0 2px #000,0 2px 4px rgba(0,0,0,0.95);cursor:move;';
for (var i = 0; i <= captionLineCount; i++) { for (var i = 0; i <= captionLineCount; i++) {
elem_text = document.createElement('span'); elem_text = document.createElement('span');
elem_text.style.cssText = 'position: absolute;padding-left:16px;padding-right:16px;'; elem_text.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);max-width:100%;padding:2px 14px;background:rgba(0,0,0,0.72);border-radius:6px;box-decoration-break:clone;-webkit-box-decoration-break:clone;';
elem_text.id = "t" + i; elem_text.id = "t" + i;
elem_container.appendChild(elem_text); elem_container.appendChild(elem_text);
+17 -7
View File
@@ -23,6 +23,8 @@ The app streams microphone audio to a WhisperLive server via WebSocket and displ
## Getting Started ## Getting Started
This directory contains the Swift source files for the iOS client, but it does not include a generated `.xcodeproj` or `.xcodeworkspace`. Create a new Xcode project and add these files to it.
1. Clone the repository (your fork): 1. Clone the repository (your fork):
```bash ```bash
@@ -30,16 +32,24 @@ The app streams microphone audio to a WhisperLive server via WebSocket and displ
cd whisperlive/Audio-Transcription-iOS cd whisperlive/Audio-Transcription-iOS
``` ```
2. Open the `.xcodeproj` or `.xcodeworkspace` in Xcode 2. In Xcode, choose **File ▸ New ▸ Project…**, then create an iOS **App** project with SwiftUI.
3. Add the following to your `Info.plist`: 3. Add the Swift files from this directory to the new app target:
- `AudioStream.swift`
- `AudioWebSocket.swift`
- `ContentView.swift`
- `RecordingViewModel.swift`
- `WhisperLive_iOS_ClientApp.swift`
4. Use `WhisperLive-iOS-Client-Info.plist` as a reference for your app's `Info.plist`, or add the microphone usage description manually:
```xml ```xml
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>This app requires microphone access for transcription.</string> <string>This app requires microphone access for transcription.</string>
``` ```
4. Run the app on a physical device (recommended) 5. Run the app on a physical device (recommended)
## Running on a Physical Device (with Free Apple ID) ## Running on a Physical Device (with Free Apple ID)
@@ -92,12 +102,12 @@ Now you can run and debug the app on your real device!
## Folder Structure ## Folder Structure
``` ```
Audio-Transcription-iOS/ Audio-Transcription-iOS/
├── AudioViewModel.swift ├── AudioStream.swift
├── AudioStreamer.swift
├── AudioWebSocket.swift ├── AudioWebSocket.swift
├── RecordingView.swift ├── ContentView.swift
├── RecordingViewModel.swift
├── WhisperLive-iOS-Client-Info.plist
├── WhisperLive_iOS_ClientApp.swift ├── WhisperLive_iOS_ClientApp.swift
├── Info.plist
├── README.md ├── README.md
``` ```
+95 -9
View File
@@ -23,8 +23,10 @@ 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)
- [Future Work](#future-work) - [Future Work](#future-work)
- [Blog Posts](#blog-posts) - [Blog Posts](#blog-posts)
- [Contact](#contact) - [Contact](#contact)
@@ -37,20 +39,17 @@ input from microphone and pre-recorded audio files.
``` ```
On Debian/Ubuntu this installs `portaudio19-dev`, on Fedora `portaudio-devel`, on macOS it uses Homebrew (`portaudio`). On Debian/Ubuntu this installs `portaudio19-dev`, on Fedora `portaudio-devel`, on macOS it uses Homebrew (`portaudio`).
- Install whisper-live from pip - Install 3.12 venv (on Fedora `sudo dnf install -y python3.12 python3.12-pip`)
```bash
pip install whisper-live
```
- Install 3.12 venv on Fedora
```bash ```bash
sudo dnf install -y python3.12 python3.12-pip
python3.12 -m venv whisper_env python3.12 -m venv whisper_env
source whisper_env/bin/activate source whisper_env/bin/activate
``` ```
- Install whisper-live from pip
```bash
pip install whisper-live
```
### OpenAI REST interface ### OpenAI REST interface
@@ -119,6 +118,9 @@ python3 run_server.py -p 9090 \
python3 run_server.py -p 9090 -b openvino python3 run_server.py -p 9090 -b openvino
``` ```
### Setting up AMD ROCm for faster_whisper backend
- Please follow [ROCm_whisper readme](https://github.com/collabora/WhisperLive/blob/main/ROCm_whisper.md) for setup of AMD ROCm GPU support with the CTranslate2 ROCm wheel.
#### Controlling OpenMP Threads #### Controlling OpenMP Threads
To control the number of threads used by OpenMP, you can set the `OMP_NUM_THREADS` environment variable. This is useful for managing CPU resources and ensuring consistent performance. If not specified, `OMP_NUM_THREADS` is set to `1` by default. You can change this by using the `--omp_num_threads` argument: To control the number of threads used by OpenMP, you can set the `OMP_NUM_THREADS` environment variable. This is useful for managing CPU resources and ensuring consistent performance. If not specified, `OMP_NUM_THREADS` is set to `1` by default. You can change this by using the `--omp_num_threads` argument:
@@ -170,6 +172,7 @@ client = TranscriptionClient(
mute_audio_playback=False, # Only used for file input, False by Default mute_audio_playback=False, # Only used for file input, False by Default
enable_translation=True, enable_translation=True,
target_language="hi", target_language="hi",
initial_prompt=None, # Add context for the model, e.g. 'Jane Doe context'
) )
``` ```
It connects to the server running on localhost at port 9090. Using a multilingual model, language for the transcription will be automatically detected. You can also use the language option to specify the target language for the transcription, in this case, English ("en"). The translate option should be set to `True` if we want to translate from the source language to English and `False` if we want to transcribe in the source language. It connects to the server running on localhost at port 9090. Using a multilingual model, language for the transcription will be automatically detected. You can also use the language option to specify the target language for the transcription, in this case, English ("en"). The translate option should be set to `True` if we want to translate from the source language to English and `False` if we want to transcribe in the source language.
@@ -245,6 +248,8 @@ When enabled, completed segments include a `speaker` field:
``` ```
Diarization uses online cosine-similarity clustering of speaker embeddings. If `pyannote.audio` is not installed, the server logs a warning and continues without diarization. Diarization uses online cosine-similarity clustering of speaker embeddings. If `pyannote.audio` is not installed, the server logs a warning and continues without diarization.
The OpenAI-compatible REST endpoint also accepts `known_speaker_names` and uploaded `known_speaker_references` multipart fields. When speaker fields are supplied with `response_format="verbose_json"`, segments include a `speaker` field.
#### Batch Inference #### Batch Inference
Batch multiple client sessions into single GPU calls for higher throughput: Batch multiple client sessions into single GPU calls for higher throughput:
```bash ```bash
@@ -257,7 +262,64 @@ Accept raw PCM int16 audio from clients (useful for embedded devices):
```bash ```bash
python3 run_server.py --port 9090 --backend faster_whisper --raw_pcm_input python3 run_server.py --port 9090 --backend faster_whisper --raw_pcm_input
``` ```
Audio is automatically normalized to float32 range [-1.0, 1.0]. 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 streams 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).
@@ -300,12 +362,36 @@ Refer to [`ios-client`](https://github.com/collabora/WhisperLive/tree/main/Audio
docker run -it --device=/dev/dri -p 9090:9090 ghcr.io/collabora/whisperlive-openvino docker run -it --device=/dev/dri -p 9090:9090 ghcr.io/collabora/whisperlive-openvino
``` ```
- AMD ROCm (faster-whisper on AMD GPU via CTranslate2 ROCm wheel)
```bash
docker build -f docker/Dockerfile.rocm -t whisperlive-rocm .
docker run --rm -it --device=/dev/kfd --device=/dev/dri \
--group-add "$(getent group video | cut -d: -f3)" \
--group-add "$(getent group render | cut -d: -f3)" \
-p 9090:9090 whisperlive-rocm
```
- CPU - CPU
- Faster-whisper - Faster-whisper
```bash ```bash
docker run -it -p 9090:9090 ghcr.io/collabora/whisperlive-cpu:latest docker run -it -p 9090:9090 ghcr.io/collabora/whisperlive-cpu:latest
``` ```
## Troubleshooting
#### macOS OpenMP runtime conflict
On macOS, especially on Intel Macs, `faster_whisper`/`ctranslate2` can conflict with OpenMP runtimes loaded by other Python packages. If the server aborts with a duplicate OpenMP runtime error, run the server with `KMP_DUPLICATE_LIB_OK=TRUE`:
```bash
KMP_DUPLICATE_LIB_OK=TRUE python3 run_server.py --port 9090 \
--backend faster_whisper \
--max_clients 4 \
--max_connection_time 600 \
--no_single_model
```
This workaround is intended for local development and testing. For production deployments, prefer using a clean environment that loads only one OpenMP runtime.
## Future Work ## Future Work
- [x] Add translation to other languages on top of transcription. - [x] Add translation to other languages on top of transcription.
+66
View File
@@ -0,0 +1,66 @@
# WhisperLive-ROCm
Run WhisperLive's `faster_whisper` backend on AMD GPUs using the official [CTranslate2 ROCm wheel](https://github.com/OpenNMT/CTranslate2/releases). Tested on Radeon AI PRO R9700 (gfx1201/RDNA4) and Ryzen AI Max+ 395 / Radeon 8060S (gfx1151/Strix Halo).
## Docker Installation (recommended)
- Install [docker](https://docs.docker.com/engine/install/)
- Build and run the WhisperLive ROCm image:
```bash
docker build -f docker/Dockerfile.rocm -t whisperlive-rocm .
docker run --rm -it \
--device=/dev/kfd --device=/dev/dri \
--group-add "$(getent group video | cut -d: -f3)" \
--group-add "$(getent group render | cut -d: -f3)" \
-p 9090:9090 whisperlive-rocm
```
## Native Installation
### Prerequisites
- AMD GPU with ROCm support (see [supported GPUs](https://rocm.docs.amd.com/en/latest/compatibility/compatibility-matrix.html))
- ROCm 7.2+ installed ([installation guide](https://rocm.docs.amd.com/en/latest/deploy/linux/quick_start.html))
- User in `video` and `render` groups (`sudo usermod -aG video,render $USER`, re-login)
- Python 3.12
### Verify ROCm is working
```bash
rocminfo | grep -E 'Name:|gfx'
# Should show your GPU, e.g. "Name: gfx1151" or "Name: gfx1201"
```
### Install CTranslate2 ROCm wheel
The default `pip install ctranslate2` installs a CUDA-only wheel. Replace it with the official ROCm wheel from the [CTranslate2 releases page](https://github.com/OpenNMT/CTranslate2/releases):
```bash
# Download the ROCm wheels archive (v4.8.0)
curl -LO https://github.com/OpenNMT/CTranslate2/releases/download/v4.8.0/rocm-python-wheels-Linux.zip
# Extract the Python 3.12 wheel
unzip -j rocm-python-wheels-Linux.zip 'temp-linux/ctranslate2-*-cp312-*manylinux*x86_64.whl'
# Install (replaces any existing ctranslate2)
pip install ctranslate2-*-cp312-*.whl
```
### Install WhisperLive server requirements
```bash
pip install -r requirements/server.txt
```
### Verify GPU is visible to CTranslate2
```bash
python -c "import ctranslate2; print('devices:', ctranslate2.get_cuda_device_count())"
```
Expected output: `devices: 1` (CTranslate2 uses the name "cuda" even on ROCm).
If you see `devices: 0`, check:
- Your user is in `video` and `render` groups (re-login after adding)
- `/dev/kfd` exists and is accessible
- The ROCm wheel was installed (not the default PyPI CUDA-only one)
## Run WhisperLive Server with ROCm
```bash
python3 run_server.py --port 9090 --backend faster_whisper
```
The server automatically uses the AMD GPU when the CTranslate2 ROCm wheel is installed. For multi-GPU systems, use `HIP_VISIBLE_DEVICES=N` to select a specific GPU.
+44
View File
@@ -0,0 +1,44 @@
# docker/Dockerfile.rocm
#
# WhisperLive faster_whisper backend on AMD ROCm GPUs.
# Uses the official CTranslate2 ROCm wheel (ships kernels for gfx803 through
# gfx1201 including Strix Halo gfx1151 and RDNA4 gfx1200/1201).
#
# Build:
# docker build -f docker/Dockerfile.rocm -t whisperlive-rocm .
#
# Run (expose the WebSocket port; add --enable_rest --rest_port 8000 -p 8000:8000 for REST):
# docker run --rm -it \
# --device=/dev/kfd --device=/dev/dri \
# --group-add "$(getent group video | cut -d: -f3)" \
# --group-add "$(getent group render | cut -d: -f3)" \
# -p 9090:9090 whisperlive-rocm
FROM rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.10.0
ARG DEBIAN_FRONTEND=noninteractive
ARG CT2_WHEEL_URL=https://github.com/OpenNMT/CTranslate2/releases/download/v4.8.0/rocm-python-wheels-Linux.zip
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends curl unzip portaudio19-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install the CTranslate2 ROCm wheel (official release artifact).
# This replaces any CUDA-only ctranslate2 and enables GPU on AMD.
RUN curl -sL "${CT2_WHEEL_URL}" -o /tmp/ct2-rocm.zip && \
unzip -j /tmp/ct2-rocm.zip 'temp-linux/ctranslate2-*-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl' -d /tmp && \
pip install --no-cache-dir --force-reinstall /tmp/ctranslate2-*-cp312-*.whl && \
rm -f /tmp/ct2-rocm.zip /tmp/ctranslate2-*.whl
# Install server requirements
COPY requirements/server.txt /app/
RUN pip install --no-cache-dir -r server.txt && rm server.txt
COPY whisper_live /app/whisper_live
COPY run_server.py /app
EXPOSE 9090
CMD ["python", "run_server.py", "--port", "9090", "--backend", "faster_whisper"]
+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()}")
seg = client.last_partial
if seg:
print(f" [{float(seg['start']):.2f}s → {float(seg['end']):.2f}s] {seg['text'].strip()} (partial)")
if __name__ == "__main__":
main()
+2 -1
View File
@@ -9,8 +9,9 @@ scipy
av av
jiwer jiwer
evaluate evaluate
numpy<2 numpy>=1.26.4,<2.5
openai-whisper==20250625 openai-whisper==20250625
pyannote.audio
tokenizers==0.20.3 tokenizers==0.20.3
transformers[torch] transformers[torch]
sentencepiece sentencepiece
+1 -1
View File
@@ -19,7 +19,7 @@ elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
source /etc/os-release source /etc/os-release
fi fi
if [[ "${ID:-}" == "fedora" ]]; then if [[ "$(command -v dnf)" ]]; then
echo "Detected Fedora, using dnf for installation" echo "Detected Fedora, using dnf for installation"
dnf install -y portaudio-devel wget dnf install -y portaudio-devel wget
else else
+10 -1
View File
@@ -61,7 +61,7 @@ setup(
"soundfile", "soundfile",
"tokenizers==0.20.3", "tokenizers==0.20.3",
"librosa", "librosa",
"numpy==1.26.4", "numpy>=1.26.4,<2.5",
"openvino", "openvino",
"openvino-genai", "openvino-genai",
"openvino-tokenizers", "openvino-tokenizers",
@@ -70,6 +70,15 @@ setup(
"fastapi", "fastapi",
"uvicorn", "uvicorn",
"python-multipart", "python-multipart",
# CTranslate2 (faster-whisper's backend) is hard-linked against
# libcublas.so.12 / libcudnn.so.9 but doesn't declare the matching
# wheels as runtime deps. torch >=2.12 also dropped the cu12
# wheels in favor of cu13, so users no longer get cu12 transitively.
# Without these wheels GPU inference dies at first transcription:
# ERROR: Library libcublas.so.12 is not found or cannot be loaded
# Skip only for CPU-only inference.
"nvidia-cublas-cu12; sys_platform == 'linux'",
"nvidia-cudnn-cu12; sys_platform == 'linux'",
], ],
python_requires=">=3.9" python_requires=">=3.9"
) )
+126 -1
View File
@@ -24,6 +24,24 @@ class ConcreteServeClient(ServeClientBase):
pass pass
class WaitTrackingEvent:
"""Threading event that records when wait() is entered."""
def __init__(self):
self._event = threading.Event()
self.wait_started = threading.Event()
def wait(self, timeout=None):
self.wait_started.set()
return self._event.wait(timeout)
def set(self):
self._event.set()
def __getattr__(self, name):
return getattr(self._event, name)
class TestServeClientBaseInit(unittest.TestCase): class TestServeClientBaseInit(unittest.TestCase):
def test_default_values(self): def test_default_values(self):
ws = MagicMock() ws = MagicMock()
@@ -91,7 +109,6 @@ class TestAddFrames(unittest.TestCase):
# timestamp_offset should be bumped to at least frames_offset # timestamp_offset should be bumped to at least frames_offset
self.assertGreaterEqual(self.client.timestamp_offset, self.client.frames_offset) self.assertGreaterEqual(self.client.timestamp_offset, self.client.frames_offset)
class TestAddFramesThreadSafety(unittest.TestCase): class TestAddFramesThreadSafety(unittest.TestCase):
def test_concurrent_add_frames(self): def test_concurrent_add_frames(self):
ws = MagicMock() ws = MagicMock()
@@ -114,6 +131,18 @@ class TestAddFramesThreadSafety(unittest.TestCase):
self.assertEqual(errors, []) self.assertEqual(errors, [])
self.assertIsNotNone(client.frames_np) self.assertIsNotNone(client.frames_np)
def test_exception_releases_lock_without_signaling_frames_ready(self):
ws = MagicMock()
client = ConcreteServeClient(client_uid="test", websocket=ws)
client.frames_np = np.array([0.1], dtype=np.float32)
with patch("whisper_live.backend.base.np.concatenate", side_effect=RuntimeError("boom")):
with self.assertRaisesRegex(RuntimeError, "boom"):
client.add_frames(np.array([0.2], dtype=np.float32))
self.assertFalse(client.lock.locked())
self.assertFalse(client.frames_ready.is_set())
class TestGetAudioChunkForProcessing(unittest.TestCase): class TestGetAudioChunkForProcessing(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -266,6 +295,102 @@ class TestCleanup(unittest.TestCase):
self.assertTrue(client.exit) self.assertTrue(client.exit)
def _supports_thread_time():
thread_time = getattr(time, "thread_time", None)
if thread_time is None:
return False
try:
thread_time()
except NotImplementedError:
return False
return True
class TestSpeechToTextWaitingBehavior(unittest.TestCase):
"""Tests the first-frame wait behavior in speech_to_text()."""
def setUp(self):
self.ws = MagicMock()
self.client = ConcreteServeClient(client_uid="test", websocket=self.ws)
self.client.frames_ready = WaitTrackingEvent()
self.transcribe_called = threading.Event()
self.thread_started = threading.Event()
self.cpu_used = None
self.thread = None
def tearDown(self):
if self.thread is not None and self.thread.is_alive():
self.client.exit = True
# release wait() directly so a broken cleanup() cannot hang the test process
self.client.frames_ready.set()
self.thread.join(timeout=1.0)
def _start_speech_thread(self, target=None):
self.thread = threading.Thread(target=target or self.client.speech_to_text)
self.thread.start()
return self.thread
def _join_speech_thread(self):
self.thread.join(timeout=1.0)
return not self.thread.is_alive()
def _transcribe_once(self, input_sample):
# mark the first processing step after wait and stop the loop
self.transcribe_called.set()
self.client.exit = True
return []
def _measure_waiting_cpu(self):
# measure CPU consumed by speech_to_text loop while it waits for the first frame
self.thread_started.set()
start_cpu = time.thread_time()
self.client.speech_to_text()
self.cpu_used = time.thread_time() - start_cpu
def test_waits_for_first_frame_before_transcribing(self):
self.client.transcribe_audio = MagicMock(side_effect=self._transcribe_once)
self._start_speech_thread()
self.assertTrue(self.client.frames_ready.wait_started.wait(timeout=1.0))
self.assertFalse(self.transcribe_called.is_set())
self.client.add_frames(np.zeros(self.client.RATE, dtype=np.float32))
self.assertTrue(self.transcribe_called.wait(timeout=1.0))
self.assertTrue(self._join_speech_thread())
def test_cleanup_unblocks_waiting_thread_without_audio(self):
self.client.transcribe_audio = MagicMock()
self._start_speech_thread()
self.assertTrue(self.client.frames_ready.wait_started.wait(timeout=1.0))
self.client.cleanup()
self.assertTrue(self._join_speech_thread())
self.assertTrue(self.client.exit)
self.client.transcribe_audio.assert_not_called()
def test_exit_flag_unblocks_waiting_thread_without_signal(self):
self.client.transcribe_audio = MagicMock()
self._start_speech_thread()
self.assertTrue(self.client.frames_ready.wait_started.wait(timeout=1.0))
self.client.exit = True
self.assertTrue(self._join_speech_thread())
self.client.transcribe_audio.assert_not_called()
@unittest.skipUnless(_supports_thread_time(), "time.thread_time() not supported")
def test_waiting_for_first_frame_uses_negligible_thread_cpu(self):
self._start_speech_thread(target=self._measure_waiting_cpu)
self.assertTrue(self.thread_started.wait(timeout=1.0))
time.sleep(0.25)
self.client.cleanup()
self.assertTrue(self._join_speech_thread())
self.assertIsNotNone(self.cpu_used)
self.assertLess(self.cpu_used, 0.1)
class TestTrimTranscript(unittest.TestCase): class TestTrimTranscript(unittest.TestCase):
def setUp(self): def setUp(self):
self.ws = MagicMock() self.ws = MagicMock()
+29 -1
View File
@@ -4,9 +4,10 @@ import scipy
import websocket import websocket
import copy import copy
import unittest import unittest
from io import StringIO
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from whisper_live.client import Client, TranscriptionClient, TranscriptionTeeClient from whisper_live.client import Client, TranscriptionClient, TranscriptionTeeClient
from whisper_live.utils import resample from whisper_live.utils import print_transcript, resample
from pathlib import Path from pathlib import Path
@@ -116,6 +117,33 @@ class TestAudioResampling(unittest.TestCase):
os.remove(resampled_audio) os.remove(resampled_audio)
class TestPrintTranscript(unittest.TestCase):
@patch("whisper_live.utils.shutil.get_terminal_size")
@patch("sys.stdout", new_callable=StringIO)
def test_print_transcript_respects_narrow_terminals(self, mock_stdout, mock_terminal_size):
mock_terminal_size.return_value = os.terminal_size((20, 20))
print_transcript(["This transcript should still wrap cleanly on a narrow terminal."])
output_lines = [line for line in mock_stdout.getvalue().splitlines() if line.strip()]
self.assertGreater(len(output_lines), 1)
self.assertTrue(all(len(line) <= 20 for line in output_lines))
@patch("whisper_live.utils.shutil.get_terminal_size")
@patch("sys.stdout", new_callable=StringIO)
def test_print_transcript_indents_timestamp_continuations(self, mock_stdout, mock_terminal_size):
mock_terminal_size.return_value = os.terminal_size((32, 20))
print_transcript(
[{"start": "00:00", "end": "00:05", "text": "This line should wrap and keep its timestamp indentation."}],
timestamps=True,
)
output_lines = [line.rstrip() for line in mock_stdout.getvalue().splitlines() if line.strip()]
self.assertGreater(len(output_lines), 1)
self.assertTrue(output_lines[1].startswith(" " * len("[00:00 -> 00:05] ")))
class TestSendingAudioPacket(BaseTestCase): class TestSendingAudioPacket(BaseTestCase):
def test_send_packet(self): def test_send_packet(self):
self.client.send_packet_to_server(self.mock_audio_packet) self.client.send_packet_to_server(self.mock_audio_packet)
+49
View File
@@ -8,6 +8,7 @@ class TestSpeakerDiarizer(unittest.TestCase):
def _make_diarizer(self, **kwargs): def _make_diarizer(self, **kwargs):
from whisper_live.diarization import SpeakerDiarizer from whisper_live.diarization import SpeakerDiarizer
d = SpeakerDiarizer(**kwargs) d = SpeakerDiarizer(**kwargs)
# Mock the embedding model to return deterministic embeddings # Mock the embedding model to return deterministic embeddings
d._model = MagicMock() d._model = MagicMock()
@@ -82,8 +83,26 @@ class TestSpeakerDiarizer(unittest.TestCase):
self.assertEqual(len(d.speakers), 0) self.assertEqual(len(d.speakers), 0)
self.assertEqual(d._speaker_count, 0) self.assertEqual(d._speaker_count, 0)
def test_enroll_speaker_uses_known_name(self):
d = self._make_diarizer(similarity_threshold=0.8)
self._set_embedding(d, [1.0, 0.0, 0.0])
audio = np.zeros(16000, dtype=np.float32)
self.assertTrue(d.enroll_speaker("Alice", audio))
self._set_embedding(d, [0.99, 0.01, 0.0])
speaker = d.identify_speaker(audio)
self.assertEqual(speaker, "Alice")
def test_speaker_names_label_new_speakers(self):
d = self._make_diarizer(speaker_names=["Alice"])
self._set_embedding(d, [1.0, 0.0, 0.0])
audio = np.zeros(16000, dtype=np.float32)
speaker = d.identify_speaker(audio)
self.assertEqual(speaker, "Alice")
def test_import_error_without_pyannote(self): def test_import_error_without_pyannote(self):
from whisper_live.diarization import SpeakerDiarizer from whisper_live.diarization import SpeakerDiarizer
d = SpeakerDiarizer() d = SpeakerDiarizer()
with patch.dict("sys.modules", {"pyannote": None, "pyannote.audio": None}): with patch.dict("sys.modules", {"pyannote": None, "pyannote.audio": None}):
with self.assertRaises(ImportError): with self.assertRaises(ImportError):
@@ -100,8 +119,10 @@ class TestDiarizationInBase(unittest.TestCase):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.language = "en" self.language = "en"
def transcribe_audio(self, input_sample): def transcribe_audio(self, input_sample):
return None return None
def handle_transcription_output(self, result, duration): def handle_transcription_output(self, result, duration):
pass pass
@@ -148,5 +169,33 @@ class TestDiarizationInBase(unittest.TestCase):
mock_diarizer.identify_speaker.assert_called_once() mock_diarizer.identify_speaker.assert_called_once()
class TestRestDiarizationHelpers(unittest.TestCase):
def test_normalize_form_list_accepts_repeated_or_comma_separated_values(self):
from whisper_live.server import TranscriptionServer
self.assertEqual(
TranscriptionServer._normalize_form_list(["Alice,Bob", "Carol"]),
["Alice", "Bob", "Carol"],
)
def test_speaker_labels_for_segments(self):
from whisper_live.server import TranscriptionServer
segment = MagicMock()
segment.start = 0.0
segment.end = 1.0
diarizer = MagicMock()
diarizer.identify_speaker.return_value = "Alice"
speakers = TranscriptionServer._speaker_labels_for_segments(
[segment],
np.zeros(16000, dtype=np.float32),
diarizer,
)
self.assertEqual(speakers, {0: "Alice"})
diarizer.identify_speaker.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+15 -15
View File
@@ -273,6 +273,16 @@ class TestTranscriptionServerGetAudio(unittest.TestCase):
self.assertTrue(np.all(result >= -1.0)) self.assertTrue(np.all(result >= -1.0))
self.assertTrue(np.all(result <= 1.0)) self.assertTrue(np.all(result <= 1.0))
def test_uint8_audio_format_normalizes_unsigned_pcm(self):
import numpy as np
ws = MagicMock()
self.server.audio_formats[ws] = "uint8"
pcm = np.array([0, 128, 255], dtype=np.uint8)
ws.recv.return_value = pcm.tobytes()
result = self.server.get_audio_from_websocket(ws)
expected = (pcm.astype(np.float32) - 128.0) / 128.0
np.testing.assert_array_almost_equal(result, expected)
def test_raw_pcm_input_off_reads_float32(self): def test_raw_pcm_input_off_reads_float32(self):
import numpy as np import numpy as np
self.server.raw_pcm_input = False self.server.raw_pcm_input = False
@@ -333,11 +343,6 @@ class TestStreamTranscription(unittest.TestCase):
def _make_app(self): def _make_app(self):
"""Create a FastAPI app with the transcribe endpoint that has streaming support.""" """Create a FastAPI app with the transcribe endpoint that has streaming support."""
from fastapi import FastAPI, UploadFile, Form from fastapi import FastAPI, UploadFile, Form
from fastapi.testclient import TestClient
from starlette.responses import StreamingResponse
import os
import tempfile
import shutil
app = FastAPI() app = FastAPI()
server = TranscriptionServer() server = TranscriptionServer()
@@ -494,10 +499,9 @@ class TestRESTAPIParamWarnings(unittest.TestCase):
def setUpClass(cls): def setUpClass(cls):
"""Build a FastAPI test app by extracting the endpoint definition.""" """Build a FastAPI test app by extracting the endpoint definition."""
import logging import logging
from fastapi import FastAPI, UploadFile, Form from fastapi import FastAPI, UploadFile, Form, File
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from typing import Optional, List from typing import Optional, List
from starlette.responses import PlainTextResponse, JSONResponse
app = FastAPI() app = FastAPI()
@@ -513,16 +517,12 @@ class TestRESTAPIParamWarnings(unittest.TestCase):
chunking_strategy: Optional[str] = Form(default=None), chunking_strategy: Optional[str] = Form(default=None),
include: Optional[List[str]] = Form(default=None), include: Optional[List[str]] = Form(default=None),
known_speaker_names: Optional[List[str]] = Form(default=None), known_speaker_names: Optional[List[str]] = Form(default=None),
known_speaker_references: Optional[List[str]] = Form(default=None), known_speaker_references: Optional[List[UploadFile]] = File(default=None),
stream: bool = Form(default=False), stream: bool = Form(default=False),
): ):
ignored_params = [] ignored_params = []
if chunking_strategy: if chunking_strategy:
ignored_params.append(f"chunking_strategy='{chunking_strategy}'") ignored_params.append(f"chunking_strategy='{chunking_strategy}'")
if known_speaker_names:
ignored_params.append("known_speaker_names")
if known_speaker_references:
ignored_params.append("known_speaker_references")
if include: if include:
ignored_params.append(f"include={include}") ignored_params.append(f"include={include}")
if ignored_params: if ignored_params:
@@ -555,17 +555,17 @@ class TestRESTAPIParamWarnings(unittest.TestCase):
ignored = resp.json()["ignored"] ignored = resp.json()["ignored"]
self.assertTrue(any("include" in p for p in ignored)) self.assertTrue(any("include" in p for p in ignored))
def test_known_speaker_names_warning(self): def test_known_speaker_names_supported(self):
resp = self._post(known_speaker_names="alice") resp = self._post(known_speaker_names="alice")
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
ignored = resp.json()["ignored"] ignored = resp.json()["ignored"]
self.assertTrue(any("known_speaker_names" in p for p in ignored)) self.assertFalse(any("known_speaker_names" in p for p in ignored))
def test_multiple_ignored_params(self): def test_multiple_ignored_params(self):
resp = self._post(chunking_strategy="auto", known_speaker_names="bob") resp = self._post(chunking_strategy="auto", known_speaker_names="bob")
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
ignored = resp.json()["ignored"] ignored = resp.json()["ignored"]
self.assertGreaterEqual(len(ignored), 2) self.assertEqual(len(ignored), 1)
class TestAPIKeyAuth(unittest.TestCase): class TestAPIKeyAuth(unittest.TestCase):
+210
View File
@@ -0,0 +1,210 @@
import json
import time
import unittest
from unittest.mock import patch, MagicMock
import numpy as np
from whisper_live.client import Client, StreamingTranscriptionClient
class StreamingClientTestCase(unittest.TestCase):
@patch('whisper_live.client.websocket.WebSocketApp')
def setUp(self, mock_websocket):
self.mock_websocket = mock_websocket
self.mock_ws_app = mock_websocket.return_value
self.mock_ws_app.send = MagicMock()
self.committed = []
self.partials = []
self.session_started = []
self.client = StreamingTranscriptionClient(
host='localhost',
port=9090,
lang="en",
on_session_started=lambda: self.session_started.append(True),
on_committed_transcript=lambda text, segs: self.committed.append((text, segs)),
on_partial_transcript=lambda text, segs: self.partials.append((text, segs)),
)
self._inner = self.client._client
def tearDown(self):
self._inner.close_websocket()
self.mock_websocket.stop()
def _server_ready(self, backend="faster_whisper"):
self._inner.on_message(self.mock_ws_app, json.dumps({
"uid": self._inner.uid,
"message": "SERVER_READY",
"backend": backend,
}))
def _send_segments(self, segments):
self._inner.on_message(self.mock_ws_app, json.dumps({
"uid": self._inner.uid,
"segments": segments,
}))
class TestPcmFormatConversion(StreamingClientTestCase):
def test_int16_is_normalized_to_float32(self):
self._server_ready()
raw = np.array([0, 16384, -32768], dtype=np.int16).tobytes()
with patch.object(self._inner, 'send_packet_to_server') as mock_send:
self.client.send(raw, pcm_format="int16")
sent = np.frombuffer(mock_send.call_args[0][0], dtype=np.float32)
np.testing.assert_allclose(sent, [0.0, 0.5, -1.0], atol=1e-4)
def test_float32_passes_through(self):
self._server_ready()
raw = np.array([0.1, -0.2], dtype=np.float32).tobytes()
with patch.object(self._inner, 'send_packet_to_server') as mock_send:
self.client.send(raw, pcm_format="float32")
self.assertEqual(mock_send.call_args[0][0], raw)
def test_default_format_is_int16(self):
self._server_ready()
raw = np.array([32767], dtype=np.int16).tobytes()
with patch.object(self._inner, 'send_packet_to_server') as mock_send:
self.client.send(raw)
sent = np.frombuffer(mock_send.call_args[0][0], dtype=np.float32)
self.assertAlmostEqual(float(sent[0]), 32767 / 32768.0, places=4)
def test_unsupported_format_raises(self):
self._server_ready()
with self.assertRaises(ValueError):
self.client.send(b"\x00\x00", pcm_format="int8")
def test_send_after_close_raises(self):
self.client._closed = True
with self.assertRaises(RuntimeError):
self.client.send(b"\x00\x00", pcm_format="int16")
def test_send_array_normalizes_integers(self):
with patch.object(self._inner, 'send_packet_to_server') as mock_send:
self.client.send_array(np.array([0, 16384, -32768], dtype=np.int16))
sent = np.frombuffer(mock_send.call_args[0][0], dtype=np.float32)
np.testing.assert_allclose(sent, [0.0, 0.5, -1.0], atol=1e-4)
class TestTranscriptDispatch(StreamingClientTestCase):
def test_partial_then_committed(self):
self._server_ready()
self._send_segments([{"start": 0, "end": 1, "text": "hello", "completed": False}])
self.assertEqual(len(self.partials), 1)
self.assertEqual(self.partials[0][0], "hello")
self.assertEqual(len(self.committed), 0)
self._send_segments([{"start": 0, "end": 1, "text": "hello world", "completed": True}])
self.assertEqual(len(self.committed), 1)
self.assertEqual(self.committed[0][0], "hello world")
self.assertEqual(len(self.client.transcript), 1)
def test_committed_deduplicated(self):
self._server_ready()
seg = {"start": 0, "end": 1, "text": "hi", "completed": True}
self._send_segments([seg])
self._send_segments([seg])
self.assertEqual(len(self.committed), 1)
self.assertEqual(len(self.client.transcript), 1)
def test_committed_backend_agnostic(self):
"""Committed dispatch must work for non-faster_whisper backends."""
self._server_ready(backend="tensorrt")
self._send_segments([{"start": 0, "end": 1, "text": "trt seg", "completed": True}])
self.assertEqual(len(self.committed), 1)
self.assertEqual(len(self.client.transcript), 1)
def test_last_partial_alias(self):
self._server_ready()
self._send_segments([{"start": 0, "end": 1, "text": "pending", "completed": False}])
self.assertIsNotNone(self.client.last_partial)
self.assertIs(self.client.last_partial, self.client.last_segment)
class TestConnectLifecycle(StreamingClientTestCase):
def test_connect_returns_after_ready(self):
self._server_ready()
self.assertIs(self.client.connect(), self.client)
self.assertEqual(len(self.session_started), 1)
def test_connect_times_out(self):
self.client._ready_timeout = 0.1
with self.assertRaises(TimeoutError):
self.client.connect()
def test_connect_raises_on_server_error(self):
self._inner.on_message(self.mock_ws_app, json.dumps({
"uid": self._inner.uid,
"status": "ERROR",
"message": "boom",
}))
with self.assertRaises(RuntimeError):
self.client.connect()
def test_connect_raises_when_server_full(self):
self._inner.on_message(self.mock_ws_app, json.dumps({
"uid": self._inner.uid,
"status": "WAIT",
"message": 5,
}))
with self.assertRaises(RuntimeError):
self.client.connect()
def test_close_sends_end_of_audio(self):
self._server_ready()
self._inner.recording = False # pretend server already closed
with patch.object(self._inner, 'send_packet_to_server') as mock_send, \
patch.object(self._inner, 'close_websocket') as mock_close:
self.client.close()
mock_send.assert_called_once_with(Client.END_OF_AUDIO.encode("utf-8"))
mock_close.assert_called_once()
def test_close_waits_for_server_then_times_out(self):
self._server_ready()
self.assertTrue(self._inner.recording) # server still "processing"
start = time.time()
with patch.object(self._inner, 'send_packet_to_server'), \
patch.object(self._inner, 'close_websocket') as mock_close:
self.client.close(timeout=0.2)
self.assertGreaterEqual(time.time() - start, 0.2)
mock_close.assert_called_once()
def test_close_returns_early_when_server_closes(self):
self._server_ready()
def close_soon(_msg):
self._inner.recording = False
with patch.object(self._inner, 'send_packet_to_server', side_effect=close_soon), \
patch.object(self._inner, 'close_websocket') as mock_close:
start = time.time()
self.client.close(timeout=10.0)
self.assertLess(time.time() - start, 1.0)
mock_close.assert_called_once()
class TestErrorHandling(StreamingClientTestCase):
def test_close_frame_not_reported_as_error(self):
"""A normal CLOSE control frame (opcode 8) must not fire on_error."""
self._server_ready()
errors = []
self.client._client._on_error_hook = errors.append
close_frame = MagicMock()
close_frame.opcode = 8
self._inner.on_error(self.mock_ws_app, close_frame)
self.assertEqual(errors, [])
self.assertFalse(self._inner.server_error)
def test_real_error_still_reported(self):
self._server_ready()
errors = []
self.client._client._on_error_hook = errors.append
self._inner.on_error(self.mock_ws_app, RuntimeError("boom"))
self.assertEqual(len(errors), 1)
self.assertTrue(self._inner.server_error)
if __name__ == '__main__':
unittest.main()
+12 -4
View File
@@ -21,6 +21,8 @@ class ServeClientBase(object):
"""Duration threshold in seconds for clipping audio with no valid segments.""" """Duration threshold in seconds for clipping audio with no valid segments."""
CLIP_TAIL_DURATION_S = 5 CLIP_TAIL_DURATION_S = 5
"""Duration in seconds of audio to keep after clipping.""" """Duration in seconds of audio to keep after clipping."""
FIRST_FRAME_WAIT_TIMEOUT_S = 0.1
"""Interval in seconds for re-checking exit while waiting for the first audio frame."""
client_uid: str client_uid: str
"""A unique identifier for the client.""" """A unique identifier for the client."""
@@ -81,13 +83,15 @@ class ServeClientBase(object):
# threading # threading
self.lock = threading.Lock() self.lock = threading.Lock()
self.frames_ready = threading.Event()
def speech_to_text(self): def speech_to_text(self):
""" """
Process an audio stream in an infinite loop, continuously transcribing the speech. Process an audio stream in an infinite loop, continuously transcribing the speech.
This method continuously receives audio frames, performs real-time transcription, and sends This method continuously receives audio frames, performs real-time transcription, and sends
transcribed segments to the client via a WebSocket connection. transcribed segments to the client via a WebSocket connection. The loop blocks until the first
audio frame arrives when a client is connected but still idle.
If the client's language is not detected, it waits for 30 seconds of audio input to make a language prediction. If the client's language is not detected, it waits for 30 seconds of audio input to make a language prediction.
It utilizes the Whisper ASR model to transcribe the audio, continuously processing and streaming results. Segments It utilizes the Whisper ASR model to transcribe the audio, continuously processing and streaming results. Segments
@@ -103,6 +107,8 @@ class ServeClientBase(object):
break break
if self.frames_np is None: if self.frames_np is None:
while self.frames_np is None and not self.exit:
self.frames_ready.wait(timeout=self.FIRST_FRAME_WAIT_TIMEOUT_S)
continue continue
if self.clip_audio: if self.clip_audio:
@@ -170,7 +176,8 @@ class ServeClientBase(object):
This method is responsible for maintaining the audio stream buffer, allowing the continuous addition This method is responsible for maintaining the audio stream buffer, allowing the continuous addition
of audio frames as they are received. It also ensures that the buffer does not exceed a specified size of audio frames as they are received. It also ensures that the buffer does not exceed a specified size
to prevent excessive memory usage. to prevent excessive memory usage. When the first frame arrives, it also wakes the transcription
thread so processing can begin.
If the buffer size exceeds a threshold (45 seconds of audio data), it discards the oldest 30 seconds If the buffer size exceeds a threshold (45 seconds of audio data), it discards the oldest 30 seconds
of audio data to maintain a reasonable buffer size. If the buffer is empty, it initializes it with the provided of audio data to maintain a reasonable buffer size. If the buffer is empty, it initializes it with the provided
@@ -180,7 +187,7 @@ class ServeClientBase(object):
frame_np (numpy.ndarray): The audio frame data as a NumPy array. frame_np (numpy.ndarray): The audio frame data as a NumPy array.
""" """
self.lock.acquire() with self.lock:
if self.frames_np is not None and self.frames_np.shape[0] > self.MAX_BUFFER_DURATION_S*self.RATE: if self.frames_np is not None and self.frames_np.shape[0] > self.MAX_BUFFER_DURATION_S*self.RATE:
self.frames_offset += float(self.BUFFER_TRIM_DURATION_S) self.frames_offset += float(self.BUFFER_TRIM_DURATION_S)
self.frames_np = self.frames_np[int(self.BUFFER_TRIM_DURATION_S*self.RATE):] self.frames_np = self.frames_np[int(self.BUFFER_TRIM_DURATION_S*self.RATE):]
@@ -193,7 +200,7 @@ class ServeClientBase(object):
self.frames_np = frame_np.copy() self.frames_np = frame_np.copy()
else: else:
self.frames_np = np.concatenate((self.frames_np, frame_np), axis=0) self.frames_np = np.concatenate((self.frames_np, frame_np), axis=0)
self.lock.release() self.frames_ready.set()
def clip_audio_if_no_valid_segment(self): def clip_audio_if_no_valid_segment(self):
""" """
@@ -323,6 +330,7 @@ class ServeClientBase(object):
""" """
logging.info("Cleaning up.") logging.info("Cleaning up.")
self.exit = True self.exit = True
self.frames_ready.set()
def get_segment_no_speech_prob(self, segment): def get_segment_no_speech_prob(self, segment):
return getattr(segment, "no_speech_prob", 0) return getattr(segment, "no_speech_prob", 0)
@@ -220,6 +220,7 @@ class ServeClientFasterWhisper(ServeClientBase):
use_vad=self.use_vad, use_vad=self.use_vad,
vad_parameters=self.vad_parameters if self.use_vad else None, vad_parameters=self.vad_parameters if self.use_vad else None,
word_timestamps=self.word_timestamps, word_timestamps=self.word_timestamps,
client_uid=self.client_uid,
) )
ServeClientFasterWhisper.BATCH_WORKER.submit(request) ServeClientFasterWhisper.BATCH_WORKER.submit(request)
request.future.wait(timeout=30) request.future.wait(timeout=30)
+17
View File
@@ -28,6 +28,7 @@ class ServeClientOpenVINO(ServeClientBase):
no_speech_thresh=0.45, no_speech_thresh=0.45,
clip_audio=False, clip_audio=False,
same_output_threshold=10, same_output_threshold=10,
diarization=None,
): ):
""" """
Initialize a ServeClient instance. Initialize a ServeClient instance.
@@ -56,6 +57,8 @@ class ServeClientOpenVINO(ServeClientBase):
no_speech_thresh, no_speech_thresh,
clip_audio, clip_audio,
same_output_threshold, same_output_threshold,
None, # translation_queue — OpenVINO backend does not support translation
diarization, # speaker diarization — passed through to base class
) )
self.language = "en" if language is None else language self.language = "en" if language is None else language
if not self.language.startswith("<|"): if not self.language.startswith("<|"):
@@ -96,6 +99,20 @@ class ServeClientOpenVINO(ServeClientBase):
logging.info(f"Using OpenVINO device: {self.device}") logging.info(f"Using OpenVINO device: {self.device}")
logging.info(f"Running OpenVINO backend with language: {self.language} and task: {self.task}") logging.info(f"Running OpenVINO backend with language: {self.language} and task: {self.task}")
def get_segment_end(self, segment):
"""
Override base class implementation to handle OpenVINO's end timestamp sentinel value.
WhisperDecodedResultChunk.end_ts is -1.0 when the model did not predict an ending
timestamp (e.g. audio cut off mid-word). A negative end_ts causes a negative array
index in _identify_speaker(), producing an empty audio slice and silently disabling
diarization. Fall back to start_ts + 1.0 second in that case.
"""
end = getattr(segment, "end_ts", -1.0)
if end < 0:
return getattr(segment, "start_ts", 0) + 1.0
return end
def create_model(self, model_id): def create_model(self, model_id):
""" """
Instantiates a new model, sets it as the transcriber. Instantiates a new model, sets it as the transcriber.
+65 -12
View File
@@ -74,6 +74,8 @@ class BatchRequest:
initial_prompt: Optional[str] = None initial_prompt: Optional[str] = None
use_vad: bool = True use_vad: bool = True
vad_parameters: Optional[Dict] = None vad_parameters: Optional[Dict] = None
word_timestamps: bool = False
client_uid: Optional[str] = None
# Signaling # Signaling
future: threading.Event = field(default_factory=threading.Event) future: threading.Event = field(default_factory=threading.Event)
# Results (filled by batch worker) # Results (filled by batch worker)
@@ -307,13 +309,38 @@ class BatchInferenceWorker:
tokenizers_list.append(tokenizer) tokenizers_list.append(tokenizer)
prompts.append(prompt) prompts.append(prompt)
# Step 4: Batch GPU generate # Step 4: Batch GPU generate with per-item temperature fallback.
# Mirrors faster_whisper.transcribe()'s fallback loop. Items that
# pass quality thresholds at lower temperature keep their result;
# only failed items are re-decoded at the next temperature.
suppress_tokens = get_suppressed_tokens(tokenizers_list[0], [-1]) suppress_tokens = get_suppressed_tokens(tokenizers_list[0], [-1])
results = self.transcriber.model.generate( temperatures = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
encoder_output, comp_thresh = 2.4
prompts, logprob_thresh = -1.0
beam_size=5, no_speech_thresh = 0.6
n = len(preprocessed)
final_results = [None] * n # tuples of (gen_result, avg_logprob, used_temp)
pending_indices = list(range(n))
for temp in temperatures:
if not pending_indices:
break
if len(pending_indices) == n:
sub_encoder = encoder_output
else:
# Re-encode features for just the pending items to get
# an encoder_output of the right batch dimension.
sub_feature_batch = np.stack(
[preprocessed[i][1] for i in pending_indices]
)
sub_encoder = self.transcriber.encode(sub_feature_batch)
sub_prompts = [prompts[i] for i in pending_indices]
gen_kwargs = dict(
beam_size=5 if temp == 0.0 else 1,
patience=1, patience=1,
length_penalty=1, length_penalty=1,
max_length=self.transcriber.max_length, max_length=self.transcriber.max_length,
@@ -321,22 +348,48 @@ class BatchInferenceWorker:
suppress_tokens=suppress_tokens, suppress_tokens=suppress_tokens,
return_scores=True, return_scores=True,
return_no_speech_prob=True, return_no_speech_prob=True,
sampling_temperature=0.0, sampling_temperature=temp,
repetition_penalty=1, repetition_penalty=1,
no_repeat_ngram_size=0, no_repeat_ngram_size=0,
) )
batch_results = self.transcriber.model.generate(
sub_encoder, sub_prompts, **gen_kwargs
)
next_pending = []
for j, idx in enumerate(pending_indices):
gen_result = batch_results[j]
tokens = gen_result.sequences_ids[0]
seq_len = len(tokens)
cum_logprob = gen_result.scores[0] * seq_len
avg_logprob = cum_logprob / (seq_len + 1) if seq_len > 0 else 0.0
raw_text = tokenizers_list[idx].decode(tokens).strip()
comp_ratio = get_compression_ratio(raw_text) if raw_text else 0.0
bad = (
comp_ratio > comp_thresh
or avg_logprob < logprob_thresh
)
# High no_speech + low logprob -> treat as silence, accept empty.
is_silence = (
gen_result.no_speech_prob > no_speech_thresh
and avg_logprob < logprob_thresh
)
if not bad or is_silence or temp == temperatures[-1]:
final_results[idx] = (gen_result, avg_logprob, temp)
else:
next_pending.append(idx)
pending_indices = next_pending
# Step 5: Per-item segment parsing and result dispatch # Step 5: Per-item segment parsing and result dispatch
for i, (req, features, audio, duration, speech_chunks) in enumerate(preprocessed): for i, (req, features, audio, duration, speech_chunks) in enumerate(preprocessed):
try: try:
tokenizer = tokenizers_list[i] tokenizer = tokenizers_list[i]
gen_result = results[i] gen_result, avg_logprob, used_temp = final_results[i]
tokens = gen_result.sequences_ids[0] tokens = gen_result.sequences_ids[0]
seq_len = len(tokens)
cum_logprob = gen_result.scores[0] * seq_len
avg_logprob = cum_logprob / (seq_len + 1) if seq_len > 0 else 0.0
segment_size = int(ceil(duration) * self.transcriber.frames_per_second) segment_size = int(ceil(duration) * self.transcriber.frames_per_second)
subsegments, _, _ = self.transcriber._split_segments_by_timestamps( subsegments, _, _ = self.transcriber._split_segments_by_timestamps(
@@ -364,7 +417,7 @@ class BatchInferenceWorker:
compression_ratio=get_compression_ratio(text), compression_ratio=get_compression_ratio(text),
no_speech_prob=gen_result.no_speech_prob, no_speech_prob=gen_result.no_speech_prob,
words=None, words=None,
temperature=0.0, temperature=used_temp,
)) ))
req.result = segments req.result = segments
+242
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
@@ -49,6 +50,8 @@ class Client:
word_timestamps=False, word_timestamps=False,
max_retries=0, max_retries=0,
retry_delay=5, retry_delay=5,
initial_prompt=None,
vad_parameters=None,
): ):
""" """
Initializes a Client instance for audio recording and streaming to a server. Initializes a Client instance for audio recording and streaming to a server.
@@ -75,6 +78,8 @@ class Client:
target_language (str, optional): Target language for translation. Defaults to 'fr'. target_language (str, optional): Target language for translation. Defaults to 'fr'.
translation_callback (callable, optional): A callback function to handle translation results. Default is None. translation_callback (callable, optional): A callback function to handle translation results. Default is None.
translation_srt_file_path (str, optional): The file path to save the translated output SRT file. Default is "output_translated.srt". translation_srt_file_path (str, optional): The file path to save the translated output SRT file. Default is "output_translated.srt".
initial_prompt (str, optional): Optional text to provide context to the model (e.g. domain vocabulary or names). Default is None.
vad_parameters (dict, optional): Optional voice-activity-detection parameters passed to the server backend. Default is None.
""" """
self.recording = False self.recording = False
self.task = "transcribe" self.task = "transcribe"
@@ -103,6 +108,10 @@ class Client:
self.translation_callback = translation_callback self.translation_callback = translation_callback
self.translation_srt_file_path = translation_srt_file_path self.translation_srt_file_path = translation_srt_file_path
self.last_translated_segment = None self.last_translated_segment = None
self.initial_prompt = initial_prompt
self.vad_parameters = vad_parameters
if translate: if translate:
self.task = "translate" self.task = "translate"
self.enable_timestamps = enable_timestamps self.enable_timestamps = enable_timestamps
@@ -330,6 +339,8 @@ class Client:
"enable_diarization": self.enable_diarization, "enable_diarization": self.enable_diarization,
"max_speakers": self.max_speakers, "max_speakers": self.max_speakers,
"word_timestamps": self.word_timestamps, "word_timestamps": self.word_timestamps,
"initial_prompt": self.initial_prompt,
"vad_parameters": self.vad_parameters,
} }
) )
) )
@@ -855,6 +866,8 @@ class TranscriptionClient(TranscriptionTeeClient):
enable_diarization=False, enable_diarization=False,
max_speakers=10, max_speakers=10,
word_timestamps=False, word_timestamps=False,
initial_prompt=None,
vad_parameters=None,
): ):
self.client = Client( self.client = Client(
@@ -882,6 +895,8 @@ class TranscriptionClient(TranscriptionTeeClient):
enable_diarization=enable_diarization, enable_diarization=enable_diarization,
max_speakers=max_speakers, max_speakers=max_speakers,
word_timestamps=word_timestamps, word_timestamps=word_timestamps,
initial_prompt=initial_prompt,
vad_parameters=vad_parameters,
) )
if save_output_recording and not output_recording_filename.endswith(".wav"): if save_output_recording and not output_recording_filename.endswith(".wav"):
@@ -897,3 +912,230 @@ 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):
was_recording = self.recording
super().on_message(ws, message)
if not was_recording and self.recording and self._on_session_started:
self._on_session_started()
def on_error(self, ws, error):
# websocket-client surfaces the server's CLOSE control frame (opcode 8)
# through on_error during shutdown; a normal close is not an error.
if getattr(error, "opcode", None) == 8:
return
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._transcript = []
self._committed_keys = set()
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:
for seg in segments:
if not seg.get("completed", False):
continue
key = (seg.get("start"), seg.get("end"), seg.get("text"))
if key in self._committed_keys:
continue
self._committed_keys.add(key)
self._transcript.append(seg)
if self._on_committed_transcript:
self._on_committed_transcript(seg["text"].strip(), [seg])
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 = "int16") -> None:
"""Send one PCM chunk. Any chunk size is fine; must be mono 16 kHz.
Args:
audio_bytes: Raw PCM payload.
pcm_format: ``"int16"`` is normalized to float32; ``"float32"`` passes through.
"""
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._transcript
@property
def last_partial(self) -> Optional[dict]:
"""The most recent in-progress segment, or ``None`` if none pending."""
return self._client.last_segment
# Alias for ``last_partial``; kept for readability at call sites.
last_segment = last_partial
def close(self, timeout: float = 15.0) -> None:
"""Signal end-of-stream, wait for the server to finish, then close.
After ``END_OF_AUDIO`` the server transcribes any buffered audio, sends
the final committed segment, and closes the connection. Waiting for that
server-initiated close keeps the last segment from being dropped.
Args:
timeout: Maximum seconds to wait for the server to close before
forcing the connection shut.
"""
if self._closed:
return
self._closed = True
try:
self._client.send_packet_to_server(Client.END_OF_AUDIO.encode("utf-8"))
deadline = time.time() + timeout
while self._client.recording and time.time() < deadline:
time.sleep(0.05)
finally:
self._client.close_websocket()
def __enter__(self) -> "StreamingTranscriptionClient":
return self.connect()
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
+41 -2
View File
@@ -12,6 +12,28 @@ import logging
import numpy as np import numpy as np
def load_audio(file_path, sample_rate=16000):
"""Load an audio file as mono float32 PCM at the requested sample rate."""
import av
container = av.open(file_path)
resampler = av.AudioResampler(format="flt", layout="mono", rate=sample_rate)
chunks = []
try:
for frame in container.decode(audio=0):
for resampled_frame in resampler.resample(frame):
chunks.append(
resampled_frame.to_ndarray().reshape(-1).astype(np.float32)
)
finally:
container.close()
if not chunks:
return np.array([], dtype=np.float32)
return np.concatenate(chunks)
class SpeakerDiarizer: class SpeakerDiarizer:
"""Real-time speaker diarization using speaker embeddings and online clustering. """Real-time speaker diarization using speaker embeddings and online clustering.
@@ -38,15 +60,22 @@ class SpeakerDiarizer:
max_speakers=10, max_speakers=10,
embedding_model="pyannote/wespeaker-voxceleb-resnet34-LM", embedding_model="pyannote/wespeaker-voxceleb-resnet34-LM",
hf_token=None, hf_token=None,
speaker_names=None,
): ):
self.similarity_threshold = similarity_threshold self.similarity_threshold = similarity_threshold
self.max_speakers = max_speakers self.max_speakers = max_speakers
self.speaker_names = list(speaker_names or [])
self.speakers = {} # speaker_id -> embedding (averaged) self.speakers = {} # speaker_id -> embedding (averaged)
self._speaker_count = 0 self._speaker_count = 0
self._model = None self._model = None
self._embedding_model_name = embedding_model self._embedding_model_name = embedding_model
self._hf_token = hf_token self._hf_token = hf_token
def _next_speaker_id(self):
if self._speaker_count < len(self.speaker_names):
return self.speaker_names[self._speaker_count]
return f"SPEAKER_{self._speaker_count:02d}"
def _load_model(self): def _load_model(self):
"""Lazy-load the embedding model on first use.""" """Lazy-load the embedding model on first use."""
if self._model is not None: if self._model is not None:
@@ -128,14 +157,24 @@ class SpeakerDiarizer:
if len(self.speakers) >= self.max_speakers: if len(self.speakers) >= self.max_speakers:
# Assign to closest speaker # Assign to closest speaker
return best_speaker if best_speaker else f"SPEAKER_{self._speaker_count:02d}" return (
best_speaker if best_speaker else f"SPEAKER_{self._speaker_count:02d}"
)
# Create a new speaker # Create a new speaker
speaker_id = f"SPEAKER_{self._speaker_count:02d}" speaker_id = self._next_speaker_id()
self._speaker_count += 1 self._speaker_count += 1
self.speakers[speaker_id] = embedding self.speakers[speaker_id] = embedding
return speaker_id return speaker_id
def enroll_speaker(self, speaker_name, audio_np, sample_rate=16000):
"""Enroll a known speaker from reference audio."""
embedding = self._compute_embedding(audio_np, sample_rate)
if embedding is None:
return False
self.speakers[speaker_name] = embedding
return True
def reset(self): def reset(self):
"""Reset all speaker state.""" """Reset all speaker state."""
self.speakers.clear() self.speakers.clear()
+98 -12
View File
@@ -9,19 +9,18 @@ import logging
import shutil import shutil
import tempfile import tempfile
from typing import Optional, List from typing import Optional, List
from fastapi import FastAPI, UploadFile, Form, Request from fastapi import FastAPI, UploadFile, Form, Request, File
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.responses import PlainTextResponse, JSONResponse, StreamingResponse from starlette.responses import PlainTextResponse, StreamingResponse
import uvicorn import uvicorn
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
import torch import torch
from enum import Enum from enum import Enum
from whisper_live import metrics as wl_metrics
from typing import List, Optional
import numpy as np import numpy as np
from whisper_live import metrics as wl_metrics
from websockets.sync.server import serve from websockets.sync.server import serve
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from whisper_live.vad import VoiceActivityDetector from whisper_live.vad import VoiceActivityDetector
@@ -178,6 +177,7 @@ class TranscriptionServer:
self.single_model = False self.single_model = False
self.batch_config = None self.batch_config = None
self.raw_pcm_input = False self.raw_pcm_input = False
self.audio_formats = {}
self.segment_post_processor = None self.segment_post_processor = None
def initialize_client( def initialize_client(
@@ -258,6 +258,7 @@ class TranscriptionServer:
no_speech_thresh=options.get("no_speech_thresh", 0.45), no_speech_thresh=options.get("no_speech_thresh", 0.45),
clip_audio=options.get("clip_audio", False), clip_audio=options.get("clip_audio", False),
same_output_threshold=options.get("same_output_threshold", 10), same_output_threshold=options.get("same_output_threshold", 10),
diarization=self._create_diarizer(options),
) )
logging.info("Running OpenVINO backend.") logging.info("Running OpenVINO backend.")
except Exception as e: except Exception as e:
@@ -361,7 +362,11 @@ class TranscriptionServer:
frame_data = websocket.recv() frame_data = websocket.recv()
if frame_data == b"END_OF_AUDIO": if frame_data == b"END_OF_AUDIO":
return False return False
if self.raw_pcm_input: audio_format = self.audio_formats.get(websocket)
if audio_format == "uint8":
audio_np = np.frombuffer(frame_data, dtype=np.uint8)
return (audio_np.astype(np.float32) - 128.0) / 128.0
if self.raw_pcm_input or audio_format == "int16":
audio_np = np.frombuffer(frame_data, dtype=np.int16) audio_np = np.frombuffer(frame_data, dtype=np.int16)
return audio_np.astype(np.float32) / 32768.0 return audio_np.astype(np.float32) / 32768.0
return np.frombuffer(frame_data, dtype=np.float32) return np.frombuffer(frame_data, dtype=np.float32)
@@ -378,6 +383,10 @@ class TranscriptionServer:
wl_metrics.track_connection_rejected(reason="full") wl_metrics.track_connection_rejected(reason="full")
websocket.close() websocket.close()
return False # Indicates that the connection should not continue return False # Indicates that the connection should not continue
audio_format = options.get("audio_format", "float32")
if audio_format not in {"float32", "int16", "uint8"}:
raise ValueError(f"Unsupported audio_format: {audio_format}")
self.audio_formats[websocket] = audio_format
if self.backend.is_tensorrt(): if self.backend.is_tensorrt():
self.vad_detector = VoiceActivityDetector(frame_rate=self.RATE) self.vad_detector = VoiceActivityDetector(frame_rate=self.RATE)
@@ -514,6 +523,67 @@ class TranscriptionServer:
return StreamingResponse(_sse_generator(), media_type="text/event-stream") return StreamingResponse(_sse_generator(), media_type="text/event-stream")
@staticmethod
def _normalize_form_list(values):
"""Normalize repeated or comma-separated multipart form fields."""
if not values:
return []
normalized = []
for value in values:
if isinstance(value, str):
normalized.extend(item.strip() for item in value.split(",") if item.strip())
return normalized
async def _create_rest_diarizer(self, known_speaker_names, known_speaker_references):
"""Create a diarizer from OpenAI-compatible known speaker fields."""
speaker_names = self._normalize_form_list(known_speaker_names)
speaker_references = known_speaker_references or []
if speaker_references and not speaker_names:
raise ValueError("known_speaker_references requires matching known_speaker_names")
if speaker_names and speaker_references and len(speaker_names) != len(speaker_references):
raise ValueError("known_speaker_names and known_speaker_references must have the same length")
if not speaker_names and not speaker_references:
return None
from whisper_live.diarization import SpeakerDiarizer, load_audio
diarizer = SpeakerDiarizer(
max_speakers=max(10, len(speaker_names)),
speaker_names=speaker_names,
)
for speaker_name, reference in zip(speaker_names, speaker_references):
suffix = os.path.splitext(reference.filename or "")[1] or ".wav"
reference_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await reference.read())
reference_path = tmp.name
audio_np = load_audio(reference_path)
if not diarizer.enroll_speaker(speaker_name, audio_np):
raise ValueError(f"known_speaker_references for '{speaker_name}' is too short")
finally:
if reference_path and os.path.exists(reference_path):
os.unlink(reference_path)
return diarizer
@staticmethod
def _speaker_labels_for_segments(segments, audio_np, diarizer, sample_rate=16000):
if diarizer is None or audio_np is None:
return {}
labels = {}
for index, segment in enumerate(segments):
start = max(0, int(segment.start * sample_rate))
end = min(len(audio_np), int(segment.end * sample_rate))
if end <= start:
continue
speaker = diarizer.identify_speaker(audio_np[start:end], sample_rate)
if speaker:
labels[index] = speaker
return labels
def run(self, def run(self,
host, host,
port=9090, port=9090,
@@ -594,6 +664,9 @@ class TranscriptionServer:
logging.info("Custom model option was provided. Switching to single model mode.") logging.info("Custom model option was provided. Switching to single model mode.")
self.single_model = True self.single_model = True
# TODO: load model initially # TODO: load model initially
elif batch_enabled:
logging.info("Batch inference enabled. Switching to single model mode for stock model.")
self.single_model = True
else: else:
logging.info("Single model mode currently only works with custom models.") logging.info("Single model mode currently only works with custom models.")
if not BackendType.is_valid(backend): if not BackendType.is_valid(backend):
@@ -657,7 +730,7 @@ class TranscriptionServer:
chunking_strategy: Optional[str] = Form(default=None), chunking_strategy: Optional[str] = Form(default=None),
include: Optional[List[str]] = Form(default=None), include: Optional[List[str]] = Form(default=None),
known_speaker_names: Optional[List[str]] = Form(default=None), known_speaker_names: Optional[List[str]] = Form(default=None),
known_speaker_references: Optional[List[str]] = Form(default=None), known_speaker_references: Optional[List[UploadFile]] = File(default=None),
stream: bool = Form(default=False), stream: bool = Form(default=False),
hotwords: Optional[str] = Form(default=None), hotwords: Optional[str] = Form(default=None),
): ):
@@ -671,10 +744,6 @@ class TranscriptionServer:
ignored_params = [] ignored_params = []
if chunking_strategy: if chunking_strategy:
ignored_params.append(f"chunking_strategy='{chunking_strategy}'") ignored_params.append(f"chunking_strategy='{chunking_strategy}'")
if known_speaker_names:
ignored_params.append("known_speaker_names")
if known_speaker_references:
ignored_params.append("known_speaker_references")
if include: if include:
ignored_params.append(f"include={include}") ignored_params.append(f"include={include}")
if ignored_params: if ignored_params:
@@ -689,6 +758,7 @@ class TranscriptionServer:
logging.warning(f"Model '{model}' requested; using 'small' as fallback.") logging.warning(f"Model '{model}' requested; using 'small' as fallback.")
model_name = faster_whisper_custom_model_path or "small" model_name = faster_whisper_custom_model_path or "small"
tmp_path = None
try: try:
suffix = os.path.splitext(file.filename)[1] or ".wav" suffix = os.path.splitext(file.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
@@ -708,9 +778,9 @@ class TranscriptionServer:
word_timestamps=(timestamp_granularities and "word" in timestamp_granularities), word_timestamps=(timestamp_granularities and "word" in timestamp_granularities),
hotwords=hotwords, hotwords=hotwords,
) )
segments = list(segments)
text = " ".join([s.text.strip() for s in segments]) text = " ".join([s.text.strip() for s in segments])
os.unlink(tmp_path)
if response_format == "text": if response_format == "text":
wl_metrics.track_rest_request(endpoint="transcriptions", status=200) wl_metrics.track_rest_request(endpoint="transcriptions", status=200)
@@ -726,7 +796,17 @@ class TranscriptionServer:
"text": text, "text": text,
"segments": [] "segments": []
} }
for seg in segments: speaker_labels = {}
try:
rest_diarizer = await self._create_rest_diarizer(known_speaker_names, known_speaker_references)
except ValueError as e:
wl_metrics.track_rest_request(endpoint="transcriptions", status=400)
return JSONResponse({"error": str(e)}, status_code=400)
if rest_diarizer is not None:
from whisper_live.diarization import load_audio
audio_np = load_audio(tmp_path)
speaker_labels = self._speaker_labels_for_segments(segments, audio_np, rest_diarizer)
for index, seg in enumerate(segments):
seg_dict = { seg_dict = {
"id": seg.id, "id": seg.id,
"seek": seg.seek, "seek": seg.seek,
@@ -739,6 +819,8 @@ class TranscriptionServer:
"compression_ratio": seg.compression_ratio, "compression_ratio": seg.compression_ratio,
"no_speech_prob": seg.no_speech_prob "no_speech_prob": seg.no_speech_prob
} }
if index in speaker_labels:
seg_dict["speaker"] = speaker_labels[index]
if timestamp_granularities and "word" in timestamp_granularities: if timestamp_granularities and "word" in timestamp_granularities:
seg_dict["words"] = [{"word": w.word, "start": w.start, "end": w.end, "probability": w.probability} for w in seg.words] seg_dict["words"] = [{"word": w.word, "start": w.start, "end": w.end, "probability": w.probability} for w in seg.words]
verbose["segments"].append(seg_dict) verbose["segments"].append(seg_dict)
@@ -759,6 +841,9 @@ class TranscriptionServer:
wl_metrics.track_rest_request(endpoint="transcriptions", status=500) wl_metrics.track_rest_request(endpoint="transcriptions", status=500)
wl_metrics.track_error("rest_transcription") wl_metrics.track_error("rest_transcription")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
threading.Thread( threading.Thread(
target=uvicorn.run, target=uvicorn.run,
@@ -845,3 +930,4 @@ class TranscriptionServer:
if hasattr(client, 'translation_thread') and client.translation_thread: if hasattr(client, 'translation_thread') and client.translation_thread:
client.translation_thread.join(timeout=2.0) client.translation_thread.join(timeout=2.0)
self.client_manager.remove_client(websocket) self.client_manager.remove_client(websocket)
self.audio_formats.pop(websocket, None)
+19 -6
View File
@@ -1,3 +1,5 @@
import os
import shutil
import textwrap import textwrap
import scipy import scipy
import numpy as np import numpy as np
@@ -11,15 +13,26 @@ def clear_screen():
def print_transcript(text, translated=False, timestamps=False): def print_transcript(text, translated=False, timestamps=False):
"""Prints formatted transcript text.""" """Prints formatted transcript text in a subtitle-like block."""
terminal_width = shutil.get_terminal_size((80, 20)).columns
wrap_width = max(10, min(80, terminal_width - 8))
if timestamps: if timestamps:
lines = []
for t in text: for t in text:
print(f'[{t["start"]} -> {t["end"]}] {t["text"]}') prefix = f'[{t["start"]} -> {t["end"]}] '
wrapper = textwrap.TextWrapper(
width=wrap_width,
subsequent_indent=" " * len(prefix),
)
lines.extend(wrapper.wrap(f'{prefix}{t["text"]}'))
else: else:
wrapper = textwrap.TextWrapper(width=60) wrapper = textwrap.TextWrapper(width=wrap_width)
text=" ".join(text) if translated else "".join(text) transcript = " ".join(text) if translated else "".join(text)
for line in wrapper.wrap(text=text): lines = wrapper.wrap(text=transcript)
print(line)
for line in lines[-3:]:
print(line.center(terminal_width))
def format_time(s): def format_time(s):