diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs index ded3e22..c1f31f1 100644 --- a/src-tauri/src/commands/media.rs +++ b/src-tauri/src/commands/media.rs @@ -7,15 +7,18 @@ use std::os::windows::process::CommandExt; /// Extract audio from a video file to a WAV file using ffmpeg. /// Returns the path to the extracted audio file. #[tauri::command] -pub fn extract_audio(file_path: String) -> Result { +pub fn extract_audio(file_path: String, output_path: Option) -> Result { let input = PathBuf::from(&file_path); if !input.exists() { return Err(format!("File not found: {}", file_path)); } - // Output to a temp WAV file next to the original or in temp dir + // Use provided output path, or fall back to a temp WAV file let stem = input.file_stem().unwrap_or_default().to_string_lossy(); - let output = std::env::temp_dir().join(format!("{stem}_audio.wav")); + let output = match output_path { + Some(ref p) => PathBuf::from(p), + None => std::env::temp_dir().join(format!("{stem}_audio.wav")), + }; eprintln!( "[media] Extracting audio: {} -> {}", @@ -35,7 +38,7 @@ pub fn extract_audio(file_path: String) -> Result { "-acodec", "pcm_s16le", // WAV PCM 16-bit "-ar", - "16000", // 16kHz (optimal for whisper) + "22050", // 22kHz mono for better playback quality "-ac", "1", // Mono ]) @@ -63,6 +66,23 @@ pub fn extract_audio(file_path: String) -> Result { Ok(output.to_string_lossy().to_string()) } +#[tauri::command] +pub fn check_file_exists(path: String) -> bool { + std::path::Path::new(&path).exists() +} + +#[tauri::command] +pub fn copy_file(src: String, dst: String) -> Result<(), String> { + std::fs::copy(&src, &dst).map_err(|e| format!("Failed to copy file: {e}"))?; + Ok(()) +} + +#[tauri::command] +pub fn create_dir(path: String) -> Result<(), String> { + std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory: {e}"))?; + Ok(()) +} + /// Find ffmpeg binary — check sidecar directory first, then system PATH. fn find_ffmpeg() -> Option { // Check sidecar extract dir (ffmpeg is bundled with the sidecar) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5854092..1662e70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ use tauri::Manager; use commands::ai::{ai_chat, ai_configure, ai_list_providers}; use commands::export::export_transcript; -use commands::media::extract_audio; +use commands::media::{check_file_exists, copy_file, create_dir, extract_audio}; use commands::project::{ create_project, delete_project, get_project, list_projects, load_project_file, load_project_transcript, save_project_file, save_project_transcript, update_segment, @@ -75,6 +75,9 @@ pub fn run() { log_frontend, toggle_devtools, extract_audio, + check_file_exists, + copy_file, + create_dir, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8c5887a..3fb5768 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -32,6 +32,7 @@ let currentProjectPath = $state(null); let currentProjectName = $state(''); let audioFilePath = $state(''); + let audioWavPath = $state(''); async function checkSidecar() { try { @@ -147,10 +148,19 @@ }); if (!outputPath) return; + // Derive folder path and file name from the chosen .vtn path + const vtnFileName = outputPath.split(/[\\/]/).pop() || `${defaultName}.vtn`; + const projectName = vtnFileName.replace(/\.vtn$/i, ''); + const parentDir = outputPath.replace(/[\\/][^\\/]+$/, ''); + const folderPath = `${parentDir}/${projectName}`; + const vtnInsideFolder = `${folderPath}/${vtnFileName}`; + const wavInsideFolder = `${folderPath}/audio.wav`; + const projectData = { - version: 1, - name: outputPath.split(/[\\/]/).pop()?.replace('.vtn', '') || defaultName, - audio_file: audioFilePath, + version: 2, + name: projectName, + source_file: audioFilePath, + audio_wav: 'audio.wav', created_at: new Date().toISOString(), segments: $segments.map(seg => { const speaker = $speakers.find(s => s.id === seg.speaker_id); @@ -176,9 +186,19 @@ }; try { - await invoke('save_project_file', { path: outputPath, project: projectData }); - currentProjectPath = outputPath; - currentProjectName = projectData.name; + // Create the project folder + await invoke('create_dir', { path: folderPath }); + + // Copy the extracted WAV into the project folder + if (audioWavPath) { + await invoke('copy_file', { src: audioWavPath, dst: wavInsideFolder }); + } + + // Save the .vtn file inside the folder + await invoke('save_project_file', { path: vtnInsideFolder, project: projectData }); + + currentProjectPath = vtnInsideFolder; + currentProjectName = projectName; } catch (err) { console.error('Failed to save project:', err); alert(`Failed to save: ${err}`); @@ -194,9 +214,11 @@ try { const project = await invoke<{ - version: number; + version?: number; name: string; - audio_file: string; + audio_file?: string; + source_file?: string; + audio_wav?: string; segments: Array<{ text: string; start_ms: number; @@ -246,10 +268,134 @@ })); segments.set(newSegments); - // Load audio - audioFilePath = project.audio_file; - audioUrl = convertFileSrc(project.audio_file); - waveformPlayer?.loadAudio(audioUrl); + // Determine the directory the .vtn file is in + const vtnDir = (filePath as string).replace(/[\\/][^\\/]+$/, ''); + const version = project.version ?? 1; + + // Resolve audio for wavesurfer playback + if (version >= 2) { + // Version 2: audio_wav is relative to the .vtn directory, source_file is the original import path + audioFilePath = project.source_file || ''; + const wavRelative = project.audio_wav || 'audio.wav'; + const resolvedWav = `${vtnDir}/${wavRelative}`; + + const wavExists = await invoke('check_file_exists', { path: resolvedWav }); + if (wavExists) { + audioWavPath = resolvedWav; + audioUrl = convertFileSrc(resolvedWav); + waveformPlayer?.loadAudio(audioUrl); + } else { + // WAV missing — try re-extracting from the original source file + const sourceExists = audioFilePath ? await invoke('check_file_exists', { path: audioFilePath }) : false; + if (sourceExists) { + extractingAudio = true; + await tick(); + try { + const outputPath = `${vtnDir}/${wavRelative}`; + const wavPath = await invoke('extract_audio', { filePath: audioFilePath, outputPath }); + audioWavPath = wavPath; + audioUrl = convertFileSrc(wavPath); + waveformPlayer?.loadAudio(audioUrl); + } catch (err) { + console.error('Failed to re-extract audio:', err); + alert(`Failed to re-extract audio: ${err}`); + } finally { + extractingAudio = false; + } + } else { + // Both missing — ask user to locate the file + const shouldRelink = confirm( + 'The audio file for this project could not be found.\n\n' + + `Original source: ${audioFilePath || '(unknown)'}\n\n` + + 'Would you like to locate the file?' + ); + if (shouldRelink) { + const newPath = await open({ + multiple: false, + filters: [{ + name: 'Audio/Video', + extensions: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', + 'mp4', 'mkv', 'avi', 'mov', 'webm'], + }], + }); + if (newPath) { + audioFilePath = newPath; + extractingAudio = true; + await tick(); + try { + const outputPath = `${vtnDir}/${wavRelative}`; + const wavPath = await invoke('extract_audio', { filePath: newPath, outputPath }); + audioWavPath = wavPath; + audioUrl = convertFileSrc(wavPath); + waveformPlayer?.loadAudio(audioUrl); + } catch (err) { + console.error('Failed to extract audio from re-linked file:', err); + alert(`Failed to extract audio: ${err}`); + } finally { + extractingAudio = false; + } + } + } + } + } + } else { + // Version 1 (legacy): audio_file is the source path + const sourceFile = project.audio_file || ''; + audioFilePath = sourceFile; + + const sourceExists = sourceFile ? await invoke('check_file_exists', { path: sourceFile }) : false; + if (sourceExists) { + // Extract WAV next to the .vtn file for playback + extractingAudio = true; + await tick(); + try { + const outputPath = `${vtnDir}/audio.wav`; + const wavPath = await invoke('extract_audio', { filePath: sourceFile, outputPath }); + audioWavPath = wavPath; + audioUrl = convertFileSrc(wavPath); + waveformPlayer?.loadAudio(audioUrl); + } catch (err) { + console.error('Failed to extract audio:', err); + alert(`Failed to extract audio: ${err}`); + } finally { + extractingAudio = false; + } + } else { + // Source missing — ask user to locate the file + const shouldRelink = confirm( + 'The audio file for this project could not be found.\n\n' + + `Original path: ${sourceFile || '(unknown)'}\n\n` + + 'Would you like to locate the file?' + ); + if (shouldRelink) { + const newPath = await open({ + multiple: false, + filters: [{ + name: 'Audio/Video', + extensions: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', + 'mp4', 'mkv', 'avi', 'mov', 'webm'], + }], + }); + if (newPath) { + audioFilePath = newPath; + extractingAudio = true; + await tick(); + try { + const outputPath = `${vtnDir}/audio.wav`; + const wavPath = await invoke('extract_audio', { filePath: newPath, outputPath }); + audioWavPath = wavPath; + audioUrl = convertFileSrc(wavPath); + waveformPlayer?.loadAudio(audioUrl); + } catch (err) { + console.error('Failed to extract audio from re-linked file:', err); + alert(`Failed to extract audio: ${err}`); + } finally { + extractingAudio = false; + } + } + } + } + } currentProjectPath = filePath as string; currentProjectName = project.name; @@ -269,8 +415,6 @@ // Changes persist when user saves the project file. } - const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'webm']; - async function handleFileImport() { const filePath = await open({ multiple: false, @@ -282,38 +426,35 @@ }); if (!filePath) return; - // For video files, extract audio first using ffmpeg - const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; - let audioPath = filePath; - if (VIDEO_EXTENSIONS.includes(ext)) { - extractingAudio = true; - await tick(); - try { - audioPath = await invoke('extract_audio', { filePath }); - } catch (err) { - console.error('[voice-to-notes] Failed to extract audio:', err); - const msg = String(err); - if (msg.includes('ffmpeg not found')) { - alert( - 'FFmpeg is required to import video files.\n\n' + - 'Install FFmpeg:\n' + - ' Windows: winget install ffmpeg\n' + - ' macOS: brew install ffmpeg\n' + - ' Linux: sudo apt install ffmpeg\n\n' + - 'Then restart Voice to Notes and try again.' - ); - } else { - alert(`Failed to extract audio from video: ${msg}`); - } - return; - } finally { - extractingAudio = false; + // Always extract audio to WAV for wavesurfer playback + extractingAudio = true; + await tick(); + try { + const wavPath = await invoke('extract_audio', { filePath }); + audioWavPath = wavPath; + } catch (err) { + console.error('[voice-to-notes] Failed to extract audio:', err); + const msg = String(err); + if (msg.includes('ffmpeg not found')) { + alert( + 'FFmpeg is required to extract audio.\n\n' + + 'Install FFmpeg:\n' + + ' Windows: winget install ffmpeg\n' + + ' macOS: brew install ffmpeg\n' + + ' Linux: sudo apt install ffmpeg\n\n' + + 'Then restart Voice to Notes and try again.' + ); + } else { + alert(`Failed to extract audio: ${msg}`); } + return; + } finally { + extractingAudio = false; } - // Track the original file path (video or audio) for the sidecar + // Track the original file path for the sidecar (it does its own conversion) audioFilePath = filePath; - audioUrl = convertFileSrc(audioPath); + audioUrl = convertFileSrc(audioWavPath); waveformPlayer?.loadAudio(audioUrl); // Clear previous results @@ -630,7 +771,7 @@
-

Extracting audio from video...

+

Extracting audio...

{/if}