Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default defineConfig({
},
],
webServer: {
command: "bunx vite dev",
command: "bun run dev",
url: "http://localhost:1420",
reuseExistingServer: !process.env.CI,
timeout: 30000,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ cpal = "0.16.0"
anyhow = "1.0.95"
rubato = "0.16.2"
hound = "3.5.1"
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] }
log = "0.4.25"
env_filter = "0.1.0"
tokio = "1.43.0"
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ struct RecordingErrorEvent {
struct FinishGuard(AppHandle);
impl Drop for FinishGuard {
fn drop(&mut self) {
if let Some(tm) = self.0.try_state::<Arc<TranscriptionManager>>() {
tm.set_dictation_active(false);
}
if let Some(c) = self.0.try_state::<TranscriptionCoordinator>() {
c.notify_processing_finished();
}
Expand Down Expand Up @@ -426,9 +429,11 @@ impl ShortcutAction for TranscribeAction {
}

if recording_error.is_none() {
tm.set_dictation_active(true);
// Dynamically register the cancel shortcut in a separate task to avoid deadlock
shortcut::register_cancel_shortcut(app);
} else {
tm.set_dictation_active(false);
// Starting failed (for example due to blocked microphone permissions).
// Revert UI state so we don't stay stuck in the recording overlay.
utils::hide_recording_overlay(app);
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod audio;
pub mod history;
pub mod models;
pub mod studio;
pub mod transcription;

use crate::settings::{get_settings, write_settings, AppSettings, LogLevel};
Expand Down
101 changes: 101 additions & 0 deletions src-tauri/src/commands/studio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use crate::managers::studio::{StartStudioJobConfig, StudioHomeData, StudioJob, StudioManager};
use crate::media::decode;
use std::sync::Arc;
use tauri::State;

#[tauri::command]
#[specta::specta]
pub async fn prepare_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
file_path: String,
) -> Result<StudioJob, String> {
studio_manager
.prepare_job(&file_path)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn start_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
config: StartStudioJobConfig,
) -> Result<(), String> {
studio_manager
.start_job(&job_id, config)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn cancel_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
) -> Result<(), String> {
studio_manager
.cancel_job(&job_id)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn get_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
) -> Result<Option<StudioJob>, String> {
studio_manager
.get_job(&job_id)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn list_studio_jobs(
studio_manager: State<'_, Arc<StudioManager>>,
) -> Result<StudioHomeData, String> {
studio_manager
.list_jobs()
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn delete_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
) -> Result<(), String> {
studio_manager
.delete_job(&job_id)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn open_studio_output_folder(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
) -> Result<(), String> {
studio_manager
.open_output_folder(&job_id)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn retry_studio_job(
studio_manager: State<'_, Arc<StudioManager>>,
job_id: String,
) -> Result<(), String> {
studio_manager
.retry_job(&job_id)
.map_err(|error| error.to_string())
}

#[tauri::command]
#[specta::specta]
pub async fn get_studio_supported_extensions() -> Vec<String> {
decode::SUPPORTED_EXTENSIONS
.iter()
.map(|ext| (*ext).to_string())
.collect()
}
3 changes: 3 additions & 0 deletions src-tauri/src/exporters/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod srt;
pub mod txt;
pub mod vtt;
78 changes: 78 additions & 0 deletions src-tauri/src/exporters/srt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use anyhow::Result;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct SubtitleChunk {
pub start_ms: i64,
pub end_ms: i64,
pub text: String,
}

fn format_timestamp(ms: i64) -> String {
let total_ms = ms.max(0);
let hours = total_ms / 3_600_000;
let minutes = (total_ms % 3_600_000) / 60_000;
let seconds = (total_ms % 60_000) / 1000;
let millis = total_ms % 1000;
format!("{hours:02}:{minutes:02}:{seconds:02},{millis:03}")
}

pub fn write(path: &Path, chunks: &[SubtitleChunk]) -> Result<()> {
let body = chunks
.iter()
.filter(|chunk| !chunk.text.trim().is_empty())
.enumerate()
.map(|(index, chunk)| {
format!(
"{}\n{} --> {}\n{}\n",
index + 1,
format_timestamp(chunk.start_ms),
format_timestamp(chunk.end_ms.max(chunk.start_ms + 500)),
chunk.text.trim()
)
})
.collect::<Vec<_>>()
.join("\n");

fs::write(path, body)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::{write, SubtitleChunk};

#[test]
fn write_uses_sequential_numbers_after_skipping_empty_chunks() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let path = temp_dir.path().join("out.srt");

write(
&path,
&[
SubtitleChunk {
start_ms: 0,
end_ms: 1_000,
text: "First".to_string(),
},
SubtitleChunk {
start_ms: 1_000,
end_ms: 2_000,
text: " ".to_string(),
},
SubtitleChunk {
start_ms: 2_000,
end_ms: 3_000,
text: "Third".to_string(),
},
],
)
.expect("srt write should succeed");

let output = std::fs::read_to_string(path).expect("read srt");
assert!(output.contains("1\n00:00:00,000 --> 00:00:01,000\nFirst"));
assert!(output.contains("2\n00:00:02,000 --> 00:00:03,000\nThird"));
assert!(!output.contains("\n3\n"));
}
}
8 changes: 8 additions & 0 deletions src-tauri/src/exporters/txt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use anyhow::Result;
use std::fs;
use std::path::Path;

pub fn write(path: &Path, transcript_text: &str) -> Result<()> {
fs::write(path, transcript_text)?;
Ok(())
}
73 changes: 73 additions & 0 deletions src-tauri/src/exporters/vtt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use anyhow::Result;
use std::fs;
use std::path::Path;

use crate::exporters::srt::SubtitleChunk;

fn format_timestamp(ms: i64) -> String {
let total_ms = ms.max(0);
let hours = total_ms / 3_600_000;
let minutes = (total_ms % 3_600_000) / 60_000;
let seconds = (total_ms % 60_000) / 1000;
let millis = total_ms % 1000;
format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
}

pub fn write(path: &Path, chunks: &[SubtitleChunk]) -> Result<()> {
let body = chunks
.iter()
.filter(|chunk| !chunk.text.trim().is_empty())
.map(|chunk| {
format!(
"{} --> {}\n{}\n",
format_timestamp(chunk.start_ms),
format_timestamp(chunk.end_ms.max(chunk.start_ms + 500)),
chunk.text.trim()
)
})
.collect::<Vec<_>>()
.join("\n");

fs::write(path, format!("WEBVTT\n\n{body}"))?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::write;
use crate::exporters::srt::SubtitleChunk;

#[test]
fn write_includes_header_and_skips_empty_chunks() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let path = temp_dir.path().join("out.vtt");

write(
&path,
&[
SubtitleChunk {
start_ms: 0,
end_ms: 1_000,
text: "First".to_string(),
},
SubtitleChunk {
start_ms: 1_000,
end_ms: 2_000,
text: " ".to_string(),
},
SubtitleChunk {
start_ms: 3_723_456,
end_ms: 3_724_000,
text: "Third".to_string(),
},
],
)
.expect("vtt write should succeed");

let output = std::fs::read_to_string(path).expect("read vtt");
assert!(output.starts_with("WEBVTT\n\n"));
assert!(output.contains("00:00:00.000 --> 00:00:01.000\nFirst"));
assert!(output.contains("01:02:03.456 --> 01:02:04.000\nThird"));
assert!(!output.contains("\n \n"));
}
}
23 changes: 23 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ pub mod audio_toolkit;
pub mod cli;
mod clipboard;
mod commands;
mod exporters;
mod helpers;
mod input;
mod llm_client;
mod managers;
mod media;
mod overlay;
pub mod portable;
mod settings;
Expand All @@ -29,6 +31,7 @@ use env_filter::Builder as EnvFilterBuilder;
use managers::audio::AudioRecordingManager;
use managers::history::HistoryManager;
use managers::model::ModelManager;
use managers::studio::StudioManager;
use managers::transcription::TranscriptionManager;
#[cfg(unix)]
use signal_hook::consts::{SIGUSR1, SIGUSR2};
Expand Down Expand Up @@ -155,6 +158,8 @@ fn initialize_core_logic(app_handle: &AppHandle) {
);
let history_manager =
Arc::new(HistoryManager::new(app_handle).expect("Failed to initialize history manager"));
let studio_manager =
Arc::new(StudioManager::new(app_handle).expect("Failed to initialize Studio manager"));

// Apply accelerator preferences before any model loads
managers::transcription::apply_accelerator_settings(app_handle);
Expand All @@ -164,6 +169,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
app_handle.manage(model_manager.clone());
app_handle.manage(transcription_manager.clone());
app_handle.manage(history_manager.clone());
app_handle.manage(studio_manager.clone());

// Note: Shortcuts are NOT initialized here.
// The frontend is responsible for calling the `initialize_shortcuts` command
Expand Down Expand Up @@ -424,6 +430,15 @@ pub fn run(cli_args: CliArgs) {
commands::history::retry_history_entry_transcription,
commands::history::update_history_limit,
commands::history::update_recording_retention_period,
commands::studio::prepare_studio_job,
commands::studio::start_studio_job,
commands::studio::cancel_studio_job,
commands::studio::get_studio_job,
commands::studio::list_studio_jobs,
commands::studio::delete_studio_job,
commands::studio::open_studio_output_folder,
commands::studio::retry_studio_job,
commands::studio::get_studio_supported_extensions,
helpers::clamshell::is_laptop,
])
.events(collect_events![managers::history::HistoryUpdatePayload,]);
Expand Down Expand Up @@ -594,6 +609,14 @@ pub fn run(cli_args: CliArgs) {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
if matches!(
&event,
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
) {
if let Some(studio_manager) = app.try_state::<Arc<StudioManager>>() {
studio_manager.shutdown();
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = &event {
show_main_window(app);
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/managers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod audio;
pub mod history;
pub mod model;
pub mod studio;
pub mod transcription;
Loading
Loading