feat(diarization): per-speaker transcripts via TinyDiarize + --transcribe-file (#884)

* feat(transcribe): add TinyDiarize file transcription surface

Catalog small.en-tdrz (hosted at akashmjn/tinydiarize-whisper.cpp,
pinned commit) while keeping it out of every dictation-model selection
path: its [SPEAKER_TURN] markup would type noise into the focused
window. The checksum generator resolves side-repo models at their own
pinned revision, parakeet-style.

Add speech_recognition.diarization (strict-WAV fast path with ffmpeg
fallback, tdrz transcription, speaker-turn parsing, timestamped
formatting) and surface it through a tray Transcribe Audio File flow
with on-demand model download plus a headless --transcribe-file CLI.
TranscriptDialog offers copy and save of the diarized transcript.

Closes #756

* docs(user-guide): document --transcribe-file

* fix(transcribe): pass tdrz_enable as a pywhispercpp kwarg

pywhispercpp's Model takes decode params as keyword arguments; a
params= dict crashes on the C-level whisper_full_params object. Also
let --transcribe-file report unexpected failures cleanly instead of
dumping a traceback.

* fix(tray): dismiss the tdrz download dialog on response

* test(diarization): pin real wave/numpy/tempfile around sibling mocks

Several test modules replace sys.modules entries with MagicMocks at
import time and never restore them, so diarization's wave/numpy/
tempfile bindings resolved to mocks when this file ran after them in
CI. An autouse fixture reimports the real stdlib modules per test, the
same remedy test_audio_feedback already applies for wave.

* fix(transcribe): verify tdrz model, preload libs, gate formats on ffmpeg

- Hash the on-disk TinyDiarize model against the pinned digest before
  native code maps it, matching the dictation load path
- Extract the bundled-lib preloader to utils/pywhispercpp_loader.py and
  preload before the lazy pywhispercpp import so the headless
  --transcribe-file path works on source-built installs without RPATH
- Offer only WAV in the file picker when ffmpeg is absent; name the
  dependency in the filter label
- Type the new tray/diarization callbacks; document --transcribe-file
  and the new modules in AGENTS.md

* style(audio): finish type annotations on capture helpers and download path

_setup_alsa_error_handler now declares Optional[Any] and its ctypes
callback gets concrete parameter types; the remaining capture helpers
take audio: Any (PyAudio has no importable type) and test_audio_input's
device_index becomes Optional[int], which also clears the preexisting
implicit-Optional error. _download_whispercpp_model gains -> None and
its HF url is narrowed to str so the concatenation and download call
stop adding new mypy findings.

* style(tests): wrap long monkeypatch line in preload test

* refactor(main): narrow transcribe-file errors and add file context

* style(ui): annotate TranscriptDialog constructor

* fix(diarize): log worker tracebacks, isort test helpers, document --transcribe-file

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-30 00:44:55 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent b4babcaeca
commit 8ebc4ce2ba
21 changed files with 1421 additions and 193 deletions
+42 -37
View File
@@ -123,10 +123,7 @@ class TestModelInfo(unittest.TestCase):
def test_available_models_contains_all_models(self):
"""Test that AVAILABLE_MODELS contains all defined models."""
from vocalinux.utils.whispercpp_model_info import (
AVAILABLE_MODELS,
WHISPERCPP_MODEL_INFO,
)
from vocalinux.utils.whispercpp_model_info import AVAILABLE_MODELS, WHISPERCPP_MODEL_INFO
self.assertEqual(set(AVAILABLE_MODELS), set(WHISPERCPP_MODEL_INFO.keys()))
@@ -164,6 +161,7 @@ class TestModelInfo(unittest.TestCase):
"large-v3-turbo",
"large-v3-turbo-q5_0",
"large-v3-turbo-q8_0",
"small.en-tdrz",
]
self.assertEqual(AVAILABLE_MODELS, expected_models)
@@ -173,6 +171,7 @@ class TestModelInfo(unittest.TestCase):
from vocalinux.utils.whispercpp_model_info import (
MODEL_SIZES,
MODEL_VARIANTS_BY_SIZE,
NON_DICTATION_MODELS,
WHISPERCPP_MODEL_INFO,
)
@@ -185,12 +184,42 @@ class TestModelInfo(unittest.TestCase):
grouped_models = {
model_name for variants in MODEL_VARIANTS_BY_SIZE.values() for model_name in variants
}
self.assertEqual(grouped_models, set(WHISPERCPP_MODEL_INFO.keys()))
self.assertEqual(grouped_models, set(WHISPERCPP_MODEL_INFO.keys()) - NON_DICTATION_MODELS)
self.assertFalse(
any(model_name.endswith("-tdrz") for model_name in grouped_models),
"TinyDiarize needs diarization-specific runtime support, not the base dictation picker",
)
def test_tdrz_model_is_catalogued_but_not_a_dictation_model(self):
"""TinyDiarize is fetchable through the catalog, never dictation-selectable."""
from vocalinux.utils.whispercpp_model_info import (
NON_DICTATION_MODELS,
TDRZ_MODEL,
WHISPERCPP_MODEL_INFO,
is_dictation_model,
whispercpp_model_file,
whispercpp_model_source,
)
self.assertEqual(TDRZ_MODEL, "small.en-tdrz")
self.assertIn(TDRZ_MODEL, WHISPERCPP_MODEL_INFO)
self.assertIn(TDRZ_MODEL, NON_DICTATION_MODELS)
self.assertFalse(is_dictation_model(TDRZ_MODEL))
self.assertTrue(is_dictation_model("small.en"))
self.assertFalse(is_dictation_model("not-a-model"))
self.assertEqual(whispercpp_model_file(TDRZ_MODEL), "ggml-small.en-tdrz.bin")
url = WHISPERCPP_MODEL_INFO[TDRZ_MODEL]["url"]
self.assertIn("akashmjn/tinydiarize-whisper.cpp", url)
repo, revision = whispercpp_model_source(TDRZ_MODEL)
self.assertEqual(repo, "akashmjn/tinydiarize-whisper.cpp")
self.assertTrue(revision)
self.assertIn(f"/resolve/{revision}/", url)
main_repo, main_revision = whispercpp_model_source("small.en")
self.assertEqual(main_repo, "ggerganov/whisper.cpp")
self.assertEqual(main_revision, "")
def test_model_variant_helpers_identify_size_and_english_only_variants(self):
"""Test helpers that drive the split whisper.cpp model selectors."""
from vocalinux.utils.whispercpp_model_info import (
@@ -575,10 +604,7 @@ class TestDetectVulkanSupport(unittest.TestCase):
return MagicMock(returncode=1, stdout="")
with patch("subprocess.run", side_effect=run_side_effect):
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
detect_compute_backend,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, detect_compute_backend
backend, backend_info = detect_compute_backend()
self.assertEqual(backend, ComputeBackend.VULKAN)
@@ -745,10 +771,7 @@ class TestDetectComputeBackend(unittest.TestCase):
mock_vulkan.return_value = (True, "Intel Arc GPU")
mock_cuda.return_value = (True, "NVIDIA RTX 3080 (10GB)")
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
detect_compute_backend,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, detect_compute_backend
backend, info = detect_compute_backend()
@@ -764,10 +787,7 @@ class TestDetectComputeBackend(unittest.TestCase):
mock_vulkan.return_value = (False, None)
mock_cuda.return_value = (True, "NVIDIA RTX 3080 (10GB)")
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
detect_compute_backend,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, detect_compute_backend
backend, info = detect_compute_backend()
@@ -785,10 +805,7 @@ class TestDetectComputeBackend(unittest.TestCase):
mock_cuda.return_value = (False, None)
mock_cpu.return_value = "Intel Core i7"
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
detect_compute_backend,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, detect_compute_backend
backend, info = detect_compute_backend()
@@ -1148,10 +1165,7 @@ class TestGetBackendDisplayName(unittest.TestCase):
def test_get_backend_display_name_returns_string(self):
"""Test that get_backend_display_name returns a string."""
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
get_backend_display_name,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, get_backend_display_name
result = get_backend_display_name(ComputeBackend.CPU)
self.assertIsInstance(result, str)
@@ -1159,30 +1173,21 @@ class TestGetBackendDisplayName(unittest.TestCase):
def test_get_backend_display_name_vulkan(self):
"""Test display name for Vulkan backend."""
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
get_backend_display_name,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, get_backend_display_name
result = get_backend_display_name(ComputeBackend.VULKAN)
self.assertEqual(result, "Vulkan GPU")
def test_get_backend_display_name_cuda(self):
"""Test display name for CUDA backend."""
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
get_backend_display_name,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, get_backend_display_name
result = get_backend_display_name(ComputeBackend.CUDA)
self.assertEqual(result, "NVIDIA CUDA")
def test_get_backend_display_name_cpu(self):
"""Test display name for CPU backend."""
from vocalinux.utils.whispercpp_model_info import (
ComputeBackend,
get_backend_display_name,
)
from vocalinux.utils.whispercpp_model_info import ComputeBackend, get_backend_display_name
result = get_backend_display_name(ComputeBackend.CPU)
self.assertEqual(result, "CPU")