diff --git a/lib/api/routes/dross.js b/lib/api/routes/dross.js index a06c586..11c3925 100644 --- a/lib/api/routes/dross.js +++ b/lib/api/routes/dross.js @@ -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)))); diff --git a/lib/api/routes/voice.js b/lib/api/routes/voice.js index fa54856..efd25b4 100644 --- a/lib/api/routes/voice.js +++ b/lib/api/routes/voice.js @@ -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(); })); diff --git a/lib/db/migrations/029_voice_clips.sql b/lib/db/migrations/029_voice_clips.sql new file mode 100644 index 0000000..7965a5d --- /dev/null +++ b/lib/db/migrations/029_voice_clips.sql @@ -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); diff --git a/lib/db/repos/voice_clips.js b/lib/db/repos/voice_clips.js new file mode 100644 index 0000000..73e0600 --- /dev/null +++ b/lib/db/repos/voice_clips.js @@ -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 +} diff --git a/package.json b/package.json index 5acacf3..07a1558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.12.1", + "version": "2.13.0", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index 8707b3c..41e6f69 100644 --- a/public/style.css +++ b/public/style.css @@ -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} diff --git a/public/views/settings.js b/public/views/settings.js index 73b3d8b..41f173a 100644 --- a/public/views/settings.js +++ b/public/views/settings.js @@ -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) { diff --git a/tests/repos/voice_clips.test.js b/tests/repos/voice_clips.test.js new file mode 100644 index 0000000..058fb89 --- /dev/null +++ b/tests/repos/voice_clips.test.js @@ -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); + }); +});