Measured six recordings off a live P25 node and found two independent
defects causing lost audio at the end of calls:
- stop_recording() sliced the ring buffer immediately, so if the MP3
muxer had not yet delivered the tail the file was silently short.
Now waits (bounded, 2s) until buffered audio covers the end epoch.
- TGID-change closes used the new grant's epoch as the end with no pad
at all, guaranteeing truncation on every split. Tail pad is now a
setting, default raised 0.5s -> 1.0s.
The ring buffer also capped maximum call length: a call longer than the
buffer had its front silently clamped. The ring now serves the pre-roll
only, with a per-call accumulator for the rest, bounded at 4.8MB.
Clamping is loudly warned rather than silent.
Uploads averaged 63% silence, which inflates STT cost and is a known
Whisper hallucination trigger. Leading/trailing silence is now trimmed
conservatively (-40dB, 0.25s guard, internal pauses untouched).
started_at/ended_at still describe the call; new audio_* fields carry
the trimmed audio bounds so playback can map back to wall clock.
All-silence recordings are skipped and logged instead of uploaded.
Also: log measured control-channel idle on idle-timeout closes so
CALL_IDLE_TIMEOUT can be tuned from data, and quiet the httpx logger
which emitted ~170k lines/day of poll noise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- RadioBot.join() accepts system_name parameter
- Sets bot activity to ActivityType.listening → system name on connect
- Clears presence (activity=None) on leave
- main.py passes system_name from MQTT payload to radio_bot.join()
op25-container/system.pa New PulseAudio config: auth-anonymous=1 so the edge-node can connect without group membership
op25-container/Dockerfile Copies system.pa into /etc/pulse/system.pa
drb-edge-node/Dockerfile Added libpulse0 — ffmpeg needs this runtime library to use -f pulse
drb-edge-node/app/internal/call_recorder.py Switched recording from Icecast (burst-buffer overlap) to PulseAudio monitor (zero-delay, exact call boundaries)
docker-compose.yml: Added a pulse_socket named volume mounted at /run/pulse in both op25 and edge-node. Also set PULSE_SERVER=unix:/run/pulse/native in edge-node so libpulse (and ffmpeg's pulse input) finds the right socket.
discord_radio.py: Removed _icecast_url and changed _play_stream() to use -f pulse -i default.monitor. This reads directly from the PulseAudio sink monitor — zero buffer delay. The PULSE_SERVER env var is inherited by the ffmpeg subprocess.
Note: default.monitor captures whatever audio is playing on the default sink. If OP25 uses a named virtual sink, you may need to replace default.monitor with <sink_name>.monitor (run pactl list sinks short inside the op25 container to find the name).
Issue 2 — No audio URL / GCS credentials
storage.py: storage.Client() was using ADC but ADC isn't configured in the container. Now uses storage.Client.from_service_account_json(settings.gcp_credentials_path) when GCP_CREDENTIALS_PATH is set — same credential file Firebase already loads.
You also need to mount the key file into the server container in docker-compose.yml:
c2-core:
volumes:
- ./gcp-key.json:/app/gcp-key.json:ro
And set GCS_BUCKET=your-bucket-name in .env.
Issue 3 — Token orphaning
mqtt_manager.py: Every checkin now includes "discord_connected": radio_bot.is_connected.
mqtt_handler.py: On checkin, if discord_connected is explicitly False, calls release_token(node_id). Only fires on explicit false (missing field = unknown = no action).
node_sweeper.py: When a node is swept to offline, its token is released too. This covers the case where the node stops checking in entirely (crash/power loss).