Merge pull request #385 from makaveli10/add_srt_download_opt_browser_ext

Add srt download opt browser ext
This commit is contained in:
makaveli
2025-07-16 15:49:40 +05:30
committed by GitHub
14 changed files with 588 additions and 239 deletions
+1
View File
@@ -27,6 +27,7 @@ To capture the audio in the current tab, we used the chrome `tabCapture` API to
When using the Audio Transcription extension, you have the following options: When using the Audio Transcription extension, you have the following options:
- **Use Collabora Server**: We provide a demo server which runs the whisper small model. - **Use Collabora Server**: We provide a demo server which runs the whisper small model.
- **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper. - **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper.
- **Download SRT file at Stop Capture**: Select if you want to download the srt file for the session at stop capture.
- **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English. - **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English.
- **Model Size**: Select the whisper model size to run the server with. - **Model Size**: Select the whisper model size to run the server with.
@@ -0,0 +1,77 @@
class AudioPreProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.sampleRate = sampleRate || 48000;
this.targetSampleRate = 16000;
this.inputSamplesNeeded = this.sampleRate * 0.5; // 0.5s
this.inputBuffer = new Float32Array(this.inputSamplesNeeded);
this.inputWriteOffset = 0;
this.processCount = 0;
this.audioDetectedCount = 0;
}
process(inputs, outputs) {
this.processCount++;
const input = inputs[0];
const output = outputs[0];
if (!input || input.length === 0) {
return true;
}
for (let channel = 0; channel < Math.min(input.length, output.length); channel++) {
if (input[channel] && output[channel]) {
output[channel].set(input[channel]);
}
}
let monoInput;
if (input.length === 1) {
monoInput = input[0];
} else if (input.length >= 2) {
monoInput = new Float32Array(input[0].length);
for (let i = 0; i < input[0].length; i++) {
monoInput[i] = (input[0][i] + (input[1] ? input[1][i] : 0)) * 0.5;
}
} else {
return true;
}
if (!monoInput || monoInput.length === 0) {
return true;
}
let inputOffset = 0;
while (inputOffset < monoInput.length) {
const remainingBuffer = this.inputSamplesNeeded - this.inputWriteOffset;
const toCopy = Math.min(remainingBuffer, monoInput.length - inputOffset);
this.inputBuffer.set(monoInput.subarray(inputOffset, inputOffset + toCopy), this.inputWriteOffset);
this.inputWriteOffset += toCopy;
inputOffset += toCopy;
if (this.inputWriteOffset === this.inputSamplesNeeded) {
const downsampled = this.downsampleTo16kHz(this.inputBuffer);
this.port.postMessage(downsampled);
this.inputWriteOffset = 0;
}
}
return true;
}
downsampleTo16kHz(inputBuffer) {
const ratio = this.sampleRate / this.targetSampleRate;
const length = Math.floor(inputBuffer.length / ratio);
const result = new Float32Array(length);
for (let i = 0; i < length; i++) {
const idx = Math.floor(i * ratio);
result[i] = inputBuffer[idx];
}
return result;
}
}
registerProcessor('audiopreprocessor', AudioPreProcessor);
+5 -4
View File
@@ -159,6 +159,7 @@ async function startCapture(options) {
task: options.task, task: options.task,
modelSize: options.modelSize, modelSize: options.modelSize,
useVad: options.useVad, useVad: options.useVad,
saveCaptions: options.saveCaptions,
}, },
}); });
} else { } else {
@@ -174,14 +175,14 @@ async function startCapture(options) {
* Stops the capture process and performs cleanup. * Stops the capture process and performs cleanup.
* @returns {Promise<void>} - A Promise that resolves when the capture process is stopped successfully. * @returns {Promise<void>} - A Promise that resolves when the capture process is stopped successfully.
*/ */
async function stopCapture() { async function stopCapture(options) {
const optionTabId = await getLocalStorageValue("optionTabId"); const optionTabId = await getLocalStorageValue("optionTabId");
const currentTabId = await getLocalStorageValue("currentTabId"); const currentTabId = await getLocalStorageValue("currentTabId");
if (optionTabId) { if (optionTabId) {
res = await sendMessageToTab(currentTabId, { res = await sendMessageToTab(currentTabId, {
type: "STOP", type: "STOP",
data: { currentTabId: currentTabId }, data: { currentTabId: currentTabId, saveCaptions: options.saveCaptions },
}); });
await removeChromeTab(optionTabId); await removeChromeTab(optionTabId);
} }
@@ -196,7 +197,7 @@ chrome.runtime.onMessage.addListener(async (message) => {
if (message.action === "startCapture") { if (message.action === "startCapture") {
startCapture(message); startCapture(message);
} else if (message.action === "stopCapture") { } else if (message.action === "stopCapture") {
stopCapture(); stopCapture(message);
} else if (message.action === "updateSelectedLanguage") { } else if (message.action === "updateSelectedLanguage") {
const detectedLanguage = message.detectedLanguage; const detectedLanguage = message.detectedLanguage;
chrome.runtime.sendMessage({ action: "updateSelectedLanguage", detectedLanguage }); chrome.runtime.sendMessage({ action: "updateSelectedLanguage", detectedLanguage });
@@ -204,7 +205,7 @@ chrome.runtime.onMessage.addListener(async (message) => {
} else if (message.action === "toggleCaptureButtons") { } else if (message.action === "toggleCaptureButtons") {
chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false }); chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false });
chrome.storage.local.set({ capturingState: { isCapturing: false } }) chrome.storage.local.set({ capturingState: { isCapturing: false } })
stopCapture(); stopCapture({saveCaptions: message.saveCaptions});
} }
}); });
+83 -7
View File
@@ -1,10 +1,45 @@
var elem_container = null; var elem_container = null;
var elem_text = null; var elem_text = null;
var segments = []; var segments = [];
var text_segments = []; var text_segments = [];
var allSegments = [];
var lastIncompleteSegment = null;
function formatTime(seconds) {
const date = new Date(seconds * 1000);
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
const ss = String(date.getUTCSeconds()).padStart(2, '0');
const mmm = String(date.getUTCMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss},${mmm}`;
}
function generateSRT() {
return allSegments
.map((seg, i) => {
const start = formatTime(seg.start);
const end = formatTime(seg.end);
const text = seg.text.trim().replace(/[\r\n]+/g, ' ');
return `${i + 1}\n${start} --> ${end}\n${text}`;
})
.join('\n\n');
}
function downloadSRT() {
console.log("downloadSRT called");
console.log("Total segments for SRT:", allSegments.length);
const srtBlob = new Blob([generateSRT()], { type: 'text/srt;charset=utf-8' });
const url = URL.createObjectURL(srtBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'captions.srt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function initPopupElement() { function initPopupElement() {
if (document.getElementById('popupElement')) { if (document.getElementById('popupElement')) {
@@ -32,7 +67,7 @@ function initPopupElement() {
closePopupButton.style.cursor = 'pointer'; closePopupButton.style.cursor = 'pointer';
closePopupButton.addEventListener('click', async () => { closePopupButton.addEventListener('click', async () => {
popupContainer.style.display = 'none'; popupContainer.style.display = 'none';
await browser.runtime.sendMessage({ action: 'toggleCaptureButtons', data: false }); await chrome.runtime.sendMessage({ action: 'toggleCaptureButtons', data: false });
}); });
buttonContainer.appendChild(closePopupButton); buttonContainer.appendChild(closePopupButton);
popupContainer.appendChild(buttonContainer); popupContainer.appendChild(buttonContainer);
@@ -169,8 +204,25 @@ function remove_element() {
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const { type, data } = request; const { type, data } = request;
const saveCaptions = data.saveCaptions;
if (type === "STOP") { if (type === "STOP") {
if (saveCaptions === true) {
// If there is a last incomplete segment, push it to allSegments
if (lastIncompleteSegment && lastIncompleteSegment.text && lastIncompleteSegment.text.trim() !== "") {
// Apply same Python logic: check if transcript is empty OR start >= last end
if (allSegments.length === 0 || parseFloat(lastIncompleteSegment.start) >= parseFloat(allSegments[allSegments.length - 1].end)) {
allSegments.push({
start: lastIncompleteSegment.start,
end: lastIncompleteSegment.end,
text: lastIncompleteSegment.text
});
console.log("Added final incomplete segment");
}
}
downloadSRT();
}
remove_element(); remove_element();
sendResponse({data: "STOPPED"}); sendResponse({data: "STOPPED"});
return true; return true;
@@ -184,16 +236,34 @@ 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"];
if (saveCaptions === true) {
segments.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 < segments.length; i++) {
text += message[i].text + ' '; text += segments[i].text + ' ';
} }
text = text.replace(/(\r\n|\n|\r)/gm, ""); text = text.replace(/(\r\n|\n|\r)/gm, "");
var elem = document.getElementById('t3'); var elem = document.getElementById('t3');
if (elem) {
elem.innerHTML = text; elem.innerHTML = text;
var line_height_style = getStyle('t3', 'line-height'); var line_height_style = getStyle('t3', 'line-height');
@@ -230,8 +300,14 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
{ {
var parent_elem = document.getElementById('t' + (i - 1)); var parent_elem = document.getElementById('t' + (i - 1));
var elem = document.getElementById('t' + i); var elem = document.getElementById('t' + i);
if (parent_elem && elem) {
elem.style.top = parent_elem.offsetHeight + parent_elem.offsetTop + 'px'; elem.style.top = parent_elem.offsetHeight + parent_elem.offsetTop + 'px';
} }
}
}
} catch (error) {
console.error("Error processing message:", error);
}
sendResponse({}); sendResponse({});
return true; return true;
+6
View File
@@ -9,6 +9,12 @@
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
"web_accessible_resources": [
{
"resources": ["audiopreprocessor.js"],
"matches": ["<all_urls>"]
}
],
"permissions": [ "permissions": [
"storage", "storage",
"activeTab", "activeTab",
+90 -61
View File
@@ -31,41 +31,6 @@ function sendMessageToTab(tabId, data) {
}); });
} }
/**
* Resamples the audio data to a target sample rate of 16kHz.
* @param {Array|ArrayBuffer|TypedArray} audioData - The input audio data.
* @param {number} [origSampleRate=44100] - The original sample rate of the audio data.
* @returns {Float32Array} The resampled audio data at 16kHz.
*/
function resampleTo16kHZ(audioData, origSampleRate = 44100) {
// Convert the audio data to a Float32Array
const data = new Float32Array(audioData);
// Calculate the desired length of the resampled data
const targetLength = Math.round(data.length * (16000 / origSampleRate));
// Create a new Float32Array for the resampled data
const resampledData = new Float32Array(targetLength);
// Calculate the spring factor and initialize the first and last values
const springFactor = (data.length - 1) / (targetLength - 1);
resampledData[0] = data[0];
resampledData[targetLength - 1] = data[data.length - 1];
// Resample the audio data
for (let i = 1; i < targetLength - 1; i++) {
const index = i * springFactor;
const leftIndex = Math.floor(index).toFixed();
const rightIndex = Math.ceil(index).toFixed();
const fraction = index - leftIndex;
resampledData[i] = data[leftIndex] + (data[rightIndex] - data[leftIndex]) * fraction;
}
// Return the resampled data
return resampledData;
}
function generateUUID() { function generateUUID() {
let dt = new Date().getTime(); let dt = new Date().getTime();
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -76,23 +41,98 @@ function generateUUID() {
return uuid; return uuid;
} }
// Global variables for audio processing
let audioContext = null;
let preNode = null;
let socket = null;
let isServerReady = false;
let currentStream = null;
let currentOptions = null;
// AudioWorklet URL - make sure this path matches your manifest.json
const WORKLET_URL = chrome.runtime.getURL('audiopreprocessor.js');
async function initAudioWorklet(stream) {
audioContext = new AudioContext();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
try {
await audioContext.audioWorklet.addModule(WORKLET_URL);
preNode = new AudioWorkletNode(audioContext, 'audiopreprocessor');
const mediaStream = audioContext.createMediaStreamSource(stream);
mediaStream.connect(preNode);
preNode.connect(audioContext.destination);
preNode.port.onmessage = (event) => {
const data = event.data;
const audio16k = data; // Float32Array @ 16 kHz
if (socket && socket.readyState === WebSocket.OPEN && isServerReady) {
socket.send(audio16k);
}
};
// Test if we can hear audio (this will help verify the audio path)
} catch (error) {
console.error("Error initializing AudioWorklet:", error);
throw error;
}
}
function cleanupAudio() {
if (preNode) {
preNode.port.onmessage = null;
preNode.disconnect();
preNode = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (currentStream) {
currentStream.getTracks().forEach(track => {
track.stop();
console.log("Stopped track:", track.kind);
});
currentStream = null;
}
}
/** /**
* Starts recording audio from the captured tab. * Starts recording audio from the captured tab.
* @param {Object} option - The options object containing the currentTabId. * @param {Object} option - The options object containing the currentTabId.
*/ */
async function startRecord(option) { async function startRecord(option) {
currentOptions = option;
const stream = await captureTabAudio(); const stream = await captureTabAudio();
const uuid = generateUUID(); const uuid = generateUUID();
if (stream) { if (stream) {
// call when the stream inactive currentStream = stream;
stream.oninactive = () => { stream.oninactive = () => {
cleanupAudio();
window.close(); window.close();
}; };
const socket = new WebSocket(`ws://${option.host}:${option.port}/`);
let isServerReady = false; try {
await initAudioWorklet(stream);
} catch (error) {
console.error("Failed to initialize AudioWorklet:", error);
return;
}
socket = new WebSocket(`ws://${option.host}:${option.port}/`);
isServerReady = false;
let language = option.language; let language = option.language;
socket.onopen = function(e) { socket.onopen = function(e) {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -129,7 +169,6 @@ async function startRecord(option) {
language = data["language"]; language = data["language"];
// send message to popup.js to update dropdown // send message to popup.js to update dropdown
// console.log(language);
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: "updateSelectedLanguage", action: "updateSelectedLanguage",
detectedLanguage: language, detectedLanguage: language,
@@ -139,43 +178,33 @@ async function startRecord(option) {
} }
if (data["message"] === "DISCONNECT"){ if (data["message"] === "DISCONNECT"){
chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false }) chrome.runtime.sendMessage({ action: "toggleCaptureButtons", data: false, saveCaptions: option.saveCaptions });
return; return;
} }
res = await sendMessageToTab(option.currentTabId, { const res = await sendMessageToTab(option.currentTabId, {
type: "transcript", type: "transcript",
data: {
data: event.data, data: event.data,
saveCaptions: option.saveCaptions,
},
}); });
}; };
socket.onclose = () => {
const audioDataCache = []; cleanupAudio();
const context = new AudioContext(); };
const mediaStream = context.createMediaStreamSource(stream);
const recorder = context.createScriptProcessor(4096, 1, 1); socket.onerror = (error) => {
cleanupAudio();
recorder.onaudioprocess = async (event) => {
if (!context || !isServerReady) return;
const inputData = event.inputBuffer.getChannelData(0);
const audioData16kHz = resampleTo16kHZ(inputData, context.sampleRate);
audioDataCache.push(inputData);
socket.send(audioData16kHz);
}; };
// Prevent page mute
mediaStream.connect(recorder);
recorder.connect(context.destination);
mediaStream.connect(context.destination);
// }
} else { } else {
window.close(); window.close();
} }
} }
/** /**
* Listener for incoming messages from the extension's background script. * Listener for incoming messages from the extension's background script.
* @param {Object} request - The message request object. * @param {Object} request - The message request object.
+4
View File
@@ -19,6 +19,10 @@
<input type="checkbox" id="useVadCheckbox"> <input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label> <label for="useVadCheckbox">Use Voice Activity Detection</label>
</div> </div>
<div class="checkbox-container">
<input type="checkbox" id="saveCaptionsCheckbox">
<label for="saveCaptions">Download SRT file at Stop Capture</label>
</div>
<div class="dropdown-container"> <div class="dropdown-container">
<label for="languageDropdown">Select Language:</label> <label for="languageDropdown">Select Language:</label>
<select id="languageDropdown"> <select id="languageDropdown">
+19 -1
View File
@@ -5,6 +5,7 @@ document.addEventListener("DOMContentLoaded", function () {
const useServerCheckbox = document.getElementById("useServerCheckbox"); const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox"); const useVadCheckbox = document.getElementById("useVadCheckbox");
const saveCaptionsCheckbox = document.getElementById("saveCaptionsCheckbox");
const languageDropdown = document.getElementById('languageDropdown'); const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown'); const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown'); const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -38,6 +39,12 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}); });
chrome.storage.local.get("saveCaptionsState", ({ saveCaptionsState }) => {
if (saveCaptionsState !== undefined) {
saveCaptionsCheckbox.checked = saveCaptionsState;
}
});
chrome.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => { chrome.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) { if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage; languageDropdown.value = storedLanguage;
@@ -88,6 +95,7 @@ document.addEventListener("DOMContentLoaded", function () {
task: selectedTask, task: selectedTask,
modelSize: selectedModelSize, modelSize: selectedModelSize,
useVad: useVadCheckbox.checked, useVad: useVadCheckbox.checked,
saveCaptions: saveCaptionsCheckbox.checked,
}, () => { }, () => {
// Update capturing state in storage and toggle the buttons // Update capturing state in storage and toggle the buttons
chrome.storage.local.set({ capturingState: { isCapturing: true } }, () => { chrome.storage.local.set({ capturingState: { isCapturing: true } }, () => {
@@ -105,7 +113,11 @@ document.addEventListener("DOMContentLoaded", function () {
} }
// Send a message to the background script to stop capturing // Send a message to the background script to stop capturing
chrome.runtime.sendMessage({ action: "stopCapture" }, () => { chrome.runtime.sendMessage(
{
action: "stopCapture",
saveCaptions: saveCaptionsCheckbox.checked,
}, () => {
// Update capturing state in storage and toggle the buttons // Update capturing state in storage and toggle the buttons
chrome.storage.local.set({ capturingState: { isCapturing: false } }, () => { chrome.storage.local.set({ capturingState: { isCapturing: false } }, () => {
toggleCaptureButtons(false); toggleCaptureButtons(false);
@@ -128,6 +140,7 @@ document.addEventListener("DOMContentLoaded", function () {
stopButton.disabled = !isCapturing; stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing; useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing; useVadCheckbox.disabled = isCapturing;
saveCaptionsCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing; modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing; languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing; taskDropdown.disabled = isCapturing;
@@ -146,6 +159,11 @@ document.addEventListener("DOMContentLoaded", function () {
chrome.storage.local.set({ useVadState }); chrome.storage.local.set({ useVadState });
}); });
saveCaptionsCheckbox.addEventListener("change", () => {
const saveCaptionsState = saveCaptionsCheckbox.checked;
chrome.storage.local.set({ saveCaptionsState });
});
languageDropdown.addEventListener('change', function() { languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") { if (languageDropdown.value === "") {
selectedLanguage = null; selectedLanguage = null;
+1
View File
@@ -25,6 +25,7 @@ To capture the audio in the current tab, we used the chrome `tabCapture` API to
When using the Audio Transcription extension, you have the following options: When using the Audio Transcription extension, you have the following options:
- **Use Collabora Server**: We provide a demo server which runs the whisper small model. - **Use Collabora Server**: We provide a demo server which runs the whisper small model.
- **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper. - **Language**: Select the target language for transcription or translation. You can choose from a variety of languages supported by OpenAI-whisper.
- **Download SRT file at Stop Capture**: Select if you want to download the srt file for the session at stop capture.
- **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English. - **Task:** Choose the specific task to perform on the audio. You can select either "transcribe" for transcription or "translate" to translate the audio to English.
- **Model Size**: Select the whisper model size to run the server with. - **Model Size**: Select the whisper model size to run the server with.
@@ -0,0 +1,70 @@
class AudioPreProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.sampleRate = sampleRate || 48000;
this.targetSampleRate = 16000;
this.inputSamplesNeeded = this.sampleRate * 0.5;
this.inputBuffer = new Float32Array(this.inputSamplesNeeded);
this.inputWriteOffset = 0;
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
if (!input || input.length === 0) {
return true;
}
for (let channel = 0; channel < Math.min(input.length, output.length); channel++) {
if (input[channel] && output[channel]) {
output[channel].set(input[channel]);
}
}
let monoInput;
if (input.length === 1) {
monoInput = input[0];
} else if (input.length > 1) {
monoInput = new Float32Array(input[0].length);
for (let channel = 0; channel < input.length; channel++) {
monoInput.set(input[channel], 0);
}
}
if (!monoInput) {
return true;
}
let inputOffset = 0;
while (inputOffset < monoInput.length) {
const remainingBuffer = this.inputSamplesNeeded - this.inputWriteOffset;
const toCopy = Math.min(remainingBuffer, monoInput.length - inputOffset);
this.inputBuffer.set(monoInput.subarray(inputOffset, inputOffset + toCopy), this.inputWriteOffset);
this.inputWriteOffset += toCopy;
inputOffset += toCopy;
if (this.inputWriteOffset === this.inputSamplesNeeded) {
const downsampled = this.downsampleTo16kHz(this.inputBuffer);
this.port.postMessage(downsampled);
this.inputWriteOffset = 0;
}
}
return true;
}
downsampleTo16kHz(inputBuffer) {
const ratio = this.sampleRate / this.targetSampleRate;
const length = Math.floor(inputBuffer.length / ratio);
const result = new Float32Array(length);
for (let i = 0; i < length; i++) {
const idx = Math.floor(i * ratio);
result[i] = inputBuffer[idx];
}
return result;
}
}
registerProcessor('audiopreprocessor', AudioPreProcessor);
+149 -104
View File
@@ -1,144 +1,162 @@
let socket = null; let socket = null;
let isCapturing = false; let isCapturing = false;
let mediaStream = null;
let audioContext = null; let audioContext = null;
let scriptProcessor = null;
let language = null; let language = null;
let isPaused = false; let isPaused = false;
let preNode = null;
let allSegments = [];
let lastIncompleteSegment = null;
const mediaElements = document.querySelectorAll('video, audio'); function formatTime(seconds) {
mediaElements.forEach((mediaElement) => { const date = new Date(seconds * 1000);
mediaElement.addEventListener('play', handlePlaybackStateChange); const hh = String(date.getUTCHours()).padStart(2, '0');
mediaElement.addEventListener('pause', handlePlaybackStateChange); const mm = String(date.getUTCMinutes()).padStart(2, '0');
}); const ss = String(date.getUTCSeconds()).padStart(2, '0');
const mmm = String(date.getUTCMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss},${mmm}`;
function handlePlaybackStateChange(event) {
isPaused = event.target.paused;
} }
function generateSRT() {
return allSegments
.map((seg, i) => {
const start = formatTime(seg.start);
const end = formatTime(seg.end);
const text = seg.text.trim().replace(/[\r\n]+/g, ' ');
return `${i + 1}\n${start} --> ${end}\n${text}`;
})
.join('\n\n');
}
function downloadSRT() {
const srtBlob = new Blob([generateSRT()], { type: 'text/srt;charset=utf-8' });
const url = URL.createObjectURL(srtBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'captions.srt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function generateUUID() { function generateUUID() {
let dt = new Date().getTime(); let dt = new Date().getTime();
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (dt + Math.random() * 16) % 16 | 0; const r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16); dt = Math.floor(dt / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
}); });
return uuid;
} }
/** document.querySelectorAll('video, audio').forEach(el => {
* Resamples the audio data to a target sample rate of 16kHz. el.addEventListener('play', () => { isPaused = false; });
* @param {Array|ArrayBuffer|TypedArray} audioData - The input audio data. el.addEventListener('pause', () => { isPaused = true; });
* @param {number} [origSampleRate=44100] - The original sample rate of the audio data. });
* @returns {Float32Array} The resampled audio data at 16kHz.
*/
function resampleTo16kHZ(audioData, origSampleRate = 44100) {
// Convert the audio data to a Float32Array
const data = new Float32Array(audioData);
// Calculate the desired length of the resampled data
const targetLength = Math.round(data.length * (16000 / origSampleRate));
// Create a new Float32Array for the resampled data function setupMessageHandler() {
const resampledData = new Float32Array(targetLength); if (preNode) {
preNode.port.onmessage = e => {
// Calculate the spring factor and initialize the first and last values const audio16k = e.data;
const springFactor = (data.length - 1) / (targetLength - 1); if (isCapturing && socket && socket.readyState === WebSocket.OPEN && !isPaused) {
resampledData[0] = data[0]; socket.send(audio16k);
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; const WORKLET_URL = browser.runtime.getURL('audiopreprocessor.js');
async function initAudioWorklet() {
if (audioContext && preNode) {
setupMessageHandler();
return;
}
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();
} }
function startRecording(data) { async function startRecording(data) {
if (!audioContext) {
await initAudioWorklet();
}
const uid = generateUUID();
socket = new WebSocket(`ws://${data.host}:${data.port}/`); socket = new WebSocket(`ws://${data.host}:${data.port}/`);
language = data.language; language = data.language;
const uuid = generateUUID(); socket.onopen = () => {
socket.onopen = function(e) { socket.send(JSON.stringify({
socket.send( uid,
JSON.stringify({
uid: uuid,
language: data.language, language: data.language,
task: data.task, task: data.task,
model: data.modelSize, model: data.modelSize,
use_vad: data.useVad use_vad: data.useVad
}) }));
);
}; };
let isServerReady = false; let serverReady = false;
socket.onmessage = async (event) => { socket.onmessage = async event => {
const data = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (data["uid"] !== uuid) if (msg.uid !== uid) return;
return;
if (data["status"] === "WAIT"){ if (msg.status === 'WAIT') {
await browser.runtime.sendMessage({ action: "showPopup", data: data["message"] }) await browser.runtime.sendMessage({ action: 'showPopup', data: msg.message });
return; return;
} }
if (!serverReady && msg.message === 'SERVER_READY') {
if (!isServerReady && data["message"] === "SERVER_READY"){ serverReady = true;
isServerReady = true;
return; return;
} }
if (!language && msg.language) {
if (language === null ){ language = msg.language;
language = data["language"]; await browser.runtime.sendMessage({ action: 'updateSelectedLanguage', data: language });
await browser.runtime.sendMessage({ action: "updateSelectedLanguage", data: language }) return;
return
} }
if (msg.message === 'DISCONNECT') {
if (data["message"] === "DISCONNECT"){ await browser.runtime.sendMessage({ action: 'toggleCaptureButtons' });
await browser.runtime.sendMessage({ action: "toggleCaptureButtons", data: false }) return;
return }
if (msg.segments) {
await browser.runtime.sendMessage({ action: 'transcript', data: {data: event.data, saveCaption: data.saveCaption} });
} }
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 isCapturing = true;
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);
})
} }
function stopRecording() {
isCapturing = false;
if (socket) {
socket.close();
socket = null;
}
remove_element();
}
var elem_container = null; var elem_container = null;
var elem_text = null; var elem_text = null;
@@ -308,6 +326,8 @@ function remove_element() {
browser.runtime.onMessage.addListener((request, sender, sendResponse) => { browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
const { action, data } = request; const { action, data } = request;
const saveCaption = data.saveCaption || false;
if (action === "startCapture") { if (action === "startCapture") {
isCapturing = true; isCapturing = true;
startRecording(data); startRecording(data);
@@ -319,11 +339,19 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
socket = null; socket = null;
} }
if (audioContext) {
audioContext.close(); if (saveCaption === true) {
audioContext = null; if (lastIncompleteSegment && lastIncompleteSegment.text && lastIncompleteSegment.text.trim() !== "") {
mediaStream = null; if (allSegments.length === 0 || parseFloat(lastIncompleteSegment.start) >= parseFloat(allSegments[allSegments.length - 1].end)) {
recorder = null; allSegments.push({
start: lastIncompleteSegment.start,
end: lastIncompleteSegment.end,
text: lastIncompleteSegment.text
});
}
}
downloadSRT();
} }
remove_element(); remove_element();
@@ -337,9 +365,26 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
} else if (action === "show_transcript"){ } else if (action === "show_transcript"){
if (!isCapturing) return; if (!isCapturing) return;
init_element(); init_element();
message = JSON.parse(data); message = JSON.parse(data.data);
message = message["segments"]; message = message["segments"];
if (saveCaption === true) {
message.forEach(seg => {
if (seg.completed === true &&
(allSegments.length === 0 || parseFloat(seg.start) >= parseFloat(allSegments[allSegments.length - 1].end))) {
allSegments.push({
start: seg.start,
end: seg.end,
text: seg.text
});
lastIncompleteSegment = null;
} else if (seg.completed !== true) {
lastIncompleteSegment = seg;
}
});
}
var text = ''; var text = '';
for (var i = 0; i < message.length; i++) { for (var i = 0; i < message.length; i++) {
text += message[i].text + ' '; text += message[i].text + ' ';
@@ -8,6 +8,9 @@
"activeTab", "activeTab",
"<all_urls>" "<all_urls>"
], ],
"web_accessible_resources": [
"audiopreprocessor.js"
],
"background": { "background": {
"scripts": ["background.js"], "scripts": ["background.js"],
"persistent": false "persistent": false
+4
View File
@@ -19,6 +19,10 @@
<input type="checkbox" id="useVadCheckbox"> <input type="checkbox" id="useVadCheckbox">
<label for="useVadCheckbox">Use Voice Activity Detection</label> <label for="useVadCheckbox">Use Voice Activity Detection</label>
</div> </div>
<div class="checkbox-container">
<input type="checkbox" id="saveCaptionCheckbox">
<label for="saveCaption">Download SRT file at Stop Capture</label>
</div>
<textarea id="waitTextBox" style="display: none;"></textarea> <textarea id="waitTextBox" style="display: none;"></textarea>
<div class="dropdown-container"> <div class="dropdown-container">
<label for="languageDropdown">Select Language:</label> <label for="languageDropdown">Select Language:</label>
+15 -1
View File
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", function() {
const useServerCheckbox = document.getElementById("useServerCheckbox"); const useServerCheckbox = document.getElementById("useServerCheckbox");
const useVadCheckbox = document.getElementById("useVadCheckbox"); const useVadCheckbox = document.getElementById("useVadCheckbox");
const saveCaptionCheckbox = document.getElementById("saveCaptionCheckbox");
const languageDropdown = document.getElementById('languageDropdown'); const languageDropdown = document.getElementById('languageDropdown');
const taskDropdown = document.getElementById('taskDropdown'); const taskDropdown = document.getElementById('taskDropdown');
const modelSizeDropdown = document.getElementById('modelSizeDropdown'); const modelSizeDropdown = document.getElementById('modelSizeDropdown');
@@ -41,6 +42,12 @@ document.addEventListener("DOMContentLoaded", function() {
} }
}); });
browser.storage.local.get("saveCaptionState", ({ saveCaptionState }) => {
if (saveCaptionState !== undefined) {
saveCaptionCheckbox.checked = saveCaptionState;
}
});
browser.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => { browser.storage.local.get("selectedLanguage", ({ selectedLanguage: storedLanguage }) => {
if (storedLanguage !== undefined) { if (storedLanguage !== undefined) {
languageDropdown.value = storedLanguage; languageDropdown.value = storedLanguage;
@@ -85,6 +92,7 @@ document.addEventListener("DOMContentLoaded", function() {
task: selectedTask, task: selectedTask,
modelSize: selectedModelSize, modelSize: selectedModelSize,
useVad: useVadCheckbox.checked, useVad: useVadCheckbox.checked,
saveCaption: saveCaptionCheckbox.checked,
} }
}); });
toggleCaptureButtons(true); toggleCaptureButtons(true);
@@ -101,7 +109,7 @@ document.addEventListener("DOMContentLoaded", function() {
stopButton.addEventListener("click", function() { stopButton.addEventListener("click", function() {
browser.tabs.query({ active: true, currentWindow: true }) browser.tabs.query({ active: true, currentWindow: true })
.then(function(tabs) { .then(function(tabs) {
browser.tabs.sendMessage(tabs[0].id, { action: "stopCapture" }) browser.tabs.sendMessage(tabs[0].id, { action: "stopCapture", data: {saveCaption: saveCaptionCheckbox.checked, } })
.then(function(response) { .then(function(response) {
toggleCaptureButtons(false); toggleCaptureButtons(false);
browser.storage.local.set({ capturingState: { isCapturing: false } }) browser.storage.local.set({ capturingState: { isCapturing: false } })
@@ -124,6 +132,7 @@ document.addEventListener("DOMContentLoaded", function() {
stopButton.disabled = !isCapturing; stopButton.disabled = !isCapturing;
useServerCheckbox.disabled = isCapturing; useServerCheckbox.disabled = isCapturing;
useVadCheckbox.disabled = isCapturing; useVadCheckbox.disabled = isCapturing;
saveCaptionCheckbox.disabled = isCapturing;
modelSizeDropdown.disabled = isCapturing; modelSizeDropdown.disabled = isCapturing;
languageDropdown.disabled = isCapturing; languageDropdown.disabled = isCapturing;
taskDropdown.disabled = isCapturing; taskDropdown.disabled = isCapturing;
@@ -142,6 +151,11 @@ document.addEventListener("DOMContentLoaded", function() {
browser.storage.local.set({ useVadState }); browser.storage.local.set({ useVadState });
}); });
saveCaptionCheckbox.addEventListener("change", () => {
const saveCaptionState = saveCaptionCheckbox.checked;
browser.storage.local.set({ saveCaptionState });
});
languageDropdown.addEventListener('change', function() { languageDropdown.addEventListener('change', function() {
if (languageDropdown.value === "") { if (languageDropdown.value === "") {
selectedLanguage = null; selectedLanguage = null;