Files
Void-Homelab/lib/api/routes/voice.js
root 70bdba1a24 feat(dross): voice Phase 2b — clip retention (2.13.0)
'Keep voice clips' setting (default off). When on, /api/voice/transcribe
saves the audio (0600) to the owner-only ZFS subvol at /var/lib/void/
voice-clips (CT 311 mp0, replicated to Z3) + a voice_clips row (migration
029, transcript+metadata in void-db). New clips list/play/delete API +
Settings UI. Storage path is configurable (VOICE_CLIPS_DIR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:27:40 +10:00

74 lines
3.2 KiB
JavaScript

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();
}));