- 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
168 lines
5.4 KiB
TypeScript
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
|
|
}
|