Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6de5c87d2f | |||
| 8e09d16ee4 | |||
| 4943c25ff7 | |||
| 710bdffb51 | |||
| 5f0010d720 | |||
| 6fcae6a30c | |||
| bc441dea23 | |||
| 89466f7b77 | |||
| 6ae57c81cd | |||
| 5d8629ea0c | |||
| e7e78a7151 | |||
| 3508b39584 | |||
| 067573a510 | |||
| e8bd4fd532 | |||
| f8869906b0 | |||
| 9fa7005511 | |||
| e48d16f923 | |||
| 98fcc5110b | |||
| 5e33aa2a7e | |||
| 6c8142a9d2 | |||
| 29ee640409 | |||
| b9ae2af8e6 | |||
| 9251394047 | |||
| c6ee9a6870 | |||
| f5256fc62f | |||
| c43eb1dd5a | |||
| 3b17bda5f9 | |||
| 95a9b7ef05 | |||
| 5e6be74f6d | |||
| 04db67170b | |||
| 5ce401d4c6 | |||
| 39dfd7521f | |||
| 2b8b245fa8 | |||
| 1ec437e71f | |||
| bf6251e3b8 | |||
| 8d6ddd4f7b | |||
| 8d785e5681 | |||
| 368bcdd81f | |||
| d9e608f5c8 | |||
| ad0fb23936 | |||
| 914281f449 | |||
| 40edd25468 | |||
| ad11b2b0ef | |||
| ddd32cc30f | |||
| e597c876cf | |||
| 9954548075 | |||
| 0f21c80ed8 | |||
| d79e720b34 | |||
| f3acfa2f18 | |||
| 2e5aae6585 | |||
| 179b56a260 | |||
| 4ae3825661 | |||
| 198a499f96 | |||
| 05002d6ded | |||
| 74abf66d48 | |||
| 0520978c0e | |||
| 12f3bb2012 | |||
| bff88ed3e7 |
@@ -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);
|
||||||
@@ -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});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '';
|
|
||||||
for (var i = 0; i < message.length; i++) {
|
if (saveCaptions === true) {
|
||||||
text += message[i].text + ' ';
|
segments.forEach(seg => {
|
||||||
}
|
if (seg.completed === true &&
|
||||||
text = text.replace(/(\r\n|\n|\r)/gm, "");
|
(allSegments.length === 0 || parseFloat(seg.start) >= parseFloat(allSegments[allSegments.length - 1].end))) {
|
||||||
|
allSegments.push({
|
||||||
var elem = document.getElementById('t3');
|
start: seg.start,
|
||||||
elem.innerHTML = text;
|
end: seg.end,
|
||||||
|
text: seg.text
|
||||||
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;
|
lastIncompleteSegment = null;
|
||||||
var lines = divHeight / line_height;
|
} else if (seg.completed !== true) {
|
||||||
|
lastIncompleteSegment = seg;
|
||||||
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, "");
|
||||||
|
|
||||||
|
var elem = document.getElementById('t3');
|
||||||
|
if (elem) {
|
||||||
|
elem.innerHTML = text;
|
||||||
|
|
||||||
if (text_segments.length <= 2) {
|
var line_height_style = getStyle('t3', 'line-height');
|
||||||
for (var i = 0; i < text_segments.length; i++) {
|
var line_height = parseInt(line_height_style.substring(0, line_height_style.length - 2));
|
||||||
document.getElementById('t' + i).innerHTML = text_segments[i];
|
var divHeight = elem.offsetHeight;
|
||||||
}
|
var lines = divHeight / line_height;
|
||||||
} 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++)
|
text_segments = [];
|
||||||
{
|
text_segments = get_lines(elem, line_height);
|
||||||
var parent_elem = document.getElementById('t' + (i - 1));
|
|
||||||
var elem = document.getElementById('t' + i);
|
elem.innerHTML = '';
|
||||||
elem.style.top = parent_elem.offsetHeight + parent_elem.offsetTop + 'px';
|
|
||||||
|
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({});
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
|
|
||||||
"name": "Audio Transcription",
|
"name": "Audio Transcription",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "This extension captures the audio on the current tab, sends it to a server for transcription and shows the transcription in Real-time.",
|
"description": "This extension captures the audio on the current tab, sends it to a server for transcription and shows the transcription in Real-time.",
|
||||||
|
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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,24 +41,99 @@ 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({
|
||||||
uid: uuid,
|
uid: uuid,
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -318,12 +338,20 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||||||
socket.close();
|
socket.close();
|
||||||
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,8 +365,25 @@ 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++) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -18,4 +20,8 @@ openvino
|
|||||||
openvino-genai
|
openvino-genai
|
||||||
openvino-tokenizers
|
openvino-tokenizers
|
||||||
optimum
|
optimum
|
||||||
optimum-intel
|
optimum-intel
|
||||||
|
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-multipart
|
||||||
@@ -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)
|
||||||
+63
-1
@@ -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
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from whisper_live.__version__ import __version__
|
||||||
|
|
||||||
|
__all__ = ['__version__']
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.7.1"
|
__version__ = "0.8.0"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
+121
-44
@@ -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):
|
||||||
"""
|
"""
|
||||||
@@ -205,6 +259,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}")
|
||||||
@@ -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],
|
||||||
|
|||||||
+221
-13
@@ -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]}]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user