Add Broadcastify Calls output type#550
Conversation
Add a new output type that uploads individual radio transmissions to the Broadcastify Calls API. Unlike the existing stream-oriented outputs, this buffers audio during each transmission and uploads it as a discrete MP3 file with metadata after the transmission ends. Features: - Call-oriented: detects transmission boundaries via squelch, buffers audio in memory, uploads after call ends - Two-step API upload: POST metadata to register call, PUT audio to returned one-time URL - Dedicated upload thread with queue to avoid blocking the output thread - MP3 encoding at 8000 Hz / 16 kbps CBR mono with highpass/lowpass - Configurable min/max call duration, queue depth, dev/test modes - Retry with exponential backoff for transient failures - Proper handling of all Broadcastify Calls API response codes - Behind WITH_BROADCASTIFY_CALLS cmake feature flag (default OFF) - Requires libcurl New files: - src/broadcastify_calls.h - data structures and declarations - src/broadcastify_calls.cpp - implementation - config/broadcastify_calls.conf - example configuration - Broadcastify-Calls-Output.md - documentation (wiki draft) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
charlie-foxtrot
left a comment
There was a problem hiding this comment.
Thanks for the PR. Here are some comments from my initial walk through the code (I haven't looked at the memory allocation and pointer usage closely).
Some of the design decisions may be based on how the calls server works, but I think this would be cleaner if it leverage the existing file outputs. Instead of using a memory buffer, the (already supported) MP3 encoded files can be written to the filesystem in their own directory, then uploaded to calls from there. Among other benefits this would prevent long transmissions / network outages from increasing rtl_airband's memory usage.
In fact, moving this out of the rtl_airband process into its own thing that watches a directory for new files and uploads them may be a much better architecture overall. This would also be something that could be used across multiple radio software packages.
| } | ||
| #endif /* WITH_PULSEAUDIO */ | ||
| #ifdef WITH_BROADCASTIFY_CALLS | ||
| } else if (!strncmp(outs[o]["type"], "broadcastify_calls", 18)) { |
There was a problem hiding this comment.
Add a warning if broadcastify_calls is in the config file but WITH_BROADCASTIFY_CALLS is not compiled in
| bdata->system_id = outs[o]["system_id"]; | ||
| bdata->tg = outs[o]["tg"]; |
There was a problem hiding this comment.
| bdata->system_id = outs[o]["system_id"]; | |
| bdata->tg = outs[o]["tg"]; | |
| bdata->system_id = (int)outs[o]["system_id"]; | |
| bdata->tg = (int)outs[o]["tg"]; |
| bdata->api_key = strdup(outs[o]["api_key"]); | ||
| bdata->system_id = outs[o]["system_id"]; | ||
| bdata->tg = outs[o]["tg"]; | ||
| bdata->min_call_duration = outs[o].exists("min_call_duration") ? (float)(double)outs[o]["min_call_duration"] : 1.0f; |
There was a problem hiding this comment.
why (float)(double)?
| bdata->sample_buf_len = 0; | ||
| bdata->sample_buf_capacity = 0; | ||
|
|
||
| channel->outputs[oo].has_mp3_output = false; |
There was a problem hiding this comment.
this should be true and existing MP3 encoding used
| return to_copy; | ||
| } | ||
|
|
||
| static unsigned char* encode_mp3(bcfy_call_record* rec, size_t* out_len) { |
There was a problem hiding this comment.
Use the common MP3 encoding code rather than re-implementing
| const char* api_url = rec->use_dev_api ? BCFY_CALLS_DEV_URL : BCFY_CALLS_URL; | ||
|
|
||
| // Step 1: POST metadata to register the call | ||
| CURL* curl = curl_easy_init(); |
There was a problem hiding this comment.
creating a new curl instance per upload does not allow connection re-use
| if (http_code >= 500) { | ||
| log(LOG_WARNING, "Broadcastify Calls: POST returned HTTP %ld (server error)\n", http_code); | ||
| free(response.buf); | ||
| return false; // transient, caller will retry |
There was a problem hiding this comment.
this is marked as "will retry" where as line 241 is marked as "don't retry", how does the retry info communicated to the calling function?
| log(LOG_INFO, "Broadcastify Calls: call skipped (duplicate): tg=%d freq=%d ts=%ld\n", | ||
| rec->tg, rec->freq, (long)rec->ts); | ||
| free(response.buf); | ||
| return true; // not an error, another source already uploaded this call |
There was a problem hiding this comment.
this is very interesting . . . . for a digital system it makes sense (if multiple receivers get a transmission they should all be the same) but for an analog system the audio quality can be very different from different receivers. does this mean that the first user that uploads the transmission will be used independent of audio quality?
| } | ||
|
|
||
| // Step 2: PUT audio to the one-time upload URL | ||
| curl = curl_easy_init(); |
There was a problem hiding this comment.
another init means no re-use
| free_record(rec); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
and a log line that up to X files are being drained so people know why shutdown is taking time
|
@blantonl from your end there isn't a need for this to be in the Some of the functionality added here (min / max file length) has been requested by others, so adding that part to the file output then having calls leverage file outputs would be a win-win. I'm thinking something like this: then having a separate process (python may be easiest) read the same config file, watch the going this route the separate process could be in this repo, or could be in its own repo if you want to re-use the uploaded for other file types. |
- Add warning when broadcastify_calls is configured but not compiled in - Simplify libconfig casts for system_id, tg, and min_call_duration - Introduce upload_result enum (success/permanent/transient) so retry loop skips retries on permanent failures like 4xx errors - Add log message before draining queued calls on shutdown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review, @charlie-foxtrot. Let me address the architectural question first, then the inline comments. On architecture: in-process vs. separate uploaderI understand the appeal of a separate directory-watching process - it's a clean separation of concerns and could serve other radio software. However, I'd strongly prefer keeping this as a native output type within rtl_airband for several reasons: 1. Single deployment, single config. Users running rtl_airband on Raspberry Pis and similar embedded systems benefit from a one-stop compile. Adding a separate Python process means an additional runtime dependency, a second service to manage (systemd unit, monitoring, restarts), and a second failure mode to debug. The current approach is: install rtl_airband, add an output block to your config, done. 2. Avoiding filesystem contention. A directory-watch approach means two processes competing for filesystem I/O on what are often SD cards with limited write endurance and throughput. The in-memory buffer approach avoids all intermediate disk writes — samples go directly from the demodulation pipeline to MP3 encoding to HTTP upload. On a Pi Zero running multiple channels, this matters. 3. Collision with other file-watchers. Users may already have inotify-based tools, log rotation, or other processes watching directories. Introducing another directory watcher creates potential for subtle conflicts — race conditions on file pickup, duplicate processing, permissions issues. The in-process approach has none of these concerns since it uses a simple mutex-protected queue. 4. The existing file output doesn't quite fit. The file output writes MP3 incrementally as a stream (which is great for its purpose), but Broadcastify Calls needs the complete call metadata (exact duration, start timestamp, frequency) at the point of upload. A file-watcher would need to re-derive this metadata from filenames or sidecar files, adding fragility. The in-process approach has direct access to channel state. 5. The That said, if you'd prefer not to have this in the main repo, I understand - I'll maintain it as a fork. But I think it's a natural fit for the output plugin model that's already there, and I'd rather not fork out your hard work. Inline review comments
I could factor out a shared init function with parameters, but the two use cases are different enough that sharing code would add complexity without much benefit. Happy to discuss further though.
|
One return false was not converted to UPLOAD_FAIL_TRANSIENT when the upload_result enum was introduced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
broadcastify_callsoutput type that uploads individual radio transmissions to the Broadcastify Calls APIWITH_BROADCASTIFY_CALLScmake feature flag (default OFF), requires libcurlDetails
New files:
src/broadcastify_calls.h- data structures and function declarationssrc/broadcastify_calls.cpp- upload queue, upload thread, sample buffering, LAME encoding, curl multipart HTTP uploadconfig/broadcastify_calls.conf- example configurationBroadcastify-Calls-Output.md- documentation (wiki draft)Modified files:
src/CMakeLists.txt-BROADCASTIFY_CALLSoption,find_package(CURL)src/config.h.in-#cmakedefine WITH_BROADCASTIFY_CALLSsrc/rtl_airband.h-O_BCFY_CALLSenum value, conditional includesrc/config.cpp- config parsing fortype = "broadcastify_calls"src/rtl_airband.cpp- init, startup, shutdown hookssrc/output.cpp- process_outputs and disable_channel_outputs casesKey design decisions:
axcindicate)Configuration example
Test plan
-DBROADCASTIFY_CALLS=ON(requires libcurl)test_mode = true