Skip to content

Add Broadcastify Calls output type#550

Open
blantonl wants to merge 3 commits intortl-airband:mainfrom
blantonl:feature/broadcastify-calls-output
Open

Add Broadcastify Calls output type#550
blantonl wants to merge 3 commits intortl-airband:mainfrom
blantonl:feature/broadcastify-calls-output

Conversation

@blantonl
Copy link
Copy Markdown

@blantonl blantonl commented Mar 28, 2026

Summary

  • Adds a new broadcastify_calls output type that uploads individual radio transmissions to the Broadcastify Calls API
  • Buffers audio during each transmission, encodes to MP3, and uploads after the call ends via a dedicated upload thread
  • Behind WITH_BROADCASTIFY_CALLS cmake feature flag (default OFF), requires libcurl
  • Multichannel mode only (not scan mode), not supported on mixers

Details

New files:

  • src/broadcastify_calls.h - data structures and function declarations
  • src/broadcastify_calls.cpp - upload queue, upload thread, sample buffering, LAME encoding, curl multipart HTTP upload
  • config/broadcastify_calls.conf - example configuration
  • Broadcastify-Calls-Output.md - documentation (wiki draft)

Modified files:

  • src/CMakeLists.txt - BROADCASTIFY_CALLS option, find_package(CURL)
  • src/config.h.in - #cmakedefine WITH_BROADCASTIFY_CALLS
  • src/rtl_airband.h - O_BCFY_CALLS enum value, conditional include
  • src/config.cpp - config parsing for type = "broadcastify_calls"
  • src/rtl_airband.cpp - init, startup, shutdown hooks
  • src/output.cpp - process_outputs and disable_channel_outputs cases

Key design decisions:

  • Call detection uses existing squelch/signal detection (axcindicate)
  • Audio buffered in memory (no temp files on disk), ~32 KB/sec at 8000 Hz mono
  • Dedicated upload thread with mutex-protected queue avoids blocking the output thread
  • MP3 encoded at 8000 Hz / 16 kbps CBR mono with 100 Hz highpass / 2500 Hz lowpass
  • Two-step API: multipart POST to register call, PUT audio to one-time S3 URL
  • Configurable min/max call duration, queue depth, dev/test modes
  • Retry with exponential backoff for transient failures only

Configuration example

{
    type = "broadcastify_calls";
    api_key = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
    system_id = 12345;
    tg = 1001;                  # frequency slot ID from Broadcastify
    # min_call_duration = 1.0;  # seconds, default 1.0
    # max_call_duration = 120;  # seconds, default 120
    # max_queue_depth = 25;     # calls, default 25
}

Test plan

  • Builds with -DBROADCASTIFY_CALLS=ON (requires libcurl)
  • Builds without flag (no impact on existing functionality)
  • Tested credential validation with test_mode = true
  • Tested call detection, encoding, and upload against Broadcastify Calls dev and production APIs
  • Verified proper handling of all API response codes (success, duplicate, errors)
  • CI build verification

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>
Copy link
Copy Markdown
Collaborator

@charlie-foxtrot charlie-foxtrot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.cpp
}
#endif /* WITH_PULSEAUDIO */
#ifdef WITH_BROADCASTIFY_CALLS
} else if (!strncmp(outs[o]["type"], "broadcastify_calls", 18)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a warning if broadcastify_calls is in the config file but WITH_BROADCASTIFY_CALLS is not compiled in

Comment thread src/config.cpp Outdated
Comment on lines +276 to +277
bdata->system_id = outs[o]["system_id"];
bdata->tg = outs[o]["tg"];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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"];

Comment thread src/config.cpp Outdated
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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why (float)(double)?

Comment thread src/config.cpp
bdata->sample_buf_len = 0;
bdata->sample_buf_capacity = 0;

channel->outputs[oo].has_mp3_output = false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

creating a new curl instance per upload does not allow connection re-use

Comment thread src/broadcastify_calls.cpp Outdated
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/broadcastify_calls.cpp Outdated
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another init means no re-use

free_record(rec);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and a log line that up to X files are being drained so people know why shutdown is taking time

@charlie-foxtrot
Copy link
Copy Markdown
Collaborator

@blantonl from your end there isn't a need for this to be in the rtl_airband process as long as it works (and is easy for people to use), correct?

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:

  {
    type = "file";
    directory = "/home/to_broadcastify_calls";
    filename_template = "TOWER";
    split_on_transmission = true;
    min_call_seconds = 1.0;
    max_call_seconds = 120;

     broadcastify_calls: {
           api_key = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
           system_id = 12345;
           tg = 1001;
#          max_queue_depth = 25;
     }

then having a separate process (python may be easiest) read the same config file, watch the directory for TOWER*.mp3 files, and do the uploads. the existing file output uses .mp3.tmp while the file is being written, so no explicit communication is needed between rtl_airband and the uploader.

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>
@blantonl
Copy link
Copy Markdown
Author

blantonl commented Apr 3, 2026

Thanks for the thorough review, @charlie-foxtrot. Let me address the architectural question first, then the inline comments.

On architecture: in-process vs. separate uploader

I 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 outputs model already supports this pattern. rtl_airband's architecture already has the concept of multiple output types per channel running simultaneously. Adding broadcastify_calls as another output type is consistent with how Icecast, file, and PulseAudio outputs work. Users who want both a local file recording AND Broadcastify Calls upload simply add both output blocks.

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

config.cpp:256 — Warning when not compiled in:
Good catch. Added an #else clause that logs a warning when broadcastify_calls appears in the config but WITH_BROADCASTIFY_CALLS is not enabled, and continues to skip the output rather than hard-erroring. Done in latest push.

config.cpp:277 — Cast simplification (system_id/tg):
Agreed, simplified to (int)outs[o]["system_id"] and (int)outs[o]["tg"]. Done.

config.cpp:278 — Why (float)(double)?
libconfig++ returns settings as double via its operator double(). The intermediate (double) made the libconfig conversion explicit, then (float) narrowed it to match the float field type. But you're right this is unnecessarily verbose — changed to just (float)outs[o]["min_call_duration"] since the implicit double conversion will happen either way. Done.

config.cpp:288has_mp3_output should be true:
I intentionally set this to false because Broadcastify Calls does its own MP3 encoding in the upload thread (off the output hot path), with different parameters tuned for the Calls API (8kHz, 16kbps CBR, specific highpass/lowpass). The existing MP3 encoding path in output.cpp is designed for continuous Icecast streaming with VBR, which isn't what the Calls API expects. See next point for more detail.

broadcastify_calls.cpp:94 — Use common MP3 encoding:
The Broadcastify Calls encoder differs from airlame_init() in several important ways:

  • CBR vs VBR: Calls API expects CBR (vbr_off) for accurate duration/size calculations; the existing code uses vbr_mtrh
  • Sample rate: Calls encodes at 8000 Hz output explicitly; the existing path uses MP3_RATE
  • Quality preset: Calls uses quality 2 (higher quality encoding, acceptable since it's a one-shot encode not realtime); existing uses quality 7
  • Encoding model: Calls accumulates the entire transmission in a buffer and encodes it all at once after the call ends, vs. the streaming chunk-at-a-time model in output.cpp

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.

broadcastify_calls.cpp:154 & 292 — curl handle reuse:
Fair point. The POST and PUT go to different hosts (our API server vs. an S3 presigned URL), so connection reuse between those two wouldn't help. But reusing a handle across successive uploads to our API would. I'll look at maintaining a persistent CURL* handle in the upload thread with curl_easy_reset() between calls in a follow-up.

broadcastify_calls.cpp:247 — Retry signaling (500 vs 400):
Good observation. Both previously returned false from do_upload(), but the distinction was only at the logging level. Fixed this by introducing a tri-state enum upload_result (UPLOAD_SUCCESS, UPLOAD_FAIL_PERMANENT, UPLOAD_FAIL_TRANSIENT). The retry loop in encode_and_upload() now only retries on UPLOAD_FAIL_TRANSIENT, and 4xx errors are properly treated as permanent failures that skip retry. Done in latest push.

broadcastify_calls.cpp:263 — Duplicate detection for analog:
Good question. Yes, currently the first upload wins on the server side. For analog systems where multiple receivers might capture the same transmission at different quality levels, this is a known limitation, but we can handle with priority / fallback at the API level. The Calls API was originally designed for digital trunked systems where duplicate detection is straightforward (same talkgroup + timestamp = same call). For analog, the tg field maps to a frequency slot ID assigned by Broadcastify, so in practice each receiver/frequency combination gets its own tg and duplicates only occur if the same user runs multiple instances with the same config. This is an API-side concern rather than something rtl_airband can solve.

broadcastify_calls.cpp:389 — Log line for drain on shutdown:
Agreed, added a log message before the drain loop starts: "Broadcastify Calls: draining up to %d queued calls before shutdown". Done.

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>
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.

2 participants