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.
|
/// Extract audio from a video file to a WAV file using ffmpeg.
|
||||||
/// Returns the path to the extracted audio file.
|
/// Returns the path to the extracted audio file.
|
||||||
#[tauri::command]
|
#[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);
|
let input = PathBuf::from(&file_path);
|
||||||
if !input.exists() {
|
if !input.exists() {
|
||||||
return Err(format!("File not found: {}", file_path));
|
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 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!(
|
eprintln!(
|
||||||
"[media] Extracting audio: {} -> {}",
|
"[media] Extracting audio: {} -> {}",
|
||||||
@@ -35,7 +38,7 @@ pub fn extract_audio(file_path: String) -> Result<String, String> {
|
|||||||
"-acodec",
|
"-acodec",
|
||||||
"pcm_s16le", // WAV PCM 16-bit
|
"pcm_s16le", // WAV PCM 16-bit
|
||||||
"-ar",
|
"-ar",
|
||||||
"16000", // 16kHz (optimal for whisper)
|
"22050", // 22kHz mono for better playback quality
|
||||||
"-ac",
|
"-ac",
|
||||||
"1", // Mono
|
"1", // Mono
|
||||||
])
|
])
|
||||||
@@ -63,6 +66,23 @@ pub fn extract_audio(file_path: String) -> Result<String, String> {
|
|||||||
Ok(output.to_string_lossy().to_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.
|
/// Find ffmpeg binary — check sidecar directory first, then system PATH.
|
||||||
fn find_ffmpeg() -> Option<String> {
|
fn find_ffmpeg() -> Option<String> {
|
||||||
// Check sidecar extract dir (ffmpeg is bundled with the sidecar)
|
// 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::ai::{ai_chat, ai_configure, ai_list_providers};
|
||||||
use commands::export::export_transcript;
|
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::{
|
use commands::project::{
|
||||||
create_project, delete_project, get_project, list_projects, load_project_file,
|
create_project, delete_project, get_project, list_projects, load_project_file,
|
||||||
load_project_transcript, save_project_file, save_project_transcript, update_segment,
|
load_project_transcript, save_project_file, save_project_transcript, update_segment,
|
||||||
@@ -75,6 +75,9 @@ pub fn run() {
|
|||||||
log_frontend,
|
log_frontend,
|
||||||
toggle_devtools,
|
toggle_devtools,
|
||||||
extract_audio,
|
extract_audio,
|
||||||
|
check_file_exists,
|
||||||
|
copy_file,
|
||||||
|
create_dir,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
let currentProjectPath = $state<string | null>(null);
|
let currentProjectPath = $state<string | null>(null);
|
||||||
let currentProjectName = $state('');
|
let currentProjectName = $state('');
|
||||||
let audioFilePath = $state('');
|
let audioFilePath = $state('');
|
||||||
|
let audioWavPath = $state('');
|
||||||
|
|
||||||
async function checkSidecar() {
|
async function checkSidecar() {
|
||||||
try {
|
try {
|
||||||
@@ -147,10 +148,19 @@
|
|||||||
});
|
});
|
||||||
if (!outputPath) return;
|
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 = {
|
const projectData = {
|
||||||
version: 1,
|
version: 2,
|
||||||
name: outputPath.split(/[\\/]/).pop()?.replace('.vtn', '') || defaultName,
|
name: projectName,
|
||||||
audio_file: audioFilePath,
|
source_file: audioFilePath,
|
||||||
|
audio_wav: 'audio.wav',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
segments: $segments.map(seg => {
|
segments: $segments.map(seg => {
|
||||||
const speaker = $speakers.find(s => s.id === seg.speaker_id);
|
const speaker = $speakers.find(s => s.id === seg.speaker_id);
|
||||||
@@ -176,9 +186,19 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('save_project_file', { path: outputPath, project: projectData });
|
// Create the project folder
|
||||||
currentProjectPath = outputPath;
|
await invoke('create_dir', { path: folderPath });
|
||||||
currentProjectName = projectData.name;
|
|
||||||
|
// 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) {
|
} catch (err) {
|
||||||
console.error('Failed to save project:', err);
|
console.error('Failed to save project:', err);
|
||||||
alert(`Failed to save: ${err}`);
|
alert(`Failed to save: ${err}`);
|
||||||
@@ -194,9 +214,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await invoke<{
|
const project = await invoke<{
|
||||||
version: number;
|
version?: number;
|
||||||
name: string;
|
name: string;
|
||||||
audio_file: string;
|
audio_file?: string;
|
||||||
|
source_file?: string;
|
||||||
|
audio_wav?: string;
|
||||||
segments: Array<{
|
segments: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
start_ms: number;
|
start_ms: number;
|
||||||
@@ -246,10 +268,134 @@
|
|||||||
}));
|
}));
|
||||||
segments.set(newSegments);
|
segments.set(newSegments);
|
||||||
|
|
||||||
// Load audio
|
// Determine the directory the .vtn file is in
|
||||||
audioFilePath = project.audio_file;
|
const vtnDir = (filePath as string).replace(/[\\/][^\\/]+$/, '');
|
||||||
audioUrl = convertFileSrc(project.audio_file);
|
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);
|
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;
|
currentProjectPath = filePath as string;
|
||||||
currentProjectName = project.name;
|
currentProjectName = project.name;
|
||||||
@@ -269,8 +415,6 @@
|
|||||||
// Changes persist when user saves the project file.
|
// Changes persist when user saves the project file.
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'webm'];
|
|
||||||
|
|
||||||
async function handleFileImport() {
|
async function handleFileImport() {
|
||||||
const filePath = await open({
|
const filePath = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -282,20 +426,18 @@
|
|||||||
});
|
});
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
|
|
||||||
// For video files, extract audio first using ffmpeg
|
// Always extract audio to WAV for wavesurfer playback
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
let audioPath = filePath;
|
|
||||||
if (VIDEO_EXTENSIONS.includes(ext)) {
|
|
||||||
extractingAudio = true;
|
extractingAudio = true;
|
||||||
await tick();
|
await tick();
|
||||||
try {
|
try {
|
||||||
audioPath = await invoke<string>('extract_audio', { filePath });
|
const wavPath = await invoke<string>('extract_audio', { filePath });
|
||||||
|
audioWavPath = wavPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[voice-to-notes] Failed to extract audio:', err);
|
console.error('[voice-to-notes] Failed to extract audio:', err);
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
if (msg.includes('ffmpeg not found')) {
|
if (msg.includes('ffmpeg not found')) {
|
||||||
alert(
|
alert(
|
||||||
'FFmpeg is required to import video files.\n\n' +
|
'FFmpeg is required to extract audio.\n\n' +
|
||||||
'Install FFmpeg:\n' +
|
'Install FFmpeg:\n' +
|
||||||
' Windows: winget install ffmpeg\n' +
|
' Windows: winget install ffmpeg\n' +
|
||||||
' macOS: brew install ffmpeg\n' +
|
' macOS: brew install ffmpeg\n' +
|
||||||
@@ -303,17 +445,16 @@
|
|||||||
'Then restart Voice to Notes and try again.'
|
'Then restart Voice to Notes and try again.'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
alert(`Failed to extract audio from video: ${msg}`);
|
alert(`Failed to extract audio: ${msg}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
extractingAudio = false;
|
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;
|
audioFilePath = filePath;
|
||||||
audioUrl = convertFileSrc(audioPath);
|
audioUrl = convertFileSrc(audioWavPath);
|
||||||
waveformPlayer?.loadAudio(audioUrl);
|
waveformPlayer?.loadAudio(audioUrl);
|
||||||
|
|
||||||
// Clear previous results
|
// Clear previous results
|
||||||
@@ -630,7 +771,7 @@
|
|||||||
<div class="extraction-overlay">
|
<div class="extraction-overlay">
|
||||||
<div class="extraction-card">
|
<div class="extraction-card">
|
||||||
<div class="extraction-spinner"></div>
|
<div class="extraction-spinner"></div>
|
||||||
<p>Extracting audio from video...</p>
|
<p>Extracting audio...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user