import { Router } from 'express'; import multer from 'multer'; import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { writeFile, unlink } from 'node:fs/promises'; import path from 'node:path'; import { asyncWrap } from '../errors.js'; import { requireOwner } from '../cap.js'; import * as whisper from '../../voice/whisper.js'; import * as settings from '../../db/repos/app_settings.js'; import * as clips from '../../db/repos/voice_clips.js'; export const router = Router(); const CLIPS_DIR = process.env.VOICE_CLIPS_DIR || '/var/lib/void/voice-clips'; // In-memory upload; clips are small voice notes. 25 MB ceiling. const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); function extFor(mime = '') { if (mime.includes('ogg')) return '.ogg'; if (mime.includes('mp4') || mime.includes('m4a')) return '.m4a'; if (mime.includes('wav')) return '.wav'; return '.webm'; } // POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }. // When the Dross "keepClips" setting is on, the clip + transcript are retained. router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => { if (!req.file || !req.file.buffer?.length) { return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } }); } let r; try { r = await whisper.transcribe( req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm'); } catch { return res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } }); } const cfg = await settings.get('dross', {}); let clip_id = null; if (cfg?.keepClips) { try { const id = randomUUID(); const mime = req.file.mimetype || 'audio/webm'; const filePath = path.join(CLIPS_DIR, id + extFor(mime)); await writeFile(filePath, req.file.buffer, { mode: 0o600 }); const row = await clips.create({ transcript: r.text, duration_ms: r.duration != null ? Math.round(r.duration * 1000) : null, bytes: req.file.buffer.length, mime, path: filePath }); clip_id = row.id; } catch { /* retention is best-effort; never fail the transcript */ } } res.json({ text: r.text, duration: r.duration ?? null, clip_id }); })); // GET /api/voice/clips — list retained clips (owner). router.get('/clips', requireOwner, asyncWrap(async (_req, res) => res.json(await clips.list()))); // GET /api/voice/clips/:id/audio — stream the audio file (owner). router.get('/clips/:id/audio', requireOwner, asyncWrap(async (req, res) => { const c = await clips.get(req.params.id); if (!c) return res.status(404).json({ error: { code: 'not_found', message: 'clip not found' } }); res.setHeader('Content-Type', c.mime || 'audio/webm'); createReadStream(c.path).on('error', () => res.status(404).end()).pipe(res); })); // DELETE /api/voice/clips/:id — remove the row + the file (owner). router.delete('/clips/:id', requireOwner, asyncWrap(async (req, res) => { const removed = await clips.remove(req.params.id); if (removed?.path) { try { await unlink(removed.path); } catch { /* file may be gone */ } } res.status(204).end(); }));