Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 955665401a | |||
| 17b1639a9b | |||
| 06ec02445d | |||
| daf3da8633 | |||
| 2e466b765a | |||
| dfef376086 | |||
| 26845cabe9 | |||
| c0f101f0d1 | |||
| ec1dc7c6aa | |||
| a71c578570 | |||
| f4f1b1d8be | |||
| ad4d314b79 | |||
| 8c0caf1be0 | |||
| d9459ebf2d | |||
| 5b577b34e4 | |||
| 2debc0ee80 | |||
| 9f7a043d8b | |||
| ee64458194 | |||
| 056774ea50 | |||
| e4160d2d06 | |||
| 471c3fd6b4 | |||
| ac7a9f849c | |||
| ecb052c873 | |||
| ee3113a507 | |||
| 81ff199a70 | |||
| d0ad362b20 | |||
| 44940e2834 | |||
| 32c1b18c9f |
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user