Compare commits
13 Commits
sidecar-v1
...
v0.2.42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfb1b276e | ||
|
|
908762073f | ||
|
|
2011015c9a | ||
|
|
fc5cfc4374 | ||
|
|
ac0fe3b4c7 | ||
|
|
e05f9afaff | ||
|
|
548d260061 | ||
|
|
168a43e0e1 | ||
|
|
543decd769 | ||
|
|
e05f88eecf | ||
|
|
fee1255cac | ||
|
|
2e9f2519b1 | ||
|
|
82bfcfb793 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voice-to-notes",
|
"name": "voice-to-notes",
|
||||||
"version": "0.2.35",
|
"version": "0.2.42",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voice-to-notes"
|
name = "voice-to-notes"
|
||||||
version = "0.2.35"
|
version = "0.2.42"
|
||||||
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"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -79,6 +99,16 @@ fn find_ffmpeg() -> Option<String> {
|
|||||||
};
|
};
|
||||||
let ffmpeg_path = sidecar_dir.join(ffmpeg_name);
|
let ffmpeg_path = sidecar_dir.join(ffmpeg_name);
|
||||||
if ffmpeg_path.exists() {
|
if ffmpeg_path.exists() {
|
||||||
|
// Ensure execute permission on Unix
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Ok(meta) = std::fs::metadata(&ffmpeg_path) {
|
||||||
|
let mut perms = meta.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
let _ = std::fs::set_permissions(&ffmpeg_path, perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Some(ffmpeg_path.to_string_lossy().to_string());
|
return Some(ffmpeg_path.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.42",
|
||||||
"identifier": "com.voicetonotes.app",
|
"identifier": "com.voicetonotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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);
|
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,20 +461,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 +480,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
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user