Files
dikte/tests/test_vad.py
T
yusufipek 2f59029261 Put the modules in a package and the scripts in a folder
Twenty-two files at the top of the tree was the first thing anybody saw of
this repository. They are one package now, imported relatively, and the three
scripts that are not the front door moved under scripts/. install.sh stays
where the README has always said it is.

What starts the application is dikte/__main__.py: python3 -m dikte runs it,
and so does naming the file, which is what the launcher symlink, both .desktop
files, the macOS bundle and every registered shortcut do. Run by path there is
no package around it, so it puts the checkout on sys.path itself.

The installers now keep the keys you chose when they are given none, which is
what an update is: update.sh no longer has to read them out and pass them back.
An updater from before this commit cannot read them at all, so the one thing it
can say, the default key with an empty discard key, is read as "nothing was
asked for" rather than obeyed. That guard can go once nobody is updating across
this commit.
2026-08-16 14:01:01 +03:00

147 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The silence check, which decides whether a recording is worth an API call."""
import unittest
from dikte 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()