import { execFileSync } from 'child_process' import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs' import { join } from 'path' /** * Server-side voice command recognition using whisper.cpp. * Optimized for short utterances: "Done" / "Fertig" / "Repeat" / "Wiederholen". * * POST /api/mobile/voice-command * Body: multipart form — 'audio' file * * Returns: { command: 'done' | 'repeat' | 'unknown', transcript: string } */ const DONE_KEYWORDS = ['done', 'fertig', 'erledigt', 'weiter', 'next', 'nächste', 'ok'] const REPEAT_KEYWORDS = ['repeat', 'wiederholen', 'nochmal', 'noch mal', 'again', 'erneut'] const detectCommand = (text: string): 'done' | 'repeat' | 'unknown' => { const lower = text.toLowerCase().trim() if (!lower) return 'unknown' for (const kw of DONE_KEYWORDS) { if (lower.includes(kw)) return 'done' } for (const kw of REPEAT_KEYWORDS) { if (lower.includes(kw)) return 'repeat' } return 'unknown' } export default defineEventHandler(async (event) => { const formData = await readMultipartFormData(event) if (!formData) { return { status: 400, command: 'unknown', transcript: '', message: 'Multipart form data required' } } let audioBuffer: Buffer | null = null let audioExt = 'webm' let language = 'en' for (const part of formData) { if (part.name === 'audio') { audioBuffer = part.data // Never trust the client filename (it must not reach the filesystem or a // process invocation) — only take a whitelisted extension from it. const extMatch = /\.(webm|ogg|oga|mp3|m4a|wav|aac|mp4)$/i.exec(part.filename || '') audioExt = extMatch ? extMatch[1].toLowerCase() : 'webm' } else if (part.name === 'language') { language = part.data.toString() || 'en' } } if (!audioBuffer) { return { status: 400, command: 'unknown', transcript: '', message: 'Audio file required' } } // Save audio to temp file const tmpDir = join(process.cwd(), '.tmp-voice-cmd') if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) const baseName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const audioPath = join(tmpDir, `${baseName}.${audioExt}`) const wavPath = join(tmpDir, `${baseName}.wav`) try { writeFileSync(audioPath, audioBuffer) // Convert to WAV 16kHz mono — execFileSync with an arg array (no shell) try { execFileSync('ffmpeg', ['-i', audioPath, '-ar', '16000', '-ac', '1', '-y', wavPath], { timeout: 15000, stdio: ['ignore', 'ignore', 'ignore'] }) } catch (e: any) { console.error('[VoiceCmd] ffmpeg conversion failed:', e.message) return { status: 500, command: 'unknown', transcript: '', message: 'Audio conversion failed' } } // Transcribe with whisper.cpp const persistentDir = '/opt/whisper-cpp' const nodeModulesDir = join(process.cwd(), 'node_modules/whisper-node/lib/whisper.cpp') const whisperBin = existsSync(join(persistentDir, 'main')) ? join(persistentDir, 'main') : join(nodeModulesDir, 'main') const modelFile = existsSync(join(persistentDir, 'ggml-base.bin')) ? join(persistentDir, 'ggml-base.bin') : join(nodeModulesDir, 'models/ggml-base.bin') if (!existsSync(whisperBin) || !existsSync(modelFile)) { return { status: 500, command: 'unknown', transcript: '', message: 'Whisper not available' } } // Use short language code for whisper const whisperLang = language === 'de-DE' || language === 'de' ? 'de' : 'en' let whisperOutput: string try { whisperOutput = execFileSync( whisperBin, ['-m', modelFile, '-l', whisperLang, '-f', wavPath], { timeout: 30000, encoding: 'utf-8' } ) } catch (e: any) { console.error('[VoiceCmd] Whisper failed:', e.stderr || e.message) return { status: 500, command: 'unknown', transcript: '', message: 'Transcription failed' } } // Parse whisper output const segments = whisperOutput .split('\n') .filter(line => line.includes('-->')) .map(line => { const match = line.match(/\]\s*(.*)$/) return match ? match[1].trim() : '' }) .filter(Boolean) const transcript = segments.join(' ').trim() const command = detectCommand(transcript) console.log(`[VoiceCmd] transcript="${transcript}" → command=${command}`) return { status: 200, command, transcript } } finally { try { if (existsSync(audioPath)) unlinkSync(audioPath) } catch {} try { if (existsSync(wavPath)) unlinkSync(wavPath) } catch {} } })