Skip to content

Record what the radio was doing, and turn PTT into a recording - #2

Closed
jwussler wants to merge 2 commits into
macos-bundle-identityfrom
qso-record
Closed

Record what the radio was doing, and turn PTT into a recording#2
jwussler wants to merge 2 commits into
macos-bundle-identityfrom
qso-record

Conversation

@jwussler

@jwussler jwussler commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Stacked on #1 — review that first; the diff here is just this change.

The premise was wrong, and checking cost less than building

"Port recording like the C# has" — it's already ported. src/recorder.cpp does WAV writing, continuous record and the replay ring, wired at main.cpp:213 with unit tests, and AUDIT-CSHARP.md:42 has had it as ✅ the whole time.

The real gap: a recording carried no provenance. hamdeck-rec-<local time>.wav and nothing else — no frequency, no mode, and its one timestamp in local time while every log worth matching against is UTC. "Who was that" was unanswerable by construction.

What changed

  • A .json sidecar beside every recording — UTC start/end, frequency at start and end, mode, and the operator's overs. Manual, replay and auto alike.
    • Provenance is pushed from the poll loop, not pulled. Pulling took the poller's lock while holding the recorder's, from the poll thread, waiting on the one API path that nests them the other way.
    • A replay's started_utc is derived from the sample count — the ring holds what happened before the press, so stamping it "now" matches the audio to the wrong contact.
    • "overs": null"overs": []. Null is "not tracked"; an empty array would claim the operator never transmitted.
  • QsoRecorder — the C# PTT auto-record (Views/MainWindow.xaml.cs:420), ported rather than invented: start on the rising edge, each over pushes the idle deadline, stop on idle or a QSY measured from where the QSO started. Must not start on a tune, and a tune must not move the remembered PTT state. Off by default — it records whoever you're talking to, unasked.
  • RadioPoller::OnPoll — one fan-out point for anything reacting to rig state, instead of a second loop competing for the CAT port.

The gates, each proven to fail

reintroduced caught by
NoteOver before the file opens overs == 2 — the over that starts each QSO went unlisted
localtime_r for the sidecar the local-vs-UTC hour comparison
replay stamped when saved age >= 19 && age < 60
auto-record starting on a tune !q.active()

⚠️ The UTC test was blind and passed anyway. The build box runs UTC, so localtime_r and gmtime_r agree and the assertion held just as happily against a local-time stamp. It now pins TZ to a POSIX string (glibc parses it with no tzdata, so a bare container can't silently drop back to UTC), asserts the two clocks actually differ, and compares the filename's local hour to the sidecar's UTC hour. Only then does localtime_r fail it.

Proven on the built host, not only in tests

Simulated rig, ptt_record_seconds: 3, keyed via /api/ptt/on, unkeyed, waited out the timer:

hamdeck-qso-09-02-2026-135540.wav   3.0 s, 1ch/16bit/22050, rms 5656 peak 7997
{ "trigger": "idle", "started_utc": "...13:55:40Z", "ended_utc": "...13:55:43Z",
  "freq_hz_start": 14074000, "mode": "USB", "overs": [ one, closed ] }

Not in this PR

The identification half, and a re-measurement of MONI — CARRYOVER.md:207 says it can't be captured, WsAudioClient.cs:205 says the operator hears themselves through it. Both describe the same station. Details in WIP.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9

jwussler and others added 2 commits September 2, 2026 13:57
The recorder was already ported - WAV, continuous record and the replay ring,
wired and unit-tested. What was missing is that a recording carried no
provenance: filename in local time, no frequency, no mode. Every log worth
matching against is UTC, so nothing downstream could join on it and "who was
that" was unanswerable by construction.

- Every recording now writes a .json sidecar: UTC start/end, frequency at start
  and end, mode, and the operator's overs. Manual, replay and auto alike.
  Provenance is pushed from the poll loop rather than pulled, because pulling
  took the poller's lock while holding the recorder's, from the poll thread.
  A replay's start time is derived from the sample count - the ring holds what
  happened before the press, so stamping it "now" would match it to the wrong
  contact. "overs": null means not tracked; an empty array would claim the
  operator never transmitted.
- QsoRecorder ports the C# PTT auto-record: start on the rising edge, each over
  pushes the idle deadline out, stop on idle or a QSY measured from where the
  QSO started. It must not start on a tune, and a tune must not move the
  remembered PTT state. Off by default - it records whoever the operator is
  talking to, unasked.
- RadioPoller::OnPoll is one fan-out point for everything that reacts to rig
  state, rather than a second loop competing for the CAT port.

The UTC assertion was blind: this box runs UTC, so localtime_r and gmtime_r
agree and the test passed against a local-time stamp. It now pins TZ to a POSIX
string glibc parses without tzdata, asserts the two clocks actually differ, and
compares the filename's local hour to the sidecar's UTC hour. Only then does
localtime_r fail it. Same for the other three: each bug was reintroduced and
watched to fail.

Proven on the built host against the simulated rig, not only in tests: keyed,
unkeyed, waited out the timer, got a 3.0 s WAV with rms 5656 and a sidecar
carrying both UTC times, the frequency, the mode and one closed over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9
…me claim

Takes a recording's sidecar and reports LOGGED (a fact - the operator wrote the
callsign down) separately from ON THE NET (candidates - a check-in says present,
not that they were the voice on the tape). Those must never be presented as the
same kind of answer.

The net name turns out to be in the log already, so this layer needs no API and
works retroactively over the whole log: NetLogger-sourced QSOs carry it in
COL_COMMENT in the two encodings qsl-queue measured over 29,573 rows. Bracketed
text is not always a net, so only net-shaped tokens are taken as one - checked
against the [New call sign May 2025] rows that would otherwise be reported as a
net name.

Matching is on time alone and the band is shown rather than filtered on:
filtering would silently drop true matches whenever the sidecar's frequency is
unreliable, and a dropped true match is invisible in a way a flagged odd one is
not. A QSO on another band in the same window is marked, never hidden.

A logged QSO is an instant, not a span - COL_TIME_OFF equals COL_TIME_ON on
every row - so the timestamp is when it was logged, usually the end of the
exchange. Hence the pad, and hence every match prints its offset into the
recording so an edge match reads as one.

"Nothing found" is never printed as "nobody". An unlogged QSO and a station
heard but not worked look identical from here.

Verified against the real log: a 26-minute window over the 07/26 80m net
returned three callsigns with their offsets and the net name; the same window
with the band claim changed flagged both rows as a different band; a window
with no QSOs said nothing was found and said why that is not an absence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant