Project folders, always-extract audio, re-link support
Projects now save as folders containing .vtn + audio.wav:
My Transcript/
My Transcript.vtn
audio.wav
Audio handling:
- Always extract to 22kHz mono WAV on import (all formats, not just video)
- Prevents WebAudio crash from decoding large MP3/FLAC/OGG to PCM in memory
- WAV saved alongside .vtn on project save (moved from temp)
- Sidecar still uses original file (does its own conversion)
Project format v2:
- source_file: original import path (for re-extraction)
- audio_wav: relative path to extracted WAV (portable)
Re-link on open:
- If audio.wav exists → load directly
- If missing but source exists → re-extract automatically
- If both missing → dialog to locate file via file picker
- V1 project migration: extracts WAV on first open
New Rust commands: check_file_exists, copy_file, create_dir
extract_audio: now accepts optional output_path, uses 22kHz sample rate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, String> {
|
||||
pub fn extract_audio(file_path: String, output_path: Option<String>) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
"-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<String, String> {
|
||||
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<String> {
|
||||
// Check sidecar extract dir (ffmpeg is bundled with the sidecar)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
let currentProjectPath = $state<string | null>(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<boolean>('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<boolean>('check_file_exists', { path: audioFilePath }) : false;
|
||||
if (sourceExists) {
|
||||
extractingAudio = true;
|
||||
await tick();
|
||||
try {
|
||||
const outputPath = `${vtnDir}/${wavRelative}`;
|
||||
const wavPath = await invoke<string>('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<string>('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<boolean>('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<string>('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<string>('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<string>('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<string>('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 @@
|
||||
<div class="extraction-overlay">
|
||||
<div class="extraction-card">
|
||||
<div class="extraction-spinner"></div>
|
||||
<p>Extracting audio from video...</p>
|
||||
<p>Extracting audio...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user