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:
@@ -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<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -175,5 +176,11 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
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()
|
||||
}
|
||||
|
||||
167
server/routes/recordings.ts
Normal file
167
server/routes/recordings.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user