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:
@@ -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