From a78d72984a31abb91a84c9d66c0ad237612d9f63 Mon Sep 17 00:00:00 2001 From: SpirusNox <78000963+SpirusNox@users.noreply.github.com> Date: Sun, 2 Mar 2025 18:49:39 -0600 Subject: [PATCH] feat: enhance audio quality for playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store and serve original audio files alongside WAV transcription versions. This improves playback quality while maintaining transcription accuracy. - Add schema fields for original file name and type - Update upload process to preserve original files - Modify audio API to serve either original or WAV files - Update UI components to utilize high-quality audio when available - Maintain optimized WAV format for transcription purposes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/server/db/schema.ts | 224 ++--- src/routes/api/audio-files/+server.ts | 115 +-- src/routes/api/audio/[id]/+server.ts | 314 +++---- src/routes/api/transcription/[id]/+server.ts | 76 +- src/routes/api/upload/+server.ts | 285 ++++--- src/routes/components/AudioPlayer.svelte | 445 +++++----- src/routes/components/FilePanel.svelte | 840 ++++++++++--------- 7 files changed, 1182 insertions(+), 1117 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index bee1c40..74536b9 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,111 +1,113 @@ -import { pgTable, index, jsonb, boolean, serial, text, integer, timestamp } from 'drizzle-orm/pg-core'; -import { createId } from '@paralleldrive/cuid2'; - -export const user = pgTable('user', { - id: text('id') - .notNull() - .primaryKey() - .$defaultFn(() => createId()), - username: text('username').notNull().unique(), - passwordHash: text('password_hash').notNull(), - isAdmin: boolean('is_admin').default(false).notNull(), - createdAt: timestamp('created_at').defaultNow().notNull() -}); - -export const session = pgTable('session', { - id: text('id').primaryKey(), - userId: text('user_id') - .notNull() - .references(() => user.id), - expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() -}); - - -export const systemSettings = pgTable('system_settings', { - // Using serial instead of integer for auto-incrementing primary key - id: serial('id').primaryKey(), - isInitialized: boolean('is_initialized').notNull().default(false), - firstStartupDate: timestamp('first_startup_date'), - lastStartupDate: timestamp('last_startup_date'), - whisperModelSizes: text('whisper_model_sizes').array(), - whisperQuantization: text('whisper_quantization').notNull().default('none') -}); - -export const audioFiles = pgTable('audio_files', { - id: serial('id').primaryKey(), - - // File information - fileName: text('file_name').notNull(), - duration: integer('duration'), // in seconds - - // Transcription - transcript: jsonb('transcript'), - transcriptionStatus: text('transcription_status', { - enum: ['pending', 'processing', 'completed', 'failed'] - }).default('pending').notNull(), - - // Summary - summary: text('summary'), - summaryPrompt: text('summary_prompt'), - summaryStatus: text('summary_status', { - enum: ['pending', 'processing', 'completed', 'failed'] - }), - - // Metadata & progress - language: text('language').default('en'), - lastError: text('last_error'), - peaks: jsonb('peaks'), - modelSize: text('model_size').notNull().default('base'), - threads: integer('threads').notNull().default(4), - title: text('title'), - processors: integer('processors').notNull().default(1), - diarization: boolean('diarization').default(false), - transcriptionProgress: integer('transcription_progress').default(0), - - // Timestamps - uploadedAt: timestamp('uploaded_at').defaultNow().notNull(), - transcribedAt: timestamp('transcribed_at'), - summarizedAt: timestamp('summarized_at'), - updatedAt: timestamp('updated_at'), -}, (table) => { - return { - statusIdx: index('audio_files_status_idx').on(table.transcriptionStatus), - uploadedAtIdx: index('audio_files_uploaded_at_idx').on(table.uploadedAt), - summaryStatusIdx: index('audio_files_summary_status_idx').on(table.summaryStatus) - }; -}); - -export const speakerLabelsTable = pgTable('speaker_labels', { - fileId: integer('file_id').primaryKey().references(() => audioFiles.id), - labels: jsonb('labels').notNull(), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow() -}); - -export type TranscriptSegment = { - start: number; - end: number; - text: string; - speaker?: string; -}; - -export const summarizationTemplates = pgTable('summarization_templates', { - id: text('id') - .notNull() - .primaryKey() - .$defaultFn(() => createId()), - title: text('title').notNull(), - prompt: text('prompt').notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow() -}, (table) => { - return { - titleIdx: index('summarization_templates_title_idx').on(table.title) - }; -}); - -export type SummarizationTemplate = typeof summarizationTemplates.$inferSelect; - -export type Session = typeof session.$inferSelect; - -export type User = typeof user.$inferSelect; +import { pgTable, index, jsonb, boolean, serial, text, integer, timestamp } from 'drizzle-orm/pg-core'; +import { createId } from '@paralleldrive/cuid2'; + +export const user = pgTable('user', { + id: text('id') + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + username: text('username').notNull().unique(), + passwordHash: text('password_hash').notNull(), + isAdmin: boolean('is_admin').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull() +}); + +export const session = pgTable('session', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() +}); + + +export const systemSettings = pgTable('system_settings', { + // Using serial instead of integer for auto-incrementing primary key + id: serial('id').primaryKey(), + isInitialized: boolean('is_initialized').notNull().default(false), + firstStartupDate: timestamp('first_startup_date'), + lastStartupDate: timestamp('last_startup_date'), + whisperModelSizes: text('whisper_model_sizes').array(), + whisperQuantization: text('whisper_quantization').notNull().default('none') +}); + +export const audioFiles = pgTable('audio_files', { + id: serial('id').primaryKey(), + + // File information + fileName: text('file_name').notNull(), // WAV file for transcription + originalFileName: text('original_file_name'), // Original uploaded file name + originalFileType: text('original_file_type'), // Original file format (mp3, etc.) + duration: integer('duration'), // in seconds + + // Transcription + transcript: jsonb('transcript'), + transcriptionStatus: text('transcription_status', { + enum: ['pending', 'processing', 'completed', 'failed'] + }).default('pending').notNull(), + + // Summary + summary: text('summary'), + summaryPrompt: text('summary_prompt'), + summaryStatus: text('summary_status', { + enum: ['pending', 'processing', 'completed', 'failed'] + }), + + // Metadata & progress + language: text('language').default('en'), + lastError: text('last_error'), + peaks: jsonb('peaks'), + modelSize: text('model_size').notNull().default('base'), + threads: integer('threads').notNull().default(4), + title: text('title'), + processors: integer('processors').notNull().default(1), + diarization: boolean('diarization').default(false), + transcriptionProgress: integer('transcription_progress').default(0), + + // Timestamps + uploadedAt: timestamp('uploaded_at').defaultNow().notNull(), + transcribedAt: timestamp('transcribed_at'), + summarizedAt: timestamp('summarized_at'), + updatedAt: timestamp('updated_at'), +}, (table) => { + return { + statusIdx: index('audio_files_status_idx').on(table.transcriptionStatus), + uploadedAtIdx: index('audio_files_uploaded_at_idx').on(table.uploadedAt), + summaryStatusIdx: index('audio_files_summary_status_idx').on(table.summaryStatus) + }; +}); + +export const speakerLabelsTable = pgTable('speaker_labels', { + fileId: integer('file_id').primaryKey().references(() => audioFiles.id), + labels: jsonb('labels').notNull(), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow() +}); + +export type TranscriptSegment = { + start: number; + end: number; + text: string; + speaker?: string; +}; + +export const summarizationTemplates = pgTable('summarization_templates', { + id: text('id') + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + title: text('title').notNull(), + prompt: text('prompt').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow() +}, (table) => { + return { + titleIdx: index('summarization_templates_title_idx').on(table.title) + }; +}); + +export type SummarizationTemplate = typeof summarizationTemplates.$inferSelect; + +export type Session = typeof session.$inferSelect; + +export type User = typeof user.$inferSelect; diff --git a/src/routes/api/audio-files/+server.ts b/src/routes/api/audio-files/+server.ts index 14e975b..b391dd9 100644 --- a/src/routes/api/audio-files/+server.ts +++ b/src/routes/api/audio-files/+server.ts @@ -1,56 +1,59 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$lib/server/db'; -import { audioFiles } from '$lib/server/db/schema'; -import { desc } from 'drizzle-orm'; -import { requireAuth } from '$lib/server/auth'; - -export const GET: RequestHandler = async ({ locals }) => { - console.log("API AUDIOFILEA ---->") - await requireAuth(locals); - console.log("API AUDIOFILEA ----> finishing auth") - - try { - const files = await db - .select({ - id: audioFiles.id, - fileName: audioFiles.fileName, - duration: audioFiles.duration, - title: audioFiles.title, - transcriptionStatus: audioFiles.transcriptionStatus, - summary: audioFiles.summary, - language: audioFiles.language, - uploadedAt: audioFiles.uploadedAt, - transcribedAt: audioFiles.transcribedAt, - diarization: audioFiles.diarization, - }) - .from(audioFiles) - .orderBy(desc(audioFiles.uploadedAt)); - return json(files); - } catch (error) { - console.error('Error fetching audio files:', error); - return new Response('Failed to fetch audio files', { status: 500 }); - } -}; - -export const POST: RequestHandler = async ({ request, locals }) => { - await requireAuth(locals); - - try { - const { id, status } = await request.json(); - - const [updatedFile] = await db - .update(audioFiles) - .set({ - transcriptionStatus: status, - updatedAt: new Date(), - ...(status === 'completed' ? { transcribedAt: new Date() } : {}) - }) - .where(audioFiles.id === id) - .returning(); - return json(updatedFile); - } catch (error) { - console.error('Error updating audio file:', error); - return new Response('Failed to update audio file', { status: 500 }); - } -}; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { audioFiles } from '$lib/server/db/schema'; +import { desc } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/auth'; + +export const GET: RequestHandler = async ({ locals }) => { + console.log("API AUDIOFILEA ---->") + await requireAuth(locals); + console.log("API AUDIOFILEA ----> finishing auth") + + try { + const files = await db + .select({ + id: audioFiles.id, + fileName: audioFiles.fileName, + originalFileName: audioFiles.originalFileName, + originalFileType: audioFiles.originalFileType, + duration: audioFiles.duration, + title: audioFiles.title, + transcriptionStatus: audioFiles.transcriptionStatus, + summary: audioFiles.summary, + language: audioFiles.language, + uploadedAt: audioFiles.uploadedAt, + transcribedAt: audioFiles.transcribedAt, + diarization: audioFiles.diarization, + peaks: audioFiles.peaks, + }) + .from(audioFiles) + .orderBy(desc(audioFiles.uploadedAt)); + return json(files); + } catch (error) { + console.error('Error fetching audio files:', error); + return new Response('Failed to fetch audio files', { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + await requireAuth(locals); + + try { + const { id, status } = await request.json(); + + const [updatedFile] = await db + .update(audioFiles) + .set({ + transcriptionStatus: status, + updatedAt: new Date(), + ...(status === 'completed' ? { transcribedAt: new Date() } : {}) + }) + .where(audioFiles.id === id) + .returning(); + return json(updatedFile); + } catch (error) { + console.error('Error updating audio file:', error); + return new Response('Failed to update audio file', { status: 500 }); + } +}; \ No newline at end of file diff --git a/src/routes/api/audio/[id]/+server.ts b/src/routes/api/audio/[id]/+server.ts index 9bd51fe..06b3cdd 100644 --- a/src/routes/api/audio/[id]/+server.ts +++ b/src/routes/api/audio/[id]/+server.ts @@ -1,140 +1,174 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { createReadStream, statSync } from 'fs'; -import { join } from 'path'; -import { db } from '$lib/server/db'; -import { audioFiles } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { requireAuth } from '$lib/server/auth'; -import { AUDIO_DIR, WORK_DIR } from '$env/static/private'; - -export const GET: RequestHandler = async ({ params, locals, request }) => { - console.log("AUDIO REQ --->") - await requireAuth(locals); - console.log("AUDIO REQ ---> AUTH DONE") - try { - const id = parseInt(params.id); - if (isNaN(id)) { - throw error(400, 'Invalid file ID'); - } - - const [file] = await db - .select() - .from(audioFiles) - .where(eq(audioFiles.id, id)); - - if (!file) { - throw error(404, 'File not found'); - } - - const filePath = join(AUDIO_DIR, file.fileName); - const stats = statSync(filePath); - - // Handle range requests for better streaming - const range = request.headers.get('range'); - if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; - const chunkSize = (end - start) + 1; - const stream = createReadStream(filePath, { start, end }); - - const headers = new Headers({ - 'Content-Type': 'audio/mpeg', // or the correct mime type for your audio - 'Content-Length': chunkSize.toString(), - 'Content-Range': `bytes ${start}-${end}/${stats.size}`, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=3600', - }); - - return new Response(stream, { - status: 206, - headers - }); - } - - // Non-range request - send entire file - const stream = createReadStream(filePath); - const headers = new Headers({ - 'Content-Type': 'audio/mpeg', // or the correct mime type for your audio - 'Content-Length': stats.size.toString(), - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=3600', - }); - - return new Response(stream, { headers }); - } catch (err) { - console.error('Audio file serve error:', err); - if (err.status) throw err; - throw error(500, 'Internal server error'); - } -}; - -export const PATCH: RequestHandler = async ({ params, request, locals }) => { - await requireAuth(locals); - try { - const id = parseInt(params.id); - if (isNaN(id)) throw error(400, 'Invalid file ID'); - - const body = await request.json(); - - // Remove any undefined or null values from the update - const updateData = Object.fromEntries( - Object.entries(body).filter(([_, value]) => value !== undefined && value !== null) - ); - - // Validate the fields being updated - const allowedFields = new Set([ - 'title', - 'duration', - 'peaks', - 'transcriptionStatus', - 'language', - 'transcribedAt', - 'transcript', - 'diarization', - 'lastError' - ]); - - const invalidFields = Object.keys(updateData).filter(field => !allowedFields.has(field)); - if (invalidFields.length > 0) { - throw error(400, `Invalid fields: ${invalidFields.join(', ')}`); - } - - const [updated] = await db - .update(audioFiles) - .set(updateData) - .where(eq(audioFiles.id, id)) - .returning(); - - return json(updated); - } catch (err) { - console.error('Update error:', err); - throw error(500, String(err)); - } -}; - -export async function DELETE({ params }) { - try { - const fileId = parseInt(params.id); - if (isNaN(fileId)) { - return new Response('Invalid file ID', { status: 400 }); - } - - // Delete the record from the database - await db.delete(audioFiles) - .where(eq(audioFiles.id, fileId)); - - // You might also want to delete the actual file from storage here - // This depends on your storage implementation - // For example: - // await deleteFileFromStorage(fileId); - - return json({ success: true }); - } catch (error) { - console.error('Error deleting file:', error); - return new Response('Failed to delete file', { - status: 500 - }); - } -} +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createReadStream, statSync } from 'fs'; +import { join } from 'path'; +import { db } from '$lib/server/db'; +import { audioFiles } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/auth'; +import { AUDIO_DIR, WORK_DIR } from '$env/static/private'; + +export const GET: RequestHandler = async ({ params, locals, request, url }) => { + console.log("AUDIO REQ --->") + await requireAuth(locals); + console.log("AUDIO REQ ---> AUTH DONE") + try { + const id = parseInt(params.id); + if (isNaN(id)) { + throw error(400, 'Invalid file ID'); + } + + // Check if the original file is requested + const useOriginal = url.searchParams.get('original') === 'true'; + + const [file] = await db + .select() + .from(audioFiles) + .where(eq(audioFiles.id, id)); + + if (!file) { + throw error(404, 'File not found'); + } + + // Choose between original file or WAV based on request + let filePath; + let contentType; + + if (useOriginal && file.originalFileName) { + // Use the original high-quality file if requested and available + filePath = join(AUDIO_DIR, file.originalFileName); + + // Set the content type based on file extension + const fileExt = file.originalFileType?.toLowerCase() || ''; + switch (fileExt) { + case 'mp3': + contentType = 'audio/mpeg'; + break; + case 'm4a': + contentType = 'audio/mp4'; + break; + case 'ogg': + contentType = 'audio/ogg'; + break; + case 'flac': + contentType = 'audio/flac'; + break; + default: + contentType = 'audio/mpeg'; // fallback + } + } else { + // Use the WAV file by default (for transcription) + filePath = join(AUDIO_DIR, file.fileName); + contentType = 'audio/wav'; + } + + const stats = statSync(filePath); + + // Handle range requests for better streaming + const range = request.headers.get('range'); + if (range) { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + const chunkSize = (end - start) + 1; + const stream = createReadStream(filePath, { start, end }); + + const headers = new Headers({ + 'Content-Type': contentType, + 'Content-Length': chunkSize.toString(), + 'Content-Range': `bytes ${start}-${end}/${stats.size}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=3600', + }); + + return new Response(stream, { + status: 206, + headers + }); + } + + // Non-range request - send entire file + const stream = createReadStream(filePath); + const headers = new Headers({ + 'Content-Type': contentType, + 'Content-Length': stats.size.toString(), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=3600', + }); + + return new Response(stream, { headers }); + } catch (err) { + console.error('Audio file serve error:', err); + if (err.status) throw err; + throw error(500, 'Internal server error'); + } +}; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + await requireAuth(locals); + try { + const id = parseInt(params.id); + if (isNaN(id)) throw error(400, 'Invalid file ID'); + + const body = await request.json(); + + // Remove any undefined or null values from the update + const updateData = Object.fromEntries( + Object.entries(body).filter(([_, value]) => value !== undefined && value !== null) + ); + + // Validate the fields being updated + const allowedFields = new Set([ + 'title', + 'duration', + 'peaks', + 'transcriptionStatus', + 'language', + 'transcribedAt', + 'transcript', + 'diarization', + 'lastError' + ]); + + const invalidFields = Object.keys(updateData).filter(field => !allowedFields.has(field)); + if (invalidFields.length > 0) { + throw error(400, `Invalid fields: ${invalidFields.join(', ')}`); + } + + const [updated] = await db + .update(audioFiles) + .set(updateData) + .where(eq(audioFiles.id, id)) + .returning(); + + return json(updated); + } catch (err) { + console.error('Update error:', err); + throw error(500, String(err)); + } +}; + +export async function DELETE({ params }) { + try { + const fileId = parseInt(params.id); + if (isNaN(fileId)) { + return new Response('Invalid file ID', { status: 400 }); + } + + // Delete the record from the database + await db.delete(audioFiles) + .where(eq(audioFiles.id, fileId)); + + // You might also want to delete the actual file from storage here + // This depends on your storage implementation + // For example: + // await deleteFileFromStorage(fileId); + + return json({ success: true }); + } catch (error) { + console.error('Error deleting file:', error); + return new Response('Failed to delete file', { + status: 500 + }); + } +} \ No newline at end of file diff --git a/src/routes/api/transcription/[id]/+server.ts b/src/routes/api/transcription/[id]/+server.ts index 815cdf7..7309d73 100644 --- a/src/routes/api/transcription/[id]/+server.ts +++ b/src/routes/api/transcription/[id]/+server.ts @@ -1,37 +1,39 @@ -import { error } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { audioFiles } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import type { RequestHandler } from './$types'; -import { requireAuth } from '$lib/server/auth'; - -export const GET: RequestHandler = async ({ params, locals }) => { - await requireAuth(locals); - - if (!params.id) { - throw error(400, 'Missing ID parameter'); - } - - const audioFile = await db - .select() - .from(audioFiles) - .where(eq(audioFiles.id, parseInt(params.id))) - .then(rows => rows[0]); - - if (!audioFile) { - throw error(404, 'File not found'); - } - - return new Response(JSON.stringify({ - id: audioFile.id, - fileName: audioFile.fileName, - status: audioFile.transcriptionStatus, - transcript: audioFile.transcript, - error: audioFile.lastError, - updatedAt: audioFile.updatedAt - }), { - headers: { - 'Content-Type': 'application/json' - } - }); -}; +import { error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { audioFiles } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/auth'; + +export const GET: RequestHandler = async ({ params, locals }) => { + await requireAuth(locals); + + if (!params.id) { + throw error(400, 'Missing ID parameter'); + } + + const audioFile = await db + .select() + .from(audioFiles) + .where(eq(audioFiles.id, parseInt(params.id))) + .then(rows => rows[0]); + + if (!audioFile) { + throw error(404, 'File not found'); + } + + return new Response(JSON.stringify({ + id: audioFile.id, + fileName: audioFile.fileName, + originalFileName: audioFile.originalFileName, + originalFileType: audioFile.originalFileType, + status: audioFile.transcriptionStatus, + transcript: audioFile.transcript, + error: audioFile.lastError, + updatedAt: audioFile.updatedAt + }), { + headers: { + 'Content-Type': 'application/json' + } + }); +}; \ No newline at end of file diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts index 474dc71..fce2864 100644 --- a/src/routes/api/upload/+server.ts +++ b/src/routes/api/upload/+server.ts @@ -1,135 +1,150 @@ -import { error } from '@sveltejs/kit'; -import { requireAuth } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; -import { mkdir, writeFile, readFile, unlink } from 'fs/promises'; -import { join } from 'path'; -import { db } from '$lib/server/db'; -import { audioFiles } from '$lib/server/db/schema'; -import { queueTranscriptionJob } from '$lib/server/queue'; -import { promisify } from 'util'; -import { exec } from 'child_process'; -import { AUDIO_DIR, WORK_DIR } from '$env/static/private'; - -const execAsync = promisify(exec); - -let UPLOAD_DIR; -let TEMP_DIR; - -if (AUDIO_DIR !== '') { - UPLOAD_DIR = AUDIO_DIR; -} else { - UPLOAD_DIR = join(process.cwd(), 'uploads') -} - -if (WORK_DIR !== '') { - TEMP_DIR = WORK_DIR; - -} else { - TEMP_DIR = join(process.cwd(), 'temp'); -} - -async function convertToWav(inputPath: string): Promise { - await mkdir(TEMP_DIR, { recursive: true }); - const outputPath = join(TEMP_DIR, `${Date.now()}-converted.wav`); - - try { - await execAsync( - `ffmpeg -i "${inputPath}" -ar 16000 -ac 1 -c:a pcm_s16le "${outputPath}"` - ); - return outputPath; - } catch (err) { - console.error('Failed to convert audio:', err); - throw new Error('Audio conversion failed'); - } -} - -async function extractPeaks(audioPath: string): Promise { - try { - await mkdir(TEMP_DIR, { recursive: true }); - const jsonPath = join(TEMP_DIR, `${Date.now()}.json`); - - await execAsync(`audiowaveform -i "${audioPath}" -o "${jsonPath}"`); - - const waveformData = JSON.parse(await readFile(jsonPath, 'utf-8')); - - await unlink(jsonPath); - - return waveformData.data || []; - } catch (err) { - console.error('Failed to extract peaks:', err); - return []; - } -} - -export const POST: RequestHandler = async ({ request, locals}) => { - await requireAuth(locals); - - try { - await mkdir(UPLOAD_DIR, { recursive: true }); - const formData = await request.formData(); - const file = formData.get('file') as File; - const options = JSON.parse(formData.get('options') as string); - - if (!file) { - throw error(400, 'No file uploaded'); - } - - // Save original file to temp directory first - await mkdir(TEMP_DIR, { recursive: true }); - const tempOriginalPath = join(TEMP_DIR, `${Date.now()}-original-${file.name}`); - await writeFile(tempOriginalPath, Buffer.from(await file.arrayBuffer())); - console.log("SAVE ORIG --->") - - try { - // Convert to WAV - const convertedPath = await convertToWav(tempOriginalPath); - - // Generate final filename and move to uploads directory - const finalFileName = `${Date.now()}.wav`; - const finalPath = join(UPLOAD_DIR, finalFileName); - - // Move converted file to uploads directory - await execAsync(`mv "${convertedPath}" "${finalPath}"`); - - // Extract peaks from the converted WAV file - const peaks = await extractPeaks(finalPath); - - // Create database entry - const [audioFile] = await db.insert(audioFiles).values({ - fileName: finalFileName, - transcriptionStatus: 'pending', - language: options.language, - uploadedAt: new Date(), - title: finalFileName, - peaks, - modelSize: options.modelSize, - diarization: options.diarization, - threads: options.threads, - processors: options.processors, - }).returning(); - - // Queue transcription job - await queueTranscriptionJob(audioFile.id, options); - console.log('Queued job:', { audioFile }); - - // Clean up temp files - await unlink(tempOriginalPath).catch(console.error); - - return new Response(JSON.stringify({ - id: audioFile.id, - fileName: finalFileName, - peaks, - }), { - headers: { - 'Content-Type': 'application/json' - } - }); - } finally { - // Clean up temp original file if it exists - await unlink(tempOriginalPath).catch(() => {}); - } - } catch (err) { - console.error('Upload error:', err); - throw error(500, 'Failed to upload file'); - } -}; +import { error } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; +import { mkdir, writeFile, readFile, unlink } from 'fs/promises'; +import { join } from 'path'; +import { db } from '$lib/server/db'; +import { audioFiles } from '$lib/server/db/schema'; +import { queueTranscriptionJob } from '$lib/server/queue'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { AUDIO_DIR, WORK_DIR } from '$env/static/private'; + +const execAsync = promisify(exec); + +let UPLOAD_DIR; +let TEMP_DIR; + +if (AUDIO_DIR !== '') { + UPLOAD_DIR = AUDIO_DIR; +} else { + UPLOAD_DIR = join(process.cwd(), 'uploads') +} + +if (WORK_DIR !== '') { + TEMP_DIR = WORK_DIR; +} else { + TEMP_DIR = join(process.cwd(), 'temp'); +} + +async function convertToWav(inputPath: string): Promise { + await mkdir(TEMP_DIR, { recursive: true }); + const outputPath = join(TEMP_DIR, `${Date.now()}-converted.wav`); + + try { + await execAsync( + `ffmpeg -i "${inputPath}" -ar 16000 -ac 1 -c:a pcm_s16le "${outputPath}"` + ); + return outputPath; + } catch (err) { + console.error('Failed to convert audio:', err); + throw new Error('Audio conversion failed'); + } +} + +async function extractPeaks(audioPath: string): Promise { + try { + await mkdir(TEMP_DIR, { recursive: true }); + const jsonPath = join(TEMP_DIR, `${Date.now()}.json`); + + await execAsync(`audiowaveform -i "${audioPath}" -o "${jsonPath}"`); + + const waveformData = JSON.parse(await readFile(jsonPath, 'utf-8')); + + await unlink(jsonPath); + + return waveformData.data || []; + } catch (err) { + console.error('Failed to extract peaks:', err); + return []; + } +} + +export const POST: RequestHandler = async ({ request, locals}) => { + await requireAuth(locals); + + try { + await mkdir(UPLOAD_DIR, { recursive: true }); + const formData = await request.formData(); + const file = formData.get('file') as File; + const options = JSON.parse(formData.get('options') as string); + + if (!file) { + throw error(400, 'No file uploaded'); + } + + // Create directories if needed + await mkdir(TEMP_DIR, { recursive: true }); + await mkdir(UPLOAD_DIR, { recursive: true }); + + // Generate a timestamp for consistent naming + const timestamp = Date.now(); + const fileExt = file.name.split('.').pop()?.toLowerCase() || ''; + + // Create temporary path for uploaded file + const tempOriginalPath = join(TEMP_DIR, `${timestamp}-original-${file.name}`); + await writeFile(tempOriginalPath, Buffer.from(await file.arrayBuffer())); + console.log("SAVE ORIG --->") + + try { + // Save a copy of the original file in its native format for high-quality playback + const originalFileName = `${timestamp}-original.${fileExt}`; + const originalFilePath = join(UPLOAD_DIR, originalFileName); + await execAsync(`cp "${tempOriginalPath}" "${originalFilePath}"`); + console.log("Saved original file for playback:", originalFilePath); + + // Convert to WAV for transcription (optimized for speech recognition) + const convertedPath = await convertToWav(tempOriginalPath); + + // Generate WAV filename and move to uploads directory + const finalFileName = `${timestamp}.wav`; + const finalPath = join(UPLOAD_DIR, finalFileName); + + // Move converted file to uploads directory + await execAsync(`mv "${convertedPath}" "${finalPath}"`); + + // Extract peaks from the converted WAV file for visualization + const peaks = await extractPeaks(finalPath); + + // Create database entry with both original and WAV file info + const [audioFile] = await db.insert(audioFiles).values({ + fileName: finalFileName, // WAV file for transcription + originalFileName: originalFileName, // Original file preserved + originalFileType: fileExt, // Store the file type + transcriptionStatus: 'pending', + language: options.language, + uploadedAt: new Date(), + title: file.name.replace(/\.[^/.]+$/, ""), // Use original filename without extension as title + peaks, + modelSize: options.modelSize, + diarization: options.diarization, + threads: options.threads, + processors: options.processors, + }).returning(); + + // Queue transcription job + await queueTranscriptionJob(audioFile.id, options); + console.log('Queued job:', { audioFile }); + + // Clean up temp file + await unlink(tempOriginalPath).catch(console.error); + + return new Response(JSON.stringify({ + id: audioFile.id, + fileName: finalFileName, + originalFileName: originalFileName, + peaks, + }), { + headers: { + 'Content-Type': 'application/json' + } + }); + } finally { + // Clean up temp original file if it exists + await unlink(tempOriginalPath).catch(() => {}); + } + } catch (err) { + console.error('Upload error:', err); + throw error(500, 'Failed to upload file'); + } +}; \ No newline at end of file diff --git a/src/routes/components/AudioPlayer.svelte b/src/routes/components/AudioPlayer.svelte index d1ec18d..656d859 100644 --- a/src/routes/components/AudioPlayer.svelte +++ b/src/routes/components/AudioPlayer.svelte @@ -1,220 +1,225 @@ - - -
-
- {#if error} -
{error}
- {:else if isLoading} -
-
-
-
-
- {/if} - -
-
-
- -
-
- - - -
- -
- {currentTime} - / - {duration} -
-
-
-
+ + +
+
+ {#if error} +
{error}
+ {:else if isLoading} +
+
+
+
+
+ {/if} + +
+
+
+ +
+
+ + + +
+ +
+ {currentTime} + / + {duration} +
+
+
+
\ No newline at end of file diff --git a/src/routes/components/FilePanel.svelte b/src/routes/components/FilePanel.svelte index fcd8472..956253b 100644 --- a/src/routes/components/FilePanel.svelte +++ b/src/routes/components/FilePanel.svelte @@ -1,418 +1,422 @@ - - -{#if file} - - - {#if file.transcriptionStatus === 'completed' && file.transcript} -
- -
- - Transcript - Summary - -
- - - - - - Rename - { - deleteFile(file.id); - }}>Delete - {#if file.diarization} - (isDialogOpen = true)} - > - Label Speakers - - {/if} - - -
-
- - -
- {#each file.transcript as segment} -
-
- {#if file.diarization && segment.speaker} -
- - {currentLabels[segment.speaker]?.charAt(0).toUpperCase() + - currentLabels[segment.speaker]?.slice(1) || - segment.speaker.charAt(0).toUpperCase() + segment.speaker.slice(1)} -
- {/if} -
{formatTime(segment.start)}
-
-
- {segment.text} -
-
- {/each} -
-
-
- -
-
- - - {#snippet child({ props })} - - {/snippet} - - - - - - No templates found. - {#each $templates as template} - { - selectedTemplateId = template.id; - closeAndFocusTrigger(); - }} - class="text-gray-200 aria-selected:bg-neutral-600 aria-selected:text-gray-50" - > - - {template.title} - - {/each} - - - - -
- -
- - {#if file.summary} -
- {file.summary} -
- {:else if isSummarizing} -
-
Generating summary...
-
- {:else if summary} -
- {summary} -
- {:else} -
- Select a template and click summarize to generate a summary -
- {/if} -
-
-
-
- {/if} - - - - - Rename File - Enter a new title for this file - -
- - {#if error} -

{error}

- {/if} -
- -
- - -
-
-
-
- - {#if file.diarization} - - - - Label Speakers - - Assign custom names to speakers in the transcript - - - - - - - {/if} -{/if} + + +{#if file} + + + {#if file.transcriptionStatus === 'completed' && file.transcript} +
+ +
+ + Transcript + Summary + +
+ + + + + + Rename + { + deleteFile(file.id); + }}>Delete + {#if file.diarization} + (isDialogOpen = true)} + > + Label Speakers + + {/if} + + +
+
+ + +
+ {#each file.transcript as segment} +
+
+ {#if file.diarization && segment.speaker} +
+ + {currentLabels[segment.speaker]?.charAt(0).toUpperCase() + + currentLabels[segment.speaker]?.slice(1) || + segment.speaker.charAt(0).toUpperCase() + segment.speaker.slice(1)} +
+ {/if} +
{formatTime(segment.start)}
+
+
+ {segment.text} +
+
+ {/each} +
+
+
+ +
+
+ + + {#snippet child({ props })} + + {/snippet} + + + + + + No templates found. + {#each $templates as template} + { + selectedTemplateId = template.id; + closeAndFocusTrigger(); + }} + class="text-gray-200 aria-selected:bg-neutral-600 aria-selected:text-gray-50" + > + + {template.title} + + {/each} + + + + +
+ +
+ + {#if file.summary} +
+ {file.summary} +
+ {:else if isSummarizing} +
+
Generating summary...
+
+ {:else if summary} +
+ {summary} +
+ {:else} +
+ Select a template and click summarize to generate a summary +
+ {/if} +
+
+
+
+ {/if} + + + + + Rename File + Enter a new title for this file + +
+ + {#if error} +

{error}

+ {/if} +
+ +
+ + +
+
+
+
+ + {#if file.diarization} + + + + Label Speakers + + Assign custom names to speakers in the transcript + + + + + + + {/if} +{/if}