[feat] add TTY-aware progress management with indicatif and file-specific progress bars

This commit is contained in:
2025-08-12 08:11:28 +02:00
parent 2cc5e49131
commit 041e504cb2
6 changed files with 241 additions and 22 deletions

44
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<u32>,
progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>,
) -> Result<Vec<OutputEntry>>;
}
@@ -148,8 +150,9 @@ impl TranscribeBackend for CpuBackend {
speaker: &str,
lang_opt: Option<&str>,
_gpu_layers: Option<u32>,
progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>,
) -> Result<Vec<OutputEntry>> {
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<u32>,
progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>,
) -> Result<Vec<OutputEntry>> {
// 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<u32>,
progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>,
) -> Result<Vec<OutputEntry>> {
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<u32>,
_progress_cb: Option<&(dyn Fn(i32) + Send + Sync)>,
) -> Result<Vec<OutputEntry>> {
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<Vec<OutputEntry>> {
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)
}

View File

@@ -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<MultiProgress>,
per: Vec<ProgressBar>,
total: Option<ProgressBar>,
total_n: usize,
completed: usize,
done: Vec<bool>,
}
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<ProgressBar> {
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<ProgressBar> {
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

View File

@@ -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<OutputEntry> = 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<Box<dyn Fn(i32) + Send + Sync>> = 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<Box<dyn Fn(i32) + Send + Sync>> = 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<OutputEntry> = 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<Box<dyn Fn(i32) + Send + Sync>> = 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);
}

View File

@@ -419,7 +419,7 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
crate::ui::println_above_bars(format!(" {}) {}", i + 1, b));
}
loop {
let mut line = match crate::ui::prompt_line("Select base (number or name, 'q' to cancel): ") {
let line = match crate::ui::prompt_line("Select base (number or name, 'q' to cancel): ") {
Ok(s) => s,
Err(_) => String::new(),
};