'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>
74 lines
3.2 KiB
JavaScript
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();
|
|
}));
|