feat: Auto-save voice recordings for model training

- Add /api/recordings endpoint with full CRUD operations
- Create voice_recordings SQLite table for metadata
- Save audio files to server/recordings/ as .webm
- Store transcription, duration, microphone name, file size
- Auto-save on each Whisper recording completion
This commit is contained in:
2026-02-14 01:56:53 -06:00
parent 5da6179f75
commit 950572046e
14 changed files with 229 additions and 0 deletions

View File

@@ -71,6 +71,7 @@ const showMicSelector = ref(false)
const lastAudioUrl = ref<string>('') const lastAudioUrl = ref<string>('')
const isPlayingAudio = ref(false) const isPlayingAudio = ref(false)
let audioElement: HTMLAudioElement | null = null let audioElement: HTMLAudioElement | null = null
let recordingStartTime = 0
function playLastAudio() { function playLastAudio() {
if (!lastAudioUrl.value) return if (!lastAudioUrl.value) return
@@ -98,6 +99,42 @@ function saveAudioForPlayback(blob: Blob) {
URL.revokeObjectURL(lastAudioUrl.value) URL.revokeObjectURL(lastAudioUrl.value)
} }
lastAudioUrl.value = URL.createObjectURL(blob) 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(() => { const currentMicName = computed(() => {
@@ -438,6 +475,7 @@ async function startWhisperRecording() {
// Start recording // Start recording
mediaRecorder.start(100) // Collect data every 100ms mediaRecorder.start(100) // Collect data every 100ms
isRecording.value = true isRecording.value = true
recordingStartTime = Date.now()
// Send chunks periodically for progressive transcription // Send chunks periodically for progressive transcription
chunkInterval = window.setInterval(() => { chunkInterval = window.setInterval(() => {

View File

@@ -11,3 +11,6 @@ export const MAX_BUFFER_LINES = 1000
// Database // Database
export const DB_PATH = 'agent-ui.db' export const DB_PATH = 'agent-ui.db'
// Recordings
export const RECORDINGS_DIR = 'recordings'

View File

@@ -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 // Run column migrations for existing tables
runColumnMigrations(db) runColumnMigrations(db)
} }

View File

@@ -8,6 +8,7 @@ import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanva
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea' import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database' import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database'
import { handleWhisperRoutes } from './whisper' import { handleWhisperRoutes } from './whisper'
import { handleRecordingsRoutes } from './recordings'
export async function handleRequest(req: Request): Promise<Response> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -175,5 +176,11 @@ export async function handleRequest(req: Request): Promise<Response> {
if (res) return res 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() return notFoundResponse()
} }

167
server/routes/recordings.ts Normal file
View File

@@ -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<Response | null> {
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
}