From cc317914e1740a47796fd1fba506b39bfc6974f4 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Sat, 22 Aug 2026 15:06:33 -0700 Subject: [PATCH] Add saunterbrowse, for reading back what a scan collected A long scan leaves hundreds of recordings, each with a JSON sidecar of measurements and, where a recogniser heard speech, a transcript. Reading that meant opening files one at a time and guessing which were worth playing. saunterbrowse is a second executable in the same package. Arrow keys move through the recordings; the transcript of whichever is highlighted fills the top of the screen, because that is the part anyone actually wants to read. Enter plays it, handing the file to whichever player is installed -- the recordings are ordinary WAVs, every desktop already has something that plays them, and a browser that cannot start would be worse than one that cannot play. t opens the whole transcript full screen when it is longer than the panel, and says so rather than cutting the end off silently. / filters on the frequency, the name, the identification, or anything that was said, which is the point of it: "was the repeater mentioned" is a question about content. Sidecars are read only for the rows on screen, so a directory of ten thousand recordings opens instantly. Where there is no transcript the panel says which of the reasons applies -- Morse (decoded, and shown), data, a bare carrier, or speech never offered to a recogniser -- because those want different things done about them. It only ever reads. Two things were only found by driving it through a real terminal. sys.stdin.read(1) goes through a buffered text wrapper, which in cbreak mode waits for more bytes than one keypress provides: the program drew its first frame and then hung, while tests against a stand-in stream object passed. It reads the file descriptor now, and the tests drive a pty. And stopping playback signalled only the direct child, so a player that is a wrapper script kept the sound going with nothing on screen to stop it; the whole process group is signalled instead, which is what start_new_session was there for. man saunterbrowse ships beside man bandsaunter, and the two point at each other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 87 ++- bandsaunter/__init__.py | 2 +- bandsaunter/browse.py | 1008 ++++++++++++++++++++++++++++++++++ packaging/bandsaunter.1 | 6 +- packaging/build-deb.sh | 11 + packaging/make-browse-man.py | 219 ++++++++ packaging/make-man.py | 4 + packaging/saunterbrowse.1 | 182 ++++++ pyproject.toml | 1 + tests/test_browse.py | 657 ++++++++++++++++++++++ tests/test_manpage.py | 61 ++ 11 files changed, 2233 insertions(+), 5 deletions(-) create mode 100644 bandsaunter/browse.py create mode 100755 packaging/make-browse-man.py create mode 100644 packaging/saunterbrowse.1 create mode 100644 tests/test_browse.py 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