diff --git a/README.md b/README.md index 7c390d6..bc740c0 100644 --- a/README.md +++ b/README.md @@ -1306,7 +1306,7 @@ 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 │ +│ 146.88 MHz NFM Sat 26-08-22 01:01:44 pm 42.8s SNR 17.6 dB voice │ ╰───────────────────────────────────────────────────────────────────────╯ ╭─ transcript ──────────────────────────────────────────────────────────╮ │ │ @@ -1320,10 +1320,10 @@ or symbol rate where there is one, and the bands the frequency falls in. │ 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… │ +│ 856.561096 MHz 26-08-22 01:10:25 pm fsk 4m00s Motorola SMARTNE… │ +│ 158.294200 MHz 26-08-22 01:05:15 pm nfm 20.1s Steven, I'm over… │ +│ › 146.88 MHz 26-08-22 01:01:44 pm nfm 42.8s Alright, moving … │ +│ 146.88 MHz 26-08-22 01:00:44 pm nfm 35.2s Check out commun… │ ╰───────────────────────────────────────────────────────────────────────╯ ↑↓ move ⏎ play space stop / search t read S I N file d delete m mask q quit ``` @@ -1337,7 +1337,8 @@ or symbol rate where there is one, and the bands the frequency falls in. | `t` | read the whole transcript full screen, scrolling | | `/` | filter — by frequency, filename, identification, **or anything that was said** | | — | callsigns are found and looked up automatically; no key needed | -| `s` | sort by time, frequency or length | +| `s` | sort by date/time, frequency or length | +| — | each line gives the date and time as `YY-mm-dd hh:mm:ss am/pm`, newest first | | `r` | re-read the directory, picking up what a running scan has written | | `o` | print the file's path and quit | | — | a picture is marked in the list, with the path of its PNG | @@ -1501,6 +1502,7 @@ 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 +saunterbrowse --sort date/time # newest first: the default ``` Playback is handed to whichever player is installed — `pw-play`, `paplay`, diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 256e25c..c972703 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -8,8 +8,8 @@ and transcribing speech. # 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 # to two digits so versions sort as text. -VERSION_DATE = "2026-08-29" -VERSION_REVISION = 3 +VERSION_DATE = "2026-08-30" +VERSION_REVISION = 1 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/browse.py b/bandsaunter/browse.py index f564223..43cf10e 100644 --- a/bandsaunter/browse.py +++ b/bandsaunter/browse.py @@ -614,7 +614,11 @@ class Keyboard: # The browser # --------------------------------------------------------------------------- -SORTS = ("time", "frequency", "duration") +SORTS = ("date/time", "frequency", "duration") + +# What --sort used to be called. Kept working because it is the sort of thing +# people put in a shell alias and never look at again. +SORT_ALIASES = {"time": "date/time", "date": "date/time"} # 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 @@ -630,6 +634,42 @@ FILING: tuple[tuple[str, str, str], ...] = ( _FILING_KEYS = {key: name for key, name, _ in FILING} +# How a moment is written everywhere in the browser: the date first so that a +# column of them reads down in order, then the clock the way a person says it. +# Zero-padded on purpose -- an unpadded hour puts a ragged edge down the middle +# of the listing, and a column that does not line up is a column nobody reads. +STAMP = "%y-%m-%d %I:%M:%S %p" +STAMP_WIDTH = 20 + + +def _stamp(when: datetime | None) -> str: + """One capture's date and time, or an empty string when it has neither.""" + if when is None: + return "" + # Lower case because AM in capitals shouts, and the field beside it is a + # frequency: the eye should land on the number, not on the meridiem. + return when.strftime(STAMP).lower() + + +# The widest frequency there is room for -- 1090.000001 MHz -- and the +# narrowest worth reserving. +FREQ_WIDTH = (9, 15) + + +def _freq_width(captures) -> int: + """How wide the frequency column has to be for these recordings. + + Fixed at the width of the widest frequency in the whole list rather than + the widest on screen: a column that changes width as the list scrolls + under it makes the whole listing appear to twitch. Anything given up + here goes to the end of the line, which is where what was said is. + """ + widest = max((len(fmt_hz(c.frequency)) if c.frequency + else min(FREQ_WIDTH[1], len(c.path.stem)) + for c in captures), default=0) + return max(FREQ_WIDTH[0], min(FREQ_WIDTH[1], widest)) + + def _dur(seconds: float) -> str: seconds = max(0.0, float(seconds)) if seconds >= 3600: @@ -665,7 +705,8 @@ class Browser: self.view: list[Capture] = [] self.index = 0 self.top = 0 # first row shown in the list - self.sort = "time" + self.sort = SORTS[0] + self.freq_width = FREQ_WIDTH[0] self.query = "" self.searching = False self.confirm = "" # an action waiting to be agreed to @@ -702,8 +743,14 @@ class Browser: 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) + # Newest first, and strictly by the whole moment -- year, month, + # day, then hour, minute, second -- because started_at is one + # number counting from the epoch rather than a formatted string. + # The filename breaks a tie, which only happens when two signals + # landed in the same second. + self.view.sort(key=lambda c: (-c.started_at, c.path.name)) self.index = max(0, min(self.index, len(self.view) - 1)) + self.freq_width = _freq_width(self.view) @staticmethod def _matches(cap: Capture, q: str) -> bool: @@ -1016,7 +1063,7 @@ class Browser: when = cap.when if when is not None: left.append(" ") - left.append(when.strftime("%a %d %b %H:%M:%S"), style="white") + left.append(f"{when:%a} {_stamp(when)}", style="white") left.append(" ") left.append(_dur(cap.duration), style="bright_black") snr = cap.meta.get("snr_db") @@ -1230,8 +1277,8 @@ class Browser: 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=self.freq_width, justify="right") # frequency + t.add_column(width=STAMP_WIDTH) # date and 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 @@ -1258,8 +1305,7 @@ class Browser: 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(_stamp(when), 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 @@ -1373,7 +1419,7 @@ class Browser: ("t", "read the whole transcript, full screen"), ("/", "filter by frequency, name, class or anything said"), ("Esc", "clear the filter"), - ("s", "sort by time, frequency or length"), + ("s", "sort by date/time, frequency or length"), ("r", "re-read the directory"), ("o", "print the file's path and quit"), ("", ""), @@ -1434,7 +1480,7 @@ class Browser: title += f" · {fmt_hz(cap.frequency)}" when = cap.when if when is not None: - title += f" · {when.strftime('%d %b %H:%M:%S')}" + title += f" · {_stamp(when)}" return Panel(body, title=title, title_align="left", subtitle=f"[bright_black]{where} ↑↓ scroll " f"⏎ play t or esc back[/bright_black]", @@ -1673,8 +1719,10 @@ def build_parser() -> argparse.ArgumentParser: 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("--sort", choices=SORTS + tuple(SORT_ALIASES), + default=SORTS[0], metavar="ORDER", + help="initial order: %s (default: %s, newest first)" + % (", ".join(SORTS), SORTS[0])) p.add_argument("--filter", default="", metavar="TEXT", help="start with only recordings matching this") p.add_argument("--player", default=None, metavar="CMD", @@ -1782,7 +1830,7 @@ def main(argv: list[str] | None = None) -> int: player = Player(args.player.split() if args.player else None) book = CallsignBook(online=args.lookup) browser = Browser(directory, console=console, player=player, book=book) - browser.sort = args.sort + browser.sort = SORT_ALIASES.get(args.sort, args.sort) browser.query = args.filter browser.apply() diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 3a83bd7..0f5124f 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-29" "bandsaunter 2026-08-29_03" "User Commands" +.TH BANDSAUNTER 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS diff --git a/packaging/make-browse-man.py b/packaging/make-browse-man.py index 46784f3..7efb2f7 100755 --- a/packaging/make-browse-man.py +++ b/packaging/make-browse-man.py @@ -55,6 +55,13 @@ 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 +Each line of the list gives the frequency, the date and time, the mode, how +long it ran and what was said. The moment is written +.BR "YY-mm-dd hh:mm:ss am/pm" , +date first so that a column of them reads down in order, and on a twelve-hour +clock so that it reads the way you would say it. The list starts in that +order, newest first. +.PP With no .I DIRECTORY it opens the one the scanner writes to, taken from your saved settings, so it @@ -139,8 +146,12 @@ Where the recordings are. Defaults to the scanner's output directory, or to when that is set. .TP .BI \-\-sort " ORDER" -Start in this order: {sorts}. Time is newest first; duration is longest +Start in this order: {sorts}. Date/time is newest first, ordered by the whole +moment \[em] year, month, day, then hour, minute and second; duration is longest first. +.B time +is accepted as the old name for +.BR date/time . .TP .BI \-\-filter " TEXT" Start with only the recordings matching this, exactly as if it had been typed diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index 6a1dddf..f78d265 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-browse-man.py -- do not edit by hand. -.TH SAUNTERBROWSE 1 "2026-08-29" "bandsaunter 2026-08-29_03" "User Commands" +.TH SAUNTERBROWSE 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands" .SH NAME saunterbrowse \- read and listen to what a bandsaunter scan collected .SH SYNOPSIS @@ -29,6 +29,13 @@ 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 +Each line of the list gives the frequency, the date and time, the mode, how +long it ran and what was said. The moment is written +.BR "YY-mm-dd hh:mm:ss am/pm" , +date first so that a column of them reads down in order, and on a twelve-hour +clock so that it reads the way you would say it. The list starts in that +order, newest first. +.PP With no .I DIRECTORY it opens the one the scanner writes to, taken from your saved settings, so it @@ -69,7 +76,7 @@ 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. +Cycle the order: date/time, frequency, duration. .TP .B r Re-read the directory. A scan running in another window is still writing to @@ -113,8 +120,12 @@ Where the recordings are. Defaults to the scanner's output directory, or to when that is set. .TP .BI \-\-sort " ORDER" -Start in this order: time, frequency, duration. Time is newest first; duration is longest +Start in this order: date/time, frequency, duration. Date/time is newest first, ordered by the whole +moment \[em] year, month, day, then hour, minute and second; duration is longest first. +.B time +is accepted as the old name for +.BR date/time . .TP .BI \-\-filter " TEXT" Start with only the recordings matching this, exactly as if it had been typed diff --git a/tests/test_browse.py b/tests/test_browse.py index 1510766..9efc67c 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -377,7 +377,7 @@ def test_the_cursor_stays_on_screen_in_a_long_list(tmp_path): def test_sorting_cycles_and_reorders(library): b = browser(library) - assert b.sort == "time" + assert b.sort == "date/time" b.handle("s") assert b.sort == "frequency" assert [c.frequency for c in b.view] == sorted(c.frequency @@ -387,6 +387,85 @@ def test_sorting_cycles_and_reorders(library): assert b.view[0].duration >= b.view[-1].duration +def test_the_listing_gives_the_date_as_well_as_the_time(library): + """Without the date, two recordings a week apart look like neighbours.""" + text = frame(browser(library)) + assert re.search(r"\d\d-\d\d-\d\d \d\d:\d\d:\d\d [ap]m", text) + + +def test_the_clock_is_twelve_hour_with_midnight_and_noon_named(tmp_path): + """The two hours a twelve-hour clock gets wrong if it is done by + subtraction: midnight is 12 am and noon is 12 pm, not 0 am and 0 pm.""" + make_capture(tmp_path, 146.52, "2026-08-30_00_00_30", "nfm") + make_capture(tmp_path, 146.52, "2026-08-30_12_00_30", "nfm") + make_capture(tmp_path, 146.52, "2026-08-30_13_05_00", "nfm") + text = frame(browser(tmp_path)) + assert "26-08-30 12:00:30 am" in text + assert "26-08-30 12:00:30 pm" in text + assert "26-08-30 01:05:00 pm" in text + + +def test_sorting_by_date_time_is_chronological_across_every_boundary(tmp_path): + """Year, month, day, then hour, minute, second. The order below is the + one a clock would put them in; the browser has to agree with it whichever + way the filenames happen to sort as text.""" + moments = ["2025-12-31_23_59_59", "2026-01-01_00_00_01", + "2026-01-01_00_00_02", "2026-01-31_09_00_00", + "2026-02-01_08_00_00", "2026-08-30_11_59_59", + "2026-08-30_12_00_00", "2026-08-30_13_00_00"] + for i, when in enumerate(moments): + make_capture(tmp_path, 146.52 + i * 0.01, when, "nfm") + b = browser(tmp_path) + assert [c.when.strftime("%Y-%m-%d_%H_%M_%S") for c in b.view] == \ + list(reversed(moments)) + + +def test_two_signals_in_the_same_second_keep_a_settled_order(tmp_path): + """A tie is broken by the filename so that the list does not shuffle + itself between one reload and the next.""" + make_capture(tmp_path, 146.52, "2026-08-30_10_00_00", "nfm") + make_capture(tmp_path, 145.00, "2026-08-30_10_00_00", "nfm") + first = [c.path.name for c in browser(tmp_path).view] + assert first == sorted(first) + + +def test_the_sort_key_is_called_date_time(library): + b = browser(library, width=140) + assert b.sort == "date/time" + assert "date/time" in frame(b) + + +def test_the_old_name_for_the_time_sort_still_works(library, monkeypatch): + """--sort time is the sort of thing that lives in a shell alias.""" + from bandsaunter import browse + + seen = {} + + def fake_loop(self): + seen["sort"] = self.sort + return 0 + + monkeypatch.setattr(browse.Browser, "run", fake_loop, raising=False) + monkeypatch.setattr(browse.Console, "is_terminal", property(lambda s: True)) + assert main(["--sort", "time", str(library)]) == 0 + assert seen["sort"] == "date/time" + + +def test_the_frequency_column_does_not_change_width_while_scrolling(tmp_path): + """One wide frequency in the list fixes the column for the whole list, so + that scrolling past it does not make everything else jump sideways.""" + make_capture(tmp_path, 1090.000001, "2026-08-30_10_00_00", "nfm") + for i in range(40): + make_capture(tmp_path, 146.52, f"2026-08-30_11_{i // 60:02d}_{i % 60:02d}", + "nfm") + b = browser(tmp_path, height=24) + frame(b) + was = b.freq_width + b.handle("end") + frame(b) + assert b.freq_width == was + + def test_quitting_stops_the_loop(library): assert browser(library).handle("q") is False