19 Commits

Author SHA1 Message Date
Gitea Actions
e0e1638327 chore: bump version to 0.2.45 [skip ci] 2026-03-23 20:53:55 +00:00
Claude
c4fffad027 Fix permissions on demand instead of every launch
Some checks failed
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m17s
Release / Build App (Windows) (push) Failing after 1m56s
Release / Build App (Linux) (push) Successful in 3m39s
Instead of chmod on every app start, catch EACCES (error 13) when
spawning sidecar or ffmpeg, fix permissions, then retry once:
- sidecar spawn: catches permission denied, runs set_executable_permissions
  on the sidecar dir, retries spawn
- ffmpeg: catches permission denied, chmod +x ffmpeg and ffprobe, retries

Zero overhead on normal launches. Only fixes permissions when actually needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:53:47 -07:00
Gitea Actions
618edf65ab chore: bump version to 0.2.44 [skip ci] 2026-03-23 20:45:32 +00:00
Claude
c5b8eb06c6 Fix permissions on already-extracted sidecar dirs
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m20s
Release / Build App (Windows) (push) Successful in 2m59s
Release / Build App (Linux) (push) Successful in 3m35s
The chmod fix only ran after fresh extraction, but existing sidecar
dirs extracted by older versions still lacked execute permissions.
Now set_executable_permissions() runs on EVERY app launch (both the
early-return path for existing dirs and after fresh extraction).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:45:26 -07:00
Gitea Actions
4f44bdd037 chore: bump version to 0.2.43 [skip ci] 2026-03-23 20:30:33 +00:00
Claude
32bfbd3791 Set execute permissions on ALL files in sidecar dir on Unix
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m43s
Release / Build App (Windows) (push) Successful in 3m20s
Release / Build App (Linux) (push) Successful in 3m36s
Previously only the main sidecar binary got chmod 755. Now all files
in the extraction directory get execute permissions — covers ffmpeg,
ffprobe, and any other bundled binaries. Applied in three places:
- sidecar/mod.rs: after local extraction
- commands/sidecar.rs: after download extraction
- commands/media.rs: removed single-file fix (now handled globally)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:30:26 -07:00
Gitea Actions
2bfb1b276e chore: bump version to 0.2.42 [skip ci] 2026-03-23 20:18:57 +00:00
Claude
908762073f Fix ffmpeg permission denied on Linux
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m31s
Release / Build App (Windows) (push) Successful in 3m25s
Release / Build App (Linux) (push) Successful in 3m28s
The bundled ffmpeg in the sidecar extract dir lacked execute permissions.
Now sets chmod 755 on Unix when find_ffmpeg locates the bundled binary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:18:51 -07:00
Gitea Actions
2011015c9a chore: bump version to 0.2.41 [skip ci] 2026-03-23 17:25:07 +00:00
Claude
fc5cfc4374 Save As: use save dialog so user can type a new project name
All checks were successful
Release / Bump version and tag (push) Successful in 4s
Release / Build App (macOS) (push) Successful in 1m20s
Release / Build App (Windows) (push) Successful in 3m5s
Release / Build App (Linux) (push) Successful in 3m44s
Changed from folder picker (can only select existing folders) to save
dialog where the user can type a new name. The typed name becomes the
project folder, created automatically if it doesn't exist. Any file
extension the user types is stripped (e.g. "My Project.vtn" becomes
the folder "My Project/").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:25:00 -07:00
Gitea Actions
ac0fe3b4c7 chore: bump version to 0.2.40 [skip ci] 2026-03-23 16:56:19 +00:00
Claude
e05f9afaff Add Save As, auto-migrate v1 projects to folder structure
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m17s
Release / Build App (Windows) (push) Successful in 3m5s
Release / Build App (Linux) (push) Successful in 3m22s
Save behavior:
- Save on v2 project: saves in place (no dialog)
- Save on v1 project: auto-migrates to folder structure next to the
  original .vtn (creates ProjectName/ folder with .vtn + audio.wav)
- Save on unsaved project: opens folder picker (Save As)
- Save As: always opens folder picker for a new location

Added projectIsV2 state to track project format version.
Split "Save Project" button into "Save" + "Save As".
Extracted saveToFolder() helper for shared save logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:56:13 -07:00
Gitea Actions
548d260061 chore: bump version to 0.2.39 [skip ci] 2026-03-23 16:51:24 +00:00
Claude
168a43e0e1 Save project: pick folder instead of file
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m16s
Release / Build App (Windows) (push) Successful in 3m7s
Release / Build App (Linux) (push) Successful in 3m22s
Changed save dialog from file picker (.vtn) to folder picker. The
project name is derived from the folder name. Files are created
inside the chosen folder:
  Folder/
    Folder.vtn
    audio.wav

Also: save-in-place for already-saved projects (Ctrl+S just saves,
no dialog). Extracted buildProjectData() helper for reuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:51:14 -07:00
Gitea Actions
543decd769 chore: bump version to 0.2.38 [skip ci] 2026-03-23 16:48:36 +00:00
Claude
e05f88eecf Make ProjectFile struct support both v1 and v2 formats
Some checks failed
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m20s
Release / Build App (Linux) (push) Has been cancelled
Release / Build App (Windows) (push) Has been cancelled
audio_file, source_file, audio_wav are all optional with serde defaults.
v1 projects have audio_file, v2 projects have source_file + audio_wav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:48:29 -07:00
Gitea Actions
fee1255cac chore: bump version to 0.2.37 [skip ci] 2026-03-23 15:47:16 +00:00
Claude
2e9f2519b1 Project folders, always-extract audio, re-link support
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m17s
Release / Build App (Windows) (push) Successful in 3m6s
Release / Build App (Linux) (push) Successful in 3m25s
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>
2026-03-23 08:47:08 -07:00
Gitea Actions
82bfcfb793 chore: bump version to 0.2.36 [skip ci] 2026-03-23 14:58:10 +00:00
9 changed files with 364 additions and 86 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "voice-to-notes", "name": "voice-to-notes",
"version": "0.2.35", "version": "0.2.45",
"description": "Desktop app for transcribing audio/video with speaker identification", "description": "Desktop app for transcribing audio/video with speaker identification",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "voice-to-notes" name = "voice-to-notes"
version = "0.2.35" version = "0.2.45"
description = "Voice to Notes — desktop transcription with speaker identification" description = "Voice to Notes — desktop transcription with speaker identification"
authors = ["Voice to Notes Contributors"] authors = ["Voice to Notes Contributors"]
license = "MIT" license = "MIT"

View File

@@ -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
]) ])
@@ -47,9 +50,37 @@ pub fn extract_audio(file_path: String) -> Result<String, String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); cmd.creation_flags(0x08000000);
let status = cmd let status = match cmd.status() {
.status() Ok(s) => s,
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?; Err(e) if e.raw_os_error() == Some(13) => {
// Permission denied — fix permissions and retry
eprintln!("[media] Permission denied on ffmpeg, fixing permissions and retrying...");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&ffmpeg) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&ffmpeg, perms);
}
// Also fix ffprobe if it exists
let ffprobe = ffmpeg.replace("ffmpeg", "ffprobe");
if let Ok(meta) = std::fs::metadata(&ffprobe) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&ffprobe, perms);
}
}
Command::new(&ffmpeg)
.args(["-y", "-i", &file_path, "-vn", "-acodec", "pcm_s16le", "-ar", "22050", "-ac", "1"])
.arg(output.to_str().unwrap())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.map_err(|e| format!("Failed to run ffmpeg after chmod: {e}"))?
}
Err(e) => return Err(format!("Failed to run ffmpeg: {e}")),
};
if !status.success() { if !status.success() {
return Err(format!("ffmpeg exited with status {status}")); return Err(format!("ffmpeg exited with status {status}"));
@@ -63,6 +94,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)

View File

@@ -12,7 +12,12 @@ use crate::state::AppState;
pub struct ProjectFile { pub struct ProjectFile {
pub version: u32, pub version: u32,
pub name: String, pub name: String,
pub audio_file: String, #[serde(default)]
pub audio_file: Option<String>,
#[serde(default)]
pub source_file: Option<String>,
#[serde(default)]
pub audio_wav: Option<String>,
pub created_at: String, pub created_at: String,
pub segments: Vec<ProjectFileSegment>, pub segments: Vec<ProjectFileSegment>,
pub speakers: Vec<ProjectFileSpeaker>, pub speakers: Vec<ProjectFileSpeaker>,

View File

@@ -197,15 +197,21 @@ pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), Str
let extract_dir = data_dir.join(format!("sidecar-{}", sidecar_version)); let extract_dir = data_dir.join(format!("sidecar-{}", sidecar_version));
SidecarManager::extract_zip(&zip_path, &extract_dir)?; SidecarManager::extract_zip(&zip_path, &extract_dir)?;
// Make the binary executable on Unix // Make all binaries executable on Unix (sidecar, ffmpeg, ffprobe, etc.)
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let binary_path = extract_dir.join("voice-to-notes-sidecar"); if let Ok(entries) = std::fs::read_dir(&extract_dir) {
if let Ok(meta) = std::fs::metadata(&binary_path) { for entry in entries.flatten() {
let mut perms = meta.permissions(); let path = entry.path();
perms.set_mode(0o755); if path.is_file() {
let _ = std::fs::set_permissions(&binary_path, perms); if let Ok(meta) = std::fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&path, perms);
}
}
}
} }
} }

View File

@@ -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");

View File

@@ -113,16 +113,8 @@ impl SidecarManager {
)); ));
} }
// Make executable on Unix
#[cfg(unix)] #[cfg(unix)]
{ Self::set_executable_permissions(&extract_dir);
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&binary_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&binary_path, perms);
}
}
Self::cleanup_old_sidecars(data_dir, &current_version); Self::cleanup_old_sidecars(data_dir, &current_version);
Ok(binary_path) Ok(binary_path)
@@ -207,6 +199,24 @@ impl SidecarManager {
/// Remove old sidecar-* directories that don't match the current version. /// Remove old sidecar-* directories that don't match the current version.
/// Called after the current version's sidecar is confirmed ready. /// Called after the current version's sidecar is confirmed ready.
/// Set execute permissions on all files in a directory (Unix only).
#[cfg(unix)]
fn set_executable_permissions(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(meta) = std::fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&path, perms);
}
}
}
}
}
pub(crate) fn cleanup_old_sidecars(data_dir: &Path, current_version: &str) { pub(crate) fn cleanup_old_sidecars(data_dir: &Path, current_version: &str) {
let current_dir_name = format!("sidecar-{}", current_version); let current_dir_name = format!("sidecar-{}", current_version);
@@ -321,12 +331,39 @@ impl SidecarManager {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); cmd.creation_flags(0x08000000);
let child = cmd match cmd.spawn() {
.spawn() Ok(child) => {
.map_err(|e| format!("Failed to start sidecar binary: {e}"))?; self.attach(child)?;
self.wait_for_ready()
self.attach(child)?; }
self.wait_for_ready() Err(e) if e.raw_os_error() == Some(13) => {
// Permission denied — fix permissions and retry once
eprintln!("[sidecar-rs] Permission denied, fixing permissions and retrying...");
if let Some(dir) = path.parent() {
Self::set_executable_permissions(dir);
}
let mut retry_cmd = Command::new(path);
retry_cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(if let Some(data_dir) = DATA_DIR.get() {
let log_path = data_dir.join("sidecar.log");
std::fs::File::create(&log_path)
.map(Stdio::from)
.unwrap_or_else(|_| Stdio::inherit())
} else {
Stdio::inherit()
});
#[cfg(target_os = "windows")]
retry_cmd.creation_flags(0x08000000);
let child = retry_cmd
.spawn()
.map_err(|e| format!("Failed to start sidecar binary after chmod: {e}"))?;
self.attach(child)?;
self.wait_for_ready()
}
Err(e) => Err(format!("Failed to start sidecar binary: {e}")),
}
} }
/// Spawn the Python sidecar in dev mode (system Python). /// Spawn the Python sidecar in dev mode (system Python).

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voice to Notes", "productName": "Voice to Notes",
"version": "0.2.35", "version": "0.2.45",
"identifier": "com.voicetonotes.app", "identifier": "com.voicetonotes.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -31,7 +31,9 @@
// Project management state // Project management state
let currentProjectPath = $state<string | null>(null); let currentProjectPath = $state<string | null>(null);
let currentProjectName = $state(''); let currentProjectName = $state('');
let projectIsV2 = $state(false);
let audioFilePath = $state(''); let audioFilePath = $state('');
let audioWavPath = $state('');
async function checkSidecar() { async function checkSidecar() {
try { try {
@@ -139,18 +141,12 @@
// Speaker color palette for auto-assignment // Speaker color palette for auto-assignment
const speakerColors = ['#e94560', '#4ecdc4', '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea', '#ffd93d', '#6bcb77']; const speakerColors = ['#e94560', '#4ecdc4', '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea', '#ffd93d', '#6bcb77'];
async function saveProject() { function buildProjectData(projectName: string) {
const defaultName = currentProjectName || 'Untitled'; return {
const outputPath = await save({ version: 2,
defaultPath: `${defaultName}.vtn`, name: projectName,
filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }], source_file: audioFilePath,
}); audio_wav: 'audio.wav',
if (!outputPath) return;
const projectData = {
version: 1,
name: outputPath.split(/[\\/]/).pop()?.replace('.vtn', '') || defaultName,
audio_file: audioFilePath,
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);
@@ -174,17 +170,75 @@
color: s.color || '#e94560', color: s.color || '#e94560',
})), })),
}; };
}
/** Save to a specific folder — creates .vtn + audio.wav inside it. */
async function saveToFolder(folderPath: string): Promise<boolean> {
const projectName = folderPath.split(/[\\/]/).pop() || currentProjectName || 'Untitled';
const vtnPath = `${folderPath}/${projectName}.vtn`;
const wavPath = `${folderPath}/audio.wav`;
const projectData = buildProjectData(projectName);
try { try {
await invoke('save_project_file', { path: outputPath, project: projectData }); await invoke('create_dir', { path: folderPath });
currentProjectPath = outputPath; if (audioWavPath && audioWavPath !== wavPath) {
currentProjectName = projectData.name; await invoke('copy_file', { src: audioWavPath, dst: wavPath });
audioWavPath = wavPath;
}
await invoke('save_project_file', { path: vtnPath, project: projectData });
currentProjectPath = vtnPath;
currentProjectName = projectName;
projectIsV2 = true;
return true;
} 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}`);
return false;
} }
} }
async function saveProject() {
// Already saved as v2 folder — save in place
if (currentProjectPath && projectIsV2) {
const folderPath = currentProjectPath.replace(/[\\/][^\\/]+$/, '');
await saveToFolder(folderPath);
return;
}
// V1 project opened — migrate to folder structure
if (currentProjectPath && !projectIsV2) {
const oldVtnDir = currentProjectPath.replace(/[\\/][^\\/]+$/, '');
const projectName = currentProjectPath.split(/[\\/]/).pop()?.replace(/\.vtn$/i, '') || 'Untitled';
const folderPath = `${oldVtnDir}/${projectName}`;
const success = await saveToFolder(folderPath);
if (success) {
// Optionally remove the old .vtn file
try {
// Leave old file — user can delete manually
} catch {}
}
return;
}
// Never saved — pick a folder
await saveProjectAs();
}
async function saveProjectAs() {
// Use save dialog so the user can type a new project name.
// The chosen path is treated as the project folder (created if needed).
const defaultName = currentProjectName || 'Untitled';
const chosenPath = await save({
defaultPath: defaultName,
title: 'Save Project — enter a project name',
});
if (!chosenPath) return;
// Strip any file extension the user may have typed (e.g. ".vtn")
const folderPath = chosenPath.replace(/\.[^.\\/]+$/, '');
await saveToFolder(folderPath);
}
async function openProject() { async function openProject() {
const filePath = await open({ const filePath = await open({
filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }], filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }],
@@ -194,9 +248,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 +302,135 @@
})); }));
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;
waveformPlayer?.loadAudio(audioUrl); projectIsV2 = version >= 2;
// 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; currentProjectPath = filePath as string;
currentProjectName = project.name; currentProjectName = project.name;
@@ -269,8 +450,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,38 +461,35 @@
}); });
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() ?? ''; extractingAudio = true;
let audioPath = filePath; await tick();
if (VIDEO_EXTENSIONS.includes(ext)) { try {
extractingAudio = true; const wavPath = await invoke<string>('extract_audio', { filePath });
await tick(); audioWavPath = wavPath;
try { } catch (err) {
audioPath = await invoke<string>('extract_audio', { filePath }); console.error('[voice-to-notes] Failed to extract audio:', err);
} catch (err) { const msg = String(err);
console.error('[voice-to-notes] Failed to extract audio:', err); if (msg.includes('ffmpeg not found')) {
const msg = String(err); alert(
if (msg.includes('ffmpeg not found')) { 'FFmpeg is required to extract audio.\n\n' +
alert( 'Install FFmpeg:\n' +
'FFmpeg is required to import video files.\n\n' + ' Windows: winget install ffmpeg\n' +
'Install FFmpeg:\n' + ' macOS: brew install ffmpeg\n' +
' Windows: winget install ffmpeg\n' + ' Linux: sudo apt install ffmpeg\n\n' +
' macOS: brew install ffmpeg\n' + 'Then restart Voice to Notes and try again.'
' Linux: sudo apt install ffmpeg\n\n' + );
'Then restart Voice to Notes and try again.' } else {
); alert(`Failed to extract audio: ${msg}`);
} else {
alert(`Failed to extract audio from video: ${msg}`);
}
return;
} finally {
extractingAudio = false;
} }
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; audioFilePath = filePath;
audioUrl = convertFileSrc(audioPath); audioUrl = convertFileSrc(audioWavPath);
waveformPlayer?.loadAudio(audioUrl); waveformPlayer?.loadAudio(audioUrl);
// Clear previous results // Clear previous results
@@ -574,7 +750,10 @@
</button> </button>
{#if $segments.length > 0} {#if $segments.length > 0}
<button class="settings-btn" onclick={saveProject}> <button class="settings-btn" onclick={saveProject}>
Save Project Save
</button>
<button class="settings-btn" onclick={saveProjectAs}>
Save As
</button> </button>
{/if} {/if}
<button class="import-btn" onclick={handleFileImport} disabled={isTranscribing}> <button class="import-btn" onclick={handleFileImport} disabled={isTranscribing}>
@@ -630,7 +809,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}