diff --git a/Cargo.lock b/Cargo.lock index eb74f0e..459f496 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,20 @@ dependencies = [ "roff", ] +[[package]] +name = "cliclack" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "cmake" version = "0.1.54" @@ -300,6 +314,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" @@ -362,6 +389,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 +847,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" @@ -980,6 +1026,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 +1130,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "cliclack", "libc", "reqwest", "serde", @@ -1088,6 +1141,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" @@ -1406,6 +1465,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.0" @@ -1499,6 +1564,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1682,6 +1758,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[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 +1916,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" @@ -2147,6 +2245,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 07a861f..6219f84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ 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" } libc = "0.2" +cliclack = "0.3" [dev-dependencies] tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index b82bcf8..3a3025b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,50 +173,82 @@ where } } +/// Centralized UI helpers (TTY-aware, quiet/verbose-aware) +pub mod ui { + use std::io; + // Prefer cliclack for all user-visible messages to ensure consistent, TTY-aware output. + // Falls back to stderr printing if needed. + /// Startup intro/banner (suppressed when quiet). + pub fn intro(msg: impl AsRef) { + if crate::is_quiet() { return; } + // Use cliclack intro to render a nice banner when TTY + let _ = cliclack::intro(msg.as_ref()); + } + /// Print an informational line (suppressed when quiet). + pub fn info(msg: impl AsRef) { + if crate::is_quiet() { return; } + let _ = cliclack::log::info(msg.as_ref()); + } + /// Print a warning (always printed). + pub fn warn(msg: impl AsRef) { + // cliclack provides a warning-level log utility + let _ = cliclack::log::warning(msg.as_ref()); + } + /// Print an error (always printed). + pub fn error(msg: impl AsRef) { + let _ = cliclack::log::error(msg.as_ref()); + } + /// Print a line above any progress bars (maps to cliclack log; synchronized). + pub fn println_above_bars(msg: impl AsRef) { + if crate::is_quiet() { return; } + // cliclack logs are synchronized with its spinners/bars + let _ = cliclack::log::info(msg.as_ref()); + } + /// Final outro/summary printed below any progress indicators (suppressed when quiet). + pub fn outro(msg: impl AsRef) { + if crate::is_quiet() { return; } + let _ = cliclack::outro(msg.as_ref()); + } + /// Prompt the user (TTY-aware via cliclack) and read a line from stdin. Returns the raw line with trailing newline removed. + pub fn prompt_line(prompt: &str) -> io::Result { + // Route prompt through cliclack to keep consistent styling and avoid direct eprint!/println! + let _ = cliclack::log::info(prompt); + let mut s = String::new(); + io::stdin().read_line(&mut s)?; + Ok(s) + } +} + /// Logging macros and helpers -/// Log an error to stderr (always printed). Recommended for user-visible errors. +/// Log an error using the UI helper (always printed). Recommended for user-visible errors. #[macro_export] macro_rules! elog { ($($arg:tt)*) => {{ - eprintln!("ERROR: {}", format!($($arg)*)); - }} -} -/// Internal helper macro used by other logging macros to centralize the -/// common behavior: build formatted message, check quiet/verbose flags, -/// and print to stderr with a label. -#[macro_export] -macro_rules! log_with_level { - ($label:expr, $min_lvl:expr, $always:expr, $($arg:tt)*) => {{ - let should_print = if $always { - true - } else if let Some(minv) = $min_lvl { - !$crate::is_quiet() && $crate::verbose_level() >= minv - } else { - !$crate::is_quiet() - }; - if should_print { - eprintln!("{}: {}", $label, format!($($arg)*)); - } + $crate::ui::error(format!($($arg)*)); }} } -/// Log a warning to stderr (printed even in quiet mode). +/// Log a warning using the UI helper (printed even in quiet mode). #[macro_export] macro_rules! wlog { - ($($arg:tt)*) => {{ $crate::log_with_level!("WARN", None, true, $($arg)*); }} + ($($arg:tt)*) => {{ + $crate::ui::warn(format!($($arg)*)); + }} } -/// Log an informational line to stderr unless quiet mode is enabled. +/// Log an informational line using the UI helper unless quiet mode is enabled. #[macro_export] macro_rules! ilog { - ($($arg:tt)*) => {{ $crate::log_with_level!("INFO", None, false, $($arg)*); }} + ($($arg:tt)*) => {{ + if !$crate::is_quiet() { $crate::ui::info(format!($($arg)*)); } + }} } /// Log a debug/trace line when verbose level is at least the given level (u8). #[macro_export] macro_rules! dlog { ($lvl:expr, $($arg:tt)*) => {{ - $crate::log_with_level!(&format!("DEBUG{}", &$lvl), Some($lvl), false, $($arg)*); + if !$crate::is_quiet() && $crate::verbose_level() >= $lvl { $crate::ui::info(format!("DEBUG{}: {}", $lvl, format!($($arg)*))); } }} } @@ -230,7 +262,6 @@ use anyhow::{Context, Result, anyhow}; use chrono::Local; use std::env; use std::fs::create_dir_all; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -462,10 +493,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(); - let mut input = String::new(); - io::stdin().read_line(&mut input).ok(); + let input = crate::ui::prompt_line("Would you like to download models now? [Y/n]: ").unwrap_or_default(); let ans = input.trim().to_lowercase(); if ans.is_empty() || ans == "y" || ans == "yes" { if let Err(e) = models::run_interactive_model_downloader() { @@ -519,16 +547,12 @@ pub fn find_model_file() -> Result { } } - eprintln!("Multiple Whisper models found in {}:", models_dir.display()); + crate::ui::println_above_bars(format!("Multiple Whisper models found in {}:", models_dir.display())); for (i, p) in candidates.iter().enumerate() { - eprintln!(" {}) {}", i + 1, p.display()); + crate::ui::println_above_bars(format!(" {}) {}", i + 1, p.display())); } - eprint!("Select model by number [1-{}]: ", candidates.len()); - io::stderr().flush().ok(); - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .context("Failed to read selection")?; + let input = crate::ui::prompt_line(&format!("Select model by number [1-{}]: ", candidates.len())) + .map_err(|_| anyhow!("Failed to read selection"))?; let sel: usize = input .trim() .parse() diff --git a/src/main.rs b/src/main.rs index 66fe6c2..38bebb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,25 +142,18 @@ 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!( + let buf = polyscribe::ui::prompt_line(&format!( "Enter speaker name for {display_owned} [default: {default_name}]: " - ); - io::stderr().flush().ok(); - let mut buf = String::new(); - match io::stdin().read_line(&mut buf) { - Ok(_) => { - let raw = buf.trim(); - if raw.is_empty() { - return default_name.to_string(); - } - let sanitized = sanitize_speaker_name(raw); - if sanitized.is_empty() { - default_name.to_string() - } else { - sanitized - } - } - Err(_) => default_name.to_string(), + )).unwrap_or_default(); + let raw = buf.trim(); + if raw.is_empty() { + return default_name.to_string(); + } + let sanitized = sanitize_speaker_name(raw); + if sanitized.is_empty() { + default_name.to_string() + } else { + sanitized } } @@ -217,6 +210,7 @@ where } fn run() -> Result<()> { + let _t0 = std::time::Instant::now(); // Parse CLI let args = Args::parse(); @@ -225,6 +219,9 @@ fn run() -> Result<()> { polyscribe::set_quiet(args.quiet); polyscribe::set_no_interaction(args.no_interaction); + // Startup banner via UI (TTY-aware through cliclack), suppressed when quiet + polyscribe::ui::intro(format!("PolyScribe v{}", env!("CARGO_PKG_VERSION"))); + // Handle auxiliary subcommands that write to stdout and exit early if let Some(aux) = &args.aux { use clap::CommandFactory; @@ -266,6 +263,10 @@ fn run() -> Result<()> { polyscribe::dlog!(1, "Using backend: {:?}", sel.chosen); // If requested, run the interactive model downloader first. If no inputs were provided, exit after downloading. + let mut summary_inputs_total: usize = 0; + let mut summary_audio_count: usize = 0; + let mut summary_json_count: usize = 0; + let mut summary_segments_total: usize = 0; if args.download_models { if let Err(e) = polyscribe::models::run_interactive_model_downloader() { polyscribe::elog!("Model downloader failed: {:#}", e); @@ -290,6 +291,7 @@ fn run() -> Result<()> { // Determine inputs and optional output path polyscribe::dlog!(1, "Parsed {} input(s)", args.inputs.len()); let mut inputs = args.inputs; + summary_inputs_total = inputs.len(); let mut output_path = args.output; if output_path.is_none() && inputs.len() >= 2 { if let Some(last) = inputs.last().cloned() { @@ -353,6 +355,7 @@ fn run() -> Result<()> { // Collect entries per file and extend merged 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()); let res = with_quiet_stdio_if_needed(args.quiet, || { @@ -372,6 +375,7 @@ fn run() -> Result<()> { } } } else if is_json_file(path) { + summary_json_count += 1; let mut buf = String::new(); File::open(path) .with_context(|| format!("Failed to open: {input_path}"))? @@ -409,6 +413,7 @@ fn run() -> Result<()> { for (i, e) in entries.iter_mut().enumerate() { e.id = i as u64; } + summary_segments_total += entries.len(); // Write separate outputs to out_dir let out = OutputRoot { @@ -498,6 +503,7 @@ 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()); let res = with_quiet_stdio_if_needed(args.quiet, || { @@ -520,6 +526,7 @@ fn run() -> Result<()> { } } } else if is_json_file(path) { + summary_json_count += 1; File::open(path) .with_context(|| format!("Failed to open: {}", input_path))? .read_to_string(&mut buf) @@ -559,6 +566,7 @@ fn run() -> Result<()> { e.id = i as u64; } let out = OutputRoot { items: entries }; + summary_segments_total = out.items.len(); if let Some(path) = output_path { let base_path = Path::new(&path); @@ -636,6 +644,7 @@ fn run() -> Result<()> { // Collect entries per file 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()); let res = with_quiet_stdio_if_needed(args.quiet, || { @@ -655,6 +664,7 @@ fn run() -> Result<()> { } } } else if is_json_file(path) { + summary_json_count += 1; let mut buf = String::new(); File::open(path) .with_context(|| format!("Failed to open: {input_path}"))? @@ -692,6 +702,7 @@ fn run() -> Result<()> { for (i, e) in entries.iter_mut().enumerate() { e.id = i as u64; } + summary_segments_total += entries.len(); let out = OutputRoot { items: entries }; if let Some(dir) = &out_dir { @@ -736,6 +747,20 @@ fn run() -> Result<()> { } } + // Final summary (TTY-aware via UI), only when not quiet + if !polyscribe::is_quiet() { + let elapsed = _t0.elapsed(); + let secs = elapsed.as_secs_f32(); + let mut out = String::new(); + out.push_str("Summary:\n"); + out.push_str(&format!("{:<12} {:>8}\n", "Files:", summary_inputs_total)); + out.push_str(&format!("{:<12} {:>8}\n", "Audio:", summary_audio_count)); + out.push_str(&format!("{:<12} {:>8}\n", "JSON:", summary_json_count)); + out.push_str(&format!("{:<12} {:>8}\n", "Segments:", summary_segments_total)); + out.push_str(&format!("{:<12} {:>8.2}s\n", "Time:", secs)); + polyscribe::ui::outro(out); + } + Ok(()) } diff --git a/src/models.rs b/src/models.rs index 9ba98c5..8d44605 100644 --- a/src/models.rs +++ b/src/models.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use std::env; use std::fs::{File, create_dir_all}; -use std::io::{self, Read, Write}; +use std::io::{Read, Write}; use std::path::Path; use std::time::Duration; @@ -326,8 +326,8 @@ fn fetch_all_models(client: &Client) -> Result> { match hf_fetch_repo_models(client, "akashmjn/tinydiarize-whisper.cpp") { Ok(v) => v, Err(e) => { - ilog!( - "Warning: failed to fetch optional repo akashmjn/tinydiarize-whisper.cpp: {:#}", + wlog!( + "Failed to fetch optional repo akashmjn/tinydiarize-whisper.cpp: {:#}", e ); Vec::new() @@ -413,18 +413,16 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result s, + Err(_) => String::new(), + }; let s = line.trim(); if s.eq_ignore_ascii_case("q") || s.eq_ignore_ascii_case("quit") @@ -450,12 +448,12 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result = models.iter().filter(|m| m.base == base).cloned().collect(); if filtered.is_empty() { - eprintln!("No models found for base '{base}'."); + crate::ui::warn(format!("No models found for base '{base}'.")); continue; } // Reuse the formatter but only for the chosen base list let listing = format_model_list(&filtered); - eprint!("{listing}"); + crate::ui::println_above_bars(listing); // Build index map for filtered list let mut index_map: Vec = Vec::with_capacity(filtered.len()); @@ -466,12 +464,8 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result Result = selected @@ -511,10 +505,10 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result } } Err(e) => { - qlog!( - "Warning: failed to hash existing {}: {}. Will re-download to ensure correctness.", + wlog!( + "Failed to hash existing {}: {}. Will re-download to ensure correctness.", final_path.display(), e ); @@ -618,8 +612,8 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) -> } } Err(e) => { - qlog!( - "Warning: failed to stat existing {}: {}. Will re-download to ensure correctness.", + wlog!( + "Failed to stat existing {}: {}. Will re-download to ensure correctness.", final_path.display(), e ); @@ -723,8 +717,8 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) -> )); } } else { - qlog!( - "Warning: no SHA-256 available for {}. Skipping verification.", + wlog!( + "No SHA-256 available for {}. Skipping verification.", entry.name ); } @@ -826,7 +820,7 @@ pub fn update_local_models() -> Result<()> { } } Err(e) => { - qlog!("Warning: failed hashing {}: {}. Re-downloading.", fname, e); + wlog!("Failed hashing {}: {}. Re-downloading.", fname, e); } } download_one_model(&client, models_dir, remote)?; @@ -839,7 +833,7 @@ pub fn update_local_models() -> Result<()> { download_one_model(&client, models_dir, remote)?; } Err(e) => { - qlog!("Warning: stat failed for {}: {}. Updating...", fname, e); + wlog!("Stat failed for {}: {}. Updating...", fname, e); download_one_model(&client, models_dir, remote)?; } }