bandsaunter/bandsaunter/announce.py
The Dust Council db3e0c79b9 Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:20 -07:00

436 lines
18 KiB
Python

"""Spoken announcements, for labelling recordings with the time.
Uses an installed text-to-speech program when there is one. When there is
not -- which is the common case on a headless box -- it falls back to a
built-in formant synthesiser. The vocabulary an announcement needs is small
and fixed (numbers, month names, a few words), so synthesising it directly is
practical and keeps the feature working with nothing else installed.
"""
from __future__ import annotations
import math
import shutil
import subprocess
import tempfile
import wave
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
from scipy import signal as sps
__all__ = ["say", "speak_timestamp", "timestamp_phrase", "timestamp_text",
"number_words", "available_engine", "ENGINES"]
# ---------------------------------------------------------------------------
# Phonemes. Formant targets in Hz, roughly the Peterson-Barney vowel centres
# with consonant loci from the standard synthesis-by-rule tables.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Phone:
kind: str # vowel, nasal, fric, stop, approx, silence
f1: float = 500.0
f2: float = 1500.0
f3: float = 2500.0
duration: float = 0.09 # seconds at normal speed
voiced: bool = True
noise: float = 0.0 # fricative noise level
amp: float = 1.0
# Diphthongs and glides move towards a second target.
to: tuple[float, float, float] | None = None
P = Phone
PHONEMES: dict[str, Phone] = {
# -- vowels ----------------------------------------------------------
"iy": P("vowel", 300, 2300, 3000, 0.11),
"ih": P("vowel", 400, 2000, 2550, 0.075),
"eh": P("vowel", 550, 1850, 2500, 0.09),
"ae": P("vowel", 660, 1720, 2410, 0.11),
"aa": P("vowel", 730, 1090, 2440, 0.11),
"ao": P("vowel", 570, 840, 2410, 0.10),
"uh": P("vowel", 440, 1020, 2240, 0.075),
"uw": P("vowel", 320, 900, 2200, 0.11),
"ah": P("vowel", 640, 1190, 2390, 0.075),
"er": P("vowel", 490, 1350, 1690, 0.10),
# -- diphthongs -------------------------------------------------------
"ay": P("vowel", 730, 1090, 2440, 0.16, to=(400, 2000, 2550)),
"ey": P("vowel", 550, 1850, 2500, 0.15, to=(300, 2300, 3000)),
"ow": P("vowel", 570, 840, 2410, 0.15, to=(320, 900, 2200)),
"aw": P("vowel", 730, 1090, 2440, 0.16, to=(440, 1020, 2240)),
"oy": P("vowel", 570, 840, 2410, 0.16, to=(400, 2000, 2550)),
# -- nasals ------------------------------------------------------------
"m": P("nasal", 300, 1100, 2200, 0.07, amp=0.55),
"n": P("nasal", 300, 1600, 2600, 0.07, amp=0.55),
"ng": P("nasal", 300, 2000, 2600, 0.07, amp=0.55),
# -- approximants ------------------------------------------------------
"l": P("approx", 380, 1100, 2600, 0.07, amp=0.75),
"r": P("approx", 400, 1100, 1600, 0.07, amp=0.75),
"w": P("approx", 320, 800, 2200, 0.06, amp=0.7, to=(500, 1500, 2500)),
"y": P("approx", 300, 2300, 3000, 0.06, amp=0.7, to=(500, 1500, 2500)),
# -- fricatives --------------------------------------------------------
"s": P("fric", 500, 4500, 6500, 0.11, voiced=False, noise=1.0, amp=0.5),
"z": P("fric", 400, 4200, 6000, 0.09, voiced=True, noise=0.7, amp=0.45),
"f": P("fric", 500, 1800, 4500, 0.10, voiced=False, noise=0.7, amp=0.32),
"v": P("fric", 400, 1600, 3000, 0.07, voiced=True, noise=0.4, amp=0.35),
"th": P("fric", 500, 1700, 4000, 0.09, voiced=False, noise=0.6, amp=0.28),
"dh": P("fric", 400, 1500, 2800, 0.06, voiced=True, noise=0.35, amp=0.35),
"sh": P("fric", 500, 2400, 4000, 0.12, voiced=False, noise=1.0, amp=0.5),
"h": P("fric", 500, 1500, 2500, 0.06, voiced=False, noise=0.5, amp=0.25),
# -- stops (closure, then burst, then the transition into the vowel) ---
"p": P("stop", 400, 1100, 2200, 0.085, voiced=False, noise=0.8, amp=0.4),
"t": P("stop", 400, 1800, 2600, 0.085, voiced=False, noise=0.9, amp=0.45),
"k": P("stop", 400, 1900, 2400, 0.085, voiced=False, noise=0.85, amp=0.42),
"b": P("stop", 350, 1100, 2200, 0.07, voiced=True, noise=0.35, amp=0.4),
"d": P("stop", 350, 1800, 2600, 0.07, voiced=True, noise=0.4, amp=0.4),
"g": P("stop", 350, 1900, 2400, 0.07, voiced=True, noise=0.4, amp=0.4),
# -- affricates ---------------------------------------------------------
"ch": P("stop", 500, 2400, 4000, 0.13, voiced=False, noise=1.0, amp=0.45),
"jh": P("stop", 450, 2200, 3200, 0.11, voiced=True, noise=0.6, amp=0.45),
# -- silence -------------------------------------------------------------
"_": P("silence", duration=0.09, voiced=False, amp=0.0),
"__": P("silence", duration=0.22, voiced=False, amp=0.0),
}
# ---------------------------------------------------------------------------
# Vocabulary
# ---------------------------------------------------------------------------
WORDS: dict[str, str] = {
"zero": "z iy r ow", "one": "w ah n", "two": "t uw", "three": "th r iy",
"four": "f ao r", "five": "f ay v", "six": "s ih k s",
"seven": "s eh v ah n", "eight": "ey t", "nine": "n ay n",
"ten": "t eh n", "eleven": "ih l eh v ah n", "twelve": "t w eh l v",
"thirteen": "th er t iy n", "fourteen": "f ao r t iy n",
"fifteen": "f ih f t iy n", "sixteen": "s ih k s t iy n",
"seventeen": "s eh v ah n t iy n", "eighteen": "ey t iy n",
"nineteen": "n ay n t iy n", "twenty": "t w eh n t iy",
"thirty": "th er t iy", "forty": "f ao r t iy", "fifty": "f ih f t iy",
"sixty": "s ih k s t iy", "seventy": "s eh v ah n t iy",
"eighty": "ey t iy", "ninety": "n ay n t iy",
"hundred": "h ah n d r ah d", "thousand": "th aw z ah n d",
"oh": "ow", "point": "p oy n t",
"january": "jh ae n y uw eh r iy", "february": "f eh b y uw eh r iy",
"march": "m aa r ch", "april": "ey p r ih l", "may": "m ey",
"june": "jh uw n", "july": "jh uw l ay", "august": "ao g ah s t",
"september": "s eh p t eh m b er", "october": "aa k t ow b er",
"november": "n ow v eh m b er", "december": "d ih s eh m b er",
"a.m.": "ey eh m", "p.m.": "p iy eh m",
"megahertz": "m eh g ah h er t s", "kilohertz": "k ih l ow h er t s",
"hours": "aw er z", "at": "ae t", "on": "ao n",
}
_ONES = ("zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine")
_TEENS = ("ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen")
_TENS = ("", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety")
MONTHS = ("january", "february", "march", "april", "may", "june", "july",
"august", "september", "october", "november", "december")
def number_words(n: int) -> list[str]:
"""Spell a whole number from 0 to 9999 the way it is normally said."""
n = int(n)
if n < 0:
return ["minus"] + number_words(-n)
if n < 10:
return [_ONES[n]]
if n < 20:
return [_TEENS[n - 10]]
if n < 100:
tens, ones = divmod(n, 10)
return [_TENS[tens]] + ([_ONES[ones]] if ones else [])
if n < 1000:
hundreds, rest = divmod(n, 100)
out = [_ONES[hundreds], "hundred"]
return out + (number_words(rest) if rest else [])
thousands, rest = divmod(n, 1000)
out = number_words(thousands) + ["thousand"]
return out + (number_words(rest) if rest else [])
def _year_words(year: int) -> list[str]:
"""Years are said in pairs: 2026 is "twenty twenty six", not "two thousand"."""
if 2000 <= year <= 2099:
rest = year - 2000
if rest == 0:
return ["two", "thousand"]
if rest < 10:
return ["two", "thousand", _ONES[rest]]
return ["twenty"] + number_words(rest)
return number_words(year)
def _two_digit_clock(value: int) -> list[str]:
"""Clock fields: 05 is "oh five", 30 is "thirty"."""
if value == 0:
return ["oh", "oh"]
if value < 10:
return ["oh", _ONES[value]]
return number_words(value)
def timestamp_phrase(when: datetime, frequency: float | None = None,
with_date: bool = True, with_seconds: bool = True) -> str:
"""The words to speak for one timestamp."""
words: list[str] = []
if with_date:
words += [MONTHS[when.month - 1]] + number_words(when.day)
words += ["_"] + _year_words(when.year) + ["_"]
words += number_words(when.hour) if when.hour >= 10 else \
_two_digit_clock(when.hour)
words += _two_digit_clock(when.minute)
if with_seconds:
words += ["_"] + _two_digit_clock(when.second)
if frequency:
words += ["__"] + _frequency_words(frequency)
return " ".join(words)
def timestamp_text(when: datetime, frequency: float | None = None,
with_date: bool = True, with_seconds: bool = True) -> str:
"""The same timestamp as ordinary text, for an installed engine.
Written the way those engines read best rather than as the word list the
built-in synthesiser needs. Punctuation is what gives them their phrasing,
and the obvious spellings are traps: espeak reads "14:38:05" as "fourteen
thirty, eight zero five", and an ISO date as "two thousand and twenty six
dash zero eight dash twenty one".
"""
parts: list[str] = []
if with_date:
parts.append(f"{MONTHS[when.month - 1].capitalize()} {when.day}")
parts.append(" ".join(_year_words(when.year)))
clock = f"{when.hour:02d} {when.minute:02d}"
if with_seconds:
clock += f" and {when.second:02d} seconds"
parts.append(f"at {clock}" if with_date else clock)
if frequency:
parts.append(f"{frequency / 1e6:.3f} megahertz")
return ", ".join(parts)
def _frequency_words(hz: float) -> list[str]:
mhz = hz / 1e6
whole = int(mhz)
frac = round((mhz - whole) * 1000) # kHz, three digits
out = number_words(whole) + ["point"]
for digit in f"{frac:03d}":
out.append(_ONES[int(digit)])
return out + ["megahertz"]
# ---------------------------------------------------------------------------
# Synthesis
# ---------------------------------------------------------------------------
def _resonator(freq: np.ndarray, bandwidth: float, fs: float,
x: np.ndarray, block: int = 64) -> np.ndarray:
"""Two-pole resonator whose centre frequency moves as the phrase does.
Processed in short blocks with the coefficients held constant across each
one. Articulation moves far more slowly than 4 ms, so this is
indistinguishable from recomputing every sample, and it runs through a
compiled filter instead of a Python loop.
"""
r = math.exp(-math.pi * bandwidth / fs)
out = np.zeros(x.size)
zi = np.zeros(2)
for start in range(0, x.size, block):
stop = min(start + block, x.size)
centre = float(np.mean(freq[start:stop]))
theta = 2.0 * math.pi * centre / fs
b1 = 2.0 * r * math.cos(theta)
b2 = -(r * r)
gain = 1.0 - b1 - b2
chunk, zi = sps.lfilter([gain], [1.0, -b1, -b2], x[start:stop], zi=zi)
out[start:stop] = chunk
return out
def _phones_of(text: str) -> list[tuple[str, Phone]]:
"""Turn a phrase into phones, looking each word up in the vocabulary."""
out: list[tuple[str, Phone]] = []
for word in text.split():
if word in ("_", "__"):
out.append((word, PHONEMES[word]))
continue
spelling = WORDS.get(word.lower())
if spelling is None:
continue
for name in spelling.split():
phone = PHONEMES.get(name)
if phone is not None:
out.append((name, phone))
out.append(("_", PHONEMES["_"]))
return out
def synthesize(text: str, fs: float = 16000.0, pitch: float = 115.0,
rate: float = 1.0) -> np.ndarray:
"""Render a phrase with the built-in formant synthesiser."""
phones = _phones_of(text)
if not phones:
return np.zeros(0, dtype=np.float32)
# Lay out the formant tracks and the source description sample by sample,
# so that transitions between phones are continuous rather than stepped.
tracks_f, tracks_a, voiced, noise = [], [], [], []
for name, ph in phones:
n = max(1, int(ph.duration / max(rate, 0.1) * fs))
start = np.array([ph.f1, ph.f2, ph.f3])
end = np.array(ph.to) if ph.to else start
ramp = np.linspace(0.0, 1.0, n)[:, None]
tracks_f.append(start[None, :] * (1 - ramp) + end[None, :] * ramp)
env = np.full(n, ph.amp)
if ph.kind == "stop":
# Closure, then a burst: silence for the first half, then a short
# noisy release. Without the silence a stop is just a fricative.
hold = int(n * 0.55)
env[:hold] = 0.0
env[hold:] = np.linspace(ph.amp, ph.amp * 0.35, n - hold)
elif ph.kind == "silence":
env[:] = 0.0
else:
edge = max(1, int(0.012 * fs))
if n > 2 * edge:
env[:edge] *= np.linspace(0, 1, edge)
env[-edge:] *= np.linspace(1, 0, edge)
tracks_a.append(env)
voiced.append(np.full(n, 1.0 if ph.voiced else 0.0))
noise.append(np.full(n, ph.noise))
f_track = np.vstack(tracks_f)
amp = np.concatenate(tracks_a)
voiced = np.concatenate(voiced)
noise = np.concatenate(noise)
total = amp.size
# Smooth the tracks: articulation is continuous, and abrupt formant jumps
# are heard as clicks.
smooth = max(3, int(0.02 * fs))
kernel = np.ones(smooth) / smooth
for i in range(3):
f_track[:, i] = np.convolve(f_track[:, i], kernel, mode="same")
voiced = np.convolve(voiced, kernel, mode="same")
# Source: a glottal pulse train with a falling pitch, plus noise.
t = np.arange(total) / fs
f0 = pitch * (1.0 - 0.18 * t / max(t[-1], 1e-6)) * \
(1.0 + 0.03 * np.sin(2 * np.pi * 3.1 * t))
phase = np.cumsum(2.0 * np.pi * f0 / fs)
pulses = np.zeros(total)
edges = np.flatnonzero(np.diff(np.floor(phase / (2 * np.pi))) > 0)
pulses[edges] = 1.0
pulses -= pulses.mean()
rng = np.random.default_rng(12345)
hiss = rng.standard_normal(total)
# A cascade, not three resonators added together. Summing them in
# parallel with fixed weights loses the first formant: each resonator is
# normalised at DC, which flatters a high formant far more than a low one,
# so F1 ends up buried. In cascade the product of the three responses is
# the vocal-tract envelope, and the relative levels come out on their own.
voiced_src = sps.lfilter([1.0], [1.0, -0.94], voiced * pulses)
voiced_src /= max(float(np.abs(voiced_src).max()), 1e-9)
src = voiced_src + noise * hiss * 0.30
out = _resonator(f_track[:, 0], 80.0, fs, src)
out = _resonator(f_track[:, 1], 110.0, fs, out)
out = _resonator(f_track[:, 2], 160.0, fs, out)
out *= amp
peak = float(np.abs(out).max())
if peak > 0:
out = out / peak * 0.7
return out.astype(np.float32)
# ---------------------------------------------------------------------------
# External engines
# ---------------------------------------------------------------------------
ENGINES = ("espeak-ng", "espeak", "pico2wave", "flite", "say")
def available_engine() -> str | None:
"""The first installed text-to-speech program, if any."""
for name in ENGINES:
if shutil.which(name):
return name
return None
def _external(text: str, fs: float, engine: str) -> np.ndarray | None:
"""Render with an installed engine, returning None if it will not play."""
words = " ".join(w for w in text.split() if w not in ("_", "__"))
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "say.wav"
if engine in ("espeak-ng", "espeak"):
cmd = [engine, "-w", str(out), "-s", "150", words]
elif engine == "pico2wave":
cmd = [engine, "-w", str(out), words]
elif engine == "flite":
cmd = [engine, "-t", words, "-o", str(out)]
elif engine == "say":
cmd = [engine, "-o", str(out), "--data-format=LEI16@16000", words]
else:
return None
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=20)
with wave.open(str(out)) as w:
raw = w.readframes(w.getnframes())
rate = w.getframerate()
channels = w.getnchannels()
except (OSError, subprocess.SubprocessError, wave.Error):
return None
audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
if channels > 1:
audio = audio.reshape(-1, channels).mean(axis=1)
if rate != fs and audio.size:
from scipy.signal import resample_poly
g = math.gcd(int(rate), int(fs))
audio = resample_poly(audio, int(fs) // g, int(rate) // g)
# Match the built-in synthesiser's level, so switching engines does not
# change how loud the announcements are against the recordings.
peak = float(np.abs(audio).max()) if audio.size else 0.0
if peak > 0:
audio = audio / peak * 0.7
return audio.astype(np.float32)
def say(text: str, fs: float = 16000.0, engine: str = "auto") -> np.ndarray:
"""Speak a phrase, preferring an installed engine over the built-in one."""
if engine != "builtin":
chosen = available_engine() if engine == "auto" else engine
if chosen:
audio = _external(text, fs, chosen)
if audio is not None and audio.size:
return audio
return synthesize(text, fs)
def speak_timestamp(when: datetime, fs: float = 16000.0,
frequency: float | None = None, engine: str = "auto",
with_date: bool = True) -> np.ndarray:
"""Audio saying the date and time, for splicing before a recording.
The wording differs by engine: an installed one is given ordinary text
with punctuation, while the built-in synthesiser is given the word list it
has pronunciations for.
"""
chosen = None if engine == "builtin" else (
available_engine() if engine == "auto" else engine)
if chosen:
audio = _external(timestamp_text(when, frequency, with_date=with_date),
fs, chosen)
if audio is not None and audio.size:
return audio
return synthesize(timestamp_phrase(when, frequency, with_date=with_date), fs)