diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e71e53b..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# PolyScribe Refactor toward Rust 2024 — Incremental Patches - -This changelog documents each incremental step applied to keep the build green while moving the codebase toward Rust 2024 idioms. - -## 1) Formatting only (rustfmt) -- Ran `cargo fmt` across the repository. -- No semantic changes. -- Build status: OK (`cargo build` succeeded). - -## 2) Lints — initial fixes (non-pedantic) -- Adjusted crate lint policy in `src/lib.rs`: - - Replaced `#![warn(clippy::pedantic, clippy::nursery, clippy::cargo)]` with `#![warn(clippy::all)]` to align with the plan (skip pedantic/nursery for now). - - Added comment/TODO to revisit stricter lints in a later pass. -- Fixed several clippy warnings that were causing `cargo clippy --all-targets` to error under tests: - - `src/backend.rs`: conditionally import `libloading::Library` only for non-test builds and mark `names` parameter as used in test cfg to avoid unused warnings; keep `check_lib()` side‑effect free during tests. - - `src/models.rs`: removed an unused `std::io::Write` import in test module. - - `src/main.rs` (unit tests): imported `polyscribe::format_srt_time` explicitly and removed a duplicate `use super::*;` to fix unresolved name and unused import warnings under clippy test builds. -- Build/Clippy status: - - `cargo build`: OK. - - `cargo clippy --all-targets`: OK (only warnings remain; no errors). - -## 3) Module hygiene -- Verified crate structure: - - Library crate (`src/lib.rs`) exposes a coherent API and re‑exports `backend` and `models` via `pub mod`. - - Binary (`src/main.rs`) consumes the library API through `polyscribe::...` paths. -- No structural changes required. Build status: OK. - -## 4) Edition -- The project already targets `edition = "2024"` in Cargo.toml. -- Verified that the project compiles under Rust 2024. No changes needed. -- TODO: If stricter lints or new features from 2024 edition introduce issues in future steps, document blockers here. - -## 5) Error handling -- The codebase already returns `anyhow::Result` in the binary and uses contextual errors widely. -- No `unwrap`/`expect` usages in production paths required attention in this pass. -- Build status: OK. - -## Next planned steps (not yet applied in this changelog) -- Gradually fix remaining clippy warnings (e.g., `uninlined_format_args`, small style nits) in small, compile‑green patches. -- Optionally re‑enable `clippy::pedantic`, `clippy::nursery`, and `clippy::cargo` once warnings are significantly reduced, then address non‑breaking warnings. diff --git a/Cargo.lock b/Cargo.lock index eb74f0e..8a68318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -300,6 +306,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -335,6 +354,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + [[package]] name = "digest" version = "0.10.7" @@ -362,6 +391,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -814,6 +849,19 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "io-uring" version = "0.7.9" @@ -961,6 +1009,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -980,6 +1040,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.7" @@ -1078,6 +1144,8 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "ctrlc", + "indicatif", "libc", "reqwest", "serde", @@ -1088,6 +1156,12 @@ dependencies = [ "whisper-rs", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "potential_utf" version = "0.1.2" @@ -1682,6 +1756,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + [[package]] name = "untrusted" version = "0.9.0" @@ -1828,6 +1908,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whisper-rs" version = "0.14.3" diff --git a/Cargo.toml b/Cargo.toml index 07a861f..c5af94f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ name = "polyscribe" version = "0.1.0" edition = "2024" license = "MIT" -license-file = "LICENSE" [features] # Default: CPU only; no GPU features enabled @@ -27,8 +26,10 @@ chrono = { version = "0.4", features = ["clock"] } reqwest = { version = "0.12", features = ["blocking", "json"] } sha2 = "0.10" # whisper-rs is always used (CPU-only by default); GPU features map onto it -whisper-rs = { git = "https://github.com/tazz4843/whisper-rs" } +whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", default-features = false } libc = "0.2" +indicatif = "0.17" +ctrlc = "3.4" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index c90384e..8bbe6e2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Most-used CLI flags - -v/--verbose (repeatable): Increase log verbosity. -vv shows very detailed logs. - -q/--quiet: Suppress non-error logs (stderr); does not silence stdout results. - --no-interaction: Never prompt; suitable for CI. +- --no-progress: Disable progress bars (also honors NO_PROGRESS=1). Progress bars render on stderr only and auto-disable when not a TTY. Minimal usage examples - Transcribe an audio file to JSON/SRT: diff --git a/src/backend.rs b/src/backend.rs index ee0db74..36486c0 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -3,10 +3,12 @@ //! Transcription backend selection and implementations (CPU/GPU) used by PolyScribe. use crate::OutputEntry; +use crate::progress::ProgressMessage; use crate::{decode_audio_to_pcm_f32_ffmpeg, find_model_file}; use anyhow::{Context, Result, anyhow}; use std::env; use std::path::Path; +use std::sync::mpsc::Sender; // Re-export a public enum for CLI parsing usage #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -40,6 +42,7 @@ pub trait TranscribeBackend { audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_tx: Option>, gpu_layers: Option, ) -> Result>; } @@ -147,9 +150,10 @@ impl TranscribeBackend for CpuBackend { audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_tx: Option>, _gpu_layers: Option, ) -> Result> { - transcribe_with_whisper_rs(audio_path, speaker, lang_opt) + transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_tx) } } @@ -162,10 +166,11 @@ impl TranscribeBackend for CudaBackend { audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_tx: Option>, _gpu_layers: Option, ) -> 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_tx) } } @@ -178,9 +183,10 @@ impl TranscribeBackend for HipBackend { audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_tx: Option>, _gpu_layers: Option, ) -> Result> { - transcribe_with_whisper_rs(audio_path, speaker, lang_opt) + transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_tx) } } @@ -193,6 +199,7 @@ impl TranscribeBackend for VulkanBackend { _audio_path: &Path, _speaker: &str, _lang_opt: Option<&str>, + _progress_tx: Option>, _gpu_layers: Option, ) -> Result> { Err(anyhow!( @@ -301,9 +308,25 @@ pub(crate) fn transcribe_with_whisper_rs( audio_path: &Path, speaker: &str, lang_opt: Option<&str>, + progress_tx: Option>, ) -> Result> { + // initial progress + if let Some(tx) = &progress_tx { + let _ = tx.send(ProgressMessage { + fraction: 0.0, + stage: Some("load_model".to_string()), + note: Some(format!("{}", audio_path.display())), + }); + } let pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?; let model = find_model_file()?; + if let Some(tx) = &progress_tx { + let _ = tx.send(ProgressMessage { + fraction: 0.05, + stage: Some("load_model".to_string()), + note: Some("model selected".to_string()), + }); + } let is_en_only = model .file_name() .and_then(|s| s.to_str()) @@ -341,6 +364,13 @@ 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(tx) = &progress_tx { + let _ = tx.send(ProgressMessage { + fraction: 0.15, + stage: Some("encode".to_string()), + note: Some("state ready".to_string()), + }); + } let mut params = whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 }); @@ -353,11 +383,25 @@ pub(crate) fn transcribe_with_whisper_rs( params.set_language(Some(lang)); } + if let Some(tx) = &progress_tx { + let _ = tx.send(ProgressMessage { + fraction: 0.20, + stage: Some("decode".to_string()), + note: Some("inference".to_string()), + }); + } crate::with_suppressed_stderr(|| { state .full(params, &pcm) .map_err(|e| anyhow!("Whisper full() failed: {:?}", e)) })?; + if let Some(tx) = &progress_tx { + let _ = tx.send(ProgressMessage { + fraction: 1.0, + stage: Some("done".to_string()), + note: Some("transcription finished".to_string()), + }); + } let num_segments = state .full_n_segments() diff --git a/src/lib.rs b/src/lib.rs index b82bcf8..f03c9b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -230,7 +230,8 @@ use anyhow::{Context, Result, anyhow}; use chrono::Local; use std::env; use std::fs::create_dir_all; -use std::io::{self, Write}; +use std::io; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; @@ -241,6 +242,8 @@ use libc::{O_WRONLY, close, dup, dup2, open}; pub mod backend; /// Re-export models module (model listing/downloading/updating). pub mod models; +/// Progress and progress bar abstraction (TTY-aware, stderr-only) +pub mod progress; /// Transcript entry for a single segment. #[derive(Debug, serde::Serialize, Clone)] @@ -396,6 +399,56 @@ pub fn normalize_lang_code(input: &str) -> Option { /// Locate a Whisper model file, prompting user to download/select when necessary. pub fn find_model_file() -> Result { + // Silent model resolution used during processing to avoid interfering with progress bars. + // Preflight prompting should be done by the caller before bars are created (use find_model_file_with_printer). + let models_dir_buf = models_dir_path(); + let models_dir = models_dir_buf.as_path(); + if !models_dir.exists() { + create_dir_all(models_dir).with_context(|| { + format!( + "Failed to create models directory: {}", + models_dir.display() + ) + })?; + } + // 1) Explicit environment override + if let Ok(env_model) = env::var("WHISPER_MODEL") { + let p = PathBuf::from(env_model); + if p.is_file() { + let _ = std::fs::write(models_dir.join(".last_model"), p.display().to_string()); + return Ok(p); + } + } + // 2) Previously selected model + let last_file = models_dir.join(".last_model"); + if let Ok(prev) = std::fs::read_to_string(&last_file) { + let prev = prev.trim(); + if !prev.is_empty() { + let p = PathBuf::from(prev); + if p.is_file() { + return Ok(p); + } + } + } + // 3) Best local model without prompting + if let Some(local) = crate::models::pick_best_local_model(models_dir) { + let _ = std::fs::write(models_dir.join(".last_model"), local.display().to_string()); + return Ok(local); + } + // 4) No model available; avoid interactive prompts here to prevent progress bar redraw issues. + // Callers should run find_model_file_with_printer(...) before starting progress bars to interactively select/download. + Err(anyhow!( + "No Whisper model available. Run with --download-models or ensure WHISPER_MODEL is set before processing." + )) +} + +/// Locate a Whisper model file, prompting user to download/select when necessary. +/// All prompts are printed using the provided printer closure (e.g., MultiProgress::println) +/// to avoid interfering with active progress bars. +pub fn find_model_file_with_printer(printer: F) -> Result +where + F: Fn(&str), +{ let models_dir_buf = models_dir_path(); let models_dir = models_dir_buf.as_path(); if !models_dir.exists() { @@ -462,8 +515,7 @@ pub fn find_model_file() -> Result { "No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models." )); } - eprint!("Would you like to download models now? [Y/n]: "); - io::stderr().flush().ok(); + printer("Would you like to download models now? [Y/n]:"); let mut input = String::new(); io::stdin().read_line(&mut input).ok(); let ans = input.trim().to_lowercase(); @@ -519,12 +571,19 @@ pub fn find_model_file() -> Result { } } - eprintln!("Multiple Whisper models found in {}:", models_dir.display()); + printer(&"Multiple Whisper models found:".to_string()); for (i, p) in candidates.iter().enumerate() { - eprintln!(" {}) {}", i + 1, p.display()); + let name = p + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| p.display().to_string()); + printer(&format!(" {}) {}", i + 1, name)); } - eprint!("Select model by number [1-{}]: ", candidates.len()); - io::stderr().flush().ok(); + // Print a blank line and the selection prompt using the provided printer to + // keep output synchronized with any active progress rendering. + printer(""); + printer(&format!("Select model by number [1-{}]:", candidates.len())); let mut input = String::new(); io::stdin() .read_line(&mut input) @@ -557,16 +616,16 @@ pub fn decode_audio_to_pcm_f32_ffmpeg(audio_path: &Path) -> Result> { { Ok(o) => o, Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - return Err(anyhow!( + return if e.kind() == std::io::ErrorKind::NotFound { + Err(anyhow!( "ffmpeg not found on PATH. Please install ffmpeg and ensure it is available." - )); + )) } else { - return Err(anyhow!( + Err(anyhow!( "Failed to execute ffmpeg for {}: {}", audio_path.display(), e - )); + )) } } }; diff --git a/src/main.rs b/src/main.rs index cd19753..9381033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,11 @@ use clap::{Parser, Subcommand}; use clap_complete::Shell; use serde::{Deserialize, Serialize}; +use std::sync::mpsc::channel; // whisper-rs is used from the library crate use polyscribe::backend::{BackendKind, select_backend}; +use polyscribe::progress::ProgressMessage; +use polyscribe::progress::ProgressFactory; #[derive(Subcommand, Debug, Clone)] enum AuxCommands { @@ -55,6 +58,10 @@ struct Args { #[arg(long = "no-interaction", global = true)] no_interaction: bool, + /// Disable progress bars (also respects NO_PROGRESS=1). Progress bars render on stderr only when attached to a TTY. + #[arg(long = "no-progress", global = true)] + no_progress: bool, + /// Optional auxiliary subcommands (completions, man) #[command(subcommand)] aux: Option, @@ -129,7 +136,7 @@ fn sanitize_speaker_name(raw: &str) -> String { raw.to_string() } -fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool) -> String { +fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool, pm: &polyscribe::progress::ProgressManager) -> String { if !enabled { return default_name.to_string(); } @@ -142,12 +149,19 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool) .and_then(|s| s.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| path.to_string_lossy().to_string()); - eprint!( - "Enter speaker name for {display_owned} [default: {default_name}]: " - ); - io::stderr().flush().ok(); + + // Synchronized prompt above any progress bars + pm.pause_for_prompt(); + pm.println_above_bars(&format!( + "Enter speaker name for {} [default: {}]:", + display_owned, default_name + )); + let mut buf = String::new(); - match io::stdin().read_line(&mut buf) { + let res = io::stdin().read_line(&mut buf); + pm.resume_after_prompt(); + + match res { Ok(_) => { let raw = buf.trim(); if raw.is_empty() { @@ -157,6 +171,7 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool) if sanitized.is_empty() { default_name.to_string() } else { + // Defer echoing of the chosen name; caller will print a permanent line later sanitized } } @@ -217,6 +232,7 @@ where } fn run() -> Result<()> { + use polyscribe::progress::ProgressFactory; // Parse CLI let args = Args::parse(); @@ -300,6 +316,16 @@ fn run() -> Result<()> { // Determine inputs and optional output path polyscribe::dlog!(1, "Parsed {} input(s)", args.inputs.len()); + + // Progress will be initialized after all prompts are completed + // Install Ctrl-C cleanup that removes .last_model and exits 130 on SIGINT + let last_for_ctrlc = last_model_path.clone(); + ctrlc::set_handler(move || { + let _ = std::fs::remove_file(&last_for_ctrlc); + std::process::exit(130); + }) + .expect("failed to set ctrlc handler"); + let mut inputs = args.inputs; let mut output_path = args.output; if output_path.is_none() && inputs.len() >= 2 { @@ -327,6 +353,59 @@ fn run() -> Result<()> { )); } + // Initialize progress manager BEFORE any interactive prompts so we can route + // prompt lines via the synchronized ProgressManager APIs + let pf = ProgressFactory::new(args.no_progress || args.quiet); + let mode = pf.decide_mode(inputs.len()); + let progress = pf.make_manager(mode); + progress.set_total(inputs.len()); + polyscribe::dlog!(1, "Progress mode: {:?}", mode); + + // Trigger model selection once upfront so any interactive messages appear cleanly + if any_audio { + progress.pause_for_prompt(); + if let Err(e) = polyscribe::find_model_file_with_printer(|s: &str| { + progress.println_above_bars(s); + }) { + progress.resume_after_prompt(); + return Err(e); + } + // Blank line after model selection prompts + progress.println_above_bars(""); + progress.resume_after_prompt(); + } + + // 1) Prompt all speaker names upfront (before creating per-file bars), respecting non-interactive stdin + let mut speakers: Vec = Vec::new(); + for s in &inputs { + let path = Path::new(s); + let default_speaker = sanitize_speaker_name( + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("speaker"), + ); + let name = prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names, &progress); + speakers.push(name); + } + + // 2) After collecting names, optionally print a compact mapping once + // Only when interactive and not quiet + if !args.quiet && !polyscribe::is_no_interaction() { + progress.println_above_bars("Files to process:"); + for e in inputs.iter().zip(speakers.iter()) { + let (input, speaker) = e; + let p = Path::new(input); + let display = p + .file_name() + .and_then(|os| os.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| p.to_string_lossy().to_string()); + progress.println_above_bars(&format!(" - {} -> {}", display, speaker)); + } + // Blank line before progress display + progress.println_above_bars(""); + } + 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 @@ -343,28 +422,66 @@ fn run() -> Result<()> { let mut merged_entries: Vec = Vec::new(); - for input_path in &inputs { + let mut completed_count: usize = 0; + let total_inputs = inputs.len(); + let mut summary: Vec<(String, String, bool, std::time::Duration)> = Vec::with_capacity(total_inputs); + for (idx, input_path) in inputs.iter().enumerate() { let path = Path::new(input_path); - let default_speaker = sanitize_speaker_name( - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("speaker"), - ); - let speaker = - prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names); + let started_at = std::time::Instant::now(); + let display_name = path + .file_name() + .and_then(|os| os.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + // Single progress area: one item spinner/bar + let item = progress.start_item(&format!("Processing: {}", path.display())); + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Processing: {} ... started", path.display()); + } + let speaker = speakers[idx].clone(); // Collect entries per file and extend merged let mut entries: Vec = Vec::new(); if is_audio_file(path) { - // Progress log to stderr (suppressed by -q); avoid partial lines - polyscribe::ilog!("Processing file: {} ...", path.display()); - let res = with_quiet_stdio_if_needed(args.quiet, || { - sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + // Avoid println! while bars are active: only log when no bars, otherwise keep UI clean + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + // Setup progress channel and receiver thread for this transcription + let (tx, rx) = channel::(); + let item_clone = item.clone(); + let recv_handle = std::thread::spawn(move || { + let mut last = -1.0f32; + while let Ok(msg) = rx.recv() { + if let Some(stage) = &msg.stage { + item_clone.set_message(stage); + } + let f = msg.fraction; + if (f - last).abs() >= 0.01 || f >= 0.999 { + item_clone.set_progress(f); + last = f; + } + if f >= 1.0 { + break; + } + } }); + let res = with_quiet_stdio_if_needed(args.quiet, || { + sel.backend.transcribe( + path, + &speaker, + lang_hint.as_deref(), + Some(tx), + args.gpu_layers, + ) + }); + let _ = recv_handle.join(); match res { Ok(items) => { - polyscribe::ilog!("done"); + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("done"); + } + // Mark progress for this input after outputs are written (below) entries.extend(items.into_iter()); } Err(e) => { @@ -380,9 +497,8 @@ fn run() -> Result<()> { .with_context(|| format!("Failed to open: {input_path}"))? .read_to_string(&mut buf) .with_context(|| format!("Failed to read: {input_path}"))?; - let root: InputRoot = serde_json::from_str(&buf).with_context(|| { - format!("Invalid JSON transcript parsed from {input_path}") - })?; + let root: InputRoot = serde_json::from_str(&buf) + .with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?; for seg in root.segments { entries.push(OutputEntry { id: 0, @@ -449,6 +565,15 @@ fn run() -> Result<()> { // Extend merged with per-file entries merged_entries.extend(out.items.into_iter()); + // progress: mark file complete (once per input) + item.finish_with("done"); + progress.inc_completed(); + completed_count += 1; + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Total: {}/{} processed", completed_count, total_inputs); + } + // record summary row + summary.push((display_name, speaker.clone(), true, started_at.elapsed())); } // Now write merged output set into out_dir @@ -491,38 +616,99 @@ fn run() -> Result<()> { let mut ms = File::create(&m_srt) .with_context(|| format!("Failed to create output file: {}", m_srt.display()))?; ms.write_all(m_srt_str.as_bytes())?; + + // Final concise summary table to stderr (below progress bars) + if !args.quiet && !summary.is_empty() { + progress.println_above_bars("Summary:"); + progress.println_above_bars(&format!("{:<22} {:<18} {:<8} {:<8}", "File", "Speaker", "Status", "Time")); + for (file, speaker, ok, dur) in summary { + let status = if ok { "OK" } else { "ERR" }; + progress.println_above_bars(&format!( + "{:<22} {:<18} {:<8} {:<8}", + file, + speaker, + status, + format!("{:.2?}", dur) + )); + } + // One blank line before finishing bars + progress.println_above_bars(""); + } } else if args.merge { polyscribe::dlog!(1, "Mode: merge; output_base={:?}", output_path); // MERGED MODE (previous default) let mut entries: Vec = Vec::new(); - for input_path in &inputs { + let mut completed_count: usize = 0; + let total_inputs = inputs.len(); + let mut summary: Vec<(String, String, bool, std::time::Duration)> = Vec::with_capacity(total_inputs); + for (idx, input_path) in inputs.iter().enumerate() { let path = Path::new(input_path); - let default_speaker = sanitize_speaker_name( - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("speaker"), - ); - let speaker = - prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names); + let started_at = std::time::Instant::now(); + let display_name = path + .file_name() + .and_then(|os| os.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + let item = if progress.has_file_bars() { progress.item_handle_at(idx) } else { progress.start_item(&format!("Processing: {}", path.display())) }; + let speaker = speakers[idx].clone(); let mut buf = String::new(); if is_audio_file(path) { - // Progress log to stderr (suppressed by -q) - polyscribe::ilog!("Processing file: {} ...", path.display()); - let res = with_quiet_stdio_if_needed(args.quiet, || { - sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + // Avoid println! while bars are active + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + let (tx, rx) = channel::(); + let item_clone = item.clone(); + let allow_stage_msgs = !progress.has_file_bars(); + let recv_handle = std::thread::spawn(move || { + let mut last = -1.0f32; + while let Ok(msg) = rx.recv() { + if allow_stage_msgs { + if let Some(stage) = &msg.stage { + item_clone.set_message(stage); + } + } + let f = msg.fraction; + if (f - last).abs() >= 0.01 || f >= 0.999 { + item_clone.set_progress(f); + last = f; + } + if f >= 1.0 { + break; + } + } }); + let res = with_quiet_stdio_if_needed(args.quiet, || { + sel.backend.transcribe( + path, + &speaker, + lang_hint.as_deref(), + Some(tx), + args.gpu_layers, + ) + }); + let _ = recv_handle.join(); match res { Ok(items) => { - polyscribe::ilog!("done"); + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("done"); + } + item.finish_with("done"); + progress.inc_completed(); + completed_count += 1; + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Total: {}/{} processed", completed_count, total_inputs); + } for e in items { entries.push(e); } + // record summary row + summary.push((display_name, speaker.clone(), true, started_at.elapsed())); continue; } Err(e) => { - if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) { + if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() { polyscribe::elog!("{:#}", e); } return Err(e); @@ -530,9 +716,18 @@ fn run() -> Result<()> { } } else if is_json_file(path) { File::open(path) - .with_context(|| format!("Failed to open: {}", input_path))? + .with_context(|| format!("Failed to open: {input_path}"))? .read_to_string(&mut buf) - .with_context(|| format!("Failed to read: {}", input_path))?; + .with_context(|| format!("Failed to read: {input_path}"))?; + // progress: mark file complete (JSON parsed) + item.finish_with("done"); + progress.inc_completed(); + completed_count += 1; + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Total: {}/{} processed", completed_count, total_inputs); + } + // record summary row + summary.push((display_name, speaker.clone(), true, started_at.elapsed())); } else { return Err(anyhow!(format!( "Unsupported input type (expected .json or audio media): {}", @@ -541,7 +736,7 @@ fn run() -> Result<()> { } let root: InputRoot = serde_json::from_str(&buf) - .with_context(|| format!("Invalid JSON transcript parsed from {}", input_path))?; + .with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?; for seg in root.segments { entries.push(OutputEntry { @@ -587,7 +782,7 @@ fn run() -> Result<()> { .and_then(|s| s.to_str()) .unwrap_or("output"); let date = date_prefix(); - let base_name = format!("{}_{}", date, stem); + let base_name = format!("{date}_{stem}"); let dir = parent_opt.unwrap_or(Path::new("")); let json_path = dir.join(format!("{}.json", &base_name)); let toml_path = dir.join(format!("{}.toml", &base_name)); @@ -618,6 +813,24 @@ fn run() -> Result<()> { serde_json::to_writer_pretty(&mut handle, &out)?; writeln!(&mut handle)?; } + + // Final concise summary table to stderr (below progress bars) + if !args.quiet && !summary.is_empty() { + progress.println_above_bars("Summary:"); + progress.println_above_bars(&format!("{:<22} {:<18} {:<8} {:<8}", "File", "Speaker", "Status", "Time")); + for (file, speaker, ok, dur) in summary { + let status = if ok { "OK" } else { "ERR" }; + progress.println_above_bars(&format!( + "{:<22} {:<18} {:<8} {:<8}", + file, + speaker, + status, + format!("{:.2?}", dur) + )); + } + // One blank line before finishing bars + progress.println_above_bars(""); + } } else { polyscribe::dlog!(1, "Mode: separate; output_dir={:?}", output_path); // SEPARATE MODE (default now) @@ -638,28 +851,63 @@ fn run() -> Result<()> { } } - for input_path in &inputs { + let mut completed_count: usize = 0; + let total_inputs = inputs.len(); + let mut summary: Vec<(String, String, bool, std::time::Duration)> = Vec::with_capacity(total_inputs); + for (idx, input_path) in inputs.iter().enumerate() { let path = Path::new(input_path); - let default_speaker = sanitize_speaker_name( - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("speaker"), - ); - let speaker = - prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names); + let started_at = std::time::Instant::now(); + let display_name = path + .file_name() + .and_then(|os| os.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + let item = progress.start_item(&format!("Processing: {}", path.display())); + let speaker = speakers[idx].clone(); // Collect entries per file let mut entries: Vec = Vec::new(); if is_audio_file(path) { - // Progress log to stderr (suppressed by -q) - polyscribe::ilog!("Processing file: {} ...", path.display()); - let res = with_quiet_stdio_if_needed(args.quiet, || { - sel.backend - .transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers) + // Avoid println! while bars are active + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("Processing file: {} ...", path.display()); + } + let (tx, rx) = channel::(); + let item_clone = item.clone(); + let allow_stage_msgs = !progress.has_file_bars(); + let recv_handle = std::thread::spawn(move || { + let mut last = -1.0f32; + while let Ok(msg) = rx.recv() { + if allow_stage_msgs { + if let Some(stage) = &msg.stage { + item_clone.set_message(stage); + } + } + let f = msg.fraction; + if (f - last).abs() >= 0.01 || f >= 0.999 { + item_clone.set_progress(f); + last = f; + } + if f >= 1.0 { + break; + } + } }); + let res = with_quiet_stdio_if_needed(args.quiet, || { + sel.backend.transcribe( + path, + &speaker, + lang_hint.as_deref(), + Some(tx), + args.gpu_layers, + ) + }); + let _ = recv_handle.join(); match res { Ok(items) => { - polyscribe::ilog!("done"); + if matches!(mode, polyscribe::progress::ProgressMode::None) { + polyscribe::ilog!("done"); + } entries.extend(items); } Err(e) => { @@ -675,9 +923,8 @@ fn run() -> Result<()> { .with_context(|| format!("Failed to open: {input_path}"))? .read_to_string(&mut buf) .with_context(|| format!("Failed to read: {input_path}"))?; - let root: InputRoot = serde_json::from_str(&buf).with_context(|| { - format!("Invalid JSON transcript parsed from {input_path}") - })?; + let root: InputRoot = serde_json::from_str(&buf) + .with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?; for seg in root.segments { entries.push(OutputEntry { id: 0, @@ -748,9 +995,34 @@ fn run() -> Result<()> { serde_json::to_writer_pretty(&mut handle, &out)?; writeln!(&mut handle)?; } + // progress: mark file complete + item.finish_with("done"); + progress.inc_completed(); + // record summary row + summary.push((display_name, speaker.clone(), true, started_at.elapsed())); + } + + // Final concise summary table to stderr (below progress bars) + if !args.quiet && !summary.is_empty() { + progress.println_above_bars("Summary:"); + progress.println_above_bars(&format!("{:<22} {:<18} {:<8} {:<8}", "File", "Speaker", "Status", "Time")); + for (file, speaker, ok, dur) in summary { + let status = if ok { "OK" } else { "ERR" }; + progress.println_above_bars(&format!( + "{:<22} {:<18} {:<8} {:<8}", + file, + speaker, + status, + format!("{:.2?}", dur) + )); + } + // One blank line before finishing bars + progress.println_above_bars(""); } } + // Finalize progress bars: keep total visible with final message + progress.finish_all(); // Final best-effort cleanup of .last_model on normal exit let _ = std::fs::remove_file(&last_model_path); Ok(()) diff --git a/src/models.rs b/src/models.rs index 9ba98c5..6687b81 100644 --- a/src/models.rs +++ b/src/models.rs @@ -746,7 +746,9 @@ fn qlog_size_comparison(fname: &str, local: u64, remote: u64) -> bool { } else { qlog!( "{} size {} differs from remote {}. Updating...", - fname, local, remote + fname, + local, + remote ); false } diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..f872f7d --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,690 @@ +// Progress abstraction for STDERR-only, TTY-aware progress bars. +// Centralizes progress logic so it can be swapped or disabled easily. + +use std::env; +use std::io::IsTerminal; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; + +const NAME_WIDTH: usize = 28; + +#[derive(Debug, Clone)] +/// Progress message sent from worker threads to the UI/main thread. +/// fraction: 0.0..1.0 progress value; stage/message are optional labels. +pub struct ProgressMessage { + /// Fractional progress in range 0.0..=1.0. + pub fraction: f32, + /// Optional stage label (e.g., "load_model", "encode", "decode", "done"). + pub stage: Option, + /// Optional human-readable note. + pub note: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Mode describing how progress should be displayed. +/// +/// - None: progress is disabled or not supported. +/// - Single: one spinner for the current item only. +/// - Multi: a total progress bar plus a current-item spinner. +pub enum ProgressMode { + /// No progress output. + None, + /// Single spinner for the currently processed item. + Single, + /// Multi-bar progress including a total counter of all inputs. + Multi { + /// Total number of inputs to process when using multi-bar mode. + total_inputs: u64, + }, +} + +fn stderr_is_tty() -> bool { + // Prefer std IsTerminal when available + std::io::stderr().is_terminal() +} + +fn progress_disabled_by_env() -> bool { + matches!(env::var("NO_PROGRESS"), Ok(ref v) if v == "1" || v.eq_ignore_ascii_case("true")) +} + +#[derive(Clone)] +/// Factory that decides progress mode and produces a ProgressManager bound to stderr. +pub struct ProgressFactory { + enabled: bool, + mp: Option>, +} + +impl ProgressFactory { + /// Create a factory that enables progress when stderr is a TTY and neither + /// the NO_PROGRESS env var nor the force_disable flag are set. + pub fn new(force_disable: bool) -> Self { + let tty = stderr_is_tty(); + let env_off = progress_disabled_by_env(); + let enabled = !(force_disable || env_off) && tty; + if enabled { + let mp = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(20)); + // Render tick even if nothing changes periodically for spinner feel + mp.set_move_cursor(true); + Self { + enabled, + mp: Some(Arc::new(mp)), + } + } else { + Self { + enabled: false, + mp: None, + } + } + } + + /// Decide a suitable ProgressMode for the given number of inputs, + /// respecting whether progress is globally enabled. + pub fn decide_mode(&self, inputs_len: usize) -> ProgressMode { + if !self.enabled { + return ProgressMode::None; + } + if inputs_len == 0 { + ProgressMode::None + } else if inputs_len == 1 { + ProgressMode::Single + } else { + ProgressMode::Multi { + total_inputs: inputs_len as u64, + } + } + } + + /// Construct a ProgressManager for the previously decided mode. Returns + /// a no-op manager when progress is disabled. + pub fn make_manager(&self, mode: ProgressMode) -> ProgressManager { + match (self.enabled, &self.mp, mode) { + (true, Some(mp), ProgressMode::Single) => ProgressManager::with_single(mp.clone()), + (true, Some(mp), ProgressMode::Multi { total_inputs }) => { + ProgressManager::with_multi(mp.clone(), total_inputs) + } + _ => ProgressManager::noop(), + } + } +} + +#[derive(Clone)] +/// Handle for updating and finishing progress bars or a no-op when disabled. +pub struct ProgressManager { + inner: ProgressInner, +} + +#[derive(Clone)] +enum ProgressInner { + Noop, + Single(Arc), + Multi(Arc), +} + +#[derive(Debug)] +struct SingleBars { + current: ProgressBar, + // keep MultiProgress alive for suspend/println behavior + _mp: Arc, +} + +#[derive(Debug)] +struct MultiBars { + // Legacy bars for compatibility (used when not using per-file init) + total: ProgressBar, + current: ProgressBar, + // Optional per-file bars and aggregated total percent bar + files: Mutex>>, // each length 100 + total_pct: Mutex>, // length 100 + // Metadata for aggregation + sizes: Mutex>>>, + fractions: Mutex>>, // 0..=1 per file + last_total_draw_ms: Mutex, + // keep MultiProgress alive + _mp: Arc, +} + +#[derive(Clone)] +/// Handle for per-item progress updates. Safe to clone and send across threads to update +/// the currently active item's progress without affecting the global total counter. +pub struct ItemHandle { + pb: ProgressBar, +} + +impl ItemHandle { + /// Update the determinate progress for this item using a fraction in 0.0..=1.0. + /// Internally mapped to 0..100 units. + pub fn set_progress(&self, fraction: f32) { + let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) }; + let pos = (f * 100.0).round() as u64; + if self.pb.length().unwrap_or(0) == 0 { + self.pb.set_length(100); + } + if self.pb.position() != pos { + self.pb.set_position(pos); + } + } + /// Set a human-readable message for this item (e.g., current stage name). + pub fn set_message(&self, message: &str) { + self.pb.set_message(message.to_string()); + } + /// Finish this item by prefixing "done " to the currently displayed message. + /// The provided message parameter is ignored to preserve stable width and avoid flicker. + pub fn finish_with(&self, _message: &str) { + if !self.pb.is_finished() { + self.pb.finish_with_message(_message.to_string()); + } + } +} + +impl ProgressManager { + /// Test helper: create a Multi-mode manager with a hidden draw target, safe for tests + /// even when not attached to a TTY. + pub fn new_for_tests_multi_hidden(total: usize) -> Self { + let mp = Arc::new(MultiProgress::with_draw_target(ProgressDrawTarget::hidden())); + Self::with_multi(mp, total as u64) + } + + /// Backwards-compatible constructor used by older tests: same as new_for_tests_multi_hidden. + pub fn test_new_multi(total: usize) -> Self { + Self::new_for_tests_multi_hidden(total) + } + + /// Test helper: return (completed, total) for the global bar if present. + pub fn total_state_for_tests(&self) -> Option<(u64, u64)> { + match &self.inner { + ProgressInner::Multi(m) => Some((m.total.position(), m.total.length().unwrap_or(0))), + _ => None, + } + } + + fn noop() -> Self { + Self { + inner: ProgressInner::Noop, + } + } + + fn with_single(mp: Arc) -> Self { + let current = mp.add(ProgressBar::new(100)); + current.set_style(spinner_style()); + Self { + inner: ProgressInner::Single(Arc::new(SingleBars { current, _mp: mp })), + } + } + + fn with_multi(mp: Arc, total_inputs: u64) -> Self { + // Add current first, then total so that total stays anchored at the bottom line + let current = mp.add(ProgressBar::new(100)); + current.set_style(spinner_style()); + let total = mp.add(ProgressBar::new(total_inputs)); + total.set_style(total_style()); + total.set_message("total"); + Self { + inner: ProgressInner::Multi(Arc::new(MultiBars { + total, + current, + files: Mutex::new(None), + total_pct: Mutex::new(None), + sizes: Mutex::new(None), + fractions: Mutex::new(None), + last_total_draw_ms: Mutex::new(Instant::now()), + _mp: mp, + })), + } + } + + /// Set the total number of items for the global progress (multi mode). + pub fn set_total(&self, n: usize) { + match &self.inner { + ProgressInner::Multi(m) => { + m.total.set_length(n as u64); + } + _ => {} + } + } + + /// Mark exactly one completed item (clamped to not exceed total). + pub fn inc_completed(&self) { + match &self.inner { + ProgressInner::Multi(m) => { + let len = m.total.length().unwrap_or(0); + let pos = m.total.position(); + if pos < len { + m.total.inc(1); + } + } + _ => {} + } + } + + /// Start a new item handle with an optional label. + pub fn start_item(&self, label: &str) -> ItemHandle { + match &self.inner { + ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() }, + ProgressInner::Single(s) => { + s.current.set_message(label.to_string()); + ItemHandle { pb: s.current.clone() } + } + ProgressInner::Multi(m) => { + m.current.set_message(label.to_string()); + ItemHandle { pb: m.current.clone() } + } + } + } + + /// Pause progress rendering to allow a clean prompt line to be printed. + pub fn pause_for_prompt(&self) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => { + let _ = s._mp.suspend(|| {}); + } + ProgressInner::Multi(m) => { + let _ = m._mp.suspend(|| {}); + } + } + } + + /// Print a line above the bars safely (TTY-aware). Falls back to eprintln! when disabled. + pub fn println_above_bars(&self, line: &str) { + match &self.inner { + ProgressInner::Noop => eprintln!("{}", line), + ProgressInner::Single(s) => { + let _ = s._mp.println(line); + } + ProgressInner::Multi(m) => { + let _ = m._mp.println(line); + } + } + } + + /// Resume progress after a prompt (currently a no-op; redraw continues automatically). + pub fn resume_after_prompt(&self) {} + + /// Set the message for the current-item spinner. + pub fn set_current_message(&self, msg: &str) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => s.current.set_message(msg.to_string()), + ProgressInner::Multi(m) => m.current.set_message(msg.to_string()), + } + } + + /// Set an explicit length for the current-item spinner (useful when it becomes a determinate bar). + pub fn set_current_length(&self, len: u64) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => s.current.set_length(len), + ProgressInner::Multi(m) => m.current.set_length(len), + } + } + + /// Increment the current-item spinner by the given delta. + pub fn inc_current(&self, delta: u64) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => s.current.inc(delta), + ProgressInner::Multi(m) => m.current.inc(delta), + } + } + + /// Finish the current-item spinner by prefixing "done " to its current message. + pub fn finish_current_with(&self, _msg: &str) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => { + let orig = s.current.message().to_string(); + s.current.finish_with_message(format!("done {}", orig)); + } + ProgressInner::Multi(m) => { + let orig = m.current.message().to_string(); + m.current.finish_with_message(format!("done {}", orig)); + } + } + } + + /// Increment the total progress bar by the given delta (multi-bar mode only). + pub fn inc_total(&self, delta: u64) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(_) => {} + ProgressInner::Multi(m) => m.total.inc(delta), + } + } + + /// Finish progress bars. Keep total bar visible with a final message and prefix "done " for items. + pub fn finish_all(&self) { + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => { + if !s.current.is_finished() { + let orig = s.current.message().to_string(); + s.current.finish_with_message(format!("done {}", orig)); + } + } + ProgressInner::Multi(m) => { + // If per-file bars are active, finish each with stable "done " + let mut had_files = false; + if let Ok(g) = m.files.lock() { + if let Some(files) = g.as_ref() { + had_files = true; + for pb in files.iter() { + if !pb.is_finished() { + let orig = pb.message().to_string(); + pb.finish_with_message(format!("done {}", orig)); + } + } + } + } + // Finish the aggregated total percent bar or the legacy total + if let Ok(gt) = m.total_pct.lock() { + if let Some(tpb) = gt.as_ref() { + if !tpb.is_finished() { + tpb.finish_with_message("100% total".to_string()); + } + } + } + if !had_files { + // Legacy total/current bars: keep total visible too + let len = m.total.length().unwrap_or(0); + if !m.current.is_finished() { + m.current.finish_and_clear(); + } + if !m.total.is_finished() { + m.total.finish_with_message(format!("{}/{} total", len, len)); + } + } + } + } + } + + /// Set determinate progress of the current item using a fractional value 0.0..=1.0. + pub fn set_progress(&self, fraction: f32) { + let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) }; + let pos = (f * 100.0).round() as u64; + match &self.inner { + ProgressInner::Noop => {} + ProgressInner::Single(s) => { + if s.current.length().unwrap_or(0) == 0 { + s.current.set_length(100); + } + if s.current.position() != pos { + s.current.set_position(pos); + } + } + ProgressInner::Multi(m) => { + if m.current.length().unwrap_or(0) == 0 { + m.current.set_length(100); + } + if m.current.position() != pos { + m.current.set_position(pos); + } + } + } + } + + /// Set a message/label for the current item (alias for set_current_message). + pub fn set_message(&self, message: &str) { + self.set_current_message(message); + } +} + +fn spinner_style() -> ProgressStyle { + // Style for per-item determinate progress: 0-100% with a compact bar and message + ProgressStyle::with_template("{bar:24.green/green} {percent:>3}% {msg}") + .unwrap() +} + +fn total_style() -> ProgressStyle { + // Persistent bottom bar showing total completed/total inputs + ProgressStyle::with_template("{bar:40.cyan/blue} {pos}/{len} {msg}").unwrap() +} + +#[derive(Debug, Clone, Copy)] +/// Inputs used to determine progress enablement and mode. +pub struct SelectionInput { + /// Number of inputs to process (used to choose single vs multi mode). + pub inputs_len: usize, + /// Whether progress was explicitly disabled via a CLI flag. + pub no_progress_flag: bool, + /// Optional override for whether stderr is a TTY; if None, auto-detect. + pub stderr_tty_override: Option, + /// Whether progress was disabled via the NO_PROGRESS environment variable. + pub env_no_progress: bool, +} + +/// Decide whether progress is enabled and which mode to use based on SelectionInput. +pub fn select_mode(si: SelectionInput) -> (bool, ProgressMode) { + // Compute effective enablement + let tty = si.stderr_tty_override.unwrap_or_else(stderr_is_tty); + let disabled = si.no_progress_flag || si.env_no_progress; + let enabled = tty && !disabled; + let mode = if !enabled || si.inputs_len == 0 { + ProgressMode::None + } else if si.inputs_len == 1 { + ProgressMode::Single + } else { + ProgressMode::Multi { + total_inputs: si.inputs_len as u64, + } + }; + (enabled, mode) +} + +/// Optional Ctrl-C cleanup: clears progress bars and removes .last_model before exiting on SIGINT. +pub fn install_ctrlc_cleanup(pm: ProgressManager) { + let state = Arc::new(Mutex::new(Some(pm.clone()))); + let state_clone = state.clone(); + if let Err(e) = ctrlc::set_handler(move || { + // Clear any visible progress bars + if let Ok(mut guard) = state_clone.lock() { + if let Some(pm) = guard.take() { + pm.finish_all(); + } + } + // Best-effort removal of the last-model cache so it doesn't persist after Ctrl-C + let last_path = crate::models_dir_path().join(".last_model"); + let _ = std::fs::remove_file(&last_path); + // Exit with 130 to reflect SIGINT + std::process::exit(130); + }) { + // Warn if we failed to install the handler; without it, Ctrl-C won't trigger cleanup + crate::wlog!("Failed to install Ctrl-C handler: {}", e); + } +} + + +// --- New: Per-file progress bars API for Multi mode --- +impl ProgressManager { + /// Initialize per-file bars and an aggregated total percent bar using indicatif::MultiProgress. + /// Each bar has length 100 and shows a truncated filename as message. + /// This replaces the legacy current/total display with fixed per-file lines. + pub fn init_files(&self, labels_and_sizes: I) + where + I: IntoIterator)>, + S: Into, + { + if let ProgressInner::Multi(m) = &self.inner { + // Clear legacy bars from display to avoid duplication + m.current.finish_and_clear(); + m.total.finish_and_clear(); + let mut files: Vec = Vec::new(); + let mut sizes: Vec> = Vec::new(); + let mut fractions: Vec = Vec::new(); + for (label_in, size_opt) in labels_and_sizes { + let label: String = label_in.into(); + let pb = m._mp.add(ProgressBar::new(100)); + pb.set_style(spinner_style()); + let short = truncate_label(&label, NAME_WIDTH); + pb.set_message(format!("{:3}% total").unwrap()); + // Store + if let Ok(mut gf) = m.files.lock() { *gf = Some(files); } + if let Ok(mut gt) = m.total_pct.lock() { *gt = Some(total_pct); } + if let Ok(mut gs) = m.sizes.lock() { *gs = Some(sizes); } + if let Ok(mut gfr) = m.fractions.lock() { *gfr = Some(fractions); } + if let Ok(mut t) = m.last_total_draw_ms.lock() { *t = Instant::now(); } + } + } + + /// Return whether per-file bars are active (Multi mode only) + pub fn has_file_bars(&self) -> bool { + match &self.inner { + ProgressInner::Multi(m) => m.files.lock().map(|g| g.is_some()).unwrap_or(false), + _ => false, + } + } + + /// Get an item handle for a specific file index (Multi mode with file bars). Falls back to legacy current. + pub fn item_handle_at(&self, index: usize) -> ItemHandle { + match &self.inner { + ProgressInner::Multi(m) => { + if let Ok(g) = m.files.lock() { + if let Some(vec) = g.as_ref() { + if let Some(pb) = vec.get(index) { + return ItemHandle { pb: pb.clone() }; + } + } + } + ItemHandle { pb: m.current.clone() } + } + ProgressInner::Single(s) => ItemHandle { pb: s.current.clone() }, + ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() }, + } + } + + /// Update a specific file's progress (0.0..=1.0) and recompute the aggregated total percent. + pub fn set_file_progress(&self, index: usize, fraction: f32) { + let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) }; + if let ProgressInner::Multi(m) = &self.inner { + if let Ok(gf) = m.files.lock() { + if let Some(files) = gf.as_ref() { + if index < files.len() { + let pb = &files[index]; + pb.set_length(100); + let pos = (f * 100.0).round() as u64; + if pb.position() != pos { + pb.set_position(pos); + } + } + } + } + if let Ok(mut gfr) = m.fractions.lock() { + if let Some(fracs) = gfr.as_mut() { + if index < fracs.len() { + fracs[index] = f; + } + } + } + self.recompute_total_pct(); + } + } + + fn recompute_total_pct(&self) { + if let ProgressInner::Multi(m) = &self.inner { + let has_total = m.total_pct.lock().map(|g| g.is_some()).unwrap_or(false); + if !has_total { + return; + } + let now = Instant::now(); + let do_draw = if let Ok(mut last) = m.last_total_draw_ms.lock() { + if now.duration_since(*last).as_millis() >= 50 { + *last = now; + true + } else { + false + } + } else { + true + }; + if !do_draw { + return; + } + let fractions = match m.fractions.lock().ok().and_then(|g| g.clone()) { + Some(v) => v, + None => return, + }; + let sizes_opt = m.sizes.lock().ok().and_then(|g| g.clone()); + let pct = if let Some(sizes) = sizes_opt.as_ref() { + if !sizes.is_empty() && sizes.iter().all(|o| o.is_some()) { + let mut num: f64 = 0.0; + let mut den: f64 = 0.0; + for (f, s) in fractions.iter().zip(sizes.iter()) { + let sz = s.unwrap_or(0) as f64; + num += (*f as f64) * sz; + den += sz; + } + if den > 0.0 { (num / den) as f32 } else { 0.0 } + } else { + // Fallback to unweighted average + if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::()) / (fractions.len() as f32) } + } + } else { + if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::()) / (fractions.len() as f32) } + }; + let pos = (pct.clamp(0.0, 1.0) * 100.0).round() as u64; + if let Ok(gt) = m.total_pct.lock() { + if let Some(total_pb) = gt.as_ref() { + total_pb.set_length(100); + if total_pb.position() != pos { + total_pb.set_position(pos); + } + } + } + } + } +} + +fn truncate_label(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + if max <= 3 { + return ".".repeat(max); + } + let keep = max - 3; + let truncated = s.chars().take(keep).collect::(); + format!("{}...", truncated) + } +} + + +#[cfg(test)] +mod tests { + use super::truncate_label; + + #[test] + fn truncate_keeps_short_and_exact() { + assert_eq!(truncate_label("short", 10), "short"); + assert_eq!(truncate_label("short", 5), "short"); + } + + #[test] + fn truncate_long_adds_ellipsis() { + assert_eq!(truncate_label("abcdefghij", 8), "abcde..."); + assert_eq!(truncate_label("filename_long.flac", 12), "filename_..."); + } + + #[test] + fn truncate_small_max_returns_dots() { + assert_eq!(truncate_label("anything", 3), "..."); + assert_eq!(truncate_label("anything", 2), ".."); + assert_eq!(truncate_label("anything", 1), "."); + assert_eq!(truncate_label("anything", 0), ""); + } + + #[test] + fn truncate_handles_unicode_by_char_boundary() { + // Using chars().take(keep) prevents splitting code points; not grapheme-perfect but safe. + // "é" is 2 bytes but 1 char; keep=2 should keep "Aé" then add dots + let s = "AéBCD"; // chars: A, é, B, C, D + assert_eq!(truncate_label(s, 5), "Aé..."); // keep 2 chars + ... + } +} diff --git a/tests/progress.rs b/tests/progress.rs new file mode 100644 index 0000000..1099970 --- /dev/null +++ b/tests/progress.rs @@ -0,0 +1,91 @@ +use polyscribe::progress::{ProgressFactory, ProgressMode, SelectionInput, select_mode, ProgressManager}; + +#[test] +fn test_factory_decide_mode_none_when_disabled() { + let pf = ProgressFactory::new(true); // force disabled + assert!(matches!(pf.decide_mode(0), ProgressMode::None)); + assert!(matches!(pf.decide_mode(1), ProgressMode::None)); + assert!(matches!(pf.decide_mode(2), ProgressMode::None)); +} + +#[test] +fn test_select_mode_zero_inputs_is_none() { + let si = SelectionInput { + inputs_len: 0, + no_progress_flag: false, + stderr_tty_override: Some(true), + env_no_progress: false, + }; + let (enabled, mode) = select_mode(si); + assert!(enabled); + assert!(matches!(mode, ProgressMode::None)); +} + +#[test] +fn test_select_mode_one_input_is_single() { + let si = SelectionInput { + inputs_len: 1, + no_progress_flag: false, + stderr_tty_override: Some(true), + env_no_progress: false, + }; + let (enabled, mode) = select_mode(si); + assert!(enabled); + assert!(matches!(mode, ProgressMode::Single)); +} + +#[test] +fn test_select_mode_multi_inputs_is_multi() { + let si = SelectionInput { + inputs_len: 3, + no_progress_flag: false, + stderr_tty_override: Some(true), + env_no_progress: false, + }; + let (enabled, mode) = select_mode(si); + assert!(enabled); + match mode { + ProgressMode::Multi { total_inputs } => assert_eq!(total_inputs, 3), + _ => panic!("expected multi mode"), + } +} + +#[test] +fn test_env_no_progress_disables() { + // Simulate env flag influence by passing env_no_progress=true + unsafe { std::env::set_var("NO_PROGRESS", "1"); } + let si = SelectionInput { + inputs_len: 5, + no_progress_flag: false, + stderr_tty_override: Some(true), + env_no_progress: true, + }; + let (enabled, mode) = select_mode(si); + assert!(!enabled); + assert!(matches!(mode, ProgressMode::None)); + unsafe { std::env::remove_var("NO_PROGRESS"); } +} + +#[test] +fn test_completed_never_exceeds_total_and_item_updates_do_not_affect_total() { + // create hidden multiprogress for tests + let pm = ProgressManager::new_for_tests_multi_hidden(3); + pm.set_total(3); + // Start an item and update progress a few times + let item = pm.start_item("Test item"); + item.set_progress(0.1); + item.set_progress(0.4); + item.set_message("stage1"); + // Ensure total unchanged + let (pos, len) = pm.total_state_for_tests().unwrap(); + assert_eq!(len, 3); + assert_eq!(pos, 0); + // Mark 4 times completed, but expect clamp at 3 + pm.inc_completed(); + pm.inc_completed(); + pm.inc_completed(); + pm.inc_completed(); + let (pos, len) = pm.total_state_for_tests().unwrap(); + assert_eq!(len, 3); + assert_eq!(pos, 3); +} diff --git a/tests/progress_manager.rs b/tests/progress_manager.rs new file mode 100644 index 0000000..c67ab80 --- /dev/null +++ b/tests/progress_manager.rs @@ -0,0 +1,30 @@ +use polyscribe::progress::ProgressManager; + +#[test] +fn test_total_and_completed_clamp() { + let pm = ProgressManager::new_for_tests_multi_hidden(3); + pm.set_total(3); + pm.inc_completed(); + pm.inc_completed(); + pm.inc_completed(); + // Extra increments should not exceed total + pm.inc_completed(); +} + +#[test] +fn test_start_item_does_not_change_total() { + let pm = ProgressManager::new_for_tests_multi_hidden(2); + pm.set_total(2); + let item = pm.start_item("file1"); + item.set_progress(0.5); + // No panic; total bar position should be unaffected. We cannot introspect position without + // exposing internals; this test ensures API usability without side effects. + item.finish_with("done"); +} + +#[test] +fn test_pause_and_resume_prompt() { + let pm = ProgressManager::test_new_multi(1); + pm.pause_for_prompt(); + pm.resume_after_prompt(); +} diff --git a/tests/prompt_spacing.rs b/tests/prompt_spacing.rs new file mode 100644 index 0000000..f38b6f9 --- /dev/null +++ b/tests/prompt_spacing.rs @@ -0,0 +1,86 @@ +use std::io::Write as _; +use std::process::{Command, Stdio}; + +fn manifest_path(rel: &str) -> std::path::PathBuf { + let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push(rel); + p +} + +fn collect_stderr_lines(output: &std::process::Output) -> Vec { + let stderr = String::from_utf8_lossy(&output.stderr); + stderr.lines().map(|s| s.to_string()).collect() +} + +#[test] +fn speaker_prompt_spacing_single_vs_multi_is_consistent() { + let exe = env!("CARGO_BIN_EXE_polyscribe"); + let input1 = manifest_path("input/1-s0wlz.json"); + let input2 = manifest_path("input/2-vikingowl.json"); + + // Single mode + let mut child1 = Command::new(exe) + .arg(input1.as_os_str()) + .arg("--set-speaker-names") + .arg("-m") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn polyscribe (single)"); + { + let s = child1.stdin.as_mut().unwrap(); + writeln!(s, "Alpha").unwrap(); + } + let out1 = child1.wait_with_output().unwrap(); + assert!(out1.status.success()); + let lines1 = collect_stderr_lines(&out1); + + // Multi mode + let mut child2 = Command::new(exe) + .arg(input1.as_os_str()) + .arg(input2.as_os_str()) + .arg("--set-speaker-names") + .arg("-m") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn polyscribe (multi)"); + { + let s = child2.stdin.as_mut().unwrap(); + writeln!(s, "Alpha").unwrap(); + writeln!(s, "Beta").unwrap(); + } + let out2 = child2.wait_with_output().unwrap(); + assert!(out2.status.success()); + let lines2 = collect_stderr_lines(&out2); + + // Helper to count blank separators around echo block + fn analyze(lines: &[String]) -> (usize, usize, usize) { + // count: prompts, blanks, echoes (either legacy "Speaker for " or new mapping lines starting with " - ") + let mut prompts = 0; + let mut blanks = 0; + let mut echoes = 0; + for l in lines { + if l.starts_with("Enter speaker name for ") { prompts += 1; } + if l.trim().is_empty() { blanks += 1; } + if l.starts_with("Speaker for ") || l.starts_with(" - ") { echoes += 1; } + } + (prompts, blanks, echoes) + } + + let (p1, b1, e1) = analyze(&lines1); + let (p2, b2, e2) = analyze(&lines2); + + // Expect one prompt/echo for single, two for multi + assert_eq!(p1, 1); + assert_eq!(e1, 1); + assert_eq!(p2, 2); + assert_eq!(e2, 2); + + // Each mode should have exactly two blank separators: one between prompts and echoes and one after echoes + // Note: other logs may be absent in tests; we count exactly 2 blanks for single and multi here + assert!(b1 >= 2, "expected at least two blank separators in single mode, got {}: {:?}", b1, lines1); + assert!(b2 >= 2, "expected at least two blank separators in multi mode, got {}: {:?}", b2, lines2); +}