From 40f9aeb6035bd075f1d20504d420ed57f0f3e8f3 Mon Sep 17 00:00:00 2001 From: Alina Lozowski Date: Mon, 24 Aug 2026 23:12:57 +0200 Subject: [PATCH] fix: preserve raw transcript text and timestamps --- CONFIGURATION.md | 186 ++------- README.md | 2 +- evaluation/BASELINE.md | 184 +++++---- evaluation/protocol.json | 13 +- examples/config_example.yaml | 14 +- pyproject.toml | 3 +- scripts/evaluate_audio_quality.py | 60 ++- tests/test_audio_quality_evaluation.py | 2 + tests/test_deduplicate_segments.py | 34 -- tests/test_device_utils.py | 13 +- tests/test_gradio_ui.py | 38 +- tests/test_model_reuse.py | 55 --- tests/test_pipeline.py | 93 ++--- tests/test_whisper_batching.py | 12 +- textplease/__init__.py | 2 +- textplease/backends/transformers_pipeline.py | 39 +- textplease/gradio_ui.py | 72 +--- textplease/gradio_worker.py | 3 +- textplease/main.py | 2 +- textplease/pipeline.py | 213 ++-------- textplease/segmenter.py | 410 ------------------- textplease/transcriber.py | 2 +- textplease/utils/deduplicate_segments.py | 37 -- uv.lock | 94 +---- 24 files changed, 320 insertions(+), 1263 deletions(-) delete mode 100644 tests/test_deduplicate_segments.py delete mode 100644 textplease/segmenter.py delete mode 100644 textplease/utils/deduplicate_segments.py diff --git a/CONFIGURATION.md b/CONFIGURATION.md index df3a7f6..f2a5748 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,32 +1,24 @@ # Configuration -The defaults are a good starting point for most recordings. If this is your first time using `textplease`, try them before changing anything. +The web interface is the easiest way to use TextPlease: -You can run the app in two ways: - -- Web interface: `textplease --gradio` -- Command line: `textplease --config path/to/config.yaml` +```bash +textplease --gradio +``` -The web interface is the easiest option. Upload a file, choose a language, and start the transcription. Each run stores -its transcript, configuration, and log in a private job directory under `output/`. Clear deletes that job directory. +Upload a file, choose its language, and start the transcription. The app chooses the best available device. Each job +stores its transcript, configuration, and log in a private directory under `output/`. Clear deletes that directory. -## Quick start from the command line +## Command line -Create a YAML file, for example `my_config.yaml`: +Create a YAML file such as `my_config.yaml`: ```yaml input_path: "input/recording.mp3" output_path: "output/recording_transcript.csv" model_name: "openai/whisper-large-v3" - -device: "cpu" +device: "auto" language: "en" - -pause_threshold: 2.0 -similarity_threshold: 0.75 -min_segment_words: 3 -min_segment_chars: 15 -max_segment_words: 100 ``` Then run: @@ -35,160 +27,60 @@ Then run: textplease --config my_config.yaml ``` -Paths are read from the directory where you run the command. The input file must already exist. The output folder is created automatically. - -Only `input_path`, `output_path`, and `model_name` are required. Everything else has a default value. - -> The output file has a `.csv` extension, but its columns are separated by tabs. This makes transcript text containing commas safe to open and process. - -## The settings you are most likely to change - -### `pause_threshold` - -Default: `2.0` seconds - -This is the pause threshold used for ordinary grouping of recognized pieces into transcript segments. - -- Use a **lower** value to keep more recognized pieces separate. -- Use a **higher** value to allow more joining across pauses. - -This setting does not change Silero VAD, the audio sent to Whisper, decoder text, or raw timestamps. Speech detection is -automatic. - -### `similarity_threshold` - -Default: `0.75` - -This controls whether nearby pieces of text are similar enough to join together. - -- Use a **higher** value to keep more segments separate. -- Use a **lower** value to allow more merging. - -| Value | What to expect | -|-------|----------------| -| `0.0` | Very permissive; many segments may merge | -| `0.75` | A balanced default | -| `0.9` | Keeps more topic and sentence boundaries | -| `1.0` | Effectively turns off similarity-based merging | - -`0.0` does **not** turn merging off. Short fragments may still be joined to a neighbour even when this is set to `1.0`. -At `1.0`, the embedding model is not loaded because semantic similarity cannot affect the result. - -### Segment length - -These three settings keep the transcript from becoming too fragmented or too dense: - -```yaml -min_segment_words: 3 -min_segment_chars: 15 -max_segment_words: 100 -``` - -- A segment below either minimum is treated as a fragment and is usually joined to a neighbour. -- A segment above the maximum is split into smaller pieces. -- A fragment is kept when it cannot be merged without exceeding the maximum. - -Set both minimums to `1` if short replies such as “Yes” or “No” should stay on their own. - -## Keep Whisper's segments mostly unchanged - -```yaml -similarity_threshold: 1.0 -min_segment_words: 1 -min_segment_chars: 1 -max_segment_words: 100000 -``` - -This turns off the normal segmentation rules as far as practical. The result is not completely raw Whisper output: `textplease` still splits sentences, removes repeated overlap and known false phrases, and drops empty segments. - -## Input and model settings +Paths are resolved from the directory where you run the command. The input must exist. The output directory is +created automatically. | Setting | Default | Notes | |---------|---------|-------| | `input_path` | Required | An existing audio or video file | | `output_path` | Required | Replaced if it already exists | -| `model_name` | Required | A Hugging Face model ID downloaded on first use, or a local directory; the web interface uses `openai/whisper-large-v3` | -| `device` | `cpu` | Use `auto` for the best available device, `cuda` for NVIDIA, or `mps` for Apple Silicon | -| `language` | `en` | Language code passed to Whisper; use `null` for automatic detection with multilingual models | -| `embedding_model` | `all-MiniLM-L6-v2` | Model used to compare segment meaning | -| `log_level` | `INFO` | Also accepts `DEBUG`, `WARNING`, and `ERROR` | - -`auto` prefers CUDA, then MPS, then CPU. An unavailable explicit accelerator uses the same fallback order. The web -interface automatically chooses the best available device. It currently offers these languages: +| `model_name` | Required | A Hugging Face model ID downloaded on first use, or a local model directory | +| `device` | `cpu` | `auto`, `cpu`, `cuda`, or `mps` | +| `language` | `en` | A Whisper language code, or `null` for automatic detection | +| `log_level` | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR` | -- English (`en`), Russian (`ru`), Spanish (`es`), French (`fr`), Italian (`it`) -- German (`de`), Turkish (`tr`), Chinese (`zh`), Korean (`ko`), Japanese (`ja`) +`auto` prefers CUDA, then MPS, then CPU. An unavailable accelerator falls back in the same order. The web interface +offers English, Russian, Spanish, French, Italian, German, Turkish, Chinese, Korean, and Japanese. YAML files can use +other language codes supported by the selected Whisper model. -The command line can use other language codes supported by the selected Whisper model. +The transcript preserves each retained, nonblank Whisper span and its timestamp. TextPlease does not merge, split, +deduplicate, or rewrite that text. -## Performance settings +## Performance -Most users can leave these alone. +Most users should keep the default: ```yaml performance: whisper_batch_size: 1 - similarity_batch_size: 32 - chunk_size: 1000 ``` -`whisper_batch_size` controls how many VAD speech chunks Whisper transcribes together. The default is `1` on every -device because output parity for larger real-model batches is not yet established. An explicitly configured batch that -exhausts accelerator memory automatically retries one chunk at a time. +This controls how many detected speech chunks Whisper processes together. The default is `1` because parity for larger +real-model batches is not established. If an explicitly configured accelerator batch runs out of memory, TextPlease +retries one chunk at a time. -`similarity_batch_size` controls how many text embeddings are created at once. Lower it if the embedding step runs out of memory. +The web interface keeps its loaded Whisper model after a successful job. Cancelling or failing a job discards the worker +and model. Changing the model or device also starts a new worker. Cancel removes temporary PCM. Clear also deletes the +job's transcript, configuration, and log. -`chunk_size` controls how many pieces of the Whisper transcript are handled at once during merging. Set it to `0` to turn chunked merging off. Embeddings are created before this stage, so lowering `chunk_size` does not reduce the memory used to create them. +## Privacy and model downloads -The web interface keeps its loaded Whisper and embedding models after a successful transcription so later jobs can -reuse them. Cancelling or failing a job discards the worker and its models; changing the model or device also starts a -fresh worker. Cancel removes temporary PCM, while Clear also deletes that job's transcript, configuration, and log. +Audio and transcripts stay on the machine. Hugging Face receives model and metadata requests when a required model is +not cached. Models download automatically and later runs reuse the local cache. -## Environment variables - -Configuration files do not change the process environment. Set required environment variables in your shell or -operating system before starting `textplease`; this keeps credentials and machine-specific settings out of saved YAML -files and logs. - -## What the web interface exposes - -The web interface lets you change: - -- device and language -- transcript-grouping pause and similarity thresholds -- minimum words, minimum characters, and maximum words - -It chooses the input and output paths for you and uses the default Whisper and embedding models. Performance and logging settings are available only in a CLI YAML file. - -## How merging works - -In the usual case, two neighbouring segments are joined only when: - -1. The pause is no longer than `pause_threshold`. -2. Their similarity is greater than `similarity_threshold`. -3. Their combined length is no more than `max_segment_words`. - -Short fragments get an extra cleanup pass. They may be joined without passing the similarity check, but a merge is never allowed to create a segment longer than `max_segment_words`. If no suitable neighbour is available, the fragment is left as it is. - -Speech detection is automatic. Changing transcript grouping does not change the audio sent to Whisper. +Configuration files do not set environment variables. Set machine-specific values in the shell before starting +TextPlease so they are not copied into saved job configurations or logs. ## Troubleshooting | If you see this | Try this | |-----------------|----------| -| Too many tiny segments | Raise `min_segment_words` or `min_segment_chars` | -| Segments are too long | Lower `max_segment_words` | -| Not enough transcript breaks | Lower `pause_threshold` or raise `similarity_threshold` | -| Too many transcript breaks | Raise `pause_threshold` or lower `similarity_threshold` | -| Unrelated sentences are joined | Raise `similarity_threshold` to about `0.85` | -| Short replies disappear into nearby text | Set both minimums to `1` | -| Whisper runs out of memory | Lower `performance.whisper_batch_size` | -| Embedding runs out of memory | Lower `performance.similarity_batch_size` | -| GPU transcription runs out of memory | Use `device: "cpu"` or a smaller compatible Whisper model | +| Whisper runs out of memory | Keep `performance.whisper_batch_size: 1`, select `cpu`, or use a smaller compatible Whisper model | | A model download fails | Check the network connection and Hugging Face access, then try again | +| Speech is missing | Confirm the language and review the quality baseline before changing code | +| Music produces text | Record the file and expected silence as a new credited evaluation case | -## A note about CLI values - -The command line checks that the required fields are present and that the input file exists, but it does not validate every numeric value. Use non-negative thresholds and positive length and batch values. Keep `min_segment_words` lower than or equal to `max_segment_words`. Unknown settings are ignored. +The output uses tab-separated columns even when its name ends in `.csv`. This preserves commas in spoken text. Unknown +settings produce an error so misspellings and removed options cannot silently change expectations. -For a complete example with every supported option, see [`examples/config_example.yaml`](examples/config_example.yaml). +See [`examples/config_example.yaml`](examples/config_example.yaml) for a complete example. diff --git a/README.md b/README.md index 2120c4c..6d4b997 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The [baseline](evaluation/BASELINE.md) has all results and audio credits. The [p ## How it works -FFmpeg makes mono 16 kHz PCM for Whisper. Silero VAD finds likely speech. A local AudioSet model suppresses output that it rates as music without speech. Whisper transcribes what remains. The app groups the text by pauses, meaning, and length, then writes the tab-separated file. +FFmpeg makes mono 16 kHz PCM for Whisper. Silero VAD finds likely speech. A local AudioSet model suppresses output that it rates as music without speech. Whisper transcribes what remains. The app writes each retained, nonblank Whisper span and timestamp without rewriting its text. ## License diff --git a/evaluation/BASELINE.md b/evaluation/BASELINE.md index 549d32d..9dd1934 100644 --- a/evaluation/BASELINE.md +++ b/evaluation/BASELINE.md @@ -7,13 +7,13 @@ This report scores the configured public `textplease` pipeline against the versi | Field | Value | |---|---| | Manifest SHA-256 | `58a4e462e4c2fcbbd61615d6506328ca524e43485615bc1d696283ef4704be9c` | -| Protocol SHA-256 | `84a14380f14e18c5912d557018f6f942d940e0caa4182e86097481e2404e6048` | -| Inference evaluator SHA-256 | `7a508a172bfcf7f9a6f6d844a5d9286d16d2a43b2d9e816d7ba2481756f8be89` | -| Scorer SHA-256 | `7a508a172bfcf7f9a6f6d844a5d9286d16d2a43b2d9e816d7ba2481756f8be89` | +| Protocol SHA-256 | `ec77f64af4222ce40b0164f303d71744f1c26c194e7352dca7bc46faae3ed6e2` | +| Inference evaluator SHA-256 | `f0816ffb181ba47dc5e0ef2c068a2c8c4a416807b4143603b906156c3bd1e891` | +| Scorer SHA-256 | `e48a1b88a07a9555be748c6d0027493c18f53cd37da5613638b7a946682d521a` | | Scorer JiWER | `4.0.0` | | Scorer RapidFuzz | `3.14.5` | | Random seed | `0` | -| Source revision | `74a4054d227183954cd7eb6c295c37ad7efa6e69` | +| Source revision | `2939d32b1cd5f3030bd1043fb82caf7299e31711` | | Source dirty | `True` | | Device | `mps` | | Whisper batch size | `1` | @@ -25,7 +25,6 @@ This report scores the configured public `textplease` pipeline against the versi | Environment numpy | `2.5.1` | | Environment platform | `macOS-26.6.2-arm64-arm-64bit` | | Environment python | `3.12.12` | -| Environment sentence-transformers | `5.6.0` | | Environment silero-vad | `6.2.1` | | Environment textplease | `0.1.0` | | Environment torch | `2.13.0` | @@ -59,70 +58,102 @@ This report scores the configured public `textplease` pipeline against the versi | Metric | Value | |---|---:| | Cases | 18 | -| WER | 0.2359 | -| Word substitutions | 875 | -| Word deletions | 3576 | -| Word insertions | 169 | -| CER | 0.1659 | +| WER | 0.2355 | +| Word substitutions | 882 | +| Word deletions | 3559 | +| Word insertions | 172 | +| CER | 0.1655 | | Short exact-match rate | 0.5000 | | Non-speech nonempty cases | 0 | | Non-speech error cases | 0 | | Prediction error cases | 0 | | Reference speech (ms) | 5026202 | -| Missed speech (ms) | 471550 | -| Missed speech rate | 0.0938 | +| Missed speech (ms) | 467530 | +| Missed speech rate | 0.0930 | | Reference non-speech (ms) | 539150 | | False alarm (ms) | 102493 | | False-alarm rate | 0.1901 | -| Boundary precision | 0.0951 | -| Boundary recall | 0.5279 | -| Boundary median error (ms) | 99.0000 | +| Boundary precision | 0.0961 | +| Boundary recall | 0.5201 | +| Boundary median error (ms) | 98.5000 | | Boundary p95 error (ms) | 234.0000 | -| Onset median error (ms) | 58.0000 | -| Onset p95 error (ms) | 230.0000 | -| Offset median error (ms) | 142.0000 | +| Onset median error (ms) | 55.5000 | +| Onset p95 error (ms) | 223.0000 | +| Offset median error (ms) | 143.0000 | | Offset p95 error (ms) | 234.0000 | | Timestamp violation cases | 0 | | Timestamp violations | 0 | +| Output segments | 1794 | +| Segment characters median | 43.0000 | +| Segment characters p95 | 94.0000 | +| Segment characters max | 241 | +| Segment duration median (ms) | 2220.0000 | +| Segment duration p95 (ms) | 6540.0000 | +| Segment duration max (ms) | 17120 | | Parity mismatch cases | 0 | -| Median RTF | 0.1416 | -| p95 RTF | 5.3647 | -| Peak RSS (MiB) | 9451.6250 | +| Median RTF | 0.1427 | +| p95 RTF | 4.8415 | +| Peak RSS (MiB) | 7337.9531 | | Peak CUDA allocation (MiB) | — | ## Per stratum | Group | Cases | WER | CER | Short exact | Non-speech nonempty | Miss (ms) | Miss rate | False alarm (ms) | False-alarm rate | Boundary P | Boundary R | Timestamp violations | RTF | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| split=tuning | 9 | 0.2607 | 0.1942 | 0.0000 | 0 | 232022 | 0.1328 | 4696 | 0.0373 | 0.0389 | 0.4804 | 0 | 0.1431 | -| split=acceptance | 9 | 0.2222 | 0.1504 | 1.0000 | 0 | 239528 | 0.0730 | 97797 | 0.2366 | 0.1255 | 0.5368 | 0 | 0.1394 | -| language=en | 18 | 0.2359 | 0.1659 | 0.5000 | 0 | 471550 | 0.0938 | 102493 | 0.1901 | 0.0951 | 0.5279 | 0 | 0.1416 | -| stratum=30_minute | 1 | 0.2733 | 0.2055 | — | 0 | 231903 | 0.1339 | 4416 | 0.0652 | 0.0343 | 0.4479 | 0 | 0.1355 | -| stratum=60_minute | 1 | 0.2280 | 0.1555 | — | 0 | 239412 | 0.0733 | 80501 | 0.2406 | 0.1246 | 0.5372 | 0 | 0.1369 | -| stratum=background_noise | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0104 | -| stratum=clean | 8 | 0.0358 | 0.0136 | — | 0 | 92 | 0.0034 | 732 | 0.6393 | 0.7500 | 0.7500 | 0 | 0.1615 | -| stratum=long_form | 2 | 0.2438 | 0.1729 | — | 0 | 471315 | 0.0943 | 84917 | 0.2111 | 0.0929 | 0.5237 | 0 | 0.1362 | -| stratum=meeting | 2 | 0.2438 | 0.1729 | — | 0 | 471315 | 0.0943 | 84917 | 0.2111 | 0.0929 | 0.5237 | 0 | 0.1362 | -| stratum=mono_16khz_flac | 12 | 0.2358 | 0.1659 | 0.5000 | 0 | 471434 | 0.0938 | 102438 | 0.2213 | 0.0941 | 0.5249 | 0 | 0.1444 | -| stratum=mono_44khz_ogg | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0104 | -| stratum=multi_speaker | 2 | 0.2438 | 0.1729 | — | 0 | 471315 | 0.0943 | 84917 | 0.2111 | 0.0929 | 0.5237 | 0 | 0.1362 | -| stratum=music | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 27 | 0.0315 | 16789 | 0.1400 | 0.5000 | 0.5000 | 0 | 0.0434 | -| stratum=name | 1 | 0.0000 | 0.0000 | 1.0000 | 0 | 116 | 0.2755 | 55 | 0.1250 | 1.0000 | 1.0000 | 0 | 1.6527 | -| stratum=non_speech | 4 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0187 | -| stratum=pace_fast | 2 | 0.0403 | 0.0094 | — | 0 | — | — | — | — | — | — | 0 | 0.1856 | -| stratum=pace_normal | 2 | 0.0232 | 0.0203 | — | 0 | — | — | — | — | — | — | 0 | 0.1472 | -| stratum=pace_slow | 2 | 0.0545 | 0.0139 | — | 0 | — | — | — | — | — | — | 0 | 0.1412 | -| stratum=pcm_wav | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0197 | -| stratum=read_speech | 8 | 0.0358 | 0.0136 | — | 0 | 92 | 0.0034 | 732 | 0.6393 | 0.7500 | 0.7500 | 0 | 0.1615 | -| stratum=saxophone | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0410 | -| stratum=short_utterance | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 143 | 0.0833 | 16844 | 0.2808 | 0.7500 | 0.7500 | 0 | 0.8964 | -| stratum=silence | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0197 | -| stratum=single_speaker | 6 | 0.0372 | 0.0149 | — | 0 | — | — | — | — | — | — | 0 | 0.1472 | -| stratum=speech | 14 | 0.2359 | 0.1659 | 0.5000 | 0 | 471550 | 0.0938 | 102493 | 0.2212 | 0.0951 | 0.5279 | 0 | 0.1472 | -| stratum=speech_over_music | 2 | 0.5000 | 0.2857 | 0.5000 | 0 | 27 | 0.0315 | 16789 | 0.2819 | 0.5000 | 0.5000 | 0 | 0.0930 | -| stratum=spontaneous_speech | 2 | 0.2438 | 0.1729 | — | 0 | 471315 | 0.0943 | 84917 | 0.2111 | 0.0929 | 0.5237 | 0 | 0.1362 | -| stratum=stereo_44khz_ogg | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 116 | 0.1352 | 55 | 0.0009 | 1.0000 | 1.0000 | 0 | 0.8468 | -| stratum=word | 1 | 1.0000 | 0.6667 | 0.0000 | 0 | 0 | 0.0000 | 0 | — | 1.0000 | 1.0000 | 0 | 5.3647 | +| split=tuning | 9 | 0.2595 | 0.1931 | 0.0000 | 0 | 230082 | 0.1317 | 4696 | 0.0373 | 0.0404 | 0.4902 | 0 | 0.1423 | +| split=acceptance | 9 | 0.2223 | 0.1503 | 1.0000 | 0 | 237448 | 0.0724 | 97797 | 0.2366 | 0.1267 | 0.5257 | 0 | 0.1430 | +| language=en | 18 | 0.2355 | 0.1655 | 0.5000 | 0 | 467530 | 0.0930 | 102493 | 0.1901 | 0.0961 | 0.5201 | 0 | 0.1427 | +| stratum=30_minute | 1 | 0.2720 | 0.2044 | — | 0 | 229963 | 0.1327 | 4416 | 0.0652 | 0.0357 | 0.4583 | 0 | 0.1670 | +| stratum=60_minute | 1 | 0.2281 | 0.1554 | — | 0 | 237332 | 0.0727 | 80501 | 0.2406 | 0.1257 | 0.5260 | 0 | 0.1472 | +| stratum=background_noise | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0107 | +| stratum=clean | 8 | 0.0358 | 0.0136 | — | 0 | 92 | 0.0034 | 732 | 0.6393 | 0.7500 | 0.7500 | 0 | 0.1586 | +| stratum=long_form | 2 | 0.2435 | 0.1724 | — | 0 | 467295 | 0.0935 | 84917 | 0.2111 | 0.0939 | 0.5158 | 0 | 0.1571 | +| stratum=meeting | 2 | 0.2435 | 0.1724 | — | 0 | 467295 | 0.0935 | 84917 | 0.2111 | 0.0939 | 0.5158 | 0 | 0.1571 | +| stratum=mono_16khz_flac | 12 | 0.2355 | 0.1655 | 0.5000 | 0 | 467414 | 0.0930 | 102438 | 0.2213 | 0.0951 | 0.5171 | 0 | 0.1451 | +| stratum=mono_44khz_ogg | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0107 | +| stratum=multi_speaker | 2 | 0.2435 | 0.1724 | — | 0 | 467295 | 0.0935 | 84917 | 0.2111 | 0.0939 | 0.5158 | 0 | 0.1571 | +| stratum=music | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 27 | 0.0315 | 16789 | 0.1400 | 0.5000 | 0.5000 | 0 | 0.0383 | +| stratum=name | 1 | 0.0000 | 0.0000 | 1.0000 | 0 | 116 | 0.2755 | 55 | 0.1250 | 1.0000 | 1.0000 | 0 | 1.4878 | +| stratum=non_speech | 4 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0205 | +| stratum=pace_fast | 2 | 0.0403 | 0.0094 | — | 0 | — | — | — | — | — | — | 0 | 0.1765 | +| stratum=pace_normal | 2 | 0.0232 | 0.0203 | — | 0 | — | — | — | — | — | — | 0 | 0.1427 | +| stratum=pace_slow | 2 | 0.0545 | 0.0139 | — | 0 | — | — | — | — | — | — | 0 | 0.1370 | +| stratum=pcm_wav | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0225 | +| stratum=read_speech | 8 | 0.0358 | 0.0136 | — | 0 | 92 | 0.0034 | 732 | 0.6393 | 0.7500 | 0.7500 | 0 | 0.1586 | +| stratum=saxophone | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0343 | +| stratum=short_utterance | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 143 | 0.0833 | 16844 | 0.2808 | 0.7500 | 0.7500 | 0 | 0.8118 | +| stratum=silence | 1 | — | — | — | 0 | 0 | — | 0 | 0.0000 | — | — | 0 | 0.0225 | +| stratum=single_speaker | 6 | 0.0372 | 0.0149 | — | 0 | — | — | — | — | — | — | 0 | 0.1427 | +| stratum=speech | 14 | 0.2355 | 0.1655 | 0.5000 | 0 | 467530 | 0.0930 | 102493 | 0.2212 | 0.0961 | 0.5201 | 0 | 0.1571 | +| stratum=speech_over_music | 2 | 0.5000 | 0.2857 | 0.5000 | 0 | 27 | 0.0315 | 16789 | 0.2819 | 0.5000 | 0.5000 | 0 | 0.0891 | +| stratum=spontaneous_speech | 2 | 0.2435 | 0.1724 | — | 0 | 467295 | 0.0935 | 84917 | 0.2111 | 0.0939 | 0.5158 | 0 | 0.1571 | +| stratum=stereo_44khz_ogg | 4 | 0.5000 | 0.2857 | 0.5000 | 0 | 116 | 0.1352 | 55 | 0.0009 | 1.0000 | 1.0000 | 0 | 0.7610 | +| stratum=word | 1 | 1.0000 | 0.6667 | 0.0000 | 0 | 0 | 0.0000 | 0 | — | 1.0000 | 1.0000 | 0 | 4.8415 | + +## Segment shape + +These descriptive metrics expose transcript line compactness. They are not release gates. + +| Case | Segments | Characters median | Characters p95 | Characters max | Duration median (ms) | Duration p95 (ms) | Duration max (ms) | +|---|---:|---:|---:|---:|---:|---:|---:| +| silence-5s | 0 | — | — | — | — | — | — | +| rain-10s | 0 | — | — | — | — | — | — | +| music-jazz-sax-24s | 0 | — | — | — | — | — | — | +| music-36s | 0 | — | — | — | — | — | — | +| speech-over-music-ear | 1 | 5.0000 | 5.0000 | 5 | 500.0000 | 500.0000 | 500 | +| speech-over-music-john | 1 | 5.0000 | 5.0000 | 5 | 17120.0000 | 17120.0000 | 17120 | +| short-word-ear | 1 | 5.0000 | 5.0000 | 5 | 437.0000 | 437.0000 | 437 | +| short-name-john | 1 | 5.0000 | 5.0000 | 5 | 360.0000 | 360.0000 | 360 | +| librispeech-sample-1 | 1 | 241.0000 | 241.0000 | 241 | 13580.0000 | 13580.0000 | 13580 | +| librispeech-sample-2 | 1 | 192.0000 | 192.0000 | 192 | 13820.0000 | 13820.0000 | 13820 | +| pace-slow-tuning | 7 | 65.0000 | 93.0000 | 93 | 4640.0000 | 7060.0000 | 7060 | +| pace-normal-tuning | 7 | 93.0000 | 100.0000 | 100 | 5800.0000 | 6400.0000 | 6400 | +| pace-fast-tuning | 11 | 67.0000 | 98.0000 | 98 | 3020.0000 | 5040.0000 | 5040 | +| pace-slow-acceptance | 6 | 91.5000 | 93.0000 | 93 | 5660.0000 | 7280.0000 | 7280 | +| pace-normal-acceptance | 10 | 86.0000 | 145.0000 | 145 | 5300.0000 | 7500.0000 | 7500 | +| pace-fast-acceptance | 5 | 132.0000 | 149.0000 | 149 | 6540.0000 | 6820.0000 | 6820 | +| ami-meeting-30m | 616 | 41.0000 | 88.0000 | 188 | 2080.0000 | 6000.0000 | 11220 | +| ami-meeting-60m | 1126 | 43.0000 | 94.0000 | 169 | 2260.0000 | 7000.0000 | 14000 | ## Gates @@ -130,50 +161,51 @@ Gates evaluate only manifest rows with `split=acceptance`. | Gate | Rule | Actual | Status | |---|---:|---:|---| -| `boundary_precision` | min 0.9500 | 0.1255 | DISABLED | -| `boundary_recall` | min 0.9500 | 0.5368 | DISABLED | -| `cer` | max 0.0500 | 0.1504 | DISABLED | +| `boundary_precision` | min 0.9500 | 0.1267 | DISABLED | +| `boundary_recall` | min 0.9500 | 0.5257 | DISABLED | +| `cer` | max 0.0500 | 0.1503 | DISABLED | | `false_alarm_rate` | max 0.0500 | 0.2366 | DISABLED | -| `missed_speech_rate` | max 0.0500 | 0.0730 | DISABLED | +| `missed_speech_rate` | max 0.0500 | 0.0724 | DISABLED | | `non_speech_error_cases` | max 0.0000 | 0 | PASS | | `non_speech_nonempty_cases` | max 0.0000 | 0 | PASS | | `parity_mismatch_cases` | max 0.0000 | 0 | DISABLED | | `peak_cuda_mb_max` | max 16384.0000 | — | DISABLED | -| `peak_rss_mb_max` | max 16384.0000 | 2702.6406 | DISABLED | -| `rtf_median` | max 1.0000 | 0.1394 | DISABLED | +| `peak_rss_mb_max` | max 16384.0000 | 3004.1562 | DISABLED | +| `rtf_median` | max 1.0000 | 0.1430 | DISABLED | | `short_exact_match_rate` | min 1.0000 | 1.0000 | PASS | | `timestamp_violation_cases` | max 0.0000 | 0 | PASS | -| `wer` | max 0.1000 | 0.2222 | DISABLED | +| `wer` | max 0.1000 | 0.2223 | DISABLED | ## Cases | Case | Split | Strata | Duration (s) | Inference (s) | RTF | Peak RSS (MiB) | Peak CUDA (MiB) | WER | CER | Miss (ms) | Miss rate | False alarm (ms) | False-alarm rate | Timestamp violations | Error | |---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| -| silence-5s | acceptance | non_speech, silence, pcm_wav | 5.0000 | 0.0987 | 0.0197 | 428.8594 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | -| rain-10s | tuning | non_speech, background_noise, mono_44khz_ogg | 10.3310 | 0.1076 | 0.0104 | 436.1719 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | -| music-jazz-sax-24s | tuning | non_speech, music, saxophone, stereo_44khz_ogg | 24.0000 | 0.9837 | 0.0410 | 559.3125 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | -| music-36s | acceptance | non_speech, music, stereo_44khz_ogg | 36.4090 | 0.6445 | 0.0177 | 571.8281 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | -| speech-over-music-ear | tuning | speech, music, speech_over_music, short_utterance, mono_16khz_flac | 24.0000 | 3.3622 | 0.1401 | 9451.6250 | — | 1.0000 | 0.6667 | 27 | 0.0618 | 90 | 0.0038 | 0 | — | -| speech-over-music-john | acceptance | speech, music, speech_over_music, short_utterance, mono_16khz_flac | 36.4090 | 1.6717 | 0.0459 | 1297.3750 | — | 0.0000 | 0.0000 | 0 | 0.0000 | 16699 | 0.4640 | 0 | — | -| short-word-ear | tuning | speech, short_utterance, word, stereo_44khz_ogg | 0.4370 | 2.3444 | 5.3647 | 2026.0469 | — | 1.0000 | 0.6667 | 0 | 0.0000 | 0 | — | 0 | — | -| short-name-john | acceptance | speech, short_utterance, name, stereo_44khz_ogg | 0.8610 | 1.4229 | 1.6527 | 1222.7969 | — | 0.0000 | 0.0000 | 116 | 0.2755 | 55 | 0.1250 | 0 | — | -| librispeech-sample-1 | tuning | speech, read_speech, clean, mono_16khz_flac | 13.6900 | 2.7604 | 0.2016 | 1631.3906 | — | 0.0455 | 0.0041 | 92 | 0.0068 | 190 | 0.9135 | 0 | — | -| librispeech-sample-2 | acceptance | speech, read_speech, clean, mono_16khz_flac | 14.2150 | 2.6495 | 0.1864 | 1599.9062 | — | 0.0000 | 0.0000 | 0 | 0.0000 | 542 | 0.5784 | 0 | — | -| pace-slow-tuning | tuning | speech, read_speech, clean, single_speaker, pace_slow, mono_16khz_flac | 34.9400 | 5.0002 | 0.1431 | 1205.1875 | — | 0.0127 | 0.0049 | — | — | — | — | 0 | — | -| pace-normal-tuning | tuning | speech, read_speech, clean, single_speaker, pace_normal, mono_16khz_flac | 39.7550 | 5.9147 | 0.1488 | 1181.9844 | — | 0.0273 | 0.0265 | — | — | — | — | 0 | — | -| pace-fast-tuning | tuning | speech, read_speech, clean, single_speaker, pace_fast, mono_16khz_flac | 36.4550 | 7.1773 | 0.1969 | 1200.1094 | — | 0.0441 | 0.0088 | — | — | — | — | 0 | — | -| pace-slow-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_slow, mono_16khz_flac | 35.5900 | 4.9602 | 0.1394 | 1193.7031 | — | 0.0930 | 0.0208 | — | — | — | — | 0 | — | -| pace-normal-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_normal, mono_16khz_flac | 53.6300 | 7.8137 | 0.1457 | 1197.7969 | — | 0.0201 | 0.0155 | — | — | — | — | 0 | — | -| pace-fast-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_fast, mono_16khz_flac | 31.7750 | 5.5379 | 0.1743 | 1176.5625 | — | 0.0357 | 0.0100 | — | — | — | — | 0 | — | -| ami-meeting-30m | tuning | speech, meeting, multi_speaker, spontaneous_speech, long_form, 30_minute, mono_16khz_flac | 1800.0000 | 243.9746 | 0.1355 | 2499.2031 | — | 0.2733 | 0.2055 | 231903 | 0.1339 | 4416 | 0.0652 | 0 | — | -| ami-meeting-60m | acceptance | speech, meeting, multi_speaker, spontaneous_speech, long_form, 60_minute, mono_16khz_flac | 3600.0000 | 492.9831 | 0.1369 | 2702.6406 | — | 0.2280 | 0.1555 | 239412 | 0.0733 | 80501 | 0.2406 | 0 | — | +| silence-5s | acceptance | non_speech, silence, pcm_wav | 5.0000 | 0.1123 | 0.0225 | 414.2188 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | +| rain-10s | tuning | non_speech, background_noise, mono_44khz_ogg | 10.3310 | 0.1105 | 0.0107 | 427.5156 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | +| music-jazz-sax-24s | tuning | non_speech, music, saxophone, stereo_44khz_ogg | 24.0000 | 0.8221 | 0.0343 | 552.4844 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | +| music-36s | acceptance | non_speech, music, stereo_44khz_ogg | 36.4090 | 0.6781 | 0.0186 | 561.9375 | — | — | — | 0 | — | 0 | 0.0000 | 0 | — | +| speech-over-music-ear | tuning | speech, music, speech_over_music, short_utterance, mono_16khz_flac | 24.0000 | 3.2606 | 0.1359 | 7337.9531 | — | 1.0000 | 0.6667 | 27 | 0.0618 | 90 | 0.0038 | 0 | — | +| speech-over-music-john | acceptance | speech, music, speech_over_music, short_utterance, mono_16khz_flac | 36.4090 | 1.5423 | 0.0424 | 1196.1406 | — | 0.0000 | 0.0000 | 0 | 0.0000 | 16699 | 0.4640 | 0 | — | +| short-word-ear | tuning | speech, short_utterance, word, stereo_44khz_ogg | 0.4370 | 2.1157 | 4.8415 | 2058.3438 | — | 1.0000 | 0.6667 | 0 | 0.0000 | 0 | — | 0 | — | +| short-name-john | acceptance | speech, short_utterance, name, stereo_44khz_ogg | 0.8610 | 1.2810 | 1.4878 | 1127.1250 | — | 0.0000 | 0.0000 | 116 | 0.2755 | 55 | 0.1250 | 0 | — | +| librispeech-sample-1 | tuning | speech, read_speech, clean, mono_16khz_flac | 13.6900 | 2.4989 | 0.1825 | 1610.2812 | — | 0.0455 | 0.0041 | 92 | 0.0068 | 190 | 0.9135 | 0 | — | +| librispeech-sample-2 | acceptance | speech, read_speech, clean, mono_16khz_flac | 14.2150 | 2.5151 | 0.1769 | 1634.7344 | — | 0.0000 | 0.0000 | 0 | 0.0000 | 542 | 0.5784 | 0 | — | +| pace-slow-tuning | tuning | speech, read_speech, clean, single_speaker, pace_slow, mono_16khz_flac | 34.9400 | 4.7456 | 0.1358 | 1166.7031 | — | 0.0127 | 0.0049 | — | — | — | — | 0 | — | +| pace-normal-tuning | tuning | speech, read_speech, clean, single_speaker, pace_normal, mono_16khz_flac | 39.7550 | 5.6579 | 0.1423 | 1159.8125 | — | 0.0273 | 0.0265 | — | — | — | — | 0 | — | +| pace-fast-tuning | tuning | speech, read_speech, clean, single_speaker, pace_fast, mono_16khz_flac | 36.4550 | 6.5174 | 0.1788 | 1183.1562 | — | 0.0441 | 0.0088 | — | — | — | — | 0 | — | +| pace-slow-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_slow, mono_16khz_flac | 35.5900 | 4.9145 | 0.1381 | 1180.2969 | — | 0.0930 | 0.0208 | — | — | — | — | 0 | — | +| pace-normal-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_normal, mono_16khz_flac | 53.6300 | 7.6683 | 0.1430 | 1183.4219 | — | 0.0201 | 0.0155 | — | — | — | — | 0 | — | +| pace-fast-acceptance | acceptance | speech, read_speech, clean, single_speaker, pace_fast, mono_16khz_flac | 31.7750 | 5.5342 | 0.1742 | 1163.2031 | — | 0.0357 | 0.0100 | — | — | — | — | 0 | — | +| ami-meeting-30m | tuning | speech, meeting, multi_speaker, spontaneous_speech, long_form, 30_minute, mono_16khz_flac | 1800.0000 | 300.6465 | 0.1670 | 2537.3594 | — | 0.2720 | 0.2044 | 229963 | 0.1327 | 4416 | 0.0652 | 0 | — | +| ami-meeting-60m | acceptance | speech, meeting, multi_speaker, spontaneous_speech, long_form, 60_minute, mono_16khz_flac | 3600.0000 | 529.7534 | 0.1472 | 3004.1562 | — | 0.2281 | 0.1554 | 237332 | 0.0727 | 80501 | 0.2406 | 0 | — | ## Interpretation limits - Boundary and speech-duration metrics compare final TSV intervals with the references. They are end-to-end output metrics, not direct Silero VAD measurements. - Activity duration metrics exclude cases whose speech interval reference is null. An em dash means no interval reference was scored. - Short exact match includes recordings whose total annotated speech is within the configured short duration, even when the surrounding recording is longer. -- WER and CER score final post-processed text. The current pipeline does not expose raw decoder text, so this report cannot isolate decoder fidelity from later text mutation. +- WER and CER score the retained decoder spans after text normalization for comparison. The application does not rewrite their nonblank text. +- Segment-shape metrics count Unicode code points and measured output duration. They describe compactness, not semantic coherence, and have no enabled gates. - Audio-classifier identity is declared by the protocol and is not mechanically queried from the backend. Source and evaluator metadata aid auditing but do not hash an uncommitted runtime diff. - Pipeline settings are defined by the protocol and may differ from application defaults; interpret results only for the recorded configuration. - CER includes spaces after NFKC, casefolding, punctuation-to-space conversion, and whitespace collapse. diff --git a/evaluation/protocol.json b/evaluation/protocol.json index 526ae23..0f783db 100644 --- a/evaluation/protocol.json +++ b/evaluation/protocol.json @@ -9,18 +9,7 @@ "repository": "MIT/ast-finetuned-audioset-10-10-0.4593", "revision": "f826b80d28226b62986cc218e5cec390b1096902" }, - "pipeline": { - "pause_threshold": 2.0, - "similarity_threshold": 1.0, - "embedding_model": "all-MiniLM-L6-v2", - "min_segment_words": 1, - "min_segment_chars": 1, - "max_segment_words": 100, - "performance": { - "similarity_batch_size": 32, - "chunk_size": 1000 - } - }, + "pipeline": {}, "normalization": { "unicode_form": "NFKC", "casefold": true, diff --git a/examples/config_example.yaml b/examples/config_example.yaml index 5b17760..0a0d85a 100644 --- a/examples/config_example.yaml +++ b/examples/config_example.yaml @@ -7,21 +7,9 @@ model_name: "openai/whisper-large-v3" device: "cpu" # "auto" for the best available device, "cuda" for NVIDIA, "mps" for Apple Silicon language: "en" # language code (97+ languages supported) -# Segmentation -similarity_threshold: 0.75 # [0.0–1.0] cosine similarity required to merge two segments -pause_threshold: 2.0 # pause threshold for ordinary transcript grouping -min_segment_words: 3 # segments below this are merged with a neighbour -min_segment_chars: 15 # segments below this are merged with a neighbour -max_segment_words: 100 # segments above this are split regardless of other settings - -# Embedding model used for semantic similarity (segmentation step) -embedding_model: "all-MiniLM-L6-v2" - # Performance performance: - whisper_batch_size: 1 # deterministic default, larger batches need model-specific parity checks - similarity_batch_size: 32 # batch size for pre-encoding segment embeddings - chunk_size: 1000 # process segments in chunks of this size (0 = no chunking) + whisper_batch_size: 1 # deterministic default, larger batches need model-specific parity checks # Logging log_level: "INFO" # DEBUG, INFO, WARNING, ERROR diff --git a/pyproject.toml b/pyproject.toml index 55e72ac..db5ffa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,10 +11,8 @@ dependencies = [ "gradio>=6.0.1", "numpy>=1.26.0", "pandas>=2.3.0", - "psutil>=5.9.0", "pydub>=0.25.1", "pyyaml>=6.0.2", - "sentence-transformers>=4.1.0", "silero-vad>=5.1.0", "torch>=2.0.0,<3.0.0", "transformers>=5.13", @@ -26,6 +24,7 @@ dev = [ "jiwer==4.0.0", "pytest", "pytest-asyncio", + "psutil>=5.9.0", "ruff==0.12.0", "ty==0.0.58", "pre-commit", diff --git a/scripts/evaluate_audio_quality.py b/scripts/evaluate_audio_quality.py index f2075b1..5994c9b 100644 --- a/scripts/evaluate_audio_quality.py +++ b/scripts/evaluate_audio_quality.py @@ -215,12 +215,6 @@ def _validate_protocol(protocol: dict) -> None: ) allowed_pipeline_fields = { "device", - "pause_threshold", - "similarity_threshold", - "embedding_model", - "min_segment_words", - "min_segment_chars", - "max_segment_words", "performance", } unknown_pipeline_fields = pipeline.keys() - allowed_pipeline_fields @@ -438,7 +432,6 @@ def _infer( "torch": metadata.version("torch"), "transformers": metadata.version("transformers"), "silero-vad": metadata.version("silero-vad"), - "sentence-transformers": metadata.version("sentence-transformers"), }, "rss_sampling_interval_ms": rss_sample_interval_ms, }, @@ -649,12 +642,16 @@ def _score_subset(cases: list[dict], predictions: dict[str, dict], protocol: dic offset_errors: list[int] = [] timestamp_violation_cases = 0 timestamp_violations = 0 + segment_characters: list[int] = [] + segment_durations_ms: list[int] = [] rtfs: list[float] = [] peak_rss_bytes: list[int] = [] peak_cuda_bytes: list[int] = [] for case in cases: prediction = predictions[case["id"]] + segment_characters.extend(len(segment["text"]) for segment in prediction["segments"]) + segment_durations_ms.extend(segment["end_ms"] - segment["start_ms"] for segment in prediction["segments"]) normalized_reference = _normalize(case["reference"]["text"]) normalized_prediction = _normalize(" ".join(segment["text"] for segment in prediction["segments"])) reference_interval_data = case["reference"]["speech_intervals_ms"] @@ -798,6 +795,15 @@ def _score_subset(cases: list[dict], predictions: dict[str, dict], protocol: dic "offset_error_p95_ms": _percentile(offset_errors, 0.95), "timestamp_violation_cases": timestamp_violation_cases, "timestamp_violations": timestamp_violations, + "output_segments": len(segment_characters), + "segment_characters_median": (float(statistics.median(segment_characters)) if segment_characters else None), + "segment_characters_p95": _percentile(segment_characters, 0.95), + "segment_characters_max": max(segment_characters) if segment_characters else None, + "segment_duration_median_ms": ( + float(statistics.median(segment_durations_ms)) if segment_durations_ms else None + ), + "segment_duration_p95_ms": _percentile(segment_durations_ms, 0.95), + "segment_duration_max_ms": max(segment_durations_ms) if segment_durations_ms else None, "rtf_median": float(statistics.median(rtfs)), "rtf_p95": _percentile(rtfs, 0.95), "peak_rss_mb_max": max(peak_rss_bytes) / (1024 * 1024), @@ -1040,6 +1046,13 @@ def _score( ("Offset p95 error (ms)", "offset_error_p95_ms"), ("Timestamp violation cases", "timestamp_violation_cases"), ("Timestamp violations", "timestamp_violations"), + ("Output segments", "output_segments"), + ("Segment characters median", "segment_characters_median"), + ("Segment characters p95", "segment_characters_p95"), + ("Segment characters max", "segment_characters_max"), + ("Segment duration median (ms)", "segment_duration_median_ms"), + ("Segment duration p95 (ms)", "segment_duration_p95_ms"), + ("Segment duration max (ms)", "segment_duration_max_ms"), ("Parity mismatch cases", "parity_mismatch_cases"), ("Median RTF", "rtf_median"), ("p95 RTF", "rtf_p95"), @@ -1082,6 +1095,36 @@ def _score( + " |" ) + lines.extend( + [ + "", + "## Segment shape", + "", + "These descriptive metrics expose transcript line compactness. They are not release gates.", + "", + "| Case | Segments | Characters median | Characters p95 | Characters max | Duration median (ms) | Duration p95 (ms) | Duration max (ms) |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + ) + for case in cases: + case_metrics = _score_subset([case], predictions, protocol) + lines.append( + "| " + + " | ".join( + [ + _escape_markdown(case["id"]), + _format_value(case_metrics["output_segments"]), + _format_value(case_metrics["segment_characters_median"]), + _format_value(case_metrics["segment_characters_p95"]), + _format_value(case_metrics["segment_characters_max"]), + _format_value(case_metrics["segment_duration_median_ms"]), + _format_value(case_metrics["segment_duration_p95_ms"]), + _format_value(case_metrics["segment_duration_max_ms"]), + ] + ) + + " |" + ) + lines.extend( [ "", @@ -1148,7 +1191,8 @@ def _score( "- Boundary and speech-duration metrics compare final TSV intervals with the references. They are end-to-end output metrics, not direct Silero VAD measurements.", "- Activity duration metrics exclude cases whose speech interval reference is null. An em dash means no interval reference was scored.", "- Short exact match includes recordings whose total annotated speech is within the configured short duration, even when the surrounding recording is longer.", - "- WER and CER score final post-processed text. The current pipeline does not expose raw decoder text, so this report cannot isolate decoder fidelity from later text mutation.", + "- WER and CER score the retained decoder spans after text normalization for comparison. The application does not rewrite their nonblank text.", + "- Segment-shape metrics count Unicode code points and measured output duration. They describe compactness, not semantic coherence, and have no enabled gates.", "- Audio-classifier identity is declared by the protocol and is not mechanically queried from the backend. Source and evaluator metadata aid auditing but do not hash an uncommitted runtime diff.", "- Pipeline settings are defined by the protocol and may differ from application defaults; interpret results only for the recorded configuration.", "- CER includes spaces after NFKC, casefolding, punctuation-to-space conversion, and whitespace collapse.", diff --git a/tests/test_audio_quality_evaluation.py b/tests/test_audio_quality_evaluation.py index 5514754..a7e5e22 100644 --- a/tests/test_audio_quality_evaluation.py +++ b/tests/test_audio_quality_evaluation.py @@ -136,6 +136,8 @@ def test_score_reports_text_boundary_and_activity_metrics( assert "| False-alarm rate | 0.5000 |" in markdown assert "| Boundary median error (ms) | 50.0000 |" in markdown assert "| Timestamp violations | 0 |" in markdown + assert "| Output segments | 1 |" in markdown + assert "| speech-case | 1 | 17.0000 | 17.0000 | 17 | 900.0000 | 900.0000 | 900 |" in markdown def test_score_marks_missing_activity_reference_unavailable( diff --git a/tests/test_deduplicate_segments.py b/tests/test_deduplicate_segments.py deleted file mode 100644 index 83abbef..0000000 --- a/tests/test_deduplicate_segments.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from textplease.utils.deduplicate_segments import deduplicate_segments - - -def _seg(text: str, start: str = "00:00:00.000", end: str = "00:00:01.000") -> dict[str, str]: - return {"text": text, "start_time": start, "end_time": end} - - -def test_removes_boundary_overlap(): - segs = [_seg("the quick brown fox"), _seg("brown fox jumps over")] - out = deduplicate_segments(segs, overlap_words=15) - assert [s["text"] for s in out] == ["the quick brown fox", "jumps over"] - - -def test_no_overlap_left_unchanged(): - segs = [_seg("alpha beta"), _seg("gamma delta")] - out = deduplicate_segments(segs, overlap_words=5) - assert [s["text"] for s in out] == ["alpha beta", "gamma delta"] - - -def test_empty_list_returns_empty(): - assert deduplicate_segments([]) == [] - - -def test_missing_key_raises(): - with pytest.raises(KeyError): - deduplicate_segments([_seg("hello"), {"text": "world"}]) - - -def test_input_not_mutated(): - segs = [_seg("a b c"), _seg("b c d")] - deduplicate_segments(segs, overlap_words=5) - assert segs[1]["text"] == "b c d" diff --git a/tests/test_device_utils.py b/tests/test_device_utils.py index 431fc9a..4192c2b 100644 --- a/tests/test_device_utils.py +++ b/tests/test_device_utils.py @@ -31,23 +31,14 @@ def test_pipeline_uses_resolved_device(monkeypatch, tmp_path): input_path = tmp_path / "input.wav" input_path.touch() output_path = tmp_path / "output.csv" - embedding_model = object() - calls = Mock() - sentence_transformer = Mock(return_value=embedding_model) transcribe_audio = Mock( return_value=[ {"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": "First test segment."}, {"start_time": "00:00:02.000", "end_time": "00:00:03.000", "text": "Second test segment."}, ] ) - segment_transcript = Mock(side_effect=lambda segments, **kwargs: segments) - calls.attach_mock(transcribe_audio, "transcription") - calls.attach_mock(sentence_transformer, "embedding") - monkeypatch.setattr(pipeline, "detect_device", lambda device: "cuda") - monkeypatch.setattr(pipeline, "SentenceTransformer", sentence_transformer) monkeypatch.setattr(pipeline, "transcribe_audio", transcribe_audio) - monkeypatch.setattr(pipeline, "segment_transcript", segment_transcript) pipeline.run_transcription_pipeline( { @@ -62,6 +53,4 @@ def test_pipeline_uses_resolved_device(monkeypatch, tmp_path): assert transcribe_audio.call_args.args[2] == "cuda" assert "temporary_directory" in transcribe_audio.call_args.kwargs assert transcribe_audio.call_args.kwargs["batch_size"] == 1 - assert sentence_transformer.call_args.kwargs["device"] == "cuda" - assert segment_transcript.call_args.kwargs["preferred_device"] == "cuda" - assert [entry[0] for entry in calls.mock_calls] == ["transcription", "embedding"] + assert output_path.read_text(encoding="utf-8").splitlines()[1].endswith("First test segment.") diff --git a/tests/test_gradio_ui.py b/tests/test_gradio_ui.py index a41da12..59150a5 100644 --- a/tests/test_gradio_ui.py +++ b/tests/test_gradio_ui.py @@ -20,11 +20,6 @@ def test_start_transcription_uses_gradio_cached_file(tmp_path): worker, output_dir, str(upload_path), - 0.75, - 4.5, - 100, - 3, - 15, "en", "cpu", None, @@ -40,7 +35,14 @@ def test_start_transcription_uses_gradio_cached_file(tmp_path): assert run["output_path"] == workspace_path / "recording_transcript.csv" assert run["config_path"] == workspace_path / "config.yaml" assert yaml.safe_load(run["config_path"].read_text()) == config - assert config["pause_threshold"] == 4.5 + assert set(config) == { + "input_path", + "output_path", + "model_name", + "device", + "log_level", + "language", + } if os.name != "nt": assert workspace_path.stat().st_mode & 0o777 == 0o700 assert run["config_path"].stat().st_mode & 0o777 == 0o600 @@ -59,9 +61,7 @@ def test_second_start_preserves_active_job(tmp_path): worker.submit.return_value = 7 worker.is_running.return_value = True - first = gradio_ui.start_transcription( - worker, output_dir, str(upload_path), 0.75, 2.0, 100, 3, 15, "en", "cpu", None - ) + first = gradio_ui.start_transcription(worker, output_dir, str(upload_path), "en", "cpu", None) active_run = first[-1] config_contents = active_run["config_path"].read_text() @@ -69,11 +69,6 @@ def test_second_start_preserves_active_job(tmp_path): worker, output_dir, str(upload_path), - 0.75, - 2.0, - 100, - 3, - 15, "en", "cpu", active_run, @@ -101,11 +96,6 @@ def test_same_name_jobs_keep_immutable_results(monkeypatch, tmp_path): worker, output_dir, str(upload_path), - 0.75, - 2.0, - 100, - 3, - 15, "en", "cpu", None, @@ -119,11 +109,6 @@ def test_same_name_jobs_keep_immutable_results(monkeypatch, tmp_path): worker, output_dir, str(upload_path), - 0.75, - 2.0, - 100, - 3, - 15, "en", "cpu", first, @@ -202,11 +187,6 @@ def test_clear_cancels_job_and_deletes_artifacts(tmp_path): worker, output_dir, str(upload_path), - 0.75, - 2.0, - 100, - 3, - 15, "en", "cpu", None, diff --git a/tests/test_model_reuse.py b/tests/test_model_reuse.py index a500a6c..54f1243 100644 --- a/tests/test_model_reuse.py +++ b/tests/test_model_reuse.py @@ -2,66 +2,11 @@ from unittest.mock import Mock import numpy as np -import torch -from textplease import pipeline, segmenter from textplease.backends import transformers_pipeline from textplease.utils.audio_utils import TARGET_SAMPLE_RATE -def test_pipeline_reuses_embedding_model(monkeypatch, tmp_path): - input_path = tmp_path / "input.wav" - input_path.touch() - output_path = tmp_path / "output.csv" - embedding_model = object() - sentence_transformer = Mock(return_value=embedding_model) - segments = [ - {"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": "First test segment."}, - {"start_time": "00:00:02.000", "end_time": "00:00:03.000", "text": "Second test segment."}, - ] - - monkeypatch.setattr(pipeline, "transcribe_audio", lambda *args, **kwargs: segments) - monkeypatch.setattr(pipeline, "SentenceTransformer", sentence_transformer) - monkeypatch.setattr(pipeline, "segment_transcript", lambda transcript, **kwargs: transcript) - - pipeline._load_embedding_model.cache_clear() - try: - config = { - "input_path": str(input_path), - "output_path": str(output_path), - "model_name": "test-model", - } - pipeline.run_transcription_pipeline(config) - pipeline.run_transcription_pipeline(config) - finally: - pipeline._load_embedding_model.cache_clear() - - sentence_transformer.assert_called_once_with( - "all-MiniLM-L6-v2", - device="cpu", - ) - - -def test_segmenter_allows_embedding_model_download(monkeypatch): - embedding_model = Mock() - embedding_model.encode.return_value = torch.tensor([[1.0, 0.0], [1.0, 0.0]]) - sentence_transformer = Mock(return_value=embedding_model) - monkeypatch.setattr(segmenter, "SentenceTransformer", sentence_transformer) - - segmenter.segment_transcript( - [ - {"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": "First segment."}, - {"start_time": "00:00:01.000", "end_time": "00:00:02.000", "text": "Second segment."}, - ], - embedding_model_name="test-embedding-model", - ) - - sentence_transformer.assert_called_once_with( - "test-embedding-model", - device="cpu", - ) - - def test_transcriber_reuses_whisper_model(monkeypatch): class LoadedModel: config = SimpleNamespace(max_source_positions=1500) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ddcacf4..8a44eac 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -6,40 +6,37 @@ import pytest -from textplease import pipeline, segmenter +from textplease import pipeline -def test_similarity_threshold_one_skips_embedding_model(monkeypatch, tmp_path): +def test_pipeline_preserves_recognized_segments_exactly(monkeypatch, tmp_path): input_path = tmp_path / "input.wav" input_path.touch() output_path = tmp_path / "output.csv" - sentence_transformer = Mock(side_effect=AssertionError("Embedding model should not load")) - - monkeypatch.setattr( - pipeline, - "transcribe_audio", - lambda *args, **kwargs: [ - {"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": "Short"}, - {"start_time": "00:00:01.500", "end_time": "00:00:02.000", "text": "fragment"}, - ], - ) - monkeypatch.setattr(pipeline, "SentenceTransformer", sentence_transformer) - monkeypatch.setattr(segmenter, "SentenceTransformer", sentence_transformer) + recognized_segments = [ + { + "start_time": "00:00:00.000", + "end_time": "00:00:01.000", + "text": " Thank you for watching. Next sentence.", + }, + {"start_time": "00:00:01.500", "end_time": "00:00:02.000", "text": "repeat boundary"}, + {"start_time": "00:00:02.500", "end_time": "00:00:03.000", "text": "repeat boundary stays 世界 "}, + ] + + monkeypatch.setattr(pipeline, "transcribe_audio", lambda *args, **kwargs: recognized_segments) pipeline.run_transcription_pipeline( { "input_path": str(input_path), "output_path": str(output_path), "model_name": "test-model", - "similarity_threshold": 1.0, } ) with output_path.open(newline="") as output_file: rows = list(csv.DictReader(output_file, delimiter="\t")) - sentence_transformer.assert_not_called() - assert [row["text"] for row in rows] == ["Short fragment"] + assert rows == recognized_segments def test_no_speech_writes_header_only_transcript(monkeypatch, tmp_path, caplog): @@ -47,11 +44,8 @@ def test_no_speech_writes_header_only_transcript(monkeypatch, tmp_path, caplog): input_path.touch() output_path = tmp_path / "output.csv" output_path.write_text("previous transcript", encoding="utf-8") - sentence_transformer = Mock(side_effect=AssertionError("Embedding model should not load")) monkeypatch.setattr(pipeline, "transcribe_audio", lambda *args, **kwargs: []) - monkeypatch.setattr(pipeline, "SentenceTransformer", sentence_transformer) - monkeypatch.setattr(segmenter, "SentenceTransformer", sentence_transformer) with caplog.at_level(logging.INFO): pipeline.run_transcription_pipeline( @@ -66,53 +60,44 @@ def test_no_speech_writes_header_only_transcript(monkeypatch, tmp_path, caplog): if os.name != "nt": assert output_path.stat().st_mode & 0o777 == 0o600 assert "No speech was transcribed" in caplog.text - sentence_transformer.assert_not_called() -def test_pause_threshold_only_controls_transcript_grouping(monkeypatch, tmp_path): +def test_pipeline_rejects_input_as_output_before_transcription(monkeypatch, tmp_path): input_path = tmp_path / "input.wav" - input_path.touch() - output_path = tmp_path / "output.csv" - recognized_segments = [ - {"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": "First complete segment."}, - {"start_time": "00:00:02.000", "end_time": "00:00:03.000", "text": "Second complete segment."}, - ] - transcribe_audio = Mock(return_value=recognized_segments) - segment_transcript = Mock(return_value=recognized_segments) + input_path.write_bytes(b"original audio") + transcribe_audio = Mock() monkeypatch.setattr(pipeline, "transcribe_audio", transcribe_audio) - monkeypatch.setattr(pipeline, "segment_transcript", segment_transcript) - pipeline.run_transcription_pipeline( - { - "input_path": str(input_path), - "output_path": str(output_path), - "model_name": "test-model", - "pause_threshold": 4.5, - "similarity_threshold": 1.0, - } - ) + with pytest.raises(ValueError, match="different files"): + pipeline.run_transcription_pipeline( + { + "input_path": str(input_path), + "output_path": str(input_path), + "model_name": "test-model", + } + ) - assert "pause_threshold" not in transcribe_audio.call_args.kwargs - assert segment_transcript.call_args.kwargs["pause_threshold"] == 4.5 + transcribe_audio.assert_not_called() + assert input_path.read_bytes() == b"original audio" -def test_pipeline_rejects_input_as_output_before_transcription(monkeypatch, tmp_path): +def test_pipeline_rejects_removed_text_mutation_settings(monkeypatch, tmp_path): input_path = tmp_path / "input.wav" - input_path.write_bytes(b"original audio") + input_path.touch() transcribe_audio = Mock() monkeypatch.setattr(pipeline, "transcribe_audio", transcribe_audio) - with pytest.raises(ValueError, match="different files"): + with pytest.raises(ValueError, match="Unknown config keys.*similarity_threshold"): pipeline.run_transcription_pipeline( { "input_path": str(input_path), - "output_path": str(input_path), + "output_path": str(tmp_path / "output.csv"), "model_name": "test-model", + "similarity_threshold": 0.75, } ) transcribe_audio.assert_not_called() - assert input_path.read_bytes() == b"original audio" def test_pipeline_rejects_symlinked_output_to_input(monkeypatch, tmp_path): @@ -192,17 +177,3 @@ def fail_after_partial_write(frame, path, **kwargs): assert output_path.read_text() == "existing transcript" assert not (temporary_directory / "transcript.tsv").exists() - - -def test_short_segment_log_does_not_include_transcript(caplog): - private_text = "My private account recovery phrase" - - with caplog.at_level(logging.WARNING): - segmenter.post_process_segments( - [{"start_time": "00:00:00.000", "end_time": "00:00:01.000", "text": private_text}], - min_words=10, - min_chars=100, - ) - - assert "Keeping a short segment that cannot be merged" in caplog.text - assert private_text not in caplog.text diff --git a/tests/test_whisper_batching.py b/tests/test_whisper_batching.py index abeb863..55d01c1 100644 --- a/tests/test_whisper_batching.py +++ b/tests/test_whisper_batching.py @@ -176,7 +176,7 @@ def test_transcribe_clamps_offsets_and_preserves_terminal_text(monkeypatch): interval_end = 3 * TARGET_SAMPLE_RATE // 4 audio = np.ones(TARGET_SAMPLE_RATE, dtype=np.float32) offsets = [ - {"text": "Leading", "timestamp": (-1.0, 0.2)}, + {"text": " Leading. Still leading.", "timestamp": (-1.0, 0.2)}, {"text": " overlap", "timestamp": (0.1, 0.3)}, {"text": " terminal", "timestamp": (0.4, None)}, ] @@ -208,9 +208,13 @@ def test_transcribe_clamps_offsets_and_preserves_terminal_text(monkeypatch): ) assert segments == [ - {"text": "Leading", "start_time": "00:00:00.250", "end_time": "00:00:00.450"}, - {"text": "overlap", "start_time": "00:00:00.450", "end_time": "00:00:00.550"}, - {"text": "terminal", "start_time": "00:00:00.650", "end_time": "00:00:00.750"}, + { + "text": " Leading. Still leading.", + "start_time": "00:00:00.250", + "end_time": "00:00:00.450", + }, + {"text": " overlap", "start_time": "00:00:00.450", "end_time": "00:00:00.550"}, + {"text": " terminal", "start_time": "00:00:00.650", "end_time": "00:00:00.750"}, ] diff --git a/textplease/__init__.py b/textplease/__init__.py index d2aea4e..272c44a 100644 --- a/textplease/__init__.py +++ b/textplease/__init__.py @@ -1,4 +1,4 @@ -"""TextPlease: A unified audio transcription and segmentation library.""" +"""Local audio transcription with open-source ASR models.""" import logging diff --git a/textplease/backends/transformers_pipeline.py b/textplease/backends/transformers_pipeline.py index 105d536..23a78f4 100644 --- a/textplease/backends/transformers_pipeline.py +++ b/textplease/backends/transformers_pipeline.py @@ -1,7 +1,6 @@ """Whisper ASR backend with local speech and music detection.""" import gc -import re import logging from typing import TypedDict from functools import lru_cache @@ -273,11 +272,17 @@ def _offsets_to_segments(offsets: list[_WhisperOffset]) -> list[dict[str, str]]: """Convert decoded timestamp offsets to the standard {start_time, end_time, text} format.""" segments: list[dict[str, str]] = [] for chunk in offsets: - text = chunk.get("text", "").strip() + text = chunk.get("text", "") ts = chunk.get("timestamp", (0.0, 0.0)) - if not text or len(ts) != 2 or ts[0] is None or ts[1] is None: + if not text.strip() or len(ts) != 2 or ts[0] is None or ts[1] is None: continue - segments.extend(_split_chunk_by_sentences(text, float(ts[0]), float(ts[1]))) + segments.append( + { + "start_time": format_time(float(ts[0])), + "end_time": format_time(float(ts[1])), + "text": text, + } + ) return segments @@ -336,29 +341,3 @@ def transcribe( logger.info("Whisper returned no usable timestamped text for detected speech") return [] - - -def _split_chunk_by_sentences(text: str, start_time: float, end_time: float) -> list[dict[str, str]]: - """Split a segment's text at sentence boundaries, distributing duration proportionally.""" - sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] - if len(sentences) <= 1: - return [{"start_time": format_time(start_time), "end_time": format_time(end_time), "text": text}] - - duration = end_time - start_time - total_chars = sum(len(s) for s in sentences) - if total_chars == 0: - return [{"start_time": format_time(start_time), "end_time": format_time(end_time), "text": text}] - - segments: list[dict[str, str]] = [] - current_time = start_time - for sentence in sentences: - end = min(current_time + (len(sentence) / total_chars) * duration, end_time) - segments.append( - { - "start_time": format_time(current_time), - "end_time": format_time(end), - "text": sentence, - } - ) - current_time = end - return segments diff --git a/textplease/gradio_ui.py b/textplease/gradio_ui.py index 837b449..8984e93 100644 --- a/textplease/gradio_ui.py +++ b/textplease/gradio_ui.py @@ -12,7 +12,6 @@ from pydub.utils import mediainfo from pydub.exceptions import CouldntDecodeError -from textplease.pipeline import DEFAULT_EMBEDDING_MODEL from textplease.gradio_worker import CANCELLED_ERROR, PersistentPipelineWorker from textplease.utils.device_utils import detect_device @@ -76,11 +75,6 @@ def start_transcription( worker: PersistentPipelineWorker, output_directory: Path, audio_file: str | None, - similarity_threshold: float, - pause_threshold: float, - max_segment_words: int, - min_segment_words: int, - min_segment_chars: int, language: str, device: str, run: dict | None, @@ -130,12 +124,6 @@ def error_result(message: str) -> tuple[object, ...]: "output_path": str(output_path), "model_name": DEFAULT_MODEL, "device": device, - "similarity_threshold": similarity_threshold, - "pause_threshold": pause_threshold, - "max_segment_words": max_segment_words, - "min_segment_words": min_segment_words, - "min_segment_chars": min_segment_chars, - "embedding_model": DEFAULT_EMBEDDING_MODEL, "log_level": "INFO", "language": language, } @@ -199,9 +187,7 @@ def check_completion( except OSError: log_text = "" - if "Starting segmentation" in log_text: - status = f"⏳ Segmenting transcript... · {elapsed}s elapsed" - elif matches := _PROGRESS_RE.findall(log_text): + if matches := _PROGRESS_RE.findall(log_text): # The log line marks a segment *starting*, so one fewer is actually complete. current, total = int(matches[-1][0]), int(matches[-1][1]) completed = current - 1 @@ -408,51 +394,12 @@ def launch_gradio( ) with gr.Accordion("⚙️ Advanced Settings", open=False): - with gr.Row(): - device = gr.Dropdown( - choices=["auto", "cpu", "cuda", "mps"], - value=best_device, - label="Device", - info="Auto: best available | CPU: universal | CUDA: NVIDIA GPU | MPS: Apple Silicon", - ) - similarity_threshold = gr.Slider( - 0.0, - 1.0, - step=0.01, - value=0.75, - label="Similarity Threshold", - info="Higher = more segments split", - ) - pause_threshold = gr.Slider( - 0.0, - 10.0, - step=0.1, - value=2.0, - label="Transcript Grouping Pause (seconds)", - info="Used only when grouping recognized segments. Speech detection is automatic.", - ) - with gr.Row(): - max_segment_words = gr.Slider( - 10, - 200, - step=5, - value=100, - label="Max Segment Words", - ) - min_segment_words = gr.Slider( - 1, - 20, - value=3, - step=1, - label="Min Segment Words", - ) - min_segment_chars = gr.Slider( - 1, - 100, - value=15, - step=1, - label="Min Segment Characters", - ) + device = gr.Dropdown( + choices=["auto", "cpu", "cuda", "mps"], + value=best_device, + label="Device", + info="Auto: best available | CPU: universal | CUDA: NVIDIA GPU | MPS: Apple Silicon", + ) with gr.Row(equal_height=True): language = gr.Dropdown( @@ -507,11 +454,6 @@ def launch_gradio( partial(start_transcription, worker, output_directory), inputs=[ audio_input, - similarity_threshold, - pause_threshold, - max_segment_words, - min_segment_words, - min_segment_chars, language, device, run_state, diff --git a/textplease/gradio_worker.py b/textplease/gradio_worker.py index eb5de14..de9bd77 100644 --- a/textplease/gradio_worker.py +++ b/textplease/gradio_worker.py @@ -59,7 +59,7 @@ def __init__(self, runner: Callable[[dict], None] = run_transcription_pipeline): self._next_job_id = 1 self._results: dict[int, str | None] = {} self._temporary_directories: dict[int, tempfile.TemporaryDirectory[str]] = {} - self._model_key: tuple[str, str, str] | None = None + self._model_key: tuple[str, str] | None = None self._last_exitcode: int | None = None @property @@ -91,7 +91,6 @@ def submit(self, config: dict, log_path: str) -> int: model_key = ( config["model_name"], config.get("device", "cpu"), - config.get("embedding_model", "all-MiniLM-L6-v2"), ) if self._process is not None and self._process.is_alive() and model_key != self._model_key: self._stop_worker() diff --git a/textplease/main.py b/textplease/main.py index 1d32214..a5faba2 100644 --- a/textplease/main.py +++ b/textplease/main.py @@ -30,7 +30,7 @@ def load_config(path: str) -> dict: def main() -> None: """Provide main entry point for the textplease CLI.""" - parser = argparse.ArgumentParser(description="Transcribe and segment audio using open-source ASR models.") + parser = argparse.ArgumentParser(description="Transcribe audio locally using open-source ASR models.") parser.add_argument("--config", help="Path to YAML config file.") parser.add_argument("--gradio", action="store_true", help="Launch the Gradio UI instead of CLI pipeline.") diff --git a/textplease/pipeline.py b/textplease/pipeline.py index bcaf4b4..f35af29 100644 --- a/textplease/pipeline.py +++ b/textplease/pipeline.py @@ -1,49 +1,20 @@ import os -import re import time import logging import tempfile from pathlib import Path -from functools import lru_cache from contextlib import nullcontext import pandas as pd -from sentence_transformers import SentenceTransformer -from textplease.segmenter import segment_transcript, post_process_segments from textplease.transcriber import transcribe_audio from textplease.utils.device_utils import detect_device -from textplease.utils.deduplicate_segments import deduplicate_segments logger = logging.getLogger(__name__) -DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2" -# Whisper hallucinates these phrases into silence regions. -# Patterns are checked case-insensitively against the full segment text. -_HALLUCINATION_PATTERNS: list[re.Pattern] = [ - re.compile(p, re.IGNORECASE) - for p in [ - # English - r"thank\s+you\s+for\s+watching", - r"thanks\s+for\s+watching", - r"please\s+subscribe", - r"subtitles?\s+by", - r"transcribed\s+by", - r"translation\s+by", - # Russian - r"субтитры\s+(созданы|создавал|сделаны|сде[а-яё]*)", - r"продолжение\s+следует", - r"подписывайтесь\s+на\s+канал", - ] -] - -# Phrase repeated ≥3 times consecutively within one segment = hallucination loop. -_REPEATED_PHRASE = re.compile(r"(.{4,40}?)(\s+\1){2,}", re.IGNORECASE) - - -def save_to_csv(segments: list, output_path: str, temporary_directory: str | Path) -> str: +def save_to_csv(segments: list[dict[str, str]], output_path: str, temporary_directory: str | Path) -> str: """Save segments to a tab-separated CSV file.""" if segments: df = pd.DataFrame(segments) @@ -67,20 +38,6 @@ def save_to_csv(segments: list, output_path: str, temporary_directory: str | Pat return output_path -def estimate_processing_time(num_segments: int) -> str: - """Estimate segmentation processing time based on segment count.""" - if num_segments < 100: - return "< 1 minute" - elif num_segments < 500: - return "1–3 minutes" - elif num_segments < 1000: - return "3–5 minutes" - elif num_segments < 5000: - return "5–15 minutes" - else: - return "15+ minutes" - - def _validate_pipeline_config(config: dict) -> None: """Validate configuration for the transcription pipeline.""" if not isinstance(config, dict): @@ -90,6 +47,27 @@ def _validate_pipeline_config(config: dict) -> None: if missing: raise ValueError(f"Config missing required keys: {missing}") + supported = { + "input_path", + "output_path", + "model_name", + "device", + "language", + "log_level", + "performance", + "_temporary_directory", + } + unknown = set(config) - supported + if unknown: + raise ValueError(f"Unknown config keys: {unknown}") + + performance = config.get("performance", {}) + if not isinstance(performance, dict): + raise ValueError("Config performance must be a dictionary") + unknown_performance = set(performance) - {"whisper_batch_size"} + if unknown_performance: + raise ValueError(f"Unknown performance config keys: {unknown_performance}") + input_path = config["input_path"] if not input_path or not isinstance(input_path, str): raise ValueError(f"Invalid input_path: {input_path}") @@ -107,128 +85,22 @@ def _validate_pipeline_config(config: dict) -> None: raise ValueError("Input and output paths must refer to different files") -def _extract_config_params(config: dict) -> dict: - """Extract and normalise pipeline parameters from config.""" - return { - "input_path": config["input_path"], - "output_path": config["output_path"], - "model_name": config["model_name"], - "device": config.get("device", "cpu"), - "pause_threshold": config.get("pause_threshold", 2.0), - "similarity_threshold": config.get("similarity_threshold", 0.75), - "embedding_model_name": config.get("embedding_model", DEFAULT_EMBEDDING_MODEL), - "min_segment_words": config.get("min_segment_words", 3), - "min_segment_chars": config.get("min_segment_chars", 15), - "max_segment_words": config.get("max_segment_words", 100), - "language": config.get("language", "en"), - "whisper_batch_size": config.get("performance", {}).get("whisper_batch_size", 1), - "similarity_batch_size": config.get("performance", {}).get("similarity_batch_size", 32), - "chunk_size": config.get("performance", {}).get("chunk_size", 1000), - } - - -@lru_cache(maxsize=1) -def _load_embedding_model(model_name: str, device: str) -> SentenceTransformer: - logger.info(f"Loading SentenceTransformer '{model_name}' on: {device}") - return SentenceTransformer(model_name, device=device) - - -def _filter_hallucinations(segments: list[dict]) -> list[dict]: - """Strip known Whisper hallucination phrases and drop repetition-loop segments.""" - cleaned = [] - for seg in segments: - text = seg["text"] - - # Collapse repetition loops but keep any trailing real content. - text = _REPEATED_PHRASE.sub(lambda m: m.group(1), text).strip() - - for pattern in _HALLUCINATION_PATTERNS: - text = pattern.sub("", text).strip() - - if not text: - logger.debug(f"Dropping empty-after-hallucination-strip segment [{seg['start_time']}]") - continue - - cleaned.append({**seg, "text": text}) - - removed = len(segments) - len(cleaned) - if removed: - logger.info(f"Hallucination filter removed {removed} segments") - return cleaned - - -def _execute_transcription_stage(params: dict, temporary_directory: str | Path) -> list[dict]: - """Transcribe original media, deduplicate boundaries, and filter hallucinations.""" - t0 = time.time() - segments = transcribe_audio( - params["input_path"], - params["model_name"], - params["device"], - temporary_directory=temporary_directory, - language=params["language"], - batch_size=params["whisper_batch_size"], - ) - logger.info(f"Transcription: {len(segments)} segments in {time.time() - t0:.2f}s") - - t1 = time.time() - # Use a wider window (15 words) — the 5-second stride at typical speech rate - # produces 10-20 words of overlap, which a 5-word window silently misses. - segments = deduplicate_segments(segments, overlap_words=15) - logger.info(f"Deduplication: {len(segments)} segments remaining in {time.time() - t1:.2f}s") - - segments = _filter_hallucinations(segments) - return segments - - -def _execute_segmentation_stage(segments: list, params: dict, model: SentenceTransformer | None) -> list[dict]: - """Merge segments semantically using sentence embeddings.""" - logger.info(f"Estimated segmentation time: {estimate_processing_time(len(segments))}") - t0 = time.time() - coherent = segment_transcript( - segments, - similarity_threshold=params["similarity_threshold"], - pause_threshold=params["pause_threshold"], - model=model, - max_words=params["max_segment_words"], - min_words=params["min_segment_words"], - min_chars=params["min_segment_chars"], - embedding_model_name=params["embedding_model_name"], - preferred_device=params["device"], - batch_size=params["similarity_batch_size"], - chunk_size=params["chunk_size"], - ) - logger.info(f"Segmentation: {len(coherent)} segments in {time.time() - t0:.2f}s") - return coherent - - -def _execute_post_processing(segments: list, params: dict) -> list[dict]: - """Enforce min/max segment length constraints.""" - t0 = time.time() - final = post_process_segments( - segments, - min_words=params["min_segment_words"], - min_chars=params["min_segment_chars"], - max_words=params["max_segment_words"], - ) - logger.info(f"Post-processing: {len(final)} final segments in {time.time() - t0:.2f}s") - return final - - def run_transcription_pipeline(config: dict) -> None: """Run the complete transcription pipeline.""" start = time.time() _validate_pipeline_config(config) - params = _extract_config_params(config) - params["device"] = detect_device(params["device"]) + input_path = config["input_path"] + output_path = config["output_path"] + model_name = config["model_name"] + device = detect_device(config.get("device", "cpu")) + language = config.get("language", "en") + performance = config.get("performance", {}) + whisper_batch_size = performance.get("whisper_batch_size", 1) - logger.info(f"Input: {params['input_path']} → Output: {params['output_path']}") - logger.info(f"ASR: {params['model_name']} | Device: {params['device']}") - logger.info( - f"Segmentation: {params['embedding_model_name']} | " - f"Similarity threshold: {params['similarity_threshold']} | Grouping pause: {params['pause_threshold']}s" - ) + logger.info(f"Input: {input_path} → Output: {output_path}") + logger.info(f"ASR: {model_name} | Device: {device}") - output_parent = Path(params["output_path"]).resolve().parent + output_parent = Path(output_path).resolve().parent output_parent.mkdir(parents=True, exist_ok=True) provided_directory = config.get("_temporary_directory") if provided_directory is not None and ( @@ -241,18 +113,19 @@ def run_transcription_pipeline(config: dict) -> None: else tempfile.TemporaryDirectory(prefix=".textplease-", dir=output_parent) ) with temporary_directory as work_directory: - segments = _execute_transcription_stage(params, work_directory) - - embedding_model = None - if len(segments) > 1 and params["similarity_threshold"] < 1.0: - t0 = time.time() - embedding_model = _load_embedding_model(params["embedding_model_name"], params["device"]) - logger.info(f"SentenceTransformer loaded in {time.time() - t0:.2f}s") + t0 = time.time() + segments = transcribe_audio( + input_path, + model_name, + device, + temporary_directory=work_directory, + language=language, + batch_size=whisper_batch_size, + ) + logger.info(f"Transcription: {len(segments)} segments in {time.time() - t0:.2f}s") - coherent = _execute_segmentation_stage(segments, params, embedding_model) - final = _execute_post_processing(coherent, params) t0 = time.time() - save_to_csv(final, params["output_path"], work_directory) + save_to_csv(segments, output_path, work_directory) logger.info(f"Save: {time.time() - t0:.2f}s") logger.info(f"Total processing time: {time.time() - start:.2f}s") diff --git a/textplease/segmenter.py b/textplease/segmenter.py deleted file mode 100644 index f8a12d1..0000000 --- a/textplease/segmenter.py +++ /dev/null @@ -1,410 +0,0 @@ -import gc -import logging - -import torch -import psutil -from sentence_transformers import SentenceTransformer, util - -from textplease.utils.time_utils import parse_time_str, format_time_precise -from textplease.utils.device_utils import detect_device - - -logger = logging.getLogger(__name__) - - -class MemoryMonitor: - """Monitor RSS memory usage during processing.""" - - def __init__(self, warn_threshold_gb: float = 2.0): - """Initialise with a warning threshold in GB.""" - self.warn_threshold_gb = warn_threshold_gb - self.process = psutil.Process() - - def get_memory_usage(self) -> float: - """Return current RSS memory usage in GB.""" - return self.process.memory_info().rss / (1024**3) - - def check_memory(self, context: str = "") -> bool: - """Warn and return True if memory exceeds threshold.""" - memory_gb = self.get_memory_usage() - if memory_gb > self.warn_threshold_gb: - logger.warning(f"High memory usage: {memory_gb:.2f}GB {context}") - return True - return False - - def force_cleanup(self) -> None: - """Run garbage collection and clear GPU cache if available.""" - gc.collect() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - -class SimilarityComputer: - """Compute semantic similarity between segment texts using pre-encoded cached embeddings.""" - - def __init__(self, model: SentenceTransformer, batch_size: int = 32): - """Initialise with a loaded SentenceTransformer and batch size.""" - self.model = model - self.batch_size = batch_size - self.embedding_cache: dict[str, torch.Tensor] = {} - - def precompute_embeddings(self, texts: list[str]) -> None: - """Encode all unique texts in one batched call and store in the cache.""" - unique = list({t for t in texts if t.strip()}) - if not unique: - return - logger.info(f"Pre-encoding {len(unique)} unique segment texts (batch_size={self.batch_size})") - try: - embeddings = self.model.encode(unique, batch_size=self.batch_size, convert_to_tensor=True) - if not isinstance(embeddings, torch.Tensor): - raise TypeError("SentenceTransformer did not return tensor embeddings") - self.embedding_cache.update(dict(zip(unique, embeddings))) - except Exception as e: - logger.warning(f"Batch pre-encoding failed: {e}. Similarity will fall back to 0.0.") - - def compute_similarity(self, text1: str, text2: str) -> float: - """Return cosine similarity for a cached pair of texts.""" - if not text1.strip() or not text2.strip(): - return 0.0 - try: - emb1 = self.embedding_cache.get(text1) - emb2 = self.embedding_cache.get(text2) - if emb1 is None or emb2 is None: - # Fallback for texts not in cache (e.g. merged segment text) - texts_to_encode = [t for t, e in [(text1, emb1), (text2, emb2)] if e is None] - encoded = self.model.encode(texts_to_encode, convert_to_tensor=True) - if not isinstance(encoded, torch.Tensor): - raise TypeError("SentenceTransformer did not return tensor embeddings") - for t, e in zip(texts_to_encode, encoded): - self.embedding_cache[t] = e - emb1 = self.embedding_cache[text1] - emb2 = self.embedding_cache[text2] - return util.pytorch_cos_sim(emb1, emb2).item() - except Exception as e: - logger.warning(f"Error computing similarity: {e}") - return 0.0 - - -def is_segment_too_short(text: str, min_words: int = 3, min_chars: int = 15) -> bool: - """Return True if text is below word or character threshold.""" - words = text.strip().split() - return len(words) < min_words or len(text.strip()) < min_chars - - -def _should_merge( - current_text: str, - next_text: str, - pause_duration: float, - similarity_computer: SimilarityComputer | None, - similarity_threshold: float, - max_pause: float, -) -> bool: - """Return True if two segments are semantically similar within the pause limit.""" - if not current_text.strip() or not next_text.strip(): - return False - if similarity_computer is None or pause_duration > max_pause: - return False - return similarity_computer.compute_similarity(current_text, next_text) > similarity_threshold - - -def merge_segments_if_short( - processed: list[dict], - current: dict, - max_words: int, - min_words: int, - min_chars: int, -) -> bool: - """Merge a too-short segment into the previous one if within word limit.""" - if is_segment_too_short(current["text"], min_words, min_chars) and processed: - prev = processed[-1] - if len(prev["text"].split()) + len(current["text"].split()) <= max_words: - prev["text"] += " " + current["text"] - prev["end_time"] = current["end_time"] - return True - return False - - -def split_long_segment(segment: dict, max_words_per_chunk: int) -> list[dict]: - """Split a segment that exceeds max_words into equal-duration chunks.""" - words = segment["text"].split() - total_words = len(words) - if total_words <= max_words_per_chunk: - return [segment] - - start_sec = parse_time_str(segment["start_time"]) - end_sec = parse_time_str(segment["end_time"]) - secs_per_word = (end_sec - start_sec) / total_words if total_words else 1.0 - - result = [] - for i in range(0, total_words, max_words_per_chunk): - chunk = words[i : i + max_words_per_chunk] - chunk_start = start_sec + i * secs_per_word - chunk_end = chunk_start + len(chunk) * secs_per_word - result.append( - { - "start_time": format_time_precise(chunk_start), - "end_time": format_time_precise(chunk_end), - "text": " ".join(chunk), - } - ) - return result - - -def _merge_segments( - segments: list[dict], - similarity_computer: SimilarityComputer | None, - similarity_threshold: float, - pause_threshold: float, - max_words: int, - min_words: int, - min_chars: int, - memory_monitor: MemoryMonitor | None = None, -) -> list[dict]: - """Merge a sequence of segments by semantic similarity and pause duration.""" - merged: list[dict] = [] - current = segments[0].copy() - - for i, nxt in enumerate(segments[1:], 1): - if memory_monitor and i % 100 == 0: - memory_monitor.check_memory(f"at segment {i}") - - nxt_text = nxt["text"] - pause = parse_time_str(nxt["start_time"]) - parse_time_str(current["end_time"]) - cur_words = len(current["text"].split()) - nxt_words = len(nxt_text.split()) - - do_merge = ( - ( - is_segment_too_short(current["text"], min_words, min_chars) - or is_segment_too_short(nxt_text, min_words, min_chars) - ) - and pause <= pause_threshold - or ( - _should_merge( - current["text"], - nxt_text, - pause, - similarity_computer, - similarity_threshold, - pause_threshold, - ) - and cur_words < max_words - ) - ) - - if do_merge and cur_words + nxt_words <= max_words: - current["end_time"] = nxt["end_time"] - current["text"] += " " + nxt_text - else: - if not merge_segments_if_short(merged, current, max_words, min_words, min_chars): - merged.append(current) - current = nxt.copy() - - if not merge_segments_if_short(merged, current, max_words, min_words, min_chars): - merged.append(current) - - return merged - - -def _merge_chunk_boundaries( - prev_last: dict, - curr_first: dict, - similarity_computer: SimilarityComputer | None, - similarity_threshold: float, - pause_threshold: float, - max_words: int, -) -> bool: - """Return True if the boundary segments from adjacent chunks should merge.""" - pause = parse_time_str(curr_first["start_time"]) - parse_time_str(prev_last["end_time"]) - if not _should_merge( - prev_last["text"], - curr_first["text"], - pause, - similarity_computer, - similarity_threshold, - pause_threshold, - ): - return False - return len(prev_last["text"].split()) + len(curr_first["text"].split()) <= max_words - - -def _process_segments_in_chunks( - segments: list[dict], - similarity_computer: SimilarityComputer | None, - similarity_threshold: float, - pause_threshold: float, - max_words: int, - min_words: int, - min_chars: int, - chunk_size: int, - memory_monitor: MemoryMonitor, -) -> list[dict]: - """Process segments in chunks to bound peak memory usage.""" - all_merged: list[dict] = [] - - for chunk_start in range(0, len(segments), chunk_size): - chunk_end = min(chunk_start + chunk_size, len(segments)) - chunk = segments[chunk_start:chunk_end] - chunk_num = chunk_start // chunk_size + 1 - logger.info(f"Processing chunk {chunk_num}: segments {chunk_start}–{chunk_end}") - - merged = _merge_segments( - chunk, - similarity_computer, - similarity_threshold, - pause_threshold, - max_words, - min_words, - min_chars, - memory_monitor=memory_monitor, - ) - - if all_merged and merged: - if _merge_chunk_boundaries( - all_merged[-1], - merged[0], - similarity_computer, - similarity_threshold, - pause_threshold, - max_words, - ): - all_merged[-1]["text"] += " " + merged[0]["text"] - all_merged[-1]["end_time"] = merged[0]["end_time"] - merged = merged[1:] - - all_merged.extend(merged) - gc.collect() # cheap; GPU cache cleared once after the full loop - logger.info(f"Chunk {chunk_num} done. Memory: {memory_monitor.get_memory_usage():.2f}GB") - - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - return all_merged - - -def _handle_short_segment( - current: dict, - processed: list[dict], - segments: list[dict], - index: int, - max_words: int, - min_words: int, - min_chars: int, -) -> int: - """Absorb a too-short segment into a neighbour; return the extra index increment.""" - text = current["text"].strip() - - if merge_segments_if_short(processed, current, max_words, min_words, min_chars): - return 0 - - if index + 1 < len(segments): - nxt = segments[index + 1] - combined = text + " " + nxt["text"].strip() - cw = len(combined.split()) - if cw >= min_words and len(combined) >= min_chars and cw <= max_words: - current["text"] = combined - current["end_time"] = nxt["end_time"] - processed.append(current) - return 1 - - logger.warning("Keeping a short segment that cannot be merged") - processed.append(current) - return 0 - - -def post_process_segments( - segments: list[dict], - min_words: int = 3, - min_chars: int = 15, - max_words: int = 80, -) -> list[dict]: - """Enforce min/max length constraints on all segments.""" - if not segments: - return [] - - logger.info(f"Post-processing {len(segments)} segments...") - memory_monitor = MemoryMonitor() - processed: list[dict] = [] - i = 0 - - while i < len(segments): - if i % 500 == 0: - memory_monitor.check_memory(f"post-processing segment {i}") - - current = segments[i].copy() - text = current["text"].strip() - word_count = len(text.split()) - char_count = len(text) - - if word_count < min_words or char_count < min_chars: - i += _handle_short_segment(current, processed, segments, i, max_words, min_words, min_chars) - elif word_count > max_words: - processed.extend(split_long_segment(current, max_words)) - else: - processed.append(current) - - i += 1 - - memory_monitor.force_cleanup() - logger.info(f"Post-processing complete. Memory: {memory_monitor.get_memory_usage():.2f}GB") - return processed - - -def segment_transcript( - segments: list[dict], - similarity_threshold: float = 0.7, - pause_threshold: float = 2.0, - model: SentenceTransformer | None = None, - max_words: int = 80, - min_words: int = 3, - min_chars: int = 15, - embedding_model_name: str = "all-MiniLM-L6-v2", - preferred_device: str = "cpu", - batch_size: int = 32, - chunk_size: int = 1000, -) -> list[dict]: - """Merge transcript segments by semantic similarity.""" - if not segments: - return [] - - memory_monitor = MemoryMonitor() - logger.info(f"Starting segmentation: {len(segments)} segments. Memory: {memory_monitor.get_memory_usage():.2f}GB") - - similarity_computer = None - if len(segments) > 1 and similarity_threshold < 1.0: - if model is None: - device = detect_device(preferred_device) - model = SentenceTransformer(embedding_model_name, device=device) - similarity_computer = SimilarityComputer(model, batch_size) - similarity_computer.precompute_embeddings([seg["text"] for seg in segments]) - - effective_chunk_size = chunk_size if chunk_size > 0 else len(segments) + 1 - - if len(segments) > effective_chunk_size: - logger.info(f"Processing in chunks of {effective_chunk_size}") - result = _process_segments_in_chunks( - segments, - similarity_computer, - similarity_threshold, - pause_threshold, - max_words, - min_words, - min_chars, - effective_chunk_size, - memory_monitor, - ) - else: - result = _merge_segments( - segments, - similarity_computer, - similarity_threshold, - pause_threshold, - max_words, - min_words, - min_chars, - memory_monitor=memory_monitor, - ) - - memory_monitor.force_cleanup() - logger.info(f"Segmentation complete. Memory: {memory_monitor.get_memory_usage():.2f}GB") - return result diff --git a/textplease/transcriber.py b/textplease/transcriber.py index c029394..f5b4d96 100644 --- a/textplease/transcriber.py +++ b/textplease/transcriber.py @@ -16,7 +16,7 @@ def transcribe_audio( language: str | None = None, batch_size: int = 1, temporary_directory: str | Path, -) -> list[dict]: +) -> list[dict[str, str]]: """Normalize local media for the built-in Whisper runtime and transcribe it.""" logger.info(f"Transcribing with model: {model_name}") normalized_audio_path = normalize_audio(audio_path, temporary_directory) diff --git a/textplease/utils/deduplicate_segments.py b/textplease/utils/deduplicate_segments.py deleted file mode 100644 index 6c9d79b..0000000 --- a/textplease/utils/deduplicate_segments.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - - -logger = logging.getLogger(__name__) - - -def deduplicate_segments(segments: list[dict[str, str]], overlap_words: int = 5) -> list[dict[str, str]]: - """Remove words repeated across consecutive segments from model chunk overlap.""" - if not segments: - return [] - - required_keys = {"text", "start_time", "end_time"} - deduplicated: list[dict[str, str]] = [] - - for current in segments: - missing = required_keys - current.keys() - if missing: - raise KeyError(f"Segment missing required keys: {missing}") - - current_copy = current.copy() - if deduplicated: - prev_tail = deduplicated[-1]["text"].split()[-overlap_words:] - curr_head = current["text"].split() - - max_overlap = 0 - for i in range(1, min(len(prev_tail), len(curr_head), overlap_words) + 1): - if prev_tail[-i:] == curr_head[:i]: - max_overlap = i - - if max_overlap: - current_copy["text"] = " ".join(curr_head[max_overlap:]).strip() - if not current_copy["text"]: - logger.warning(f"Segment empty after deduplication: start={current.get('start_time', 'unknown')}") - - deduplicated.append(current_copy) - - return deduplicated diff --git a/uv.lock b/uv.lock index fa5578f..e171b85 100644 --- a/uv.lock +++ b/uv.lock @@ -408,15 +408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/c9/172c525330c739a068c01050759a6f855ce16212db10a0359e690a03ac48/jiwer-4.0.0-py3-none-any.whl", hash = "sha256:7efaf0bd336b095d99ddef9dd67e1ee829d75d58aa2a81d9639870b01d6d95ea", size = 23034, upload-time = "2025-06-19T16:05:21.821Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -466,15 +457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] -[[package]] -name = "narwhals" -version = "2.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/66ed1fc6e38a0c0f330627ec5c5d597990d6159b6712b82af0ad2c65f06c/narwhals-2.23.0.tar.gz", hash = "sha256:13e7ff5b4bb4a2f77b907c2e4d8a76e273dfc1323a3c997440a2f9fd26aed408", size = 656209, upload-time = "2026-07-01T11:21:53.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl", hash = "sha256:769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9", size = 458938, upload-time = "2026-07-01T11:21:51.677Z" }, -] - [[package]] name = "networkx" version = "3.6.1" @@ -1066,48 +1048,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] -[[package]] -name = "scikit-learn" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "narwhals" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, - { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, - { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, - { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, -] - -[[package]] -name = "scipy" -version = "1.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, - { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, - { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, - { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, - { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, -] - [[package]] name = "semantic-version" version = "2.10.0" @@ -1117,25 +1057,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, ] -[[package]] -name = "sentence-transformers" -version = "5.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/56/d2cb00765a6b15c994a7fccf20f9032f16e8193ca49147cb5155166ad744/sentence_transformers-5.6.0.tar.gz", hash = "sha256:0e7164d051e416c1853ade7c274ff52af3f9da0f4be7f0b83d734c27699e1057", size = 453194, upload-time = "2026-06-16T14:01:56.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c1/dc1582b79e9a2eb0cddf9559cd9bcdff084f541d6fe881fdd9d98630dba7/sentence_transformers-5.6.0-py3-none-any.whl", hash = "sha256:d2075b5e687a1611005e20ab04a6846994d51adfcf39610aed066af3c0c0b81f", size = 596411, upload-time = "2026-06-16T14:01:55.103Z" }, -] - [[package]] name = "setuptools" version = "83.0.0" @@ -1210,10 +1131,8 @@ dependencies = [ { name = "gradio" }, { name = "numpy" }, { name = "pandas" }, - { name = "psutil" }, { name = "pydub" }, { name = "pyyaml" }, - { name = "sentence-transformers" }, { name = "silero-vad" }, { name = "torch" }, { name = "transformers" }, @@ -1225,6 +1144,7 @@ dev = [ { name = "jiwer" }, { name = "pandas-stubs" }, { name = "pre-commit" }, + { name = "psutil" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -1237,10 +1157,8 @@ requires-dist = [ { name = "gradio", specifier = ">=6.0.1" }, { name = "numpy", specifier = ">=1.26.0" }, { name = "pandas", specifier = ">=2.3.0" }, - { name = "psutil", specifier = ">=5.9.0" }, { name = "pydub", specifier = ">=0.25.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "sentence-transformers", specifier = ">=4.1.0" }, { name = "silero-vad", specifier = ">=5.1.0" }, { name = "torch", specifier = ">=2.0.0,<3.0.0" }, { name = "transformers", specifier = ">=5.13" }, @@ -1252,6 +1170,7 @@ dev = [ { name = "jiwer", specifier = "==4.0.0" }, { name = "pandas-stubs", specifier = ">=2.3.2.250926" }, { name = "pre-commit" }, + { name = "psutil", specifier = ">=5.9.0" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff", specifier = "==0.12.0" }, @@ -1259,15 +1178,6 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - [[package]] name = "tokenizers" version = "0.22.2"