Compare commits
3 Commits
8c9fac80db
...
255be1e413
Author | SHA1 | Date | |
---|---|---|---|
255be1e413 | |||
9bab7b75d3 | |||
cd25b526c6 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -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.
|
|
182
Cargo.lock
generated
182
Cargo.lock
generated
@@ -201,6 +201,12 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.41"
|
version = "0.4.41"
|
||||||
@@ -285,6 +291,20 @@ dependencies = [
|
|||||||
"roff",
|
"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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.54"
|
version = "0.1.54"
|
||||||
@@ -300,6 +320,19 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -335,6 +368,29 @@ dependencies = [
|
|||||||
"typenum",
|
"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 = "dialoguer"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
|
||||||
|
dependencies = [
|
||||||
|
"console",
|
||||||
|
"shell-words",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -362,6 +418,12 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -814,6 +876,19 @@ dependencies = [
|
|||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.9"
|
version = "0.7.9"
|
||||||
@@ -961,6 +1036,18 @@ dependencies = [
|
|||||||
"tempfile",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -980,6 +1067,12 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "number_prefix"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.36.7"
|
version = "0.36.7"
|
||||||
@@ -1078,6 +1171,10 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"clap_mangen",
|
"clap_mangen",
|
||||||
|
"cliclack",
|
||||||
|
"ctrlc",
|
||||||
|
"dialoguer",
|
||||||
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1088,6 +1185,12 @@ dependencies = [
|
|||||||
"whisper-rs",
|
"whisper-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1388,6 +1491,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1406,6 +1515,12 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smawk"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1499,6 +1614,37 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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 = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1682,6 +1828,18 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1828,6 +1986,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "whisper-rs"
|
name = "whisper-rs"
|
||||||
version = "0.14.3"
|
version = "0.14.3"
|
||||||
@@ -2147,6 +2315,20 @@ name = "zeroize"
|
|||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
@@ -3,7 +3,6 @@ name = "polyscribe"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-file = "LICENSE"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Default: CPU only; no GPU features enabled
|
# Default: CPU only; no GPU features enabled
|
||||||
@@ -27,8 +26,12 @@ chrono = { version = "0.4", features = ["clock"] }
|
|||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
# whisper-rs is always used (CPU-only by default); GPU features map onto it
|
# 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"
|
libc = "0.2"
|
||||||
|
indicatif = "0.17"
|
||||||
|
ctrlc = "3.4"
|
||||||
|
dialoguer = "0.11"
|
||||||
|
cliclack = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
@@ -57,6 +57,7 @@ Most-used CLI flags
|
|||||||
- -v/--verbose (repeatable): Increase log verbosity. -vv shows very detailed logs.
|
- -v/--verbose (repeatable): Increase log verbosity. -vv shows very detailed logs.
|
||||||
- -q/--quiet: Suppress non-error logs (stderr); does not silence stdout results.
|
- -q/--quiet: Suppress non-error logs (stderr); does not silence stdout results.
|
||||||
- --no-interaction: Never prompt; suitable for CI.
|
- --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
|
Minimal usage examples
|
||||||
- Transcribe an audio file to JSON/SRT:
|
- Transcribe an audio file to JSON/SRT:
|
||||||
|
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
//! Transcription backend selection and implementations (CPU/GPU) used by PolyScribe.
|
//! Transcription backend selection and implementations (CPU/GPU) used by PolyScribe.
|
||||||
use crate::OutputEntry;
|
use crate::OutputEntry;
|
||||||
|
use crate::progress::ProgressMessage;
|
||||||
use crate::{decode_audio_to_pcm_f32_ffmpeg, find_model_file};
|
use crate::{decode_audio_to_pcm_f32_ffmpeg, find_model_file};
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
// Re-export a public enum for CLI parsing usage
|
// Re-export a public enum for CLI parsing usage
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -40,6 +42,7 @@ pub trait TranscribeBackend {
|
|||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
lang_opt: Option<&str>,
|
||||||
|
progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
gpu_layers: Option<u32>,
|
gpu_layers: Option<u32>,
|
||||||
) -> Result<Vec<OutputEntry>>;
|
) -> Result<Vec<OutputEntry>>;
|
||||||
}
|
}
|
||||||
@@ -147,9 +150,10 @@ impl TranscribeBackend for CpuBackend {
|
|||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
lang_opt: Option<&str>,
|
||||||
|
progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> 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,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
lang_opt: Option<&str>,
|
||||||
|
progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> Result<Vec<OutputEntry>> {
|
||||||
// whisper-rs uses enabled CUDA feature at build time; call same code path
|
// 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,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
lang_opt: Option<&str>,
|
||||||
|
progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> 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,
|
_audio_path: &Path,
|
||||||
_speaker: &str,
|
_speaker: &str,
|
||||||
_lang_opt: Option<&str>,
|
_lang_opt: Option<&str>,
|
||||||
|
_progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> Result<Vec<OutputEntry>> {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
@@ -301,9 +308,25 @@ pub(crate) fn transcribe_with_whisper_rs(
|
|||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
lang_opt: Option<&str>,
|
||||||
|
progress_tx: Option<Sender<ProgressMessage>>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> 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 pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?;
|
||||||
let model = find_model_file()?;
|
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
|
let is_en_only = model
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.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))?;
|
.map_err(|e| anyhow!("Failed to create Whisper state: {:?}", e))?;
|
||||||
Ok::<_, anyhow::Error>((ctx, state))
|
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 =
|
let mut params =
|
||||||
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
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));
|
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(|| {
|
crate::with_suppressed_stderr(|| {
|
||||||
state
|
state
|
||||||
.full(params, &pcm)
|
.full(params, &pcm)
|
||||||
.map_err(|e| anyhow!("Whisper full() failed: {:?}", e))
|
.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
|
let num_segments = state
|
||||||
.full_n_segments()
|
.full_n_segments()
|
||||||
|
106
src/lib.rs
106
src/lib.rs
@@ -230,7 +230,8 @@ use anyhow::{Context, Result, anyhow};
|
|||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs::create_dir_all;
|
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::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -241,6 +242,11 @@ use libc::{O_WRONLY, close, dup, dup2, open};
|
|||||||
pub mod backend;
|
pub mod backend;
|
||||||
/// Re-export models module (model listing/downloading/updating).
|
/// Re-export models module (model listing/downloading/updating).
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
/// Progress and progress bar abstraction (TTY-aware, stderr-only)
|
||||||
|
pub mod progress;
|
||||||
|
|
||||||
|
/// UI helpers for interactive prompts (cliclack-backed)
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
/// Transcript entry for a single segment.
|
/// Transcript entry for a single segment.
|
||||||
#[derive(Debug, serde::Serialize, Clone)]
|
#[derive(Debug, serde::Serialize, Clone)]
|
||||||
@@ -396,6 +402,56 @@ pub fn normalize_lang_code(input: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Locate a Whisper model file, prompting user to download/select when necessary.
|
/// Locate a Whisper model file, prompting user to download/select when necessary.
|
||||||
pub fn find_model_file() -> Result<PathBuf> {
|
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_buf = models_dir_path();
|
||||||
let models_dir = models_dir_buf.as_path();
|
let models_dir = models_dir_buf.as_path();
|
||||||
if !models_dir.exists() {
|
if !models_dir.exists() {
|
||||||
@@ -462,12 +518,10 @@ pub fn find_model_file() -> Result<PathBuf> {
|
|||||||
"No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models."
|
"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]: ");
|
// Use unified cliclack confirm via UI helper
|
||||||
io::stderr().flush().ok();
|
let download_now = crate::ui::prompt_confirm("Download models now?", true)
|
||||||
let mut input = String::new();
|
.context("prompt error during confirmation")?;
|
||||||
io::stdin().read_line(&mut input).ok();
|
if download_now {
|
||||||
let ans = input.trim().to_lowercase();
|
|
||||||
if ans.is_empty() || ans == "y" || ans == "yes" {
|
|
||||||
if let Err(e) = models::run_interactive_model_downloader() {
|
if let Err(e) = models::run_interactive_model_downloader() {
|
||||||
elog!("Downloader failed: {:#}", e);
|
elog!("Downloader failed: {:#}", e);
|
||||||
}
|
}
|
||||||
@@ -519,25 +573,31 @@ 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() {
|
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());
|
// Print a blank line and the selection prompt using the provided printer to
|
||||||
io::stderr().flush().ok();
|
// keep output synchronized with any active progress rendering.
|
||||||
let mut input = String::new();
|
printer("");
|
||||||
io::stdin()
|
let prompt = format!("Select model [1-{}]:", candidates.len());
|
||||||
.read_line(&mut input)
|
// TODO(ui): migrate to cliclack::Select for model picking to standardize UI.
|
||||||
|
let sel: usize = dialoguer::Input::new()
|
||||||
|
.with_prompt(prompt)
|
||||||
|
.interact_text()
|
||||||
.context("Failed to read selection")?;
|
.context("Failed to read selection")?;
|
||||||
let sel: usize = input
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| anyhow!("Invalid selection: {}", input.trim()))?;
|
|
||||||
if sel == 0 || sel > candidates.len() {
|
if sel == 0 || sel > candidates.len() {
|
||||||
return Err(anyhow!("Selection out of range"));
|
return Err(anyhow!("Selection out of range"));
|
||||||
}
|
}
|
||||||
let chosen = candidates.swap_remove(sel - 1);
|
let chosen = candidates.swap_remove(sel - 1);
|
||||||
let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string());
|
let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string());
|
||||||
|
// Print an empty line after selection input
|
||||||
|
printer("");
|
||||||
Ok(chosen)
|
Ok(chosen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,16 +617,16 @@ pub fn decode_audio_to_pcm_f32_ffmpeg(audio_path: &Path) -> Result<Vec<f32>> {
|
|||||||
{
|
{
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
return if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
return Err(anyhow!(
|
Err(anyhow!(
|
||||||
"ffmpeg not found on PATH. Please install ffmpeg and ensure it is available."
|
"ffmpeg not found on PATH. Please install ffmpeg and ensure it is available."
|
||||||
));
|
))
|
||||||
} else {
|
} else {
|
||||||
return Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Failed to execute ffmpeg for {}: {}",
|
"Failed to execute ffmpeg for {}: {}",
|
||||||
audio_path.display(),
|
audio_path.display(),
|
||||||
e
|
e
|
||||||
));
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
420
src/main.rs
420
src/main.rs
@@ -10,8 +10,11 @@ use clap::{Parser, Subcommand};
|
|||||||
use clap_complete::Shell;
|
use clap_complete::Shell;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
// whisper-rs is used from the library crate
|
// whisper-rs is used from the library crate
|
||||||
use polyscribe::backend::{BackendKind, select_backend};
|
use polyscribe::backend::{BackendKind, select_backend};
|
||||||
|
use polyscribe::progress::ProgressMessage;
|
||||||
|
use polyscribe::progress::ProgressFactory;
|
||||||
|
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
enum AuxCommands {
|
enum AuxCommands {
|
||||||
@@ -55,6 +58,10 @@ struct Args {
|
|||||||
#[arg(long = "no-interaction", global = true)]
|
#[arg(long = "no-interaction", global = true)]
|
||||||
no_interaction: bool,
|
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)
|
/// Optional auxiliary subcommands (completions, man)
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
aux: Option<AuxCommands>,
|
aux: Option<AuxCommands>,
|
||||||
@@ -129,7 +136,7 @@ fn sanitize_speaker_name(raw: &str) -> String {
|
|||||||
raw.to_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 {
|
if !enabled {
|
||||||
return default_name.to_string();
|
return default_name.to_string();
|
||||||
}
|
}
|
||||||
@@ -137,30 +144,29 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool)
|
|||||||
// Explicitly non-interactive: never prompt
|
// Explicitly non-interactive: never prompt
|
||||||
return default_name.to_string();
|
return default_name.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let display_owned: String = path
|
let display_owned: String = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
eprint!(
|
|
||||||
"Enter speaker name for {display_owned} [default: {default_name}]: "
|
// Render prompt above any progress bars
|
||||||
);
|
pm.pause_for_prompt();
|
||||||
io::stderr().flush().ok();
|
let answer = {
|
||||||
let mut buf = String::new();
|
let prompt = format!("Enter speaker name for {} [default: {}]", display_owned, default_name);
|
||||||
match io::stdin().read_line(&mut buf) {
|
match polyscribe::ui::prompt_text(&prompt, default_name) {
|
||||||
Ok(_) => {
|
Ok(ans) => ans,
|
||||||
let raw = buf.trim();
|
Err(_) => default_name.to_string(),
|
||||||
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(),
|
};
|
||||||
|
pm.resume_after_prompt();
|
||||||
|
|
||||||
|
let sanitized = sanitize_speaker_name(&answer);
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
default_name.to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +223,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run() -> Result<()> {
|
fn run() -> Result<()> {
|
||||||
|
use polyscribe::progress::ProgressFactory;
|
||||||
// Parse CLI
|
// Parse CLI
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
@@ -254,6 +261,17 @@ fn run() -> Result<()> {
|
|||||||
path: last_model_path.clone(),
|
path: last_model_path.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Also ensure cleanup on panic: install a panic hook that removes .last_model, then chains
|
||||||
|
{
|
||||||
|
let last_for_panic = last_model_path.clone();
|
||||||
|
let prev_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let _ = std::fs::remove_file(&last_for_panic);
|
||||||
|
// chain to default/previous hook for normal panic reporting
|
||||||
|
prev_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Select backend
|
// Select backend
|
||||||
let requested = match args.gpu_backend {
|
let requested = match args.gpu_backend {
|
||||||
GpuBackendCli::Auto => BackendKind::Auto,
|
GpuBackendCli::Auto => BackendKind::Auto,
|
||||||
@@ -289,6 +307,16 @@ fn run() -> Result<()> {
|
|||||||
|
|
||||||
// Determine inputs and optional output path
|
// Determine inputs and optional output path
|
||||||
polyscribe::dlog!(1, "Parsed {} input(s)", args.inputs.len());
|
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 inputs = args.inputs;
|
||||||
let mut output_path = args.output;
|
let mut output_path = args.output;
|
||||||
if output_path.is_none() && inputs.len() >= 2 {
|
if output_path.is_none() && inputs.len() >= 2 {
|
||||||
@@ -316,6 +344,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 {
|
if args.merge_and_separate {
|
||||||
polyscribe::dlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path);
|
polyscribe::dlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path);
|
||||||
// Combined mode: write separate outputs per input and also a merged output set
|
// Combined mode: write separate outputs per input and also a merged output set
|
||||||
@@ -332,28 +413,66 @@ fn run() -> Result<()> {
|
|||||||
|
|
||||||
let mut merged_entries: Vec<OutputEntry> = Vec::new();
|
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 path = Path::new(input_path);
|
||||||
let default_speaker = sanitize_speaker_name(
|
let started_at = std::time::Instant::now();
|
||||||
path.file_stem()
|
let display_name = path
|
||||||
.and_then(|s| s.to_str())
|
.file_name()
|
||||||
.unwrap_or("speaker"),
|
.and_then(|os| os.to_str())
|
||||||
);
|
.map(|s| s.to_string())
|
||||||
let speaker =
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names);
|
// 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
|
// Collect entries per file and extend merged
|
||||||
let mut entries: Vec<OutputEntry> = Vec::new();
|
let mut entries: Vec<OutputEntry> = Vec::new();
|
||||||
if is_audio_file(path) {
|
if is_audio_file(path) {
|
||||||
// Progress log to stderr (suppressed by -q); avoid partial lines
|
// Avoid println! while bars are active: only log when no bars, otherwise keep UI clean
|
||||||
polyscribe::ilog!("Processing file: {} ...", path.display());
|
if matches!(mode, polyscribe::progress::ProgressMode::None) {
|
||||||
let res = with_quiet_stdio_if_needed(args.quiet, || {
|
polyscribe::ilog!("Processing file: {} ...", path.display());
|
||||||
sel.backend
|
}
|
||||||
.transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers)
|
// 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 {
|
match res {
|
||||||
Ok(items) => {
|
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());
|
entries.extend(items.into_iter());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -369,9 +488,8 @@ fn run() -> Result<()> {
|
|||||||
.with_context(|| format!("Failed to open: {input_path}"))?
|
.with_context(|| format!("Failed to open: {input_path}"))?
|
||||||
.read_to_string(&mut buf)
|
.read_to_string(&mut buf)
|
||||||
.with_context(|| format!("Failed to read: {input_path}"))?;
|
.with_context(|| format!("Failed to read: {input_path}"))?;
|
||||||
let root: InputRoot = serde_json::from_str(&buf).with_context(|| {
|
let root: InputRoot = serde_json::from_str(&buf)
|
||||||
format!("Invalid JSON transcript parsed from {input_path}")
|
.with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?;
|
||||||
})?;
|
|
||||||
for seg in root.segments {
|
for seg in root.segments {
|
||||||
entries.push(OutputEntry {
|
entries.push(OutputEntry {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -438,6 +556,15 @@ fn run() -> Result<()> {
|
|||||||
|
|
||||||
// Extend merged with per-file entries
|
// Extend merged with per-file entries
|
||||||
merged_entries.extend(out.items.into_iter());
|
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
|
// Now write merged output set into out_dir
|
||||||
@@ -480,38 +607,99 @@ fn run() -> Result<()> {
|
|||||||
let mut ms = File::create(&m_srt)
|
let mut ms = File::create(&m_srt)
|
||||||
.with_context(|| format!("Failed to create output file: {}", m_srt.display()))?;
|
.with_context(|| format!("Failed to create output file: {}", m_srt.display()))?;
|
||||||
ms.write_all(m_srt_str.as_bytes())?;
|
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 {
|
} else if args.merge {
|
||||||
polyscribe::dlog!(1, "Mode: merge; output_base={:?}", output_path);
|
polyscribe::dlog!(1, "Mode: merge; output_base={:?}", output_path);
|
||||||
// MERGED MODE (previous default)
|
// MERGED MODE (previous default)
|
||||||
let mut entries: Vec<OutputEntry> = Vec::new();
|
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 path = Path::new(input_path);
|
||||||
let default_speaker = sanitize_speaker_name(
|
let started_at = std::time::Instant::now();
|
||||||
path.file_stem()
|
let display_name = path
|
||||||
.and_then(|s| s.to_str())
|
.file_name()
|
||||||
.unwrap_or("speaker"),
|
.and_then(|os| os.to_str())
|
||||||
);
|
.map(|s| s.to_string())
|
||||||
let speaker =
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names);
|
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();
|
let mut buf = String::new();
|
||||||
if is_audio_file(path) {
|
if is_audio_file(path) {
|
||||||
// Progress log to stderr (suppressed by -q)
|
// Avoid println! while bars are active
|
||||||
polyscribe::ilog!("Processing file: {} ...", path.display());
|
if matches!(mode, polyscribe::progress::ProgressMode::None) {
|
||||||
let res = with_quiet_stdio_if_needed(args.quiet, || {
|
polyscribe::ilog!("Processing file: {} ...", path.display());
|
||||||
sel.backend
|
}
|
||||||
.transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers)
|
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 {
|
match res {
|
||||||
Ok(items) => {
|
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 {
|
for e in items {
|
||||||
entries.push(e);
|
entries.push(e);
|
||||||
}
|
}
|
||||||
|
// record summary row
|
||||||
|
summary.push((display_name, speaker.clone(), true, started_at.elapsed()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) {
|
if !polyscribe::is_no_interaction() && polyscribe::stdin_is_tty() {
|
||||||
polyscribe::elog!("{:#}", e);
|
polyscribe::elog!("{:#}", e);
|
||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
@@ -519,9 +707,18 @@ fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
} else if is_json_file(path) {
|
} else if is_json_file(path) {
|
||||||
File::open(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)
|
.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 {
|
} else {
|
||||||
return Err(anyhow!(format!(
|
return Err(anyhow!(format!(
|
||||||
"Unsupported input type (expected .json or audio media): {}",
|
"Unsupported input type (expected .json or audio media): {}",
|
||||||
@@ -530,7 +727,7 @@ fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let root: InputRoot = serde_json::from_str(&buf)
|
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 {
|
for seg in root.segments {
|
||||||
entries.push(OutputEntry {
|
entries.push(OutputEntry {
|
||||||
@@ -576,7 +773,7 @@ fn run() -> Result<()> {
|
|||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("output");
|
.unwrap_or("output");
|
||||||
let date = date_prefix();
|
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 dir = parent_opt.unwrap_or(Path::new(""));
|
||||||
let json_path = dir.join(format!("{}.json", &base_name));
|
let json_path = dir.join(format!("{}.json", &base_name));
|
||||||
let toml_path = dir.join(format!("{}.toml", &base_name));
|
let toml_path = dir.join(format!("{}.toml", &base_name));
|
||||||
@@ -607,6 +804,24 @@ fn run() -> Result<()> {
|
|||||||
serde_json::to_writer_pretty(&mut handle, &out)?;
|
serde_json::to_writer_pretty(&mut handle, &out)?;
|
||||||
writeln!(&mut handle)?;
|
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 {
|
} else {
|
||||||
polyscribe::dlog!(1, "Mode: separate; output_dir={:?}", output_path);
|
polyscribe::dlog!(1, "Mode: separate; output_dir={:?}", output_path);
|
||||||
// SEPARATE MODE (default now)
|
// SEPARATE MODE (default now)
|
||||||
@@ -627,28 +842,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 path = Path::new(input_path);
|
||||||
let default_speaker = sanitize_speaker_name(
|
let started_at = std::time::Instant::now();
|
||||||
path.file_stem()
|
let display_name = path
|
||||||
.and_then(|s| s.to_str())
|
.file_name()
|
||||||
.unwrap_or("speaker"),
|
.and_then(|os| os.to_str())
|
||||||
);
|
.map(|s| s.to_string())
|
||||||
let speaker =
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names);
|
let item = progress.start_item(&format!("Processing: {}", path.display()));
|
||||||
|
let speaker = speakers[idx].clone();
|
||||||
|
|
||||||
// Collect entries per file
|
// Collect entries per file
|
||||||
let mut entries: Vec<OutputEntry> = Vec::new();
|
let mut entries: Vec<OutputEntry> = Vec::new();
|
||||||
if is_audio_file(path) {
|
if is_audio_file(path) {
|
||||||
// Progress log to stderr (suppressed by -q)
|
// Avoid println! while bars are active
|
||||||
polyscribe::ilog!("Processing file: {} ...", path.display());
|
if matches!(mode, polyscribe::progress::ProgressMode::None) {
|
||||||
let res = with_quiet_stdio_if_needed(args.quiet, || {
|
polyscribe::ilog!("Processing file: {} ...", path.display());
|
||||||
sel.backend
|
}
|
||||||
.transcribe(path, &speaker, lang_hint.as_deref(), args.gpu_layers)
|
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 {
|
match res {
|
||||||
Ok(items) => {
|
Ok(items) => {
|
||||||
polyscribe::ilog!("done");
|
if matches!(mode, polyscribe::progress::ProgressMode::None) {
|
||||||
|
polyscribe::ilog!("done");
|
||||||
|
}
|
||||||
entries.extend(items);
|
entries.extend(items);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -664,9 +914,8 @@ fn run() -> Result<()> {
|
|||||||
.with_context(|| format!("Failed to open: {input_path}"))?
|
.with_context(|| format!("Failed to open: {input_path}"))?
|
||||||
.read_to_string(&mut buf)
|
.read_to_string(&mut buf)
|
||||||
.with_context(|| format!("Failed to read: {input_path}"))?;
|
.with_context(|| format!("Failed to read: {input_path}"))?;
|
||||||
let root: InputRoot = serde_json::from_str(&buf).with_context(|| {
|
let root: InputRoot = serde_json::from_str(&buf)
|
||||||
format!("Invalid JSON transcript parsed from {input_path}")
|
.with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?;
|
||||||
})?;
|
|
||||||
for seg in root.segments {
|
for seg in root.segments {
|
||||||
entries.push(OutputEntry {
|
entries.push(OutputEntry {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -737,9 +986,36 @@ fn run() -> Result<()> {
|
|||||||
serde_json::to_writer_pretty(&mut handle, &out)?;
|
serde_json::to_writer_pretty(&mut handle, &out)?;
|
||||||
writeln!(&mut handle)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
156
src/models.rs
156
src/models.rs
@@ -393,130 +393,62 @@ fn format_model_list(models: &[ModelEntry]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntry>> {
|
fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntry>> {
|
||||||
|
// Non-interactive safeguard: return empty (caller will handle as cancel/skip)
|
||||||
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||||
// Non-interactive: do not prompt, return empty selection to skip
|
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
// 1) Choose base (tiny, small, medium, etc.)
|
|
||||||
|
if models.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 1: pick a base family; preserve order from input list
|
||||||
let mut bases: Vec<String> = Vec::new();
|
let mut bases: Vec<String> = Vec::new();
|
||||||
let mut last = String::new();
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
for m in models.iter() {
|
for m in models.iter() {
|
||||||
if m.base != last {
|
if !seen.contains(&m.base) {
|
||||||
// models are sorted by base; avoid duplicates while preserving order
|
seen.insert(m.base.clone());
|
||||||
if !bases.last().map(|b| b == &m.base).unwrap_or(false) {
|
bases.push(m.base.clone());
|
||||||
bases.push(m.base.clone());
|
|
||||||
}
|
|
||||||
last = m.base.clone();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bases.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print base selection on stderr
|
let base = if bases.len() == 1 {
|
||||||
eprintln!("Available base model families:");
|
bases[0].clone()
|
||||||
for (i, b) in bases.iter().enumerate() {
|
} else {
|
||||||
eprintln!(" {}) {}", i + 1, b);
|
crate::ui::prompt_select_one("Select model family/base:", &bases)?
|
||||||
}
|
};
|
||||||
loop {
|
|
||||||
eprint!("Select base (number or name, 'q' to cancel): ");
|
// Stage 2: within base, present variants
|
||||||
io::stderr().flush().ok();
|
let mut variants: Vec<&ModelEntry> = models.iter().filter(|m| m.base == base).collect();
|
||||||
let mut line = String::new();
|
variants.sort_by_key(|m| (m.size, m.subtype.clone(), m.name.clone()));
|
||||||
io::stdin()
|
|
||||||
.read_line(&mut line)
|
let labels: Vec<String> = variants
|
||||||
.context("Failed to read base selection")?;
|
.iter()
|
||||||
let s = line.trim();
|
.map(|m| {
|
||||||
if s.eq_ignore_ascii_case("q")
|
let size_h = human_size(m.size);
|
||||||
|| s.eq_ignore_ascii_case("quit")
|
if let Some(sha) = &m.sha256 {
|
||||||
|| s.eq_ignore_ascii_case("exit")
|
format!("{} ({}, {}, sha: {}…)", m.name, m.subtype, size_h, &sha[..std::cmp::min(8, sha.len())])
|
||||||
{
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let chosen_base = if let Ok(i) = s.parse::<usize>() {
|
|
||||||
if i >= 1 && i <= bases.len() {
|
|
||||||
Some(bases[i - 1].clone())
|
|
||||||
} else {
|
} else {
|
||||||
None
|
format!("{} ({}, {})", m.name, m.subtype, size_h)
|
||||||
}
|
}
|
||||||
} else if !s.is_empty() {
|
})
|
||||||
// accept exact name match (case-insensitive)
|
.collect();
|
||||||
bases.iter().find(|b| b.eq_ignore_ascii_case(s)).cloned()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(base) = chosen_base {
|
let selected_labels = crate::ui::prompt_multiselect(
|
||||||
// 2) Choose sub-type(s) within that base
|
"Select one or more variants to download:",
|
||||||
let filtered: Vec<ModelEntry> =
|
&labels,
|
||||||
models.iter().filter(|m| m.base == base).cloned().collect();
|
&[],
|
||||||
if filtered.is_empty() {
|
)?;
|
||||||
eprintln!("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}");
|
|
||||||
|
|
||||||
// Build index map for filtered list
|
// Map labels back to entries in stable order
|
||||||
let mut index_map: Vec<usize> = Vec::with_capacity(filtered.len());
|
let mut picked: Vec<ModelEntry> = Vec::new();
|
||||||
let mut idx = 1usize;
|
for (i, label) in labels.iter().enumerate() {
|
||||||
for (pos, _m) in filtered.iter().enumerate() {
|
if selected_labels.iter().any(|s| s == label) {
|
||||||
index_map.push(pos);
|
picked.push(variants[i].clone().clone());
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
// Second prompt: sub-type selection
|
|
||||||
loop {
|
|
||||||
eprint!("Selection: ");
|
|
||||||
io::stderr().flush().ok();
|
|
||||||
let mut line2 = String::new();
|
|
||||||
io::stdin()
|
|
||||||
.read_line(&mut line2)
|
|
||||||
.context("Failed to read selection")?;
|
|
||||||
let s2 = line2.trim().to_lowercase();
|
|
||||||
if s2 == "q" || s2 == "quit" || s2 == "exit" {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut selected: Vec<usize> = Vec::new();
|
|
||||||
if s2 == "all" || s2 == "*" {
|
|
||||||
selected = (1..idx).collect();
|
|
||||||
} else if !s2.is_empty() {
|
|
||||||
for part in s2.split([',', ' ', ';']) {
|
|
||||||
let part = part.trim();
|
|
||||||
if part.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some((a, b)) = part.split_once('-') {
|
|
||||||
if let (Ok(ia), Ok(ib)) = (a.parse::<usize>(), b.parse::<usize>()) {
|
|
||||||
if ia >= 1 && ib < idx && ia <= ib {
|
|
||||||
selected.extend(ia..=ib);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Ok(i) = part.parse::<usize>() {
|
|
||||||
if i >= 1 && i < idx {
|
|
||||||
selected.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selected.sort_unstable();
|
|
||||||
selected.dedup();
|
|
||||||
if selected.is_empty() {
|
|
||||||
eprintln!("No valid selection. Please try again or 'q' to cancel.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let chosen: Vec<ModelEntry> = selected
|
|
||||||
.into_iter()
|
|
||||||
.map(|i| filtered[index_map[i - 1]].clone())
|
|
||||||
.collect();
|
|
||||||
return Ok(chosen);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"Invalid base selection. Please enter a number from 1-{} or a base name.",
|
|
||||||
bases.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(picked)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_file_sha256_hex(path: &Path) -> Result<String> {
|
fn compute_file_sha256_hex(path: &Path) -> Result<String> {
|
||||||
@@ -746,7 +678,9 @@ fn qlog_size_comparison(fname: &str, local: u64, remote: u64) -> bool {
|
|||||||
} else {
|
} else {
|
||||||
qlog!(
|
qlog!(
|
||||||
"{} size {} differs from remote {}. Updating...",
|
"{} size {} differs from remote {}. Updating...",
|
||||||
fname, local, remote
|
fname,
|
||||||
|
local,
|
||||||
|
remote
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
710
src/progress.rs
Normal file
710
src/progress.rs
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
// 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 {
|
||||||
|
header: ProgressBar,
|
||||||
|
info: ProgressBar,
|
||||||
|
current: ProgressBar,
|
||||||
|
// keep MultiProgress alive for suspend/println behavior
|
||||||
|
_mp: Arc<MultiProgress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MultiBars {
|
||||||
|
// Header row shown above bars
|
||||||
|
header: ProgressBar,
|
||||||
|
// Single info/status row shown under header and above bars
|
||||||
|
info: ProgressBar,
|
||||||
|
// Bars: current file and total
|
||||||
|
current: ProgressBar,
|
||||||
|
total: ProgressBar,
|
||||||
|
// Optional per-file bars and aggregated total percent bar (unused in new UX)
|
||||||
|
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 {
|
||||||
|
// Order: header, info row, then current file bar
|
||||||
|
let header = mp.add(ProgressBar::new(0));
|
||||||
|
header.set_style(info_style());
|
||||||
|
let info = mp.add(ProgressBar::new(0));
|
||||||
|
info.set_style(info_style());
|
||||||
|
let current = mp.add(ProgressBar::new(100));
|
||||||
|
current.set_style(current_style());
|
||||||
|
Self {
|
||||||
|
inner: ProgressInner::Single(Arc::new(SingleBars { header, info, current, _mp: mp })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_multi(mp: Arc<MultiProgress>, total_inputs: u64) -> Self {
|
||||||
|
// Order: header, info row, then current file bar, then total bar at the bottom
|
||||||
|
let header = mp.add(ProgressBar::new(0));
|
||||||
|
header.set_style(info_style());
|
||||||
|
let info = mp.add(ProgressBar::new(0));
|
||||||
|
info.set_style(info_style());
|
||||||
|
let current = mp.add(ProgressBar::new(100));
|
||||||
|
current.set_style(current_style());
|
||||||
|
let total = mp.add(ProgressBar::new(total_inputs));
|
||||||
|
total.set_style(total_style());
|
||||||
|
Self {
|
||||||
|
inner: ProgressInner::Multi(Arc::new(MultiBars {
|
||||||
|
header,
|
||||||
|
info,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
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 current_style() -> ProgressStyle {
|
||||||
|
// Per-item determinate progress: show 0..100 as pos/len with a simple bar
|
||||||
|
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {pos}/{len} {bar:40.cyan/blue} {msg}")
|
||||||
|
.expect("invalid progress template in current_style()")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_style() -> ProgressStyle {
|
||||||
|
ProgressStyle::with_template("{msg}").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_style() -> ProgressStyle {
|
||||||
|
// Bottom total bar with elapsed time
|
||||||
|
ProgressStyle::with_template("Total [{bar:28=> }] {pos}/{len} [{elapsed_precise}]").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(current_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 + ...
|
||||||
|
}
|
||||||
|
}
|
93
src/ui.rs
Normal file
93
src/ui.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Centralized UI helpers for interactive prompts.
|
||||||
|
// Uses cliclack for consistent TTY-friendly UX.
|
||||||
|
//
|
||||||
|
// If you need a new prompt type, add it here so callers don't depend on a specific library.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
|
/// Prompt the user for a free-text value with a default fallback.
|
||||||
|
///
|
||||||
|
/// - Uses cliclack Input to render a TTY-friendly prompt.
|
||||||
|
/// - Returns `default` when the user submits an empty value.
|
||||||
|
/// - On any prompt error (e.g., non-TTY, read error), returns an error; callers should
|
||||||
|
/// handle it and typically fall back to `default` in non-interactive contexts.
|
||||||
|
pub fn prompt_text(prompt: &str, default: &str) -> Result<String> {
|
||||||
|
let res: Result<String, _> = cliclack::input(prompt)
|
||||||
|
.default_input(default)
|
||||||
|
.interact();
|
||||||
|
let value = res.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||||
|
|
||||||
|
let trimmed = value.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for yes/no confirmation with a default choice.
|
||||||
|
///
|
||||||
|
/// Returns the selected boolean. Any underlying prompt error is returned as an error.
|
||||||
|
pub fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||||
|
let res: Result<bool, _> = cliclack::confirm(prompt)
|
||||||
|
.initial_value(default)
|
||||||
|
.interact();
|
||||||
|
res.map_err(|e| anyhow!("prompt error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-select from a list of displayable items, returning the selected index.
|
||||||
|
///
|
||||||
|
/// - `items`: non-empty slice of displayable items.
|
||||||
|
/// - Returns the index into `items`.
|
||||||
|
pub fn prompt_select_index<T: std::fmt::Display>(prompt: &str, items: &[T]) -> Result<usize> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(anyhow!("prompt_select_index called with empty items"));
|
||||||
|
}
|
||||||
|
let mut sel = cliclack::select(prompt);
|
||||||
|
for (i, it) in items.iter().enumerate() {
|
||||||
|
sel = sel.item(i, format!("{}", it), "");
|
||||||
|
}
|
||||||
|
let idx: usize = sel
|
||||||
|
.interact()
|
||||||
|
.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||||
|
Ok(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-select from a list of clonable displayable items, returning the chosen item.
|
||||||
|
pub fn prompt_select_one<T: std::fmt::Display + Clone>(prompt: &str, items: &[T]) -> Result<T> {
|
||||||
|
let idx = prompt_select_index(prompt, items)?;
|
||||||
|
Ok(items[idx].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-select from a list, returning the selected indices.
|
||||||
|
///
|
||||||
|
/// - `defaults`: indices that should be pre-selected.
|
||||||
|
pub fn prompt_multiselect_indices<T: std::fmt::Display>(
|
||||||
|
prompt: &str,
|
||||||
|
items: &[T],
|
||||||
|
defaults: &[usize],
|
||||||
|
) -> Result<Vec<usize>> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(anyhow!("prompt_multiselect_indices called with empty items"));
|
||||||
|
}
|
||||||
|
let mut ms = cliclack::multiselect(prompt);
|
||||||
|
for (i, it) in items.iter().enumerate() {
|
||||||
|
ms = ms.item(i, format!("{}", it), "");
|
||||||
|
}
|
||||||
|
let indices: Vec<usize> = ms
|
||||||
|
.initial_values(defaults.to_vec())
|
||||||
|
.required(false)
|
||||||
|
.interact()
|
||||||
|
.map_err(|e| anyhow!("prompt error: {e}"))?;
|
||||||
|
Ok(indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-select from a list, returning the chosen items in order of appearance.
|
||||||
|
pub fn prompt_multiselect<T: std::fmt::Display + Clone>(
|
||||||
|
prompt: &str,
|
||||||
|
items: &[T],
|
||||||
|
defaults: &[usize],
|
||||||
|
) -> Result<Vec<T>> {
|
||||||
|
let indices = prompt_multiselect_indices(prompt, items, defaults)?;
|
||||||
|
Ok(indices.into_iter().map(|i| items[i].clone()).collect())
|
||||||
|
}
|
91
tests/progress.rs
Normal file
91
tests/progress.rs
Normal file
@@ -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);
|
||||||
|
}
|
30
tests/progress_manager.rs
Normal file
30
tests/progress_manager.rs
Normal file
@@ -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();
|
||||||
|
}
|
86
tests/prompt_spacing.rs
Normal file
86
tests/prompt_spacing.rs
Normal file
@@ -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<String> {
|
||||||
|
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);
|
||||||
|
}
|
Reference in New Issue
Block a user