diff --git a/README.md b/README.md index 1a59970..2237ae5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ built-in US band plan — and it sweeps them, stops on anything above the noise floor, records it, and works out what kind of signal it was. CW/Morse is decoded to text. +Two programs: `bandsaunter` scans, and +[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected — +transcripts, identifications and playback, in one screen. + ``` ╭──────────────────────────────── receiver ────────────────────────────────╮ │ Rafael Micro R820T/R820T2 2.048 MS/s gain auto +0 ppm │ @@ -931,6 +935,81 @@ and `--simulate` is looking at an invented band, whose frequencies would sit in a real settings file for ever, skipping whatever genuine signal happened to land near one. Both still lock out for the run in hand, and say so. +## Browsing what you recorded + +A long scan leaves hundreds of files. `saunterbrowse` is a second program in +the same package for reading them: + +```bash +saunterbrowse # opens the scanner's output directory +saunterbrowse /mnt/recordings # or any other +``` + +Arrow keys move through the recordings; the transcript of whichever one is +highlighted fills the top of the screen, because that is the part you actually +want to read. Under it are the identification, the confidence, the CTCSS tone +or symbol rate where there is one, and the bands the frequency falls in. + +``` +╭──────────────────────────────────────────────────────────── 4 of 126 ─╮ +│ 146.88 MHz NFM Sat 22 Aug 13:01:44 42.8s SNR 17.6 dB voice │ +╰───────────────────────────────────────────────────────────────────────╯ +╭─ transcript ──────────────────────────────────────────────────────────╮ +│ │ +│ Alright, moving on. It is the 4th Saturday of the month. There is │ +│ an HF net at 1.30pm on 7.242 megahertz. Are there any │ +│ announcements for the net? │ +│ │ +╰───────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ Narrowband FM voice (CTCSS 110.9 Hz) 88% CTCSS 110.9 Hz │ +│ 2 m Amateur · 2 m FM Simplex · 2 m Repeater Outputs │ +╰───────────────────────────────────────────────────────────────────────╯ +╭─ recordings in /mnt/global/bandsaunter ───────────────────────────────╮ +│ 856.561096 MHz 13:10:25 fsk 4m00s Motorola SMARTNET / Smart… │ +│ 158.294200 MHz 13:05:15 nfm 20.1s Steven, I'm over to Colvi… │ +│ › 146.88 MHz 13:01:44 nfm 42.8s Alright, moving on. It is… │ +│ 146.88 MHz 13:00:44 nfm 35.2s Check out communication o… │ +╰───────────────────────────────────────────────────────────────────────╯ + ↑↓ move ⏎ play space stop / search t read s sort ? keys q +``` + +| Key | What it does | +|---|---| +| `↑` `↓` `k` `j` | move through the recordings | +| `PgUp` `PgDn` `Home` `End` | a screenful, or straight to either end | +| `Enter` | play the highlighted recording | +| `space` | stop playing | +| `t` | read the whole transcript full screen, scrolling | +| `/` | filter — by frequency, filename, identification, **or anything that was said** | +| `s` | sort by time, frequency or length | +| `r` | re-read the directory, picking up what a running scan has written | +| `o` | print the file's path and quit | +| `q` | quit | + +Searching the transcripts is the point of it: *"did anyone mention the +repeater"* is a question about content, not about filenames. + +```bash +saunterbrowse --list | grep -i "mile marker" # or ask it from a script +saunterbrowse --sort frequency # group by channel, not by time +``` + +Playback is handed to whichever player is installed — `pw-play`, `paplay`, +`aplay`, `sox` or `ffplay`, in that order, or whatever `--player` names. The +recordings are ordinary WAVs and every desktop already has something that +plays them; a browser that cannot start would be worse than one that cannot +play. Over ssh, where there is usually no sound server at the far end, the +transcripts still work and only `Enter` has nothing to do. + +Where a recording has no transcript the panel says which of the reasons +applies — Morse (decoded, and shown), data, a bare carrier, or speech that was +never offered to a recogniser — because those want different things done about +them. + +It only ever reads. Nothing in the recordings directory is renamed, moved or +deleted. + ## Built-in help Press `h` in the menus for topics covering setup, how the sweep works, why @@ -945,14 +1024,16 @@ and `bandsaunter scan --help` lists every flag grouped the same way as the menus `man bandsaunter` documents every command, option and setting, each with a plain-language note on what it is and why you would turn it up, down, on or -off — written for someone who does not already speak radio. +off — written for someone who does not already speak radio. `man +saunterbrowse` does the same for the browser. It is generated from the same settings table the menus and the flags come from, so it cannot describe a setting the program does not have, or miss one it does: ```bash -./packaging/make-man.py # regenerate packaging/bandsaunter.1 -man -l packaging/bandsaunter.1 # read it without installing +./packaging/make-man.py # regenerate packaging/bandsaunter.1 +./packaging/make-browse-man.py # and packaging/saunterbrowse.1 +man -l packaging/bandsaunter.1 # read either without installing ``` The `.deb` installs it; installing from source does not, so read it from the diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 40c5d07..e6fec0c 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -9,7 +9,7 @@ and transcribing speech. # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. VERSION_DATE = "2026-08-22" -VERSION_REVISION = 2 +VERSION_REVISION = 3 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/browse.py b/bandsaunter/browse.py new file mode 100644 index 0000000..ff33479 --- /dev/null +++ b/bandsaunter/browse.py @@ -0,0 +1,1008 @@ +"""saunterbrowse -- read and listen to what a scan collected. + +A scan leaves a directory of recordings, each with a JSON sidecar holding what +the classifier made of it and, for speech, a transcript. Reading that by +hand means opening files one at a time and guessing which is worth playing. + +This browses them instead: arrow keys move, Enter plays, and the transcript -- +the thing you actually want to read -- gets the top of the screen. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import select +import shutil +import signal +import subprocess +import sys +import termios +import time +import tty +import wave +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from rich.align import Align +from rich.console import Console, Group +from rich.layout import Layout +from rich.live import Live +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from . import __version__ +from .bandplan import fmt_hz +from .config import load_default + +__all__ = ["main", "Capture", "scan_directory", "Browser", "Player"] + + +# --------------------------------------------------------------------------- +# One recording on disk +# --------------------------------------------------------------------------- + +# 0146.880000MHz--2026-08-22_12_54_59-nfm.wav -- the name a scan writes. +_STEM = re.compile( + r"^(?P\d+\.\d+)MHz" + r"--(?P\d{4}-\d{2}-\d{2})_(?P\d{2})_(?P\d{2})_(?P\d{2})" + r"-(?P.+)$") + +# Colour per content category, so the list can be read at a glance rather than +# word by word. Voice is what most people are looking for, so it is the one +# that stands out. +CATEGORY_STYLE = { + "voice": "bold green", + "cw": "bold yellow", + "digital": "cyan", + "trunk": "bright_black", + "carrier": "magenta", + "noise": "bright_black", +} + + +@dataclass +class Capture: + """A .wav in the recordings directory, plus whatever sits beside it. + + The sidecars are read on demand and cached. A directory of ten thousand + recordings would take a noticeable moment to open otherwise, and only the + dozen rows actually on screen are ever needed. + """ + + path: Path + frequency: float = 0.0 + started_at: float = 0.0 + mode: str = "" + combined: bool = False # one growing file per frequency, not a hit + _meta: dict | None = field(default=None, init=False, repr=False) + _transcript: str | None = field(default=None, init=False, repr=False) + _duration: float | None = field(default=None, init=False, repr=False) + + # -- lazily read sidecars --------------------------------------------- + @property + def meta(self) -> dict: + """The ``hit`` block of the JSON sidecar, or an empty dict.""" + if self._meta is None: + self._meta = {} + side = self.path.with_suffix(".json") + try: + doc = json.loads(side.read_text()) + self._meta = doc.get("hit") or doc + except (OSError, ValueError): + pass + return self._meta + + @property + def transcript(self) -> str: + """The speech in this recording, if a recogniser found any. + + Two places hold it and they can disagree: the sidecar records what was + recognised at the time, while the .txt file is what a later re-run + wrote. The file wins, being the more recent of the two. + """ + if self._transcript is None: + self._transcript = "" + for candidate in (self.path.with_name(self.path.stem + + "_transcription.txt"),): + try: + self._transcript = candidate.read_text().strip() + break + except OSError: + continue + if not self._transcript: + self._transcript = str(self.meta.get("transcript", "")).strip() + return self._transcript + + @property + def duration(self) -> float: + """Seconds of audio, from the WAV header rather than the sidecar. + + The header is the only source that is true for a combined file, which + grows every time another transmission is appended to it. + """ + if self._duration is None: + self._duration = 0.0 + try: + with wave.open(str(self.path)) as w: + rate = w.getframerate() + if rate: + self._duration = w.getnframes() / float(rate) + except (wave.Error, OSError): + pass + return self._duration + + # -- derived ---------------------------------------------------------- + @property + def category(self) -> str: + if self.combined: + return "combined" + return str(self.meta.get("category", "")) or "unknown" + + @property + def classification(self) -> str: + if self.combined: + return "every transmission heard on this frequency" + return str(self.meta.get("classification", "")) or "unclassified" + + @property + def when(self) -> datetime | None: + return (datetime.fromtimestamp(self.started_at) + if self.started_at else None) + + def size_bytes(self) -> int: + try: + return self.path.stat().st_size + except OSError: + return 0 + + +def _from_name(path: Path) -> Capture: + """Read what the filename alone says, without touching any sidecar.""" + m = _STEM.match(path.stem) + if not m: + # A combined per-frequency file is named for its frequency and nothing + # else. Anything else unparseable is still listed: a recording the + # browser cannot name is better shown than hidden. + freq = 0.0 + bare = re.match(r"^(\d+\.\d+)MHz$", path.stem) + if bare: + freq = float(bare.group(1)) * 1e6 + try: + mtime = path.stat().st_mtime + except OSError: + mtime = 0.0 + return Capture(path=path, frequency=freq, started_at=mtime, + mode="", combined=bool(bare)) + when = datetime.strptime( + f"{m['date']} {m['h']}:{m['m']}:{m['s']}", "%Y-%m-%d %H:%M:%S") + return Capture(path=path, frequency=float(m["mhz"]) * 1e6, + started_at=when.timestamp(), mode=m["mode"]) + + +def scan_directory(directory: Path) -> list[Capture]: + """Every recording in one directory, newest first.""" + try: + names = sorted(p for p in Path(directory).iterdir() + if p.suffix.lower() == ".wav" and p.is_file()) + except OSError: + return [] + caps = [_from_name(p) for p in names] + caps.sort(key=lambda c: c.started_at, reverse=True) + return caps + + +# --------------------------------------------------------------------------- +# Playback +# --------------------------------------------------------------------------- + +# In preference order. The first that exists is used; the flags are whatever +# each one needs to play a file once and exit without opening a window. +PLAYERS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("pw-play", ()), + ("paplay", ()), + ("aplay", ("-q",)), + ("play", ("-q",)), + ("ffplay", ("-nodisp", "-autoexit", "-loglevel", "quiet")), + ("mpv", ("--no-video", "--really-quiet")), +) + + +class Player: + """Plays one file at a time, in a child process. + + A child rather than an audio library: the recordings are ordinary WAVs, + every desktop already has something that plays them, and a browser that + cannot start is worse than one that cannot play. + """ + + def __init__(self, command: list[str] | None = None): + self.command = command if command is not None else self._find() + self.proc: subprocess.Popen | None = None + self.playing: Capture | None = None + self.started_at = 0.0 + self.error = "" + + @staticmethod + def _find() -> list[str] | None: + for name, args in PLAYERS: + found = shutil.which(name) + if found: + return [found, *args] + return None + + @property + def available(self) -> bool: + return bool(self.command) + + def play(self, cap: Capture) -> None: + self.stop() + if not self.command: + self.error = ("no audio player found -- install one of: " + + ", ".join(n for n, _ in PLAYERS)) + return + try: + self.proc = subprocess.Popen( + [*self.command, str(cap.path)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + except OSError as exc: + self.error = f"{self.command[0]}: {exc}" + return + self.error = "" + self.playing = cap + self.started_at = time.time() + + def stop(self) -> None: + if self.proc is not None and self.proc.poll() is None: + # The whole group, not just the child. Several of these players + # are wrapper scripts that exec or fork the real one, and + # signalling only the script leaves the sound playing with nothing + # on screen to stop it. start_new_session put them in a group of + # their own precisely so this is safe. + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM) + except (OSError, ProcessLookupError): + try: + self.proc.terminate() + except OSError: + pass + self.proc = None + self.playing = None + + @property + def active(self) -> bool: + if self.proc is None: + return False + if self.proc.poll() is not None: + self.proc = None + self.playing = None + return False + return True + + @property + def elapsed(self) -> float: + return time.time() - self.started_at if self.active else 0.0 + + +# --------------------------------------------------------------------------- +# Keyboard +# --------------------------------------------------------------------------- + +# An arrow key is not a character: the terminal sends "\x1b[A", three bytes, +# and reading them one at a time turns one keypress into three commands. +_ESCAPES = { + "[A": "up", "[B": "down", "[C": "right", "[D": "left", + "[H": "home", "[F": "end", "[5~": "pgup", "[6~": "pgdn", + "OA": "up", "OB": "down", "OC": "right", "OD": "left", + "OH": "home", "OF": "end", "[1~": "home", "[4~": "end", +} + + +class Keyboard: + """Non-blocking key reader that understands escape sequences. + + Reads the file descriptor rather than the stream. ``sys.stdin.read(1)`` + goes through a buffered text wrapper, which in cbreak mode blocks until it + has enough bytes to be sure of a character -- so the first keypress never + returned and the program hung with the screen drawn. + """ + + NAMED = {"\r": "enter", "\n": "enter", " ": "space", + "\x7f": "backspace", "\x08": "backspace", "\x03": "q"} + + def __init__(self, stream=None): + self.stream = stream or sys.stdin + self.fd: int | None = None + self.enabled = False + self._old = None + self._pending = "" + + def __enter__(self): + try: + if self.stream.isatty(): + self.fd = self.stream.fileno() + self._old = termios.tcgetattr(self.fd) + tty.setcbreak(self.fd) + self.enabled = True + except (termios.error, ValueError, OSError, AttributeError): + self.enabled = False + return self + + def __exit__(self, *exc): + if self._old is not None and self.fd is not None: + try: + termios.tcsetattr(self.fd, termios.TCSADRAIN, self._old) + except (termios.error, ValueError, OSError): + pass + return False + + def _fill(self, timeout: float) -> bool: + """Take whatever is waiting on the terminal into the buffer.""" + if self.fd is None: + return False + try: + if not select.select([self.fd], [], [], timeout)[0]: + return False + data = os.read(self.fd, 64) + except (OSError, ValueError): + return False + if not data: + return False + self._pending += data.decode("utf8", "replace") + return True + + def get(self, timeout: float = 0.1) -> str: + """One key, named. Empty string when nothing was pressed.""" + if not self.enabled: + time.sleep(timeout) + return "" + if not self._pending and not self._fill(timeout): + return "" + return self._take() + + def _take(self) -> str: + ch = self._pending[0] + if ch != "\x1b": + self._pending = self._pending[1:] + return self.NAMED.get(ch, ch) + # Escape, or the first byte of a sequence. A terminal sends the rest + # in the same breath, so a short wait separates a pressed Esc from an + # arrow without making Esc feel slow. + if len(self._pending) == 1: + self._fill(0.02) + body = self._pending[1:] + for n in (3, 2): + if body[:n] in _ESCAPES: + self._pending = self._pending[1 + n:] + return _ESCAPES[body[:n]] + self._pending = self._pending[1:] + return "escape" + + +# --------------------------------------------------------------------------- +# The browser +# --------------------------------------------------------------------------- + +SORTS = ("time", "frequency", "duration") + + +def _dur(seconds: float) -> str: + seconds = max(0.0, float(seconds)) + if seconds >= 3600: + return f"{int(seconds // 3600)}h{int(seconds % 3600 // 60):02d}m" + if seconds >= 60: + return f"{int(seconds // 60)}m{int(seconds % 60):02d}s" + return f"{seconds:.1f}s" + + +def _size(n: int) -> str: + for unit, scale in (("G", 1 << 30), ("M", 1 << 20), ("k", 1 << 10)): + if n >= scale: + return f"{n / scale:.1f} {unit}B" + return f"{n} B" + + +class Browser: + """The whole application: state, rendering and the key loop.""" + + def __init__(self, directory: Path, console: Console | None = None, + player: Player | None = None): + self.directory = Path(directory) + self.console = console or Console() + self.player = player if player is not None else Player() + self.captures: list[Capture] = [] + self.view: list[Capture] = [] + self.index = 0 + self.top = 0 # first row shown in the list + self.sort = "time" + self.query = "" + self.searching = False + self.message = "" + self.show_help = False + self.reading = False # full-screen transcript + self.read_top = 0 + self.reload() + + # -- state ------------------------------------------------------------ + def reload(self) -> None: + keep = self.current.path if self.view else None + self.captures = scan_directory(self.directory) + self.apply() + if keep is not None: + for i, cap in enumerate(self.view): + if cap.path == keep: + self.index = i + break + + def apply(self) -> None: + """Re-filter and re-sort, keeping the cursor inside the result.""" + q = self.query.lower().strip() + if q: + self.view = [c for c in self.captures if self._matches(c, q)] + else: + self.view = list(self.captures) + if self.sort == "frequency": + self.view.sort(key=lambda c: (c.frequency, c.started_at)) + elif self.sort == "duration": + self.view.sort(key=lambda c: c.duration, reverse=True) + else: + self.view.sort(key=lambda c: c.started_at, reverse=True) + self.index = max(0, min(self.index, len(self.view) - 1)) + + @staticmethod + def _matches(cap: Capture, q: str) -> bool: + """Search the name, the identification and the words that were said. + + Searching the transcript is the point of it: "did anyone mention the + repeater" is a question about content, not about filenames. + """ + if q in cap.path.name.lower(): + return True + if q in cap.classification.lower() or q in cap.category.lower(): + return True + return q in cap.transcript.lower() + + @property + def current(self) -> Capture | None: + if not self.view: + return None + return self.view[max(0, min(self.index, len(self.view) - 1))] + + def move(self, delta: int) -> None: + if not self.view: + return + self.index = max(0, min(len(self.view) - 1, self.index + delta)) + + # -- rendering -------------------------------------------------------- + def _transcript_lines(self, pad: int = 3) -> list[str]: + """The transcript wrapped to the width it will be drawn at. + + The width has to be the panel's own, not the screen's: wrapping to one + width and drawing at a narrower one wraps everything a second time, + which strands the last word or two of each line on a line of its own. + """ + cap = self.current + if cap is None or not cap.transcript: + return [] + width = max(20, (self.console.size.width or 80) - 2 - 2 * pad) + text = Text(cap.transcript, style="bold white") + return [line.plain.rstrip() + for line in text.wrap(self.console, width)] + + def _transcript_height(self) -> int: + """Tall enough for the words, but never more than a third of the screen. + + A fixed panel is wrong in both directions: an eight-line frame around + one sentence is mostly empty, and the same frame around a four-minute + net swallows the rest of it without saying so. + """ + screen = self.console.size.height or 24 + ceiling = max(6, min(16, screen // 3)) + needed = len(self._transcript_lines()) + 4 # borders and padding + return max(6, min(ceiling, needed)) + + def _rows(self) -> int: + """How many list rows fit under everything above them.""" + height = self.console.size.height or 24 + # header 3, transcript n, details 4, footer 1, the list's own borders 2 + return max(3, height - 3 - self._transcript_height() - 4 - 1 - 2) + + def _header(self) -> Panel: + cap = self.current + if cap is None: + body = Text("no recordings here", style="bright_black") + return Panel(Align.center(body), border_style="bright_black", + padding=(0, 1)) + left = Text() + left.append(fmt_hz(cap.frequency) if cap.frequency else cap.path.stem, + style="bold cyan") + if cap.mode: + left.append(" ") + left.append(cap.mode.upper(), style="bold white") + when = cap.when + if when is not None: + left.append(" ") + left.append(when.strftime("%a %d %b %H:%M:%S"), style="white") + left.append(" ") + left.append(_dur(cap.duration), style="bright_black") + snr = cap.meta.get("snr_db") + if snr: + left.append(f" SNR {float(snr):.1f} dB", style="bright_black") + left.append(" ") + left.append(cap.category, style=CATEGORY_STYLE.get(cap.category, + "white")) + return Panel(left, border_style="blue", padding=(0, 1), + title=f"[bright_black]{self.index + 1} of " + f"{len(self.view)}[/bright_black]", + title_align="right") + + def _transcript_panel(self) -> Panel: + """The top of the screen, and the reason this program exists.""" + cap = self.current + height = self._transcript_height() + if cap is None: + return Panel("", border_style="bright_black", height=height) + if not cap.transcript: + inner = Align.center(Text(self._why_no_transcript(cap), + style="bright_black", justify="center"), + vertical="middle") + return Panel(inner, title="no transcript", title_align="left", + border_style="bright_black", padding=(1, 3), + height=height) + + lines = self._transcript_lines() + room = height - 4 # borders and vertical padding + overflows = len(lines) > room + # The notice needs a line of its own, or the panel crops the very + # thing that was there to say something had been cropped. + shown = lines[:room - 1] if overflows else lines + hidden = len(lines) - len(shown) + body = Text("\n".join(shown), style="bold white") + if hidden > 0: + # Say so. Silently cutting the end off a transmission is how a + # reader ends up believing they have read all of it. + body.append(f"\n… {hidden} more line(s) — press t to read it all", + style="not bold yellow") + block = body + elif len(shown) + 2 < height: + block = Align.left(body, vertical="middle") + else: + block = body + return Panel(block, title="transcript", title_align="left", + border_style="green", padding=(1, 3), height=height) + + @staticmethod + def _why_no_transcript(cap: Capture) -> str: + """Say which of the several reasons applies, rather than just 'none'. + + An empty panel is the same shape whether the recording was Morse, was + silent, or was never offered to a recogniser, and those want different + things done about them. + """ + if cap.combined: + return ("one file per frequency\n" + "transcripts for these are appended to a .txt beside it") + cat = cap.category + if cap.meta.get("morse_text"): + return f'Morse, decoded as:\n"{cap.meta["morse_text"].strip()}"' + if cat == "cw": + return "Morse -- nothing for a speech recogniser to hear" + if cat in ("digital", "trunk"): + return "data, not speech" + if cat == "carrier": + return "an unmodulated carrier -- silence by definition" + if cat == "voice": + return ("speech, but no transcript beside it\n" + "run: bandsaunter transcribe --engines") + if not cap.meta: + return "no sidecar beside this recording" + return "nothing was transcribed for this recording" + + def _details(self) -> Panel: + cap = self.current + if cap is None: + return Panel("", border_style="bright_black", height=4) + line = Text() + line.append(cap.classification, + style=CATEGORY_STYLE.get(cap.category, "white")) + conf = cap.meta.get("confidence") + if conf: + line.append(f" {float(conf) * 100:.0f}%", style="bright_black") + if cap.meta.get("ctcss_hz"): + line.append(f" CTCSS {float(cap.meta['ctcss_hz']):.1f} Hz", + style="yellow") + # Only where it means something. The estimator returns a figure for + # every capture, and "120 baud" beside a conversation is noise. + if cap.meta.get("baud") and cap.category in ("digital", "trunk", "cw"): + line.append(f" {float(cap.meta['baud']):.0f} baud", + style="cyan") + if cap.meta.get("morse_wpm"): + line.append(f" {float(cap.meta['morse_wpm']):.0f} WPM", + style="yellow") + + second = Text() + bands = cap.meta.get("band_labels") or [] + if bands: + second.append(" · ".join(bands[:3]), style="bright_black") + elif cap.meta.get("range_label"): + second.append(str(cap.meta["range_label"]), style="bright_black") + else: + second.append(f"{cap.path.name} {_size(cap.size_bytes())}", + style="bright_black") + return Panel(Group(line, second), border_style="bright_black", + padding=(0, 1), height=4) + + def _list(self) -> Panel: + rows = self._rows() + # Keep the cursor in view with a little context either side, so moving + # never lands on the very edge of the window. + margin = 2 if rows > 6 else 0 + if self.index < self.top + margin: + self.top = max(0, self.index - margin) + elif self.index >= self.top + rows - margin: + self.top = self.index - rows + 1 + margin + self.top = max(0, min(self.top, max(0, len(self.view) - rows))) + + t = Table(box=None, expand=True, pad_edge=False, show_edge=False, + show_header=False) + t.add_column(width=2) # cursor / playing marker + t.add_column(width=15, justify="right") # frequency + t.add_column(width=8) # time + t.add_column(width=5) # mode + t.add_column(width=7, justify="right") # duration + # One line per recording, always. A wrapped row would push the ones + # below it off the bottom, and the cursor arithmetic counts rows. + t.add_column(ratio=1, overflow="ellipsis", no_wrap=True) + + for i in range(self.top, min(len(self.view), self.top + rows)): + cap = self.view[i] + here = i == self.index + playing = (self.player.playing is not None + and self.player.playing.path == cap.path) + mark = "▶" if playing else ("›" if here else " ") + base = "on grey19 " if here else "" + cat = CATEGORY_STYLE.get(cap.category, "white") + when = cap.when + summary = cap.transcript.replace("\n", " ") or cap.classification + t.add_row( + Text(mark, style=base + ("bold red" if playing + else "bold cyan")), + Text(fmt_hz(cap.frequency) if cap.frequency + else cap.path.stem[:15], + style=base + ("bold cyan" if here else "cyan")), + Text(when.strftime("%H:%M:%S") if when else "", + style=base + "bright_black"), + Text(cap.mode or "", style=base + "white"), + Text(_dur(cap.duration), style=base + "bright_black"), + Text(summary, style=base + (cat if not cap.transcript + else "white"), + no_wrap=True, overflow="ellipsis"), + ) + if not self.view: + hint = ("nothing here yet" if not self.captures + else f"nothing matches '{self.query}'") + t.add_row("", "", "", "", "", Text(hint, style="bright_black")) + + title = f"recordings in {self.directory}" + if self.query: + title += f" filter: {self.query}" + return Panel(t, title=title, title_align="left", + border_style="blue", padding=(0, 1)) + + def _footer(self) -> Text: + if self.searching: + return Text.from_markup( + f"[bold]search:[/bold] {self.query}[blink]_[/blink]" + " [bright_black]enter to accept, esc to clear" + "[/bright_black]") + if self.message: + return Text.from_markup(f"[yellow]{self.message}[/yellow]") + if self.player.active and self.player.playing is not None: + total = self.player.playing.duration + done = self.player.elapsed + width = max(10, min(40, (self.console.size.width or 80) - 46)) + filled = int(width * min(1.0, done / total)) if total else 0 + bar = ("[red]" + "━" * filled + "[/red]" + + "[grey37]" + "━" * (width - filled) + "[/grey37]") + return Text.from_markup( + f"[bold red]♪[/bold red] {bar} {_dur(done)}/{_dur(total)}" + " [bright_black]space stop[/bright_black]") + return Text.from_markup( + "[bright_black]↑↓[/bright_black] move " + "[bright_black]⏎[/bright_black] play " + "[bright_black]space[/bright_black] stop " + "[bright_black]/[/bright_black] search " + "[bright_black]t[/bright_black] read " + "[bright_black]s[/bright_black] sort by " + f"{self.sort} " + "[bright_black]r[/bright_black] reload " + "[bright_black]?[/bright_black] keys " + "[bright_black]q[/bright_black] quit") + + def _help(self) -> Panel: + t = Table(box=None, show_header=False, pad_edge=False) + t.add_column(style="bold cyan", width=14) + t.add_column() + for key, what in ( + ("↑ ↓ / k j", "move through the recordings"), + ("PgUp PgDn", "a screenful at a time"), + ("Home End", "first and last"), + ("Enter", "play the highlighted recording"), + ("space", "stop playing"), + ("t", "read the whole transcript, full screen"), + ("/", "filter by frequency, name, identification or " + "anything that was said"), + ("Esc", "clear the filter"), + ("s", "sort by time, frequency or length"), + ("r", "re-read the directory"), + ("o", "print the file's path and quit"), + ("? h", "this list"), + ("q", "quit")): + t.add_row(key, what) + player = (" ".join(self.player.command) if self.player.command + else "none found -- install pw-play, paplay, aplay, " + "sox or ffmpeg") + t.add_row("", "") + t.add_row("player", player) + return Panel(t, title="keys", title_align="left", + border_style="cyan", padding=(1, 2)) + + def _reader(self) -> Panel: + """The whole transcript, with nothing else competing for the screen.""" + cap = self.current + screen = self.console.size.height or 24 + room = max(3, screen - 4) + lines = self._transcript_lines(pad=4) + self.read_top = max(0, min(self.read_top, max(0, len(lines) - room))) + shown = lines[self.read_top:self.read_top + room] + body = Text("\n".join(shown), style="white") + where = (f"{self.read_top + 1}-{self.read_top + len(shown)}" + f" of {len(lines)}" if len(lines) > room else "") + title = "transcript" + if cap is not None and cap.frequency: + title += f" · {fmt_hz(cap.frequency)}" + when = cap.when + if when is not None: + title += f" · {when.strftime('%d %b %H:%M:%S')}" + return Panel(body, title=title, title_align="left", + subtitle=f"[bright_black]{where} ↑↓ scroll " + f"⏎ play t or esc back[/bright_black]", + subtitle_align="right", + border_style="green", padding=(1, 4)) + + def render(self): + if self.show_help: + return Align.center(self._help(), vertical="middle") + if self.reading: + return self._reader() + layout = Layout() + # One height, computed once: the panel and the region it sits in have + # to agree, or the difference shows up as a gap in the middle of the + # screen. + tall = self._transcript_height() + layout.split_column( + Layout(self._header(), size=3), + Layout(self._transcript_panel(), size=tall), + Layout(self._details(), size=4), + Layout(self._list(), ratio=1), + Layout(self._footer(), size=1), + ) + return layout + + # -- key handling ----------------------------------------------------- + def handle(self, key: str) -> bool: + """Act on one key. Returns False when it is time to stop.""" + if not key: + return True + self.message = "" + if self.searching: + return self._handle_search(key) + if self.show_help and key not in ("?", "h", "q"): + self.show_help = False + return True + if self.reading: + return self._handle_reader(key) + + rows = self._rows() + if key in ("q", "escape") and not self.query: + return False + if key == "escape": + self.query = "" + self.apply() + elif key == "q": + return False + elif key in ("up", "k"): + self.move(-1) + elif key in ("down", "j"): + self.move(1) + elif key in ("pgup", "left"): + self.move(-rows) + elif key in ("pgdn", "right"): + self.move(rows) + elif key == "home": + self.index = 0 + elif key == "end": + self.index = max(0, len(self.view) - 1) + elif key == "enter": + self._play() + elif key == "space": + if self.player.active: + self.player.stop() + else: + self._play() + elif key == "s": + self.sort = SORTS[(SORTS.index(self.sort) + 1) % len(SORTS)] + self.apply() + self.message = f"sorted by {self.sort}" + elif key == "r": + self.reload() + self.message = f"{len(self.captures)} recording(s)" + elif key == "/": + self.searching = True + self.query = "" + elif key in ("?", "h"): + self.show_help = not self.show_help + elif key == "t": + cap = self.current + if cap is not None and cap.transcript: + self.reading = True + self.read_top = 0 + else: + self.message = "no transcript for this recording" + elif key == "o": + cap = self.current + if cap is not None: + self.message = str(cap.path) + return False + return True + + def _handle_reader(self, key: str) -> bool: + rows = max(3, (self.console.size.height or 24) - 6) + if key in ("t", "escape", "q", "space"): + self.reading = False + elif key in ("up", "k"): + self.read_top -= 1 + elif key in ("down", "j"): + self.read_top += 1 + elif key in ("pgup", "left"): + self.read_top -= rows + elif key in ("pgdn", "right"): + self.read_top += rows + elif key == "home": + self.read_top = 0 + elif key == "end": + self.read_top = 1 << 20 # clamped when the panel is drawn + elif key == "enter": + self._play() + self.read_top = max(0, self.read_top) + return True + + def _handle_search(self, key: str) -> bool: + if key == "enter": + self.searching = False + elif key == "escape": + self.searching = False + self.query = "" + elif key == "backspace": + self.query = self.query[:-1] + elif len(key) == 1 and key.isprintable(): + self.query += key + else: + return True + self.index = 0 + self.top = 0 + self.apply() + return True + + def _play(self) -> None: + cap = self.current + if cap is None: + return + self.player.play(cap) + if self.player.error: + self.message = self.player.error + + # -- main loop -------------------------------------------------------- + def run(self) -> int: + with Keyboard() as keys, Live(self.render(), console=self.console, + screen=True, auto_refresh=False, + transient=False) as live: + while True: + key = keys.get(0.15 if self.player.active else 0.4) + if not self.handle(key): + break + live.update(self.render(), refresh=True) + self.player.stop() + if self.message: + self.console.print(self.message) + return 0 + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def default_directory() -> Path: + """Where a scan writes, so the browser opens on it without being told.""" + env = os.environ.get("BANDSAUNTER_OUTPUT") + if env: + return Path(env).expanduser() + try: + cfg, _ = load_default() + return Path(cfg.output_dir).expanduser() + except Exception: + return Path("~/bandsaunter").expanduser() + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="saunterbrowse", + description="Read and listen to what a bandsaunter scan collected.", + epilog="Arrow keys move, Enter plays, / searches what was said.") + p.add_argument("directory", nargs="?", default=None, + help="where the recordings are " + "(default: the scanner's output directory)") + p.add_argument("--sort", choices=SORTS, default="time", + help="initial order (default: time, newest first)") + p.add_argument("--filter", default="", metavar="TEXT", + help="start with only recordings matching this") + p.add_argument("--player", default=None, metavar="CMD", + help="command to play a .wav, given the path as its last " + "argument (default: the first of " + + ", ".join(n for n, _ in PLAYERS) + " installed)") + p.add_argument("--list", action="store_true", + help="print one line per recording and exit, " + "without opening the browser") + p.add_argument("-V", "--version", action="version", + version=f"saunterbrowse {__version__}") + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + console = Console() + directory = (Path(args.directory).expanduser() if args.directory + else default_directory()) + if not directory.is_dir(): + console.print(f"[red]no such directory: {directory}[/red]") + console.print("[bright_black]Pass one, or run a scan first so there " + "is something to browse.[/bright_black]") + return 2 + + player = Player(args.player.split() if args.player else None) + browser = Browser(directory, console=console, player=player) + browser.sort = args.sort + browser.query = args.filter + browser.apply() + + if args.list: + for cap in browser.view: + when = cap.when.strftime("%Y-%m-%d %H:%M:%S") if cap.when else "" + line = (f"{fmt_hz(cap.frequency):>14} {when} " + f"{_dur(cap.duration):>7} {cap.category:<8} " + f"{cap.transcript.splitlines()[0] if cap.transcript else cap.classification}") + console.print(line, highlight=False, soft_wrap=True) + return 0 + + if not browser.captures: + console.print(f"[yellow]no recordings in {directory}[/yellow]") + return 1 + if not console.is_terminal: + console.print("[red]saunterbrowse needs a terminal.[/red] " + "[bright_black]Use --list to print the recordings " + "instead.[/bright_black]") + return 2 + try: + return browser.run() + except KeyboardInterrupt: + browser.player.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 158c80d..d480756 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-man.py -- do not edit by hand. -.TH BANDSAUNTER 1 "2026-08-22" "bandsaunter 2026-08-22_02" "User Commands" +.TH BANDSAUNTER 1 "2026-08-22" "bandsaunter 2026-08-22_03" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -961,6 +961,10 @@ Raise the squelch threshold and save it as the new default. 0 on success, 1 for a bad option or an unusable configuration, 2 when the receiver could not be opened. .SH SEE ALSO +.BR saunterbrowse (1) +\[em] browse and play back what a scan collected: the recordings list, their +transcripts and their identifications, on one screen. +.PP .BR rtl_test (1), .BR rtl_sdr (1), .BR espeak-ng (1) diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 9b4ae88..14fd5b4 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -27,7 +27,10 @@ gzip -9n "$pkgdir/usr/share/doc/bandsaunter/README.md" mkdir -p "$pkgdir/usr/share/man/man1" python3 "$here/packaging/make-man.py" "$pkgdir/usr/share/man/man1/bandsaunter.1" \ >/dev/null +python3 "$here/packaging/make-browse-man.py" \ + "$pkgdir/usr/share/man/man1/saunterbrowse.1" >/dev/null gzip -9n "$pkgdir/usr/share/man/man1/bandsaunter.1" +gzip -9n "$pkgdir/usr/share/man/man1/saunterbrowse.1" cat > "$pkgdir/usr/bin/bandsaunter" <<'EOF' #!/usr/bin/python3 @@ -37,6 +40,14 @@ sys.exit(main()) EOF chmod 755 "$pkgdir/usr/bin/bandsaunter" +cat > "$pkgdir/usr/bin/saunterbrowse" <<'EOF' +#!/usr/bin/python3 +import sys +from bandsaunter.browse import main +sys.exit(main()) +EOF +chmod 755 "$pkgdir/usr/bin/saunterbrowse" + # Every one of these is in Debian, so apt resolves the lot. No speech # recogniser is packaged for Debian, so that part ships as its own package # (built by build-repo.sh) and is recommended rather than depended on: apt diff --git a/packaging/make-browse-man.py b/packaging/make-browse-man.py new file mode 100755 index 0000000..524d531 --- /dev/null +++ b/packaging/make-browse-man.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Generate the saunterbrowse manual page. + +Hand-written rather than generated from a table: unlike the scanner, the +browser's surface is a dozen keys and five flags, and describing those is +prose, not a listing. The version and date still come from the program, so +the page cannot claim to document a release that was never built. +""" +import sys +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import bandsaunter # noqa: E402 +from bandsaunter.browse import PLAYERS, SORTS # noqa: E402 + + +PAGE = r'''.\" Generated by packaging/make-browse-man.py -- do not edit by hand. +.TH SAUNTERBROWSE 1 "{date}" "bandsaunter {version}" "User Commands" +.SH NAME +saunterbrowse \- read and listen to what a bandsaunter scan collected +.SH SYNOPSIS +.B saunterbrowse +.RI [ DIRECTORY ] +.RB [ \-\-sort +.IR ORDER ] +.RB [ \-\-filter +.IR TEXT ] +.RB [ \-\-player +.IR CMD ] +.RB [ \-\-list ] +.SH DESCRIPTION +A scan leaves a directory of recordings. Beside each one is a JSON file +holding what the classifier made of it, and for speech a transcript of what +was said. Reading that by hand means opening files one at a time and guessing +which are worth playing. +.PP +.B saunterbrowse +shows them as a list you move through with the arrow keys. The transcript of +whichever recording is highlighted fills the top of the screen, because that +is the part you actually want to read; underneath it are the identification, +the bands the frequency falls in, and the list itself. Pressing Enter plays +the recording. +.PP +With no +.I DIRECTORY +it opens the one the scanner writes to, taken from your saved settings, so it +normally needs no arguments at all. +.PP +It only ever reads. Nothing in the recordings directory is renamed, moved or +deleted. +.SH KEYS +.TP +.B "Up Down k j" +Move through the recordings. +.TP +.B "PgUp PgDn" +A screenful at a time. +.TP +.B "Home End" +The first and the last. +.TP +.B Enter +Play the highlighted recording. Playing a second one stops the first: two +players talking over each other is worse than either alone. +.TP +.B Space +Stop playing. With nothing playing, it starts, like Enter. +.TP +.B t +Read the whole transcript full screen, scrolling with the arrow keys. Useful +for a long net, where the panel at the top can only show the first few lines +\[em] it says so when there is more. +.TP +.B / +Filter. What you type is matched against the frequency, the filename, the +identification and +.I everything that was said, +so "was the repeater mentioned" is a question you can ask directly. Enter +accepts, Escape clears. +.TP +.B s +Cycle the order: {sorts}. +.TP +.B r +Re-read the directory. A scan running in another window is still writing to +it, and this picks up what has arrived since. +.TP +.B o +Print the highlighted recording's path and quit, for piping into something +else. +.TP +.B "? h" +The list of keys, and which audio player was found. +.TP +.B q +Quit. With a filter in force, Escape clears the filter first. +.SH OPTIONS +.TP +.BI DIRECTORY +Where the recordings are. Defaults to the scanner's output directory, or to +.B $BANDSAUNTER_OUTPUT +when that is set. +.TP +.BI \-\-sort " ORDER" +Start in this order: {sorts}. Time is newest first; duration is longest +first. +.TP +.BI \-\-filter " TEXT" +Start with only the recordings matching this, exactly as if it had been typed +at the +.B / +prompt. +.TP +.BI \-\-player " CMD" +The command used to play a recording. The path is appended as its last +argument. By default the first of these that is installed is used: +{players}. +.TP +.B \-\-list +Print one line per recording and exit, without drawing anything. This is what +to use over a pipe, in a script, or anywhere there is no terminal. +.TP +.B \-V ", " \-\-version +Print the version and exit. +.SH SOUND +Playback is handed to whichever player is installed rather than done in the +program: the recordings are ordinary WAV files, every desktop already has +something that plays them, and a browser that cannot start is worse than one +that cannot play. If none is found, everything else still works and the +message says which packages would fix it. +.PP +Over ssh there is usually no sound server at the far end. The browser and its +transcripts work regardless; only Enter has nothing to do. +.SH TRANSCRIPTS +A transcript appears only where a recogniser produced one, which means the +capture was judged to be speech and +.B bandsaunter\-transcribe +was installed at the time. Where there is none, the panel says which of the +several reasons applies \[em] Morse, data, a bare carrier, or speech that was +never offered to a recogniser \[em] because those want different things done +about them. +.PP +Two places can hold the text: the JSON sidecar records what was recognised +during the scan, and the +.I _transcription.txt +beside the recording is what a later re\-run wrote. The file wins, being the +more recent of the two. +.SH FILES +.TP +.I ~/bandsaunter/ +Where recordings are written, unless the saved settings say otherwise. +.TP +.IR frequency \-\- date _ time \- modulation .wav +A recording. +.TP +.IR ... .json +Its measurements and identification. +.TP +.IR ... _transcription.txt +What was said, where a recogniser heard speech. +.SH ENVIRONMENT +.TP +.B BANDSAUNTER_OUTPUT +The directory to open, overriding the saved settings. +.SH EXAMPLES +.PP +.RS +.EX +saunterbrowse +.EE +.RE +.PP +Open the scanner's output directory. +.PP +.RS +.EX +saunterbrowse /mnt/recordings \-\-sort frequency +.EE +.RE +.PP +Browse somewhere else, grouped by channel rather than by time. +.PP +.RS +.EX +saunterbrowse \-\-list | grep \-i "mile marker" +.EE +.RE +.PP +Search the transcripts from a script. +.SH EXIT STATUS +0 on a clean exit, 1 when the directory holds no recordings, 2 when it does +not exist or there is no terminal to draw on. +.SH SEE ALSO +.BR bandsaunter (1) +.SH BUGS +The list is read when the browser opens. Press +.B r +to pick up recordings a running scan has written since. +''' + + +def main() -> int: + text = PAGE.format( + date=date.today().isoformat(), + version=bandsaunter.__version__, + sorts=", ".join(SORTS), + players=", ".join(name for name, _ in PLAYERS)) + text = text.replace("\n\n", "\n") # troff dislikes blank lines + target = Path(sys.argv[1] if len(sys.argv) > 1 + else Path(__file__).parent / "saunterbrowse.1") + target.write_text(text) + print(target) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/make-man.py b/packaging/make-man.py index 402c78d..2dcc798 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -412,6 +412,10 @@ Raise the squelch threshold and save it as the new default. 0 on success, 1 for a bad option or an unusable configuration, 2 when the receiver could not be opened. .SH SEE ALSO +.BR saunterbrowse (1) +\[em] browse and play back what a scan collected: the recordings list, their +transcripts and their identifications, on one screen. +.PP .BR rtl_test (1), .BR rtl_sdr (1), .BR espeak-ng (1) diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 new file mode 100644 index 0000000..7778bdc --- /dev/null +++ b/packaging/saunterbrowse.1 @@ -0,0 +1,182 @@ +.\" Generated by packaging/make-browse-man.py -- do not edit by hand. +.TH SAUNTERBROWSE 1 "2026-08-22" "bandsaunter 2026-08-22_03" "User Commands" +.SH NAME +saunterbrowse \- read and listen to what a bandsaunter scan collected +.SH SYNOPSIS +.B saunterbrowse +.RI [ DIRECTORY ] +.RB [ \-\-sort +.IR ORDER ] +.RB [ \-\-filter +.IR TEXT ] +.RB [ \-\-player +.IR CMD ] +.RB [ \-\-list ] +.SH DESCRIPTION +A scan leaves a directory of recordings. Beside each one is a JSON file +holding what the classifier made of it, and for speech a transcript of what +was said. Reading that by hand means opening files one at a time and guessing +which are worth playing. +.PP +.B saunterbrowse +shows them as a list you move through with the arrow keys. The transcript of +whichever recording is highlighted fills the top of the screen, because that +is the part you actually want to read; underneath it are the identification, +the bands the frequency falls in, and the list itself. Pressing Enter plays +the recording. +.PP +With no +.I DIRECTORY +it opens the one the scanner writes to, taken from your saved settings, so it +normally needs no arguments at all. +.PP +It only ever reads. Nothing in the recordings directory is renamed, moved or +deleted. +.SH KEYS +.TP +.B "Up Down k j" +Move through the recordings. +.TP +.B "PgUp PgDn" +A screenful at a time. +.TP +.B "Home End" +The first and the last. +.TP +.B Enter +Play the highlighted recording. Playing a second one stops the first: two +players talking over each other is worse than either alone. +.TP +.B Space +Stop playing. With nothing playing, it starts, like Enter. +.TP +.B t +Read the whole transcript full screen, scrolling with the arrow keys. Useful +for a long net, where the panel at the top can only show the first few lines +\[em] it says so when there is more. +.TP +.B / +Filter. What you type is matched against the frequency, the filename, the +identification and +.I everything that was said, +so "was the repeater mentioned" is a question you can ask directly. Enter +accepts, Escape clears. +.TP +.B s +Cycle the order: time, frequency, duration. +.TP +.B r +Re-read the directory. A scan running in another window is still writing to +it, and this picks up what has arrived since. +.TP +.B o +Print the highlighted recording's path and quit, for piping into something +else. +.TP +.B "? h" +The list of keys, and which audio player was found. +.TP +.B q +Quit. With a filter in force, Escape clears the filter first. +.SH OPTIONS +.TP +.BI DIRECTORY +Where the recordings are. Defaults to the scanner's output directory, or to +.B $BANDSAUNTER_OUTPUT +when that is set. +.TP +.BI \-\-sort " ORDER" +Start in this order: time, frequency, duration. Time is newest first; duration is longest +first. +.TP +.BI \-\-filter " TEXT" +Start with only the recordings matching this, exactly as if it had been typed +at the +.B / +prompt. +.TP +.BI \-\-player " CMD" +The command used to play a recording. The path is appended as its last +argument. By default the first of these that is installed is used: +pw-play, paplay, aplay, play, ffplay, mpv. +.TP +.B \-\-list +Print one line per recording and exit, without drawing anything. This is what +to use over a pipe, in a script, or anywhere there is no terminal. +.TP +.B \-V ", " \-\-version +Print the version and exit. +.SH SOUND +Playback is handed to whichever player is installed rather than done in the +program: the recordings are ordinary WAV files, every desktop already has +something that plays them, and a browser that cannot start is worse than one +that cannot play. If none is found, everything else still works and the +message says which packages would fix it. +.PP +Over ssh there is usually no sound server at the far end. The browser and its +transcripts work regardless; only Enter has nothing to do. +.SH TRANSCRIPTS +A transcript appears only where a recogniser produced one, which means the +capture was judged to be speech and +.B bandsaunter\-transcribe +was installed at the time. Where there is none, the panel says which of the +several reasons applies \[em] Morse, data, a bare carrier, or speech that was +never offered to a recogniser \[em] because those want different things done +about them. +.PP +Two places can hold the text: the JSON sidecar records what was recognised +during the scan, and the +.I _transcription.txt +beside the recording is what a later re\-run wrote. The file wins, being the +more recent of the two. +.SH FILES +.TP +.I ~/bandsaunter/ +Where recordings are written, unless the saved settings say otherwise. +.TP +.IR frequency \-\- date _ time \- modulation .wav +A recording. +.TP +.IR ... .json +Its measurements and identification. +.TP +.IR ... _transcription.txt +What was said, where a recogniser heard speech. +.SH ENVIRONMENT +.TP +.B BANDSAUNTER_OUTPUT +The directory to open, overriding the saved settings. +.SH EXAMPLES +.PP +.RS +.EX +saunterbrowse +.EE +.RE +.PP +Open the scanner's output directory. +.PP +.RS +.EX +saunterbrowse /mnt/recordings \-\-sort frequency +.EE +.RE +.PP +Browse somewhere else, grouped by channel rather than by time. +.PP +.RS +.EX +saunterbrowse \-\-list | grep \-i "mile marker" +.EE +.RE +.PP +Search the transcripts from a script. +.SH EXIT STATUS +0 on a clean exit, 1 when the directory holds no recordings, 2 when it does +not exist or there is no terminal to draw on. +.SH SEE ALSO +.BR bandsaunter (1) +.SH BUGS +The list is read when the browser opens. Press +.B r +to pick up recordings a running scan has written since. diff --git a/pyproject.toml b/pyproject.toml index 9444693..e52e873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ transcribe-small = ["vosk>=0.3"] [project.scripts] bandsaunter = "bandsaunter.cli:main" +saunterbrowse = "bandsaunter.browse:main" [tool.setuptools.dynamic] # The displayed version (2026-08-21_01) is not valid PEP 440, so packaging diff --git a/tests/test_browse.py b/tests/test_browse.py new file mode 100644 index 0000000..4c568d3 --- /dev/null +++ b/tests/test_browse.py @@ -0,0 +1,657 @@ +"""saunterbrowse: reading and listening to what a scan collected. + +The browser never writes to the recordings directory, so every test here is +about what it *shows* and what it does with a keypress. Playback is checked +against a recorded command rather than a real one: whether audio came out of +the speakers is not something a test can see, but which file was handed to +which player is. +""" +import json +import re +import time +import wave +from datetime import datetime +from pathlib import Path + +import numpy as np +import pytest +from rich.console import Console + +from bandsaunter.browse import (Browser, Player, PLAYERS, Keyboard, main, + scan_directory) + + +# -- fixtures ---------------------------------------------------------------- + +def write_wav(path: Path, seconds: float = 1.0, rate: int = 16_000) -> None: + n = int(seconds * rate) + tone = (np.sin(2 * np.pi * 440 * np.arange(n) / rate) * 8000) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(tone.astype(np.int16).tobytes()) + + +def make_capture(directory: Path, mhz: float, when: str, mode: str, + seconds: float = 1.0, transcript: str = "", + meta: dict | None = None) -> Path: + """One recording and its sidecars, named the way a scan names them.""" + stem = f"{mhz:011.6f}MHz--{when}-{mode}" + wav = directory / f"{stem}.wav" + write_wav(wav, seconds) + if meta is not None: + body = {"frequency_hz": mhz * 1e6, "hit": meta} + (directory / f"{stem}.json").write_text(json.dumps(body)) + if transcript: + (directory / f"{stem}_transcription.txt").write_text(transcript + "\n") + return wav + + +@pytest.fixture +def library(tmp_path): + """A small recordings directory covering the cases that render differently.""" + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 4.0, + transcript="Net control, this is W1AW, standing by.", + meta={"category": "voice", "snr_db": 21.5, + "classification": "Narrowband FM voice", + "confidence": 0.88, "ctcss_hz": 100.0, + "band_labels": ["2 m Amateur"]}) + make_capture(tmp_path, 856.5625, "2026-08-22_11_00_00", "fsk", 2.0, + meta={"category": "trunk", "snr_db": 38.0, "baud": 3600.0, + "classification": "Motorola SMARTNET / SmartZone " + "(Type I/II) trunking control channel", + "confidence": 0.95}) + make_capture(tmp_path, 144.1, "2026-08-22_09_00_00", "cw", 3.0, + meta={"category": "cw", "morse_text": "VVV DE W1AW", + "morse_wpm": 18.0, "classification": "CW / Morse"}) + return tmp_path + + +def browser(directory, width=100, height=30, player=None) -> Browser: + console = Console(width=width, height=height, force_terminal=True) + return Browser(directory, console=console, + player=player if player is not None else Player([])) + + +def frame(b: Browser) -> str: + with b.console.capture() as cap: + b.console.print(b.render()) + return cap.get() + + +class FakePlayer(Player): + """Records what it was asked to play instead of playing it.""" + + def __init__(self): + super().__init__(["/bin/true"]) + self.played: list[Path] = [] + self.stops = 0 + self._active = False + + def play(self, cap): + self.played.append(cap.path) + self.playing = cap + self._active = True + self.error = "" + + def stop(self): + if self._active: + self.stops += 1 + self._active = False + self.playing = None + + @property + def active(self): + return self._active + + +# -- reading the directory --------------------------------------------------- + +def test_every_recording_is_found(library): + assert len(scan_directory(library)) == 3 + + +def test_the_newest_recording_comes_first(library): + caps = scan_directory(library) + assert [c.mode for c in caps] == ["fsk", "nfm", "cw"] + + +def test_the_filename_alone_gives_frequency_time_and_mode(library): + cap = [c for c in scan_directory(library) if c.mode == "nfm"][0] + assert cap.frequency == pytest.approx(146_520_000.0) + assert cap.when == datetime(2026, 8, 22, 10, 0, 0) + + +def test_length_comes_from_the_wav_not_the_sidecar(tmp_path): + """A combined file grows after its sidecar was written, and a sidecar can + be missing entirely.""" + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", 2.5, + meta={"duration": 999.0}) + cap = scan_directory(tmp_path)[0] + assert cap.duration == pytest.approx(2.5, abs=0.05) + + +def test_a_recording_with_no_sidecar_is_still_listed(tmp_path): + write_wav(tmp_path / "0146.520000MHz--2026-08-22_10_00_00-nfm.wav") + cap = scan_directory(tmp_path)[0] + assert cap.category == "unknown" + assert cap.classification == "unclassified" + + +def test_a_file_the_browser_cannot_name_is_shown_rather_than_hidden(tmp_path): + write_wav(tmp_path / "something-else.wav") + caps = scan_directory(tmp_path) + assert len(caps) == 1 and caps[0].frequency == 0.0 + + +def test_a_combined_per_frequency_file_is_recognised(tmp_path): + write_wav(tmp_path / "0146.880000MHz.wav") + cap = scan_directory(tmp_path)[0] + assert cap.combined + assert cap.frequency == pytest.approx(146_880_000.0) + assert cap.category == "combined" + + +def test_sidecars_are_only_read_when_something_asks(library): + """A directory of ten thousand recordings has to open instantly.""" + caps = scan_directory(library) + assert all(c._meta is None for c in caps) + _ = caps[0].category + assert caps[0]._meta is not None + + +def test_a_missing_directory_is_not_an_exception(tmp_path): + assert scan_directory(tmp_path / "nowhere") == [] + + +# -- the transcript ---------------------------------------------------------- + +def test_the_transcript_is_at_the_top_of_the_screen(library): + b = browser(library) + b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0] + out = frame(b) + head = out.split("recordings in")[0] + assert "Net control, this is W1AW" in head + assert "transcript" in head + + +def test_the_txt_beside_the_recording_beats_the_sidecar(tmp_path): + """The sidecar records what was recognised at the time; the file is what a + later re-run wrote.""" + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", + transcript="the newer text", + meta={"transcript": "the older text", "category": "voice"}) + assert scan_directory(tmp_path)[0].transcript == "the newer text" + + +def test_the_sidecar_transcript_is_used_when_there_is_no_txt(tmp_path): + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", + meta={"transcript": "only in the sidecar", "category": "voice"}) + assert scan_directory(tmp_path)[0].transcript == "only in the sidecar" + + +@pytest.mark.parametrize("meta,expected", [ + ({"category": "digital"}, "data, not speech"), + ({"category": "trunk"}, "data, not speech"), + ({"category": "carrier"}, "silence by definition"), + ({"category": "cw"}, "Morse"), + ({"category": "voice"}, "no transcript beside it"), +]) +def test_an_empty_transcript_says_which_reason_applies(tmp_path, meta, + expected): + """"No transcript" is the same shape for Morse, for data and for a + recogniser that was never installed, and those want different things + done about them.""" + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", meta=meta) + b = browser(tmp_path) + assert expected in frame(b) + + +def test_decoded_morse_is_shown_where_the_transcript_would_be(library): + b = browser(library) + b.index = [i for i, c in enumerate(b.view) if c.mode == "cw"][0] + assert "VVV DE W1AW" in frame(b) + + +def test_a_transcript_too_long_for_the_panel_says_so(tmp_path): + """Cutting the end off a transmission silently is how a reader ends up + believing they have read all of it.""" + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", + transcript=" ".join(f"word{i}" for i in range(400)), + meta={"category": "voice"}) + out = frame(browser(tmp_path, height=24)) + assert "more line(s)" in out and "press t" in out + + +def test_the_reader_shows_what_the_panel_could_not(tmp_path): + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", + transcript=" ".join(f"word{i}" for i in range(400)), + meta={"category": "voice"}) + b = browser(tmp_path, height=24) + panel_text = frame(b) + b.handle("t") + assert b.reading + reader = frame(b) + assert "word0" in reader + assert "word200" in reader and "word200" not in panel_text + + +def test_the_reader_scrolls_and_comes_back(tmp_path): + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm", + transcript=" ".join(f"word{i}" for i in range(400)), + meta={"category": "voice"}) + b = browser(tmp_path, height=20) + b.handle("t") + first = frame(b) + b.handle("pgdn") + assert frame(b) != first + b.handle("home") + assert frame(b) == first + b.handle("escape") + assert not b.reading + + +def test_the_reader_refuses_when_there_is_nothing_to_read(library): + b = browser(library) + b.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0] + b.handle("t") + assert not b.reading + assert "no transcript" in b.message + + +# -- the frame --------------------------------------------------------------- + +@pytest.mark.parametrize("width,height", + [(60, 16), (80, 24), (100, 30), (200, 60)]) +def test_the_frame_never_overflows_the_terminal(library, width, height): + """A frame taller than the terminal cannot be redrawn in place: every + refresh scrolls another copy of it into the scrollback.""" + b = browser(library, width=width, height=height) + lines = frame(b).rstrip("\n").split("\n") + assert len(lines) <= height, f"{len(lines)} lines in {height}" + bare = [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines] + assert max(len(line) for line in bare) <= width, "a line ran off the side" + + +def test_one_row_per_recording_however_long_the_transcript(tmp_path): + """A wrapped row would push the ones below it off the bottom, and the + cursor arithmetic counts rows.""" + for i in range(6): + make_capture(tmp_path, 146.0 + i, f"2026-08-22_10_0{i}_00", "nfm", + transcript=" ".join(f"word{n}" for n in range(200)), + meta={"category": "voice"}) + b = browser(tmp_path, height=30) + body = frame(b).split("recordings in")[1] + assert body.count("word0") == 6, "a row wrapped instead of truncating" + + +def test_the_header_names_the_frequency_mode_and_category(library): + b = browser(library) + b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0] + head = frame(b).split("transcript")[0] + assert "146.52 MHz" in head and "NFM" in head and "voice" in head + + +def test_a_symbol_rate_is_only_shown_where_it_means_something(library): + """The estimator returns a figure for every capture, and "120 baud" + beside a conversation is noise.""" + b = browser(library) + b.index = [i for i, c in enumerate(b.view) if c.mode == "fsk"][0] + assert "3600 baud" in frame(b) + b.index = [i for i, c in enumerate(b.view) if c.mode == "nfm"][0] + assert "baud" not in frame(b) + + +def test_an_empty_directory_renders_rather_than_crashing(tmp_path): + b = browser(tmp_path) + assert "nothing here yet" in frame(b) + + +# -- moving around ----------------------------------------------------------- + +def test_the_arrow_keys_move_the_cursor(library): + b = browser(library) + assert b.index == 0 + b.handle("down") + assert b.index == 1 + b.handle("up") + assert b.index == 0 + + +def test_the_cursor_stops_at_both_ends(library): + b = browser(library) + for _ in range(20): + b.handle("up") + assert b.index == 0 + for _ in range(20): + b.handle("down") + assert b.index == len(b.view) - 1 + + +def test_home_and_end_go_to_the_ends(library): + b = browser(library) + b.handle("end") + assert b.index == len(b.view) - 1 + b.handle("home") + assert b.index == 0 + + +def test_the_cursor_stays_on_screen_in_a_long_list(tmp_path): + for i in range(60): + make_capture(tmp_path, 146.0, f"2026-08-22_10_{i // 60:02d}_{i % 60:02d}", + "nfm", meta={"category": "voice"}) + b = browser(tmp_path, height=24) + b.handle("end") + frame(b) + assert b.top <= b.index < b.top + b._rows() + + +def test_sorting_cycles_and_reorders(library): + b = browser(library) + assert b.sort == "time" + b.handle("s") + assert b.sort == "frequency" + assert [c.frequency for c in b.view] == sorted(c.frequency + for c in b.view) + b.handle("s") + assert b.sort == "duration" + assert b.view[0].duration >= b.view[-1].duration + + +def test_quitting_stops_the_loop(library): + assert browser(library).handle("q") is False + + +# -- searching --------------------------------------------------------------- + +def test_search_matches_what_was_said(library): + """The point of it: "did anyone mention the repeater" is a question about + content, not about filenames.""" + b = browser(library) + b.handle("/") + for ch in "standing by": + b.handle(ch) + b.handle("enter") + assert len(b.view) == 1 and b.view[0].mode == "nfm" + + +def test_search_matches_the_identification(library): + b = browser(library) + b.query = "smartnet" + b.apply() + assert len(b.view) == 1 and b.view[0].mode == "fsk" + + +def test_search_matches_the_frequency_in_the_name(library): + b = browser(library) + b.query = "0856" + b.apply() + assert len(b.view) == 1 + + +def test_backspace_edits_the_search(library): + b = browser(library) + b.handle("/") + for ch in "smartnetX": + b.handle(ch) + assert b.view == [] + b.handle("backspace") + assert len(b.view) == 1 + + +def test_escape_clears_the_search_before_it_quits(library): + """Escape with a filter in force means "show me everything again", not + "throw the program away".""" + b = browser(library) + b.query = "smartnet" + b.apply() + assert b.handle("escape") is True + assert b.query == "" and len(b.view) == 3 + assert b.handle("escape") is False + + +def test_a_search_that_matches_nothing_says_so(library): + b = browser(library) + b.query = "no such thing" + b.apply() + assert "nothing matches" in frame(b) + + +def test_keys_typed_while_searching_are_not_commands(library): + """'q' has to be a letter in the search box, not an instruction to quit.""" + b = browser(library) + b.handle("/") + assert b.handle("q") is True + assert b.query == "q" + + +# -- playing ----------------------------------------------------------------- + +def test_enter_plays_the_highlighted_recording(library): + p = FakePlayer() + b = browser(library, player=p) + b.handle("down") + b.handle("enter") + assert p.played == [b.current.path] + + +def test_space_stops_what_is_playing(library): + p = FakePlayer() + b = browser(library, player=p) + b.handle("enter") + b.handle("space") + assert p.stops == 1 and not p.active + + +def test_playing_a_second_recording_stops_the_first(tmp_path): + """Two players talking over each other is worse than either alone.""" + calls = [] + + class Recorder(Player): + def play(self, cap): + calls.append(("play", cap.path.name)) + self.playing = cap + + def stop(self): + calls.append(("stop", None)) + + make_capture(tmp_path, 146.52, "2026-08-22_10_00_00", "nfm") + make_capture(tmp_path, 147.52, "2026-08-22_10_01_00", "nfm") + b = browser(tmp_path, player=Recorder([])) + b.handle("enter") + b.handle("down") + b.handle("enter") + assert [c[0] for c in calls] == ["play", "play"] + + +def test_the_real_player_stops_the_previous_file_first(library, tmp_path): + """The stop is in Player.play, so every caller gets it.""" + p = Player(["/bin/sleep", "5"]) + caps = scan_directory(library) + p.play(caps[0]) + first = p.proc + assert first is not None + p.play(caps[1]) + assert first.poll() is not None, "the first player was left running" + p.stop() + + +def test_stopping_kills_the_player_and_anything_it_started(library, tmp_path): + """Several of these players are wrapper scripts that fork the real one. + Signalling only the script leaves the sound playing with nothing on + screen to stop it.""" + import subprocess as sp + marker = tmp_path / "child.pid" + script = tmp_path / "wrapper.sh" + script.write_text("#!/bin/sh\nsleep 30 &\necho $! > %s\nwait\n" % marker) + script.chmod(0o755) + + p = Player([str(script)]) + p.play(scan_directory(library)[0]) + for _ in range(50): + if marker.exists(): + break + time.sleep(0.05) + child = int(marker.read_text().strip()) + assert sp.run(["kill", "-0", str(child)]).returncode == 0, "never started" + p.stop() + for _ in range(50): + if sp.run(["kill", "-0", str(child)], + capture_output=True).returncode != 0: + break + time.sleep(0.05) + assert sp.run(["kill", "-0", str(child)], + capture_output=True).returncode != 0, "still playing" + + +def test_no_player_installed_is_reported_not_crashed(library): + b = browser(library, player=Player([])) # [] means "none installed" + b.handle("enter") + assert "no audio player" in b.message + assert all(name in b.message for name, _ in PLAYERS) + + +def test_the_file_is_the_last_argument_to_the_player(library): + p = Player(["/bin/true", "--flag"]) + cap = scan_directory(library)[0] + p.play(cap) + assert p.command == ["/bin/true", "--flag"] + assert p.playing is cap + p.stop() + + +def test_playback_progress_is_shown(library): + p = FakePlayer() + b = browser(library, player=p) + b.handle("enter") + p.started_at = 0.0 # started long ago: the bar is full + assert "♪" in frame(b) + + +# -- the keyboard ------------------------------------------------------------ + +# Driven through a real pty, not a stand-in object. A fake stream with a +# read() method passed every one of these while the program hung on the first +# keypress: sys.stdin.read(1) goes through a buffered text wrapper, which in +# cbreak mode waits for more bytes than a single key provides. + + +@pytest.fixture +def terminal(): + """A pty whose slave end is what Keyboard will read.""" + import os + import pty + master, slave = pty.openpty() + stream = os.fdopen(slave, "r") + yield master, stream + for fd in (master,): + try: + os.close(fd) + except OSError: + pass + try: + stream.close() + except OSError: + pass + + +@pytest.mark.parametrize("raw,name", [ + ("\x1b[A", "up"), ("\x1b[B", "down"), ("\x1b[C", "right"), + ("\x1b[D", "left"), ("\x1b[5~", "pgup"), ("\x1b[6~", "pgdn"), + ("\x1b[H", "home"), ("\x1b[F", "end"), + ("\x1bOA", "up"), ("\x1bOB", "down"), + ("\r", "enter"), ("\n", "enter"), (" ", "space"), + ("\x7f", "backspace"), ("q", "q"), ("/", "/"), ("t", "t"), +]) +def test_a_key_arrives_as_one_key(terminal, raw, name): + """An arrow is "\\x1b[A" -- three bytes. Read one at a time it becomes + three commands, and the cursor jumps somewhere unasked.""" + import os + master, stream = terminal + with Keyboard(stream) as kb: + assert kb.enabled + os.write(master, raw.encode()) + assert kb.get(0.5) == name + assert kb.get(0.05) == "", "one keypress produced more than one key" + + +def test_a_bare_escape_is_not_mistaken_for_an_arrow(terminal): + import os + master, stream = terminal + with Keyboard(stream) as kb: + os.write(master, b"\x1b") + assert kb.get(0.5) == "escape" + + +def test_keys_arriving_together_are_delivered_one_at_a_time(terminal): + """Held down, or pasted, several keys land in a single read.""" + import os + master, stream = terminal + with Keyboard(stream) as kb: + os.write(master, b"\x1b[B\x1b[Bq") + assert [kb.get(0.5) for _ in range(3)] == ["down", "down", "q"] + + +def test_nothing_pressed_returns_nothing(terminal): + _, stream = terminal + with Keyboard(stream) as kb: + assert kb.get(0.05) == "" + + +def test_the_terminal_is_restored_afterwards(terminal): + """Left in cbreak, the shell that follows has no line editing and no echo.""" + import termios + _, stream = terminal + before = termios.tcgetattr(stream.fileno()) + with Keyboard(stream) as kb: + assert kb.enabled + assert termios.tcgetattr(stream.fileno()) != before + assert termios.tcgetattr(stream.fileno()) == before + + +def test_a_stream_that_is_not_a_terminal_is_not_an_error(tmp_path): + path = tmp_path / "notatty" + path.write_text("q") + with open(path) as fh, Keyboard(fh) as kb: + assert not kb.enabled + assert kb.get(0.0) == "" + + +# -- the command line -------------------------------------------------------- + +def test_list_prints_one_line_per_recording(library, capsys): + assert main(["--list", str(library)]) == 0 + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()] + assert len(lines) == 3 + assert any("Net control" in ln for ln in lines) + + +def test_list_honours_the_filter(library, capsys): + assert main(["--list", "--filter", "smartnet", str(library)]) == 0 + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln.strip()] + assert len(lines) == 1 + + +def test_a_missing_directory_is_explained(tmp_path, capsys): + assert main([str(tmp_path / "nowhere")]) == 2 + assert "no such directory" in capsys.readouterr().out + + +def test_an_empty_directory_is_explained(tmp_path, capsys): + assert main([str(tmp_path)]) == 1 + assert "no recordings" in capsys.readouterr().out + + +def test_it_refuses_to_draw_where_there_is_no_terminal(library, capsys): + """Piped or redirected, the full-screen frame would be gibberish.""" + assert main([str(library)]) == 2 + out = capsys.readouterr().out + assert "needs a terminal" in out and "--list" in out + + +def test_the_output_directory_is_found_without_being_told(monkeypatch, + tmp_path): + monkeypatch.setenv("BANDSAUNTER_OUTPUT", str(tmp_path)) + from bandsaunter.browse import default_directory + assert default_directory() == tmp_path diff --git a/tests/test_manpage.py b/tests/test_manpage.py index 361fc22..6057989 100644 --- a/tests/test_manpage.py +++ b/tests/test_manpage.py @@ -70,3 +70,64 @@ def test_the_guidance_survives_into_the_rendered_page(page, tmp_path): # A sentence from one setting's guidance, chosen because it is the one a # newcomer most needs: what the squelch actually is. assert "This is the squelch knob." in flat + + +# -- the browser's page ------------------------------------------------------ + +BROWSE_GENERATOR = (Path(__file__).resolve().parent.parent / "packaging" + / "make-browse-man.py") + + +@pytest.fixture(scope="module") +def browse_page(tmp_path_factory): + out = tmp_path_factory.mktemp("man") / "saunterbrowse.1" + subprocess.run([sys.executable, str(BROWSE_GENERATOR), str(out)], + check=True, capture_output=True) + return out.read_text() + + +def test_the_browser_has_a_page_of_its_own(browse_page): + assert ".TH SAUNTERBROWSE 1" in browse_page + assert "saunterbrowse \\- read and listen" in browse_page + + +def test_every_browser_flag_is_documented(browse_page): + from bandsaunter.browse import build_parser + # --help is argparse's own and needs no prose of its own. + flags = [o for a in build_parser()._actions for o in a.option_strings + if o not in ("-h", "--help")] + missing = [f for f in flags + if f.replace("-", "\\-") not in browse_page + and f not in browse_page] + assert not missing, f"undocumented flags: {missing}" + + +def test_every_browser_key_is_documented(browse_page): + """A key that does something the manual does not mention is a key nobody + will press.""" + for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q", + "t"): + assert key in browse_page, key + + +def test_the_browser_page_names_the_players_it_looks_for(browse_page): + from bandsaunter.browse import PLAYERS + for name, _ in PLAYERS: + assert name in browse_page, name + + +def test_the_two_pages_point_at_each_other(page, browse_page): + assert "saunterbrowse (1)" in page or "saunterbrowse" in page + assert "bandsaunter (1)" in browse_page + + +def test_the_browser_page_renders_without_complaint(browse_page, tmp_path): + groff = shutil.which("groff") + if groff is None: + pytest.skip("groff is not installed") + src = tmp_path / "saunterbrowse.1" + src.write_text(browse_page) + done = subprocess.run([groff, "-man", "-ww", "-z", str(src)], + capture_output=True, text=True) + assert done.returncode == 0, done.stderr + assert not done.stderr.strip(), done.stderr