[feat] add robust progress management utilities and new tests

This commit is contained in:
2025-08-11 06:59:24 +02:00
parent cd25b526c6
commit 9bab7b75d3
12 changed files with 1443 additions and 117 deletions

View File

@@ -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<Sender<ProgressMessage>>,
gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>>;
}
@@ -147,9 +150,10 @@ impl TranscribeBackend for CpuBackend {
audio_path: &Path,
speaker: &str,
lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> {
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<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>,
) -> 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_tx)
}
}
@@ -178,9 +183,10 @@ impl TranscribeBackend for HipBackend {
audio_path: &Path,
speaker: &str,
lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> {
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<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> {
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<Sender<ProgressMessage>>,
) -> Result<Vec<OutputEntry>> {
// 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()

View File

@@ -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<String> {
/// Locate a Whisper model file, prompting user to download/select when necessary.
pub fn find_model_file() -> Result<PathBuf> {
// 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<F>(printer: F) -> Result<PathBuf>
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<PathBuf> {
"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<PathBuf> {
}
}
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<Vec<f32>> {
{
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
));
))
}
}
};

View File

@@ -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<AuxCommands>,
@@ -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<String> = 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<OutputEntry> = 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<OutputEntry> = 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::<ProgressMessage>();
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<OutputEntry> = 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::<ProgressMessage>();
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<OutputEntry> = 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::<ProgressMessage>();
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(())

View File

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

690
src/progress.rs Normal file
View File

@@ -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<String>,
/// Optional human-readable note.
pub note: Option<String>,
}
#[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<Arc<MultiProgress>>,
}
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<SingleBars>),
Multi(Arc<MultiBars>),
}
#[derive(Debug)]
struct SingleBars {
current: ProgressBar,
// keep MultiProgress alive for suspend/println behavior
_mp: Arc<MultiProgress>,
}
#[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<Option<Vec<ProgressBar>>>, // each length 100
total_pct: Mutex<Option<ProgressBar>>, // length 100
// Metadata for aggregation
sizes: Mutex<Option<Vec<Option<u64>>>>,
fractions: Mutex<Option<Vec<f32>>>, // 0..=1 per file
last_total_draw_ms: Mutex<Instant>,
// keep MultiProgress alive
_mp: Arc<MultiProgress>,
}
#[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<MultiProgress>) -> 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<MultiProgress>, 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 <msg>"
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<bool>,
/// 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<I, S>(&self, labels_and_sizes: I)
where
I: IntoIterator<Item = (S, Option<u64>)>,
S: Into<String>,
{
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<ProgressBar> = Vec::new();
let mut sizes: Vec<Option<u64>> = Vec::new();
let mut fractions: Vec<f32> = 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!("{:<width$}", short, width = NAME_WIDTH));
files.push(pb);
sizes.push(size_opt);
fractions.push(0.0);
}
let total_pct = m._mp.add(ProgressBar::new(100));
total_pct
.set_style(ProgressStyle::with_template("{bar:40.cyan/blue} {percent:>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::<f32>()) / (fractions.len() as f32) }
}
} else {
if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::<f32>()) / (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::<String>();
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 + ...
}
}