diff --git a/.smallclaw/config.json b/.smallclaw/config.json index a600747..08f0cb9 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -214,15 +214,19 @@ "path": "/hooks" }, "voice": { - "enabled": false, + "enabled": true, + "ffmpegPath": "/usr/bin/ffmpeg", "stt": { "provider": "whisper_cpp", - "model": "ggml-base.bin", + "whisperPath": "/usr/local/bin/whisper-cli", + "modelPath": "/usr/local/share/whisper-models/ggml-base.bin", "language": "ko" }, "tts": { "provider": "piper", - "model": "ko_KR-kss-medium" + "piperPath": "/usr/local/bin/piper", + "modelPath": "/usr/local/share/piper-voices/ko_KR-kss-medium.onnx", + "configPath": "/usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json" } }, "search": { diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index d0812d8..dd5e72a 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -144,6 +144,8 @@ import { clearFileOpCheckpoint, } from '../orchestration/file-op-v2'; import { OllamaProcessManager } from './ollama-process-manager'; +import { transcribeAudio } from '../tools/stt.js'; +import { synthesizeSpeech, isTTSAvailable } from '../tools/tts.js'; import { raceWithWatchdog, PreemptState } from './preempt-watchdog'; import { detectGpu, logGpuStatus } from './gpu-detector'; import { internalAgentTaskRouter } from './internal-agent-task'; @@ -8718,6 +8720,42 @@ app.put('/api/files/{*filePath}', (req: express.Request, res: express.Response) } }); +// ── Voice: STT (Whisper) ────────────────────────────────────────────────── +app.post('/api/voice/stt', async (req: express.Request, res: express.Response) => { + const contentType = String(req.headers['content-type'] || 'audio/webm'); + const fmt = contentType.includes('ogg') ? 'ogg' + : contentType.includes('wav') ? 'wav' + : contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a' + : 'webm'; + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', async () => { + const buf = Buffer.concat(chunks); + if (!buf.length) { res.status(400).json({ success: false, error: 'No audio data' }); return; } + try { + const result = await transcribeAudio(buf, fmt); + res.json({ success: true, text: result.text.trim(), language: result.language }); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); + } + }); + req.on('error', (e: any) => res.status(500).json({ success: false, error: String(e?.message || e) })); +}); + +// ── Voice: TTS (Piper) ──────────────────────────────────────────────────── +app.post('/api/voice/tts', async (req: express.Request, res: express.Response) => { + const { text } = req.body || {}; + if (!text || typeof text !== 'string') { res.status(400).json({ success: false, error: 'text required' }); return; } + if (!isTTSAvailable()) { res.status(503).json({ success: false, error: 'TTS not configured' }); return; } + try { + const audio = await synthesizeSpeech(text.slice(0, 2000)); + res.set('Content-Type', 'audio/ogg'); + res.send(audio); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); + } +}); + // ── Canvas code execution ────────────────────────────────────────────────── const CANVAS_EXEC_EXT: Record = { '.py': 'python3', '.js': 'node', '.ts': 'npx tsx', '.mjs': 'node', diff --git a/web-ui/index.html b/web-ui/index.html index 8348f3e..7bb3fe4 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -1021,6 +1021,10 @@ .btn { padding: 10px 16px; font-size: 12px; } .btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; } + #mic-btn.recording { background: #ef4444 !important; animation: mic-pulse 1s ease-in-out infinite; } + @keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:0.55} } + .tts-btn { background:none;border:none;cursor:pointer;font-size:13px;padding:1px 4px;border-radius:4px;opacity:0.45;vertical-align:middle;line-height:1; } + .tts-btn:hover,.tts-btn.playing{opacity:1;background:var(--panel-2);} .export-dropdown { position:relative; display:inline-block; } .export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; } .export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; } @@ -2420,6 +2424,7 @@
+