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>
This commit is contained in:
@@ -10,7 +10,7 @@ import * as agents from '../../db/repos/agents.js';
|
||||
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const COMPANION_SLUG = 'companion';
|
||||
|
||||
export const router = Router();
|
||||
@@ -23,7 +23,8 @@ const settingsBody = z.object({
|
||||
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
persona: z.string().max(8000),
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action']),
|
||||
keepClips: z.boolean().default(false)
|
||||
});
|
||||
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||
|
||||
@@ -1,24 +1,73 @@
|
||||
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 }.
|
||||
// (Phase 2b will optionally persist the clip + transcript when keepClips is on.)
|
||||
// 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 {
|
||||
const r = await whisper.transcribe(
|
||||
r = await whisper.transcribe(
|
||||
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
|
||||
res.json({ text: r.text, duration: r.duration ?? null });
|
||||
} catch {
|
||||
res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
|
||||
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();
|
||||
}));
|
||||
|
||||
14
lib/db/migrations/029_voice_clips.sql
Normal file
14
lib/db/migrations/029_voice_clips.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 029_voice_clips.sql
|
||||
-- Optional retained Dross voice clips (when the "Keep voice clips" setting is on).
|
||||
-- Transcript + metadata here (durable, HA-replicated); audio bytes live as files
|
||||
-- on the owner-only ZFS subvol mounted at /var/lib/void/voice-clips.
|
||||
CREATE TABLE voice_clips (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
transcript text NOT NULL DEFAULT '',
|
||||
duration_ms integer,
|
||||
bytes bigint,
|
||||
mime text,
|
||||
path text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_voice_clips_created ON voice_clips (created_at DESC);
|
||||
26
lib/db/repos/voice_clips.js
Normal file
26
lib/db/repos/voice_clips.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
export async function create({ transcript = '', duration_ms = null, bytes = null, mime = null, path }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO voice_clips (transcript, duration_ms, bytes, mime, path)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[transcript, duration_ms, bytes, mime, path]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function list(limit = 100) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, transcript, duration_ms, bytes, mime, created_at
|
||||
FROM voice_clips ORDER BY created_at DESC LIMIT $1`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(id) {
|
||||
const { rows } = await pool.query(`SELECT * FROM voice_clips WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
const { rows } = await pool.query(`DELETE FROM voice_clips WHERE id = $1 RETURNING path`, [id]);
|
||||
return rows[0] || null; // returns {path} so the caller can unlink the file
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.12.1",
|
||||
"version": "2.13.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -776,3 +776,7 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||
.dross-clips{display:flex;flex-direction:column;gap:2px}
|
||||
.dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08}
|
||||
.dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dross-clip audio{display:none}
|
||||
|
||||
@@ -151,7 +151,7 @@ async function renderAgents(c) {
|
||||
|
||||
function drossBody() {
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const avatarRow = el('div', { class: 'dross-pick' });
|
||||
const accent = el('input', { type: 'color', value: cur.accent });
|
||||
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||
@@ -159,6 +159,8 @@ function drossBody() {
|
||||
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||
const keep = el('input', { type: 'checkbox' });
|
||||
const clipsWrap = el('div', { class: 'dross-clips' });
|
||||
|
||||
function paintAvatars() {
|
||||
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||
@@ -169,22 +171,55 @@ function drossBody() {
|
||||
return card;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadClips() {
|
||||
if (!keep.checked) { mount(clipsWrap); return; }
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
|
||||
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
|
||||
...rows.map(c => {
|
||||
const audio = el('audio');
|
||||
const play = el('button', { class: 'ghost' }, '▶');
|
||||
play.onclick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
|
||||
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
|
||||
audio.src = URL.createObjectURL(await res.blob()); audio.play();
|
||||
} catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
|
||||
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
|
||||
return el('div', { class: 'dross-clip' }, play, audio,
|
||||
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
|
||||
el('span', { class: 'muted', style: { fontSize: '10px' } },
|
||||
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
del);
|
||||
}));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
|
||||
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
keep.addEventListener('change', loadClips);
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||
await api.put('/api/dross/settings', {
|
||||
avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value, keepClips: keep.checked
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||
out.textContent = 'Saved.';
|
||||
} catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||
persona,
|
||||
el('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
|
||||
el('div', { class: 'theme-actions' }, mode, save, out),
|
||||
clipsWrap);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
|
||||
19
tests/repos/voice_clips.test.js
Normal file
19
tests/repos/voice_clips.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import * as clips from '../../lib/db/repos/voice_clips.js';
|
||||
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('voice_clips repo', () => {
|
||||
it('creates, lists newest-first, and removes (returning path)', async () => {
|
||||
const a = await clips.create({ transcript: 'first', bytes: 10, mime: 'audio/webm', path: '/x/a.webm' });
|
||||
const b = await clips.create({ transcript: 'second', bytes: 20, mime: 'audio/webm', path: '/x/b.webm' });
|
||||
const list = await clips.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].transcript).toBe('second'); // newest first
|
||||
const removed = await clips.remove(a.id);
|
||||
expect(removed.path).toBe('/x/a.webm');
|
||||
expect((await clips.list()).length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user