Compare commits
13 Commits
main
...
5ace0a0d7e
Author | SHA1 | Date | |
---|---|---|---|
5ace0a0d7e | |||
ed3af9210f | |||
79397a3b9c | |||
9fd44a2e37 | |||
a987a3fcfb | |||
f41f1a4117 | |||
75cfb6f160 | |||
8ebdf876ed | |||
eb1bf9e02d | |||
9b4bd545dd | |||
041e504cb2 | |||
2cc5e49131 | |||
4916aa6224 |
153
Cargo.lock
generated
153
Cargo.lock
generated
@@ -93,9 +93,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.98"
|
version = "1.0.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
@@ -179,9 +179,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.31"
|
version = "1.2.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2"
|
checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
@@ -228,9 +228,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.43"
|
version = "4.5.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
|
checksum = "1c1f056bae57e3e54c3375c41ff79619ddd13460a17d7438712bd0d83fda4ff8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -238,9 +238,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.43"
|
version = "4.5.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
|
checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -250,9 +250,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_complete"
|
name = "clap_complete"
|
||||||
version = "4.5.56"
|
version = "4.5.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67e4efcbb5da11a92e8a609233aa1e8a7d91e38de0be865f016d14700d45a7fd"
|
checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
]
|
]
|
||||||
@@ -285,6 +285,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 +314,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"
|
||||||
@@ -362,6 +389,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"
|
||||||
@@ -520,9 +553,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
@@ -814,6 +847,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"
|
||||||
@@ -874,9 +920,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.174"
|
version = "0.2.175"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
|
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
@@ -980,6 +1026,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 +1130,8 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"clap_mangen",
|
"clap_mangen",
|
||||||
|
"cliclack",
|
||||||
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1088,6 +1142,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"
|
||||||
@@ -1109,9 +1169,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.95"
|
version = "1.0.97"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -1282,9 +1342,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.21"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
@@ -1396,9 +1456,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.10"
|
version = "0.4.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
|
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
@@ -1406,6 +1466,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 +1565,17 @@ 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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1682,6 +1759,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 +1917,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 +2246,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
|
||||||
@@ -29,6 +28,8 @@ 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" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
cliclack = "0.3"
|
||||||
|
indicatif = "0.17"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
@@ -28,6 +28,7 @@ Installation
|
|||||||
Quickstart
|
Quickstart
|
||||||
1) Download a model (first run can prompt you):
|
1) Download a model (first run can prompt you):
|
||||||
- ./target/release/polyscribe --download-models
|
- ./target/release/polyscribe --download-models
|
||||||
|
- In the interactive picker, use Up/Down to navigate, Space to toggle selections, and Enter to confirm. Models are grouped by base (e.g., tiny, base, small).
|
||||||
|
|
||||||
2) Transcribe a file:
|
2) Transcribe a file:
|
||||||
- ./target/release/polyscribe -v -o output my_audio.mp3
|
- ./target/release/polyscribe -v -o output my_audio.mp3
|
||||||
|
@@ -32,6 +32,7 @@ CLI reference
|
|||||||
- Number of layers to offload to the GPU when supported.
|
- Number of layers to offload to the GPU when supported.
|
||||||
- --download-models
|
- --download-models
|
||||||
- Launch interactive model downloader (lists Hugging Face models; multi-select to download).
|
- Launch interactive model downloader (lists Hugging Face models; multi-select to download).
|
||||||
|
- Controls: Use Up/Down to navigate, Space to toggle selections, and Enter to confirm. Models are grouped by base (e.g., tiny, base, small).
|
||||||
- --update-models
|
- --update-models
|
||||||
- Verify/update local models by comparing sizes and hashes with the upstream manifest.
|
- Verify/update local models by comparing sizes and hashes with the upstream manifest.
|
||||||
- -v, --verbose (repeatable)
|
- -v, --verbose (repeatable)
|
||||||
|
208
src/backend.rs
208
src/backend.rs
@@ -24,23 +24,18 @@ pub enum BackendKind {
|
|||||||
Vulkan,
|
Vulkan,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abstraction for a transcription backend implementation.
|
/// Abstraction for a transcription backend.
|
||||||
pub trait TranscribeBackend {
|
pub trait TranscribeBackend {
|
||||||
/// Return the backend kind for this implementation.
|
/// Backend kind implemented by this type.
|
||||||
fn kind(&self) -> BackendKind;
|
fn kind(&self) -> BackendKind;
|
||||||
/// Transcribe the given audio file path and return transcript entries.
|
/// Transcribe the given audio and return transcript entries.
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - audio_path: path to input media (audio or video) to be decoded/transcribed.
|
|
||||||
/// - speaker: label to attach to all produced segments.
|
|
||||||
/// - lang_opt: optional language hint (e.g., "en"); None means auto/multilingual model default.
|
|
||||||
/// - gpu_layers: optional GPU layer count if applicable (ignored by some backends).
|
|
||||||
fn transcribe(
|
fn transcribe(
|
||||||
&self,
|
&self,
|
||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
language: Option<&str>,
|
||||||
gpu_layers: Option<u32>,
|
gpu_layers: Option<u32>,
|
||||||
|
progress: Option<&(dyn Fn(i32) + Send + Sync)>,
|
||||||
) -> Result<Vec<OutputEntry>>;
|
) -> Result<Vec<OutputEntry>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,104 +80,39 @@ fn vulkan_available() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// CPU-based transcription backend using whisper-rs.
|
/// CPU-based transcription backend using whisper-rs.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct CpuBackend;
|
pub struct CpuBackend;
|
||||||
/// CUDA-accelerated transcription backend for NVIDIA GPUs.
|
/// CUDA-accelerated transcription backend for NVIDIA GPUs.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct CudaBackend;
|
pub struct CudaBackend;
|
||||||
/// ROCm/HIP-accelerated transcription backend for AMD GPUs.
|
/// ROCm/HIP-accelerated transcription backend for AMD GPUs.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct HipBackend;
|
pub struct HipBackend;
|
||||||
/// Vulkan-based transcription backend (experimental/incomplete).
|
/// Vulkan-based transcription backend (experimental/incomplete).
|
||||||
|
#[derive(Default)]
|
||||||
pub struct VulkanBackend;
|
pub struct VulkanBackend;
|
||||||
|
|
||||||
impl CpuBackend {
|
macro_rules! impl_whisper_backend {
|
||||||
/// Create a new CPU backend instance.
|
($ty:ty, $kind:expr) => {
|
||||||
pub fn new() -> Self {
|
impl TranscribeBackend for $ty {
|
||||||
CpuBackend
|
fn kind(&self) -> BackendKind { $kind }
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Default for CpuBackend {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl CudaBackend {
|
|
||||||
/// Create a new CUDA backend instance.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
CudaBackend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Default for CudaBackend {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl HipBackend {
|
|
||||||
/// Create a new HIP backend instance.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
HipBackend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Default for HipBackend {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl VulkanBackend {
|
|
||||||
/// Create a new Vulkan backend instance.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
VulkanBackend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Default for VulkanBackend {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TranscribeBackend for CpuBackend {
|
|
||||||
fn kind(&self) -> BackendKind {
|
|
||||||
BackendKind::Cpu
|
|
||||||
}
|
|
||||||
fn transcribe(
|
fn transcribe(
|
||||||
&self,
|
&self,
|
||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
language: Option<&str>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
|
progress: Option<&(dyn Fn(i32) + Send + Sync)>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> Result<Vec<OutputEntry>> {
|
||||||
transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
|
transcribe_with_whisper_rs(audio_path, speaker, language, progress)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscribeBackend for CudaBackend {
|
impl_whisper_backend!(CpuBackend, BackendKind::Cpu);
|
||||||
fn kind(&self) -> BackendKind {
|
impl_whisper_backend!(CudaBackend, BackendKind::Cuda);
|
||||||
BackendKind::Cuda
|
impl_whisper_backend!(HipBackend, BackendKind::Hip);
|
||||||
}
|
|
||||||
fn transcribe(
|
|
||||||
&self,
|
|
||||||
audio_path: &Path,
|
|
||||||
speaker: &str,
|
|
||||||
lang_opt: Option<&str>,
|
|
||||||
_gpu_layers: Option<u32>,
|
|
||||||
) -> Result<Vec<OutputEntry>> {
|
|
||||||
// whisper-rs uses enabled CUDA feature at build time; call same code path
|
|
||||||
transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TranscribeBackend for HipBackend {
|
|
||||||
fn kind(&self) -> BackendKind {
|
|
||||||
BackendKind::Hip
|
|
||||||
}
|
|
||||||
fn transcribe(
|
|
||||||
&self,
|
|
||||||
audio_path: &Path,
|
|
||||||
speaker: &str,
|
|
||||||
lang_opt: Option<&str>,
|
|
||||||
_gpu_layers: Option<u32>,
|
|
||||||
) -> Result<Vec<OutputEntry>> {
|
|
||||||
transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TranscribeBackend for VulkanBackend {
|
impl TranscribeBackend for VulkanBackend {
|
||||||
fn kind(&self) -> BackendKind {
|
fn kind(&self) -> BackendKind {
|
||||||
@@ -192,8 +122,9 @@ impl TranscribeBackend for VulkanBackend {
|
|||||||
&self,
|
&self,
|
||||||
_audio_path: &Path,
|
_audio_path: &Path,
|
||||||
_speaker: &str,
|
_speaker: &str,
|
||||||
_lang_opt: Option<&str>,
|
_language: Option<&str>,
|
||||||
_gpu_layers: Option<u32>,
|
_gpu_layers: Option<u32>,
|
||||||
|
_progress: Option<&(dyn Fn(i32) + Send + Sync)>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> Result<Vec<OutputEntry>> {
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Vulkan backend not yet wired to whisper.cpp FFI. Build with --features gpu-vulkan and ensure Vulkan SDK is installed. How to fix: install Vulkan loader (libvulkan), set VULKAN_SDK, and run cargo build --features gpu-vulkan."
|
"Vulkan backend not yet wired to whisper.cpp FFI. Build with --features gpu-vulkan and ensure Vulkan SDK is installed. How to fix: install Vulkan loader (libvulkan), set VULKAN_SDK, and run cargo build --features gpu-vulkan."
|
||||||
@@ -231,13 +162,13 @@ pub fn select_backend(requested: BackendKind, verbose: bool) -> Result<Selection
|
|||||||
detected.push(BackendKind::Vulkan);
|
detected.push(BackendKind::Vulkan);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mk = |k: BackendKind| -> Box<dyn TranscribeBackend + Send + Sync> {
|
let instantiate_backend = |k: BackendKind| -> Box<dyn TranscribeBackend + Send + Sync> {
|
||||||
match k {
|
match k {
|
||||||
BackendKind::Cpu => Box::new(CpuBackend::new()),
|
BackendKind::Cpu => Box::new(CpuBackend::default()),
|
||||||
BackendKind::Cuda => Box::new(CudaBackend::new()),
|
BackendKind::Cuda => Box::new(CudaBackend::default()),
|
||||||
BackendKind::Hip => Box::new(HipBackend::new()),
|
BackendKind::Hip => Box::new(HipBackend::default()),
|
||||||
BackendKind::Vulkan => Box::new(VulkanBackend::new()),
|
BackendKind::Vulkan => Box::new(VulkanBackend::default()),
|
||||||
BackendKind::Auto => Box::new(CpuBackend::new()), // will be replaced
|
BackendKind::Auto => Box::new(CpuBackend::default()), // placeholder for Auto
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,7 +220,7 @@ pub fn select_backend(requested: BackendKind, verbose: bool) -> Result<Selection
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(SelectionResult {
|
Ok(SelectionResult {
|
||||||
backend: mk(chosen),
|
backend: instantiate_backend(chosen),
|
||||||
chosen,
|
chosen,
|
||||||
detected,
|
detected,
|
||||||
})
|
})
|
||||||
@@ -300,88 +231,99 @@ pub fn select_backend(requested: BackendKind, verbose: bool) -> Result<Selection
|
|||||||
pub(crate) fn transcribe_with_whisper_rs(
|
pub(crate) fn transcribe_with_whisper_rs(
|
||||||
audio_path: &Path,
|
audio_path: &Path,
|
||||||
speaker: &str,
|
speaker: &str,
|
||||||
lang_opt: Option<&str>,
|
language: Option<&str>,
|
||||||
|
progress: Option<&(dyn Fn(i32) + Send + Sync)>,
|
||||||
) -> Result<Vec<OutputEntry>> {
|
) -> Result<Vec<OutputEntry>> {
|
||||||
let pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?;
|
let report = |p: i32| {
|
||||||
let model = find_model_file()?;
|
if let Some(cb) = progress { cb(p); }
|
||||||
let is_en_only = model
|
};
|
||||||
|
report(0);
|
||||||
|
|
||||||
|
let pcm_samples = decode_audio_to_pcm_f32_ffmpeg(audio_path)?;
|
||||||
|
report(5);
|
||||||
|
|
||||||
|
let model_path = find_model_file()?;
|
||||||
|
let english_only_model = model_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.map(|s| s.contains(".en.") || s.ends_with(".en.bin"))
|
.map(|s| s.contains(".en.") || s.ends_with(".en.bin"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if let Some(lang) = lang_opt {
|
if let Some(lang) = language {
|
||||||
if is_en_only && lang != "en" {
|
if english_only_model && lang != "en" {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Selected model is English-only ({}), but a non-English language hint '{}' was provided. Please use a multilingual model or set WHISPER_MODEL.",
|
"Selected model is English-only ({}), but a non-English language hint '{}' was provided. Please use a multilingual model or set WHISPER_MODEL.",
|
||||||
model.display(),
|
model_path.display(),
|
||||||
lang
|
lang
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let model_str = model
|
let model_path_str = model_path
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| anyhow!("Model path not valid UTF-8: {}", model.display()))?;
|
.ok_or_else(|| anyhow!("Model path not valid UTF-8: {}", model_path.display()))?;
|
||||||
|
|
||||||
// Try to reduce native library logging via environment variables when not super-verbose.
|
|
||||||
if crate::verbose_level() < 2 {
|
if crate::verbose_level() < 2 {
|
||||||
// These env vars are recognized by ggml/whisper in many builds; harmless if unknown.
|
// Some builds of whisper/ggml expect these env vars; harmless if unknown
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("GGML_LOG_LEVEL", "0");
|
std::env::set_var("GGML_LOG_LEVEL", "0");
|
||||||
std::env::set_var("WHISPER_PRINT_PROGRESS", "0");
|
std::env::set_var("WHISPER_PRINT_PROGRESS", "0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress stderr from whisper/ggml during model load and inference when quiet and not verbose.
|
let (_context, mut state) = crate::with_suppressed_stderr(|| {
|
||||||
let (_ctx, mut state) = crate::with_suppressed_stderr(|| {
|
let params = whisper_rs::WhisperContextParameters::default();
|
||||||
let cparams = whisper_rs::WhisperContextParameters::default();
|
let context = whisper_rs::WhisperContext::new_with_params(model_path_str, params)
|
||||||
let ctx = whisper_rs::WhisperContext::new_with_params(model_str, cparams)
|
.with_context(|| format!("Failed to load Whisper model at {}", model_path.display()))?;
|
||||||
.with_context(|| format!("Failed to load Whisper model at {}", model.display()))?;
|
let state = context
|
||||||
let state = ctx
|
|
||||||
.create_state()
|
.create_state()
|
||||||
.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>((context, state))
|
||||||
})?;
|
})?;
|
||||||
|
report(20);
|
||||||
|
|
||||||
let mut params =
|
let mut full_params =
|
||||||
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
||||||
let n_threads = std::thread::available_parallelism()
|
let threads = std::thread::available_parallelism()
|
||||||
.map(|n| n.get() as i32)
|
.map(|n| n.get() as i32)
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
params.set_n_threads(n_threads);
|
full_params.set_n_threads(threads);
|
||||||
params.set_translate(false);
|
full_params.set_translate(false);
|
||||||
if let Some(lang) = lang_opt {
|
if let Some(lang) = language {
|
||||||
params.set_language(Some(lang));
|
full_params.set_language(Some(lang));
|
||||||
}
|
}
|
||||||
|
report(30);
|
||||||
|
|
||||||
crate::with_suppressed_stderr(|| {
|
crate::with_suppressed_stderr(|| {
|
||||||
|
report(40);
|
||||||
state
|
state
|
||||||
.full(params, &pcm)
|
.full(full_params, &pcm_samples)
|
||||||
.map_err(|e| anyhow!("Whisper full() failed: {:?}", e))
|
.map_err(|e| anyhow!("Whisper full() failed: {:?}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
report(90);
|
||||||
let num_segments = state
|
let num_segments = state
|
||||||
.full_n_segments()
|
.full_n_segments()
|
||||||
.map_err(|e| anyhow!("Failed to get segments: {:?}", e))?;
|
.map_err(|e| anyhow!("Failed to get segments: {:?}", e))?;
|
||||||
let mut items = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for i in 0..num_segments {
|
for seg_idx in 0..num_segments {
|
||||||
let text = state
|
let segment_text = state
|
||||||
.full_get_segment_text(i)
|
.full_get_segment_text(seg_idx)
|
||||||
.map_err(|e| anyhow!("Failed to get segment text: {:?}", e))?;
|
.map_err(|e| anyhow!("Failed to get segment text: {:?}", e))?;
|
||||||
let t0 = state
|
let t0 = state
|
||||||
.full_get_segment_t0(i)
|
.full_get_segment_t0(seg_idx)
|
||||||
.map_err(|e| anyhow!("Failed to get segment t0: {:?}", e))?;
|
.map_err(|e| anyhow!("Failed to get segment t0: {:?}", e))?;
|
||||||
let t1 = state
|
let t1 = state
|
||||||
.full_get_segment_t1(i)
|
.full_get_segment_t1(seg_idx)
|
||||||
.map_err(|e| anyhow!("Failed to get segment t1: {:?}", e))?;
|
.map_err(|e| anyhow!("Failed to get segment t1: {:?}", e))?;
|
||||||
let start = (t0 as f64) * 0.01;
|
let start = (t0 as f64) * 0.01;
|
||||||
let end = (t1 as f64) * 0.01;
|
let end = (t1 as f64) * 0.01;
|
||||||
items.push(OutputEntry {
|
entries.push(OutputEntry {
|
||||||
id: 0,
|
id: 0,
|
||||||
speaker: speaker.to_string(),
|
speaker: speaker.to_string(),
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
text: text.trim().to_string(),
|
text: segment_text.trim().to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(items)
|
report(100);
|
||||||
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
244
src/lib.rs
244
src/lib.rs
@@ -4,9 +4,6 @@
|
|||||||
#![forbid(elided_lifetimes_in_paths)]
|
#![forbid(elided_lifetimes_in_paths)]
|
||||||
#![forbid(unused_must_use)]
|
#![forbid(unused_must_use)]
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
// Lint policy for incremental refactor toward 2024:
|
|
||||||
// - Keep basic clippy warnings enabled; skip pedantic/nursery for now (will revisit in step 7).
|
|
||||||
// - cargo lints can be re-enabled later once codebase is tidied.
|
|
||||||
#![warn(clippy::all)]
|
#![warn(clippy::all)]
|
||||||
//! PolyScribe library: business logic and core types.
|
//! PolyScribe library: business logic and core types.
|
||||||
//!
|
//!
|
||||||
@@ -19,10 +16,11 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
|||||||
static QUIET: AtomicBool = AtomicBool::new(false);
|
static QUIET: AtomicBool = AtomicBool::new(false);
|
||||||
static NO_INTERACTION: AtomicBool = AtomicBool::new(false);
|
static NO_INTERACTION: AtomicBool = AtomicBool::new(false);
|
||||||
static VERBOSE: AtomicU8 = AtomicU8::new(0);
|
static VERBOSE: AtomicU8 = AtomicU8::new(0);
|
||||||
|
static NO_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Set quiet mode: when true, non-interactive logs should be suppressed.
|
/// Set quiet mode: when true, non-interactive logs should be suppressed.
|
||||||
pub fn set_quiet(q: bool) {
|
pub fn set_quiet(enabled: bool) {
|
||||||
QUIET.store(q, Ordering::Relaxed);
|
QUIET.store(enabled, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
/// Return current quiet mode state.
|
/// Return current quiet mode state.
|
||||||
pub fn is_quiet() -> bool {
|
pub fn is_quiet() -> bool {
|
||||||
@@ -30,8 +28,8 @@ pub fn is_quiet() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set non-interactive mode: when true, interactive prompts must be skipped.
|
/// Set non-interactive mode: when true, interactive prompts must be skipped.
|
||||||
pub fn set_no_interaction(b: bool) {
|
pub fn set_no_interaction(enabled: bool) {
|
||||||
NO_INTERACTION.store(b, Ordering::Relaxed);
|
NO_INTERACTION.store(enabled, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
/// Return current non-interactive state.
|
/// Return current non-interactive state.
|
||||||
pub fn is_no_interaction() -> bool {
|
pub fn is_no_interaction() -> bool {
|
||||||
@@ -47,19 +45,19 @@ pub fn verbose_level() -> u8 {
|
|||||||
VERBOSE.load(Ordering::Relaxed)
|
VERBOSE.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disable interactive progress indicators (bars/spinners)
|
||||||
|
pub fn set_no_progress(enabled: bool) {
|
||||||
|
NO_PROGRESS.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
/// Return current no-progress state
|
||||||
|
pub fn is_no_progress() -> bool {
|
||||||
|
NO_PROGRESS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check whether stdin is connected to a TTY. Used to avoid blocking prompts when not interactive.
|
/// Check whether stdin is connected to a TTY. Used to avoid blocking prompts when not interactive.
|
||||||
pub fn stdin_is_tty() -> bool {
|
pub fn stdin_is_tty() -> bool {
|
||||||
#[cfg(unix)]
|
use std::io::IsTerminal as _;
|
||||||
{
|
std::io::stdin().is_terminal()
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
unsafe { libc::isatty(std::io::stdin().as_raw_fd()) == 1 }
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
// Best-effort on non-Unix: assume TTY when not redirected by common CI vars
|
|
||||||
// This avoids introducing a new dependency for atty.
|
|
||||||
!(std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A guard that temporarily redirects stderr to /dev/null on Unix when quiet mode is active.
|
/// A guard that temporarily redirects stderr to /dev/null on Unix when quiet mode is active.
|
||||||
@@ -91,7 +89,6 @@ impl StderrSilencer {
|
|||||||
pub fn activate() -> Self {
|
pub fn activate() -> Self {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
// Duplicate current stderr (fd 2)
|
|
||||||
let old_fd = dup(2);
|
let old_fd = dup(2);
|
||||||
if old_fd < 0 {
|
if old_fd < 0 {
|
||||||
return Self {
|
return Self {
|
||||||
@@ -102,9 +99,8 @@ impl StderrSilencer {
|
|||||||
}
|
}
|
||||||
// Open /dev/null for writing
|
// Open /dev/null for writing
|
||||||
let devnull_cstr = std::ffi::CString::new("/dev/null").unwrap();
|
let devnull_cstr = std::ffi::CString::new("/dev/null").unwrap();
|
||||||
let dn = open(devnull_cstr.as_ptr(), O_WRONLY);
|
let devnull_fd = open(devnull_cstr.as_ptr(), O_WRONLY);
|
||||||
if dn < 0 {
|
if devnull_fd < 0 {
|
||||||
// failed to open devnull; restore and bail
|
|
||||||
close(old_fd);
|
close(old_fd);
|
||||||
return Self {
|
return Self {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -112,9 +108,8 @@ impl StderrSilencer {
|
|||||||
devnull_fd: -1,
|
devnull_fd: -1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Redirect fd 2 to devnull
|
if dup2(devnull_fd, 2) < 0 {
|
||||||
if dup2(dn, 2) < 0 {
|
close(devnull_fd);
|
||||||
close(dn);
|
|
||||||
close(old_fd);
|
close(old_fd);
|
||||||
return Self {
|
return Self {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -125,7 +120,7 @@ impl StderrSilencer {
|
|||||||
Self {
|
Self {
|
||||||
active: true,
|
active: true,
|
||||||
old_stderr_fd: old_fd,
|
old_stderr_fd: old_fd,
|
||||||
devnull_fd: dn,
|
devnull_fd: devnull_fd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -142,7 +137,6 @@ impl Drop for StderrSilencer {
|
|||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
// Restore old stderr and close devnull and old copies
|
|
||||||
let _ = dup2(self.old_stderr_fd, 2);
|
let _ = dup2(self.old_stderr_fd, 2);
|
||||||
let _ = close(self.devnull_fd);
|
let _ = close(self.devnull_fd);
|
||||||
let _ = close(self.old_stderr_fd);
|
let _ = close(self.old_stderr_fd);
|
||||||
@@ -160,63 +154,52 @@ where
|
|||||||
{
|
{
|
||||||
// Suppress noisy native logs unless super-verbose (-vv) is enabled.
|
// Suppress noisy native logs unless super-verbose (-vv) is enabled.
|
||||||
if verbose_level() < 2 {
|
if verbose_level() < 2 {
|
||||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
let _guard = StderrSilencer::activate();
|
let _guard = StderrSilencer::activate();
|
||||||
f()
|
f()
|
||||||
}));
|
}));
|
||||||
match res {
|
match result {
|
||||||
Ok(v) => v,
|
Ok(value) => value,
|
||||||
Err(p) => std::panic::resume_unwind(p),
|
Err(panic_payload) => std::panic::resume_unwind(panic_payload),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Centralized UI helpers (TTY-aware, quiet/verbose-aware)
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
/// Logging macros and helpers
|
/// Logging macros and helpers
|
||||||
/// Log an error to stderr (always printed). Recommended for user-visible errors.
|
/// Log an error using the UI helper (always printed). Recommended for user-visible errors.
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! elog {
|
macro_rules! elog {
|
||||||
($($arg:tt)*) => {{
|
($($arg:tt)*) => {{
|
||||||
eprintln!("ERROR: {}", format!($($arg)*));
|
$crate::ui::error(format!($($arg)*));
|
||||||
}}
|
|
||||||
}
|
|
||||||
/// Internal helper macro used by other logging macros to centralize the
|
|
||||||
/// common behavior: build formatted message, check quiet/verbose flags,
|
|
||||||
/// and print to stderr with a label.
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! log_with_level {
|
|
||||||
($label:expr, $min_lvl:expr, $always:expr, $($arg:tt)*) => {{
|
|
||||||
let should_print = if $always {
|
|
||||||
true
|
|
||||||
} else if let Some(minv) = $min_lvl {
|
|
||||||
!$crate::is_quiet() && $crate::verbose_level() >= minv
|
|
||||||
} else {
|
|
||||||
!$crate::is_quiet()
|
|
||||||
};
|
|
||||||
if should_print {
|
|
||||||
eprintln!("{}: {}", $label, format!($($arg)*));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a warning to stderr (printed even in quiet mode).
|
/// Log a warning using the UI helper (printed even in quiet mode).
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! wlog {
|
macro_rules! wlog {
|
||||||
($($arg:tt)*) => {{ $crate::log_with_level!("WARN", None, true, $($arg)*); }}
|
($($arg:tt)*) => {{
|
||||||
|
$crate::ui::warn(format!($($arg)*));
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log an informational line to stderr unless quiet mode is enabled.
|
/// Log an informational line using the UI helper unless quiet mode is enabled.
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! ilog {
|
macro_rules! ilog {
|
||||||
($($arg:tt)*) => {{ $crate::log_with_level!("INFO", None, false, $($arg)*); }}
|
($($arg:tt)*) => {{
|
||||||
|
if !$crate::is_quiet() { $crate::ui::info(format!($($arg)*)); }
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a debug/trace line when verbose level is at least the given level (u8).
|
/// Log a debug/trace line when verbose level is at least the given level (u8).
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! dlog {
|
macro_rules! dlog {
|
||||||
($lvl:expr, $($arg:tt)*) => {{
|
($lvl:expr, $($arg:tt)*) => {{
|
||||||
$crate::log_with_level!(&format!("DEBUG{}", &$lvl), Some($lvl), false, $($arg)*);
|
if !$crate::is_quiet() && $crate::verbose_level() >= $lvl { $crate::ui::info(format!("DEBUG{}: {}", $lvl, format!($($arg)*))); }
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +213,6 @@ 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::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -267,39 +249,39 @@ pub fn format_srt_time(seconds: f64) -> String {
|
|||||||
let total_ms = (seconds * 1000.0).round() as i64;
|
let total_ms = (seconds * 1000.0).round() as i64;
|
||||||
let ms = total_ms % 1000;
|
let ms = total_ms % 1000;
|
||||||
let total_secs = total_ms / 1000;
|
let total_secs = total_ms / 1000;
|
||||||
let s = total_secs % 60;
|
let sec = total_secs % 60;
|
||||||
let m = (total_secs / 60) % 60;
|
let min = (total_secs / 60) % 60;
|
||||||
let h = total_secs / 3600;
|
let hour = total_secs / 3600;
|
||||||
format!("{h:02}:{m:02}:{s:02},{ms:03}")
|
format!("{hour:02}:{min:02}:{sec:02},{ms:03}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a list of transcript entries to SRT format.
|
/// Render a list of transcript entries to SRT format.
|
||||||
pub fn render_srt(items: &[OutputEntry]) -> String {
|
pub fn render_srt(entries: &[OutputEntry]) -> String {
|
||||||
let mut out = String::new();
|
let mut srt = String::new();
|
||||||
for (i, e) in items.iter().enumerate() {
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
let idx = i + 1;
|
let srt_index = index + 1;
|
||||||
out.push_str(&format!("{idx}\n"));
|
srt.push_str(&format!("{srt_index}\n"));
|
||||||
out.push_str(&format!(
|
srt.push_str(&format!(
|
||||||
"{} --> {}\n",
|
"{} --> {}\n",
|
||||||
format_srt_time(e.start),
|
format_srt_time(entry.start),
|
||||||
format_srt_time(e.end)
|
format_srt_time(entry.end)
|
||||||
));
|
));
|
||||||
if !e.speaker.is_empty() {
|
if !entry.speaker.is_empty() {
|
||||||
out.push_str(&format!("{}: {}\n", e.speaker, e.text));
|
srt.push_str(&format!("{}: {}\n", entry.speaker, entry.text));
|
||||||
} else {
|
} else {
|
||||||
out.push_str(&format!("{}\n", e.text));
|
srt.push_str(&format!("{}\n", entry.text));
|
||||||
}
|
}
|
||||||
out.push('\n');
|
srt.push('\n');
|
||||||
}
|
}
|
||||||
out
|
srt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the default models directory, honoring POLYSCRIBE_MODELS_DIR override.
|
/// Determine the default models directory, honoring POLYSCRIBE_MODELS_DIR override.
|
||||||
pub fn models_dir_path() -> PathBuf {
|
pub fn models_dir_path() -> PathBuf {
|
||||||
if let Ok(p) = env::var("POLYSCRIBE_MODELS_DIR") {
|
if let Ok(env_val) = env::var("POLYSCRIBE_MODELS_DIR") {
|
||||||
let pb = PathBuf::from(p);
|
let env_path = PathBuf::from(env_val);
|
||||||
if !pb.as_os_str().is_empty() {
|
if !env_path.as_os_str().is_empty() {
|
||||||
return pb;
|
return env_path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
@@ -324,17 +306,17 @@ pub fn models_dir_path() -> PathBuf {
|
|||||||
|
|
||||||
/// Normalize a language identifier to a short ISO code when possible.
|
/// Normalize a language identifier to a short ISO code when possible.
|
||||||
pub fn normalize_lang_code(input: &str) -> Option<String> {
|
pub fn normalize_lang_code(input: &str) -> Option<String> {
|
||||||
let mut s = input.trim().to_lowercase();
|
let mut lang = input.trim().to_lowercase();
|
||||||
if s.is_empty() || s == "auto" || s == "c" || s == "posix" {
|
if lang.is_empty() || lang == "auto" || lang == "c" || lang == "posix" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some((lhs, _)) = s.split_once('.') {
|
if let Some((prefix, _)) = lang.split_once('.') {
|
||||||
s = lhs.to_string();
|
lang = prefix.to_string();
|
||||||
}
|
}
|
||||||
if let Some((lhs, _)) = s.split_once('_') {
|
if let Some((prefix, _)) = lang.split_once('_') {
|
||||||
s = lhs.to_string();
|
lang = prefix.to_string();
|
||||||
}
|
}
|
||||||
let code = match s.as_str() {
|
let code = match lang.as_str() {
|
||||||
"en" => "en",
|
"en" => "en",
|
||||||
"de" => "de",
|
"de" => "de",
|
||||||
"es" => "es",
|
"es" => "es",
|
||||||
@@ -408,10 +390,10 @@ pub fn find_model_file() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(env_model) = env::var("WHISPER_MODEL") {
|
if let Ok(env_model) = env::var("WHISPER_MODEL") {
|
||||||
let p = PathBuf::from(env_model);
|
let model_path = PathBuf::from(env_model);
|
||||||
if p.is_file() {
|
if model_path.is_file() {
|
||||||
let _ = std::fs::write(models_dir.join(".last_model"), p.display().to_string());
|
let _ = std::fs::write(models_dir.join(".last_model"), model_path.display().to_string());
|
||||||
return Ok(p);
|
return Ok(model_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,9 +412,9 @@ pub fn find_model_file() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
let rd = std::fs::read_dir(models_dir)
|
let dir_entries = std::fs::read_dir(models_dir)
|
||||||
.with_context(|| format!("Failed to read models directory: {}", models_dir.display()))?;
|
.with_context(|| format!("Failed to read models directory: {}", models_dir.display()))?;
|
||||||
for entry in rd {
|
for entry in dir_entries {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
@@ -462,20 +444,17 @@ 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]: ");
|
let input = crate::ui::prompt_line("Would you like to download models now? [Y/n]: ").unwrap_or_default();
|
||||||
io::stderr().flush().ok();
|
let answer = input.trim().to_lowercase();
|
||||||
let mut input = String::new();
|
if answer.is_empty() || answer == "y" || answer == "yes" {
|
||||||
io::stdin().read_line(&mut input).ok();
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
candidates.clear();
|
candidates.clear();
|
||||||
let rd2 = std::fs::read_dir(models_dir).with_context(|| {
|
let dir_entries2 = std::fs::read_dir(models_dir).with_context(|| {
|
||||||
format!("Failed to read models directory: {}", models_dir.display())
|
format!("Failed to read models directory: {}", models_dir.display())
|
||||||
})?;
|
})?;
|
||||||
for entry in rd2 {
|
for entry in dir_entries2 {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
@@ -501,42 +480,36 @@ pub fn find_model_file() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if candidates.len() == 1 {
|
if candidates.len() == 1 {
|
||||||
let only = candidates.remove(0);
|
let only_model = candidates.remove(0);
|
||||||
let _ = std::fs::write(models_dir.join(".last_model"), only.display().to_string());
|
let _ = std::fs::write(models_dir.join(".last_model"), only_model.display().to_string());
|
||||||
return Ok(only);
|
return Ok(only_model);
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_file = models_dir.join(".last_model");
|
let last_file = models_dir.join(".last_model");
|
||||||
if let Ok(prev) = std::fs::read_to_string(&last_file) {
|
if let Ok(previous_content) = std::fs::read_to_string(&last_file) {
|
||||||
let prev = prev.trim();
|
let previous_content = previous_content.trim();
|
||||||
if !prev.is_empty() {
|
if !previous_content.is_empty() {
|
||||||
let p = PathBuf::from(prev);
|
let previous_path = PathBuf::from(previous_content);
|
||||||
if p.is_file() && candidates.iter().any(|c| c == &p) {
|
if previous_path.is_file() && candidates.iter().any(|c| c == &previous_path) {
|
||||||
// Previously printed: INFO about using previously selected model.
|
return Ok(previous_path);
|
||||||
// Suppress this to avoid duplicate/noisy messages; per-file progress will be shown elsewhere.
|
|
||||||
return Ok(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("Multiple Whisper models found in {}:", models_dir.display());
|
crate::ui::println_above_bars(format!("Multiple Whisper models found in {}:", models_dir.display()));
|
||||||
for (i, p) in candidates.iter().enumerate() {
|
for (index, path) in candidates.iter().enumerate() {
|
||||||
eprintln!(" {}) {}", i + 1, p.display());
|
crate::ui::println_above_bars(format!(" {}) {}", index + 1, path.display()));
|
||||||
}
|
}
|
||||||
eprint!("Select model by number [1-{}]: ", candidates.len());
|
let input = crate::ui::prompt_line(&format!("Select model by number [1-{}]: ", candidates.len()))
|
||||||
io::stderr().flush().ok();
|
.map_err(|_| anyhow!("Failed to read selection"))?;
|
||||||
let mut input = String::new();
|
let selection: usize = input
|
||||||
io::stdin()
|
|
||||||
.read_line(&mut input)
|
|
||||||
.context("Failed to read selection")?;
|
|
||||||
let sel: usize = input
|
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!("Invalid selection: {}", input.trim()))?;
|
.map_err(|_| anyhow!("Invalid selection: {}", input.trim()))?;
|
||||||
if sel == 0 || sel > candidates.len() {
|
if selection == 0 || selection > 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(selection - 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());
|
||||||
Ok(chosen)
|
Ok(chosen)
|
||||||
}
|
}
|
||||||
@@ -571,27 +544,28 @@ pub fn decode_audio_to_pcm_f32_ffmpeg(audio_path: &Path) -> Result<Vec<f32>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
|
let stderr_str = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"ffmpeg failed for {}: {}",
|
"Failed to decode audio from {} using ffmpeg. This may indicate the file is not a valid or supported audio/video file, is corrupted, or cannot be opened. ffmpeg stderr: {}",
|
||||||
audio_path.display(),
|
audio_path.display(),
|
||||||
String::from_utf8_lossy(&output.stderr)
|
stderr_str.trim()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let bytes = output.stdout;
|
let data = output.stdout;
|
||||||
if bytes.len() % 4 != 0 {
|
if data.len() % 4 != 0 {
|
||||||
let truncated = bytes.len() - (bytes.len() % 4);
|
let truncated = data.len() - (data.len() % 4);
|
||||||
let mut v = Vec::with_capacity(truncated / 4);
|
let mut samples = Vec::with_capacity(truncated / 4);
|
||||||
for chunk in bytes[..truncated].chunks_exact(4) {
|
for chunk in data[..truncated].chunks_exact(4) {
|
||||||
let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
|
let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
|
||||||
v.push(f32::from_le_bytes(arr));
|
samples.push(f32::from_le_bytes(arr));
|
||||||
}
|
}
|
||||||
Ok(v)
|
Ok(samples)
|
||||||
} else {
|
} else {
|
||||||
let mut v = Vec::with_capacity(bytes.len() / 4);
|
let mut samples = Vec::with_capacity(data.len() / 4);
|
||||||
for chunk in bytes.chunks_exact(4) {
|
for chunk in data.chunks_exact(4) {
|
||||||
let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
|
let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
|
||||||
v.push(f32::from_le_bytes(arr));
|
samples.push(f32::from_le_bytes(arr));
|
||||||
}
|
}
|
||||||
Ok(v)
|
Ok(samples)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
988
src/main.rs
988
src/main.rs
File diff suppressed because it is too large
Load Diff
1246
src/models.rs
1246
src/models.rs
File diff suppressed because it is too large
Load Diff
84
src/ui.rs
Normal file
84
src/ui.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 <COPYRIGHT HOLDER>. All rights reserved.
|
||||||
|
|
||||||
|
//! Centralized UI helpers (TTY-aware, quiet/verbose-aware)
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
/// Startup intro/banner (suppressed when quiet).
|
||||||
|
pub fn intro(msg: impl AsRef<str>) {
|
||||||
|
let _ = cliclack::intro(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final outro/summary printed below any progress indicators (suppressed when quiet).
|
||||||
|
pub fn outro(msg: impl AsRef<str>) {
|
||||||
|
let _ = cliclack::outro(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info message (TTY-aware; suppressed by --quiet is handled by outer callers if needed)
|
||||||
|
pub fn info(msg: impl AsRef<str>) {
|
||||||
|
let _ = cliclack::log::info(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a warning (always printed).
|
||||||
|
pub fn warn(msg: impl AsRef<str>) {
|
||||||
|
// cliclack provides a warning-level log utility
|
||||||
|
let _ = cliclack::log::warning(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print an error (always printed).
|
||||||
|
pub fn error(msg: impl AsRef<str>) {
|
||||||
|
let _ = cliclack::log::error(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a line above any progress bars (maps to cliclack log; synchronized).
|
||||||
|
pub fn println_above_bars(msg: impl AsRef<str>) {
|
||||||
|
if crate::is_quiet() { return; }
|
||||||
|
// cliclack logs are synchronized with its spinners/bars
|
||||||
|
let _ = cliclack::log::info(msg.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input prompt with a question: returns Ok(None) if non-interactive or canceled
|
||||||
|
pub fn prompt_input(question: impl AsRef<str>, default: Option<&str>) -> anyhow::Result<Option<String>> {
|
||||||
|
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut p = cliclack::input(question.as_ref());
|
||||||
|
if let Some(d) = default {
|
||||||
|
// Use default_input when available in 0.3.x
|
||||||
|
p = p.default_input(d);
|
||||||
|
}
|
||||||
|
match p.interact() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation prompt; returns Ok(None) if non-interactive or canceled
|
||||||
|
pub fn prompt_confirm(question: impl AsRef<str>, default_yes: bool) -> anyhow::Result<Option<bool>> {
|
||||||
|
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let res = cliclack::confirm(question.as_ref())
|
||||||
|
.initial_value(default_yes)
|
||||||
|
.interact();
|
||||||
|
match res {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt the user (TTY-aware via cliclack) and read a line from stdin. Returns the raw line with trailing newline removed.
|
||||||
|
pub fn prompt_line(prompt: &str) -> io::Result<String> {
|
||||||
|
// Route prompt through cliclack to keep consistent styling and avoid direct eprint!/println!
|
||||||
|
let _ = cliclack::log::info(prompt);
|
||||||
|
let mut s = String::new();
|
||||||
|
io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTY-aware progress UI built on `indicatif` for per-file and aggregate progress bars.
|
||||||
|
///
|
||||||
|
/// This small helper encapsulates a `MultiProgress` with one aggregate (total) bar and
|
||||||
|
/// one per-file bar. It is intentionally minimal to keep integration lightweight.
|
||||||
|
pub mod progress;
|
81
src/ui/progress.rs
Normal file
81
src/ui/progress.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 <COPYRIGHT HOLDER>. All rights reserved.
|
||||||
|
|
||||||
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use std::io::IsTerminal as _;
|
||||||
|
|
||||||
|
/// Manages a set of per-file progress bars plus a top aggregate bar.
|
||||||
|
pub struct ProgressManager {
|
||||||
|
enabled: bool,
|
||||||
|
mp: Option<MultiProgress>,
|
||||||
|
per: Vec<ProgressBar>,
|
||||||
|
total: Option<ProgressBar>,
|
||||||
|
completed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressManager {
|
||||||
|
/// Create a new manager with the given enabled flag.
|
||||||
|
pub fn new(enabled: bool) -> Self {
|
||||||
|
Self { enabled, mp: None, per: Vec::new(), total: None, completed: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a manager that enables bars when `n > 1`, stderr is a TTY, and not quiet.
|
||||||
|
pub fn default_for_files(n: usize) -> Self {
|
||||||
|
let enabled = n > 1 && std::io::stderr().is_terminal() && !crate::is_quiet() && !crate::is_no_progress();
|
||||||
|
Self::new(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize bars for the given file labels. If disabled or single file, no-op.
|
||||||
|
pub fn init_files(&mut self, labels: &[String]) {
|
||||||
|
if !self.enabled || labels.len() <= 1 {
|
||||||
|
// No bars in single-file mode or when disabled
|
||||||
|
self.enabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mp = MultiProgress::new();
|
||||||
|
// Aggregate bar at the top
|
||||||
|
let total = mp.add(ProgressBar::new(labels.len() as u64));
|
||||||
|
total.set_style(ProgressStyle::with_template("{prefix} [{bar:40.cyan/blue}] {pos}/{len}")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("=>-"));
|
||||||
|
total.set_prefix("Total");
|
||||||
|
self.total = Some(total);
|
||||||
|
// Per-file bars
|
||||||
|
for label in labels {
|
||||||
|
let pb = mp.add(ProgressBar::new(100));
|
||||||
|
pb.set_style(ProgressStyle::with_template("{prefix} [{bar:40.green/black}] {pos}% {msg}")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("=>-"));
|
||||||
|
pb.set_position(0);
|
||||||
|
pb.set_prefix(label.clone());
|
||||||
|
self.per.push(pb);
|
||||||
|
}
|
||||||
|
self.mp = Some(mp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when bars are enabled (multi-file TTY mode).
|
||||||
|
pub fn is_enabled(&self) -> bool { self.enabled }
|
||||||
|
|
||||||
|
/// Get a clone of the per-file progress bar at index, if enabled.
|
||||||
|
pub fn per_bar(&self, idx: usize) -> Option<ProgressBar> {
|
||||||
|
if !self.enabled { return None; }
|
||||||
|
self.per.get(idx).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a clone of the aggregate (total) progress bar, if enabled.
|
||||||
|
pub fn total_bar(&self) -> Option<ProgressBar> {
|
||||||
|
if !self.enabled { return None; }
|
||||||
|
self.total.as_ref().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a file as finished (set to 100% and update total counter).
|
||||||
|
pub fn mark_file_done(&mut self, idx: usize) {
|
||||||
|
if !self.enabled { return; }
|
||||||
|
if let Some(pb) = self.per.get(idx) {
|
||||||
|
pb.set_position(100);
|
||||||
|
pb.finish_with_message("done");
|
||||||
|
}
|
||||||
|
self.completed += 1;
|
||||||
|
if let Some(total) = &self.total { total.set_position(self.completed as u64); }
|
||||||
|
}
|
||||||
|
}
|
125
tests/integration_validation.rs
Normal file
125
tests/integration_validation.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Validation and error-handling integration tests
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn bin() -> &'static str {
|
||||||
|
env!("CARGO_BIN_EXE_polyscribe")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_path(relative: &str) -> PathBuf {
|
||||||
|
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
p.push(relative);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_on_missing_input_file() {
|
||||||
|
let exe = bin();
|
||||||
|
let missing = manifest_path("input/definitely_missing_123.json");
|
||||||
|
let out = Command::new(exe)
|
||||||
|
.arg(missing.as_os_str())
|
||||||
|
.output()
|
||||||
|
.expect("failed to run polyscribe with missing input");
|
||||||
|
assert!(!out.status.success(), "command should fail on missing input");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("Input not found") || stderr.contains("No input files provided"),
|
||||||
|
"stderr should mention missing input; got: {}",
|
||||||
|
stderr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_on_directory_as_input() {
|
||||||
|
let exe = bin();
|
||||||
|
// Use the repo's input directory which exists and is a directory
|
||||||
|
let input_dir = manifest_path("input");
|
||||||
|
let out = Command::new(exe)
|
||||||
|
.arg(input_dir.as_os_str())
|
||||||
|
.output()
|
||||||
|
.expect("failed to run polyscribe with directory input");
|
||||||
|
assert!(!out.status.success(), "command should fail on dir input");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("directory") || stderr.contains("Unsupported input type"),
|
||||||
|
"stderr should mention directory/unsupported; got: {}",
|
||||||
|
stderr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_on_no_ffmpeg_present() {
|
||||||
|
let exe = bin();
|
||||||
|
// Create a tiny temp .wav file (may be empty; ffmpeg will be attempted but PATH will be empty)
|
||||||
|
let tmp_dir = manifest_path("target/tmp/itest_no_ffmpeg");
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
fs::create_dir_all(&tmp_dir).unwrap();
|
||||||
|
let wav = tmp_dir.join("dummy.wav");
|
||||||
|
fs::write(&wav, b"\0\0\0\0").unwrap();
|
||||||
|
|
||||||
|
let out = Command::new(exe)
|
||||||
|
.arg(wav.as_os_str())
|
||||||
|
.env("PATH", "") // simulate ffmpeg missing
|
||||||
|
.env_remove("WHISPER_MODEL")
|
||||||
|
.env("POLYSCRIBE_MODELS_BASE_COPY_DIR", manifest_path("models").as_os_str())
|
||||||
|
.arg("--language").arg("en")
|
||||||
|
.output()
|
||||||
|
.expect("failed to run polyscribe with empty PATH");
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"command should fail when ffmpeg is not found"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("ffmpeg not found") || stderr.contains("Failed to execute ffmpeg"),
|
||||||
|
"stderr should mention ffmpeg not found; got: {}",
|
||||||
|
stderr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn error_on_readonly_output_dir() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let exe = bin();
|
||||||
|
let input1 = manifest_path("input/1-s0wlz.json");
|
||||||
|
|
||||||
|
// Prepare a read-only directory
|
||||||
|
let tmp_dir = manifest_path("target/tmp/itest_readonly_out");
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
fs::create_dir_all(&tmp_dir).unwrap();
|
||||||
|
let mut perms = fs::metadata(&tmp_dir).unwrap().permissions();
|
||||||
|
perms.set_mode(0o555); // read & execute, no write
|
||||||
|
fs::set_permissions(&tmp_dir, perms).unwrap();
|
||||||
|
|
||||||
|
let out = Command::new(exe)
|
||||||
|
.arg(input1.as_os_str())
|
||||||
|
.arg("-o")
|
||||||
|
.arg(tmp_dir.as_os_str())
|
||||||
|
.output()
|
||||||
|
.expect("failed to run polyscribe with read-only output dir");
|
||||||
|
|
||||||
|
// Restore perms for cleanup
|
||||||
|
let mut perms2 = fs::metadata(&tmp_dir).unwrap().permissions();
|
||||||
|
perms2.set_mode(0o755);
|
||||||
|
let _ = fs::set_permissions(&tmp_dir, perms2);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"command should fail when outputs cannot be created"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("Failed to create output") || stderr.contains("permission"),
|
||||||
|
"stderr should mention failure to create outputs; got: {}",
|
||||||
|
stderr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
}
|
Reference in New Issue
Block a user