mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
570 tests over the standard library's unittest, so there is nothing to install beyond the PyQt6 the application already needs. They reach neither the network, the microphone, nor the real ~/.config/dikte: urllib is faked at one function, the tools are faked at shutil.which, and every test is handed its own config and data directory. What they hold onto is what a change is most likely to move without meaning to. The request each provider is sent, field by field. A settings window that loads a value into a widget and writes it back, which is where a setting added to one half and not the other is silently reset. The dictation chain end to end: what is transcribed, what is pasted, what lands in the history, and what happens to the audio afterwards. A config file written by an older version. A meeting whose two channels heard the same sentence. 59 of them carry @linux_only, because they cover what Dikte is on this desktop rather than what it does: PipeWire, wl-clipboard, ydotool, KDE's shortcut file. The other 511 pass on any platform, and that line is worth holding as the ports arrive. CONTRIBUTING.md says how to run them, what support.py offers, and the three things about this codebase that trip up a new test.
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""The silence check, which decides whether a recording is worth an API call."""
|
||
|
||
import unittest
|
||
|
||
import vad
|
||
from tests.support import DikteTest
|
||
|
||
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
|
||
|
||
|
||
def levels(*, quiet=0.0005, loud=0.2, quiet_chunks=40, loud_chunks=20):
|
||
"""A recording as its per-chunk RMS values: a noise floor, then speech."""
|
||
return [quiet] * quiet_chunks + [loud] * loud_chunks
|
||
|
||
|
||
class ToDb(unittest.TestCase):
|
||
def test_silence_does_not_take_the_logarithm_of_zero(self):
|
||
self.assertEqual(vad.to_db(0.0), -120.0)
|
||
self.assertEqual(vad.to_db(-1.0), -120.0)
|
||
|
||
def test_full_scale_is_zero_db(self):
|
||
self.assertEqual(vad.to_db(1.0), 0.0)
|
||
|
||
def test_half_scale_is_about_minus_six(self):
|
||
self.assertAlmostEqual(vad.to_db(0.5), -6.02, places=2)
|
||
|
||
|
||
class Percentile(unittest.TestCase):
|
||
def test_empty(self):
|
||
self.assertEqual(vad._percentile([], 0.5), 0.0)
|
||
|
||
def test_does_not_run_off_the_end(self):
|
||
self.assertEqual(vad._percentile([1, 2, 3], 1.0), 3)
|
||
|
||
def test_picks_by_position(self):
|
||
self.assertEqual(vad._percentile([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.1), 1)
|
||
|
||
|
||
class Analyse(unittest.TestCase):
|
||
def test_no_chunks_at_all(self):
|
||
stats = vad.analyse([], CHUNK)
|
||
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||
self.assertEqual(stats["dynamic_db"], 0.0)
|
||
self.assertEqual(stats["speech_db"], -120.0)
|
||
|
||
def test_speech_rises_above_the_floor(self):
|
||
stats = vad.analyse(levels(), CHUNK)
|
||
self.assertGreater(stats["speech_db"], stats["noise_db"])
|
||
self.assertGreater(stats["dynamic_db"], 10.0)
|
||
self.assertGreater(stats["voiced_seconds"], 0.3)
|
||
|
||
def test_a_flat_recording_has_no_dynamics_and_no_voice(self):
|
||
stats = vad.analyse([0.02] * 60, CHUNK)
|
||
self.assertEqual(stats["dynamic_db"], 0.0)
|
||
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||
|
||
def test_voiced_seconds_follow_the_chunk_length(self):
|
||
short = vad.analyse(levels(), CHUNK)
|
||
long = vad.analyse(levels(), CHUNK * 2)
|
||
self.assertAlmostEqual(long["voiced_seconds"], short["voiced_seconds"] * 2)
|
||
|
||
def test_a_wider_margin_counts_fewer_chunks_as_voice(self):
|
||
wide = vad.analyse(levels(loud=0.005), CHUNK, margin_db=40.0)
|
||
narrow = vad.analyse(levels(loud=0.005), CHUNK, margin_db=3.0)
|
||
self.assertLess(wide["voiced_seconds"], narrow["voiced_seconds"])
|
||
|
||
|
||
class IsSilent(unittest.TestCase):
|
||
def test_speech_passes(self):
|
||
self.assertFalse(vad.is_silent(vad.analyse(levels(), CHUNK)))
|
||
|
||
def test_everything_below_the_absolute_floor(self):
|
||
stats = vad.analyse([0.00001] * 60, CHUNK)
|
||
self.assertTrue(vad.is_silent(stats))
|
||
|
||
def test_a_single_loud_chunk_is_not_long_enough_to_be_a_word(self):
|
||
stats = vad.analyse(levels(loud_chunks=1), CHUNK)
|
||
self.assertTrue(vad.is_silent(stats))
|
||
self.assertLess(stats["voiced_seconds"], 0.3)
|
||
|
||
def test_steady_hiss_near_the_floor(self):
|
||
# Loud enough to clear the absolute floor, but the level never moves,
|
||
# which is a fan rather than a voice.
|
||
stats = vad.analyse([0.0025] * 60, CHUNK)
|
||
self.assertGreater(stats["speech_db"], -55.0)
|
||
self.assertTrue(vad.is_silent(stats))
|
||
|
||
def test_a_level_that_never_moves_is_never_speech_however_loud(self):
|
||
"""The floor is the whole recording, so nothing can rise above it.
|
||
|
||
This is what settles a flat recording, at any volume: the margin rule
|
||
gets there before the dynamics rule ever does.
|
||
"""
|
||
stats = vad.analyse([0.25] * 60, CHUNK)
|
||
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||
self.assertTrue(vad.is_silent(stats))
|
||
|
||
def test_flat_dynamics_alone_do_not_reject_a_loud_recording(self):
|
||
stats = {"speech_db": -20.0, "noise_db": -24.0,
|
||
"dynamic_db": 4.0, "voiced_seconds": 1.0}
|
||
self.assertFalse(vad.is_silent(stats))
|
||
|
||
def test_flat_dynamics_do_reject_one_sitting_near_the_floor(self):
|
||
stats = {"speech_db": -50.0, "noise_db": -54.0,
|
||
"dynamic_db": 4.0, "voiced_seconds": 1.0}
|
||
self.assertTrue(vad.is_silent(stats))
|
||
|
||
def test_the_thresholds_are_honoured(self):
|
||
stats = vad.analyse(levels(), CHUNK)
|
||
self.assertTrue(vad.is_silent(stats, silence_db=-1.0))
|
||
self.assertTrue(vad.is_silent(stats, min_voiced_seconds=999.0))
|
||
|
||
|
||
class Hallucinations(DikteTest):
|
||
def test_a_stock_phrase_from_a_short_clip(self):
|
||
self.assertTrue(vad.looks_like_hallucination("Altyazı M.K.", 2.0))
|
||
self.assertTrue(vad.looks_like_hallucination("Thanks for watching!", 2.0))
|
||
|
||
def test_the_same_phrase_repeated(self):
|
||
self.assertTrue(vad.looks_like_hallucination(
|
||
"Altyazı M.K. Altyazı M.K. Altyazı M.K.", 3.0))
|
||
|
||
def test_a_long_clip_is_believed(self):
|
||
# Somebody who talks for half a minute and lands on the phrase meant it.
|
||
self.assertFalse(vad.looks_like_hallucination("Thanks for watching", 30.0))
|
||
|
||
def test_real_speech_is_kept(self):
|
||
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
||
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
||
|
||
def test_an_empty_transcript_counts_as_invented(self):
|
||
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
||
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
||
|
||
def test_matching_ignores_case_punctuation_and_turkish_letters(self):
|
||
for text in ("altyazi mk", "ALTYAZI M.K.", "Altyazı M.K.!"):
|
||
with self.subTest(text=text):
|
||
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
||
|
||
def test_the_boundary_is_the_max_duration(self):
|
||
self.assertTrue(vad.looks_like_hallucination("you", 6.0))
|
||
self.assertFalse(vad.looks_like_hallucination("you", 6.1))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|