First commit of local Closed Caption App

This commit is contained in:
2025-08-05 15:17:40 -07:00
commit f4c3a024bd
21 changed files with 3023 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
let mainWindow;
let displayWindow;
let serverProcess;
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.loadFile(path.join(__dirname, '../public/index.html'));
if (process.argv.includes('--dev')) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
if (displayWindow) {
displayWindow.close();
}
});
}
function createDisplayWindow() {
displayWindow = new BrowserWindow({
width: 600,
height: 200,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
displayWindow.loadFile(path.join(__dirname, '../public/display.html'));
displayWindow.on('closed', () => {
displayWindow = null;
if (mainWindow) {
mainWindow.webContents.send('display-window-closed');
}
});
return displayWindow;
}
function startServer() {
return new Promise((resolve) => {
serverProcess = spawn('node', [path.join(__dirname, 'server/index.js')], {
cwd: path.join(__dirname, '..'),
env: { ...process.env }
});
serverProcess.stdout.on('data', (data) => {
console.log(`Server: ${data}`);
if (data.toString().includes('Server running on port')) {
resolve();
}
});
serverProcess.stderr.on('data', (data) => {
console.error(`Server Error: ${data}`);
});
// Fallback resolve after 2 seconds
setTimeout(resolve, 2000);
});
}
app.whenReady().then(async () => {
await startServer();
createWindow();
});
app.on('window-all-closed', () => {
if (serverProcess) {
serverProcess.kill();
}
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
ipcMain.handle('toggle-display-window', () => {
if (displayWindow) {
displayWindow.close();
return false;
} else {
createDisplayWindow();
return true;
}
});
ipcMain.handle('update-display-settings', (event, settings) => {
if (displayWindow) {
displayWindow.webContents.send('update-settings', settings);
}
if (mainWindow) {
mainWindow.webContents.send('update-settings', settings);
}
});
ipcMain.handle('send-transcription', (event, data) => {
if (displayWindow) {
displayWindow.webContents.send('transcription', data);
}
if (mainWindow) {
mainWindow.webContents.send('transcription', data);
}
});
+10
View File
@@ -0,0 +1,10 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
toggleDisplayWindow: () => ipcRenderer.invoke('toggle-display-window'),
updateDisplaySettings: (settings) => ipcRenderer.invoke('update-display-settings', settings),
sendTranscription: (data) => ipcRenderer.invoke('send-transcription', data),
onTranscription: (callback) => ipcRenderer.on('transcription', (event, data) => callback(data)),
onUpdateSettings: (callback) => ipcRenderer.on('update-settings', (event, settings) => callback(settings)),
onDisplayWindowClosed: (callback) => ipcRenderer.on('display-window-closed', () => callback())
});
+57
View File
@@ -0,0 +1,57 @@
const axios = require('axios');
const FormData = require('form-data');
const settingsManager = require('../utils/settingsManager');
async function transcribe(audioBuffer) {
// Check for API key in settings first, then env
const apiKey = settingsManager.getSetting('elevenlabsApiKey') || process.env.ELEVENLABS_API_KEY;
if (!apiKey) {
throw new Error('ElevenLabs API key not configured');
}
const formData = new FormData();
formData.append('model_id', 'scribe_v1');
formData.append('file', audioBuffer, {
filename: 'audio.webm',
contentType: 'audio/webm'
});
try {
const response = await axios.post(
'https://api.elevenlabs.io/v1/speech-to-text',
formData,
{
headers: {
...formData.getHeaders(),
'xi-api-key': apiKey
},
maxContentLength: Infinity,
maxBodyLength: Infinity
}
);
// Handle the response
if (response.data && typeof response.data.text === 'string') {
// Return the text even if it's empty (no speech detected)
return response.data.text;
} else if (response.data && response.data.transcription) {
return response.data.transcription;
} else if (typeof response.data === 'string') {
return response.data;
} else {
console.log('ElevenLabs unexpected response:', JSON.stringify(response.data, null, 2));
throw new Error('Unexpected response format from ElevenLabs API');
}
} catch (error) {
if (error.response) {
console.error('ElevenLabs API error response:', error.response.data);
throw new Error(`ElevenLabs API error: ${error.response.data.detail?.message || error.response.data.error || error.response.statusText}`);
}
throw error;
}
}
module.exports = {
transcribe
};
+192
View File
@@ -0,0 +1,192 @@
const { spawn } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const OpenAI = require('openai');
const transcriptionQueue = require('../utils/transcriptionQueue');
const { getAudioLevel } = require('../utils/audioUtils');
const settingsManager = require('../utils/settingsManager');
const openai = process.env.OPENAI_API_KEY ? new OpenAI({
apiKey: process.env.OPENAI_API_KEY
}) : null;
// Get audio threshold dynamically from settings
function getAudioThreshold() {
return settingsManager.getSetting('audioThreshold') || parseFloat(process.env.AUDIO_THRESHOLD) || 0.01;
}
async function transcribeLocal(audioBuffer) {
// Check audio level before processing
const audioLevel = getAudioLevel(audioBuffer);
const audioThreshold = getAudioThreshold();
if (audioLevel < audioThreshold) {
console.log(`Audio level ${audioLevel.toFixed(4)} below threshold ${audioThreshold}, skipping transcription`);
return ''; // Return empty string for silence
}
console.log(`Audio level ${audioLevel.toFixed(4)} above threshold, queuing for transcription`);
// Use queue to prevent multiple concurrent Whisper processes
return transcriptionQueue.enqueue(async () => {
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `audio_${Date.now()}.wav`);
try {
await fs.writeFile(tempFile, audioBuffer);
return new Promise((resolve, reject) => {
// Build Whisper command with current settings (not cached)
const currentModel = settingsManager.getSetting('whisperModel') || process.env.WHISPER_MODEL || 'base';
const whisperArgs = [
tempFile,
'--model', currentModel,
'--language', 'en',
'--task', 'transcribe',
'--output_format', 'txt',
'--output_dir', tempDir
];
// Get current GPU settings (not cached)
const gpuAcceleration = settingsManager.getSetting('gpuAcceleration') || 'none';
switch (gpuAcceleration) {
case 'cuda':
// NVIDIA CUDA
whisperArgs.push('--device', 'cuda');
whisperArgs.push('--fp16', 'True');
break;
case 'rocm':
// AMD ROCm (requires PyTorch built with ROCm)
whisperArgs.push('--device', 'cuda'); // ROCm uses CUDA interface
whisperArgs.push('--fp16', 'True');
// Set environment variable for ROCm
process.env.HSA_OVERRIDE_GFX_VERSION = '10.3.0'; // May need adjustment
break;
case 'openvino':
// Intel OpenVINO
whisperArgs.push('--device', 'cpu'); // OpenVINO uses CPU device
// Note: Requires whisper-openvino fork or custom implementation
whisperArgs.push('--fp16', 'False');
whisperArgs.push('--threads', '4');
break;
default:
// CPU optimizations
whisperArgs.push('--device', 'cpu');
whisperArgs.push('--fp16', 'False');
whisperArgs.push('--threads', '2');
}
console.log(`Starting Whisper with model: ${currentModel}, GPU: ${gpuAcceleration}`);
console.log('Whisper args:', whisperArgs.join(' '));
const whisperProcess = spawn('whisper', whisperArgs);
let output = '';
let error = '';
whisperProcess.stdout.on('data', (data) => {
output += data.toString();
});
whisperProcess.stderr.on('data', (data) => {
error += data.toString();
});
whisperProcess.on('close', async (code) => {
if (code !== 0) {
reject(new Error(`Whisper process exited with code ${code}: ${error}`));
return;
}
try {
const outputFile = tempFile.replace('.wav', '.txt');
const transcription = await fs.readFile(outputFile, 'utf8');
// Clean up files
await fs.unlink(tempFile).catch(() => {});
await fs.unlink(outputFile).catch(() => {});
resolve(transcription.trim());
} catch (err) {
reject(err);
}
});
// Kill process if it takes too long (30 seconds timeout)
const timeout = setTimeout(() => {
whisperProcess.kill('SIGKILL');
reject(new Error('Whisper process timeout'));
}, 30000);
whisperProcess.on('close', () => {
clearTimeout(timeout);
});
});
} catch (error) {
await fs.unlink(tempFile).catch(() => {});
throw error;
}
}, `whisper_${Date.now()}`);
}
async function transcribeRemote(audioBuffer) {
// Check for API key in settings first, then env
const apiKey = settingsManager.getSetting('openaiApiKey') || process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OpenAI API key not configured');
}
// Create OpenAI client with current API key
const currentOpenAI = new OpenAI({ apiKey });
// Check audio level before processing
const audioLevel = getAudioLevel(audioBuffer);
const audioThreshold = getAudioThreshold();
if (audioLevel < audioThreshold) {
console.log(`Audio level ${audioLevel.toFixed(4)} below threshold ${audioThreshold}, skipping remote transcription`);
return ''; // Return empty string for silence
}
console.log(`Audio level ${audioLevel.toFixed(4)} above threshold, sending to OpenAI`);
// Use queue for remote requests too to prevent overwhelming the API
return transcriptionQueue.enqueue(async () => {
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `audio_${Date.now()}.wav`);
try {
await fs.writeFile(tempFile, audioBuffer);
const fileBuffer = await fs.readFile(tempFile);
const transcription = await currentOpenAI.audio.transcriptions.create({
file: new File([fileBuffer], 'audio.wav', { type: 'audio/wav' }),
model: "whisper-1",
language: "en"
});
await fs.unlink(tempFile).catch(() => {});
return transcription.text;
} catch (error) {
await fs.unlink(tempFile).catch(() => {});
throw error;
}
}, `openai_${Date.now()}`);
}
// Clean up old queued jobs periodically
setInterval(() => {
transcriptionQueue.clearOldJobs(30000); // Clear jobs older than 30 seconds
}, 10000);
module.exports = {
transcribeLocal,
transcribeRemote,
getQueueStatus: () => transcriptionQueue.getStatus()
};
+271
View File
@@ -0,0 +1,271 @@
require('dotenv').config();
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const cors = require('cors');
const path = require('path');
const multer = require('multer');
const whisperBackend = require('./backends/whisper');
const elevenLabsBackend = require('./backends/elevenlabs');
const settingsManager = require('./utils/settingsManager');
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const upload = multer({ storage: multer.memoryStorage() });
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../../public')));
const activeBackend = {
current: 'whisper-local',
instance: null
};
// Initialize settings
settingsManager.loadSettings().then(() => {
settingsManager.applyToEnv();
activeBackend.current = settingsManager.getSetting('selectedBackend') || 'whisper-local';
console.log('Settings loaded, active backend:', activeBackend.current);
});
app.post('/api/backend', (req, res) => {
const { backend } = req.body;
activeBackend.current = backend;
console.log(`Switched to backend: ${backend}`);
res.json({ success: true, backend });
});
app.post('/api/transcribe', upload.single('audio'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
let transcription = '';
switch (activeBackend.current) {
case 'whisper-local':
transcription = await whisperBackend.transcribeLocal(req.file.buffer);
break;
case 'whisper-remote':
transcription = await whisperBackend.transcribeRemote(req.file.buffer);
break;
case 'elevenlabs':
transcription = await elevenLabsBackend.transcribe(req.file.buffer);
break;
default:
throw new Error(`Unknown backend: ${activeBackend.current}`);
}
// Only emit non-empty transcriptions
if (transcription && transcription.trim()) {
io.emit('transcription', { text: transcription, timestamp: Date.now() });
}
res.json({ transcription });
} catch (error) {
console.error('Transcription error:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/backends', (req, res) => {
res.json({
backends: [
{ id: 'whisper-local', name: 'Whisper (Local)', available: true },
{ id: 'whisper-remote', name: 'Whisper (OpenAI API)', available: !!process.env.OPENAI_API_KEY },
{ id: 'elevenlabs', name: 'ElevenLabs (Scribe)', available: !!process.env.ELEVENLABS_API_KEY }
],
current: activeBackend.current
});
});
app.get('/api/queue-status', (req, res) => {
try {
const status = whisperBackend.getQueueStatus();
res.json(status);
} catch (error) {
res.status(500).json({ error: 'Failed to get queue status' });
}
});
// Settings endpoints
app.get('/api/settings', (req, res) => {
try {
const settings = settingsManager.getSettings();
// Don't send API keys to client for security
const clientSettings = { ...settings };
if (clientSettings.openaiApiKey) {
clientSettings.openaiApiKey = '***hidden***';
}
if (clientSettings.elevenlabsApiKey) {
clientSettings.elevenlabsApiKey = '***hidden***';
}
res.json(clientSettings);
} catch (error) {
res.status(500).json({ error: 'Failed to load settings' });
}
});
app.post('/api/settings', async (req, res) => {
try {
const updates = req.body;
// Only update API keys if they're not the hidden placeholder
if (updates.openaiApiKey === '***hidden***') {
delete updates.openaiApiKey;
}
if (updates.elevenlabsApiKey === '***hidden***') {
delete updates.elevenlabsApiKey;
}
const success = await settingsManager.updateSettings(updates);
if (success) {
// Reload settings to ensure they're current
await settingsManager.loadSettings();
settingsManager.applyToEnv();
// Update active backend if changed
if (updates.selectedBackend) {
activeBackend.current = updates.selectedBackend;
}
// Log GPU changes for debugging
if (updates.gpuAcceleration !== undefined) {
console.log(`GPU acceleration changed to: ${updates.gpuAcceleration}`);
}
if (updates.whisperModel !== undefined) {
console.log(`Whisper model changed to: ${updates.whisperModel}`);
}
res.json({ success: true });
} else {
res.status(500).json({ error: 'Failed to save settings' });
}
} catch (error) {
console.error('Error updating settings:', error);
res.status(500).json({ error: 'Failed to save settings' });
}
});
app.post('/api/settings/reset', async (req, res) => {
try {
const success = await settingsManager.resetToDefaults();
if (success) {
settingsManager.applyToEnv();
activeBackend.current = 'whisper-local';
res.json({ success: true });
} else {
res.status(500).json({ error: 'Failed to reset settings' });
}
} catch (error) {
console.error('Error resetting settings:', error);
res.status(500).json({ error: 'Failed to reset settings' });
}
});
app.post('/api/detect-gpu', async (req, res) => {
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const result = {
cuda: false,
rocm: false,
openvino: false,
recommended: 'none'
};
try {
// Check for NVIDIA GPU (CUDA)
try {
await execPromise('nvidia-smi');
result.cuda = true;
result.recommended = 'cuda';
} catch (e) {
// NVIDIA not available
}
// Check for AMD GPU (ROCm)
try {
await execPromise('rocm-smi');
result.rocm = true;
if (!result.cuda) {
result.recommended = 'rocm';
}
} catch (e) {
// ROCm not available
}
// Check for Intel GPU/OpenVINO
try {
const { stdout } = await execPromise('lspci | grep -i intel | grep -i vga || echo ""');
if (stdout.includes('Intel')) {
result.openvino = true;
if (!result.cuda && !result.rocm) {
result.recommended = 'openvino';
}
}
} catch (e) {
// Intel GPU check failed
}
res.json(result);
} catch (error) {
console.error('GPU detection error:', error);
res.status(500).json({ error: 'Failed to detect GPU' });
}
});
app.post('/api/test-connections', async (req, res) => {
const { openaiApiKey, elevenlabsApiKey } = req.body;
const results = {};
// Test OpenAI connection
if (openaiApiKey && openaiApiKey !== '***hidden***') {
try {
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: openaiApiKey });
await openai.models.list();
results.openai = { success: true };
} catch (error) {
results.openai = { success: false, error: error.message };
}
}
// Test ElevenLabs connection
if (elevenlabsApiKey && elevenlabsApiKey !== '***hidden***') {
try {
const axios = require('axios');
await axios.get('https://api.elevenlabs.io/v1/user', {
headers: { 'xi-api-key': elevenlabsApiKey }
});
results.elevenlabs = { success: true };
} catch (error) {
results.elevenlabs = { success: false, error: error.response?.data?.detail?.message || error.message };
}
}
res.json(results);
});
io.on('connection', (socket) => {
console.log('Client connected');
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
const PORT = process.env.SERVER_PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+85
View File
@@ -0,0 +1,85 @@
// Audio utility functions for level detection and processing
/**
* Calculate the RMS (Root Mean Square) amplitude of an audio buffer
* @param {Float32Array} audioData - Audio samples
* @returns {number} RMS value between 0 and 1
*/
function calculateRMS(audioData) {
let sum = 0;
for (let i = 0; i < audioData.length; i++) {
sum += audioData[i] * audioData[i];
}
return Math.sqrt(sum / audioData.length);
}
/**
* Check if audio contains speech above threshold
* @param {Buffer} audioBuffer - Raw audio buffer
* @param {number} threshold - Minimum RMS threshold (0-1)
* @returns {Promise<boolean>} True if audio is above threshold
*/
async function hasAudioAboveThreshold(audioBuffer, threshold = 0.01) {
try {
// For WebM/audio files, we'd need to decode them properly
// For now, we'll do a simple buffer analysis
// This is a simplified approach - in production you'd want proper audio decoding
// Convert buffer to Float32Array (simplified approach)
const samples = new Float32Array(audioBuffer.length / 4);
for (let i = 0; i < samples.length; i++) {
samples[i] = audioBuffer.readFloatLE(i * 4) || 0;
}
const rms = calculateRMS(samples);
console.log(`Audio RMS level: ${rms.toFixed(4)}, threshold: ${threshold}`);
return rms > threshold;
} catch (error) {
console.warn('Error analyzing audio level, proceeding with transcription:', error);
return true; // If we can't analyze, proceed with transcription
}
}
/**
* Simple audio level detection for basic threshold checking
* @param {Buffer} audioBuffer - Raw audio buffer
* @returns {number} Estimated audio level (0-1)
*/
function getAudioLevel(audioBuffer) {
try {
// For WebM files, we can't easily parse the audio data, so we'll use a simple heuristic
// based on the buffer size and non-zero bytes
let nonZeroBytes = 0;
let sumAmplitude = 0;
// Sample every 100th byte to get a rough estimate
for (let i = 0; i < audioBuffer.length; i += 100) {
const byte = audioBuffer[i];
if (byte !== 0) {
nonZeroBytes++;
sumAmplitude += Math.abs(byte - 128) / 128; // Normalize assuming 8-bit audio
}
}
if (nonZeroBytes === 0) return 0;
const avgAmplitude = sumAmplitude / nonZeroBytes;
const activityRatio = nonZeroBytes / (audioBuffer.length / 100);
// Combine amplitude and activity for better detection
const level = Math.min((avgAmplitude * activityRatio) * 2, 1);
return level;
} catch (error) {
console.warn('Error calculating audio level:', error);
return 0.1; // Return low level if we can't calculate, but not zero
}
}
module.exports = {
calculateRMS,
hasAudioAboveThreshold,
getAudioLevel
};
+128
View File
@@ -0,0 +1,128 @@
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
// Settings file location
const SETTINGS_DIR = path.join(os.homedir(), '.live-transcription');
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
// Default settings
const DEFAULT_SETTINGS = {
// API Keys
openaiApiKey: '',
elevenlabsApiKey: '',
// Audio Processing
audioThreshold: 0.01,
chunkSize: 2000,
whisperModel: 'base',
gpuAcceleration: 'none', // none, cuda, rocm, openvino
ambientNoiseLevel: null,
// UI Settings
selectedBackend: 'whisper-local',
textDuration: 5,
textColor: '#ffffff',
backgroundColor: '#000000',
fontFamily: 'Arial, sans-serif',
fontSize: 32,
googleFont: null,
// Server Settings
serverPort: 3000
};
class SettingsManager {
constructor() {
this.settings = { ...DEFAULT_SETTINGS };
this.loaded = false;
}
async ensureSettingsDir() {
try {
await fs.mkdir(SETTINGS_DIR, { recursive: true });
} catch (error) {
console.warn('Could not create settings directory:', error);
}
}
async loadSettings() {
try {
await this.ensureSettingsDir();
const data = await fs.readFile(SETTINGS_FILE, 'utf8');
this.settings = { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
this.loaded = true;
console.log('Settings loaded from:', SETTINGS_FILE);
} catch (error) {
console.log('Using default settings (no settings file found)');
this.settings = { ...DEFAULT_SETTINGS };
this.loaded = true;
}
return this.settings;
}
async saveSettings(newSettings = null) {
try {
await this.ensureSettingsDir();
if (newSettings) {
this.settings = { ...this.settings, ...newSettings };
}
await fs.writeFile(SETTINGS_FILE, JSON.stringify(this.settings, null, 2));
console.log('Settings saved to:', SETTINGS_FILE);
return true;
} catch (error) {
console.error('Error saving settings:', error);
return false;
}
}
getSetting(key) {
return this.settings[key];
}
getSettings() {
return { ...this.settings };
}
async updateSetting(key, value) {
this.settings[key] = value;
return await this.saveSettings();
}
async updateSettings(updates) {
this.settings = { ...this.settings, ...updates };
return await this.saveSettings();
}
// Get environment variables with settings fallback
getEnvWithFallback(envKey, settingsKey) {
return process.env[envKey] || this.settings[settingsKey] || DEFAULT_SETTINGS[settingsKey];
}
// Apply settings to process.env (for compatibility with existing code)
applyToEnv() {
if (this.settings.openaiApiKey) {
process.env.OPENAI_API_KEY = this.settings.openaiApiKey;
}
if (this.settings.elevenlabsApiKey) {
process.env.ELEVENLABS_API_KEY = this.settings.elevenlabsApiKey;
}
if (this.settings.audioThreshold !== null) {
process.env.AUDIO_THRESHOLD = this.settings.audioThreshold.toString();
}
if (this.settings.whisperModel) {
process.env.WHISPER_MODEL = this.settings.whisperModel;
}
if (this.settings.serverPort) {
process.env.SERVER_PORT = this.settings.serverPort.toString();
}
}
async resetToDefaults() {
this.settings = { ...DEFAULT_SETTINGS };
return await this.saveSettings();
}
}
// Export singleton instance
module.exports = new SettingsManager();
+92
View File
@@ -0,0 +1,92 @@
// Queue system to manage transcription requests and prevent resource overload
class TranscriptionQueue {
constructor(maxConcurrent = 1) {
this.queue = [];
this.running = [];
this.maxConcurrent = maxConcurrent;
}
/**
* Add a transcription job to the queue
* @param {Function} transcriptionFn - Function that returns a Promise
* @param {string} jobId - Unique identifier for the job
* @returns {Promise} Promise that resolves with transcription result
*/
async enqueue(transcriptionFn, jobId = Date.now().toString()) {
return new Promise((resolve, reject) => {
const job = {
id: jobId,
fn: transcriptionFn,
resolve,
reject,
timestamp: Date.now()
};
this.queue.push(job);
this.processQueue();
});
}
/**
* Process jobs in the queue
*/
async processQueue() {
if (this.running.length >= this.maxConcurrent || this.queue.length === 0) {
return;
}
const job = this.queue.shift();
this.running.push(job);
console.log(`Starting transcription job ${job.id}, queue length: ${this.queue.length}, running: ${this.running.length}`);
try {
const result = await job.fn();
job.resolve(result);
} catch (error) {
job.reject(error);
} finally {
// Remove from running jobs
const index = this.running.findIndex(j => j.id === job.id);
if (index > -1) {
this.running.splice(index, 1);
}
console.log(`Completed transcription job ${job.id}, queue length: ${this.queue.length}, running: ${this.running.length}`);
// Process next job
this.processQueue();
}
}
/**
* Clear old jobs from queue (older than maxAge milliseconds)
* @param {number} maxAge - Maximum age in milliseconds
*/
clearOldJobs(maxAge = 30000) {
const now = Date.now();
const originalLength = this.queue.length;
this.queue = this.queue.filter(job => (now - job.timestamp) < maxAge);
if (originalLength !== this.queue.length) {
console.log(`Cleared ${originalLength - this.queue.length} old jobs from transcription queue`);
}
}
/**
* Get queue status
* @returns {Object} Queue status information
*/
getStatus() {
return {
queueLength: this.queue.length,
runningJobs: this.running.length,
maxConcurrent: this.maxConcurrent
};
}
}
// Export singleton instance
module.exports = new TranscriptionQueue(1); // Only allow 1 concurrent Whisper process