Deal with the recordings, not just read them

A night's scan leaves hundreds of files, most worth nothing and a few of
them the reason it was left running.  Sorting that out meant leaving the
browser and going at the directory with mv and rm.

Five keys, meant to be pressed once each going down the list:

  S I N   file it into saved/, investigate/ or noise/
  u       put the last one filed back
  d       delete it and its sidecars, for good -- asks first
  m       lock the frequency out, so no later scan stops on it

Each of these acts on the whole capture -- the .wav, the JSON sidecar, the
IQ, the transcript and the decoded data -- because a recording in one
directory and its transcript in another is a pair nothing will ever put
back together.  A move that cannot be finished puts back whatever already
moved.  The cursor stays on the row it was on, which is now the next
recording, since a cursor that jumped would make one-key-per-recording
impossible.

m writes to the lock-out list in the settings file, the same one the
scanner's own l key maintains, so a birdie found while reading last night's
recordings is gone from tonight's.  It says "the next scan": one already
running read its settings when it started.

The subdirectories sit under the recordings directory, so a scan writing
there never looks in them, and saunterbrowse ~/bandsaunter/saved reads one
back.

Also here, because this is the first part of the browser that writes:

 - The help screen is back inside eighty by twenty-four.  It had grown past
   the bottom of an ordinary window, which puts "q quit" off the screen.
 - The footer drops keys in a deliberate order when the window is narrow,
   rather than ellipsising whichever happened to be at the end.
 - Moving or deleting what is playing stops the player first.
 - The pty harness accepted an env and ignored it, so a test aimed at a
   throwaway settings directory wrote to the real one.  It honours it now,
   and conftest redirects the settings directory for every test besides.

1014 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-08-29 13:21:32 -07:00
parent d6ae6d0c22
commit f9f0d94000
11 changed files with 1162 additions and 44 deletions

View file

@ -1086,6 +1086,10 @@ span is taken exactly as written, since a noisy stretch of spectrum has a
definite width rather than a point with a guess around it. Ranges accept the definite width rather than a point with a guess around it. Ranges accept the
same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`. same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`.
`saunterbrowse` writes to the same list: pressing `m` over a recording locks
out the frequency it was heard on, which is usually when you find out that a
frequency is not worth listening to.
`--lockout-control` adds each trunking control channel to the list as it is `--lockout-control` adds each trunking control channel to the list as it is
found. Two runs never write anything back: `--no-config` has no settings file found. Two runs never write anything back: `--no-config` has no settings file
to write to, since the point of the flag is to leave the saved settings alone; to write to, since the point of the flag is to leave the saved settings alone;
@ -1129,7 +1133,7 @@ or symbol rate where there is one, and the bands the frequency falls in.
146.88 MHz 13:01:44 nfm 42.8s Alright, moving on. It is… │ 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… │ │ 146.88 MHz 13:00:44 nfm 35.2s Check out communication o… │
╰───────────────────────────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────────╯
↑↓ move ⏎ play space stop / search t read s sort ? keys q ↑↓ move ⏎ play space stop / search t read S I N file d delete m mask q quit
``` ```
| Key | What it does | | Key | What it does |
@ -1144,8 +1148,46 @@ or symbol rate where there is one, and the bands the frequency falls in.
| `s` | sort by time, frequency or length | | `s` | sort by time, frequency or length |
| `r` | re-read the directory, picking up what a running scan has written | | `r` | re-read the directory, picking up what a running scan has written |
| `o` | print the file's path and quit | | `o` | print the file's path and quit |
| `S` `I` `N` | file it into `saved/`, `investigate/` or `noise/` |
| `u` | put the last one filed back |
| `d` | delete it and its sidecars, for good — asks first |
| `m` | lock this frequency out, so no later scan stops on it |
| `q` | quit | | `q` | quit |
### Dealing with what you find
A night's scan leaves hundreds of files, most worth nothing and a few of them
the reason you left it running. Sorting that out is one key per recording,
going down the list:
```
S saved/ keep this one
I investigate/ come back to this one
N noise/ not a signal worth keeping
d delete it outright — asks first
m never record this frequency again
```
Each of `S I N` moves the whole capture — the `.wav`, the JSON sidecar, the
raw IQ if it was kept, the transcript and the decoded data — because a
recording in one directory and its transcript in another is a pair nothing
will ever put back together. If a move cannot be finished, whatever already
moved is put back.
The subdirectories are ordinary directories inside the recordings directory,
so a scan writing there never looks in them, and `saunterbrowse
~/bandsaunter/saved` reads one back. `u` puts the last one filed back — one
step, so that a mistyped key costs nothing. `d` asks first, because nothing
puts that back.
`m` is the other half of the same job. A birdie or a pager transmitter that
fills the directory night after night is a scanning problem, not a recording
one, so this writes the frequency into the lock-out list in your settings —
the same list [the scanner's own `l` key](#lock-outs) writes to. It
takes effect on the next scan; one already running read its settings when it
started. Locking a frequency out does not delete what has already been
recorded on it, so pressing `m` and then `d` is the usual thing to do.
### Detected callsigns ### Detected callsigns
Under the transcript, every callsign heard in it is listed with the name and Under the transcript, every callsign heard in it is listed with the name and

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so # Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded # 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text. # to two digits so versions sort as text.
VERSION_DATE = "2026-08-28" VERSION_DATE = "2026-08-29"
VERSION_REVISION = 3 VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -11,6 +11,7 @@ the thing you actually want to read -- gets the top of the screen.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import glob
import json import json
import os import os
import re import re
@ -39,10 +40,12 @@ from rich.text import Text
from . import __version__ from . import __version__
from .bandplan import band_names, fmt_hz, shorten_band from .bandplan import band_names, fmt_hz, shorten_band
from .callsign import CallsignBook, HEADING, find_callsigns from .callsign import CallsignBook, HEADING, find_callsigns
from .config import load_default from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts
from .kml import DEFAULT_KML_NAME, KmlLog from .kml import DEFAULT_KML_NAME, KmlLog
from .ranges import Lockout
__all__ = ["main", "Capture", "scan_directory", "Browser", "Player"] __all__ = ["main", "Capture", "scan_directory", "Browser", "Player",
"FILING"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -233,6 +236,36 @@ class Capture:
except OSError: except OSError:
return 0 return 0
def files(self) -> list[Path]:
"""Every file that belongs to this recording, the .wav included.
A capture is not one file. Beside it a scan writes the JSON sidecar,
sometimes the raw IQ and its SigMF description, the transcript, and
the decoded data. They are one thing, and moving or deleting the
recording without them leaves sidecars describing a recording nobody
has any more.
Matched on the stem followed by a dot, never on the stem followed by
anything: two signals can land in the same second on the same
frequency, in which case the second is filed as ``...-nfm_2``, and a
looser pattern would sweep that up with its neighbour.
"""
stem = self.path.stem
found = {self.path}
try:
found.update(p for p in
self.path.parent.glob(glob.escape(stem) + ".*")
if p.is_file())
except OSError:
pass
# The two that do not follow the pattern, because a transcript is
# named after the recording rather than sharing its extension.
for tail in ("_transcription.txt", "_data.txt"):
side = self.path.with_name(stem + tail)
if side.is_file():
found.add(side)
return sorted(found)
def _from_name(path: Path) -> Capture: def _from_name(path: Path) -> Capture:
"""Read what the filename alone says, without touching any sidecar.""" """Read what the filename alone says, without touching any sidecar."""
@ -463,6 +496,19 @@ class Keyboard:
SORTS = ("time", "frequency", "duration") SORTS = ("time", "frequency", "duration")
# The three subdirectories a recording can be filed into, and the key that
# does it. Upper case on purpose: j and k are under the same fingers, and a
# key that moves a file is not one to hit while scrolling. They are ordinary
# directories under the recordings directory, so a scan never looks in them
# again and the browser opens one by being pointed at it.
FILING: tuple[tuple[str, str, str], ...] = (
("S", "saved", "keep this one"),
("I", "investigate", "come back to this one"),
("N", "noise", "not a signal worth keeping"),
)
_FILING_KEYS = {key: name for key, name, _ in FILING}
def _dur(seconds: float) -> str: def _dur(seconds: float) -> str:
seconds = max(0.0, float(seconds)) seconds = max(0.0, float(seconds))
@ -485,11 +531,16 @@ class Browser:
def __init__(self, directory: Path, console: Console | None = None, def __init__(self, directory: Path, console: Console | None = None,
player: Player | None = None, player: Player | None = None,
book: CallsignBook | None = None): book: CallsignBook | None = None,
config_dir: Path | None = None):
self.directory = Path(directory) self.directory = Path(directory)
self.console = console or Console() self.console = console or Console()
self.player = player if player is not None else Player() self.player = player if player is not None else Player()
self.book = book if book is not None else CallsignBook() self.book = book if book is not None else CallsignBook()
# Where the lock-out key writes. Passed in rather than reached for,
# so that nothing can write to the settings a real scan runs from
# without having been told to.
self.config_dir = Path(config_dir) if config_dir else None
self.captures: list[Capture] = [] self.captures: list[Capture] = []
self.view: list[Capture] = [] self.view: list[Capture] = []
self.index = 0 self.index = 0
@ -497,10 +548,14 @@ class Browser:
self.sort = "time" self.sort = "time"
self.query = "" self.query = ""
self.searching = False self.searching = False
self.confirm = "" # an action waiting to be agreed to
self.message = "" self.message = ""
self.show_help = False self.show_help = False
self.reading = False # full-screen transcript self.reading = False # full-screen transcript
self.read_top = 0 self.read_top = 0
self._undo: list[tuple[Path, Path]] = [] # the last move, to reverse
self._undo_path: Path | None = None # where its .wav came from
self._undo_label = ""
self._size = self.console.size self._size = self.console.size
self.reload() self.reload()
@ -562,6 +617,172 @@ class Browser:
return return
self.index = max(0, min(len(self.view) - 1, self.index + delta)) self.index = max(0, min(len(self.view) - 1, self.index + delta))
# -- managing what is on disk -----------------------------------------
#
# Everything here works on the whole capture -- the recording and every
# sidecar written beside it -- and leaves the cursor on the row it was
# on, which is now the next recording. Going through a night's scan is
# one key per recording, and a cursor that jumped after each one would
# make that impossible.
def _forget(self, cap: Capture) -> None:
"""Drop a capture from the lists without re-reading the directory."""
for group in (self.captures, self.view):
try:
group.remove(cap)
except ValueError:
pass
self.index = max(0, min(self.index, len(self.view) - 1))
self.top = max(0, min(self.top, max(0, len(self.view) - 1)))
@staticmethod
def _plan_move(cap: Capture, target: Path) -> list[tuple[Path, Path]]:
"""Where each of a capture's files should go, avoiding collisions.
The whole set is renamed together or not at all: a recording called
one thing and a transcript called another is a pair nothing will ever
put back together.
"""
files = cap.files()
stem = cap.path.stem
tails = [f.name[len(stem):] for f in files]
candidate, n = stem, 2
while any((target / (candidate + tail)).exists() for tail in tails):
candidate, n = f"{stem}_{n}", n + 1
return [(f, target / (candidate + tail))
for f, tail in zip(files, tails)]
def _release(self, cap: Capture) -> None:
"""Stop playing this recording, if it is the one being played.
Moving or deleting a file out from under a player leaves sound coming
out of a recording that is no longer there, and a progress bar
counting up against a name nothing on disk answers to.
"""
if (self.player.playing is not None
and self.player.playing.path == cap.path):
self.player.stop()
def _file_into(self, name: str) -> None:
"""Move the highlighted capture into a subdirectory of its own."""
cap = self.current
if cap is None:
return
self._release(cap)
target = self.directory / name
try:
target.mkdir(parents=True, exist_ok=True)
except OSError as exc:
self.message = f"cannot make {name}/: {exc}"
return
done: list[tuple[Path, Path]] = []
for src, dst in self._plan_move(cap, target):
try:
src.rename(dst)
except OSError as exc:
# Put back whatever has already moved. Half a capture in
# each of two directories is worse than none moved at all.
for was, now in reversed(done):
try:
now.rename(was)
except OSError:
pass
self.message = f"could not move {src.name}: {exc}"
return
done.append((src, dst))
self._undo, self._undo_path = done, cap.path
self._undo_label = f"{cap.path.name} back from {name}/"
self._forget(cap)
plural = "" if len(done) == 1 else "s"
self.message = (f"{cap.path.name}{name}/ "
f"({len(done)} file{plural}) u to undo")
def _undo_move(self) -> None:
"""Reverse the last filing. Not a delete: that one is gone."""
if not self._undo:
self.message = "nothing to put back"
return
moves, self._undo = self._undo, []
home, self._undo_path = self._undo_path, None
failed = 0
for was, now in reversed(moves):
try:
now.rename(was)
except OSError:
failed += 1
# A full re-read, because the capture has to go back into the list in
# the place the current sort order puts it, not the place it was.
self.reload()
if home is not None:
for i, cap in enumerate(self.view):
if cap.path == home:
self.index = i
break
self.message = (f"{failed} file(s) could not be put back" if failed
else f"put {self._undo_label}")
self._undo_label = ""
def _delete(self) -> None:
"""Delete the highlighted capture and everything written beside it."""
cap = self.current
if cap is None:
return
self._release(cap)
gone = 0
for path in cap.files():
try:
path.unlink()
gone += 1
except OSError as exc:
self.message = f"could not delete {path.name}: {exc}"
if gone:
self.reload()
return
# There is nothing to undo a delete with, and leaving the last move
# under u would put the wrong thing back.
self._undo, self._undo_path, self._undo_label = [], None, ""
self._forget(cap)
others = gone - 1
self.message = f"deleted {cap.path.name}" + (
f" and {others} sidecar{'' if others == 1 else 's'}"
if others else "")
def _mask(self) -> None:
"""Lock this frequency out, so no later scan stops on it again.
The same list the scanner's own lock-out key writes to, and the same
file, so a birdie masked here while reading last night's recordings
is gone from tonight's.
"""
cap = self.current
if cap is None:
return
if not cap.frequency:
self.message = ("nothing in this recording's name says what "
"frequency it was on")
return
directory = self.config_dir or DEFAULT_CONFIG_DIR
cfg, _ = load_default(directory)
width = max(1.0, cfg.lockout_width)
for existing in cfg.lockout:
low, high = Lockout.coerce(existing).interval(width)
if low <= cap.frequency <= high:
self.message = (f"{fmt_hz(cap.frequency)} is already locked "
"out")
return
entry = Lockout(cap.frequency)
cfg.lockout.append(entry)
written = remember_lockouts(cfg, directory)
if written is None:
self.message = f"could not write the lock-out to {directory}"
return
# "The next scan", not "scans": one already running read its settings
# when it started and will not see this.
self.message = (f"locked out {entry.describe()} ±{fmt_hz(width / 2)} "
f"in {written} — the next scan will not stop there")
# -- rendering -------------------------------------------------------- # -- rendering --------------------------------------------------------
def _callsign_lines(self, pad: int = 3) -> list[str]: def _callsign_lines(self, pad: int = 3) -> list[str]:
"""The DETECTED CALLSIGNS block, wrapped to the panel's width. """The DETECTED CALLSIGNS block, wrapped to the panel's width.
@ -845,18 +1066,65 @@ class Browser:
return Panel(t, title=title, title_align="left", return Panel(t, title=title, title_align="left",
border_style="blue", padding=(0, 1)) border_style="blue", padding=(0, 1))
@staticmethod
def _line(markup: str) -> Text:
"""One line, and never more, whatever it was asked to hold.
The footer sits in a region exactly one row tall. A line that wrapped
would push the frame past the bottom of the window, and the whole
display depends on the frame being the height it says it is.
"""
text = Text.from_markup(markup)
text.no_wrap = True
text.overflow = "ellipsis"
return text
def _keyline(self) -> Text:
"""The keys, as many of them as the window is wide enough to hold.
Dropped in a deliberate order rather than truncated: an ellipsis in
the middle of the line hides whichever keys happen to be at the end,
and "q quit" is the one nobody can afford not to be told.
"""
items = [("↑↓", "move"), ("", "play"),
("space", "stop"), ("/", "search"), ("t", "read"),
("S I N", "file"), ("d", "delete"), ("m", "mask"),
("s", f"sort by {self.sort}"), ("r", "reload"),
("?", "keys"), ("q", "quit")]
# Everything given up here is still on the page ? opens, which is why
# ? is among the last to go.
expendable = ["r", "s", "space", "m", "d", "S I N", "t", "/",
"", "↑↓", "?"]
width = self.console.size.width or 80
shown = items
while expendable and sum(len(k) + len(w) + 4
for k, w in shown) - 3 > width:
gone = expendable.pop(0)
shown = [item for item in shown if item[0] != gone]
text = Text(no_wrap=True, overflow="ellipsis")
for i, (key, what) in enumerate(shown):
if i:
text.append(" ")
text.append(key, style="bright_black")
text.append(" " + what)
return text
def _footer(self) -> Text: def _footer(self) -> Text:
# Both of these carry text that did not come from this program -- what # All of these carry text that did not come from this program -- what
# the user typed, a filename, a player's error -- and rich reads a # the user typed, a filename, a player's error -- and rich reads a
# square bracket as markup. Typing "[/" at the search prompt used to # square bracket as markup. Typing "[/" at the search prompt used to
# end the session with a MarkupError. # end the session with a MarkupError.
if self.confirm:
return self._line(
f"[bold yellow]{escape(self.confirm)}[/bold yellow] "
"[bold]y[/bold][bright_black]/[/bright_black][bold]n[/bold]")
if self.searching: if self.searching:
return Text.from_markup( return self._line(
f"[bold]search:[/bold] {escape(self.query)}[blink]_[/blink]" f"[bold]search:[/bold] {escape(self.query)}[blink]_[/blink]"
" [bright_black]enter to accept, esc to clear" " [bright_black]enter to accept, esc to clear"
"[/bright_black]") "[/bright_black]")
if self.message: if self.message:
return Text.from_markup(f"[yellow]{escape(self.message)}[/yellow]") return self._line(f"[yellow]{escape(self.message)}[/yellow]")
if self.player.active and self.player.playing is not None: if self.player.active and self.player.playing is not None:
total = self.player.playing.duration total = self.player.playing.duration
done = self.player.elapsed done = self.player.elapsed
@ -864,25 +1132,27 @@ class Browser:
filled = int(width * min(1.0, done / total)) if total else 0 filled = int(width * min(1.0, done / total)) if total else 0
bar = ("[red]" + "" * filled + "[/red]" bar = ("[red]" + "" * filled + "[/red]"
+ "[grey37]" + "" * (width - filled) + "[/grey37]") + "[grey37]" + "" * (width - filled) + "[/grey37]")
return Text.from_markup( return self._line(
f"[bold red]♪[/bold red] {bar} {_dur(done)}/{_dur(total)}" f"[bold red]♪[/bold red] {bar} {_dur(done)}/{_dur(total)}"
" [bright_black]space stop[/bright_black]") " [bright_black]space stop[/bright_black]")
return Text.from_markup( return self._keyline()
"[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: def _help(self) -> Panel:
"""Every key, on one screen, in a window the size a terminal opens at.
Eighty by twenty-four is still what a new terminal is, and a list of
keys that has to be scrolled to reach "q" is a list that failed at the
one job it has. So it is kept to twenty rows that fit in sixty
columns: the three filing keys share a line, because the directories
they name say what each one does, and the player goes in the border
rather than costing a row.
"""
t = Table(box=None, show_header=False, pad_edge=False) t = Table(box=None, show_header=False, pad_edge=False)
t.add_column(style="bold cyan", width=14) t.add_column(style="bold cyan", width=14)
t.add_column() t.add_column()
filing = " ".join(key for key, _, _ in FILING)
names = ", ".join(f"{name}/" for _, name, _ in FILING[:-1])
names += f" or {FILING[-1][1]}/"
for key, what in ( for key, what in (
("↑ ↓ / k j", "move through the recordings"), ("↑ ↓ / k j", "move through the recordings"),
("PgUp PgDn", "a screenful at a time"), ("PgUp PgDn", "a screenful at a time"),
@ -890,24 +1160,28 @@ class Browser:
("Enter", "play the highlighted recording"), ("Enter", "play the highlighted recording"),
("space", "stop playing"), ("space", "stop playing"),
("t", "read the whole transcript, full screen"), ("t", "read the whole transcript, full screen"),
("/", "filter by frequency, name, identification or " ("/", "filter by frequency, name, class or anything said"),
"anything that was said"),
("", ""),
("callsigns", "found in the transcript and looked up "
"automatically; --no-lookup keeps it offline"),
("Esc", "clear the filter"), ("Esc", "clear the filter"),
("s", "sort by time, frequency or length"), ("s", "sort by time, frequency or length"),
("r", "re-read the directory"), ("r", "re-read the directory"),
("o", "print the file's path and quit"), ("o", "print the file's path and quit"),
("", ""),
(filing, f"file it into {names}"),
("u", "put the last one filed back"),
("d", "delete it and its sidecars for good; asks first"),
("m", "lock this frequency out of every later scan"),
("", ""),
("callsigns", "found in transcripts and looked up for you"),
("? h", "this list"), ("? h", "this list"),
("q", "quit")): ("q", "quit")):
t.add_row(key, what) t.add_row(key, what)
player = (" ".join(self.player.command) if self.player.command player = (" ".join(self.player.command) if self.player.command
else "none found -- install pw-play, paplay, aplay, " else "none found -- install pw-play, paplay, aplay, "
"sox or ffmpeg") "sox or ffmpeg")
t.add_row("", "")
t.add_row("player", player)
return Panel(t, title="keys", title_align="left", return Panel(t, title="keys", title_align="left",
subtitle=f"[bright_black]player: {escape(player)}"
"[/bright_black]",
subtitle_align="right",
border_style="cyan", padding=(1, 2)) border_style="cyan", padding=(1, 2))
def _reader(self) -> Panel: def _reader(self) -> Panel:
@ -981,6 +1255,8 @@ class Browser:
if not key: if not key:
return True return True
self.message = "" self.message = ""
if self.confirm:
return self._handle_confirm(key)
if self.searching: if self.searching:
return self._handle_search(key) return self._handle_search(key)
if self.show_help and key not in ("?", "h", "q"): if self.show_help and key not in ("?", "h", "q"):
@ -1043,6 +1319,33 @@ class Browser:
if cap is not None: if cap is not None:
self.message = str(cap.path) self.message = str(cap.path)
return False return False
elif key in _FILING_KEYS:
self._file_into(_FILING_KEYS[key])
elif key == "u":
self._undo_move()
elif key == "m":
self._mask()
elif key == "d":
cap = self.current
if cap is None:
return True
# Asked rather than done. Filing is reversible and is not worth
# a question every time; this one is not reversible at all, and
# d is next to s and f on the same row of the keyboard.
others = len(cap.files()) - 1
self.confirm = "delete " + cap.path.name + (
f" and {others} sidecar file{'' if others == 1 else 's'}?"
if others else "?")
return True
def _handle_confirm(self, key: str) -> bool:
"""One question, one answer. Anything but yes means no."""
question, self.confirm = self.confirm, ""
if key not in ("y", "Y"):
self.message = "left alone"
return True
if question.startswith("delete "):
self._delete()
return True return True
def _handle_reader(self, key: str) -> bool: def _handle_reader(self, key: str) -> bool:

View file

@ -599,7 +599,12 @@ q stop the scan
p pause and resume p pause and resume
s skip the signal being recorded and carry on sweeping s skip the signal being recorded and carry on sweeping
l lock out this frequency for the rest of the run l lock out this frequency for the rest of the run
+/- raise or lower the squelch threshold on the fly"""), +/- raise or lower the squelch threshold on the fly
saunterbrowse writes to the same lock-out list afterwards: pressing m over a
recording locks out the frequency it was heard on, which is usually when you
find out a frequency is not worth listening to. It can also file recordings
into saved/, investigate/ or noise/, and delete the ones worth nothing."""),
} }

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" .TH BANDSAUNTER 1 "2026-08-29" "bandsaunter 2026-08-29_01" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS

View file

@ -13,7 +13,15 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import bandsaunter # noqa: E402 import bandsaunter # noqa: E402
from bandsaunter.browse import PLAYERS, SORTS # noqa: E402 from bandsaunter.browse import FILING, PLAYERS, SORTS # noqa: E402
def _english(items) -> str:
"""a, b and c -- the way a sentence lists things."""
items = list(items)
if len(items) < 2:
return "".join(items)
return ", ".join(items[:-1]) + " and " + items[-1]
PAGE = r'''.\" Generated by packaging/make-browse-man.py -- do not edit by hand. PAGE = r'''.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
@ -52,8 +60,10 @@ With no
it opens the one the scanner writes to, taken from your saved settings, so it it opens the one the scanner writes to, taken from your saved settings, so it
normally needs no arguments at all. normally needs no arguments at all.
.PP .PP
It only ever reads. Nothing in the recordings directory is renamed, moved or Recordings can also be dealt with as they are read. A key files one into
deleted. {filing_dirs}, another deletes it outright, and another locks its frequency
out of every later scan. Nothing else is written: without one of those keys
the browser only reads.
.SH KEYS .SH KEYS
.TP .TP
.B "Up Down k j" .B "Up Down k j"
@ -95,6 +105,27 @@ it, and this picks up what has arrived since.
Print the highlighted recording's path and quit, for piping into something Print the highlighted recording's path and quit, for piping into something
else. else.
.TP .TP
.B "{filing_keys}"
File the highlighted recording into {filing_list} respectively \[em] the
recording and every sidecar written beside it, together. See
.B MANAGING THE RECORDINGS
below.
.TP
.B u
Put the last recording that was filed back where it came from. One level
only, and a delete cannot be undone this way.
.TP
.B d
Delete the highlighted recording and its sidecars for good. It asks first:
this is the one key here that cannot be taken back.
.TP
.B m
Lock the highlighted recording's frequency out, so that no later scan stops
on it again. The frequency is written into your saved settings, the same list
.BR bandsaunter (1)
maintains, and takes effect on the next scan \[em] a scan already running read
its settings when it started.
.TP
.B "? h" .B "? h"
The list of keys, and which audio player was found. The list of keys, and which audio player was found.
.TP .TP
@ -225,6 +256,45 @@ KML is the format Google Earth uses.
.BR marble (1) .BR marble (1)
and OsmAnd open it too, and it is XML, so a scan interrupted halfway through and OsmAnd open it too, and it is XML, so a scan interrupted halfway through
leaves a file that still opens. leaves a file that still opens.
.SH MANAGING THE RECORDINGS
A night's scan leaves hundreds of files, most of which are worth nothing and
a few of which are the reason you left it running. Deciding which is which is
what this program is for, and the keys that act on a recording are meant to
be pressed once each, going down the list.
{filing_prose}
.PP
Each of these moves the whole capture \[em] the
.IR .wav ,
the JSON sidecar, the raw IQ where it was kept, the transcript and the decoded
data \[em] because a recording in one directory and its transcript in another
is a pair nothing will ever put back together. If the move cannot be finished,
whatever has already moved is put back: half a capture in each of two places
is worse than none moved at all.
.PP
The subdirectories are ordinary directories inside the recordings directory,
so a scan writing there never looks into them and never lists what is in
them. To read what is in one, point the browser at it:
.IP
.EX
saunterbrowse ~/bandsaunter/{first_dir}
.EE
.PP
.B u
puts the last one filed back. One step, not a history: it exists so that a
mistyped key costs nothing, not so that an evening's sorting can be unwound.
.PP
.B d
deletes instead, and asks first, because nothing puts that back.
.PP
.B m
is the other half of the same job. A birdie, a pager transmitter or a data
link that fills the recordings directory night after night is not a recording
problem, it is a scanning problem, and this writes the frequency into the
lock-out list in your settings file. The width comes from the
.B lockout_width
setting, so a lock-out is a channel rather than a single point. Locking out a
frequency does not delete what has already been recorded on it \[em] the two
keys are separate on purpose, and pressing both is the usual thing to do.
.SH DECODED DATA .SH DECODED DATA
Where a capture carried data rather than speech, what was decoded takes the Where a capture carried data rather than speech, what was decoded takes the
place of the transcript at the top of the screen: the kind of packet, and then place of the transcript at the top of the screen: the kind of packet, and then
@ -270,6 +340,15 @@ What was said, where a recogniser heard speech.
.TP .TP
.IR ... _data.txt .IR ... _data.txt
What was decoded, where the capture carried data. What was decoded, where the capture carried data.
.TP
.IR {filing_files}
Where the filing keys move recordings to. Created on first use; a scan never
looks in them.
.TP
.I ~/.config/bandsaunter/config.yaml
The settings the
.B m
key writes a locked-out frequency into.
.SH ENVIRONMENT .SH ENVIRONMENT
.TP .TP
.B BANDSAUNTER_OUTPUT .B BANDSAUNTER_OUTPUT
@ -328,11 +407,20 @@ to pick up recordings a running scan has written since.
def main() -> int: def main() -> int:
dirs = [name for _, name, _ in FILING]
text = PAGE.format( text = PAGE.format(
date=date.today().isoformat(), date=date.today().isoformat(),
version=bandsaunter.__version__, version=bandsaunter.__version__,
sorts=", ".join(SORTS), sorts=", ".join(SORTS),
players=", ".join(name for name, _ in PLAYERS)) players=", ".join(name for name, _ in PLAYERS),
filing_keys=" ".join(key for key, _, _ in FILING),
filing_dirs=_english(f"{name}/" for name in dirs),
filing_list=_english(f"{name}/" for name in dirs),
filing_files=", ".join(f"{name} /" for name in dirs),
first_dir=dirs[0],
filing_prose="\n".join(
f".TP\n.B {key}\nInto\n.IR {name} /\n\\[em] {why}."
for key, name, why in FILING))
text = text.replace("\n\n", "\n") # troff dislikes blank lines text = text.replace("\n\n", "\n") # troff dislikes blank lines
target = Path(sys.argv[1] if len(sys.argv) > 1 target = Path(sys.argv[1] if len(sys.argv) > 1
else Path(__file__).parent / "saunterbrowse.1") else Path(__file__).parent / "saunterbrowse.1")

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand. .\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-08-28" "bandsaunter 2026-08-28_03" "User Commands" .TH SAUNTERBROWSE 1 "2026-08-29" "bandsaunter 2026-08-29_01" "User Commands"
.SH NAME .SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS .SH SYNOPSIS
@ -34,8 +34,10 @@ With no
it opens the one the scanner writes to, taken from your saved settings, so it it opens the one the scanner writes to, taken from your saved settings, so it
normally needs no arguments at all. normally needs no arguments at all.
.PP .PP
It only ever reads. Nothing in the recordings directory is renamed, moved or Recordings can also be dealt with as they are read. A key files one into
deleted. saved/, investigate/ and noise/, another deletes it outright, and another locks its frequency
out of every later scan. Nothing else is written: without one of those keys
the browser only reads.
.SH KEYS .SH KEYS
.TP .TP
.B "Up Down k j" .B "Up Down k j"
@ -77,6 +79,27 @@ it, and this picks up what has arrived since.
Print the highlighted recording's path and quit, for piping into something Print the highlighted recording's path and quit, for piping into something
else. else.
.TP .TP
.B "S I N"
File the highlighted recording into saved/, investigate/ and noise/ respectively \[em] the
recording and every sidecar written beside it, together. See
.B MANAGING THE RECORDINGS
below.
.TP
.B u
Put the last recording that was filed back where it came from. One level
only, and a delete cannot be undone this way.
.TP
.B d
Delete the highlighted recording and its sidecars for good. It asks first:
this is the one key here that cannot be taken back.
.TP
.B m
Lock the highlighted recording's frequency out, so that no later scan stops
on it again. The frequency is written into your saved settings, the same list
.BR bandsaunter (1)
maintains, and takes effect on the next scan \[em] a scan already running read
its settings when it started.
.TP
.B "? h" .B "? h"
The list of keys, and which audio player was found. The list of keys, and which audio player was found.
.TP .TP
@ -207,6 +230,59 @@ KML is the format Google Earth uses.
.BR marble (1) .BR marble (1)
and OsmAnd open it too, and it is XML, so a scan interrupted halfway through and OsmAnd open it too, and it is XML, so a scan interrupted halfway through
leaves a file that still opens. leaves a file that still opens.
.SH MANAGING THE RECORDINGS
A night's scan leaves hundreds of files, most of which are worth nothing and
a few of which are the reason you left it running. Deciding which is which is
what this program is for, and the keys that act on a recording are meant to
be pressed once each, going down the list.
.TP
.B S
Into
.IR saved /
\[em] keep this one.
.TP
.B I
Into
.IR investigate /
\[em] come back to this one.
.TP
.B N
Into
.IR noise /
\[em] not a signal worth keeping.
.PP
Each of these moves the whole capture \[em] the
.IR .wav ,
the JSON sidecar, the raw IQ where it was kept, the transcript and the decoded
data \[em] because a recording in one directory and its transcript in another
is a pair nothing will ever put back together. If the move cannot be finished,
whatever has already moved is put back: half a capture in each of two places
is worse than none moved at all.
.PP
The subdirectories are ordinary directories inside the recordings directory,
so a scan writing there never looks into them and never lists what is in
them. To read what is in one, point the browser at it:
.IP
.EX
saunterbrowse ~/bandsaunter/saved
.EE
.PP
.B u
puts the last one filed back. One step, not a history: it exists so that a
mistyped key costs nothing, not so that an evening's sorting can be unwound.
.PP
.B d
deletes instead, and asks first, because nothing puts that back.
.PP
.B m
is the other half of the same job. A birdie, a pager transmitter or a data
link that fills the recordings directory night after night is not a recording
problem, it is a scanning problem, and this writes the frequency into the
lock-out list in your settings file. The width comes from the
.B lockout_width
setting, so a lock-out is a channel rather than a single point. Locking out a
frequency does not delete what has already been recorded on it \[em] the two
keys are separate on purpose, and pressing both is the usual thing to do.
.SH DECODED DATA .SH DECODED DATA
Where a capture carried data rather than speech, what was decoded takes the Where a capture carried data rather than speech, what was decoded takes the
place of the transcript at the top of the screen: the kind of packet, and then place of the transcript at the top of the screen: the kind of packet, and then
@ -252,6 +328,15 @@ What was said, where a recogniser heard speech.
.TP .TP
.IR ... _data.txt .IR ... _data.txt
What was decoded, where the capture carried data. What was decoded, where the capture carried data.
.TP
.IR saved /, investigate /, noise /
Where the filing keys move recordings to. Created on first use; a scan never
looks in them.
.TP
.I ~/.config/bandsaunter/config.yaml
The settings the
.B m
key writes a locked-out frequency into.
.SH ENVIRONMENT .SH ENVIRONMENT
.TP .TP
.B BANDSAUNTER_OUTPUT .B BANDSAUNTER_OUTPUT

View file

@ -1,14 +1,39 @@
"""Fixtures every test gets. """Fixtures every test gets.
The one that matters is the cache: a lookup writes to ``~/.cache`` by Both of them are about not touching the machine the tests run on.
default, and a test run that touches the real one leaves entries behind and
reads back entries an earlier version wrote. Redirecting it per test makes The cache: a lookup writes to ``~/.cache`` by default, and a test run that
each run start from nothing. touches the real one leaves entries behind and reads back entries an earlier
version wrote. Redirecting it per test makes each run start from nothing.
The settings: locking a frequency out writes it into ``config.yaml``, and a
test that reached the real one would silently change what the next real scan
does. The constant is replaced in every module that holds a copy, so that
forgetting to pass a directory somewhere cannot end in someone's own settings.
""" """
import pytest import pytest
import bandsaunter.browse
import bandsaunter.cli
import bandsaunter.config
import bandsaunter.tui
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolated_cache(tmp_path_factory, monkeypatch): def isolated_cache(tmp_path_factory, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", monkeypatch.setenv("XDG_CACHE_HOME",
str(tmp_path_factory.mktemp("cache"))) str(tmp_path_factory.mktemp("cache")))
@pytest.fixture(autouse=True)
def isolated_settings(tmp_path_factory, monkeypatch):
where = tmp_path_factory.mktemp("config")
for module in (bandsaunter.config, bandsaunter.browse, bandsaunter.cli,
bandsaunter.tui):
if hasattr(module, "DEFAULT_CONFIG_DIR"):
monkeypatch.setattr(module, "DEFAULT_CONFIG_DIR", where)
if hasattr(module, "DEFAULT_CONFIG_PATH"):
monkeypatch.setattr(module, "DEFAULT_CONFIG_PATH",
where / "config.yaml")
monkeypatch.setenv("BANDSAUNTER_CONFIG_DIR", str(where))
return where

View file

@ -22,6 +22,12 @@ class Terminal:
"""One program running in a pty, with a screen that can be read back.""" """One program running in a pty, with a screen that can be read back."""
def __init__(self, argv, cols=100, rows=30, env=None): def __init__(self, argv, cols=100, rows=30, env=None):
"""``env`` is added to the child's environment, not substituted for it.
It was accepted and silently ignored until a test that pointed the
program at a throwaway settings directory wrote to the real one
instead.
"""
import pyte import pyte
self.cols, self.rows = cols, rows self.cols, self.rows = cols, rows
@ -35,7 +41,7 @@ class Terminal:
self.pid, self.fd = pty.fork() self.pid, self.fd = pty.fork()
if self.pid == 0: # pragma: no cover -- the child if self.pid == 0: # pragma: no cover -- the child
child = dict(os.environ, TERM="xterm-256color", child = dict(os.environ, TERM="xterm-256color",
PYTHONUNBUFFERED="1") PYTHONUNBUFFERED="1", **(env or {}))
# rich reads COLUMNS and LINES in preference to asking the # rich reads COLUMNS and LINES in preference to asking the
# terminal, so leaving them set would make every resize invisible # terminal, so leaving them set would make every resize invisible
# and every one of these tests pass for the wrong reason. # and every one of these tests pass for the wrong reason.

560
tests/test_manage.py Normal file
View file

@ -0,0 +1,560 @@
"""Dealing with the recordings, not just reading them.
A night's scan leaves hundreds of files and most of them are worth nothing.
The browser can now file one away, delete it, or lock its frequency out of
every later scan -- which makes it the first part of this program that writes
to the recordings directory, so most of what is checked here is that it
writes exactly what it meant to and nothing else.
Two things run through all of it. A capture is not one file: the .wav, the
sidecar, the IQ, the transcript and the decoded data move or die together, or
what is left is a transcript describing a recording nobody has. And the
cursor stays on the row it was on, because going down a list is one key per
recording and a cursor that jumped after each one would make that impossible.
"""
import json
from pathlib import Path
import pytest
import yaml
from rich.console import Console
from bandsaunter.browse import Browser, FILING, Player, scan_directory
from bandsaunter.config import ScanConfig
from test_browse import library, make_capture, write_wav # noqa: F401
def _browser(directory, config_dir=None, width=100, height=30) -> Browser:
console = Console(width=width, height=height, force_terminal=True)
return Browser(directory, console=console, player=Player([]),
config_dir=config_dir)
def frame(b: Browser) -> str:
with b.console.capture() as cap:
b.console.print(b.render())
return cap.get()
def names(directory: Path) -> set[str]:
return {p.name for p in Path(directory).iterdir()}
@pytest.fixture
def full(tmp_path):
"""One capture with every sidecar a scan can write beside it."""
stem = "0146.520000MHz--2026-08-22_10_00_00-nfm"
write_wav(tmp_path / f"{stem}.wav", 2.0)
(tmp_path / f"{stem}.json").write_text(json.dumps(
{"hit": {"frequency": 146.52e6, "category": "voice"}}))
(tmp_path / f"{stem}.cf32").write_bytes(b"\0" * 64)
(tmp_path / f"{stem}.sigmf-meta").write_text("{}")
(tmp_path / f"{stem}_transcription.txt").write_text("this is W1AW\n")
(tmp_path / f"{stem}_data.txt").write_text("beacon 0x41\n")
return tmp_path
# -- what belongs to a capture -----------------------------------------------
def test_a_capture_owns_every_file_written_beside_it(full):
cap = scan_directory(full)[0]
assert {p.name.split("-nfm")[1] for p in cap.files()} == {
".wav", ".json", ".cf32", ".sigmf-meta",
"_transcription.txt", "_data.txt"}
def test_a_capture_does_not_claim_its_neighbour(full):
"""Two signals in the same second on the same frequency: ...-nfm_2.
Matching the stem followed by anything would sweep the second capture up
with the first, and file away a recording nobody asked about.
"""
stem = "0146.520000MHz--2026-08-22_10_00_00-nfm"
write_wav(full / f"{stem}_2.wav", 1.0)
(full / f"{stem}_2.json").write_text("{}")
first = [c for c in scan_directory(full) if c.path.stem == stem][0]
assert not any("_2" in p.name for p in first.files())
def test_a_recording_on_its_own_owns_only_itself(library):
cap = [c for c in scan_directory(library) if "144.1" in c.path.name][0]
assert [p.suffix for p in cap.files()] == [".json", ".wav"]
# -- filing it away ----------------------------------------------------------
@pytest.mark.parametrize("key,name,_why", FILING)
def test_a_filing_key_moves_the_whole_capture(full, key, name, _why):
b = _browser(full)
cap = b.current
was = {p.name for p in cap.files()}
assert b.handle(key)
assert names(full / name) == was
assert not any(p.suffix == ".wav" for p in full.iterdir())
def test_the_three_keys_are_three_directories(full):
assert [name for _, name, _ in FILING] == ["saved", "investigate",
"noise"]
def test_a_filed_recording_leaves_the_list(library):
b = _browser(library)
before = len(b.view)
b.handle("N")
assert len(b.view) == before - 1
assert len(b.captures) == before - 1
def test_the_cursor_stays_where_it_was(library):
"""One key per recording, going down the list: it must not jump."""
b = _browser(library)
b.move(1)
third = b.view[2].path
b.handle("S")
assert b.index == 1
assert b.current.path == third
def test_filing_the_last_one_leaves_the_cursor_in_range(library):
b = _browser(library)
for _ in range(3):
b.handle("N")
assert b.view == [] and b.index == 0
assert "no recordings" in frame(b)
def test_a_scan_never_sees_what_was_filed_away(library):
"""The subdirectories are below the recordings directory on purpose."""
b = _browser(library)
b.handle("N")
assert (library / "noise").is_dir()
assert len(scan_directory(library)) == 2
def test_what_was_filed_can_be_browsed_by_opening_the_directory(library):
b = _browser(library)
b.handle("I")
assert len(scan_directory(library / "investigate")) == 1
def test_filing_says_where_it_went(full):
b = _browser(full)
b.handle("S")
assert "saved/" in b.message and "6 file" in b.message
def test_a_name_already_there_is_not_overwritten(library):
"""The same frequency and second can be recorded on two different days."""
b = _browser(library)
cap = b.current
(library / "saved").mkdir()
(library / "saved" / cap.path.name).write_text("an older one")
b.handle("S")
kept = (library / "saved" / cap.path.name).read_text()
assert kept == "an older one"
assert len(list((library / "saved").glob("*.wav"))) == 2
def test_the_whole_set_is_renamed_together_when_it_collides(full):
b = _browser(full)
cap = b.current
(full / "saved").mkdir()
(full / "saved" / cap.path.name).write_text("an older one")
b.handle("S")
moved = sorted(p.name for p in (full / "saved").iterdir()
if "_2" in p.name)
assert len(moved) == 6
assert len({p.split("_2")[0] for p in moved}) == 1
def test_a_move_that_fails_halfway_is_put_back(full, monkeypatch):
"""Half a capture in each of two directories is worse than none moved."""
b = _browser(full)
cap = b.current
was = {p.name for p in cap.files()}
real = Path.rename
def flaky(self, target):
if self.suffix == ".sigmf-meta":
raise OSError(13, "permission denied")
return real(self, target)
monkeypatch.setattr(Path, "rename", flaky)
b.handle("S")
assert {p.name for p in cap.files()} == was
assert list((full / "saved").iterdir()) == []
assert "could not move" in b.message
assert b.current is cap, "the capture was dropped from a move that failed"
# -- putting one back --------------------------------------------------------
def test_u_puts_the_last_one_filed_back(full):
b = _browser(full)
was = {p.name for p in b.current.files()}
b.handle("N")
b.handle("u")
assert names(full) - {"noise"} == was
assert list((full / "noise").iterdir()) == []
assert len(b.view) == 1
def test_the_one_put_back_is_the_one_under_the_cursor(library):
b = _browser(library)
b.move(1)
filed = b.current.path
b.handle("S")
b.handle("u")
assert b.current.path == filed, "the cursor did not follow it back"
def test_undo_is_one_step_not_a_history(library):
b = _browser(library)
b.handle("N")
b.handle("N")
b.handle("u")
b.handle("u")
assert "nothing to put back" in b.message
assert len(list((library / "noise").glob("*.wav"))) == 1
def test_there_is_nothing_to_put_back_to_begin_with(library):
b = _browser(library)
b.handle("u")
assert "nothing to put back" in b.message
# -- deleting ----------------------------------------------------------------
def test_delete_asks_first(library):
b = _browser(library)
cap = b.current
b.handle("d")
assert b.confirm and cap.path.name in b.confirm
assert cap.path.exists(), "deleted without being agreed to"
assert b._footer().plain.endswith(" y/n")
def test_anything_but_yes_means_no(library):
b = _browser(library)
cap = b.current
b.handle("d")
assert b.handle("n")
assert not b.confirm and cap.path.exists()
assert b.message == "left alone"
def test_q_at_the_question_answers_it_rather_than_quitting(library):
b = _browser(library)
b.handle("d")
assert b.handle("q"), "q answered the question and quit as well"
assert b.current is not None and b.current.path.exists()
def test_yes_deletes_the_recording_and_its_sidecars(full):
b = _browser(full)
cap = b.current
b.handle("d")
b.handle("y")
assert not cap.path.exists()
assert list(full.iterdir()) == [], "a sidecar outlived its recording"
assert b.view == []
def test_the_question_counts_the_whole_capture(full):
b = _browser(full)
b.handle("d")
assert b.confirm.endswith("and 5 sidecar files?")
def test_the_question_counts_in_the_singular_too(library):
b = _browser(library)
b.index = next(i for i, c in enumerate(b.view) if "144.1" in c.path.name)
b.handle("d")
assert b.confirm.endswith("and 1 sidecar file?")
def test_a_recording_with_nothing_beside_it_is_asked_about_plainly(tmp_path):
write_wav(tmp_path / "0146.520000MHz--2026-08-22_10_00_00-nfm.wav", 1.0)
b = _browser(tmp_path)
b.handle("d")
assert b.confirm.endswith("-nfm.wav?")
def test_a_delete_cannot_be_undone_with_u(full):
b = _browser(full)
b.handle("S") # something to undo
b.handle("u") # ... used up
b.handle("d")
b.handle("y")
b.handle("u")
assert "nothing to put back" in b.message
def test_a_delete_does_not_put_back_the_last_move(library):
"""u after a delete must not resurrect a different recording."""
b = _browser(library)
filed = b.current.path.name
b.handle("N")
b.handle("d")
b.handle("y")
b.handle("u")
assert "nothing to put back" in b.message
assert (library / "noise" / filed).exists()
# -- masking a frequency -----------------------------------------------------
def _lockouts(where: Path) -> list[dict]:
return yaml.safe_load((where / "config.yaml").read_text())["lockout"]
def test_m_writes_the_frequency_into_the_settings(library, tmp_path):
cfg_dir = tmp_path / "cfg"
b = _browser(library, config_dir=cfg_dir)
heard = b.current.frequency
b.handle("m")
assert _lockouts(cfg_dir) == [{"start": heard, "stop": heard}]
def test_the_scanner_reads_back_what_the_browser_wrote(library, tmp_path):
"""The two write the same list, so one has to be able to read the other."""
cfg_dir = tmp_path / "cfg"
b = _browser(library, config_dir=cfg_dir)
heard = b.current.frequency
b.handle("m")
saved = ScanConfig.from_dict(
yaml.safe_load((cfg_dir / "config.yaml").read_text()))
low, high = saved.lockout[0].interval(saved.lockout_width)
assert low < heard < high
def test_masking_says_it_takes_effect_next_time(library, tmp_path):
b = _browser(library, config_dir=tmp_path / "cfg")
b.handle("m")
assert "locked out" in b.message and "next scan" in b.message
def test_the_same_frequency_is_not_locked_out_twice(library, tmp_path):
cfg_dir = tmp_path / "cfg"
b = _browser(library, config_dir=cfg_dir)
b.handle("m")
b.handle("m")
assert len(_lockouts(cfg_dir)) == 1
assert "already locked out" in b.message
def test_a_frequency_inside_an_existing_lock_out_is_left_alone(library,
tmp_path):
"""A lock-out is a channel, not a point: 146.52 covers 146.5205 too."""
cfg_dir = tmp_path / "cfg"
b = _browser(library, config_dir=cfg_dir)
heard = b.current.frequency
cfg_dir.mkdir()
(cfg_dir / "config.yaml").write_text(yaml.safe_dump(
{"lockout": [{"start": heard - 1e5, "stop": heard + 1e5}]}))
b.handle("m")
assert len(_lockouts(cfg_dir)) == 1
assert "already locked out" in b.message
def test_masking_leaves_every_other_setting_alone(library, tmp_path):
"""A settings file is not the browser's to rewrite."""
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir()
(cfg_dir / "config.yaml").write_text(yaml.safe_dump(
{"output_dir": "/somewhere/else", "record_seconds": 99.0,
"gain": "auto"}))
b = _browser(library, config_dir=cfg_dir)
b.handle("m")
after = yaml.safe_load((cfg_dir / "config.yaml").read_text())
assert after["output_dir"] == "/somewhere/else"
assert after["record_seconds"] == 99.0
assert len(after["lockout"]) == 1
def test_a_recording_with_no_frequency_cannot_be_masked(tmp_path):
write_wav(tmp_path / "something-else.wav", 1.0)
b = _browser(tmp_path, config_dir=tmp_path / "cfg")
b.handle("m")
assert "frequency" in b.message
assert not (tmp_path / "cfg").exists()
def test_masking_does_not_touch_the_recording(library, tmp_path):
"""Two keys, on purpose: locking out is about later scans, not this file."""
b = _browser(library, config_dir=tmp_path / "cfg")
cap = b.current
b.handle("m")
assert cap.path.exists() and len(b.view) == 3
def test_without_being_told_it_writes_where_the_scanner_reads(library,
isolated_settings):
b = _browser(library)
b.handle("m")
assert (isolated_settings / "config.yaml").exists()
# -- these keys are not commands everywhere ----------------------------------
@pytest.mark.parametrize("key", ["S", "I", "N", "d", "m", "u"])
def test_nothing_happens_while_typing_a_search(library, key):
b = _browser(library)
b.handle("/")
b.handle(key)
assert b.query == key and not b.confirm
assert len(scan_directory(library)) == 3
@pytest.mark.parametrize("key", ["S", "I", "N", "d", "m"])
def test_nothing_happens_on_an_empty_directory(tmp_path, key):
b = _browser(tmp_path, config_dir=tmp_path / "cfg")
assert b.handle(key)
assert not b.confirm
# -- what the screen says about them -----------------------------------------
def test_the_keys_are_on_the_help_screen(library):
b = _browser(library)
b.handle("?")
shown = frame(b)
for _key, name, _why in FILING:
assert f"{name}/" in shown
for word in ("delete", "lock this frequency out", "put the last one"):
assert word in shown
@pytest.mark.parametrize("width", [100, 80])
def test_the_help_screen_still_fits_an_ordinary_window(library, width):
"""Eighty by twenty-four is still what a new terminal is.
A list of keys that has to be scrolled to reach "q" has failed at the one
job it has, so every row has to be on the screen at once.
"""
b = _browser(library, width=width, height=24)
b.handle("?")
lines = frame(b).rstrip("\n").split("\n")
assert len(lines) == 24
# border, a blank from the padding, then the last row of keys.
assert "quit" in lines[-3], "the last key is off the bottom of the panel"
assert "" in lines[-1], "the panel does not close"
def test_the_footer_offers_them(library):
b = _browser(library)
line = frame(b).splitlines()[-1]
assert "file" in line and "delete" in line and "mask" in line
def test_a_narrow_window_drops_keys_rather_than_cutting_the_line(library):
"""An ellipsis would hide whichever keys happened to be at the end."""
b = _browser(library, width=46)
line = frame(b).splitlines()[-1]
assert "" not in line
assert "quit" in line, "the one key nobody can afford not to be told"
@pytest.mark.parametrize("width,height", [(120, 40), (100, 30), (80, 24),
(64, 16), (50, 10)])
def test_the_frame_still_fits_while_it_is_asking(library, width, height):
b = _browser(library, width=width, height=height)
b.handle("d")
assert len(frame(b).rstrip("\n").split("\n")) <= height
# -- in a real terminal ------------------------------------------------------
#
# These keys write to disk, which is exactly the thing a test that stubs the
# console cannot get wrong in an interesting way. Running the program in a
# pty and typing at it checks the whole path: the key reaches the handler,
# the handler moves the files, and the settings land where they were told to
# rather than in the settings a real scan runs from.
def _pty(directory, config_dir):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from terminal import Terminal
return Terminal([sys.executable, "-m", "bandsaunter.browse",
str(directory)], cols=100, rows=30,
env={"BANDSAUNTER_CONFIG_DIR": str(config_dir)})
def test_typing_the_keys_at_a_real_terminal_moves_real_files(library,
tmp_path):
pytest.importorskip("pyte", reason="terminal emulator not installed")
with _pty(library, tmp_path / "cfg") as term:
term.pump(2.5)
term.send("N")
term.pump(1.2)
assert len(list((library / "noise").glob("*.wav"))) == 1
assert "noise/" in term.text()
term.send("u")
term.pump(1.2)
assert list((library / "noise").glob("*.wav")) == []
def test_the_question_is_asked_on_the_screen_and_answered(library, tmp_path):
pytest.importorskip("pyte", reason="terminal emulator not installed")
with _pty(library, tmp_path / "cfg") as term:
term.pump(2.5)
term.send("d")
term.pump(1.0)
assert "y/n" in term.text()
assert len(scan_directory(library)) == 3, "deleted before answering"
term.send("y")
term.pump(1.2)
assert len(scan_directory(library)) == 2
def test_masking_writes_where_it_was_told_and_nowhere_else(library, tmp_path):
"""The settings a real scan runs from are not a test's to write.
The harness accepted an environment and ignored it, which is how this was
found: a smoke test aimed at a throwaway directory locked a frequency out
of the settings on the machine it was run on.
"""
pytest.importorskip("pyte", reason="terminal emulator not installed")
cfg_dir = tmp_path / "cfg"
with _pty(library, cfg_dir) as term:
term.pump(2.5)
term.send("m")
term.pump(1.5)
assert "locked out" in term.text()
assert len(_lockouts(cfg_dir)) == 1
# -- and what was playing ----------------------------------------------------
def test_deleting_what_is_playing_stops_it_first(library):
"""Sound from a recording that is no longer there is worse than silence."""
from test_browse import FakePlayer
b = _browser(library)
b.player = FakePlayer()
b.handle("enter")
assert b.player.active
b.handle("d")
b.handle("y")
assert not b.player.active
def test_filing_what_is_playing_stops_it_too(library):
from test_browse import FakePlayer
b = _browser(library)
b.player = FakePlayer()
b.handle("enter")
b.handle("S")
assert not b.player.active
def test_a_different_recording_keeps_playing(library):
from test_browse import FakePlayer
b = _browser(library)
b.player = FakePlayer()
b.handle("enter")
b.move(1)
b.handle("N")
assert b.player.active, "stopped a recording that was not the one filed"

View file

@ -105,9 +105,13 @@ def test_every_browser_flag_is_documented(browse_page):
def test_every_browser_key_is_documented(browse_page): def test_every_browser_key_is_documented(browse_page):
"""A key that does something the manual does not mention is a key nobody """A key that does something the manual does not mention is a key nobody
will press.""" will press."""
from bandsaunter.browse import FILING
for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q", for key in ("Enter", "Space", "PgUp", "Home", "/", "s", "r", "o", "q",
"t"): "t", "u", "d", "m"):
assert key in browse_page, key assert f".B {key}\n" in browse_page or f'.B "{key}' in browse_page, key
for _key, name, _why in FILING:
assert name in browse_page, name
assert '.B "' + " ".join(k for k, _, _ in FILING) in browse_page
def test_the_browser_page_names_the_players_it_looks_for(browse_page): def test_the_browser_page_names_the_players_it_looks_for(browse_page):