Files
agent-ui/server/routes/recordings.ts
josedario87 950572046e 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
2026-02-14 01:56:53 -06:00

168 lines
5.4 KiB
TypeScript

/**
* 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
}