Two things, both found by measuring rather than by reading the code. The voice-activity filter inside the recogniser is off. It was costing words: across a night of land-mobile captures it dropped 5-15% of what the same model finds without it -- 491 against 507, 339 against 384, 263 against 310 -- because a single-word over between two transmissions looks to a VAD exactly like the noise it exists to remove, and on a scanner those short replies are the ones worth having. Turning it off has a cost, and the cost is that Whisper hands back "You" for five seconds of hiss as confidently as it hands back a sentence. So the whole capture is now asked once whether anything in it rises above its own noise. Digital silence measures 0.0 dB of contrast and hiss at any level 0.7, while the quietest real capture of that night measures 8.9 and most measure 10-27; the bar sits at 3, an order of magnitude clear of both. It can veto a capture but never trim one, which is the whole difference between it and the filter it replaces. The second thing: callsigns like WQVF960 were being missed entirely. The shape being matched was the amateur one -- prefix, district digit, suffix -- and everything else the FCC licenses is written the other way round, the letters first and then the digits. On the GMRS and business channels that is most of what is said: nine callsigns across five transcripts of one evening went by unrecognised, and now do not. The shape is written as the three allocations that exist rather than as "letters then digits", which claims KN95, WD40 and KC135. Its letters are checked against the word list even when they arrive as a single token, which the amateur shape does not need -- no English word has a digit in the middle of it, but "west 120" and "word 100" fit this one exactly. Lookups now fall back to hamdb.org when callook has nothing. Not a spare copy: callook holds United States amateur licences only, so DL1ABC and VE3ABC are INVALID there and resolve perfectly well from the other. And a GMRS callsign is not looked up at all -- every database reachable without an account is an amateur register, so reporting WQVF960 as "unlisted" would blame the callsign for the absence of a source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
532 lines
23 KiB
Python
532 lines
23 KiB
Python
"""Speech to text: the engine plumbing, and how captures reach it."""
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import re
|
|
import pytest
|
|
|
|
from bandsaunter import transcribe as tr
|
|
from bandsaunter.config import ScanConfig
|
|
from bandsaunter.ranges import parse_range_list
|
|
from bandsaunter.scanner import Scanner, ScannerCallbacks
|
|
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_engine(monkeypatch):
|
|
"""A recogniser that reports what it was given, so the plumbing is testable
|
|
on a machine with none installed."""
|
|
seen = []
|
|
|
|
def engine(audio, rate, model, language):
|
|
seen.append({"samples": audio.size, "rate": rate, "model": model,
|
|
"language": language})
|
|
return tr.Transcript(text="this is the transcribed text",
|
|
engine="fake", language=language or "en")
|
|
|
|
monkeypatch.setitem(tr._DISPATCH, "fake", engine)
|
|
monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES)
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
return seen
|
|
|
|
|
|
def _speech(seconds=3.0, rate=16000):
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from speech import synth_speech
|
|
return synth_speech(seconds, rate, 120, 0)
|
|
|
|
|
|
def _sounds(seconds=1.0, rate=16000):
|
|
"""Something -- anything -- rather than digital silence.
|
|
|
|
A clip with nothing in it is now refused before an engine ever sees it,
|
|
so a test about plumbing has to hand over audio that has something in it,
|
|
or it is testing the refusal instead.
|
|
"""
|
|
n = int(seconds * rate)
|
|
t = np.arange(n) / rate
|
|
tone = np.sin(2 * np.pi * 440 * t).astype(np.float32) * 0.3
|
|
tone[: n // 2] = 0.0 # a quiet part to stand above
|
|
return tone
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Engines
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_engine_reports_rather_than_failing(monkeypatch):
|
|
"""Every capture failing noisily would be worse than saying so once."""
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: False)
|
|
assert tr.available_engine() is None
|
|
assert tr.transcribe(np.zeros(1000, np.float32), 16000) is None
|
|
|
|
|
|
def test_engine_listing_is_complete():
|
|
listed = {name for name, _, _ in tr.describe_engines()}
|
|
assert listed == set(tr.ENGINES)
|
|
for _, _, how in tr.describe_engines():
|
|
assert how, "every engine should say how to get it"
|
|
|
|
|
|
def test_a_failing_engine_is_reported_not_raised(monkeypatch):
|
|
def boom(audio, rate, model, language):
|
|
raise RuntimeError("model file is corrupt")
|
|
monkeypatch.setitem(tr._DISPATCH, "fake", boom)
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
result = tr.transcribe(_sounds(), 16000, engine="fake")
|
|
assert result is not None and not result
|
|
assert "corrupt" in result.note
|
|
|
|
|
|
def test_audio_is_resampled_to_what_the_engines_expect(fake_engine):
|
|
for rate in (8000, 16000, 32000, 48000):
|
|
tr.transcribe(_sounds(2.0, rate), rate, engine="fake")
|
|
assert [s["samples"] for s in fake_engine] == [32000] * 4
|
|
|
|
|
|
def test_the_model_and_language_reach_the_engine(fake_engine):
|
|
tr.transcribe(_sounds(), 16000, engine="fake",
|
|
model="small.en", language="fr")
|
|
assert fake_engine[-1]["model"] == "small.en"
|
|
assert fake_engine[-1]["language"] == "fr"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The worker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _drain(worker, timeout=20.0):
|
|
worker.close(timeout=timeout)
|
|
|
|
|
|
def test_worker_writes_a_transcript_beside_the_recording(tmp_path, fake_engine):
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
out = tmp_path / "0146.520000MHz--2026-08-21_12_00_00-nfm_transcription.txt"
|
|
worker.submit(_speech(), 16000, out, datetime(2026, 8, 21, 12, 0, 0),
|
|
146.52e6)
|
|
_drain(worker)
|
|
assert out.exists()
|
|
assert "transcribed text" in out.read_text()
|
|
assert worker.written == 1
|
|
|
|
|
|
def test_worker_appends_with_a_timestamp_when_combining(tmp_path, fake_engine):
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
out = tmp_path / "0146.520000MHz_transcription.txt"
|
|
for minute in (0, 5, 9):
|
|
worker.submit(_speech(), 16000, out,
|
|
datetime(2026, 8, 21, 12, minute, 0), 146.52e6,
|
|
append=True)
|
|
_drain(worker)
|
|
lines = out.read_text().strip().split("\n")
|
|
assert len(lines) == 3
|
|
assert lines[0].startswith("[2026-08-21 12:00:00] ")
|
|
assert lines[2].startswith("[2026-08-21 12:09:00] ")
|
|
|
|
|
|
def test_nothing_recognised_writes_no_file_at_all(tmp_path, monkeypatch):
|
|
"""A directory of placeholder files is worse than no file."""
|
|
monkeypatch.setitem(tr._DISPATCH, "fake",
|
|
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
out = tmp_path / "quiet_transcription.txt"
|
|
worker.submit(_sounds(), 16000, out, datetime.now(), 1e6)
|
|
_drain(worker)
|
|
assert not out.exists()
|
|
assert not list(tmp_path.iterdir())
|
|
assert worker.empty == 1 and worker.written == 0
|
|
|
|
|
|
def test_whitespace_only_speech_writes_no_file(tmp_path, monkeypatch):
|
|
monkeypatch.setitem(tr._DISPATCH, "fake",
|
|
lambda a, r, m, l: tr.Transcript(text=" \n ",
|
|
engine="fake"))
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
out = tmp_path / "blank_transcription.txt"
|
|
worker.submit(_sounds(), 16000, out, datetime.now(), 1e6)
|
|
_drain(worker)
|
|
assert not out.exists() and worker.empty == 1
|
|
|
|
|
|
def test_an_empty_result_adds_no_line_when_combining(tmp_path, monkeypatch):
|
|
"""Combined transcripts must not fill up with empty timestamps."""
|
|
texts = iter(["something was said", "", "and something else"])
|
|
monkeypatch.setitem(tr._DISPATCH, "fake",
|
|
lambda a, r, m, l: tr.Transcript(text=next(texts),
|
|
engine="fake"))
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
out = tmp_path / "0146.520000MHz_transcription.txt"
|
|
for minute in (0, 5, 9):
|
|
worker.submit(_sounds(), 16000, out,
|
|
datetime(2026, 8, 21, 12, minute, 0), 1e6, append=True)
|
|
_drain(worker)
|
|
lines = out.read_text().strip().split("\n")
|
|
assert len(lines) == 2, lines
|
|
assert "12:00:00" in lines[0] and "12:09:00" in lines[1]
|
|
|
|
|
|
def test_worker_does_not_hold_up_the_caller(tmp_path, monkeypatch):
|
|
"""Recognition takes seconds; a scan must not wait for it."""
|
|
def slow(audio, rate, model, language):
|
|
time.sleep(1.0)
|
|
return tr.Transcript(text="eventually", engine="fake")
|
|
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
started = time.perf_counter()
|
|
for i in range(3):
|
|
worker.submit(_speech(), 16000, tmp_path / f"{i}.txt", datetime.now(),
|
|
1e6)
|
|
assert time.perf_counter() - started < 0.5, "submitting blocked"
|
|
_drain(worker)
|
|
assert worker.written == 3
|
|
|
|
|
|
def test_a_full_queue_is_counted_not_blocked(tmp_path, monkeypatch):
|
|
def slow(audio, rate, model, language):
|
|
time.sleep(0.4)
|
|
return tr.Transcript(text="x", engine="fake")
|
|
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
worker = tr.TranscriptionWorker(engine="fake", max_queue=2)
|
|
worker.start()
|
|
accepted = sum(worker.submit(_speech(0.5), 16000, tmp_path / f"{i}.txt",
|
|
datetime.now(), 1e6) for i in range(12))
|
|
assert accepted < 12 and worker.dropped > 0
|
|
_drain(worker)
|
|
|
|
|
|
def test_empty_audio_is_not_submitted(tmp_path, fake_engine):
|
|
worker = tr.TranscriptionWorker(engine="fake")
|
|
worker.start()
|
|
assert not worker.submit(np.zeros(0, np.float32), 16000,
|
|
tmp_path / "x.txt", datetime.now(), 1e6)
|
|
_drain(worker)
|
|
assert worker.written == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Through a scan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _scan(tmp_path, transmitters, **over):
|
|
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
|
output_dir=str(tmp_path), record_seconds=4.0,
|
|
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
|
|
max_cycles=1, revisit_seconds=0.2, transcribe=True,
|
|
transcribe_engine="fake")
|
|
for k, v in over.items():
|
|
setattr(cfg, k, v)
|
|
hits = []
|
|
scanner = Scanner(cfg, device=SimulatedDevice(transmitters=transmitters).open(),
|
|
callbacks=ScannerCallbacks(on_record_end=hits.append))
|
|
scanner.prepare()
|
|
scanner.run()
|
|
return scanner, [h for h in hits if h.kept]
|
|
|
|
|
|
def test_a_voice_capture_is_transcribed(tmp_path, fake_engine, monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
|
assert hits and hits[0].category == "voice"
|
|
written = list(tmp_path.glob("*_transcription.txt"))
|
|
assert written, "no transcript written"
|
|
assert written[0].stem.startswith(hits[0].filename)
|
|
assert "transcribed text" in written[0].read_text()
|
|
|
|
|
|
def test_morse_and_data_are_not_transcribed(tmp_path, fake_engine, monkeypatch):
|
|
"""Running a recogniser over CW or a data burst wastes seconds per capture."""
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [
|
|
V(146_520_000, "fsk4", 0.4, 12_500, "data", baud=4800, deviation=1800)])
|
|
assert hits and hits[0].category == "digital"
|
|
assert not list(tmp_path.glob("*_transcription.txt"))
|
|
|
|
|
|
def test_short_captures_are_skipped(tmp_path, fake_engine, monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
|
transcribe_min_seconds=60.0)
|
|
assert hits
|
|
assert not list(tmp_path.glob("*_transcription.txt"))
|
|
|
|
|
|
def test_the_transcript_is_recorded_in_the_metadata(tmp_path, fake_engine,
|
|
monkeypatch):
|
|
import json
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
|
meta = json.loads(Path(hits[0].meta_path).read_text())
|
|
assert meta["hit"]["transcript_path"].endswith("_transcription.txt")
|
|
assert "transcribed text" in meta["hit"]["transcript"]
|
|
|
|
|
|
def test_the_metadata_never_names_a_transcript_that_was_not_written(
|
|
tmp_path, monkeypatch):
|
|
"""Recording a path for a file that never appears would be a lie."""
|
|
import json
|
|
monkeypatch.setitem(tr._DISPATCH, "fake",
|
|
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
|
|
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
|
|
assert hits
|
|
assert not list(tmp_path.glob("*_transcription.txt"))
|
|
meta = json.loads(Path(hits[0].meta_path).read_text())
|
|
assert not meta["hit"].get("transcript_path")
|
|
|
|
|
|
def test_combined_recordings_get_one_transcript_per_frequency(tmp_path,
|
|
fake_engine,
|
|
monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(
|
|
tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
|
combine_by_frequency=True, announce_timestamps=False,
|
|
record_seconds=2.0, max_cycles=3, revisit_seconds=0.05)
|
|
assert len(hits) >= 2
|
|
written = list(tmp_path.glob("*_transcription.txt"))
|
|
assert len(written) == 1, written
|
|
lines = written[0].read_text().strip().split("\n")
|
|
assert len(lines) == len(hits)
|
|
assert all(line.startswith("[") for line in lines)
|
|
|
|
|
|
def test_transcription_off_writes_nothing(tmp_path, fake_engine, monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
|
transcribe=False)
|
|
assert hits
|
|
assert not list(tmp_path.glob("*_transcription.txt"))
|
|
|
|
|
|
def test_missing_engine_says_so_once(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: None)
|
|
notes = []
|
|
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
|
|
output_dir=str(tmp_path), record_seconds=2.0,
|
|
threshold_db=12, max_cycles=1, transcribe=True)
|
|
scanner = Scanner(cfg,
|
|
device=SimulatedDevice(transmitters=[
|
|
V(146_520_000, "nfm", 0.4, 12_500, "v")]).open(),
|
|
callbacks=ScannerCallbacks(on_status=notes.append))
|
|
scanner.prepare()
|
|
scanner.run()
|
|
assert any("no speech recogniser" in n for n in notes), notes
|
|
assert scanner.transcriber is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Installed engines, when there are any
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INSTALLED = tr.available_engine()
|
|
needs_engine = pytest.mark.skipif(INSTALLED is None,
|
|
reason="no speech recogniser installed")
|
|
|
|
|
|
@needs_engine
|
|
def test_an_installed_engine_recognises_synthesised_speech():
|
|
"""Round trip: say something, then read it back off the audio."""
|
|
from bandsaunter.announce import say
|
|
audio = say("one two three four five six seven eight nine", 16000)
|
|
result = tr.transcribe(audio, 16000, engine="auto",
|
|
model="base.en", language="en")
|
|
assert result is not None
|
|
assert not result.note, result.note
|
|
# Engines are free to write numbers as digits, and whisper does.
|
|
text = result.text.lower()
|
|
spelled = ("one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine")
|
|
hits = sum((word in text) or (str(i) in text)
|
|
for i, word in enumerate(spelled, 1))
|
|
assert hits >= 4, f"only {hits} of nine numbers recognised: {result.text!r}"
|
|
|
|
|
|
@needs_engine
|
|
@pytest.mark.parametrize("rate", [8000, 16000, 32000])
|
|
def test_an_installed_engine_copes_with_any_rate(rate):
|
|
from bandsaunter.announce import say
|
|
audio = say("testing one two three", rate)
|
|
result = tr.transcribe(audio, rate, engine="auto", model="base.en",
|
|
language="en")
|
|
assert result is not None and not result.note, result.note
|
|
|
|
|
|
@needs_engine
|
|
def test_silence_produces_no_transcript_rather_than_invention():
|
|
result = tr.transcribe(np.zeros(16000 * 3, np.float32), 16000,
|
|
engine="auto", model="base.en", language="en")
|
|
assert result is not None
|
|
assert not result.text.strip(), f"invented {result.text!r} from silence"
|
|
|
|
|
|
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
|
|
def test_vosk_falls_back_when_given_a_whisper_model_name():
|
|
"""The model setting is shared with whisper, whose names are not paths."""
|
|
from bandsaunter.announce import say
|
|
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
|
|
model="base.en", language="en")
|
|
assert result is not None
|
|
assert not result.note, result.note
|
|
|
|
|
|
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
|
|
def test_vosk_accepts_the_plain_language_code():
|
|
"""Vosk names its models by region and rejects a bare "en"."""
|
|
from bandsaunter.announce import say
|
|
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
|
|
language="en")
|
|
assert result is not None and not result.note, result.note
|
|
|
|
|
|
# --- packaged engine and models -------------------------------------------
|
|
|
|
def test_a_packaged_model_is_used_in_place_of_its_name(tmp_path, monkeypatch):
|
|
""""base.en" should mean the copy on this machine when one is installed."""
|
|
monkeypatch.setattr(tr, "MODEL_DIR", tmp_path)
|
|
(tmp_path / "base.en").mkdir()
|
|
assert tr.resolve_model("base.en") == str(tmp_path / "base.en")
|
|
|
|
|
|
def test_an_unpackaged_model_keeps_its_name_to_be_downloaded(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(tr, "MODEL_DIR", tmp_path)
|
|
assert tr.resolve_model("small.en") == "small.en"
|
|
assert tr.resolve_model("") == ""
|
|
|
|
|
|
def test_an_explicit_model_directory_wins_over_the_packaged_one(tmp_path, monkeypatch):
|
|
packaged = tmp_path / "packaged"
|
|
(packaged / "base.en").mkdir(parents=True)
|
|
monkeypatch.setattr(tr, "MODEL_DIR", packaged)
|
|
mine = tmp_path / "base.en"
|
|
mine.mkdir()
|
|
assert tr.resolve_model(str(mine)) == str(mine)
|
|
|
|
|
|
def test_the_vendored_engine_is_searched_after_the_system_one(tmp_path, monkeypatch):
|
|
"""Anything apt provides must still win; the vendor copy fills a gap."""
|
|
monkeypatch.setattr(sys, "path", list(sys.path))
|
|
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path)
|
|
tr._add_vendor_path()
|
|
assert sys.path[-1] == str(tmp_path)
|
|
|
|
|
|
def test_the_vendor_path_is_added_once(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(sys, "path", list(sys.path))
|
|
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path)
|
|
tr._add_vendor_path()
|
|
tr._add_vendor_path()
|
|
assert sys.path.count(str(tmp_path)) == 1
|
|
|
|
|
|
def test_a_missing_vendor_directory_is_not_added(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(sys, "path", list(sys.path))
|
|
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path / "absent")
|
|
tr._add_vendor_path()
|
|
assert str(tmp_path / "absent") not in sys.path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Two transmissions on one frequency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_each_transmission_gets_a_transcript_of_its_own(tmp_path, fake_engine,
|
|
monkeypatch):
|
|
"""The default is one file per transmission, named after it. Nothing is
|
|
overwritten because nothing is shared: the timestamp is in the name, so
|
|
two overs on one frequency cannot land on one file."""
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
|
record_seconds=2.0, max_cycles=3,
|
|
revisit_seconds=0.05)
|
|
assert len(hits) >= 2, "only one transmission was captured"
|
|
texts = sorted(tmp_path.glob("*_transcription.txt"))
|
|
assert len(texts) == len(hits), [p.name for p in texts]
|
|
for path in texts:
|
|
assert "transcribed text" in path.read_text()
|
|
# One transmission, one line. More than one would mean two captures
|
|
# had collided on a single name.
|
|
assert len(path.read_text().strip().split("\n")) == 1, path.name
|
|
|
|
|
|
def test_combining_appends_every_over_to_one_file(tmp_path, fake_engine,
|
|
monkeypatch):
|
|
"""With --combine there is one recording per frequency, so there is one
|
|
transcript per frequency, and each over is added to the end of it with the
|
|
time it was heard."""
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
|
|
combine_by_frequency=True,
|
|
announce_timestamps=False, record_seconds=2.0,
|
|
max_cycles=3, revisit_seconds=0.05)
|
|
assert len(hits) >= 2
|
|
texts = list(tmp_path.glob("*_transcription.txt"))
|
|
assert len(texts) == 1, [p.name for p in texts]
|
|
lines = [ln for ln in texts[0].read_text().strip().split("\n") if ln]
|
|
assert len(lines) == len(hits)
|
|
for line in lines:
|
|
assert re.match(r"^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] ", line), line
|
|
|
|
|
|
def test_a_later_run_never_truncates_an_earlier_transcript(tmp_path,
|
|
fake_engine,
|
|
monkeypatch):
|
|
"""The question this answers: can a later transmission on the same
|
|
frequency wipe out an earlier one's words? The combined file is opened
|
|
for append, and it is the only transcript two captures ever share, so an
|
|
unattended receiver adds to it night after night rather than starting it
|
|
over."""
|
|
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
|
|
args = dict(combine_by_frequency=True, announce_timestamps=False,
|
|
record_seconds=2.0, max_cycles=2, revisit_seconds=0.05)
|
|
tx = [V(146_520_000, "nfm", 0.4, 12_500, "v")]
|
|
|
|
_scan(tmp_path, tx, **args)
|
|
combined = next(iter(tmp_path.glob("*_transcription.txt")))
|
|
before = combined.read_text()
|
|
assert before.strip()
|
|
|
|
_scan(tmp_path, tx, **args) # a second run into the same directory
|
|
after = combined.read_text()
|
|
assert after.startswith(before), "the earlier transcript was overwritten"
|
|
assert len(after) > len(before), "the later over was not added"
|
|
|
|
|
|
def test_a_clip_with_nothing_in_it_is_refused_before_the_engine(fake_engine):
|
|
"""A recogniser with no voice-activity filter hands back "You" for five
|
|
seconds of hiss as confidently as it hands back a sentence, so a capture
|
|
with nothing above its own noise is not offered to one."""
|
|
rng = np.random.default_rng(0)
|
|
for clip in (np.zeros(16000 * 5, np.float32),
|
|
rng.standard_normal(16000 * 5).astype(np.float32) * 0.01):
|
|
result = tr.transcribe(clip, 16000, engine="fake")
|
|
assert result is not None and not result.text
|
|
assert "nothing above the noise" in result.note
|
|
assert fake_engine == [], "the engine was asked about silence"
|
|
|
|
|
|
def test_the_refusal_is_a_whole_clip_veto_not_a_voice_activity_filter(fake_engine):
|
|
"""One short word in the middle of a long quiet capture is exactly what a
|
|
VAD throws away and exactly what a scanner is for, so it survives."""
|
|
clip = np.zeros(16000 * 10, np.float32)
|
|
t = np.arange(16000) / 16000
|
|
clip[16000 * 4:16000 * 5] = (np.sin(2 * np.pi * 300 * t)
|
|
* np.hanning(16000) * 0.4).astype(np.float32)
|
|
result = tr.transcribe(clip, 16000, engine="fake")
|
|
assert result is not None and result.text
|
|
assert len(fake_engine) == 1
|