diff --git a/frontend/src/components/FloatingVoice.vue b/frontend/src/components/FloatingVoice.vue index 52a2599..837af65 100644 --- a/frontend/src/components/FloatingVoice.vue +++ b/frontend/src/components/FloatingVoice.vue @@ -71,6 +71,7 @@ const showMicSelector = ref(false) const lastAudioUrl = ref('') const isPlayingAudio = ref(false) let audioElement: HTMLAudioElement | null = null +let recordingStartTime = 0 function playLastAudio() { if (!lastAudioUrl.value) return @@ -98,6 +99,42 @@ function saveAudioForPlayback(blob: Blob) { URL.revokeObjectURL(lastAudioUrl.value) } lastAudioUrl.value = URL.createObjectURL(blob) + + // Also save to backend for training data + saveRecordingToBackend(blob) +} + +async function saveRecordingToBackend(blob: Blob) { + try { + const duration_ms = Date.now() - recordingStartTime + const reader = new FileReader() + + reader.onloadend = async () => { + const base64 = (reader.result as string).split(',')[1] + + const response = await fetch(`http://${window.location.hostname}:4100/api/recordings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + audio: base64, + transcription: transcript.value.trim(), + microphone: currentMicName.value, + duration_ms + }) + }) + + const data = await response.json() + if (data.success) { + console.log(`[Voice] Recording saved: ${data.filename} (${(data.size / 1024).toFixed(1)} KB)`) + } else { + console.error('[Voice] Failed to save recording:', data.error) + } + } + + reader.readAsDataURL(blob) + } catch (e) { + console.error('[Voice] Error saving recording:', e) + } } const currentMicName = computed(() => { @@ -438,6 +475,7 @@ async function startWhisperRecording() { // Start recording mediaRecorder.start(100) // Collect data every 100ms isRecording.value = true + recordingStartTime = Date.now() // Send chunks periodically for progressive transcription chunkInterval = window.setInterval(() => { diff --git a/server/config.ts b/server/config.ts index 2611c42..4f69d7d 100644 --- a/server/config.ts +++ b/server/config.ts @@ -11,3 +11,6 @@ export const MAX_BUFFER_LINES = 1000 // Database export const DB_PATH = 'agent-ui.db' + +// Recordings +export const RECORDINGS_DIR = 'recordings' diff --git a/server/db/migrations.ts b/server/db/migrations.ts index dee9f50..eddafd1 100644 --- a/server/db/migrations.ts +++ b/server/db/migrations.ts @@ -85,6 +85,20 @@ export function runMigrations(db: Database) { ) `) + // Voice recordings table (for training custom speech models) + db.run(` + CREATE TABLE IF NOT EXISTS voice_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + transcription TEXT, + duration_ms INTEGER, + microphone TEXT, + sample_rate INTEGER, + file_size INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `) + // Run column migrations for existing tables runColumnMigrations(db) } diff --git a/server/recordings/recording-2026-02-14T07-52-35-438Z.webm b/server/recordings/recording-2026-02-14T07-52-35-438Z.webm new file mode 100644 index 0000000..914ab0c Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-52-35-438Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-53-03-073Z.webm b/server/recordings/recording-2026-02-14T07-53-03-073Z.webm new file mode 100644 index 0000000..beedc20 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-53-03-073Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-53-30-435Z.webm b/server/recordings/recording-2026-02-14T07-53-30-435Z.webm new file mode 100644 index 0000000..0877aea Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-53-30-435Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-53-50-254Z.webm b/server/recordings/recording-2026-02-14T07-53-50-254Z.webm new file mode 100644 index 0000000..0bb2e88 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-53-50-254Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-54-14-015Z.webm b/server/recordings/recording-2026-02-14T07-54-14-015Z.webm new file mode 100644 index 0000000..0e9feba Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-54-14-015Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-54-30-904Z.webm b/server/recordings/recording-2026-02-14T07-54-30-904Z.webm new file mode 100644 index 0000000..dca6075 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-54-30-904Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-54-40-560Z.webm b/server/recordings/recording-2026-02-14T07-54-40-560Z.webm new file mode 100644 index 0000000..b030a42 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-54-40-560Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-55-50-902Z.webm b/server/recordings/recording-2026-02-14T07-55-50-902Z.webm new file mode 100644 index 0000000..8aa8720 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-55-50-902Z.webm differ diff --git a/server/recordings/recording-2026-02-14T07-56-02-707Z.webm b/server/recordings/recording-2026-02-14T07-56-02-707Z.webm new file mode 100644 index 0000000..0802507 Binary files /dev/null and b/server/recordings/recording-2026-02-14T07-56-02-707Z.webm differ diff --git a/server/routes/index.ts b/server/routes/index.ts index f993292..e31b8a1 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -8,6 +8,7 @@ import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanva import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea' import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database' import { handleWhisperRoutes } from './whisper' +import { handleRecordingsRoutes } from './recordings' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -175,5 +176,11 @@ export async function handleRequest(req: Request): Promise { if (res) return res } + // Voice recordings (for training custom models) + if (path.startsWith('/api/recordings')) { + const res = await handleRecordingsRoutes(req) + if (res) return res + } + return notFoundResponse() } diff --git a/server/routes/recordings.ts b/server/routes/recordings.ts new file mode 100644 index 0000000..b2ee610 --- /dev/null +++ b/server/routes/recordings.ts @@ -0,0 +1,167 @@ +/** + * Voice Recordings API routes + * Save audio recordings for training custom speech models + */ + +import { db } from '../db' +import { RECORDINGS_DIR } from '../config' +import { mkdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +// Ensure recordings directory exists +async function ensureRecordingsDir() { + if (!existsSync(RECORDINGS_DIR)) { + await mkdir(RECORDINGS_DIR, { recursive: true }) + } +} + +export async function handleRecordingsRoutes(req: Request): Promise { + const url = new URL(req.url) + const path = url.pathname + + // GET /api/recordings - List all recordings + if (path === '/api/recordings' && req.method === 'GET') { + const recordings = db.query(` + SELECT * FROM voice_recordings + ORDER BY created_at DESC + `).all() + + return Response.json({ + success: true, + recordings, + count: recordings.length + }) + } + + // POST /api/recordings - Save a new recording + if (path === '/api/recordings' && req.method === 'POST') { + try { + await ensureRecordingsDir() + + const body = await req.json() + const { audio, transcription, microphone, duration_ms } = body + + if (!audio) { + return Response.json({ success: false, error: 'No audio data provided' }, { status: 400 }) + } + + // Decode base64 audio + const audioBuffer = Buffer.from(audio, 'base64') + + // Generate filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `recording-${timestamp}.webm` + const filepath = join(RECORDINGS_DIR, filename) + + // Save audio file + await Bun.write(filepath, audioBuffer) + + // Save metadata to database + const result = db.run(` + INSERT INTO voice_recordings (filename, transcription, duration_ms, microphone, file_size) + VALUES (?, ?, ?, ?, ?) + `, [filename, transcription || '', duration_ms || 0, microphone || 'unknown', audioBuffer.length]) + + return Response.json({ + success: true, + id: result.lastInsertRowid, + filename, + size: audioBuffer.length, + message: 'Recording saved' + }) + } catch (e: any) { + console.error('[Recordings] Error saving:', e) + return Response.json({ success: false, error: e.message }, { status: 500 }) + } + } + + // GET /api/recordings/:id - Get recording metadata + const recordingMatch = path.match(/^\/api\/recordings\/(\d+)$/) + if (recordingMatch && req.method === 'GET') { + const id = recordingMatch[1] + const recording = db.query('SELECT * FROM voice_recordings WHERE id = ?').get(id) + + if (!recording) { + return Response.json({ success: false, error: 'Recording not found' }, { status: 404 }) + } + + return Response.json({ success: true, recording }) + } + + // GET /api/recordings/:id/audio - Download audio file + const audioMatch = path.match(/^\/api\/recordings\/(\d+)\/audio$/) + if (audioMatch && req.method === 'GET') { + const id = audioMatch[1] + const recording = db.query('SELECT filename FROM voice_recordings WHERE id = ?').get(id) as { filename: string } | null + + if (!recording) { + return Response.json({ success: false, error: 'Recording not found' }, { status: 404 }) + } + + const filepath = join(RECORDINGS_DIR, recording.filename) + if (!existsSync(filepath)) { + return Response.json({ success: false, error: 'Audio file not found' }, { status: 404 }) + } + + const file = Bun.file(filepath) + return new Response(file, { + headers: { + 'Content-Type': 'audio/webm', + 'Content-Disposition': `attachment; filename="${recording.filename}"` + } + }) + } + + // DELETE /api/recordings/:id - Delete a recording + const deleteMatch = path.match(/^\/api\/recordings\/(\d+)$/) + if (deleteMatch && req.method === 'DELETE') { + const id = deleteMatch[1] + const recording = db.query('SELECT filename FROM voice_recordings WHERE id = ?').get(id) as { filename: string } | null + + if (!recording) { + return Response.json({ success: false, error: 'Recording not found' }, { status: 404 }) + } + + // Delete file + const filepath = join(RECORDINGS_DIR, recording.filename) + if (existsSync(filepath)) { + await Bun.write(filepath, '') // Clear file + const file = Bun.file(filepath) + // Note: Bun doesn't have a direct unlink, using rm + const { rm } = await import('node:fs/promises') + await rm(filepath) + } + + // Delete from database + db.run('DELETE FROM voice_recordings WHERE id = ?', [id]) + + return Response.json({ success: true, message: 'Recording deleted' }) + } + + // GET /api/recordings/stats - Get recording statistics + if (path === '/api/recordings/stats' && req.method === 'GET') { + const stats = db.query(` + SELECT + COUNT(*) as total_recordings, + SUM(file_size) as total_size, + SUM(duration_ms) as total_duration_ms, + AVG(duration_ms) as avg_duration_ms + FROM voice_recordings + `).get() as any + + return Response.json({ + success: true, + stats: { + totalRecordings: stats.total_recordings || 0, + totalSizeBytes: stats.total_size || 0, + totalSizeMB: ((stats.total_size || 0) / 1024 / 1024).toFixed(2), + totalDurationMs: stats.total_duration_ms || 0, + totalDurationMinutes: ((stats.total_duration_ms || 0) / 1000 / 60).toFixed(2), + avgDurationMs: Math.round(stats.avg_duration_ms || 0) + } + }) + } + + return null +}