diff --git a/Cargo.lock b/Cargo.lock index 459f496..93dcf04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -588,6 +599,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "http" version = "1.3.1" @@ -1126,11 +1146,13 @@ name = "polyscribe" version = "0.1.0" dependencies = [ "anyhow", + "atty", "chrono", "clap", "clap_complete", "clap_mangen", "cliclack", + "indicatif", "libc", "reqwest", "serde", @@ -1945,6 +1967,28 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 6219f84..b3f65a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ sha2 = "0.10" whisper-rs = { git = "https://github.com/tazz4843/whisper-rs" } libc = "0.2" cliclack = "0.3" +indicatif = "0.17" +atty = "0.2" [dev-dependencies] tempfile = "3" diff --git a/src/backend.rs b/src/backend.rs index ee0db74..4f3ffae 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -35,12 +35,14 @@ pub trait TranscribeBackend { /// - speaker: label to attach to all produced segments. /// - lang_opt: optional language hint (e.g., "en"); None means auto/multilingual model default. /// - gpu_layers: optional GPU layer count if applicable (ignored by some backends). + /// - progress_cb: optional callback receiving percentage [0..=100] updates. fn transcribe( &self, audio_path: &Path, speaker: &str, lang_opt: Option<&str>, gpu_layers: Option, + progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result>; } @@ -148,8 +150,9 @@ impl TranscribeBackend for CpuBackend { speaker: &str, lang_opt: Option<&str>, _gpu_layers: Option, + progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result> { - transcribe_with_whisper_rs(audio_path, speaker, lang_opt) + transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_cb) } } @@ -163,9 +166,10 @@ impl TranscribeBackend for CudaBackend { speaker: &str, lang_opt: Option<&str>, _gpu_layers: Option, + progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result> { // whisper-rs uses enabled CUDA feature at build time; call same code path - transcribe_with_whisper_rs(audio_path, speaker, lang_opt) + transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_cb) } } @@ -179,8 +183,9 @@ impl TranscribeBackend for HipBackend { speaker: &str, lang_opt: Option<&str>, _gpu_layers: Option, + progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result> { - transcribe_with_whisper_rs(audio_path, speaker, lang_opt) + transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_cb) } } @@ -194,6 +199,7 @@ impl TranscribeBackend for VulkanBackend { _speaker: &str, _lang_opt: Option<&str>, _gpu_layers: Option, + _progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result> { Err(anyhow!( "Vulkan backend not yet wired to whisper.cpp FFI. Build with --features gpu-vulkan and ensure Vulkan SDK is installed. How to fix: install Vulkan loader (libvulkan), set VULKAN_SDK, and run cargo build --features gpu-vulkan." @@ -301,8 +307,13 @@ pub(crate) fn transcribe_with_whisper_rs( audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>, ) -> Result> { + if let Some(cb) = progress_cb { cb(0); } + let pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?; + if let Some(cb) = progress_cb { cb(5); } + let model = find_model_file()?; let is_en_only = model .file_name() @@ -341,6 +352,7 @@ pub(crate) fn transcribe_with_whisper_rs( .map_err(|e| anyhow!("Failed to create Whisper state: {:?}", e))?; Ok::<_, anyhow::Error>((ctx, state)) })?; + if let Some(cb) = progress_cb { cb(20); } let mut params = whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 }); @@ -352,13 +364,16 @@ pub(crate) fn transcribe_with_whisper_rs( if let Some(lang) = lang_opt { params.set_language(Some(lang)); } + if let Some(cb) = progress_cb { cb(30); } crate::with_suppressed_stderr(|| { + if let Some(cb) = progress_cb { cb(40); } state .full(params, &pcm) .map_err(|e| anyhow!("Whisper full() failed: {:?}", e)) })?; + if let Some(cb) = progress_cb { cb(90); } let num_segments = state .full_n_segments() .map_err(|e| anyhow!("Failed to get segments: {:?}", e))?; @@ -383,5 +398,6 @@ pub(crate) fn transcribe_with_whisper_rs( text: text.trim().to_string(), }); } + if let Some(cb) = progress_cb { cb(100); } Ok(items) } diff --git a/src/lib.rs b/src/lib.rs index 3a3025b..690ac01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -217,6 +217,95 @@ pub mod ui { io::stdin().read_line(&mut s)?; Ok(s) } + + // Progress manager built on indicatif MultiProgress for per-file and aggregate bars + /// TTY-aware progress UI built on `indicatif` for per-file and aggregate progress bars. + /// + /// This small helper encapsulates a `MultiProgress` with one aggregate (total) bar and + /// one per-file bar. It is intentionally minimal to keep integration lightweight. + pub mod progress { + use atty::Stream; + use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; + + /// Manages a set of per-file progress bars plus a top aggregate bar. + pub struct ProgressManager { + enabled: bool, + mp: Option, + per: Vec, + total: Option, + total_n: usize, + completed: usize, + done: Vec, + } + + impl ProgressManager { + /// Create a new manager with the given enabled flag. + pub fn new(enabled: bool) -> Self { + Self { enabled, mp: None, per: Vec::new(), total: None, total_n: 0, completed: 0, done: Vec::new() } + } + + /// Create a manager that enables bars when `n > 1`, stderr is a TTY, and not quiet. + pub fn default_for_files(n: usize) -> Self { + let enabled = n > 1 && atty::is(Stream::Stderr) && !crate::is_quiet(); + Self::new(enabled) + } + + /// Initialize bars for the given file labels. If disabled or single file, no-op. + pub fn init_files(&mut self, labels: &[String]) { + self.total_n = labels.len(); + if !self.enabled || self.total_n <= 1 { + // No bars in single-file mode or when disabled + self.enabled = false; + return; + } + let mp = MultiProgress::new(); + // Aggregate bar at the top + let total = mp.add(ProgressBar::new(labels.len() as u64)); + total.set_style(ProgressStyle::with_template("{prefix} [{bar:40.cyan/blue}] {pos}/{len}") + .unwrap() + .progress_chars("=>-")); + total.set_prefix("Total"); + self.total = Some(total); + // Per-file bars + for label in labels { + let pb = mp.add(ProgressBar::new(100)); + pb.set_style(ProgressStyle::with_template("{prefix} [{bar:40.green/black}] {pos}% {msg}") + .unwrap() + .progress_chars("=>-")); + pb.set_position(0); + pb.set_prefix(label.clone()); + self.per.push(pb); + } + self.mp = Some(mp); + } + + /// Returns true when bars are enabled (multi-file TTY mode). + pub fn is_enabled(&self) -> bool { self.enabled } + + /// Get a clone of the per-file progress bar at index, if enabled. + pub fn per_bar(&self, idx: usize) -> Option { + if !self.enabled { return None; } + self.per.get(idx).cloned() + } + + /// Get a clone of the aggregate (total) progress bar, if enabled. + pub fn total_bar(&self) -> Option { + if !self.enabled { return None; } + self.total.as_ref().cloned() + } + + /// Mark a file as finished (set to 100% and update total counter). + pub fn mark_file_done(&mut self, idx: usize) { + if !self.enabled { return; } + if let Some(pb) = self.per.get(idx) { + pb.set_position(100); + pb.finish_with_message("done"); + } + self.completed += 1; + if let Some(total) = &self.total { total.set_position(self.completed as u64); } + } + } + } } /// Logging macros and helpers diff --git a/src/main.rs b/src/main.rs index 38bebb2..883275e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -332,6 +332,11 @@ fn run() -> Result<()> { }) .collect(); + // Initialize multi-file progress bars (TTY-aware); suppressed for single-file/non-TTY/quiet + let mut pm = polyscribe::ui::progress::ProgressManager::default_for_files(speakers.len()); + // Use speaker names (derived from file names or prompted) as labels + pm.init_files(&speakers); + if args.merge_and_separate { polyscribe::dlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path); // Combined mode: write separate outputs per input and also a merged output set @@ -356,20 +361,41 @@ fn run() -> Result<()> { let mut entries: Vec = Vec::new(); if is_audio_file(path) { summary_audio_count += 1; - // Progress log to stderr (suppressed by -q); avoid partial lines - polyscribe::ilog!("Processing file: {} ...", path.display()); + // Progress log only when multi-bars are not enabled + if !pm.is_enabled() { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + // Prepare per-file progress callback if multi-bars enabled + let mut cb_holder: Option> = None; + if let Some(pb) = pm.per_bar(idx) { + let pb = pb.clone(); + cb_holder = Some(Box::new(move |p: i32| { + let p = p.clamp(0, 100) as u64; + pb.set_position(p); + })); + } let res = with_quiet_stdio_if_needed(args.quiet, || { + let cb_ref = cb_holder.as_ref().map(|b| &**b as &(dyn Fn(i32) + Send + Sync)); sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers, cb_ref) }); match res { Ok(items) => { - polyscribe::ilog!("done"); + if pm.is_enabled() { + pm.mark_file_done(idx); + } else { + polyscribe::ilog!("done"); + } entries.extend(items.into_iter()); } Err(e) => { - if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() { - polyscribe::elog!("{:#}", e); + if let Some(pb) = pm.per_bar(idx) { + pb.finish_with_message("error"); + } + if !pm.is_enabled() { + if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() { + polyscribe::elog!("{:#}", e); + } } return Err(e); } @@ -504,23 +530,44 @@ fn run() -> Result<()> { let mut buf = String::new(); if is_audio_file(path) { summary_audio_count += 1; - // Progress log to stderr (suppressed by -q) - polyscribe::ilog!("Processing file: {} ...", path.display()); + // Progress log only when multi-bars are not enabled + if !pm.is_enabled() { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + // Prepare per-file progress callback if multi-bars enabled + let mut cb_holder: Option> = None; + if let Some(pb) = pm.per_bar(idx) { + let pb = pb.clone(); + cb_holder = Some(Box::new(move |p: i32| { + let p = p.clamp(0, 100) as u64; + pb.set_position(p); + })); + } let res = with_quiet_stdio_if_needed(args.quiet, || { + let cb_ref = cb_holder.as_ref().map(|b| &**b as &(dyn Fn(i32) + Send + Sync)); sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers, cb_ref) }); match res { Ok(items) => { - polyscribe::ilog!("done"); + if pm.is_enabled() { + pm.mark_file_done(idx); + } else { + polyscribe::ilog!("done"); + } for e in items { entries.push(e); } continue; } Err(e) => { - if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) { - polyscribe::elog!("{:#}", e); + if let Some(pb) = pm.per_bar(idx) { + pb.finish_with_message("error"); + } + if !pm.is_enabled() { + if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) { + polyscribe::elog!("{:#}", e); + } } return Err(e); } @@ -645,20 +692,41 @@ fn run() -> Result<()> { let mut entries: Vec = Vec::new(); if is_audio_file(path) { summary_audio_count += 1; - // Progress log to stderr (suppressed by -q) - polyscribe::ilog!("Processing file: {} ...", path.display()); + // Progress log only when multi-bars are not enabled + if !pm.is_enabled() { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + // Prepare per-file progress callback if multi-bars enabled + let mut cb_holder: Option> = None; + if let Some(pb) = pm.per_bar(idx) { + let pb = pb.clone(); + cb_holder = Some(Box::new(move |p: i32| { + let p = p.clamp(0, 100) as u64; + pb.set_position(p); + })); + } let res = with_quiet_stdio_if_needed(args.quiet, || { + let cb_ref = cb_holder.as_ref().map(|b| &**b as &(dyn Fn(i32) + Send + Sync)); sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers, cb_ref) }); match res { Ok(items) => { - polyscribe::ilog!("done"); + if pm.is_enabled() { + pm.mark_file_done(idx); + } else { + polyscribe::ilog!("done"); + } entries.extend(items); } Err(e) => { - if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() { - polyscribe::elog!("{:#}", e); + if let Some(pb) = pm.per_bar(idx) { + pb.finish_with_message("error"); + } + if !pm.is_enabled() { + if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() { + polyscribe::elog!("{:#}", e); + } } return Err(e); } diff --git a/src/models.rs b/src/models.rs index 8d44605..54fbbb6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -419,7 +419,7 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result s, Err(_) => String::new(), };