"""Spoken announcements and the files they are appended to.""" from datetime import datetime import numpy as np import pytest from bandsaunter import announce from bandsaunter.recorder import FrequencyLog, append_wav, read_wav from bandsaunter.quality import voice_metrics # --------------------------------------------------------------------------- # Words # --------------------------------------------------------------------------- @pytest.mark.parametrize("n,words", [ (0, "zero"), (7, "seven"), (10, "ten"), (12, "twelve"), (19, "nineteen"), (20, "twenty"), (21, "twenty one"), (45, "forty five"), (59, "fifty nine"), (100, "one hundred"), (146, "one hundred forty six"), (2026, "two thousand twenty six"), ]) def test_number_words(n, words): assert " ".join(announce.number_words(n)) == words def test_timestamp_phrase_reads_naturally(): phrase = announce.timestamp_phrase(datetime(2026, 8, 21, 14, 38, 5)) assert "august" in phrase assert "twenty one" in phrase assert "twenty twenty six" in phrase # years are said in pairs assert "fourteen thirty eight" in phrase assert "oh five" in phrase # 05 seconds, not "five" def test_midnight_and_single_digits(): phrase = announce.timestamp_phrase(datetime(2026, 1, 3, 0, 5, 0)) assert "january three" in phrase assert "oh oh" in phrase # hour 00 and second 00 def test_frequency_is_spoken_digit_by_digit(): phrase = announce.timestamp_phrase(datetime(2026, 1, 1, 0, 0, 0), frequency=146_520_000.0) assert "one hundred forty six point one four six" not in phrase assert "point five two zero" in phrase assert "megahertz" in phrase def test_every_word_in_the_vocabulary_can_be_spoken(): """A word with no pronunciation would be silently dropped.""" for word in announce.WORDS: for phone in announce.WORDS[word].split(): assert phone in announce.PHONEMES, f"{word} uses unknown {phone}" def test_all_the_words_a_timestamp_needs_are_covered(): for month in range(1, 13): for day in (1, 9, 15, 21, 28): # 28 is safe in every month for hour in (0, 5, 12, 23): phrase = announce.timestamp_phrase( datetime(2026, month, day, hour, 45, 9)) for word in phrase.split(): assert word in ("_", "__") or word in announce.WORDS, word # --------------------------------------------------------------------------- # Synthesis # --------------------------------------------------------------------------- def _lpc_formants(x, fs, order=12): x = x - x.mean() x = np.append(x[0], x[1:] - 0.97 * x[:-1]) * np.hamming(x.size) r = np.correlate(x, x, "full")[x.size - 1:x.size - 1 + order + 1] if r[0] == 0: return [] a = np.zeros(order + 1) a[0], e = 1.0, r[0] for i in range(1, order + 1): k = -(a[:i] @ r[i:0:-1]) / e a[:i + 1] = (np.concatenate([a[:i], [0]]) + k * np.concatenate([[0], a[i - 1::-1]])) e *= (1 - k * k) if e <= 0: break roots = [z for z in np.roots(a) if np.imag(z) > 0.01] return [f for f in sorted(float(np.angle(z) * fs / (2 * np.pi)) for z in roots) if 120 < f < 5000] @pytest.mark.parametrize("vowel", ["iy", "ih", "eh", "ae", "aa", "ao", "uw", "uh", "ah", "er"]) def test_vowels_land_on_their_formant_targets(vowel): """Intelligibility rests on the formants being where they are meant to be. Summing resonators in parallel instead of cascading them loses the first formant entirely, and every vowel then sounds the same. """ spec = announce.PHONEMES[vowel] announce.WORDS["_probe_"] = vowel audio = announce.synthesize("_probe_", 16000) seg = audio[int(audio.size * 0.35):][:800] formants = _lpc_formants(seg, 16000) assert len(formants) >= 2, f"{vowel}: found {formants}" assert abs(formants[0] - spec.f1) < 130, f"{vowel} F1: {formants}" assert abs(formants[1] - spec.f2) < 250, f"{vowel} F2: {formants}" def test_speech_is_in_the_voice_band(): audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5)) n = 1 << 14 spec = np.abs(np.fft.rfft(audio[:n] * np.hanning(n))) ** 2 freqs = np.fft.rfftfreq(n, 1 / 16000) band = (freqs >= 250) & (freqs < 3400) assert spec[band].sum() / spec.sum() > 0.6 def test_the_announcement_reads_as_speech(): """The voice detector should hear the announcement as a voice.""" audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5)) m = voice_metrics(audio, 16000) assert m.score > 0.6, m.describe() assert 80 < m.pitch_hz < 200 def test_synthesis_is_fast_enough_to_run_inline(): import time t0 = time.perf_counter() audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5)) elapsed = time.perf_counter() - t0 assert elapsed < 0.25 * audio.size / 16000, f"{elapsed:.2f}s" def test_unknown_words_do_not_crash(): assert announce.synthesize("gibberishwordnotinvocabulary", 16000).size == 0 # --------------------------------------------------------------------------- # Appending # --------------------------------------------------------------------------- def test_a_wav_stays_valid_after_every_append(tmp_path): """A scan can be stopped at any moment; the file so far must still play.""" path = tmp_path / "acc.wav" for i in range(1, 5): append_wav(path, np.full(1600, 0.1 * i, dtype=np.float32), 16000) audio, rate = read_wav(path) assert rate == 16000 assert audio.size == 1600 * i import wave with wave.open(str(path)) as w: # the standard reader must agree assert w.getnframes() == 1600 * i def test_appending_a_different_rate_is_refused(tmp_path): path = tmp_path / "acc.wav" append_wav(path, np.zeros(160, dtype=np.float32), 16000) with pytest.raises(ValueError, match="16000"): append_wav(path, np.zeros(160, dtype=np.float32), 32000) def test_frequency_log_groups_nearby_receptions(tmp_path): log = FrequencyLog(tmp_path, tolerance_hz=6250.0, announce=False) audio = np.full(1600, 0.2, dtype=np.float32) log.add(146_520_040.0, audio, 16000) log.add(146_519_800.0, audio, 16000) # 240 Hz away: same channel log.add(147_100_000.0, audio, 16000) # far away: its own file files = sorted(p.name for p in tmp_path.glob("*.wav")) assert len(files) == 2, files assert log.appended == 3 def test_frequency_log_resamples_a_mismatched_capture(tmp_path): log = FrequencyLog(tmp_path, announce=False) log.add(146_520_000.0, np.full(16000, 0.2, dtype=np.float32), 16000) log.add(146_520_000.0, np.full(32000, 0.2, dtype=np.float32), 32000) audio, rate = read_wav(log.files[0]) assert rate == 16000 # one second at each rate, plus the gaps between them assert 2.0 <= audio.size / rate <= 3.0 def test_announcement_is_placed_before_each_recording(tmp_path): quiet = FrequencyLog(tmp_path / "a", announce=False) spoken = FrequencyLog(tmp_path / "b", announce=True) body = np.full(16000, 0.2, dtype=np.float32) quiet.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5)) spoken.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5)) short, _ = read_wav(quiet.files[0]) long_, rate = read_wav(spoken.files[0]) assert long_.size > short.size + 2 * rate, "no announcement was inserted" # the recording is a steady 0.2; the announcement is not, and comes first assert voice_metrics(long_[:int(3 * rate)], rate).score > 0.5 def test_an_existing_file_from_an_earlier_run_is_continued(tmp_path): first = FrequencyLog(tmp_path, announce=False) first.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000) before = read_wav(first.files[0])[0].size second = FrequencyLog(tmp_path, announce=False) second.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000) after = read_wav(second.files[0])[0].size assert after > before, "a later run started a new file instead of appending" assert len(list(tmp_path.glob("*.wav"))) == 1 def test_the_date_is_only_spoken_when_it_changes(tmp_path): """Repeating the date before every over takes longer than most overs last.""" log = FrequencyLog(tmp_path, announce=True) body = np.full(16000, 0.2, dtype=np.float32) lengths = [] previous = 0 for when in (datetime(2026, 8, 21, 14, 38, 5), datetime(2026, 8, 21, 14, 41, 0), datetime(2026, 8, 22, 9, 2, 0)): log.add(146_520_000.0, body, 16000, when=when) size = read_wav(log.files[0])[0].size lengths.append(size - previous) previous = size first, same_day, next_day = lengths assert same_day < first * 0.7, "the date was repeated needlessly" assert next_day > same_day * 1.5, "a new day should get the full date" def test_time_only_announcement_is_shorter(): when = datetime(2026, 8, 21, 14, 38, 5) full = announce.speak_timestamp(when, 16000, with_date=True) brief = announce.speak_timestamp(when, 16000, with_date=False) assert brief.size < full.size assert brief.size > 8000, "the time itself must still be spoken" # --------------------------------------------------------------------------- # Installed engines # --------------------------------------------------------------------------- HAVE_ENGINE = announce.available_engine() is not None needs_engine = pytest.mark.skipif(not HAVE_ENGINE, reason="no text-to-speech program installed") def test_engine_detection_does_not_throw(): engine = announce.available_engine() assert engine is None or engine in announce.ENGINES @pytest.mark.parametrize("when", [ datetime(2026, 8, 21, 14, 38, 5), datetime(2026, 1, 3, 9, 5, 0), datetime(2026, 12, 31, 23, 59, 59), ]) def test_engine_text_avoids_the_forms_engines_misread(when): """Punctuation is what gives an engine its phrasing, and the obvious spellings are traps: a colon makes espeak read 14:38:05 as "fourteen thirty, eight zero five", and an ISO date has its dashes read aloud.""" text = announce.timestamp_text(when) assert ":" not in text assert "-" not in text assert text.count(",") >= 2, text # date, year and time separated assert "twenty twenty six" in text # not "two thousand and ..." def test_engine_text_covers_the_time_only_case(): text = announce.timestamp_text(datetime(2026, 8, 21, 9, 5, 0), with_date=False) assert "August" not in text assert "09 05" in text @needs_engine def test_installed_engine_renders_speech(): audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000, engine="auto") assert audio.size > 16000, "suspiciously short" assert audio.dtype == np.float32 m = voice_metrics(audio, 16000) assert m.score > 0.6, m.describe() @needs_engine @pytest.mark.parametrize("rate", [8000, 16000, 32000, 48000]) def test_installed_engine_is_resampled_to_the_asked_for_rate(rate): """Engines render at their own rate; espeak-ng uses 22050 Hz.""" audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), rate, engine="auto") seconds = audio.size / rate assert 2.0 < seconds < 12.0, f"{seconds:.2f}s at {rate} Hz" @needs_engine def test_both_engines_produce_the_same_level(): """Switching engines must not change how loud announcements are.""" when = datetime(2026, 8, 21, 14, 38, 5) external = announce.speak_timestamp(when, 16000, engine="auto") builtin = announce.speak_timestamp(when, 16000, engine="builtin") assert abs(float(np.abs(external).max()) - float(np.abs(builtin).max())) < 0.05 @needs_engine def test_dropping_the_date_shortens_the_announcement(): when = datetime(2026, 8, 21, 14, 38, 5) full = announce.speak_timestamp(when, 16000, engine="auto") brief = announce.speak_timestamp(when, 16000, engine="auto", with_date=False) assert brief.size < full.size * 0.75 def test_an_engine_that_is_not_installed_falls_back_to_the_builtin(): """The feature must keep working with nothing else on the machine.""" audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000, engine="definitely-not-installed") assert audio.size > 0 assert voice_metrics(audio, 16000).score > 0.6 def test_a_failing_engine_falls_back_rather_than_crashing(monkeypatch): monkeypatch.setattr(announce, "_external", lambda *a, **k: None) audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000, engine="auto") assert audio.size > 0