UNPKG

contaigents

Version:

Modular AI Content Ecosystem with Audio Generation

631 lines (621 loc) • 26.3 kB
import { BaseTool } from './BaseTool.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import fs from 'fs/promises'; import FormData from 'form-data'; import fetch from 'node-fetch'; const execAsync = promisify(exec); // Dynamic import for @xenova/transformers to avoid loading issues let WhisperPipeline = null; let whisperInitialized = false; export class SpeechToTextTool extends BaseTool { constructor(baseDir) { super(); this.maxTimeout = 300000; // 5 minutes for audio processing this.baseDir = baseDir || process.cwd(); } getName() { return 'speech_to_text'; } getDescription() { return 'Convert speech/audio to text using Whisper models (local/cloud) or cloud providers (OpenAI, Gemini). Supports multiple audio formats and languages.'; } getParameters() { return [ { name: 'input_file', type: 'string', description: 'Path to the audio/video file to transcribe', required: true }, { name: 'output_file', type: 'string', description: 'Path to save the transcription text file (optional)', required: false }, { name: 'provider', type: 'string', description: 'ASR provider (whisper_local, openai_whisper, gemini, auto)', required: false, default: 'auto' }, { name: 'model', type: 'string', description: 'Model to use (tiny, base, small, medium, large for Whisper; whisper-1 for OpenAI)', required: false, default: 'base' }, { name: 'language', type: 'string', description: 'Language code (en, es, fr, de, etc.) or auto-detect', required: false, default: 'auto' }, { name: 'format', type: 'string', description: 'Output format (text, srt, vtt, json)', required: false, default: 'text' }, { name: 'timestamps', type: 'boolean', description: 'Include timestamps in output', required: false, default: false }, { name: 'translate', type: 'boolean', description: 'Translate to English (Whisper only)', required: false, default: false }, { name: 'api_key', type: 'string', description: 'API key for cloud providers (optional if set in environment)', required: false } ]; } getAgentGuidance() { return ` šŸŽ¤ SPEECH-TO-TEXT OPERATIONS: šŸ“± PROVIDER OPTIONS: - whisper_local: Use local Whisper installation (privacy-first, no API costs) - openai_whisper: OpenAI Whisper API (high quality, requires API key) - gemini: Google Gemini API (fast, supports many languages) - auto: Smart provider selection (tries local first, falls back to cloud) šŸŽÆ MODEL SELECTION: Local Whisper Models (by accuracy/speed): - tiny: Fastest, least accurate (~39 MB) - base: Good balance (~74 MB) - DEFAULT - small: Better accuracy (~244 MB) - medium: High accuracy (~769 MB) - large: Best accuracy (~1550 MB) Cloud Models: - OpenAI: whisper-1 (high quality) - Gemini: Built-in speech recognition šŸ“„ OUTPUT FORMATS: - text: Plain text transcription - srt: Subtitle format with timestamps - vtt: WebVTT format for web players - json: Detailed JSON with confidence scores šŸ“ RESPONSE SIZE CONTROL: - max_chars: Maximum characters to return in response (default: 2000) - Full transcription always saved to file - Response includes is_trimmed flag if content was truncated - Agents can read full content from saved file when needed šŸŒ LANGUAGE SUPPORT: - auto: Auto-detect language - en: English - es: Spanish - fr: French - de: German - ja: Japanese - zh: Chinese - And 90+ more languages ⚔ SMART PROVIDER SELECTION: 1. Local Whisper (Node.js) - Privacy + No costs + No Python dependencies 2. OpenAI Whisper API - High quality fallback 3. Gemini API - Fast multilingual fallback šŸ”§ LOCAL INSTALLATION: - Node.js Whisper: npm install @xenova/transformers (pure JavaScript) - No Python or pip required - everything runs in Node.js! EXAMPLES: - Basic transcription: input_file="audio.mp3", provider="auto" - High quality: input_file="interview.wav", provider="openai_whisper", model="whisper-1" - With subtitles: input_file="video.mp4", format="srt", timestamps=true - Translate to English: input_file="spanish.mp3", translate=true, language="es" - Local privacy: input_file="private.wav", provider="whisper_local", model="medium" - Large file with preview: input_file="long_meeting.wav", max_chars=500 `; } async execute(parameters) { // Validate parameters const validationError = this.validateParameters(parameters); if (validationError) { return validationError; } const { input_file, output_file, provider = 'auto', model = 'base', language = 'auto', format = 'text', timestamps = false, translate = false, api_key, max_chars = 2000 } = parameters; try { // Resolve file paths const inputPath = path.resolve(this.baseDir, input_file); // Security check - ensure paths are within base directory if (!inputPath.startsWith(this.baseDir)) { return { success: false, error: 'File paths must be within the base directory' }; } // Check if input file exists try { await fs.access(inputPath); } catch { return { success: false, error: `Input file not found: ${input_file}` }; } // Smart provider selection const selectedProvider = await this.selectProvider(provider, api_key); // Perform transcription based on provider let transcriptionResult; switch (selectedProvider) { case 'whisper_local': transcriptionResult = await this.transcribeWithLocalWhisper(inputPath, { model, language, format, timestamps, translate }); break; case 'openai_whisper': transcriptionResult = await this.transcribeWithOpenAI(inputPath, { model: model === 'base' ? 'whisper-1' : model, language, format, timestamps, translate, api_key }); break; case 'gemini': transcriptionResult = await this.transcribeWithGemini(inputPath, { language, format, timestamps, api_key }); break; default: return { success: false, error: `Unsupported provider: ${selectedProvider}` }; } if (!transcriptionResult.success) { return transcriptionResult; } // Save to output file if specified let outputPath; if (output_file) { outputPath = path.resolve(this.baseDir, output_file); if (!outputPath.startsWith(this.baseDir)) { return { success: false, error: 'Output file path must be within the base directory' }; } await fs.writeFile(outputPath, transcriptionResult.text, 'utf8'); } // Use the output file from the transcription result if available (for local Whisper) const finalOutputFile = transcriptionResult.outputFile || outputPath; const finalOutputSize = transcriptionResult.outputSize || (transcriptionResult.text ? Buffer.byteLength(transcriptionResult.text, 'utf8') : 0); // Handle content trimming based on max_chars const fullTranscription = transcriptionResult.text; const fullLength = fullTranscription.length; const isTrimmed = fullLength > max_chars; const responseTranscription = isTrimmed ? fullTranscription.substring(0, max_chars) + '...' : fullTranscription; return { success: true, data: { transcription: responseTranscription, is_trimmed: isTrimmed, full_length: fullLength, provider: selectedProvider, model: model, language: transcriptionResult.detectedLanguage || language, format: format, input_file: path.relative(this.baseDir, inputPath), output_file: finalOutputFile ? path.relative(this.baseDir, finalOutputFile) : null, output_size: finalOutputSize, characters_transcribed: transcriptionResult.charactersTranscribed || fullLength, confidence: transcriptionResult.confidence, duration: transcriptionResult.duration, word_count: fullTranscription.split(/\s+/).filter(word => word.length > 0).length, timestamp: new Date().toISOString() }, message: `Speech-to-text completed successfully using ${selectedProvider}. ${fullLength} characters transcribed.${isTrimmed ? ` Response trimmed to ${max_chars} chars - full content in file.` : ''}${finalOutputFile ? ` Output saved to: ${path.relative(this.baseDir, finalOutputFile)} (${finalOutputSize} bytes)` : ''}` }; } catch (error) { return { success: false, error: `Speech-to-text failed: ${error.message}`, data: { provider: provider, input_file: input_file } }; } } async selectProvider(requestedProvider, apiKey) { if (requestedProvider !== 'auto') { return requestedProvider; } // Try local Node.js Whisper first try { await this.initializeWhisper(); return 'whisper_local'; } catch { // Local Whisper not available } // Check for API keys and fall back to cloud providers const openaiKey = apiKey || process.env.OPENAI_API_KEY; if (openaiKey) { return 'openai_whisper'; } const geminiKey = apiKey || process.env.GEMINI_API_KEY; if (geminiKey) { return 'gemini'; } throw new Error('No speech-to-text provider available. Install @xenova/transformers or provide API keys.'); } async initializeWhisper() { if (whisperInitialized && WhisperPipeline) { return; } try { const { pipeline } = await import('@xenova/transformers'); WhisperPipeline = await pipeline('automatic-speech-recognition', 'Xenova/whisper-base'); whisperInitialized = true; } catch (error) { throw new Error('Failed to initialize Whisper: @xenova/transformers not available'); } } async transcribeWithLocalWhisper(inputPath, options) { try { // Initialize Whisper if not already done await this.initializeWhisper(); // Configure the model based on options let modelName = 'Xenova/whisper-base'; switch (options.model) { case 'tiny': modelName = 'Xenova/whisper-tiny'; break; case 'small': modelName = 'Xenova/whisper-small'; break; case 'medium': modelName = 'Xenova/whisper-medium'; break; case 'large': modelName = 'Xenova/whisper-large-v3'; break; default: modelName = 'Xenova/whisper-base'; } // Create a new pipeline with the specified model const { pipeline } = await import('@xenova/transformers'); const transcriber = await pipeline('automatic-speech-recognition', modelName); // Prepare transcription options const transcribeOptions = { // Handle long audio files by chunking chunk_length_s: 30, stride_length_s: 5 }; if (options.language !== 'auto') { transcribeOptions.language = options.language; } if (options.translate) { transcribeOptions.task = 'translate'; } if (options.timestamps) { transcribeOptions.return_timestamps = true; } // Preprocess audio for Node.js environment const audioData = await this.preprocessAudioForNode(inputPath); // Perform transcription using preprocessed audio data with chunking support const result = await transcriber(audioData, transcribeOptions); let transcriptionText = ''; let detectedLanguage = options.language !== 'auto' ? options.language : undefined; if (options.format === 'json') { // Return detailed JSON format transcriptionText = JSON.stringify(result, null, 2); } else if (options.format === 'srt' && result.chunks) { // Convert to SRT format transcriptionText = this.convertToSRT(result.chunks); } else if (options.format === 'vtt' && result.chunks) { // Convert to VTT format transcriptionText = this.convertToVTT(result.chunks); } else { // Plain text format transcriptionText = result.text || ''; } // Always save result to file const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const outputFileName = options.output_file || `transcription_whisper_local_${timestamp}.${options.format === 'json' ? 'json' : 'txt'}`; const outputPath = path.resolve(this.baseDir, outputFileName); // Save to file await fs.writeFile(outputPath, transcriptionText, 'utf8'); const resultSize = Buffer.byteLength(transcriptionText, 'utf8'); return { success: true, text: transcriptionText.trim(), detectedLanguage, confidence: result.confidence || undefined, duration: result.duration || undefined, outputFile: outputPath, outputSize: resultSize, charactersTranscribed: transcriptionText.length, provider: 'whisper_local', model: options.model || 'base' }; } catch (error) { return { success: false, text: '', error: `Local Whisper (Node.js) failed: ${error.message}` }; } } async transcribeWithOpenAI(inputPath, options) { try { const apiKey = options.api_key || process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error('OpenAI API key not found'); } // Read the audio file const audioBuffer = await fs.readFile(inputPath); // Create form data const formData = new FormData(); formData.append('file', audioBuffer, { filename: path.basename(inputPath), contentType: this.getAudioMimeType(inputPath) }); formData.append('model', options.model || 'whisper-1'); if (options.language !== 'auto') { formData.append('language', options.language); } if (options.format === 'srt') { formData.append('response_format', 'srt'); } else if (options.format === 'vtt') { formData.append('response_format', 'vtt'); } else if (options.format === 'json') { formData.append('response_format', 'verbose_json'); } else { formData.append('response_format', 'text'); } if (options.timestamps && options.format === 'json') { formData.append('timestamp_granularities[]', 'word'); } const endpoint = options.translate ? 'https://api.openai.com/v1/audio/translations' : 'https://api.openai.com/v1/audio/transcriptions'; const response = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, ...formData.getHeaders() }, body: formData }); if (!response.ok) { throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`); } const result = await response.text(); let transcription = result; let detectedLanguage; let confidence; let duration; if (options.format === 'json') { const jsonResult = JSON.parse(result); transcription = jsonResult.text; detectedLanguage = jsonResult.language; duration = jsonResult.duration; // Calculate average confidence if available if (jsonResult.words) { const confidences = jsonResult.words.map((w) => w.confidence || 1); confidence = confidences.reduce((a, b) => a + b, 0) / confidences.length; } } return { success: true, text: transcription.trim(), detectedLanguage, confidence, duration }; } catch (error) { return { success: false, text: '', error: `OpenAI Whisper failed: ${error.message}` }; } } async transcribeWithGemini(inputPath, options) { try { const apiKey = options.api_key || process.env.GEMINI_API_KEY; if (!apiKey) { throw new Error('Gemini API key not found'); } // Read and encode audio file const audioBuffer = await fs.readFile(inputPath); const audioBase64 = audioBuffer.toString('base64'); const mimeType = this.getAudioMimeType(inputPath); const requestBody = { contents: [{ parts: [{ inline_data: { mime_type: mimeType, data: audioBase64 } }, { text: options.language !== 'auto' ? `Transcribe this audio in ${options.language}. Return only the transcription text.` : 'Transcribe this audio. Return only the transcription text.' }] }] }; const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Gemini API error: ${response.status} ${response.statusText}`); } const result = await response.json(); if (!result.candidates || !result.candidates[0] || !result.candidates[0].content) { throw new Error('No transcription returned from Gemini'); } const transcription = result.candidates[0].content.parts[0].text; return { success: true, text: transcription.trim(), detectedLanguage: options.language !== 'auto' ? options.language : undefined }; } catch (error) { return { success: false, text: '', error: `Gemini failed: ${error.message}` }; } } getAudioMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska' }; return mimeTypes[ext] || 'audio/mpeg'; } convertToSRT(chunks) { let srtContent = ''; chunks.forEach((chunk, index) => { const startTime = this.formatSRTTime(chunk.timestamp[0] || 0); const endTime = this.formatSRTTime(chunk.timestamp[1] || chunk.timestamp[0] + 1); srtContent += `${index + 1}\n${startTime} --> ${endTime}\n${chunk.text}\n\n`; }); return srtContent; } convertToVTT(chunks) { let vttContent = 'WEBVTT\n\n'; chunks.forEach((chunk) => { const startTime = this.formatVTTTime(chunk.timestamp[0] || 0); const endTime = this.formatVTTTime(chunk.timestamp[1] || chunk.timestamp[0] + 1); vttContent += `${startTime} --> ${endTime}\n${chunk.text}\n\n`; }); return vttContent; } formatSRTTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 1000); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`; } formatVTTTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 1000); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`; } async preprocessAudioForNode(inputPath) { try { // First, try to use FFmpeg to convert to WAV format if needed const ext = path.extname(inputPath).toLowerCase(); let wavPath = inputPath; if (ext !== '.wav') { // Convert to WAV using FFmpeg wavPath = inputPath.replace(path.extname(inputPath), '_temp.wav'); await execAsync(`ffmpeg -i "${inputPath}" -ar 16000 -ac 1 -f wav "${wavPath}" -y`); } // Read the WAV file const audioBuffer = await fs.readFile(wavPath); // Try to use wav-decoder for proper audio processing try { const { decode } = await import('wav-decoder'); const audioData = await decode(audioBuffer); // Convert to Float32Array (mono, 16kHz) let samples; if (audioData.channelData.length > 1) { // Convert stereo to mono by averaging channels const left = audioData.channelData[0]; const right = audioData.channelData[1]; samples = new Float32Array(left.length); for (let i = 0; i < left.length; i++) { samples[i] = (left[i] + right[i]) / 2; } } else { samples = audioData.channelData[0]; } // Clean up temporary file if created if (wavPath !== inputPath) { try { await fs.unlink(wavPath); } catch (error) { // Ignore cleanup errors } } return samples; } catch (wavError) { // Fallback: Simple WAV parsing for basic WAV files return this.parseSimpleWav(audioBuffer); } } catch (error) { throw new Error(`Audio preprocessing failed: ${error.message}`); } } parseSimpleWav(buffer) { // Simple WAV parser for basic 16-bit PCM WAV files // This is a fallback when wav-decoder is not available // Skip WAV header (44 bytes for standard WAV) const dataStart = 44; const dataLength = buffer.length - dataStart; const samples = new Float32Array(dataLength / 2); // Convert 16-bit PCM to Float32Array for (let i = 0; i < samples.length; i++) { const sample = buffer.readInt16LE(dataStart + i * 2); samples[i] = sample / 32768.0; // Normalize to [-1, 1] } return samples; } }