Files
voculinux/tests/test_whisper_model_info.py
Jatin K MalikandGitHub d07c70dbc9 fix(audio): stop sink-wake from clipping start/stop cues (#804)
* fix(audio): prepend silent preroll so sink-wake does not clip cues

Short catalog tones start in a fresh paplay process while PipeWire is
still waking a suspended sink, so the first tens of milliseconds of the
cue are missing. Prepend 100ms of silence in the same WAV, cache the
result by source path, mtime, and preroll length, and fall back to the
original file if the preroll cannot be built. CI mock players keep the
raw path.

Fixes #800

* fix(audio): cache sink-wake preroll without tempfile.mkstemp

Some tests replace sys.modules["tempfile"] with a MagicMock, so
mkstemp unpacking fails and playback falls back to the raw cue.
Name the cache file from a short sha256 of the source path, mtime,
and preroll length, write a sibling .partial, then os.replace it
into place. Reload real tempfile and wave in the audio feedback
tests so they still pass after those mocks leak.

Fixes #800

* fix(tests): pin real wave for preroll without reloading tempfile

Popping tempfile out of sys.modules in the audio feedback fixture
made preroll tests pass, but later whisper tests then bound
TemporaryDirectory from a MagicMock. Import real tempfile and wave
at module top, pin only wave while audio_feedback is reimported,
and bind TemporaryDirectory from the real stdlib if a sibling test
already replaced tempfile.

Fixes #800

* fix(audio): catch specific preroll errors and type test helpers

The new preroll path swallowed every exception, which hid bugs and
disagreed with AGENTS.md. Catch OSError, ValueError, and wave.Error
on the inner partial cleanup and the outer fallback so a bad WAV or
cache write still falls back to the original cue, and other errors
still propagate. Annotate the two test helpers the same review
flagged.

* fix(audio): fall back when truncated cues raise EOFError

wave.open can raise EOFError on a truncated RIFF before playback.
Catch it with the other preroll failures so start/stop still play the
original file.

Fixes #800
2026-09-07 18:10:38 -07:00

170 lines
7.0 KiB
Python

"""Tests for OpenAI Whisper checkpoint file naming.
Regression cover for the review finding on #713: the file name was derived as
``f"{model_size}.pt"`` in three places, which is wrong for "large" — upstream
only ships the versioned checkpoint, so `openai-whisper` stores `large-v3.pt`.
The consequences were a 2.9GB model downloaded and stored twice, and a settings
dialog that reported "large" as missing no matter how often it was fetched.
"""
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
# test_recognition_manager / test_speech_recognition replace sys.modules
# ["tempfile"] with a MagicMock at import time and never put it back. Bind
# TemporaryDirectory from the real stdlib module even when that happens.
if not isinstance(TemporaryDirectory, type):
sys.modules.pop("tempfile", None)
from tempfile import TemporaryDirectory
from vocalinux.utils.whisper_model_info import (
WHISPER_MODEL_SIZES,
migrate_legacy_checkpoint_names,
whisper_model_file,
)
class TestWhisperModelFile(unittest.TestCase):
def test_large_maps_to_the_versioned_checkpoint(self):
self.assertEqual(whisper_model_file("large"), "large-v3.pt")
def test_other_sizes_keep_their_own_name(self):
for size in ("tiny", "base", "small", "medium"):
self.assertEqual(whisper_model_file(size), f"{size}.pt")
def test_every_catalog_size_is_covered(self):
for size in WHISPER_MODEL_SIZES:
self.assertTrue(whisper_model_file(size).endswith(".pt"), size)
def test_names_match_what_openai_whisper_stores(self):
"""openai-whisper names each file after the basename of its URL."""
upstream = {
"tiny": "tiny.pt",
"base": "base.pt",
"small": "small.pt",
"medium": "medium.pt",
# whisper's _MODELS maps "large" to the large-v3 URL
"large": "large-v3.pt",
}
for size, expected in upstream.items():
self.assertEqual(whisper_model_file(size), expected, size)
class TestLegacyCheckpointMigration(unittest.TestCase):
"""A checkpoint fetched by an earlier release must not be stranded.
That release saved "large" as large.pt, from the same large-v3 URL used
today. Under the old name load_model() cannot see it (2.9GB refetched),
Settings does not list it, and what is not listed cannot be deleted there.
"""
def setUp(self):
self.cache = Path(self.enterContext(TemporaryDirectory()))
def test_a_legacy_large_is_renamed(self):
(self.cache / "large.pt").write_bytes(b"checkpoint")
renamed = migrate_legacy_checkpoint_names(str(self.cache))
self.assertEqual(renamed, ["large-v3.pt"])
self.assertEqual((self.cache / "large-v3.pt").read_bytes(), b"checkpoint")
self.assertFalse((self.cache / "large.pt").exists())
def test_names_that_already_match_are_untouched(self):
for size in ("tiny", "base", "small", "medium"):
(self.cache / f"{size}.pt").write_bytes(size.encode())
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
for size in ("tiny", "base", "small", "medium"):
self.assertEqual((self.cache / f"{size}.pt").read_bytes(), size.encode())
def test_an_existing_upstream_file_is_never_clobbered(self):
"""The real checkpoint wins; the stale duplicate is left for the user."""
(self.cache / "large.pt").write_bytes(b"legacy")
(self.cache / "large-v3.pt").write_bytes(b"current")
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
self.assertEqual((self.cache / "large-v3.pt").read_bytes(), b"current")
self.assertEqual((self.cache / "large.pt").read_bytes(), b"legacy")
def test_a_rename_failure_is_not_fatal(self):
"""Worst case is the refetch that would have happened regardless."""
(self.cache / "large.pt").write_bytes(b"checkpoint")
with patch("os.rename", side_effect=OSError("read-only")):
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
def test_an_empty_cache_is_fine(self):
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
class TestSettingsDialogUsesTheSameName(unittest.TestCase):
"""The UI must look for the file that is actually written to disk."""
def test_large_is_detected_once_present(self):
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.join("/models", "large-v3.pt")
with patch("os.path.exists", side_effect=exists):
self.assertTrue(settings_dialog._is_whisper_model_downloaded("large"))
def test_a_copy_only_in_the_default_cache_is_not_reported_as_downloaded(self):
"""The engine cannot use ~/.cache/whisper, so the UI must not promise it."""
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.expanduser("~/.cache/whisper/base.pt")
with patch("os.path.exists", side_effect=exists):
self.assertFalse(settings_dialog._is_whisper_model_downloaded("base"))
def test_large_is_not_detected_under_the_wrong_name(self):
"""`large.pt` is a name nothing writes; it must not count as downloaded."""
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.join("/models", "large.pt")
with patch("os.path.exists", side_effect=exists):
self.assertFalse(settings_dialog._is_whisper_model_downloaded("large"))
class TestTheDialogRescuesALegacyCheckpoint(unittest.TestCase):
"""Wiring test: the rename has to happen where the list is built.
Without it a "large" downloaded by an earlier release stays absent from the
dialog, and absent means the delete button never offers it, so 2.9GB sits
there with no way to reclaim it short of a file manager.
"""
def test_a_legacy_large_becomes_listed_and_therefore_deletable(self):
from vocalinux.ui import settings_dialog
with TemporaryDirectory() as cache:
Path(cache, "large.pt").write_bytes(b"checkpoint")
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value=cache):
listed = settings_dialog._list_downloaded_whisper_models()
self.assertIn("large", listed)
# Listed is only half of it: deletion resolves the path again.
self.assertEqual(
settings_dialog._whisper_model_files("large"),
[os.path.realpath(os.path.join(cache, "large-v3.pt"))],
)
if __name__ == "__main__":
unittest.main()