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:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/recordings/recording-2026-02-14T07-52-35-438Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-52-35-438Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-53-03-073Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-53-03-073Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-53-30-435Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-53-30-435Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-53-50-254Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-53-50-254Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-54-14-015Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-54-14-015Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-54-30-904Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-54-30-904Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-54-40-560Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-54-40-560Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-55-50-902Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-55-50-902Z.webm
Normal file
Binary file not shown.
BIN
server/recordings/recording-2026-02-14T07-56-02-707Z.webm
Normal file
BIN
server/recordings/recording-2026-02-14T07-56-02-707Z.webm
Normal file
Binary file not shown.
@@ -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
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