Archived
First commit of local Closed Caption App
This commit is contained in:
+130
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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())
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user