58 Commits

Author SHA1 Message Date
Vineet Suryan 6de5c87d2f Bump version v0.8.0 2026-03-17 14:48:07 +05:30
Vineet Suryan 8e09d16ee4 Merge pull request #430 from makaveli10/vineet/fix-run-client
Fix crash when no --files provided; use microphone input instead
2026-03-17 14:46:57 +05:30
Xiaoliang Gao 4943c25ff7 Fix crash when no --files provided; use microphone input instead 2026-03-17 14:36:20 +05:30
Vineet Suryan 710bdffb51 Merge pull request #429 from makaveli10/vineet/update-setup-packages
Expose __version__ in package root and update dependencies in setup.py
2026-03-17 14:19:15 +05:30
makaveli10 5f0010d720 Expose __version__ in package root and update dependencies in setup.py
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2026-03-17 13:34:34 +05:30
Vineet Suryan 6fcae6a30c Merge pull request #425 from nightcityblade/fix/issue-377
feat: make display_segments configurable in Client/TranscriptionClient
2026-03-16 10:34:29 +05:30
Vineet Suryan bc441dea23 Merge pull request #427 from ianwh02/fix/batch-single-vad-none
Fix NoneType crash in _process_single when VAD filters all audio
2026-03-13 17:02:43 +05:30
ianwh02 89466f7b77 Fix NoneType crash in _process_single when VAD filters all audio
When VAD removes all speech from an audio chunk, transcriber.transcribe() returns (None, info). Calling list(None) raises TypeError. The _process_multi path already handles this case; this aligns _process_single to match.
2026-03-11 16:48:23 +00:00
nightcityblade 6ae57c81cd feat: add --n_display_segments CLI arg to run_client.py 2026-03-11 00:05:07 +08:00
Vineet Suryan 5d8629ea0c Merge pull request #422 from ianwh02/feature/batch-inference
Add cross-client GPU batch inference for faster_whisper backend
2026-03-09 23:01:28 +05:30
ianwh02 e7e78a7151 Add unit tests for BatchInferenceWorker 2026-03-09 16:18:36 +00:00
ianwh02 3508b39584 Fix missing batch_config init causing CI test hang 2026-03-09 11:42:15 +00:00
nightcityblade 067573a510 feat: make display_segments configurable in Client/TranscriptionClient
Replace hardcoded [-4:] truncation with a configurable display_segments
parameter (default: 4) in both Client and TranscriptionClient classes.

Fixes #377
2026-03-08 12:19:54 +08:00
ianwh02 e8bd4fd532 Add cross-client GPU batch inference for faster_whisper backend 2026-02-26 00:00:51 +00:00
Marcus Edel f8869906b0 Merge pull request #419 from makaveli10/bump-whisper-version
Bump openai-whisper version to 20250625.
2026-02-20 08:04:31 -05:00
makaveli10 9fa7005511 Bump openai-whisper version to 20250625
Resolves pkg_resources missing during wheel build

Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2026-02-11 12:39:38 +00:00
Vineet Suryan e48d16f923 Merge pull request #398 from AlexStansfield/feature/faster-whisper-1.2.0
feat: update to support faster whisper 1.2.0
2026-02-11 17:57:05 +05:30
Vineet Suryan 98fcc5110b Merge pull request #418 from JenySadadia/enable-timestamps
Enable timestamps for transcripted text
2026-02-11 17:51:58 +05:30
Jeny Sadadia 5e33aa2a7e Enable timestamps for transcripted text
Add `--enable-timestamps` option to `run_client.py`
script to print out transcripted text with timestamps.

Sample output with translation enabled:
```
[0.000 -> 7.440]  And so, my fellow Americans, ask not what your country can do for you.
[7.440 -> 10.300]  Ask what you can do for your country.

TRANSLATION to fr:
[0.000 -> 7.440] Et donc, mes camarades américains, ne demandez pas ce que votre pays peut faire pour vous.
[7.440 -> 10.300] Demandez ce que vous pouvez faire pour votre pays.
```

Signed-off-by: Jeny Sadadia <jeny.sadadia@collabora.com>
2026-02-10 15:43:20 +05:30
Vineet Suryan 6c8142a9d2 Merge pull request #415 from JenySadadia/run-client-docs
README.md: add instructions for running client
2026-02-06 20:03:19 +05:30
Aaron Boxer 29ee640409 api: add support OpenAI REST transcription api 2026-02-05 22:31:59 -05:00
Aaron Boxer b9ae2af8e6 setup.sh: support Fedora 2026-02-05 22:31:59 -05:00
Jeny Sadadia 9251394047 README.md: add instructions for running client
Specify command to run client script.

Signed-off-by: Jeny Sadadia <jeny.sadadia@collabora.com>
2026-02-03 17:11:40 +05:30
Marcus Edel c6ee9a6870 Merge pull request #412 from makaveli10/vineet/fix-faster-whisper-custom-model-loading
Feat: support HuggingFace model IDs for faster_whisper_custom_model_path.
2026-01-13 15:27:43 -05:00
makaveli10 f5256fc62f feat: support HuggingFace model IDs for faster_whisper_custom_model_path
Previously, the server only accepted local file paths for custom Faster Whisper
models. This change allows passing HuggingFace repo IDs which are automatically
downloaded and converted to CTranslate2 format by the backend if not already in
CTranslate2 format.

Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2026-01-13 16:34:04 +05:30
Alex Stansfield c43eb1dd5a update to support faster whisper 1.2.0 2025-10-07 14:20:20 +00:00
Vineet Suryan 3b17bda5f9 Merge pull request #397 from locnnil/patch-1
fix(run_server.py): help text for max_connection_time argument
2025-09-25 21:09:58 +05:30
Lincoln Wallace 95a9b7ef05 fix(run_server.py): help text for max_connection_time argument
The help text for `--max_connection_time` is incorrect. Looks like a copy-paste mistake from `--cache_path`.
2025-09-25 11:14:35 -03:00
Vineet Suryan 5e6be74f6d Merge pull request #391 from makaveli10/integrate_live_translation
Integrate live translation
2025-07-24 12:07:03 +05:30
makaveli10 04db67170b ServeClientTranslation import only when enable_tranlsation is True
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-22 15:47:06 +00:00
makaveli10 5ce401d4c6 Update test_client to expect translation args
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-22 09:06:31 +00:00
makaveli10 39dfd7521f Update requirements
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-22 09:05:54 +00:00
makaveli10 2b8b245fa8 Add translation backend
Translate from any language to any language with alirezamsh/small100
running in a thread and reading from a queue shared with transcription thread.

Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-22 08:54:10 +00:00
makaveli 1ec437e71f Merge pull request #387 from makaveli10/modify_max_client_time_server_only
Change max_clients max_connection_time from server only
2025-07-22 10:13:48 +05:30
makaveli10 bf6251e3b8 Fix max_client, max_connection_time failed tests
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-21 23:09:16 +05:30
makaveli10 8d6ddd4f7b Change max_clients max_connection_time from server only
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-21 22:52:24 +05:30
makaveli 8d785e5681 Merge pull request #384 from klonikar/main
issue 371
2025-07-21 18:44:57 +05:30
Kiran Lonikar 368bcdd81f temporarily delete web_live directory to merge PR 2025-07-17 16:46:51 +05:30
makaveli d9e608f5c8 Merge pull request #385 from makaveli10/add_srt_download_opt_browser_ext
Add srt download opt browser ext
2025-07-16 15:49:40 +05:30
makaveli10 ad0fb23936 Add download srt file option firefox extension
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-14 09:43:52 +05:30
makaveli10 914281f449 Add download srt file option chrome extension
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-07-14 09:43:39 +05:30
Kiran Lonikar 40edd25468 remove commented code 2025-07-12 22:29:30 +05:30
Kiran Lonikar ad11b2b0ef web client which can take microphone input and transcribe the speech 2025-07-09 21:01:54 +05:30
Kiran Lonikar ddd32cc30f adding test client to transcribe audio files 2025-07-07 13:24:13 +05:30
Kiran Lonikar e597c876cf changes to run when audio playback is muted 2025-07-07 13:21:20 +05:30
Kiran Lonikar 9954548075 issue 371
model name is of form namespace/repo_name and not os path.
2025-07-06 16:10:49 +05:30
makaveli 0f21c80ed8 Merge pull request #382 from ParkMazorika/main
Add iOS client for WhisperLive (Audio-Transcription-iOS)
2025-06-30 18:51:31 +05:30
Park hyeon gyu d79e720b34 Delete .DS_Store 2025-06-30 18:50:12 +09:00
바견규 f3acfa2f18 Add iOS client section to main README/ modify iOS README 2025-06-28 07:21:34 +09:00
Park hyeon gyu 2e5aae6585 Update Audio-Transcription-iOS/README.md
Co-authored-by: makaveli <39617050+makaveli10@users.noreply.github.com>
2025-06-23 20:50:39 +09:00
바견규 179b56a260 Add iOS client for WhisperLive (Audio-Transcription-iOS) 2025-06-17 01:19:56 +09:00
makaveli 4ae3825661 Merge pull request #378 from makaveli10/auto_convert_faster_whisper
Auto convert hf custom whisper to ct2(faster-whisper)
2025-06-02 17:52:19 +05:30
Marcus Edel 198a499f96 Merge pull request #381 from makaveli10/add_blog_to_readme
Add hi transcription video; add blog post section.
2025-06-02 07:54:34 -04:00
makaveli10 05002d6ded Add hi transcription video; add blog post section
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-06-02 10:52:48 +00:00
makaveli10 74abf66d48 Make cache path configurable to save auto converted ct2 models
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-06-01 09:04:22 +00:00
Marcus Edel 0520978c0e Merge pull request #379 from adamsz-lume/run-setup-sh-on-mac
Make setup.sh to work on macos.
2025-05-30 13:59:48 -04:00
Adam 12f3bb2012 make setup.sh to work on macos 2025-05-29 17:44:39 +01:00
makaveli10 bff88ed3e7 Auto convert hf custom whisper to ct2(faster-whisper)
Signed-off-by: makaveli10 <vineet.suryan@collabora.com>
2025-05-29 09:23:32 +00:00
42 changed files with 3389 additions and 358 deletions
+1
View File
@@ -27,6 +27,7 @@ To capture the audio in the current tab, we used the chrome `tabCapture` API to
When using the Audio Transcription extension, you have the following options: When using the Audio Transcription extension, you have the following options:
- **Use Collabora Server**: We provide a demo server which runs the whisper small model. - **Use Collabora Server**: We provide a demo server which runs the whisper small model.
- **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper. - **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper.
- **Download SRT file at Stop Capture**: Select if you want to download the srt file for the session at stop capture.
- **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English. - **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English.
- **Model Size**: Select the whisper model size to run the server with. - **Model Size**: Select the whisper model size to run the server with.
@@ -0,0 +1,77 @@
class AudioPreProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.sampleRate = sampleRate || 48000;
this.targetSampleRate = 16000;
this.inputSamplesNeeded = this.sampleRate * 0.5; // 0.5s
this.inputBuffer = new Float32Array(this.inputSamplesNeeded);
this.inputWriteOffset = 0;
this.processCount = 0;
this.audioDetectedCount = 0;
}
process(inputs, outputs) {
this.processCount++;
const input = inputs[0];
const output = outputs[0];
if (!input || input.length === 0) {
return true;
}
for (let channel = 0; channel < Math.min(input.length, output.length); channel++) {
if (input[channel] && output[channel]) {
output[channel].set(input[channel]);
}
}
let monoInput;
if (input.length === 1) {
monoInput = input[0];
} else if (input.length >= 2) {
monoInput = new Float32Array(input[0].length);
for (let i = 0; i < input[0].length; i++) {
monoInput[i] = (input[0][i] + (input[1] ? input[1][i] : 0)) * 0.5;
}
} else {
return true;
}
if (!monoInput || monoInput.length === 0) {
return true;
}
let inputOffset = 0;
while (inputOffset < monoInput.length) {
const remainingBuffer = this.inputSamplesNeeded - this.inputWriteOffset;
const toCopy = Math.min(remainingBuffer, monoInput.length - inputOffset);
this.inputBuffer.set(monoInput.subarray(inputOffset, inputOffset + toCopy), this.inputWriteOffset);
this.inputWriteOffset += toCopy;
inputOffset += toCopy;
if (this.inputWriteOffset === this.inputSamplesNeeded) {
const downsampled = this.downsampleTo16kHz(this.inputBuffer);
this.port.postMessage(downsampled);
this.inputWriteOffset = 0;
}
}
return true;
}
downsampleTo16kHz(inputBuffer) {
const ratio = this.sampleRate / this.targetSampleRate;
const length = Math.floor(inputBuffer.length / ratio);
const result = new Float32Array(length);
for (let i = 0; i < length; i++) {
const idx = Math.floor(i * ratio);
result[i] = inputBuffer[idx];
}
return result;
}
}
registerProcessor('audiopreprocessor', AudioPreProcessor);
+5 -4
View File
@@ -159,6 +159,7 @@ async function startCapture(options) {
task: options.task, task: options.task,
modelSize: options.modelSize, modelSize: options.modelSize,
useVad: options.useVad, useVad: options.useVad,
saveCaptions: options.saveCaptions,
}, },
}); });
} else { } else {
@@ -174,14 +175,14 @@ async function startCapture(options) {
* Stops the capture process and performs cleanup. * Stops the capture process and performs cleanup.
* @returns {Promise<void>} - A Promise that resolves when the capture process is stopped successfully. * @returns {Promise<void>} - A Promise that resolves when the capture process is stopped successfully.
*/ */
async function stopCapture() { async function stopCapture(options) {
const optionTabId = await getLocalStorageValue("optionTabId"); const optionTabId = await getLocalStorageValue("optionTabId");
const currentTabId = await getLocalStorageValue("currentTabId"); const currentTabId = await getLocalStorageValue("currentTabId");
if (optionTabId) { if (optionTabId) {
res = await sendMessageToTab(currentTabId, { res = await sendMessageToTab(currentTabId, {
type: "STOP", type: "STOP",
data: { currentTabId: currentTabId }, data: { currentTabId: currentTabId, saveCaptions: options.saveCaptions },
}); });
await removeChromeTab(optionTabId); await removeChromeTab(optionTabId);
} }
@@ -196,7 +197,7 @@ chrome.runtime.onMessage.addListener(async (message) => {
if (message.action === "startCapture") { if (message.action === "startCapture") {
startCapture(message); startCapture(message);
} else if (message.action === "stopCapture") { } else if (message.action === "stopCapture") {
stopCapture(); stopCapture(message);
} else if (message.action === "updateSelectedLanguage") { } else if (message.action === "updateSelectedLanguage") {
const detectedLanguage = message.detectedLanguage; const detectedLanguage = message.detectedLanguage;
chrome.runtime.sendMessage({ action: "updateSelectedLanguage", detectedLanguage }); chrome.runtime.sendMessage({ action: "updateSelectedLanguage", detectedLanguage });
@@ -204,7 +205,7 @@ chrome.runtime.onMessage.addListener(async (message) => {
} else if (message.action === "toggleCaptureButtons") { } else if (message.action === "toggleCaptureButtons") {
chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false }); chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false });
chrome.storage.local.set({ capturingState: { isCapturing: false } }) chrome.storage.local.set({ capturingState: { isCapturing: false } })
stopCapture(); stopCapture({saveCaptions: message.saveCaptions});
} }
}); });
+120 -44
View File
@@ -1,10 +1,45 @@
var elem_container = null; var elem_container = null;
var elem_text = null; var elem_text = null;
var segments = []; var segments = [];
var text_segments = []; var text_segments = [];
var allSegments = [];
var lastIncompleteSegment = null;
function formatTime(seconds) {
const date = new Date(seconds * 1000);
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
const ss = String(date.getUTCSeconds()).padStart(2, '0');
const mmm = String(date.getUTCMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss},${mmm}`;
}
function generateSRT() {
return allSegments
.map((seg, i) => {
const start = formatTime(seg.start);
const end = formatTime(seg.end);
const text = seg.text.trim().replace(/[\r\n]+/g, ' ');
return `${i + 1}\n${start} --> ${end}\n${text}`;
})
.join('\n\n');
}
function downloadSRT() {
console.log("downloadSRT called");
console.log("Total segments for SRT:", allSegments.length);
const srtBlob = new Blob([generateSRT()], { type: 'text/srt;charset=utf-8' });
const url = URL.createObjectURL(srtBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'captions.srt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function initPopupElement() { function initPopupElement() {
if (document.getElementById('popupElement')) { if (document.getElementById('popupElement')) {
@@ -32,7 +67,7 @@ function initPopupElement() {
closePopupButton.style.cursor = 'pointer'; closePopupButton.style.cursor = 'pointer';
closePopupButton.addEventListener('click', async () => { closePopupButton.addEventListener('click', async () => {
popupContainer.style.display = 'none'; popupContainer.style.display = 'none';
await browser.runtime.sendMessage({ action: 'toggleCaptureButtons', data: false }); await chrome.runtime.sendMessage({ action: 'toggleCaptureButtons', data: false });
}); });
buttonContainer.appendChild(closePopupButton); buttonContainer.appendChild(closePopupButton);
popupContainer.appendChild(buttonContainer); popupContainer.appendChild(buttonContainer);
@@ -169,8 +204,25 @@ function remove_element() {
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const { type, data } = request; const { type, data } = request;
const saveCaptions = data.saveCaptions;
if (type === "STOP") { if (type === "STOP") {
if (saveCaptions === true) {
// If there is a last incomplete segment, push it to allSegments
if (lastIncompleteSegment && lastIncompleteSegment.text && lastIncompleteSegment.text.trim() !== "") {
// Apply same Python logic: check if transcript is empty OR start >= last end
if (allSegments.length === 0 || parseFloat(lastIncompleteSegment.start) >= parseFloat(allSegments[allSegments.length - 1].end)) {
allSegments.push({
start: lastIncompleteSegment.start,
end: lastIncompleteSegment.end,
text: lastIncompleteSegment.text
});
console.log("Added final incomplete segment");
}
}
downloadSRT();
}
remove_element(); remove_element();
sendResponse({data: "STOPPED"}); sendResponse({data: "STOPPED"});
return true; return true;
@@ -184,53 +236,77 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
init_element(); init_element();
message = JSON.parse(data); try {
message = message["segments"]; const message = JSON.parse(data.data);
const segments = message["segments"];
var text = ''; if (saveCaptions === true) {
for (var i = 0; i < message.length; i++) { segments.forEach(seg => {
text += message[i].text + ' '; if (seg.completed === true &&
} (allSegments.length === 0 || parseFloat(seg.start) >= parseFloat(allSegments[allSegments.length - 1].end))) {
text = text.replace(/(\r\n|\n|\r)/gm, ""); allSegments.push({
start: seg.start,
end: seg.end,
text: seg.text
});
var elem = document.getElementById('t3'); lastIncompleteSegment = null;
elem.innerHTML = text; } else if (seg.completed !== true) {
lastIncompleteSegment = seg;
var line_height_style = getStyle('t3', 'line-height'); }
var line_height = parseInt(line_height_style.substring(0, line_height_style.length - 2)); });
var divHeight = elem.offsetHeight;
var lines = divHeight / line_height;
text_segments = [];
text_segments = get_lines(elem, line_height);
elem.innerHTML = '';
if (text_segments.length > 2) {
for (var i = 0; i < 3; i++) {
document.getElementById('t' + i).innerHTML = text_segments[text_segments.length - 3 + i];
} }
} else { var text = '';
for (var i = 0; i < 3; i++) { for (var i = 0; i < segments.length; i++) {
document.getElementById('t' + i).innerHTML = ''; text += segments[i].text + ' ';
} }
} text = text.replace(/(\r\n|\n|\r)/gm, "");
if (text_segments.length <= 2) { var elem = document.getElementById('t3');
for (var i = 0; i < text_segments.length; i++) { if (elem) {
document.getElementById('t' + i).innerHTML = text_segments[i]; elem.innerHTML = text;
}
} else {
for (var i = 0; i < 3; i++) {
document.getElementById('t' + i).innerHTML = text_segments[text_segments.length - 3 + i];
}
}
for (var i = 1; i < 3; i++) var line_height_style = getStyle('t3', 'line-height');
{ var line_height = parseInt(line_height_style.substring(0, line_height_style.length - 2));
var parent_elem = document.getElementById('t' + (i - 1)); var divHeight = elem.offsetHeight;
var elem = document.getElementById('t' + i); var lines = divHeight / line_height;
elem.style.top = parent_elem.offsetHeight + parent_elem.offsetTop + 'px';
text_segments = [];
text_segments = get_lines(elem, line_height);
elem.innerHTML = '';
if (text_segments.length > 2) {
for (var i = 0; i < 3; i++) {
document.getElementById('t' + i).innerHTML = text_segments[text_segments.length - 3 + i];
}
} else {
for (var i = 0; i < 3; i++) {
document.getElementById('t' + i).innerHTML = '';
}
}
if (text_segments.length <= 2) {
for (var i = 0; i < text_segments.length; i++) {
document.getElementById('t' + i).innerHTML = text_segments[i];
}
} else {
for (var i = 0; i < 3; i++) {
document.getElementById('t' + i).innerHTML = text_segments[text_segments.length - 3 + i];
}
}
for (var i = 1; i < 3; i++)
{
var parent_elem = document.getElementById('t' + (i - 1));
var elem = document.getElementById('t' + i);
if (parent_elem && elem) {
elem.style.top = parent_elem.offsetHeight + parent_elem.offsetTop + 'px';
}
}
}
} catch (error) {
console.error("Error processing message:", error);
} }
sendResponse({}); sendResponse({});
+7 -1
View File
@@ -1,4 +1,4 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Audio Transcription", "name": "Audio Transcription",
@@ -9,6 +9,12 @@
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
"web_accessible_resources": [
{
"resources": ["audiopreprocessor.js"],
"matches": ["<all_urls>"]
}
],
"permissions": [ "permissions": [
"storage", "storage",
"activeTab", "activeTab",
+91 -62
View File
@@ -31,41 +31,6 @@ function sendMessageToTab(tabId, data) {
}); });
} }
/**
* Resamples the audio data to a target sample rate of 16kHz.
* @param {Array|ArrayBuffer|TypedArray} audioData - The input audio data.
* @param {number} [origSampleRate=44100] - The original sample rate of the audio data.
* @returns {Float32Array} The resampled audio data at 16kHz.
*/
function resampleTo16kHZ(audioData, origSampleRate = 44100) {
// Convert the audio data to a Float32Array
const data = new Float32Array(audioData);
// Calculate the desired length of the resampled data
const targetLength = Math.round(data.length * (16000 / origSampleRate));
// Create a new Float32Array for the resampled data
const resampledData = new Float32Array(targetLength);
// Calculate the spring factor and initialize the first and last values
const springFactor = (data.length - 1) / (targetLength - 1);
resampledData[0] = data[0];
resampledData[targetLength - 1] = data[data.length - 1];
// Resample the audio data
for (let i = 1; i < targetLength - 1; i++) {
const index = i * springFactor;
const leftIndex = Math.floor(index).toFixed();
const rightIndex = Math.ceil(index).toFixed();
const fraction = index - leftIndex;
resampledData[i] = data[leftIndex] + (data[rightIndex] - data[leftIndex]) * fraction;
}
// Return the resampled data
return resampledData;
}
function generateUUID() { function generateUUID() {
let dt = new Date().getTime(); let dt = new Date().getTime();
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -76,23 +41,98 @@ function generateUUID() {
return uuid; return uuid;
} }
// Global variables for audio processing
let audioContext = null;
let preNode = null;
let socket = null;
let isServerReady = false;
let currentStream = null;
let currentOptions = null;
// AudioWorklet URL - make sure this path matches your manifest.json
const WORKLET_URL = chrome.runtime.getURL('audiopreprocessor.js');
async function initAudioWorklet(stream) {
audioContext = new AudioContext();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
try {
await audioContext.audioWorklet.addModule(WORKLET_URL);
preNode = new AudioWorkletNode(audioContext, 'audiopreprocessor');
const mediaStream = audioContext.createMediaStreamSource(stream);
mediaStream.connect(preNode);
preNode.connect(audioContext.destination);
preNode.port.onmessage = (event) => {
const data = event.data;
const audio16k = data; // Float32Array @ 16 kHz
if (socket && socket.readyState === WebSocket.OPEN && isServerReady) {
socket.send(audio16k);
}
};
// Test if we can hear audio (this will help verify the audio path)
} catch (error) {
console.error("Error initializing AudioWorklet:", error);
throw error;
}
}
function cleanupAudio() {
if (preNode) {
preNode.port.onmessage = null;
preNode.disconnect();
preNode = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (currentStream) {
currentStream.getTracks().forEach(track => {
track.stop();
console.log("Stopped track:", track.kind);
});
currentStream = null;
}
}
/** /**
* Starts recording audio from the captured tab. * Starts recording audio from the captured tab.
* @param {Object} option - The options object containing the currentTabId. * @param {Object} option - The options object containing the currentTabId.
*/ */
async function startRecord(option) { async function startRecord(option) {
currentOptions = option;
const stream = await captureTabAudio(); const stream = await captureTabAudio();
const uuid = generateUUID(); const uuid = generateUUID();
if (stream) { if (stream) {
// call when the stream inactive currentStream = stream;
stream.oninactive = () => { stream.oninactive = () => {
cleanupAudio();
window.close(); window.close();
}; };
const socket = new WebSocket(`ws://${option.host}:${option.port}/`);
let isServerReady = false; try {
await initAudioWorklet(stream);
} catch (error) {
console.error("Failed to initialize AudioWorklet:", error);
return;
}
socket = new WebSocket(`ws://${option.host}:${option.port}/`);
isServerReady = false;
let language = option.language; let language = option.language;
socket.onopen = function(e) { socket.onopen = function(e) {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -129,7 +169,6 @@ async function startRecord(option) {
language = data["language"]; language = data["language"];
// send message to popup.js to update dropdown // send message to popup.js to update dropdown
// console.log(language);
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: "updateSelectedLanguage", action: "updateSelectedLanguage",
detectedLanguage: language, detectedLanguage: language,
@@ -139,43 +178,33 @@ async function startRecord(option) {
} }
if (data["message"] === "DISCONNECT"){ if (data["message"] === "DISCONNECT"){
chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false }) chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false, saveCaptions: option.saveCaptions });
return; return;
} }
res = await sendMessageToTab(option.currentTabId, { const res = await sendMessageToTab(option.currentTabId, {
type: "transcript", type: "transcript",
data: event.data, data: {
data: event.data,
saveCaptions: option.saveCaptions,
},
}); });
}; };
socket.onclose = () => {
const audioDataCache = []; cleanupAudio();
const context = new AudioContext(); };
const mediaStream = context.createMediaStreamSource(stream);
const recorder = context.createScriptProcessor(4096, 1, 1); socket.onerror = (error) => {
cleanupAudio();
recorder.onaudioprocess = async (event) => {
if (!context || !isServerReady) return;
const inputData = event.inputBuffer.getChannelData(0);
const audioData16kHz = resampleTo16kHZ(inputData, context.sampleRate);
audioDataCache.push(inputData);
socket.send(audioData16kHz);
}; };
// Prevent page mute
mediaStream.connect(recorder);
recorder.connect(context.destination);
mediaStream.connect(context.destination);
// }
} else { } else {
window.close(); window.close();
} }
} }
/** /**
* Listener for incoming messages from the extension's background script. * Listener for incoming messages from the extension's background script.
* @param {Object} request - The message request object. * @param {Object} request - The message request object.
+4
View File
@@ -19,6 +19,10 @@
<input type="checkbox" id="useVadCheckbox"> <input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label> <label for="useVadCheckbox">Use Voice Activity Detection</label>
</div> </div>
<div class="checkbox-container">
<input type="checkbox" id="saveCaptionsCheckbox">
<label for="saveCaptions">Download SRT file at Stop Capture</label>
</div>
<div class="dropdown-container"> <div class="dropdown-container">
<label for="languageDropdown">Select Language:</label> <label for="languageDropdown">Select Language:</label>
<select id="languageDropdown"> <select id="languageDropdown">
+19 -1
View File
@@ -5,6 +5,7 @@ document.addEventListener("DOMContentLoaded", function () {
const useServerCheckbox = document.getElementById("useServerCheckbox"); const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox"); const useVadCheckbox = document.getElementById("useVadCheckbox");
const saveCaptionsCheckbox = document.getElementById("saveCaptionsCheckbox");
const languageDropdown = document.getElementById('languageDropdown'); const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown'); const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown'); const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -38,6 +39,12 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}); });
chrome.storage.local.get("saveCaptionsState", ({ saveCaptionsState }) => {
if (saveCaptionsState !== undefined) {
saveCaptionsCheckbox.checked = saveCaptionsState;
}
});
chrome.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => { chrome.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) { if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage; languageDropdown.value = storedLanguage;
@@ -88,6 +95,7 @@ document.addEventListener("DOMContentLoaded", function () {
task: selectedTask, task: selectedTask,
modelSize: selectedModelSize, modelSize: selectedModelSize,
useVad: useVadCheckbox.checked, useVad: useVadCheckbox.checked,
saveCaptions: saveCaptionsCheckbox.checked,
}, () => { }, () => {
// Update capturing state in storage and toggle the buttons // Update capturing state in storage and toggle the buttons
chrome.storage.local.set({ capturingState: { isCapturing: true } }, () => { chrome.storage.local.set({ capturingState: { isCapturing: true } }, () => {
@@ -105,7 +113,11 @@ document.addEventListener("DOMContentLoaded", function () {
} }
// Send a message to the background script to stop capturing // Send a message to the background script to stop capturing
chrome.runtime.sendMessage({ action: "stopCapture" }, () => { chrome.runtime.sendMessage(
{
action: "stopCapture",
saveCaptions: saveCaptionsCheckbox.checked,
}, () => {
// Update capturing state in storage and toggle the buttons // Update capturing state in storage and toggle the buttons
chrome.storage.local.set({ capturingState: { isCapturing: false } }, () => { chrome.storage.local.set({ capturingState: { isCapturing: false } }, () => {
toggleCaptureButtons(false); toggleCaptureButtons(false);
@@ -128,6 +140,7 @@ document.addEventListener("DOMContentLoaded", function () {
stopButton.disabled = !isCapturing; stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing; useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing; useVadCheckbox.disabled = isCapturing;
saveCaptionsCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing; modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing; languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing; taskDropdown.disabled = isCapturing;
@@ -146,6 +159,11 @@ document.addEventListener("DOMContentLoaded", function () {
chrome.storage.local.set({ useVadState }); chrome.storage.local.set({ useVadState });
}); });
saveCaptionsCheckbox.addEventListener("change", () => {
const saveCaptionsState = saveCaptionsCheckbox.checked;
chrome.storage.local.set({ saveCaptionsState });
});
languageDropdown.addEventListener('change', function() { languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") { if (languageDropdown.value === "") {
selectedLanguage = null; selectedLanguage = null;
+1
View File
@@ -25,6 +25,7 @@ To capture the audio in the current tab, we used the chrome `tabCapture` API to
When using the Audio Transcription extension, you have the following options: When using the Audio Transcription extension, you have the following options:
- **Use Collabora Server**: We provide a demo server which runs the whisper small model. - **Use Collabora Server**: We provide a demo server which runs the whisper small model.
- **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper. - **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper.
- **Download SRT file at Stop Capture**: Select if you want to download the srt file for the session at stop capture.
- **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English. - **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English.
- **Model Size**: Select the whisper model size to run the server with. - **Model Size**: Select the whisper model size to run the server with.
@@ -0,0 +1,70 @@
class AudioPreProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.sampleRate = sampleRate || 48000;
this.targetSampleRate = 16000;
this.inputSamplesNeeded = this.sampleRate * 0.5;
this.inputBuffer = new Float32Array(this.inputSamplesNeeded);
this.inputWriteOffset = 0;
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
if (!input || input.length === 0) {
return true;
}
for (let channel = 0; channel < Math.min(input.length, output.length); channel++) {
if (input[channel] && output[channel]) {
output[channel].set(input[channel]);
}
}
let monoInput;
if (input.length === 1) {
monoInput = input[0];
} else if (input.length > 1) {
monoInput = new Float32Array(input[0].length);
for (let channel = 0; channel < input.length; channel++) {
monoInput.set(input[channel], 0);
}
}
if (!monoInput) {
return true;
}
let inputOffset = 0;
while (inputOffset < monoInput.length) {
const remainingBuffer = this.inputSamplesNeeded - this.inputWriteOffset;
const toCopy = Math.min(remainingBuffer, monoInput.length - inputOffset);
this.inputBuffer.set(monoInput.subarray(inputOffset, inputOffset + toCopy), this.inputWriteOffset);
this.inputWriteOffset += toCopy;
inputOffset += toCopy;
if (this.inputWriteOffset === this.inputSamplesNeeded) {
const downsampled = this.downsampleTo16kHz(this.inputBuffer);
this.port.postMessage(downsampled);
this.inputWriteOffset = 0;
}
}
return true;
}
downsampleTo16kHz(inputBuffer) {
const ratio = this.sampleRate / this.targetSampleRate;
const length = Math.floor(inputBuffer.length / ratio);
const result = new Float32Array(length);
for (let i = 0; i < length; i++) {
const idx = Math.floor(i * ratio);
result[i] = inputBuffer[idx];
}
return result;
}
}
registerProcessor('audiopreprocessor', AudioPreProcessor);
+164 -119
View File
@@ -1,144 +1,162 @@
let socket = null; let socket = null;
let isCapturing = false; let isCapturing = false;
let mediaStream = null;
let audioContext = null; let audioContext = null;
let scriptProcessor = null;
let language = null; let language = null;
let isPaused = false; let isPaused = false;
let preNode = null;
let allSegments = [];
let lastIncompleteSegment = null;
const mediaElements = document.querySelectorAll('video, audio'); function formatTime(seconds) {
mediaElements.forEach((mediaElement) => { const date = new Date(seconds * 1000);
mediaElement.addEventListener('play', handlePlaybackStateChange); const hh = String(date.getUTCHours()).padStart(2, '0');
mediaElement.addEventListener('pause', handlePlaybackStateChange); const mm = String(date.getUTCMinutes()).padStart(2, '0');
}); const ss = String(date.getUTCSeconds()).padStart(2, '0');
const mmm = String(date.getUTCMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss},${mmm}`;
function handlePlaybackStateChange(event) {
isPaused = event.target.paused;
} }
function generateSRT() {
return allSegments
.map((seg, i) => {
const start = formatTime(seg.start);
const end = formatTime(seg.end);
const text = seg.text.trim().replace(/[\r\n]+/g, ' ');
return `${i + 1}\n${start} --> ${end}\n${text}`;
})
.join('\n\n');
}
function downloadSRT() {
const srtBlob = new Blob([generateSRT()], { type: 'text/srt;charset=utf-8' });
const url = URL.createObjectURL(srtBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'captions.srt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function generateUUID() { function generateUUID() {
let dt = new Date().getTime(); let dt = new Date().getTime();
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (dt + Math.random() * 16) % 16 | 0; const r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16); dt = Math.floor(dt / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
}); });
return uuid;
} }
/** document.querySelectorAll('video, audio').forEach(el => {
* Resamples the audio data to a target sample rate of 16kHz. el.addEventListener('play', () => { isPaused = false; });
* @param {Array|ArrayBuffer|TypedArray} audioData - The input audio data. el.addEventListener('pause', () => { isPaused = true; });
* @param {number} [origSampleRate=44100] - The original sample rate of the audio data. });
* @returns {Float32Array} The resampled audio data at 16kHz.
*/
function resampleTo16kHZ(audioData, origSampleRate = 44100) {
// Convert the audio data to a Float32Array
const data = new Float32Array(audioData);
// Calculate the desired length of the resampled data
const targetLength = Math.round(data.length * (16000 / origSampleRate));
// Create a new Float32Array for the resampled data function setupMessageHandler() {
const resampledData = new Float32Array(targetLength); if (preNode) {
preNode.port.onmessage = e => {
const audio16k = e.data;
if (isCapturing && socket && socket.readyState === WebSocket.OPEN && !isPaused) {
socket.send(audio16k);
}
};
}
}
// Calculate the spring factor and initialize the first and last values
const springFactor = (data.length - 1) / (targetLength - 1);
resampledData[0] = data[0];
resampledData[targetLength - 1] = data[data.length - 1];
// Resample the audio data const WORKLET_URL = browser.runtime.getURL('audiopreprocessor.js');
for (let i = 1; i < targetLength - 1; i++) {
const index = i * springFactor; async function initAudioWorklet() {
const leftIndex = Math.floor(index).toFixed(); if (audioContext && preNode) {
const rightIndex = Math.ceil(index).toFixed(); setupMessageHandler();
const fraction = index - leftIndex; return;
resampledData[i] = data[leftIndex] + (data[rightIndex] - data[leftIndex]) * fraction; }
audioContext = new AudioContext();
await audioContext.audioWorklet.addModule(WORKLET_URL);
preNode = new AudioWorkletNode(audioContext, 'audiopreprocessor');
document.querySelectorAll('audio, video').forEach(el => {
let src;
try {
src = audioContext.createMediaElementSource(el);
} catch(e) {
console.warn('Could not create MediaElementSource for', el, e);
return;
}
src.connect(preNode);
src.connect(audioContext.destination);
});
preNode.connect(audioContext.destination);
setupMessageHandler();
}
async function startRecording(data) {
if (!audioContext) {
await initAudioWorklet();
} }
// Return the resampled data const uid = generateUUID();
return resampledData; socket = new WebSocket(`ws://${data.host}:${data.port}/`);
language = data.language;
socket.onopen = () => {
socket.send(JSON.stringify({
uid,
language: data.language,
task: data.task,
model: data.modelSize,
use_vad: data.useVad
}));
};
let serverReady = false;
socket.onmessage = async event => {
const msg = JSON.parse(event.data);
if (msg.uid !== uid) return;
if (msg.status === 'WAIT') {
await browser.runtime.sendMessage({ action: 'showPopup', data: msg.message });
return;
}
if (!serverReady && msg.message === 'SERVER_READY') {
serverReady = true;
return;
}
if (!language && msg.language) {
language = msg.language;
await browser.runtime.sendMessage({ action: 'updateSelectedLanguage', data: language });
return;
}
if (msg.message === 'DISCONNECT') {
await browser.runtime.sendMessage({ action: 'toggleCaptureButtons' });
return;
}
if (msg.segments) {
await browser.runtime.sendMessage({ action: 'transcript', data: {data: event.data, saveCaption: data.saveCaption} });
}
};
isCapturing = true;
} }
function startRecording(data) { function stopRecording() {
socket = new WebSocket(`ws://${data.host}:${data.port}/`); isCapturing = false;
language = data.language; if (socket) {
socket.close();
socket = null;
}
const uuid = generateUUID(); remove_element();
socket.onopen = function(e) {
socket.send(
JSON.stringify({
uid: uuid,
language: data.language,
task: data.task,
model: data.modelSize,
use_vad: data.useVad
})
);
};
let isServerReady = false;
socket.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data["uid"] !== uuid)
return;
if (data["status"] === "WAIT"){
await browser.runtime.sendMessage({ action: "showPopup", data: data["message"] })
return;
}
if (!isServerReady && data["message"] === "SERVER_READY"){
isServerReady = true;
return;
}
if (language === null ){
language = data["language"];
await browser.runtime.sendMessage({ action: "updateSelectedLanguage", data: language })
return
}
if (data["message"] === "DISCONNECT"){
await browser.runtime.sendMessage({ action: "toggleCaptureButtons", data: false })
return
}
await browser.runtime.sendMessage({ action: "transcript", data: event.data })
.catch(function(error) {
console.error("Error sending message:", error);
});
};
// Access the audio stream from the current tab
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function(stream) {
// Create a new MediaRecorder instance
const audioDataCache = [];
audioContext = new AudioContext();
mediaStream = audioContext.createMediaStreamSource(stream);
recorder = audioContext.createScriptProcessor(4096, 1, 1);
recorder.onaudioprocess = async (event) => {
if (!audioContext || !isCapturing || !isServerReady || isPaused) return;
const inputData = event.inputBuffer.getChannelData(0);
const audioData16kHz = resampleTo16kHZ(inputData, audioContext.sampleRate);
audioDataCache.push(inputData);
socket.send(audioData16kHz);
};
// Prevent page mute
mediaStream.connect(recorder);
recorder.connect(audioContext.destination);
})
} }
var elem_container = null; var elem_container = null;
var elem_text = null; var elem_text = null;
@@ -308,6 +326,8 @@ function remove_element() {
browser.runtime.onMessage.addListener((request, sender, sendResponse) => { browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
const { action, data } = request; const { action, data } = request;
const saveCaption = data.saveCaption || false;
if (action === "startCapture") { if (action === "startCapture") {
isCapturing = true; isCapturing = true;
startRecording(data); startRecording(data);
@@ -319,11 +339,19 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
socket = null; socket = null;
} }
if (audioContext) {
audioContext.close(); if (saveCaption === true) {
audioContext = null; if (lastIncompleteSegment && lastIncompleteSegment.text && lastIncompleteSegment.text.trim() !== "") {
mediaStream = null; if (allSegments.length === 0 || parseFloat(lastIncompleteSegment.start) >= parseFloat(allSegments[allSegments.length - 1].end)) {
recorder = null; allSegments.push({
start: lastIncompleteSegment.start,
end: lastIncompleteSegment.end,
text: lastIncompleteSegment.text
});
}
}
downloadSRT();
} }
remove_element(); remove_element();
@@ -337,9 +365,26 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
} else if (action === "show_transcript"){ } else if (action === "show_transcript"){
if (!isCapturing) return; if (!isCapturing) return;
init_element(); init_element();
message = JSON.parse(data); message = JSON.parse(data.data);
message = message["segments"]; message = message["segments"];
if (saveCaption === true) {
message.forEach(seg => {
if (seg.completed === true &&
(allSegments.length === 0 || parseFloat(seg.start) >= parseFloat(allSegments[allSegments.length - 1].end))) {
allSegments.push({
start: seg.start,
end: seg.end,
text: seg.text
});
lastIncompleteSegment = null;
} else if (seg.completed !== true) {
lastIncompleteSegment = seg;
}
});
}
var text = ''; var text = '';
for (var i = 0; i < message.length; i++) { for (var i = 0; i < message.length; i++) {
text += message[i].text + ' '; text += message[i].text + ' ';
@@ -8,6 +8,9 @@
"activeTab", "activeTab",
"<all_urls>" "<all_urls>"
], ],
"web_accessible_resources": [
"audiopreprocessor.js"
],
"background": { "background": {
"scripts": ["background.js"], "scripts": ["background.js"],
"persistent": false "persistent": false
+4
View File
@@ -19,6 +19,10 @@
<input type="checkbox" id="useVadCheckbox"> <input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label> <label for="useVadCheckbox">Use Voice Activity Detection</label>
</div> </div>
<div class="checkbox-container">
<input type="checkbox" id="saveCaptionCheckbox">
<label for="saveCaption">Download SRT file at Stop Capture</label>
</div>
<textarea id="waitTextBox" style="display: none;"></textarea> <textarea id="waitTextBox" style="display: none;"></textarea>
<div class="dropdown-container"> <div class="dropdown-container">
<label for="languageDropdown">Select Language:</label> <label for="languageDropdown">Select Language:</label>
+15 -1
View File
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", function() {
const useServerCheckbox = document.getElementById("useServerCheckbox"); const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox"); const useVadCheckbox = document.getElementById("useVadCheckbox");
const saveCaptionCheckbox = document.getElementById("saveCaptionCheckbox");
const languageDropdown = document.getElementById('languageDropdown'); const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown'); const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown'); const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -41,6 +42,12 @@ document.addEventListener("DOMContentLoaded", function() {
} }
}); });
browser.storage.local.get("saveCaptionState", ({ saveCaptionState }) => {
if (saveCaptionState !== undefined) {
saveCaptionCheckbox.checked = saveCaptionState;
}
});
browser.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => { browser.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) { if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage; languageDropdown.value = storedLanguage;
@@ -85,6 +92,7 @@ document.addEventListener("DOMContentLoaded", function() {
task: selectedTask, task: selectedTask,
modelSize: selectedModelSize, modelSize: selectedModelSize,
useVad: useVadCheckbox.checked, useVad: useVadCheckbox.checked,
saveCaption: saveCaptionCheckbox.checked,
} }
}); });
toggleCaptureButtons(true); toggleCaptureButtons(true);
@@ -101,7 +109,7 @@ document.addEventListener("DOMContentLoaded", function() {
stopButton.addEventListener("click", function() { stopButton.addEventListener("click", function() {
browser.tabs.query({ active: true, currentWindow: true }) browser.tabs.query({ active: true, currentWindow: true })
.then(function(tabs) { .then(function(tabs) {
browser.tabs.sendMessage(tabs[0].id, { action: "stopCapture" }) browser.tabs.sendMessage(tabs[0].id, { action: "stopCapture", data: {saveCaption: saveCaptionCheckbox.checked, } })
.then(function(response) { .then(function(response) {
toggleCaptureButtons(false); toggleCaptureButtons(false);
browser.storage.local.set({ capturingState: { isCapturing: false } }) browser.storage.local.set({ capturingState: { isCapturing: false } })
@@ -124,6 +132,7 @@ document.addEventListener("DOMContentLoaded", function() {
stopButton.disabled = !isCapturing; stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing; useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing; useVadCheckbox.disabled = isCapturing;
saveCaptionCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing; modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing; languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing; taskDropdown.disabled = isCapturing;
@@ -142,6 +151,11 @@ document.addEventListener("DOMContentLoaded", function() {
browser.storage.local.set({ useVadState }); browser.storage.local.set({ useVadState });
}); });
saveCaptionCheckbox.addEventListener("change", () => {
const saveCaptionState = saveCaptionCheckbox.checked;
browser.storage.local.set({ saveCaptionState });
});
languageDropdown.addEventListener('change', function() { languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") { if (languageDropdown.value === "") {
selectedLanguage = null; selectedLanguage = null;
+229
View File
@@ -0,0 +1,229 @@
// AudioStream.swift
// Lecture2Quiz
//
// Created by ParkMazorika on 4/27/25.
//
import AVFoundation
/// Streams audio input to a WebSocket after converting and normalizing.
class AudioStreamer {
private let engine = AVAudioEngine()
private let inputNode: AVAudioInputNode
private var inputFormat: AVAudioFormat?
private var isPaused: Bool = false
private var audioWebSocket: AudioWebSocket?
private var partialBuffer = Data()
private var isStreaming: Bool = false
private var bufferSize: AVAudioFrameCount = 1600 // ~100ms of audio
private var sampleRate: Double = 16000
private var channels: UInt32 = 1
private var converter: AVAudioConverter?
init(webSocket: AudioWebSocket) {
self.inputNode = engine.inputNode
self.audioWebSocket = webSocket
let inputFormat = inputNode.outputFormat(forBus: 0)
print("Input format: \(inputFormat)")
let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: true
)!
self.converter = AVAudioConverter(from: inputFormat, to: outputFormat)
self.inputFormat = outputFormat
}
/// Configures the audio session for recording.
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker])
try session.setPreferredSampleRate(48000)
try session.setPreferredInputNumberOfChannels(1)
try session.setMode(.videoChat)
try session.setActive(true, options: .notifyOthersOnDeactivation)
sampleRate = session.sampleRate
channels = UInt32(session.inputNumberOfChannels)
print("Sample rate: \(sampleRate)")
print("Input channels: \(channels)")
} catch {
print("Failed to configure audio session: \(error.localizedDescription)")
}
}
/// Starts capturing and streaming audio data.
func startStreaming() {
guard !isStreaming else {
print("Already streaming.")
return
}
configureAudioSession()
let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 48000,
channels: channels,
interleaved: true
)
guard let hardwareFormat = format else {
print("Failed to create audio format.")
return
}
self.inputFormat = hardwareFormat
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
do {
try engine.start()
isStreaming = true
print("AVAudioEngine started.")
} catch {
print("Failed to start AVAudioEngine: \(error.localizedDescription)")
}
}
/// Converts and sends the audio buffer to the server via WebSocket.
func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
guard let converter = self.converter else {
print("Audio converter is nil.")
return
}
if let floatChannelData = buffer.floatChannelData {
let frameLength = Int(buffer.frameLength)
let channelData = Array(UnsafeBufferPointer(start: floatChannelData.pointee, count: frameLength))
let rms = sqrt(channelData.map { $0 * $0 }.reduce(0, +) / Float(frameLength))
print("Audio RMS: \(rms)")
if rms < 0.001 {
print("Warning: Input volume is too low.")
}
}
let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: true
)!
guard let newBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: 1600) else {
print("Failed to allocate PCM buffer.")
return
}
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
var error: NSError?
converter.convert(to: newBuffer, error: &error, withInputFrom: inputBlock)
if let error = error {
print("Audio conversion failed: \(error.localizedDescription)")
return
}
print("Converted buffer frameLength: \(newBuffer.frameLength), sampleRate: \(newBuffer.format.sampleRate)")
if let audioData = convertToFloat32BytesLikePython(newBuffer) {
var completeData = partialBuffer + audioData
let chunkSize = 4096
while completeData.count >= chunkSize {
let chunk = completeData.prefix(chunkSize)
audioWebSocket?.sendDataToServer(chunk)
print("Sent 4096 bytes of audio.")
completeData.removeFirst(chunkSize)
}
partialBuffer = completeData
}
}
/// Converts the audio buffer to Float32 Data with RMS normalization and soft clipping.
func convertToFloat32BytesLikePython(_ buffer: AVAudioPCMBuffer) -> Data? {
guard let int16ChannelData = buffer.int16ChannelData else {
print("int16ChannelData is nil.")
return nil
}
let frameLength = Int(buffer.frameLength)
let channelPointer = int16ChannelData.pointee
var floatArray = [Float32](repeating: 0, count: frameLength)
for i in 0..<frameLength {
let int16Value = channelPointer[i]
floatArray[i] = Float32(Int16(littleEndian: int16Value)) / 32768.0
}
let rms = sqrt(floatArray.map { $0 * $0 }.reduce(0, +) / Float(frameLength))
let targetRMS: Float32 = 0.25
let gain = targetRMS / max(rms, 0.00001)
print("Original RMS: \(rms), applied gain: \(gain)")
for i in 0..<frameLength {
let scaled = floatArray[i] * gain
let clipped = tanh(scaled * 3.0)
floatArray[i] = clipped
}
let floatData = Data(bytes: floatArray, count: frameLength * MemoryLayout<Float32>.size)
if let minVal = floatArray.min(), let maxVal = floatArray.max() {
print("Float32 value range after normalization: \(minVal)...\(maxVal)")
}
print("Converted to Float32 data: \(floatData.count) bytes")
return floatData
}
/// Pauses audio streaming by removing the input tap.
func pauseStreaming() {
guard !isPaused else { return }
inputNode.removeTap(onBus: 0)
isPaused = true
print("Audio streaming paused.")
}
/// Resumes audio streaming by reinstalling the input tap.
func resumeStreaming() {
guard isPaused else { return }
guard let inputFormat = inputFormat else {
print("inputFormat is nil.")
return
}
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
isPaused = false
print("Audio streaming resumed.")
}
/// Stops the AVAudioEngine and resets streaming state.
func stopStreaming() {
guard isStreaming else {
print("Already stopped.")
return
}
inputNode.removeTap(onBus: 0)
engine.stop()
isStreaming = false
print("AVAudioEngine stopped.")
}
}
@@ -0,0 +1,256 @@
//
// RecordingViewModel.swift
// Lecture2Quiz
//
// Created by ParkMazorika on 4/27/25.
//
import Foundation
/// WebSocket client that connects to a transcription server and handles streaming, JSON messages, and retries.
class AudioWebSocket: NSObject, URLSessionWebSocketDelegate {
private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession!
private let host: String
private let port: Int
private var retryCount = 0
private let maxRetries = 3
private var uid: String
private let modelSize: String
private var pingTimer: Timer?
private var processedTexts = Set<String>()
var onServerReady: (() -> Void)?
var onTranscriptionReceived: ((String) -> Void)?
init(host: String, port: Int, modelSize: String = "medium") {
self.host = host
self.port = port
self.uid = UUID().uuidString
self.modelSize = modelSize
super.init()
self.urlSession = URLSession(
configuration: .default,
delegate: self,
delegateQueue: .main
)
connect()
}
/// Establishes a WebSocket connection with the configured server.
private func connect() {
guard retryCount <= maxRetries else {
print("Maximum reconnect attempts exceeded.")
return
}
let socketURL = port == 443 || port == 80
? "wss://\(host)"
: "wss://\(host):\(port)"
guard let url = URL(string: socketURL) else {
print("Invalid URL: \(socketURL)")
return
}
webSocketTask = urlSession.webSocketTask(with: url)
webSocketTask?.resume()
print("Attempting WebSocket connection: \(socketURL)")
listen()
sendInitialJSON()
startPing()
}
/// Sends the initial JSON payload to identify and configure the session.
private func sendInitialJSON() {
let jsonPayload: [String: Any] = [
"uid": uid,
"language": "en",
"task": "transcribe",
"model": modelSize,
"use_vad": true,
"max_clients": 4,
"max_connection_time": 600
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: jsonPayload, options: [])
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
print("Sending config JSON: \(jsonString)")
webSocketTask?.send(.string(jsonString)) { [weak self] error in
if let error = error {
print("Failed to send config JSON: \(error.localizedDescription)")
self?.reconnect()
} else {
print("Config JSON sent successfully.")
}
}
} catch {
print("JSON serialization error: \(error.localizedDescription)")
}
}
/// Sends audio data to the server.
func sendDataToServer(_ data: Data) {
guard isConnected else {
print("Not connected - skipping data send.")
reconnect()
return
}
webSocketTask?.send(.data(data)) { [weak self] error in
if let error = error {
print("Failed to send audio data: \(error.localizedDescription)")
self?.reconnect()
} else {
print("Sent audio data: \(data.count) bytes")
}
}
}
/// Returns true if the WebSocket is currently connected.
internal var isConnected: Bool {
webSocketTask?.state == .running
}
/// Attempts reconnection with exponential backoff.
private func reconnect() {
retryCount += 1
stopPing()
let delay = min(5.0, pow(2.0, Double(retryCount)))
DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
print("Reconnecting... (\(self?.retryCount ?? 0)/\(self?.maxRetries ?? 0))")
self?.connect()
}
}
/// Starts listening for incoming messages from the server.
private func listen() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
self?.handleMessage(message)
self?.listen()
case .failure(let error):
print("Receive error: \(error.localizedDescription)")
self?.reconnect()
}
}
}
/// Handles incoming WebSocket messages (text or binary).
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .data(let data):
print("Received binary data: \(data.count) bytes")
case .string(let text):
print("Received text message: \(text)")
guard let data = text.data(using: .utf8) else { return }
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let status = json["status"] as? String {
handleStatusMessage(status: status, message: json["message"] as? String)
return
}
if let message = json["message"] as? String, message == "SERVER_READY" {
print("Server is ready.")
onServerReady?()
return
}
if let segments = json["segments"] as? [[String: Any]] {
let wrapped = ["segments": segments]
let segmentData = try JSONSerialization.data(withJSONObject: wrapped, options: [])
let segmentString = String(data: segmentData, encoding: .utf8)!
onTranscriptionReceived?(segmentString)
print("Transcription segments forwarded.")
}
}
} catch {
print("JSON parsing error: \(error.localizedDescription)")
}
@unknown default:
print("Unknown message type received.")
}
}
/// Handles status message JSON from the server.
private func handleStatusMessage(status: String, message: String?) {
switch status {
case "WAIT":
print("Waiting: \(message ?? "")")
case "ERROR":
print("Error: \(message ?? "")")
case "WARNING":
print("Warning: \(message ?? "")")
default:
print("\(status): \(message ?? "")")
}
}
/// Sends the "END_OF_AUDIO" signal to the server.
func sendEndOfAudio() {
guard isConnected else {
print("Not connected - skipping END_OF_AUDIO.")
return
}
webSocketTask?.send(.string("END_OF_AUDIO")) { error in
if let error = error {
print("Failed to send END_OF_AUDIO: \(error.localizedDescription)")
} else {
print("END_OF_AUDIO sent.")
}
}
}
/// Gracefully closes the WebSocket connection.
func closeConnection() {
stopPing()
webSocketTask?.cancel(with: .normalClosure, reason: nil)
retryCount = maxRetries
print("WebSocket closed.")
}
/// Starts periodic ping to keep the WebSocket alive.
private func startPing() {
stopPing()
pingTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
self?.webSocketTask?.sendPing { error in
if let error = error {
print("Ping failed: \(error.localizedDescription)")
} else {
print("Ping sent successfully.")
}
}
}
RunLoop.main.add(pingTimer!, forMode: .common)
}
/// Stops the periodic ping timer.
private func stopPing() {
pingTimer?.invalidate()
pingTimer = nil
}
/// Called when the WebSocket is closed by the server.
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?) {
let reasonString = String(data: reason ?? Data(), encoding: .utf8) ?? "No reason"
print("WebSocket closed - code: \(closeCode.rawValue), reason: \(reasonString)")
stopPing()
reconnect()
}
}
+99
View File
@@ -0,0 +1,99 @@
//
// ContentView.swift
// WhisperLive_iOS_Client
//
// Created by ParkMazorika on 6/17/25.
//
import SwiftUI
/// A standalone view for recording and real-time transcription display.
struct RecordingView: View {
var onDismiss: () -> Void
@StateObject private var recordingViewModel = AudioViewModel()
@State private var showSubmitView = false
var body: some View {
VStack(spacing: 0) {
// Stop button (only visible when recording)
HStack {
Spacer()
if recordingViewModel.isRecording {
Button("Stop Recording") {
recordingViewModel.stopRecording()
recordingViewModel.finalizeTranscription()
showSubmitView = true
}
.font(.headline)
.padding()
.foregroundColor(.gray)
}
}
// Transcription display
ScrollView {
VStack(spacing: 8) {
ForEach(recordingViewModel.transcriptionList.indices, id: \.self) { index in
Text(recordingViewModel.transcriptionList[index])
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.font(.system(size: 14, weight: .semibold))
}
}
.padding(.horizontal)
}
Divider().padding(.top, 8)
// Timer and Record/Pause/Resume button
VStack(spacing: 16) {
Text(recordingViewModel.timeLabel)
.font(.system(size: 40))
Button(action: {
if recordingViewModel.isRecording {
recordingViewModel.isPaused
? recordingViewModel.resumeRecording()
: recordingViewModel.pauseRecording()
} else {
recordingViewModel.startRecording()
}
}) {
Image(systemName: recordingViewModel.isRecording
? (recordingViewModel.isPaused ? "play.circle.fill" : "pause.circle.fill")
: "mic.circle.fill")
.font(.system(size: 50))
.foregroundStyle(.black)
}
}
.padding(.bottom, 40)
}
.padding(.top)
.background(Color(.systemBackground))
.overlay(
Group {
if recordingViewModel.isLoading {
ZStack {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView("Processing...")
.padding()
.background(Color.white)
.cornerRadius(10)
}
}
}
)
.sheet(isPresented: $showSubmitView) {
//anotherView
}
}
}
#Preview("Recording View") {
RecordingView {
// Dummy dismiss handler
print("RecordingView dismissed")
}
}
+109
View File
@@ -0,0 +1,109 @@
# Audio-Transcription-iOS
This is an iOS client for [WhisperLive](https://github.com/collabora/WhisperLive), a real-time speech-to-text server based on OpenAI Whisper.
The app streams microphone audio to a WhisperLive server via WebSocket and displays live transcription results in real time.
> ⚠️ This client is designed to work specifically with the [WhisperLive Python WebSocket server](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server).
> Make sure the server is running and reachable from your iOS device.
## Features
- Real-time microphone capture with AVAudioEngine
- Streaming to WhisperLive backend using WebSocket
- Displays transcription as segments arrive
- Start / Pause / Resume / Stop recording with SwiftUI interface
- Final transcription view on stop
## Requirements
- iOS 15.0+
- Swift 5.8+
- AVFoundation (for microphone)
- Working WhisperLive WebSocket server
## Getting Started
1. Clone the repository (your fork):
```bash
git clone https://github.com/yourusername/whisperlive.git
cd whisperlive/Audio-Transcription-iOS
```
2. Open the `.xcodeproj` or `.xcodeworkspace` in Xcode
3. Add the following to your `Info.plist`:
```xml
<key>NSMicrophoneUsageDescription</key>
<string>This app requires microphone access for transcription.</string>
```
4. Run the app on a physical device (recommended)
## Running on a Physical Device (with Free Apple ID)
You can run this app on a real iPhone without a paid Apple Developer account. Follow these steps:
### 1. Register a Free Apple ID in Xcode
1. Open Xcode ▸ Settings… (or Preferences) ▸ **Accounts**
2. Click the **+** button ▸ Select **Apple ID**
3. Sign in with your Apple ID (a free one is fine)
4. A "Personal Team" will be created automatically
> ✅ You can deploy up to 3 apps on a physical device using a free Apple ID with a 7-day provisioning profile.
---
### 2. Set Up Signing in Your Project
1. In Xcode, select your **project** in the Project Navigator
2. Go to **TARGETS ▸ YourAppName ▸ Signing & Capabilities**
3. Set **Team** to your Personal Team
4. Set a unique **Bundle Identifier** (e.g., `com.yourname.whisperlive`)
5. Make sure **Automatically manage signing** is checked
6. If a red warning appears, click **"Resolve Issues"**
---
### 3. Connect and Trust Your iPhone
1. Connect your iPhone via USB
2. When prompted, tap **“Trust This Computer”** on your iPhone
3. Make sure your iPhone appears in Xcode's device list
---
### 4. Enable Developer Mode on iPhone
1. Press the **Build (▶︎)** button in Xcode
2. Your iPhone will ask to enable **Developer Mode**
3. On iPhone, go to:
**Settings ▸ Privacy & Security ▸ Developer Mode**
4. Enable it and restart the device if required
---
Now you can run and debug the app on your real device!
## Folder Structure
```
Audio-Transcription-iOS/
├── AudioViewModel.swift
├── AudioStreamer.swift
├── AudioWebSocket.swift
├── RecordingView.swift
├── WhisperLive_iOS_ClientApp.swift
├── Info.plist
├── README.md
```
## License
MIT
This iOS client is provided as an open-source example to complement WhisperLive's real-time transcription ecosystem.
@@ -0,0 +1,174 @@
//
// RecordingViewModel.swift
// Lecture2Quiz
//
// Created by ParkMazorika on 4/27/25.
//
import AVFoundation
import Combine
/// Represents a segment of transcribed audio with start/end timestamps and completion flag.
struct TranscriptionSegment: Identifiable, Equatable {
var id = UUID()
var start: Double
var end: Double
var text: String
var completed: Bool
}
/// ViewModel responsible for managing audio recording and transcription logic.
class AudioViewModel: ObservableObject {
@Published var isRecording = false // Indicates if recording is active
@Published var isPaused = false // Indicates if recording is currently paused
@Published var timeLabel = "00:00" // Timer label formatted as mm:ss
@Published var transcriptionList: [String] = [] // Live transcription output
@Published var isLoading = false // True while waiting for server response
@Published var finalScript: String = "" // Final script from completed segments
private var timer: Timer?
private var elapsedTime: Int = 0
private var audioStreamer: AudioStreamer? // Handles audio capture and streaming
private var audioWebSocket: AudioWebSocket? // Manages WebSocket communication
private var segments: [TranscriptionSegment] = [] // Stores all transcription segments
init() {}
/// Starts audio recording and initializes WebSocket + AVAudioEngine.
func startRecording() {
let audioAPIUrl = "your server url"
audioWebSocket = AudioWebSocket(host: audioAPIUrl, port: 443)
audioStreamer = AudioStreamer(webSocket: audioWebSocket!)
isLoading = true
// Handle server transcription message
audioWebSocket?.onTranscriptionReceived = { [weak self] text in
self?.handleRawTranscriptionJSON(text)
}
// When server sends SERVER_READY
audioWebSocket?.onServerReady = { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.isLoading = false
self.isRecording = true
self.isPaused = false
self.timeLabel = "00:00"
self.elapsedTime = 0
self.startTimer()
self.audioStreamer?.startStreaming()
}
}
}
/// Pauses the recording and stops the timer.
func pauseRecording() {
isPaused = true
audioStreamer?.pauseStreaming()
timer?.invalidate()
}
/// Resumes recording and restarts the timer.
func resumeRecording() {
isPaused = false
audioStreamer?.resumeStreaming()
startTimer()
}
/// Stops recording and finalizes connection to server.
func stopRecording() {
isRecording = false
isPaused = false
timer?.invalidate()
audioStreamer?.stopStreaming()
audioWebSocket?.sendEndOfAudio()
audioWebSocket?.onTranscriptionReceived = nil
audioWebSocket?.closeConnection()
}
/// Starts the recording timer (1-second interval).
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.elapsedTime += 1
let minutes = self.elapsedTime / 60
let seconds = self.elapsedTime % 60
self.timeLabel = String(format: "%02d:%02d", minutes, seconds)
}
}
/// Finalizes the transcription by joining all completed segments into one string.
func finalizeTranscription() {
isLoading = false
let completedText = segments
.filter { $0.completed }
.map { $0.text.trimmingCharacters(in: .whitespaces) }
.joined(separator: " ")
finalScript = completedText
print("Final transcript:\n\(finalScript)")
}
/// Handles incoming JSON from the server and updates UI state.
/// Supports both full JSON and raw string cases.
func handleRawTranscriptionJSON(_ jsonString: String) {
let trimmed = jsonString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = trimmed.data(using: .utf8) else { return }
if trimmed.hasPrefix("{") {
// Parse JSON containing segment list
do {
if let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let segmentDicts = dict["segments"] as? [[String: Any]] {
for item in segmentDicts {
guard let startStr = item["start"] as? String,
let endStr = item["end"] as? String,
let text = item["text"] as? String,
let completed = item["completed"] as? Bool,
let start = Double(startStr),
let end = Double(endStr) else { continue }
let newSegment = TranscriptionSegment(start: start, end: end, text: text, completed: completed)
// Overwrite if already exists, else append
if let index = self.segments.firstIndex(where: { $0.start == start }) {
self.segments[index] = newSegment
} else {
self.segments.append(newSegment)
}
}
// Update the UI
DispatchQueue.main.async {
let completedTexts = self.segments
.filter { $0.completed }
.sorted(by: { $0.start < $1.start })
.map { $0.text.trimmingCharacters(in: .whitespaces) }
let pendingText = self.segments
.filter { !$0.completed }
.sorted(by: { $0.start < $1.start })
.map { $0.text.trimmingCharacters(in: .whitespaces) }
.last ?? ""
self.transcriptionList = completedTexts + (pendingText.isEmpty ? [] : [pendingText])
self.finalScript = self.transcriptionList.joined(separator: " ")
}
}
} catch {
print("JSON parsing error: \(error)")
}
} else {
// Handle raw text line
DispatchQueue.main.async {
if self.transcriptionList.last != trimmed {
self.transcriptionList.append(trimmed)
self.finalScript = self.transcriptionList.joined(separator: " ")
}
}
}
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires microphone access for voice transcription.</string>
</dict>
</plist>
@@ -0,0 +1,20 @@
//
// WhisperLive_iOS_ClientApp.swift
// WhisperLive_iOS_Client
//
// Created by on 6/17/25.
//
import SwiftUI
@main
struct WhisperLive_iOS_ClientApp: App {
var body: some Scene {
WindowGroup {
RecordingView {
// Handle dismiss action here, or leave it empty for now
print("RecordingView dismissed")
}
}
}
}
+73 -14
View File
@@ -3,6 +3,8 @@
<h2 align="center"> <h2 align="center">
<a href="https://www.youtube.com/watch?v=0PHWCApIcCI"><img <a href="https://www.youtube.com/watch?v=0PHWCApIcCI"><img
src="https://img.youtube.com/vi/0PHWCApIcCI/0.jpg" style="background-color:rgba(0,0,0,0);" height=300 alt="WhisperLive"></a> src="https://img.youtube.com/vi/0PHWCApIcCI/0.jpg" style="background-color:rgba(0,0,0,0);" height=300 alt="WhisperLive"></a>
<a href="https://www.youtube.com/watch?v=0f5oiG4oPWQ"><img
src="https://img.youtube.com/vi/0f5oiG4oPWQ/0.jpg" style="background-color:rgba(0,0,0,0);" height=300 alt="WhisperLive"></a>
<br><br>A nearly-live implementation of OpenAI's Whisper. <br><br>A nearly-live implementation of OpenAI's Whisper.
<br><br> <br><br>
</h2> </h2>
@@ -18,11 +20,12 @@ input from microphone and pre-recorded audio files.
- [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)
- [Future Work](#future-work) - [Future Work](#future-work)
- [Blog Posts](#blog-posts)
- [Contact](#contact) - [Contact](#contact)
- [Citations](#citations) - [Citations](#citations)
## Installation ## Installation
- Install PyAudio - Install PortAudio
```bash ```bash
bash scripts/setup.sh bash scripts/setup.sh
``` ```
@@ -32,6 +35,32 @@ input from microphone and pre-recorded audio files.
pip install whisper-live pip install whisper-live
``` ```
- Install 3.12 venv on Fedora
```bash
sudo dnf install -y python3.12 python3.12-pip
python3.12 -m venv whisper_env
source whisper_env/bin/activate
```
### OpenAI REST interface
#### Server
```bash
python3 run_server.py --port 9090 --backend faster_whisper --max_clients 4 --max_connection_time 600 --enable_rest --cors-origins="http://localhost:8080,http://127.0.0.1:8080"
```
#### Client
```bash
python3 client_openai.py $AUDIO_FILE
```
### Setting up NVIDIA/TensorRT-LLM for TensorRT backend ### Setting up NVIDIA/TensorRT-LLM for TensorRT backend
- Please follow [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) for setup of [NVIDIA/TensorRT-LLM](https://github.com/NVIDIA/TensorRT-LLM) and for building Whisper-TensorRT engine. - Please follow [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) for setup of [NVIDIA/TensorRT-LLM](https://github.com/NVIDIA/TensorRT-LLM) and for building Whisper-TensorRT engine.
@@ -42,12 +71,17 @@ The server supports 3 backends `faster_whisper`, `tensorrt` and `openvino`. If r
- [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) backend - [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) backend
```bash ```bash
python3 run_server.py --port 9090 \ python3 run_server.py --port 9090 \
--backend faster_whisper --backend faster_whisper \
--max_clients 4 \
--max_connection_time 600
# running with custom model # running with custom model and cache_dir to save auto-converted ctranslate2 models
python3 run_server.py --port 9090 \ python3 run_server.py --port 9090 \
--backend faster_whisper \ --backend faster_whisper \
-fw "/path/to/custom/faster/whisper/model" --max_clients 4 \
--max_connection_time 600 \
-fw "/path/to/custom/faster/whisper/model" \
-c ~/.cache/whisper-live/
``` ```
- TensorRT backend. Currently, we recommend to only use the docker setup for TensorRT. Follow [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) which works as expected. Make sure to build your TensorRT Engines before running the server with TensorRT backend. - TensorRT backend. Currently, we recommend to only use the docker setup for TensorRT. Follow [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) which works as expected. Make sure to build your TensorRT Engines before running the server with TensorRT backend.
@@ -55,15 +89,20 @@ python3 run_server.py --port 9090 \
# Run English only model # Run English only model
python3 run_server.py -p 9090 \ python3 run_server.py -p 9090 \
-b tensorrt \ -b tensorrt \
-trt /home/TensorRT-LLM/examples/whisper/whisper_small_en -trt /home/TensorRT-LLM/examples/whisper/whisper_small_en \
--max_clients 4 \
--max_connection_time 600
# Run Multilingual model # Run Multilingual model
python3 run_server.py -p 9090 \ python3 run_server.py -p 9090 \
-b tensorrt \ -b tensorrt \
-trt /home/TensorRT-LLM/examples/whisper/whisper_small \ -trt /home/TensorRT-LLM/examples/whisper/whisper_small \
-m -m \
--max_clients 4 \
--max_connection_time 600
``` ```
- Use `--max_clients` option to restrict the number of clients the server should allow. Defaults to 4.
- Use `--max_connection_time` options to limit connection time for a client in seconds. Defaults to 600.
- WhisperLive now supports the [OpenVINO](https://github.com/openvinotoolkit/openvino) backend for efficient inference on Intel CPUs, iGPU and dGPUs. Currently, we tested the models uploaded to [huggingface by OpenVINO](https://huggingface.co/OpenVINO?search_models=whisper). - WhisperLive now supports the [OpenVINO](https://github.com/openvinotoolkit/openvino) backend for efficient inference on Intel CPUs, iGPU and dGPUs. Currently, we tested the models uploaded to [huggingface by OpenVINO](https://huggingface.co/OpenVINO?search_models=whisper).
- > **Docker Recommended:** Running WhisperLive with OpenVINO inside Docker automatically enables GPU support (iGPU/dGPU) without requiring additional host setup. - > **Docker Recommended:** Running WhisperLive with OpenVINO inside Docker automatically enables GPU support (iGPU/dGPU) without requiring additional host setup.
- > **Native (non-Docker) Use:** If you prefer running outside Docker, ensure the Intel drivers and OpenVINO runtime are installed and properly configured on your system. Refer to the documentation for [installing OpenVINO](https://docs.openvino.ai/2025/get-started/install-openvino.html?PACKAGE=OPENVINO_BASE&VERSION=v_2025_0_0&OP_SYSTEM=LINUX&DISTRIBUTION=PIP#). - > **Native (non-Docker) Use:** If you prefer running outside Docker, ensure the Intel drivers and OpenVINO runtime are installed and properly configured on your system. Refer to the documentation for [installing OpenVINO](https://docs.openvino.ai/2025/get-started/install-openvino.html?PACKAGE=OPENVINO_BASE&VERSION=v_2025_0_0&OP_SYSTEM=LINUX&DISTRIBUTION=PIP#).
@@ -90,16 +129,24 @@ If you don't want this, set `--no_single_model`.
### Running the Client ### Running the Client
- Initializing the client with below parameters:
Use the below command to run the client:
```bash
python3 run_client.py --files <audio-file-name>
```
This will connect to the localhost server running on port 9090 by default. Use flags `--server` and `--port` to use different configurations. The above command will transcribe audio file provided with `--files` flag.
Here are the details of client instance implemented in `run_client.py` script:
- `lang`: Language of the input audio, applicable only if using a multilingual model. - `lang`: Language of the input audio, applicable only if using a multilingual model.
- `translate`: If set to `True` then translate from any language to `en`. - `translate`: If set to `True` then translate from any language to `en`.
- `model`: Whisper model size. - `model`: Whisper model size.
- `use_vad`: Whether to use `Voice Activity Detection` on the server. - `use_vad`: Whether to use `Voice Activity Detection` on the server.
- `save_output_recording`: Set to True to save the microphone input as a `.wav` file during live transcription. This option is helpful for recording sessions for later playback or analysis. Defaults to `False`. - `save_output_recording`: Set to True to save the microphone input as a `.wav` file during live transcription. This option is helpful for recording sessions for later playback or analysis. Defaults to `False`.
- `output_recording_filename`: Specifies the `.wav` file path where the microphone input will be saved if `save_output_recording` is set to `True`. - `output_recording_filename`: Specifies the `.wav` file path where the microphone input will be saved if `save_output_recording` is set to `True`.
- `max_clients`: Specifies the maximum number of clients the server should allow. Defaults to 4.
- `max_connection_time`: Maximum connection time for each client in seconds. Defaults to 600.
- `mute_audio_playback`: Whether to mute audio playback when transcribing an audio file. Defaults to False. - `mute_audio_playback`: Whether to mute audio playback when transcribing an audio file. Defaults to False.
- `enable_translation`: Start translation thread on the server (from any to any).
- `target_language`: Server translation thread's target translation language.
```python ```python
from whisper_live.client import TranscriptionClient from whisper_live.client import TranscriptionClient
@@ -112,9 +159,9 @@ client = TranscriptionClient(
use_vad=False, use_vad=False,
save_output_recording=True, # Only used for microphone input, False by Default save_output_recording=True, # Only used for microphone input, False by Default
output_recording_filename="./output_recording.wav", # Only used for microphone input output_recording_filename="./output_recording.wav", # Only used for microphone input
max_clients=4,
max_connection_time=600,
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,
target_language="hi",
) )
``` ```
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.
@@ -143,6 +190,12 @@ client(hls_url="http://as-hls-ww-live.akamaized.net/pool_904/live/ww/bbc_1xtra/b
- Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server). - Run the server with your desired backend as shown [here](https://github.com/collabora/WhisperLive?tab=readme-ov-file#running-the-server).
- Transcribe audio directly from your browser using our Chrome or Firefox extensions. Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) and https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md - Transcribe audio directly from your browser using our Chrome or Firefox extensions. Refer to [Audio-Transcription-Chrome](https://github.com/collabora/whisper-live/tree/main/Audio-Transcription-Chrome#readme) and https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md
## iOS Client
Use WhisperLive on iOS with our native iOS client.
Refer to [`ios-client`](https://github.com/collabora/WhisperLive/tree/main/Audio-Transcription-iOS) and [`ios-client/README.md`](https://github.com/collabora/WhisperLive/blob/main/Audio-Transcription-iOS/README.md) for setup and usage instructions.
## Whisper Live Server in Docker ## Whisper Live Server in Docker
- GPU - GPU
- Faster-Whisper - Faster-Whisper
@@ -153,7 +206,7 @@ client(hls_url="http://as-hls-ww-live.akamaized.net/pool_904/live/ww/bbc_1xtra/b
- TensorRT. Refer to [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) for setup and more tensorrt backend configurations. - TensorRT. Refer to [TensorRT_whisper readme](https://github.com/collabora/WhisperLive/blob/main/TensorRT_whisper.md) for setup and more tensorrt backend configurations.
```bash ```bash
docker build . -f docker/Dockerfile.tensorrt -t whisperlive-tensorrt docker build . -f docker/Dockerfile.tensorrt -t whisperlive-tensorrt
docker run -p 9090:9090 --runtime=nvidia --gpus all --entrypoint /bin/bash -it whisperlive-tensorrt docker run -p 9090:9090 --runtime=nvidia --entrypoint /bin/bash -it whisperlive-tensorrt
# Build small.en engine # Build small.en engine
bash build_whisper_tensorrt.sh /app/TensorRT-LLM-examples small.en # float16 bash build_whisper_tensorrt.sh /app/TensorRT-LLM-examples small.en # float16
@@ -180,12 +233,18 @@ client(hls_url="http://as-hls-ww-live.akamaized.net/pool_904/live/ww/bbc_1xtra/b
``` ```
## Future Work ## Future Work
- [ ] Add translation to other languages on top of transcription. - [x] Add translation to other languages on top of transcription.
## Blog Posts
- [Transforming speech technology with WhisperLive](https://www.collabora.com/news-and-blog/blog/2024/05/28/transforming-speech-technology-with-whisperlive/)
- [WhisperFusion: Ultra-low latency conversations with an AI chatbot](https://www.collabora.com/news-and-blog/news-and-events/whisperfusion-ultra-low-latency-conversations-with-an-ai-chatbot.html) powered by WhisperLive
- [Breaking language barriers 2.0: Moving closer towards fully reliable, production-ready Hindi ASR](https://www.collabora.com/news-and-blog/news-and-events/breaking-language-barriers-20-moving-closer-production-ready-hindi-asr.html) which is used in WhisperLive for hindi.
## Contact ## Contact
We are available to help you with both Open Source and proprietary AI projects. You can reach us via the Collabora website or [vineet.suryan@collabora.com](mailto:vineet.suryan@collabora.com) and [marcus.edel@collabora.com](mailto:marcus.edel@collabora.com). We are available to help you with both Open Source and proprietary AI projects. You can reach us via the Collabora website or [vineet.suryan@collabora.com](mailto:vineet.suryan@collabora.com) and [marcus.edel@collabora.com](mailto:marcus.edel@collabora.com).
## Citations ## Citations
```bibtex ```bibtex
@article{Whisper @article{Whisper
+38
View File
@@ -0,0 +1,38 @@
import sys
import requests
if len(sys.argv) < 2:
print("Usage: python transcribe_file.py <path_to_audio_file>")
sys.exit(1)
audio_file = sys.argv[1]
# Configuration
host = "localhost"
port = 8000 # Default REST port; change if you used --rest_port
url = f"http://{host}:{port}/v1/audio/transcriptions"
model = "small" # Or "whisper-1" (mapped to small internally)
language = "en" # Or "hi" for Hindi
response_format = "json" # Options: "json", "text", "verbose_json", "srt", "vtt"
# Prepare the request
files = {"file": open(audio_file, "rb")}
data = {
"model": model,
"language": language,
"response_format": response_format,
# Optional: Add "prompt" for style guidance, "temperature" (0-1), etc.
}
# Send the request
response = requests.post(url, files=files, data=data)
if response.status_code == 200:
if response_format == "json" or response_format == "verbose_json":
result = response.json()
print("Transcript:", result.get("text", "No text found"))
# If you need translation, post-process here (e.g., using another API like Google Translate)
else:
print("Transcript:", response.text)
else:
print("Error:", response.status_code, response.json().get("error", "Unknown error"))
+8 -2
View File
@@ -1,4 +1,4 @@
faster-whisper==1.1.0 faster-whisper==1.2.0
websockets websockets
onnxruntime==1.17.0 onnxruntime==1.17.0
numba numba
@@ -9,8 +9,10 @@ av
jiwer jiwer
evaluate evaluate
numpy<2 numpy<2
openai-whisper==20240930 openai-whisper==20250625
tokenizers==0.20.3 tokenizers==0.20.3
transformers[torch]
sentencepiece
# openvino # openvino
librosa librosa
@@ -19,3 +21,7 @@ openvino-genai
openvino-tokenizers openvino-tokenizers
optimum optimum
optimum-intel optimum-intel
fastapi
uvicorn
python-multipart
+98
View File
@@ -0,0 +1,98 @@
from pathlib import Path
import sys
from whisper_live.client import TranscriptionClient
import argparse
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--port', '-p',
type=int,
default=9090,
help="Websocket port to run the server on.")
parser.add_argument('--server', '-s',
type=str,
default='localhost',
help='hostname or ip address of server')
parser.add_argument('--files', '-f',
type=str,
nargs='+',
help='Files to transcribe, separated by spaces. '
'If not provided, will use microphone input.')
parser.add_argument('--output_file', '-o',
type=str,
default='./output_recording.wav',
help='output recording filename, only used for microphone input.')
parser.add_argument('--model', '-m',
type=str,
default='small',
help='Model to use for transcription, e.g., "tiny, small.en, large-v3".')
parser.add_argument('--lang', '-l',
type=str,
default='en',
help='Language code for transcription, e.g., "en" for English.')
parser.add_argument('--translate', '-t',
action='store_true',
help='Enable translation of the transcription output.')
parser.add_argument('--mute_audio_playback', '-a',
action='store_true',
help='Mute audio playback during transcription.')
parser.add_argument('--save_output_recording', '-r',
action='store_true',
help='Save the output recording, only used for microphone input.')
parser.add_argument('--enable_translation',
action='store_true',
help='Enable translation of the transcription output.')
parser.add_argument('--target_language', '-tl',
type=str,
default='fr',
help='Target language for translation, e.g., "fr" for French.')
parser.add_argument('--enable_timestamps',
action='store_true',
help='Show transcription with timestamps')
parser.add_argument('--n_display_segments',
type=int,
default=4,
help='Number of transcript segments to display in terminal (default: 4).')
args = parser.parse_args()
client = TranscriptionClient(
args.server,
args.port,
lang=args.lang,
translate=args.translate,
model=args.model, # also support hf_model => `Systran/faster-whisper-small`
use_vad=True,
save_output_recording=args.save_output_recording, # Only used for microphone input, False by Default
output_recording_filename=args.output_file, # Only used for microphone input
mute_audio_playback=args.mute_audio_playback, # Only used for file input, False by Default
enable_translation=args.enable_translation, # Enable translation of the transcription output
target_language=args.target_language, # Target language for translation, e.g., "fr
enable_timestamps=args.enable_timestamps,
display_segments=args.n_display_segments,
)
if args.files is None:
client()
sys.exit(0)
# Validate audio files
valid_files = []
for file_path in args.files:
path = Path(file_path)
if path.exists() and path.is_file():
valid_files.append(str(path))
else:
print(f"Warning: File not found: {file_path}")
if not valid_files:
print("Error: No valid audio files found!")
sys.exit(1)
print(f"Found {len(valid_files)} audio file(s) to stream:")
for file_path in valid_files:
print(f" - {file_path}")
for f in valid_files:
client(f)
+62
View File
@@ -1,5 +1,14 @@
import argparse import argparse
import os import os
import threading
import logging
from fastapi import FastAPI
from fastapi import UploadFile, Form
import uvicorn
import tempfile
import shutil
import json
from starlette.responses import PlainTextResponse, JSONResponse
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -31,6 +40,50 @@ if __name__ == "__main__":
parser.add_argument('--no_single_model', '-nsm', parser.add_argument('--no_single_model', '-nsm',
action='store_true', action='store_true',
help='Set this if every connection should instantiate its own model. Only relevant for custom model, passed using -trt or -fw.') help='Set this if every connection should instantiate its own model. Only relevant for custom model, passed using -trt or -fw.')
parser.add_argument('--max_clients',
type=int,
default=4,
help='Maximum clients supported by the server.')
parser.add_argument('--max_connection_time',
type=int,
default=300,
help='The maximum duration (in seconds) a client can stay connected. Defaults to 300 seconds (5 minutes)')
parser.add_argument('--cache_path', '-c',
type=str,
default="~/.cache/whisper-live/",
help='Path to cache the converted ctranslate2 models.')
parser.add_argument(
"--rest_port", type=int, default=8000, help="Port for the REST API server."
)
parser.add_argument(
"--enable_rest",
action="store_true",
help="Enable the OpenAI-compatible REST API endpoint.",
)
parser.add_argument(
'--cors-origins',
type=str,
default=None,
help="Comma-separated list of allowed CORS origins (e.g., 'http://localhost:3000,http://example.com'). Defaults to localhost/127.0.0.1 on the WebSocket port."
)
parser.add_argument(
'--batch_inference',
action='store_true',
help='Enable batched GPU inference for concurrent sessions. '
'Batches multiple sessions into a single GPU call for higher throughput.'
)
parser.add_argument(
'--batch_max_size',
type=int,
default=8,
help='Maximum batch size for batched inference (default: 8).'
)
parser.add_argument(
'--batch_window_ms',
type=int,
default=50,
help='Maximum time in ms to wait for batch to fill (default: 50).'
)
args = parser.parse_args() args = parser.parse_args()
if args.backend == "tensorrt": if args.backend == "tensorrt":
@@ -51,4 +104,13 @@ if __name__ == "__main__":
trt_multilingual=args.trt_multilingual, trt_multilingual=args.trt_multilingual,
trt_py_session=args.trt_py_session, trt_py_session=args.trt_py_session,
single_model=not args.no_single_model, single_model=not args.no_single_model,
max_clients=args.max_clients,
max_connection_time=args.max_connection_time,
cache_path=args.cache_path,
rest_port=args.rest_port,
enable_rest=args.enable_rest,
cors_origins=args.cors_origins,
batch_enabled=args.batch_inference,
batch_max_size=args.batch_max_size,
batch_window_ms=args.batch_window_ms,
) )
+31 -2
View File
@@ -1,3 +1,32 @@
#! /bin/bash #!/bin/bash
apt-get install portaudio19-dev wget -y # Detect the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
echo "Detected macOS, using Homebrew for installation"
# Check if Homebrew is installed
if ! command -v brew &> /dev/null; then
echo "Homebrew not found. Please install Homebrew first: https://brew.sh/"
exit 1
fi
# Install packages using Homebrew
brew install portaudio wget
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux
if [[ -f /etc/os-release ]]; then
source /etc/os-release
fi
if [[ "${ID:-}" == "fedora" ]]; then
echo "Detected Fedora, using dnf for installation"
dnf install -y portaudio-devel wget
else
echo "Detected Linux (assuming Debian/Ubuntu), using apt-get for installation"
apt-get install -y portaudio19-dev wget
fi
else
echo "Unsupported operating system: $OSTYPE"
exit 1
fi
+2 -2
View File
@@ -43,7 +43,7 @@ setup(
), ),
install_requires=[ install_requires=[
"PyAudio", "PyAudio",
"faster-whisper==1.1.0", "faster-whisper==1.2.0",
"torch", "torch",
"torchaudio", "torchaudio",
"websockets", "websockets",
@@ -51,7 +51,7 @@ setup(
"scipy", "scipy",
"websocket-client", "websocket-client",
"numba", "numba",
"openai-whisper==20240930", "openai-whisper==20250625",
"kaldialign", "kaldialign",
"soundfile", "soundfile",
"tokenizers==0.20.3", "tokenizers==0.20.3",
+163
View File
@@ -0,0 +1,163 @@
import time
import unittest
from unittest import mock
from unittest.mock import MagicMock
import numpy as np
from whisper_live.batch_inference import BatchInferenceWorker, BatchRequest
class TestBatchInferenceWorker(unittest.TestCase):
def setUp(self):
self.mock_transcriber = MagicMock()
self.worker = BatchInferenceWorker(
transcriber=self.mock_transcriber,
max_batch_size=8,
batch_window_ms=200,
)
self.worker.start()
def tearDown(self):
self.worker.stop()
def _make_audio(self, duration_s=1.0):
return np.random.randn(int(16000 * duration_s)).astype(np.float32)
def test_single_request_uses_transcribe(self):
"""Single request should fall back to transcriber.transcribe()."""
fake_segment = MagicMock()
fake_info = MagicMock()
self.mock_transcriber.transcribe.return_value = ([fake_segment], fake_info)
req = BatchRequest(audio=self._make_audio(), language="en", use_vad=False)
self.worker.submit(req)
req.future.wait(timeout=5)
self.assertTrue(req.future.is_set())
self.assertIsNone(req.error)
self.assertEqual(req.result, [fake_segment])
self.assertEqual(req.info, fake_info)
self.mock_transcriber.transcribe.assert_called_once()
@mock.patch('whisper_live.batch_inference.get_suppressed_tokens', return_value=[-1])
@mock.patch('whisper_live.batch_inference.Tokenizer')
def test_multiple_requests_batched(self, mock_tokenizer_cls, mock_suppress):
"""Multiple concurrent requests should go through the batched GPU path."""
# Mock tokenizer
mock_tok = MagicMock()
mock_tok.decode.return_value = "hello world"
mock_tokenizer_cls.return_value = mock_tok
# Mock feature extractor
self.mock_transcriber.feature_extractor.return_value = np.zeros(
(80, 3000), dtype=np.float32
)
self.mock_transcriber.feature_extractor.sampling_rate = 16000
# Mock encode
self.mock_transcriber.encode.return_value = np.zeros(
(3, 1500, 512), dtype=np.float32
)
# Mock model.generate — one result per item
gen_result = MagicMock()
gen_result.sequences_ids = [[50257, 50362, 1234, 50256]]
gen_result.scores = [np.float32(-1.0)]
gen_result.no_speech_prob = 0.1
self.mock_transcriber.model.generate.return_value = [gen_result] * 3
# Mock remaining model attributes
self.mock_transcriber.model.is_multilingual = False
self.mock_transcriber.max_length = 448
self.mock_transcriber.frames_per_second = 50
self.mock_transcriber.get_prompt.return_value = [50258]
self.mock_transcriber._split_segments_by_timestamps.return_value = (
[{"start": 0.0, "end": 1.0, "tokens": [1234], "seek": 0}],
None,
None,
)
requests = [
BatchRequest(audio=self._make_audio(), language="en", use_vad=False)
for _ in range(3)
]
for req in requests:
self.worker.submit(req)
for req in requests:
req.future.wait(timeout=5)
for req in requests:
self.assertTrue(req.future.is_set())
self.assertIsNone(req.error)
self.assertIsNotNone(req.result)
# Verify the batched encode path was used (not transcribe)
self.mock_transcriber.encode.assert_called()
self.mock_transcriber.transcribe.assert_not_called()
def test_error_propagation(self):
"""Transcriber errors should propagate to the request without crashing the worker."""
self.mock_transcriber.transcribe.side_effect = RuntimeError("GPU OOM")
req = BatchRequest(audio=self._make_audio(), language="en", use_vad=False)
self.worker.submit(req)
req.future.wait(timeout=5)
self.assertTrue(req.future.is_set())
self.assertIsInstance(req.error, RuntimeError)
self.assertIn("GPU OOM", str(req.error))
# Worker should still be alive — submit another request
self.mock_transcriber.transcribe.side_effect = None
self.mock_transcriber.transcribe.return_value = ([MagicMock()], MagicMock())
req2 = BatchRequest(audio=self._make_audio(), language="en", use_vad=False)
self.worker.submit(req2)
req2.future.wait(timeout=5)
self.assertIsNone(req2.error)
self.assertIsNotNone(req2.result)
def test_worker_stop(self):
"""Worker thread should exit cleanly when stop() is called."""
self.assertTrue(self.worker._thread.is_alive())
self.worker.stop()
self.assertFalse(self.worker._thread.is_alive())
def test_batch_respects_max_size(self):
"""Batches should not exceed max_batch_size."""
self.worker.stop() # Stop the default worker
observed_batch_sizes = []
original_process = BatchInferenceWorker._process_batch
def tracking_process(self_inner, batch):
observed_batch_sizes.append(len(batch))
original_process(self_inner, batch)
self.worker = BatchInferenceWorker(
transcriber=self.mock_transcriber,
max_batch_size=2,
batch_window_ms=100,
)
self.mock_transcriber.transcribe.return_value = ([MagicMock()], MagicMock())
with mock.patch.object(
BatchInferenceWorker, '_process_batch', tracking_process
):
self.worker.start()
requests = [
BatchRequest(audio=self._make_audio(), language="en", use_vad=False)
for _ in range(4)
]
for req in requests:
self.worker.submit(req)
for req in requests:
req.future.wait(timeout=5)
for size in observed_batch_sizes:
self.assertLessEqual(size, 2)
self.assertTrue(all(req.future.is_set() for req in requests))
+2 -2
View File
@@ -49,12 +49,12 @@ class TestClientCallbacks(BaseTestCase):
"task": self.client.task, "task": self.client.task,
"model": self.client.model, "model": self.client.model,
"use_vad": True, "use_vad": True,
"max_clients": 4,
"max_connection_time": 600,
"send_last_n_segments": 10, "send_last_n_segments": 10,
"no_speech_thresh": 0.45, "no_speech_thresh": 0.45,
"clip_audio": False, "clip_audio": False,
"same_output_threshold": 10, "same_output_threshold": 10,
"enable_translation": False,
"target_language": "fr",
}) })
self.client.on_open(self.mock_ws_app) self.client.on_open(self.mock_ws_app)
self.mock_ws_app.send.assert_called_with(expected_message) self.mock_ws_app.send.assert_called_with(expected_message)
+2
View File
@@ -42,6 +42,8 @@ class TestGetWaitTime(unittest.TestCase):
class TestServerConnection(unittest.TestCase): class TestServerConnection(unittest.TestCase):
def setUp(self): def setUp(self):
self.server = TranscriptionServer() self.server = TranscriptionServer()
self.server.client_manager = ClientManager(max_clients=4, max_connection_time=600)
self.server.cache_path = "~/.cache/whisper-live/"
@mock.patch('websockets.WebSocketCommonProtocol') @mock.patch('websockets.WebSocketCommonProtocol')
def test_connection(self, mock_websocket): def test_connection(self, mock_websocket):
+3
View File
@@ -0,0 +1,3 @@
from whisper_live.__version__ import __version__
__all__ = ['__version__']
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.7.1" __version__ = "0.8.0"
+21 -3
View File
@@ -2,6 +2,7 @@ import json
import logging import logging
import threading import threading
import time import time
import queue
import numpy as np import numpy as np
@@ -31,6 +32,7 @@ class ServeClientBase(object):
no_speech_thresh=0.45, no_speech_thresh=0.45,
clip_audio=False, clip_audio=False,
same_output_threshold=10, same_output_threshold=10,
translation_queue=None,
): ):
self.client_uid = client_uid self.client_uid = client_uid
self.websocket = websocket self.websocket = websocket
@@ -50,6 +52,7 @@ class ServeClientBase(object):
self.same_output_count = 0 self.same_output_count = 0
self.transcript = [] self.transcript = []
self.end_time_for_same_output = None self.end_time_for_same_output = None
self.translation_queue = translation_queue
# threading # threading
self.lock = threading.Lock() self.lock = threading.Lock()
@@ -307,7 +310,14 @@ class ServeClientBase(object):
continue continue
if self.get_segment_no_speech_prob(s) > self.no_speech_thresh: if self.get_segment_no_speech_prob(s) > self.no_speech_thresh:
continue continue
self.transcript.append(self.format_segment(start, end, text_, completed=True)) completed_segment = self.format_segment(start, end, text_, completed=True)
self.transcript.append(completed_segment)
if self.translation_queue:
try:
self.translation_queue.put(completed_segment.copy(), timeout=0.1)
except queue.Full:
logging.warning("Translation queue is full, skipping segment")
offset = min(duration, self.get_segment_end(s)) offset = min(duration, self.get_segment_end(s))
# Process the last segment if its no_speech_prob is acceptable. # Process the last segment if its no_speech_prob is acceptable.
@@ -340,12 +350,20 @@ class ServeClientBase(object):
if not self.text or self.text[-1].strip().lower() != self.current_out.strip().lower(): if not self.text or self.text[-1].strip().lower() != self.current_out.strip().lower():
self.text.append(self.current_out) self.text.append(self.current_out)
with self.lock: with self.lock:
self.transcript.append(self.format_segment( completed_segment = self.format_segment(
self.timestamp_offset, self.timestamp_offset,
self.timestamp_offset + min(duration, self.end_time_for_same_output), self.timestamp_offset + min(duration, self.end_time_for_same_output),
self.current_out, self.current_out,
completed=True completed=True
)) )
self.transcript.append(completed_segment)
if self.translation_queue:
try:
self.translation_queue.put(completed_segment.copy(), timeout=0.1)
except queue.Full:
logging.warning("Translation queue is full, skipping segment")
self.current_out = '' self.current_out = ''
offset = min(duration, self.end_time_for_same_output) offset = min(duration, self.end_time_for_same_output)
self.same_output_count = 0 self.same_output_count = 0
+68 -27
View File
@@ -1,8 +1,11 @@
import os
import json import json
import logging import logging
import threading import threading
import time import time
import torch import torch
import ctranslate2
from huggingface_hub import snapshot_download
from whisper_live.transcriber.transcriber_faster_whisper import WhisperModel from whisper_live.transcriber.transcriber_faster_whisper import WhisperModel
from whisper_live.backend.base import ServeClientBase from whisper_live.backend.base import ServeClientBase
@@ -11,6 +14,7 @@ from whisper_live.backend.base import ServeClientBase
class ServeClientFasterWhisper(ServeClientBase): class ServeClientFasterWhisper(ServeClientBase):
SINGLE_MODEL = None SINGLE_MODEL = None
SINGLE_MODEL_LOCK = threading.Lock() SINGLE_MODEL_LOCK = threading.Lock()
BATCH_WORKER = None
def __init__( def __init__(
self, self,
@@ -27,7 +31,9 @@ class ServeClientFasterWhisper(ServeClientBase):
send_last_n_segments=10, send_last_n_segments=10,
no_speech_thresh=0.45, no_speech_thresh=0.45,
clip_audio=False, clip_audio=False,
same_output_threshold=10, same_output_threshold=7,
cache_path="~/.cache/whisper-live/",
translation_queue=None,
): ):
""" """
Initialize a ServeClient instance. Initialize a ServeClient instance.
@@ -57,7 +63,9 @@ class ServeClientFasterWhisper(ServeClientBase):
no_speech_thresh, no_speech_thresh,
clip_audio, clip_audio,
same_output_threshold, same_output_threshold,
translation_queue
) )
self.cache_path = cache_path
self.model_sizes = [ self.model_sizes = [
"tiny", "tiny.en", "base", "base.en", "small", "small.en", "tiny", "tiny.en", "base", "base.en", "small", "small.en",
"medium", "medium.en", "large-v2", "large-v3", "distil-small.en", "medium", "medium.en", "large-v2", "large-v3", "distil-small.en",
@@ -69,7 +77,7 @@ class ServeClientFasterWhisper(ServeClientBase):
self.language = "en" if self.model_size_or_path.endswith("en") else language self.language = "en" if self.model_size_or_path.endswith("en") else language
self.task = task self.task = task
self.initial_prompt = initial_prompt self.initial_prompt = initial_prompt
self.vad_parameters = vad_parameters or {"onset": 0.5} self.vad_parameters = vad_parameters or {"threshold": 0.5}
device = "cuda" if torch.cuda.is_available() else "cpu" device = "cuda" if torch.cuda.is_available() else "cpu"
if device == "cuda": if device == "cuda":
@@ -118,38 +126,51 @@ class ServeClientFasterWhisper(ServeClientBase):
def create_model(self, device): def create_model(self, device):
""" """
Instantiates a new model, sets it as the transcriber. Instantiates a new model, sets it as the transcriber. If model is a huggingface model_id
then it is automatically converted to ctranslate2(faster_whisper) format.
""" """
model_ref = self.model_size_or_path
if model_ref in self.model_sizes:
model_to_load = model_ref
else:
logging.info(f"Model not in model_sizes")
if os.path.isdir(model_ref) and ctranslate2.contains_model(model_ref):
model_to_load = model_ref
else:
local_snapshot = snapshot_download(
repo_id = model_ref,
repo_type = "model",
)
if ctranslate2.contains_model(local_snapshot):
model_to_load = local_snapshot
else:
cache_root = os.path.expanduser(os.path.join(self.cache_path, "whisper-ct2-models/"))
os.makedirs(cache_root, exist_ok=True)
safe_name = model_ref.replace("/", "--")
ct2_dir = os.path.join(cache_root, safe_name)
if not ctranslate2.contains_model(ct2_dir):
logging.info(f"Converting '{model_ref}' to CTranslate2 @ {ct2_dir}")
ct2_converter = ctranslate2.converters.TransformersConverter(
local_snapshot,
copy_files=["tokenizer.json", "preprocessor_config.json"]
)
ct2_converter.convert(
output_dir=ct2_dir,
quantization=self.compute_type,
force=False, # skip if already up-to-date
)
model_to_load = ct2_dir
logging.info(f"Loading model: {model_to_load}")
self.transcriber = WhisperModel( self.transcriber = WhisperModel(
self.model_size_or_path, model_to_load,
device=device, device=device,
compute_type=self.compute_type, compute_type=self.compute_type,
local_files_only=False, local_files_only=False,
) )
def check_valid_model(self, model_size):
"""
Check if it's a valid whisper model size.
Args:
model_size (str): The name of the model size to check.
Returns:
str: The model size if valid, None otherwise.
"""
if model_size not in self.model_sizes:
self.websocket.send(
json.dumps(
{
"uid": self.client_uid,
"status": "ERROR",
"message": f"Invalid model size {model_size}. Available choices: {self.model_sizes}"
}
)
)
return None
return model_size
def set_language(self, info): def set_language(self, info):
""" """
Updates the language attribute based on the detected language information. Updates the language attribute based on the detected language information.
@@ -182,6 +203,26 @@ class ServeClientFasterWhisper(ServeClientBase):
depends on the implementation of the `transcriber.transcribe` method but typically depends on the implementation of the `transcriber.transcribe` method but typically
includes the transcribed text. includes the transcribed text.
""" """
# Batch inference path: submit to central queue and wait
if ServeClientFasterWhisper.BATCH_WORKER is not None:
from whisper_live.batch_inference import BatchRequest
request = BatchRequest(
audio=input_sample,
language=self.language,
task=self.task,
initial_prompt=self.initial_prompt,
use_vad=self.use_vad,
vad_parameters=self.vad_parameters if self.use_vad else None,
)
ServeClientFasterWhisper.BATCH_WORKER.submit(request)
request.future.wait(timeout=30)
if request.error:
raise request.error
if self.language is None and request.info is not None:
self.set_language(request.info)
return request.result
# Original lock-based path (backward compatible)
if ServeClientFasterWhisper.SINGLE_MODEL: if ServeClientFasterWhisper.SINGLE_MODEL:
ServeClientFasterWhisper.SINGLE_MODEL_LOCK.acquire() ServeClientFasterWhisper.SINGLE_MODEL_LOCK.acquire()
result, info = self.transcriber.transcribe( result, info = self.transcriber.transcribe(
@@ -0,0 +1,365 @@
# Copyright (c) 2022 Idiap Research Institute, http://www.idiap.ch/
# Written by Alireza Mohammadshahi <alireza.mohammadshahi@idiap.ch>
# This is a modified version of https://github.com/huggingface/transformers/blob/main/src/transformers/models/m2m_100/tokenization_m2m_100.py
# which owns by Fariseq Authors and The HuggingFace Inc. team.
#
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tokenization classes for SMALL100."""
import json
import os
from pathlib import Path
from shutil import copyfile
from typing import Any, Dict, List, Optional, Tuple, Union
import sentencepiece
from transformers.tokenization_utils import BatchEncoding, PreTrainedTokenizer
from transformers.utils import logging
logger = logging.get_logger(__name__)
SPIECE_UNDERLINE = ""
VOCAB_FILES_NAMES = {
"vocab_file": "vocab.json",
"spm_file": "sentencepiece.bpe.model",
"tokenizer_config_file": "tokenizer_config.json",
}
PRETRAINED_VOCAB_FILES_MAP = {
"vocab_file": {
"alirezamsh/small100": "https://huggingface.co/alirezamsh/small100/resolve/main/vocab.json",
},
"spm_file": {
"alirezamsh/small100": "https://huggingface.co/alirezamsh/small100/resolve/main/sentencepiece.bpe.model",
},
"tokenizer_config_file": {
"alirezamsh/small100": "https://huggingface.co/alirezamsh/small100/resolve/main/tokenizer_config.json",
},
}
PRETRAINED_POSITIONAL_EMBEDDINGS_SIZES = {
"alirezamsh/small100": 1024,
}
# fmt: off
FAIRSEQ_LANGUAGE_CODES = {
"m2m100": ["af", "am", "ar", "ast", "az", "ba", "be", "bg", "bn", "br", "bs", "ca", "ceb", "cs", "cy", "da", "de", "el", "en", "es", "et", "fa", "ff", "fi", "fr", "fy", "ga", "gd", "gl", "gu", "ha", "he", "hi", "hr", "ht", "hu", "hy", "id", "ig", "ilo", "is", "it", "ja", "jv", "ka", "kk", "km", "kn", "ko", "lb", "lg", "ln", "lo", "lt", "lv", "mg", "mk", "ml", "mn", "mr", "ms", "my", "ne", "nl", "no", "ns", "oc", "or", "pa", "pl", "ps", "pt", "ro", "ru", "sd", "si", "sk", "sl", "so", "sq", "sr", "ss", "su", "sv", "sw", "ta", "th", "tl", "tn", "tr", "uk", "ur", "uz", "vi", "wo", "xh", "yi", "yo", "zh", "zu"]
}
# fmt: on
class SMALL100Tokenizer(PreTrainedTokenizer):
"""
Construct an SMALL100 tokenizer. Based on [SentencePiece](https://github.com/google/sentencepiece).
This tokenizer inherits from [`PreTrainedTokenizer`] which contains most of the main methods. Users should refer to
this superclass for more information regarding those methods.
Args:
vocab_file (`str`):
Path to the vocabulary file.
spm_file (`str`):
Path to [SentencePiece](https://github.com/google/sentencepiece) file (generally has a .spm extension) that
contains the vocabulary.
tgt_lang (`str`, *optional*):
A string representing the target language.
eos_token (`str`, *optional*, defaults to `"</s>"`):
The end of sequence token.
sep_token (`str`, *optional*, defaults to `"</s>"`):
The separator token, which is used when building a sequence from multiple sequences, e.g. two sequences for
sequence classification or for a text and a question for question answering. It is also used as the last
token of a sequence built with special tokens.
unk_token (`str`, *optional*, defaults to `"<unk>"`):
The unknown token. A token that is not in the vocabulary cannot be converted to an ID and is set to be this
token instead.
pad_token (`str`, *optional*, defaults to `"<pad>"`):
The token used for padding, for example when batching sequences of different lengths.
language_codes (`str`, *optional*):
What language codes to use. Should be `"m2m100"`.
sp_model_kwargs (`dict`, *optional*):
Will be passed to the `SentencePieceProcessor.__init__()` method. The [Python wrapper for
SentencePiece](https://github.com/google/sentencepiece/tree/master/python) can be used, among other things,
to set:
- `enable_sampling`: Enable subword regularization.
- `nbest_size`: Sampling parameters for unigram. Invalid for BPE-Dropout.
- `nbest_size = {0,1}`: No sampling is performed.
- `nbest_size > 1`: samples from the nbest_size results.
- `nbest_size < 0`: assuming that nbest_size is infinite and samples from the all hypothesis (lattice)
using forward-filtering-and-backward-sampling algorithm.
- `alpha`: Smoothing parameter for unigram sampling, and dropout probability of merge operations for
BPE-dropout.
Examples:
```python
>>> from tokenization_small100 import SMALL100Tokenizer
>>> tokenizer = SMALL100Tokenizer.from_pretrained("alirezamsh/small100", tgt_lang="ro")
>>> src_text = " UN Chief Says There Is No Military Solution in Syria"
>>> tgt_text = "Şeful ONU declară că nu există o soluţie militară în Siria"
>>> model_inputs = tokenizer(src_text, text_target=tgt_text, return_tensors="pt")
>>> model(**model_inputs) # should work
```"""
vocab_files_names = VOCAB_FILES_NAMES
max_model_input_sizes = PRETRAINED_POSITIONAL_EMBEDDINGS_SIZES
pretrained_vocab_files_map = PRETRAINED_VOCAB_FILES_MAP
model_input_names = ["input_ids", "attention_mask"]
prefix_tokens: List[int] = []
suffix_tokens: List[int] = []
def __init__(
self,
vocab_file,
spm_file,
tgt_lang=None,
bos_token="<s>",
eos_token="</s>",
sep_token="</s>",
pad_token="<pad>",
unk_token="<unk>",
language_codes="m2m100",
sp_model_kwargs: Optional[Dict[str, Any]] = None,
num_madeup_words=8,
**kwargs,
) -> None:
self.sp_model_kwargs = {} if sp_model_kwargs is None else sp_model_kwargs
self.language_codes = language_codes
fairseq_language_code = FAIRSEQ_LANGUAGE_CODES[language_codes]
self.lang_code_to_token = {lang_code: f"__{lang_code}__" for lang_code in fairseq_language_code}
kwargs["additional_special_tokens"] = kwargs.get("additional_special_tokens", [])
kwargs["additional_special_tokens"] += [
self.get_lang_token(lang_code)
for lang_code in fairseq_language_code
if self.get_lang_token(lang_code) not in kwargs["additional_special_tokens"]
]
self.vocab_file = vocab_file
self.encoder = load_json(vocab_file)
self.decoder = {v: k for k, v in self.encoder.items()}
self.spm_file = spm_file
self.sp_model = load_spm(spm_file, self.sp_model_kwargs)
self.encoder_size = len(self.encoder)
self.lang_token_to_id = {
self.get_lang_token(lang_code): self.encoder_size + i for i, lang_code in enumerate(fairseq_language_code)
}
self.lang_code_to_id = {lang_code: self.encoder_size + i for i, lang_code in enumerate(fairseq_language_code)}
self.id_to_lang_token = {v: k for k, v in self.lang_token_to_id.items()}
self._tgt_lang = tgt_lang if tgt_lang is not None else "en"
self.cur_lang_id = self.get_lang_id(self._tgt_lang)
self.num_madeup_words = num_madeup_words
super().__init__(
tgt_lang=tgt_lang,
bos_token=bos_token,
eos_token=eos_token,
sep_token=sep_token,
unk_token=unk_token,
pad_token=pad_token,
language_codes=language_codes,
sp_model_kwargs=self.sp_model_kwargs,
num_madeup_words=num_madeup_words,
**kwargs,
)
self.set_lang_special_tokens(self._tgt_lang)
@property
def vocab_size(self) -> int:
return len(self.encoder) + len(self.lang_token_to_id) + self.num_madeup_words
@property
def tgt_lang(self) -> str:
return self._tgt_lang
@tgt_lang.setter
def tgt_lang(self, new_tgt_lang: str) -> None:
self._tgt_lang = new_tgt_lang
self.set_lang_special_tokens(self._tgt_lang)
def _tokenize(self, text: str) -> List[str]:
return self.sp_model.encode(text, out_type=str)
def _convert_token_to_id(self, token):
if token in self.lang_token_to_id:
return self.lang_token_to_id[token]
return self.encoder.get(token, self.encoder[self.unk_token])
def _convert_id_to_token(self, index: int) -> str:
"""Converts an index (integer) in a token (str) using the decoder."""
if index in self.id_to_lang_token:
return self.id_to_lang_token[index]
return self.decoder.get(index, self.unk_token)
def convert_tokens_to_string(self, tokens: List[str]) -> str:
"""Converts a sequence of tokens (strings for sub-words) in a single string."""
return self.sp_model.decode(tokens)
def get_special_tokens_mask(
self, token_ids_0: List[int], token_ids_1: Optional[List[int]] = None, already_has_special_tokens: bool = False
) -> List[int]:
"""
Retrieve sequence ids from a token list that has no special tokens added. This method is called when adding
special tokens using the tokenizer `prepare_for_model` method.
Args:
token_ids_0 (`List[int]`):
List of IDs.
token_ids_1 (`List[int]`, *optional*):
Optional second list of IDs for sequence pairs.
already_has_special_tokens (`bool`, *optional*, defaults to `False`):
Whether or not the token list is already formatted with special tokens for the model.
Returns:
`List[int]`: A list of integers in the range [0, 1]: 1 for a special token, 0 for a sequence token.
"""
if already_has_special_tokens:
return super().get_special_tokens_mask(
token_ids_0=token_ids_0, token_ids_1=token_ids_1, already_has_special_tokens=True
)
prefix_ones = [1] * len(self.prefix_tokens)
suffix_ones = [1] * len(self.suffix_tokens)
if token_ids_1 is None:
return prefix_ones + ([0] * len(token_ids_0)) + suffix_ones
return prefix_ones + ([0] * len(token_ids_0)) + ([0] * len(token_ids_1)) + suffix_ones
def build_inputs_with_special_tokens(
self, token_ids_0: List[int], token_ids_1: Optional[List[int]] = None
) -> List[int]:
"""
Build model inputs from a sequence or a pair of sequence for sequence classification tasks by concatenating and
adding special tokens. An MBART sequence has the following format, where `X` represents the sequence:
- `input_ids` (for encoder) `X [eos, src_lang_code]`
- `decoder_input_ids`: (for decoder) `X [eos, tgt_lang_code]`
BOS is never used. Pairs of sequences are not the expected use case, but they will be handled without a
separator.
Args:
token_ids_0 (`List[int]`):
List of IDs to which the special tokens will be added.
token_ids_1 (`List[int]`, *optional*):
Optional second list of IDs for sequence pairs.
Returns:
`List[int]`: List of [input IDs](../glossary#input-ids) with the appropriate special tokens.
"""
if token_ids_1 is None:
if self.prefix_tokens is None:
return token_ids_0 + self.suffix_tokens
else:
return self.prefix_tokens + token_ids_0 + self.suffix_tokens
# We don't expect to process pairs, but leave the pair logic for API consistency
if self.prefix_tokens is None:
return token_ids_0 + token_ids_1 + self.suffix_tokens
else:
return self.prefix_tokens + token_ids_0 + token_ids_1 + self.suffix_tokens
def get_vocab(self) -> Dict:
vocab = {self.convert_ids_to_tokens(i): i for i in range(self.vocab_size)}
vocab.update(self.added_tokens_encoder)
return vocab
def __getstate__(self) -> Dict:
state = self.__dict__.copy()
state["sp_model"] = None
return state
def __setstate__(self, d: Dict) -> None:
self.__dict__ = d
# for backward compatibility
if not hasattr(self, "sp_model_kwargs"):
self.sp_model_kwargs = {}
self.sp_model = load_spm(self.spm_file, self.sp_model_kwargs)
def save_vocabulary(self, save_directory: str, filename_prefix: Optional[str] = None) -> Tuple[str]:
save_dir = Path(save_directory)
if not save_dir.is_dir():
raise OSError(f"{save_directory} should be a directory")
vocab_save_path = save_dir / (
(filename_prefix + "-" if filename_prefix else "") + self.vocab_files_names["vocab_file"]
)
spm_save_path = save_dir / (
(filename_prefix + "-" if filename_prefix else "") + self.vocab_files_names["spm_file"]
)
save_json(self.encoder, vocab_save_path)
if os.path.abspath(self.spm_file) != os.path.abspath(spm_save_path) and os.path.isfile(self.spm_file):
copyfile(self.spm_file, spm_save_path)
elif not os.path.isfile(self.spm_file):
with open(spm_save_path, "wb") as fi:
content_spiece_model = self.sp_model.serialized_model_proto()
fi.write(content_spiece_model)
return (str(vocab_save_path), str(spm_save_path))
def prepare_seq2seq_batch(
self,
src_texts: List[str],
tgt_texts: Optional[List[str]] = None,
tgt_lang: str = "ro",
**kwargs,
) -> BatchEncoding:
self.tgt_lang = tgt_lang
self.set_lang_special_tokens(self.tgt_lang)
return super().prepare_seq2seq_batch(src_texts, tgt_texts, **kwargs)
def _build_translation_inputs(self, raw_inputs, tgt_lang: Optional[str], **extra_kwargs):
"""Used by translation pipeline, to prepare inputs for the generate function"""
if tgt_lang is None:
raise ValueError("Translation requires a `tgt_lang` for this model")
self.tgt_lang = tgt_lang
inputs = self(raw_inputs, add_special_tokens=True, **extra_kwargs)
return inputs
def _switch_to_input_mode(self):
self.set_lang_special_tokens(self.tgt_lang)
def _switch_to_target_mode(self):
self.prefix_tokens = None
self.suffix_tokens = [self.eos_token_id]
def set_lang_special_tokens(self, src_lang: str) -> None:
"""Reset the special tokens to the tgt lang setting. No prefix and suffix=[eos, tgt_lang_code]."""
lang_token = self.get_lang_token(src_lang)
self.cur_lang_id = self.lang_token_to_id[lang_token]
self.prefix_tokens = [self.cur_lang_id]
self.suffix_tokens = [self.eos_token_id]
def get_lang_token(self, lang: str) -> str:
return self.lang_code_to_token[lang]
def get_lang_id(self, lang: str) -> int:
lang_token = self.get_lang_token(lang)
return self.lang_token_to_id[lang_token]
def load_spm(path: str, sp_model_kwargs: Dict[str, Any]) -> sentencepiece.SentencePieceProcessor:
spm = sentencepiece.SentencePieceProcessor(**sp_model_kwargs)
spm.Load(str(path))
return spm
def load_json(path: str) -> Union[Dict, List]:
with open(path, "r") as f:
return json.load(f)
def save_json(data, path: str) -> None:
with open(path, "w") as f:
json.dump(data, f, indent=2)
+218
View File
@@ -0,0 +1,218 @@
import json
import logging
import threading
import time
import queue
from typing import Dict, Any, Optional
import torch
import threading
from transformers import M2M100ForConditionalGeneration
from whisper_live.backend.tokenization_small100 import SMALL100Tokenizer
from whisper_live.backend.base import ServeClientBase
class ServeClientTranslation(ServeClientBase):
"""
Handles translation of completed transcription segments in a separate thread.
Reads from a queue populated by the transcription backend and sends translated
segments back to the client via WebSocket.
"""
def __init__(
self,
client_uid,
websocket,
translation_queue,
target_language="fr",
send_last_n_segments=10,
model_name="alirezamsh/small100"
):
"""
Initialize the translation client.
Args:
client_uid (str): Unique identifier for the client
websocket: WebSocket connection to the client
translation_queue (queue.Queue): Queue containing completed segments to translate
target_language (str): Target language code (default: "fr" for French)
send_last_n_segments (int): Number of recent translated segments to send
model_name (str): Translation model name to use
"""
super().__init__(client_uid, websocket, send_last_n_segments)
self.translation_queue = translation_queue
self.target_language = target_language
self.model_name = model_name
self.translated_segments = []
self.translation_model = None
self.tokenizer = None
self.device = None
self.model_loaded = False
self.load_translation_model()
def load_translation_model(self):
"""Load the translation model and tokenizer."""
try:
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
logging.info(f"Loading translation model on device: {self.device}")
self.translation_model = M2M100ForConditionalGeneration.from_pretrained(
self.model_name
).to(self.device)
self.tokenizer = SMALL100Tokenizer.from_pretrained(self.model_name)
self.tokenizer.tgt_lang = self.target_language
self.model_loaded = True
logging.info(f"Translation model loaded successfully. Target language: {self.target_language}")
except Exception as e:
logging.error(f"Failed to load translation model: {e}")
self.translation_model = None
self.tokenizer = None
self.model_loaded = False
def translate_text(self, text: str) -> str:
"""
Translate a single text segment.
Args:
text (str): Text to translate
Returns:
str: Translated text or original text if translation fails
"""
if not self.model_loaded or not text.strip():
return text
try:
# Encode input and move to device
encoded_input = self.tokenizer(text, return_tensors="pt").to(self.device)
# Generate translation
with torch.no_grad():
generated_tokens = self.translation_model.generate(**encoded_input)
# Decode output
output = self.tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
return output[0] if output else text
except Exception as e:
logging.error(f"Translation failed for text '{text}': {e}")
return text
def process_translation_queue(self):
"""
Process segments from the translation queue.
Continuously reads from the queue until None is received (exit signal).
"""
logging.info(f"Starting translation processing for client {self.client_uid}")
while not self.exit:
try:
# Get segment from queue with timeout
segment = self.translation_queue.get(timeout=1.0)
# Check for exit signal
if segment is None:
logging.info(f"Received exit signal for translation client {self.client_uid}")
break
# Only translate completed segments
if not segment.get("completed", False):
self.translation_queue.task_done()
continue
# Translate the segment
original_text = segment.get("text", "")
translated_text = self.translate_text(original_text)
# Create translated segment
translated_segment = {
"start": segment["start"],
"end": segment["end"],
"text": translated_text,
"completed": segment.get("completed", False),
"target_language": self.target_language
}
self.translated_segments.append(translated_segment)
segments_to_send = self.prepare_translated_segments()
self.send_translation_to_client(segments_to_send)
self.translation_queue.task_done()
except queue.Empty:
continue
except Exception as e:
logging.error(f"Error processing translation queue: {e}")
continue
logging.info(f"Translation processing ended for client {self.client_uid}")
def prepare_translated_segments(self):
"""
Prepare the last n translated segments to send to client.
Returns:
list: List of recent translated segments
"""
if len(self.translated_segments) >= self.send_last_n_segments:
return self.translated_segments[-self.send_last_n_segments:]
return self.translated_segments[:]
def send_translation_to_client(self, translated_segments):
"""
Send translated segments to the client via WebSocket.
Args:
translated_segments (list): List of translated segments to send
"""
try:
self.websocket.send(
json.dumps({
"uid": self.client_uid,
"translated_segments": translated_segments,
})
)
except Exception as e:
logging.error(f"[ERROR]: Sending translation data to client: {e}")
def speech_to_text(self):
"""
Override parent method to handle translation processing.
This method will be called when the translation thread starts.
"""
self.process_translation_queue()
def set_target_language(self, language: str):
"""
Change the target language for translation.
Args:
language (str): New target language code
"""
self.target_language = language
if self.tokenizer:
self.tokenizer.tgt_lang = language
logging.info(f"Target language changed to: {language}")
def cleanup(self):
"""Clean up translation resources."""
logging.info(f"Cleaning up translation resources for client {self.client_uid}")
self.exit = True
try:
self.translation_queue.put(None, timeout=1.0)
except:
pass
self.translated_segments.clear()
if self.translation_model:
del self.translation_model
self.translation_model = None
if self.tokenizer:
del self.tokenizer
self.tokenizer = None
if self.device and self.device.type == 'cuda':
torch.cuda.empty_cache()
+397
View File
@@ -0,0 +1,397 @@
"""
Batch inference scheduler for WhisperLive.
Replaces the per-session SINGLE_MODEL_LOCK with a queue-based batch system.
Multiple sessions submit audio to a central queue; a single dedicated thread
collects pending requests and runs them as a GPU batch via CTranslate2's
batched encode() + generate() API.
For batch_size=1, falls back to standard transcriber.transcribe() for
identical behavior to the non-batched path.
Usage:
Enable via ``--batch_inference`` CLI flag. The batch worker is lazily
started after the first client connects and the shared model is loaded.
Thread safety:
- ``queue.Queue`` is stdlib thread-safe.
- Each ``BatchRequest.future`` (``threading.Event``) is written by the
batch worker BEFORE ``.set()``, read by the session thread AFTER
``.wait()`` — no data race.
- Only the batch worker thread touches the GPU model — zero lock
contention between session threads.
"""
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from math import ceil
from typing import Any, Dict, List, Optional
import numpy as np
from faster_whisper.audio import pad_or_trim
from faster_whisper.tokenizer import Tokenizer
from faster_whisper.vad import (
VadOptions,
collect_chunks,
get_speech_timestamps,
)
from whisper_live.transcriber.transcriber_faster_whisper import (
Segment,
TranscriptionInfo,
get_compression_ratio,
get_suppressed_tokens,
)
@dataclass
class BatchRequest:
"""A single inference request submitted by a session thread.
The session thread creates this, calls ``BatchInferenceWorker.submit()``,
then blocks on ``future.wait()``. The batch worker fills ``result``
and/or ``error``, then signals ``future.set()``.
Attributes:
audio: Raw audio samples (float32, 16 kHz mono).
language: ISO language code or None for auto-detection.
task: ``"transcribe"`` or ``"translate"``.
initial_prompt: Optional prompt for Whisper conditioning.
use_vad: Whether to apply Voice Activity Detection.
vad_parameters: Parameters forwarded to ``VadOptions``.
future: Event signaled when the result is ready.
result: List of ``Segment`` objects (filled by worker).
info: ``TranscriptionInfo`` metadata (filled by worker).
error: Exception instance if processing failed.
"""
audio: np.ndarray
language: Optional[str] = None
task: str = "transcribe"
initial_prompt: Optional[str] = None
use_vad: bool = True
vad_parameters: Optional[Dict] = None
# Signaling
future: threading.Event = field(default_factory=threading.Event)
# Results (filled by batch worker)
result: Optional[Any] = None
info: Optional[Any] = None
error: Optional[Exception] = None
class BatchInferenceWorker:
"""Central batch inference scheduler for the faster_whisper backend.
Owns a single daemon thread that is the **only** thread touching the GPU
model. Per-session transcription threads submit ``BatchRequest`` objects
and block on ``future.wait()`` instead of competing for
``SINGLE_MODEL_LOCK``.
The worker loop:
1. Blocks until the first request arrives from the queue.
2. Waits up to ``batch_window_ms`` for additional requests (up to
``max_batch_size``).
3. Processes the collected batch:
- **batch_size == 1**: delegates to ``transcriber.transcribe()`` for
identical behavior to the non-batched path.
- **batch_size > 1**: runs a custom batched GPU path using
CTranslate2's ``encode()`` + ``generate()`` APIs.
Args:
transcriber: The shared ``WhisperModel`` instance.
max_batch_size: Maximum number of requests per batch.
batch_window_ms: Maximum time (ms) to wait for the batch to fill
after the first request arrives.
"""
def __init__(
self,
transcriber,
max_batch_size: int = 8,
batch_window_ms: int = 50,
):
self.transcriber = transcriber
self.max_batch_size = max_batch_size
self.batch_window_ms = batch_window_ms
self._queue: queue.Queue = queue.Queue()
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self):
"""Start the background batch worker thread."""
self._thread = threading.Thread(target=self._worker_loop, daemon=True)
self._thread.start()
logging.info(
f"[BatchInference] Started (max_batch={self.max_batch_size}, "
f"window={self.batch_window_ms}ms)"
)
def stop(self):
"""Signal the worker to stop and wait for it to finish."""
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
def submit(self, request: BatchRequest):
"""Submit an inference request to the batch queue.
Args:
request: The ``BatchRequest`` to enqueue. The caller should
then call ``request.future.wait()`` to block until the
result is ready.
"""
self._queue.put(request)
# -------------------------------------------------------------------------
# Worker loop
# -------------------------------------------------------------------------
def _worker_loop(self):
"""Main loop: collect requests into batches and process them."""
while not self._stop_event.is_set():
batch: List[BatchRequest] = []
# Block until first request arrives
try:
first = self._queue.get(timeout=0.5)
batch.append(first)
except queue.Empty:
continue
# Collect more requests within the batch window
deadline = time.monotonic() + (self.batch_window_ms / 1000.0)
while len(batch) < self.max_batch_size:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
item = self._queue.get(timeout=remaining)
batch.append(item)
except queue.Empty:
break
# Process the collected batch
try:
self._process_batch(batch)
except Exception as e:
logging.error(f"[BatchInference] Batch processing error: {e}")
for req in batch:
if not req.future.is_set():
req.error = e
req.future.set()
# -------------------------------------------------------------------------
# Batch processing
# -------------------------------------------------------------------------
def _process_batch(self, batch: List[BatchRequest]):
"""Dispatch to single or multi-item processing."""
if len(batch) == 1:
self._process_single(batch[0])
return
logging.info(f"[BatchInference] Processing batch of {len(batch)}")
self._process_multi(batch)
def _process_single(self, req: BatchRequest):
"""Process a single request using standard ``transcriber.transcribe()``.
This path is used when only one request is available in the batch
window, ensuring identical behavior to the non-batched code path.
"""
try:
result, info = self.transcriber.transcribe(
req.audio,
language=req.language,
task=req.task,
initial_prompt=req.initial_prompt,
vad_filter=req.use_vad,
vad_parameters=req.vad_parameters if req.use_vad else None,
)
# Materialize the generator into a list
req.result = list(result) if result is not None else []
req.info = info
except Exception as e:
req.error = e
finally:
req.future.set()
def _process_multi(self, batch: List[BatchRequest]):
"""Batched GPU path: encode + generate for multiple sessions at once.
Pipeline:
1. Per-item CPU preprocessing (VAD filtering + mel feature extraction)
2. Batch GPU encode — single ``transcriber.encode()`` call
3. Per-item prompt construction (handles different languages/tasks)
4. Batch GPU generate — single ``transcriber.model.generate()`` call
5. Per-item segment parsing and result dispatch
"""
# Step 1: Per-item CPU preprocessing (VAD + feature extraction)
preprocessed = []
for req in batch:
try:
audio = req.audio
speech_chunks = None
if req.use_vad:
vad_params = req.vad_parameters or {}
vad_opts = VadOptions(**vad_params) if isinstance(vad_params, dict) else vad_params
speech_chunks = get_speech_timestamps(audio, vad_opts)
if speech_chunks:
audio_chunks, _ = collect_chunks(audio, speech_chunks)
audio = np.concatenate(audio_chunks, axis=0) if audio_chunks else audio
if audio.shape[0] == 0:
# No speech detected — return empty result immediately
req.result = []
req.info = self._make_info(req, 0.0, 0.0)
req.future.set()
continue
duration = audio.shape[0] / self.transcriber.feature_extractor.sampling_rate
features = self.transcriber.feature_extractor(audio)
features = pad_or_trim(features) # -> [n_mels, 3000]
preprocessed.append((req, features, audio, duration, speech_chunks))
except Exception as e:
req.error = e
req.future.set()
if not preprocessed:
return
try:
# Step 2: Batch GPU encode
feature_batch = np.stack([p[1] for p in preprocessed]) # [B, n_mels, 3000]
encoder_output = self.transcriber.encode(feature_batch)
# Step 3: Build per-item prompts (handles different languages/tasks)
tokenizers_list = []
prompts = []
resolved_languages = []
for i, (req, features, audio, duration, speech_chunks) in enumerate(preprocessed):
lang = req.language
# If language unknown, detect from encoder output
if lang is None:
try:
lang_results = self.transcriber.model.detect_language(encoder_output)
if lang_results and len(lang_results) > i:
detected = lang_results[i]
if detected:
lang = detected[0][0].strip("<|>")
except Exception:
lang = "en" # fallback
resolved_languages.append(lang or "en")
tokenizer = Tokenizer(
self.transcriber.hf_tokenizer,
self.transcriber.model.is_multilingual,
task=req.task,
language=lang or "en",
)
previous_tokens = []
if req.initial_prompt:
previous_tokens = tokenizer.encode(" " + req.initial_prompt.strip())
prompt = self.transcriber.get_prompt(
tokenizer,
previous_tokens=previous_tokens,
without_timestamps=False,
)
tokenizers_list.append(tokenizer)
prompts.append(prompt)
# Step 4: Batch GPU generate
suppress_tokens = get_suppressed_tokens(tokenizers_list[0], [-1])
results = self.transcriber.model.generate(
encoder_output,
prompts,
beam_size=5,
patience=1,
length_penalty=1,
max_length=self.transcriber.max_length,
suppress_blank=True,
suppress_tokens=suppress_tokens,
return_scores=True,
return_no_speech_prob=True,
sampling_temperature=0.0,
repetition_penalty=1,
no_repeat_ngram_size=0,
)
# Step 5: Per-item segment parsing and result dispatch
for i, (req, features, audio, duration, speech_chunks) in enumerate(preprocessed):
try:
tokenizer = tokenizers_list[i]
gen_result = results[i]
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)
subsegments, _, _ = self.transcriber._split_segments_by_timestamps(
tokenizer=tokenizer,
tokens=tokens,
time_offset=0,
segment_size=segment_size,
segment_duration=duration,
seek=0,
)
segments = []
for seg_idx, subseg in enumerate(subsegments):
text = tokenizer.decode(subseg["tokens"]).strip()
if not text:
continue
segments.append(Segment(
id=seg_idx,
seek=subseg.get("seek", 0),
start=subseg["start"],
end=subseg["end"],
text=text,
tokens=subseg["tokens"],
avg_logprob=avg_logprob,
compression_ratio=get_compression_ratio(text),
no_speech_prob=gen_result.no_speech_prob,
words=None,
temperature=0.0,
))
req.result = segments
req.info = self._make_info(
req, duration, duration,
language=resolved_languages[i],
)
except Exception as e:
req.error = e
finally:
req.future.set()
except Exception as e:
logging.error(f"[BatchInference] GPU batch error: {e}")
for req, *_ in preprocessed:
if not req.future.is_set():
req.error = e
req.future.set()
def _make_info(self, req, duration, duration_after_vad, language=None):
"""Build a ``TranscriptionInfo`` for the given request."""
return TranscriptionInfo(
language=language or req.language or "en",
language_probability=1.0,
duration=duration,
duration_after_vad=duration_after_vad,
all_language_probs=None,
transcription_options=None,
vad_options=None,
)
+120 -43
View File
@@ -32,13 +32,17 @@ class Client:
use_vad=True, use_vad=True,
use_wss=False, use_wss=False,
log_transcription=True, log_transcription=True,
max_clients=4,
max_connection_time=600,
send_last_n_segments=10, send_last_n_segments=10,
no_speech_thresh=0.45, no_speech_thresh=0.45,
clip_audio=False, clip_audio=False,
same_output_threshold=10, same_output_threshold=10,
transcription_callback=None, transcription_callback=None,
enable_translation=False,
target_language="fr",
translation_callback=None,
translation_srt_file_path="output_translated.srt",
enable_timestamps=False,
display_segments=4,
): ):
""" """
Initializes a Client instance for audio recording and streaming to a server. Initializes a Client instance for audio recording and streaming to a server.
@@ -56,13 +60,15 @@ class Client:
srt_file_path (str, optional): The file path to save the output SRT file. Default is "output.srt". srt_file_path (str, optional): The file path to save the output SRT file. Default is "output.srt".
use_vad (bool, optional): Whether to enable voice activity detection. Default is True. use_vad (bool, optional): Whether to enable voice activity detection. Default is True.
log_transcription (bool, optional): Whether to log transcription output to the console. Default is True. log_transcription (bool, optional): Whether to log transcription output to the console. Default is True.
max_clients (int, optional): Maximum number of client connections allowed. Default is 4.
max_connection_time (int, optional): Maximum allowed connection time in seconds. Default is 600.
send_last_n_segments (int, optional): Number of most recent segments to send to the client. Defaults to 10. send_last_n_segments (int, optional): Number of most recent segments to send to the client. Defaults to 10.
no_speech_thresh (float, optional): Segments with no speech probability above this threshold will be discarded. Defaults to 0.45. no_speech_thresh (float, optional): Segments with no speech probability above this threshold will be discarded. Defaults to 0.45.
clip_audio (bool, optional): Whether to clip audio with no valid segments. Defaults to False. clip_audio (bool, optional): Whether to clip audio with no valid segments. Defaults to False.
same_output_threshold (int, optional): Number of repeated outputs before considering it as a valid segment. Defaults to 10. same_output_threshold (int, optional): Number of repeated outputs before considering it as a valid segment. Defaults to 10.
transcription_callback (callable, optional): A callback function to handle transcription results. Default is None. transcription_callback (callable, optional): A callback function to handle transcription results. Default is None.
enable_translation (float, optional): Whether to enable translation from any to any language. Defaults to False.
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_srt_file_path (str, optional): The file path to save the translated output SRT file. Default is "output_translated.srt".
""" """
self.recording = False self.recording = False
self.task = "transcribe" self.task = "transcribe"
@@ -79,16 +85,22 @@ class Client:
self.last_segment = None self.last_segment = None
self.last_received_segment = None self.last_received_segment = None
self.log_transcription = log_transcription self.log_transcription = log_transcription
self.max_clients = max_clients
self.max_connection_time = max_connection_time
self.send_last_n_segments = send_last_n_segments self.send_last_n_segments = send_last_n_segments
self.no_speech_thresh = no_speech_thresh self.no_speech_thresh = no_speech_thresh
self.clip_audio = clip_audio self.clip_audio = clip_audio
self.same_output_threshold = same_output_threshold self.same_output_threshold = same_output_threshold
self.transcription_callback = transcription_callback self.transcription_callback = transcription_callback
# Translation-specific attributes
self.enable_translation = enable_translation
self.target_language = target_language
self.translation_callback = translation_callback
self.translation_srt_file_path = translation_srt_file_path
self.last_translated_segment = None
if translate: if translate:
self.task = "translate" self.task = "translate"
self.enable_timestamps = enable_timestamps
self.display_segments = display_segments
self.audio_bytes = None self.audio_bytes = None
@@ -116,6 +128,7 @@ class Client:
self.ws_thread.start() self.ws_thread.start()
self.transcript = [] self.transcript = []
self.translated_transcript = []
print("[INFO]: * recording") print("[INFO]: * recording")
def handle_status_messages(self, message_data): def handle_status_messages(self, message_data):
@@ -130,36 +143,77 @@ class Client:
elif status == "WARNING": elif status == "WARNING":
print(f"Message from Server: {message_data['message']}") print(f"Message from Server: {message_data['message']}")
def process_segments(self, segments): def process_segments(self, segments, translated=False):
"""Processes transcript segments.""" """Processes transcript segments."""
text = [] text = []
for i, seg in enumerate(segments): for i, seg in enumerate(segments):
if not text or text[-1] != seg["text"]: if not text or text[-1] != seg["text"]:
text.append(seg["text"]) text.append(seg["text"].strip())
if i == len(segments) - 1 and not seg.get("completed", False): if i == len(segments) - 1 and not seg.get("completed", False):
self.last_segment = seg self.last_segment = seg
elif (self.server_backend == "faster_whisper" and seg.get("completed", False) and elif self.server_backend == "faster_whisper" and seg.get("completed", False):
(not self.transcript or if translated:
float(seg['start']) >= float(self.transcript[-1]['end']))): if (not self.translated_transcript or float(seg['start']) >= float(self.translated_transcript[-1]['end'])):
self.transcript.append(seg) self.translated_transcript.append(seg)
else:
if (not self.transcript or float(seg['start']) >= float(self.transcript[-1]['end'])):
self.transcript.append(seg)
# update last received segment and last valid response time # update last received segment and last valid response time
if self.last_received_segment is None or self.last_received_segment != segments[-1]["text"]: if not translated:
self.last_response_received = time.time() if self.last_received_segment is None or self.last_received_segment != segments[-1]["text"]:
self.last_received_segment = segments[-1]["text"] self.last_response_received = time.time()
self.last_received_segment = segments[-1]["text"]
# call the transcription callback if provided # call the transcription callback if provided
if self.transcription_callback and callable(self.transcription_callback): if translated:
try: if self.translation_callback and callable(self.translation_callback):
self.transcription_callback(" ".join(text), segments) # string, list try:
except Exception as e: self.translation_callback(" ".join(text), segments) # string, list
print(f"[WARN] transcription_callback raised: {e}") except Exception as e:
return print(f"[WARN] translation_callback raised: {e}")
return
else:
if self.transcription_callback and callable(self.transcription_callback):
try:
self.transcription_callback(" ".join(text), segments) # string, list
except Exception as e:
print(f"[WARN] transcription_callback raised: {e}")
return
if self.log_transcription: if self.log_transcription:
# Truncate to last 3 entries for brevity. if self.enable_timestamps:
text = text[-3:] original_text_with_timestamps = [
utils.clear_screen() {"start": seg["start"], "end": seg["end"], "text": seg["text"]}
utils.print_transcript(text) for seg in self.transcript[-self.display_segments:]]
if self.last_segment is not None and not any(
data.get("text") == self.last_segment["text"]
for data in original_text_with_timestamps):
original_text_with_timestamps.append({
"start": self.last_segment["start"],
"end": self.last_segment["end"],
"text": self.last_segment["text"]
})
utils.clear_screen()
utils.print_transcript(original_text_with_timestamps, timestamps=True)
if self.enable_translation:
print(f"\n\nTRANSLATION to {self.target_language}:")
utils.print_transcript([
{"start": seg["start"], "end": seg["end"], "text": seg["text"]}
for seg in self.translated_transcript[-self.display_segments:]
], timestamps=True)
else:
original_text = [seg["text"] for seg in self.transcript[-self.display_segments:]]
if self.last_segment is not None and self.last_segment["text"] not in original_text:
original_text.append(self.last_segment["text"])
utils.clear_screen()
utils.print_transcript(original_text)
if self.enable_translation:
print(f"\n\nTRANSLATION to {self.target_language}:")
utils.print_transcript([seg["text"] for seg in self.translated_transcript[-self.display_segments:]], translated=True)
def on_message(self, ws, message): def on_message(self, ws, message):
""" """
@@ -206,6 +260,9 @@ class Client:
if "segments" in message.keys(): if "segments" in message.keys():
self.process_segments(message["segments"]) self.process_segments(message["segments"])
if "translated_segments" in message.keys():
self.process_segments(message["translated_segments"], translated=True)
def on_error(self, ws, error): def on_error(self, ws, error):
print(f"[ERROR] WebSocket Error: {error}") print(f"[ERROR] WebSocket Error: {error}")
self.server_error = True self.server_error = True
@@ -236,12 +293,12 @@ class Client:
"task": self.task, "task": self.task,
"model": self.model, "model": self.model,
"use_vad": self.use_vad, "use_vad": self.use_vad,
"max_clients": self.max_clients,
"max_connection_time": self.max_connection_time,
"send_last_n_segments": self.send_last_n_segments, "send_last_n_segments": self.send_last_n_segments,
"no_speech_thresh": self.no_speech_thresh, "no_speech_thresh": self.no_speech_thresh,
"clip_audio": self.clip_audio, "clip_audio": self.clip_audio,
"same_output_threshold": self.same_output_threshold, "same_output_threshold": self.same_output_threshold,
"enable_translation": self.enable_translation,
"target_language": self.target_language,
} }
) )
) )
@@ -301,6 +358,9 @@ class Client:
self.transcript.append(self.last_segment) self.transcript.append(self.last_segment)
utils.create_srt_file(self.transcript, output_path) utils.create_srt_file(self.transcript, output_path)
if self.enable_translation:
utils.create_srt_file(self.translated_transcript, self.translation_srt_file_path)
def wait_before_disconnect(self): def wait_before_disconnect(self):
"""Waits a bit before disconnecting in order to process pending responses.""" """Waits a bit before disconnecting in order to process pending responses."""
assert self.last_response_received assert self.last_response_received
@@ -420,14 +480,18 @@ class TranscriptionTeeClient:
# read audio and create pyaudio stream # read audio and create pyaudio stream
with wave.open(filename, "rb") as wavfile: with wave.open(filename, "rb") as wavfile:
self.stream = self.p.open( if self.mute_audio_playback:
format=self.p.get_format_from_width(wavfile.getsampwidth()), self.stream = None
channels=wavfile.getnchannels(), else:
rate=wavfile.getframerate(), self.stream = self.p.open(
input=True, format=self.p.get_format_from_width(wavfile.getsampwidth()),
output=True, channels=wavfile.getnchannels(),
frames_per_buffer=self.chunk, rate=wavfile.getframerate(),
) input=True,
output=True,
frames_per_buffer=self.chunk,
)
chunk_duration = self.chunk / float(wavfile.getframerate()) chunk_duration = self.chunk / float(wavfile.getframerate())
try: try:
while any(client.recording for client in self.clients): while any(client.recording for client in self.clients):
@@ -448,7 +512,8 @@ class TranscriptionTeeClient:
client.wait_before_disconnect() client.wait_before_disconnect()
self.multicast_packet(Client.END_OF_AUDIO.encode('utf-8'), True) self.multicast_packet(Client.END_OF_AUDIO.encode('utf-8'), True)
self.write_all_clients_srt() self.write_all_clients_srt()
self.stream.close() if self.stream:
self.stream.close()
self.close_all_clients() self.close_all_clients()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -695,7 +760,7 @@ class TranscriptionClient(TranscriptionTeeClient):
""" """
Client for handling audio transcription tasks via a single WebSocket connection. Client for handling audio transcription tasks via a single WebSocket connection.
Acts as a high-level client for audio transcription tasks using a WebSocket connection. It can be used Acts as a high-level client for audio transcription tasksoutput_transcription_path using a WebSocket connection. It can be used
to send audio data for transcription to a server and receive transcribed text segments. to send audio data for transcription to a server and receive transcribed text segments.
Args: Args:
@@ -709,14 +774,16 @@ class TranscriptionClient(TranscriptionTeeClient):
output_recording_filename (str, optional): Path to save the output recording WAV file. Default is "./output_recording.wav". output_recording_filename (str, optional): Path to save the output recording WAV file. Default is "./output_recording.wav".
output_transcription_path (str, optional): File path to save the output transcription (SRT file). Default is "./output.srt". output_transcription_path (str, optional): File path to save the output transcription (SRT file). Default is "./output.srt".
log_transcription (bool, optional): Whether to log transcription output to the console. Default is True. log_transcription (bool, optional): Whether to log transcription output to the console. Default is True.
max_clients (int, optional): Maximum number of client connections allowed. Default is 4.
max_connection_time (int, optional): Maximum allowed connection time in seconds. Default is 600.
mute_audio_playback (bool, optional): If True, mutes audio playback during file playback. Default is False. mute_audio_playback (bool, optional): If True, mutes audio playback during file playback. Default is False.
send_last_n_segments (int, optional): Number of most recent segments to send to the client. Defaults to 10. send_last_n_segments (int, optional): Number of most recent segments to send to the client. Defaults to 10.
no_speech_thresh (float, optional): Segments with no speech probability above this threshold will be discarded. Defaults to 0.45. no_speech_thresh (float, optional): Segments with no speech probability above this threshold will be discarded. Defaults to 0.45.
clip_audio (bool, optional): Whether to clip audio with no valid segments. Defaults to False. clip_audio (bool, optional): Whether to clip audio with no valid segments. Defaults to False.
same_output_threshold (int, optional): Number of repeated outputs before considering it as a valid segment. Defaults to 10. same_output_threshold (int, optional): Number of repeated outputs before considering it as a valid segment. Defaults to 10.
transcription_callback (callable, optional): A callback function to handle transcription results. Default is None. transcription_callback (callable, optional): A callback function to handle transcription results. Default is None.
enable_translation (float, optional): Whether to enable translation from any to any language. Defaults to False.
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_srt_file_path (str, optional): The file path to save the translated output SRT file. Default is "output_translated.srt".
Attributes: Attributes:
client (Client): An instance of the underlying Client class responsible for handling the WebSocket connection. client (Client): An instance of the underlying Client class responsible for handling the WebSocket connection.
@@ -741,14 +808,18 @@ class TranscriptionClient(TranscriptionTeeClient):
output_recording_filename="./output_recording.wav", output_recording_filename="./output_recording.wav",
output_transcription_path="./output.srt", output_transcription_path="./output.srt",
log_transcription=True, log_transcription=True,
max_clients=4,
max_connection_time=600,
mute_audio_playback=False, mute_audio_playback=False,
send_last_n_segments=10, send_last_n_segments=10,
no_speech_thresh=0.45, no_speech_thresh=0.45,
clip_audio=False, clip_audio=False,
same_output_threshold=10, same_output_threshold=10,
transcription_callback=None, transcription_callback=None,
enable_translation=False,
target_language="fr",
translation_callback=None,
translation_srt_file_path="./output_translated.srt",
enable_timestamps=False,
display_segments=4,
): ):
self.client = Client( self.client = Client(
host, host,
@@ -760,19 +831,25 @@ class TranscriptionClient(TranscriptionTeeClient):
use_vad=use_vad, use_vad=use_vad,
use_wss=use_wss, use_wss=use_wss,
log_transcription=log_transcription, log_transcription=log_transcription,
max_clients=max_clients,
max_connection_time=max_connection_time,
send_last_n_segments=send_last_n_segments, send_last_n_segments=send_last_n_segments,
no_speech_thresh=no_speech_thresh, no_speech_thresh=no_speech_thresh,
clip_audio=clip_audio, clip_audio=clip_audio,
same_output_threshold=same_output_threshold, same_output_threshold=same_output_threshold,
transcription_callback=transcription_callback, transcription_callback=transcription_callback,
enable_translation=enable_translation,
target_language=target_language,
translation_callback=translation_callback,
translation_srt_file_path=translation_srt_file_path,
enable_timestamps=enable_timestamps,
display_segments=display_segments,
) )
if save_output_recording and not output_recording_filename.endswith(".wav"): if save_output_recording and not output_recording_filename.endswith(".wav"):
raise ValueError(f"Please provide a valid `output_recording_filename`: {output_recording_filename}") raise ValueError(f"Please provide a valid `output_recording_filename`: {output_recording_filename}")
if not output_transcription_path.endswith(".srt"): if not output_transcription_path.endswith(".srt"):
raise ValueError(f"Please provide a valid `output_transcription_path`: {output_transcription_path}. The file extension should be `.srt`.") raise ValueError(f"Please provide a valid `output_transcription_path`: {output_transcription_path}. The file extension should be `.srt`.")
if not translation_srt_file_path.endswith(".srt"):
raise ValueError(f"Please provide a valid `translation_srt_file_path`: {translation_srt_file_path}. The file extension should be `.srt`.")
TranscriptionTeeClient.__init__( TranscriptionTeeClient.__init__(
self, self,
[self.client], [self.client],
+220 -12
View File
@@ -1,12 +1,22 @@
import os import os
import time import time
import threading import threading
import queue
import json import json
import functools import functools
import logging import logging
import shutil
import tempfile
from typing import Optional, List
from fastapi import FastAPI, UploadFile, Form
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import PlainTextResponse, JSONResponse
import uvicorn
from faster_whisper import WhisperModel
import torch
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional
import numpy as np import numpy as np
from websockets.sync.server import serve from websockets.sync.server import serve
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
@@ -15,7 +25,6 @@ from whisper_live.backend.base import ServeClientBase
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
class ClientManager: class ClientManager:
def __init__(self, max_clients=4, max_connection_time=600): def __init__(self, max_clients=4, max_connection_time=600):
""" """
@@ -150,6 +159,7 @@ class TranscriptionServer:
self.no_voice_activity_chunks = 0 self.no_voice_activity_chunks = 0
self.use_vad = True self.use_vad = True
self.single_model = False self.single_model = False
self.batch_config = None
def initialize_client( def initialize_client(
self, websocket, options, faster_whisper_custom_model_path, self, websocket, options, faster_whisper_custom_model_path,
@@ -157,6 +167,35 @@ class TranscriptionServer:
): ):
client: Optional[ServeClientBase] = None client: Optional[ServeClientBase] = None
# Check if client wants translation
enable_translation = options.get("enable_translation", False)
# Create translation queue if translation is enabled
translation_queue = None
translation_client = None
translation_thread = None
if enable_translation:
target_language = options.get("target_language", "fr")
translation_queue = queue.Queue()
from whisper_live.backend.translation_backend import ServeClientTranslation
translation_client = ServeClientTranslation(
client_uid=options["uid"],
websocket=websocket,
translation_queue=translation_queue,
target_language=target_language,
send_last_n_segments=options.get("send_last_n_segments", 10)
)
# Start translation thread
translation_thread = threading.Thread(
target=translation_client.speech_to_text,
daemon=True
)
translation_thread.start()
logging.info(f"Translation enabled for client {options['uid']} with target language: {target_language}")
if self.backend.is_tensorrt(): if self.backend.is_tensorrt():
try: try:
from whisper_live.backend.trt_backend import ServeClientTensorRT from whisper_live.backend.trt_backend import ServeClientTensorRT
@@ -216,7 +255,8 @@ class TranscriptionServer:
try: try:
if self.backend.is_faster_whisper(): if self.backend.is_faster_whisper():
from whisper_live.backend.faster_whisper_backend import ServeClientFasterWhisper from whisper_live.backend.faster_whisper_backend import ServeClientFasterWhisper
if faster_whisper_custom_model_path is not None and os.path.exists(faster_whisper_custom_model_path): # model is of the form namespace/repo_name and not a filesystem path
if faster_whisper_custom_model_path is not None:
logging.info(f"Using custom model {faster_whisper_custom_model_path}") logging.info(f"Using custom model {faster_whisper_custom_model_path}")
options["model"] = faster_whisper_custom_model_path options["model"] = faster_whisper_custom_model_path
client = ServeClientFasterWhisper( client = ServeClientFasterWhisper(
@@ -233,9 +273,23 @@ 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),
cache_path=self.cache_path,
translation_queue=translation_queue
) )
logging.info("Running faster_whisper backend.") logging.info("Running faster_whisper backend.")
# Start batch inference worker on first client (after model is loaded)
if (self.batch_config is not None
and ServeClientFasterWhisper.BATCH_WORKER is None
and ServeClientFasterWhisper.SINGLE_MODEL is not None):
from whisper_live.batch_inference import BatchInferenceWorker
worker = BatchInferenceWorker(
transcriber=ServeClientFasterWhisper.SINGLE_MODEL,
**self.batch_config,
)
worker.start()
ServeClientFasterWhisper.BATCH_WORKER = worker
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
return return
@@ -243,6 +297,10 @@ class TranscriptionServer:
if client is None: if client is None:
raise ValueError(f"Backend type {self.backend.value} not recognised or not handled.") raise ValueError(f"Backend type {self.backend.value} not recognised or not handled.")
if translation_client:
client.translation_client = translation_client
client.translation_thread = translation_thread
self.client_manager.add_client(websocket, client) self.client_manager.add_client(websocket, client)
def get_audio_from_websocket(self, websocket): def get_audio_from_websocket(self, websocket):
@@ -267,11 +325,6 @@ class TranscriptionServer:
options = websocket.recv() options = websocket.recv()
options = json.loads(options) options = json.loads(options)
if self.client_manager is None:
max_clients = options.get('max_clients', 4)
max_connection_time = options.get('max_connection_time', 600)
self.client_manager = ClientManager(max_clients, max_connection_time)
self.use_vad = options.get('use_vad') self.use_vad = options.get('use_vad')
if self.client_manager.is_server_full(websocket, options): if self.client_manager.is_server_full(websocket, options):
websocket.close() websocket.close()
@@ -369,18 +422,51 @@ class TranscriptionServer:
whisper_tensorrt_path=None, whisper_tensorrt_path=None,
trt_multilingual=False, trt_multilingual=False,
trt_py_session=False, trt_py_session=False,
single_model=False): single_model=False,
max_clients=4,
max_connection_time=600,
cache_path="~/.cache/whisper-live/",
rest_port=8000,
enable_rest=False,
cors_origins: Optional[str] = None,
batch_enabled=False,
batch_max_size=8,
batch_window_ms=50):
""" """
Run the transcription server. Run the transcription server.
Args: Args:
host (str): The host address to bind the server. host (str): The host address to bind the server.
port (int): The port number to bind the server. port (int): The port number to bind the server.
batch_enabled (bool): Enable cross-client GPU batch inference for
the faster_whisper backend. When enabled, ``single_model`` is
forced to True and a ``BatchInferenceWorker`` is started after
the first client connects. Defaults to False.
batch_max_size (int): Maximum number of requests per GPU batch.
Defaults to 8.
batch_window_ms (int): Maximum time in milliseconds to wait for
the batch to fill after the first request arrives. Defaults
to 50.
""" """
self.cache_path = cache_path
self.client_manager = ClientManager(max_clients, max_connection_time)
if faster_whisper_custom_model_path is not None and not os.path.exists(faster_whisper_custom_model_path): if faster_whisper_custom_model_path is not None and not os.path.exists(faster_whisper_custom_model_path):
raise ValueError(f"Custom faster_whisper model '{faster_whisper_custom_model_path}' is not a valid path.") if "/" not in faster_whisper_custom_model_path:
raise ValueError(f"Custom faster_whisper model '{faster_whisper_custom_model_path}' is not a valid path or HuggingFace model.")
if whisper_tensorrt_path is not None and not os.path.exists(whisper_tensorrt_path): if whisper_tensorrt_path is not None and not os.path.exists(whisper_tensorrt_path):
raise ValueError(f"TensorRT model '{whisper_tensorrt_path}' is not a valid path.") raise ValueError(f"TensorRT model '{whisper_tensorrt_path}' is not a valid path.")
# Batch inference config
if batch_enabled:
single_model = True # Batch mode requires shared model
self.batch_config = {
'max_batch_size': batch_max_size,
'batch_window_ms': batch_window_ms,
}
logging.info(f"Batch inference enabled (max_batch={batch_max_size}, window={batch_window_ms}ms)")
else:
self.batch_config = None
if single_model: if single_model:
if faster_whisper_custom_model_path or whisper_tensorrt_path: if faster_whisper_custom_model_path or whisper_tensorrt_path:
logging.info("Custom model option was provided. Switching to single model mode.") logging.info("Custom model option was provided. Switching to single model mode.")
@@ -390,6 +476,122 @@ class TranscriptionServer:
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):
raise ValueError(f"{backend} is not a valid backend type. Choose backend from {BackendType.valid_types()}") raise ValueError(f"{backend} is not a valid backend type. Choose backend from {BackendType.valid_types()}")
# New OpenAI-compatible REST API (toggleable via enable_rest boolean)
if enable_rest:
app = FastAPI(title="WhisperLive OpenAI-Compatible API")
origins = [o.strip() for o in cors_origins.split(',')] if cors_origins else []
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"], # Allows all methods (GET, POST, etc.)
allow_headers=["*"], # Allows all headers
)
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile,
model: str = Form(default="whisper-1"),
language: Optional[str] = Form(default=None),
prompt: Optional[str] = Form(default=None),
response_format: str = Form(default="json"),
temperature: float = Form(default=0.0),
timestamp_granularities: Optional[List[str]] = Form(default=None),
# Stubs for unsupported OpenAI params
chunking_strategy: Optional[str] = Form(default=None),
include: Optional[List[str]] = Form(default=None),
known_speaker_names: Optional[List[str]] = Form(default=None),
known_speaker_references: Optional[List[str]] = Form(default=None),
stream: bool = Form(default=False)
):
if stream:
return JSONResponse({"error": "Streaming not supported in this backend."}, status_code=400)
if chunking_strategy or known_speaker_names or known_speaker_references:
logging.warning("Diarization/chunking params ignored; not supported.")
supported_formats = ["json", "text", "srt", "verbose_json", "vtt"]
if response_format not in supported_formats:
return JSONResponse({"error": f"Unsupported response_format. Supported: {supported_formats}"}, status_code=400)
if model != "whisper-1":
logging.warning(f"Model '{model}' requested; using 'small' as fallback.")
model_name = faster_whisper_custom_model_path or "small"
try:
suffix = os.path.splitext(file.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
shutil.copyfileobj(file.file, tmp)
tmp_path = tmp.name
device = "cuda" if torch.cuda.is_available() else "cpu"
compute_type = "float16" if device == "cuda" else "int8"
transcriber = WhisperModel(model_name, device=device, compute_type=compute_type)
segments, info = transcriber.transcribe(
tmp_path,
language=language,
initial_prompt=prompt,
temperature=temperature,
vad_filter=False,
word_timestamps=(timestamp_granularities and "word" in timestamp_granularities)
)
text = " ".join([s.text.strip() for s in segments])
os.unlink(tmp_path)
if response_format == "text":
return PlainTextResponse(text)
elif response_format == "json":
return {"text": text}
elif response_format == "verbose_json":
verbose = {
"task": "transcribe",
"language": info.language,
"duration": info.duration,
"text": text,
"segments": []
}
for seg in segments:
seg_dict = {
"id": seg.id,
"seek": seg.seek,
"start": seg.start,
"end": seg.end,
"text": seg.text.strip(),
"tokens": seg.tokens,
"temperature": seg.temperature,
"avg_logprob": seg.avg_logprob,
"compression_ratio": seg.compression_ratio,
"no_speech_prob": seg.no_speech_prob
}
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]
verbose["segments"].append(seg_dict)
return verbose
elif response_format in ["srt", "vtt"]:
output = []
for i, seg in enumerate(segments, 1):
start = f"{int(seg.start // 3600):02}:{int((seg.start % 3600) // 60):02}:{seg.start % 60:06.3f}"
end = f"{int(seg.end // 3600):02}:{int((seg.end % 3600) // 60):02}:{seg.end % 60:06.3f}"
if response_format == "srt":
output.append(f"{i}\n{start.replace('.', ',')} --> {end.replace('.', ',')}\n{seg.text.strip()}\n")
else: # vtt
output.append(f"{start} --> {end}\n{seg.text.strip()}\n")
return PlainTextResponse("\n".join(output))
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
threading.Thread(
target=uvicorn.run,
args=(app,),
kwargs={"host": "0.0.0.0", "port": rest_port, "log_level": "info"},
daemon=True
).start()
logging.info(f"✅ OpenAI-Compatible API started on http://0.0.0.0:{rest_port}")
# Original WebSocket server (always supported)
with serve( with serve(
functools.partial( functools.partial(
self.recv_audio, self.recv_audio,
@@ -441,6 +643,12 @@ class TranscriptionServer:
Args: Args:
websocket: The websocket associated with the client to be cleaned up. websocket: The websocket associated with the client to be cleaned up.
""" """
if self.client_manager.get_client(websocket): client = self.client_manager.get_client(websocket)
self.client_manager.remove_client(websocket) if client:
if hasattr(client, 'translation_client') and client.translation_client:
client.translation_client.cleanup()
# Wait for translation thread to finish
if hasattr(client, 'translation_thread') and client.translation_thread:
client.translation_thread.join(timeout=2.0)
self.client_manager.remove_client(websocket)
@@ -27,7 +27,6 @@ from faster_whisper.vad import (
VadOptions, VadOptions,
collect_chunks, collect_chunks,
get_speech_timestamps, get_speech_timestamps,
merge_segments,
) )
@@ -407,8 +406,7 @@ class BatchedInferencePipeline:
**vad_parameters, max_speech_duration_s=chunk_length **vad_parameters, max_speech_duration_s=chunk_length
) )
active_segments = get_speech_timestamps(audio, vad_parameters) clip_timestamps = get_speech_timestamps(audio, vad_parameters)
clip_timestamps = merge_segments(active_segments, vad_parameters)
# run the audio if it is less than 30 sec even without clip_timestamps # run the audio if it is less than 30 sec even without clip_timestamps
elif duration < chunk_length: elif duration < chunk_length:
clip_timestamps = [{"start": 0, "end": audio.shape[0]}] clip_timestamps = [{"start": 0, "end": audio.shape[0]}]
+9 -4
View File
@@ -11,11 +11,16 @@ def clear_screen():
os.system("cls" if os.name == "nt" else "clear") os.system("cls" if os.name == "nt" else "clear")
def print_transcript(text): def print_transcript(text, translated=False, timestamps=False):
"""Prints formatted transcript text.""" """Prints formatted transcript text."""
wrapper = textwrap.TextWrapper(width=60) if timestamps:
for line in wrapper.wrap(text="".join(text)): for t in text:
print(line) print(f'[{t["start"]} -> {t["end"]}] {t["text"]}')
else:
wrapper = textwrap.TextWrapper(width=60)
text=" ".join(text) if translated else "".join(text)
for line in wrapper.wrap(text=text):
print(line)
def format_time(s): def format_time(s):