diff --git a/Broadcastify-Calls-Output.md b/Broadcastify-Calls-Output.md
new file mode 100644
index 0000000..70d7428
--- /dev/null
+++ b/Broadcastify-Calls-Output.md
@@ -0,0 +1,191 @@
+# Broadcastify Calls Output
+
+## Overview
+
+The Broadcastify Calls output type uploads individual radio transmissions ("calls") to the [Broadcastify Calls](https://www.broadcastify.com/calls/) platform. Unlike the Icecast output which streams audio continuously, this output buffers audio during each transmission and uploads it as a discrete MP3 file after the transmission ends.
+
+This is designed for conventional radio systems where each channel monitors a single frequency.
+
+## Requirements
+
+### Build Dependencies
+
+- **libcurl** - used for HTTP communication with the Broadcastify Calls API
+
+On Debian/Ubuntu:
+```bash
+sudo apt install libcurl4-openssl-dev
+```
+
+### Build
+
+Enable the feature at cmake configure time:
+```bash
+cmake -DBROADCASTIFY_CALLS=ON ..
+make
+```
+
+The cmake summary will confirm:
+```
+- Broadcastify Calls: requested: ON, enabled: TRUE
+```
+
+### Broadcastify Calls Account
+
+You will need the following from your Broadcastify Calls system registration:
+
+| Item | Description |
+|------|-------------|
+| **API Key** | UUID issued per system (e.g. `6a1f044b-88c3-11fa-bd8b-12348ab9ccea`) |
+| **System ID** | Integer identifier for your system |
+| **Frequency Slot IDs (tg)** | One integer per monitored frequency, assigned by the Broadcastify administrator |
+
+## Configuration
+
+The `broadcastify_calls` output is added to a channel's `outputs` list, just like `icecast` or `file` outputs. It can be used alongside other output types on the same channel.
+
+### Required Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `type` | string | Must be `"broadcastify_calls"` |
+| `api_key` | string | Your Broadcastify Calls API key (UUID) |
+| `system_id` | integer | Your Broadcastify Calls system ID |
+| `tg` | integer | Frequency slot ID for this channel, assigned by Broadcastify |
+
+### Optional Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `min_call_duration` | float | `1.0` | Minimum transmission duration in seconds. Transmissions shorter than this are discarded. Useful for filtering squelch noise bursts. |
+| `max_call_duration` | integer | `120` | Maximum call duration in seconds before auto-splitting into a new call. Set to `0` to disable. |
+| `max_queue_depth` | integer | `25` | Maximum number of calls queued for upload. When exceeded, the oldest queued call is dropped. |
+| `use_dev_api` | boolean | `false` | Use the Broadcastify Calls development API endpoint instead of production. |
+| `test_mode` | boolean | `false` | Send `test=1` to the API for credential validation without uploading real calls. |
+
+### Limitations
+
+- **Multichannel mode only** - not supported on scan mode channels (which have multiple frequencies per channel).
+- **Not supported on mixers** - only on device channel outputs.
+
+### Example
+
+Combined with an Icecast stream on the same channel:
+
+```
+devices:
+({
+ type = "rtlsdr";
+ serial = "777755221";
+ gain = 25;
+ centerfreq = 123.5;
+ mode = "multichannel";
+ channels:
+ (
+ {
+ freq = 123.7;
+ outputs: (
+ {
+ type = "icecast";
+ server = "audio1.broadcastify.com";
+ port = 8080;
+ mountpoint = "feed1";
+ username = "source";
+ password = "mypassword";
+ },
+ {
+ type = "broadcastify_calls";
+ api_key = "6a1f044b-88c3-11fa-bd8b-12348ab9ccea";
+ system_id = 12345;
+ tg = 1001;
+ }
+ );
+ }
+ );
+});
+```
+
+Standalone (Broadcastify Calls only, no streaming):
+
+```
+{
+ freq = 124.1;
+ outputs: (
+ {
+ type = "broadcastify_calls";
+ api_key = "6a1f044b-88c3-11fa-bd8b-12348ab9ccea";
+ system_id = 12345;
+ tg = 1002;
+ min_call_duration = 0.5;
+ max_call_duration = 60;
+ }
+ );
+}
+```
+
+## How It Works
+
+### Call Detection
+
+The output uses RTLSDR-Airband's existing squelch system to detect transmission boundaries:
+
+1. When the squelch opens (signal detected), audio samples are buffered in memory.
+2. When the squelch closes (signal lost), the buffered audio is finalized as a complete call.
+3. If the call duration is shorter than `min_call_duration`, it is discarded.
+4. If a transmission exceeds `max_call_duration`, it is automatically split into separate calls.
+
+### Upload Process
+
+Completed calls are placed on an internal upload queue and processed by a dedicated upload thread, so the main audio processing is never blocked by network I/O.
+
+For each call, the upload thread:
+
+1. Encodes the buffered audio to MP3 (mono, 8000 Hz, 16 kbps CBR).
+2. Sends a multipart POST request to the Broadcastify Calls API with call metadata (API key, system ID, talkgroup, frequency, timestamp, duration).
+3. If the API returns a one-time upload URL, PUTs the MP3 audio to that URL.
+4. On transient failure (HTTP 5xx or network error), retries up to 3 times with exponential backoff.
+
+### Memory Usage
+
+Audio is buffered as raw float samples at 8000 Hz mono (~32 KB per second of audio). A typical 10-second transmission uses ~320 KB of memory. The upload queue depth limit (`max_queue_depth`) bounds total memory usage.
+
+No temporary files are written to disk.
+
+## Logging
+
+All Broadcastify Calls activity is logged through RTLSDR-Airband's standard logging (syslog with `-l`, stderr with `-e`).
+
+| Log Level | Message | Meaning |
+|-----------|---------|---------|
+| INFO | `call complete tg=... duration=...` | Transmission detected and queued for upload |
+| INFO | `uploaded call tg=...` | Call successfully uploaded |
+| INFO | `call skipped (duplicate)` | API reports another source already uploaded this call |
+| INFO | `POST tg=... -> HTTP ...: ...` | Raw API response for every upload attempt |
+| WARNING | `POST failed: ...` | Network/transport error |
+| WARNING | `API error: 1 ...` | Broadcastify API rejected the call (check credentials/config) |
+| WARNING | `failed to upload call ... after 3 attempts` | All retries exhausted |
+| WARNING | `upload queue full` | Queue depth exceeded, oldest call dropped |
+| ERROR | `lame_init failed` | MP3 encoder initialization failure |
+
+## Troubleshooting
+
+### `1 NO-SYSTEM-ID-SPECIFIED` or `1 INVALID-SYSTEM-ID`
+Check that `system_id` in your config matches the system ID assigned by Broadcastify.
+
+### `1 Invalid-API-Key` or `1 API-Key-Access-Denied`
+Verify your `api_key` is correct and that the API key is associated with the specified `system_id`.
+
+### `1 No-Freq-Table-Entry-For-Upload`
+The `tg` (frequency slot ID) is not configured in the Broadcastify system. Contact the Broadcastify administrator to add the frequency slot.
+
+### `1 Uploads-not-enabled-for-this-system`
+The Broadcastify Calls system is not enabled for uploads. Contact the Broadcastify administrator.
+
+### Calls uploading but audio sounds tinny
+Ensure your channel's `highpass` and `lowpass` settings are appropriate for the modulation type. The defaults (100 Hz highpass, 2500 Hz lowpass) are suitable for AM aviation voice.
+
+### Too many short calls being uploaded
+Increase `min_call_duration` to filter out squelch noise bursts. The default of 1.0 second works well for most scenarios.
+
+### Testing credentials
+Set `test_mode = true` and `use_dev_api = true` to validate your API credentials without uploading real calls. Check the [Broadcastify Calls dev status page](https://www.broadcastify.com/calls-dev/status/) for results.
diff --git a/config/broadcastify_calls.conf b/config/broadcastify_calls.conf
new file mode 100644
index 0000000..8c50179
--- /dev/null
+++ b/config/broadcastify_calls.conf
@@ -0,0 +1,78 @@
+# Example configuration file for Broadcastify Calls upload.
+#
+# Broadcastify Calls is a call-oriented audio platform that stores
+# individual radio transmissions. This output type buffers audio
+# during each transmission and uploads it as an MP3 file to the
+# Broadcastify Calls API after the transmission ends.
+#
+# Requirements:
+# - Build with: cmake -DBROADCASTIFY_CALLS=ON ..
+# - Requires libcurl (apt install libcurl4-openssl-dev)
+# - A registered Broadcastify Calls system with API key, system ID,
+# and frequency slot IDs (tg) assigned by the Broadcastify admin
+#
+# Limitations:
+# - Only supported on multichannel mode (not scan mode)
+# - Not supported on mixer outputs
+# - One tg (frequency slot ID) per channel
+#
+# The Broadcastify Calls output can be combined with other outputs
+# on the same channel (e.g. Icecast streaming).
+#
+# Refer to https://github.com/rtl-airband/RTLSDR-Airband/wiki
+# for description of keywords and config syntax.
+
+devices:
+({
+ type = "rtlsdr";
+ serial = "777755221";
+ gain = 25;
+ correction = 80;
+ centerfreq = 123.5;
+ mode = "multichannel";
+ channels:
+ (
+ # Channel with both Icecast streaming and Broadcastify Calls upload
+ {
+ freq = 123.7;
+ outputs: (
+ {
+ type = "icecast";
+ server = "audio1.broadcastify.com";
+ port = 8080;
+ mountpoint = "feed1";
+ username = "source";
+ password = "mypassword";
+ },
+ {
+ type = "broadcastify_calls";
+
+ # Required parameters:
+ api_key = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ system_id = 12345;
+ tg = 1001; # frequency slot ID assigned by Broadcastify
+
+ # Optional parameters (shown with defaults):
+ # min_call_duration = 1.0; # seconds - discard transmissions shorter than this
+ # max_call_duration = 120; # seconds - auto-split calls longer than this (0 = no limit)
+ # max_queue_depth = 25; # max calls queued for upload before dropping oldest
+ # use_dev_api = false; # use development API endpoint
+ # test_mode = false; # validate credentials without uploading
+ }
+ );
+ },
+
+ # Channel with only Broadcastify Calls upload (no streaming)
+ {
+ freq = 124.1;
+ outputs: (
+ {
+ type = "broadcastify_calls";
+ api_key = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
+ system_id = 12345;
+ tg = 1002;
+ }
+ );
+ }
+ );
+});
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 00707e9..0a5a31b 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -177,6 +177,21 @@ if(PULSEAUDIO)
endif()
endif()
+option(BROADCASTIFY_CALLS "Enable Broadcastify Calls upload support" OFF)
+set(WITH_BROADCASTIFY_CALLS FALSE)
+
+if(BROADCASTIFY_CALLS)
+ find_package(CURL)
+ if(CURL_FOUND)
+ list(APPEND rtl_airband_extra_sources broadcastify_calls.cpp)
+ list(APPEND rtl_airband_extra_libs ${CURL_LIBRARIES})
+ list(APPEND rtl_airband_include_dirs ${CURL_INCLUDE_DIRS})
+ set(WITH_BROADCASTIFY_CALLS TRUE)
+ else()
+ message(WARNING "CURL not found, Broadcastify Calls support disabled")
+ endif()
+endif()
+
if(PROFILING)
pkg_check_modules(PROFILING libprofiler)
if(PROFILING_FOUND)
@@ -261,6 +276,7 @@ message(STATUS " - Build Unit Tests:\t${BUILD_UNITTESTS}")
message(STATUS " - Broadcom VideoCore GPU:\t${WITH_BCM_VC}")
message(STATUS " - NFM support:\t\t${NFM}")
message(STATUS " - PulseAudio:\t\trequested: ${PULSEAUDIO}, enabled: ${WITH_PULSEAUDIO}")
+message(STATUS " - Broadcastify Calls:\trequested: ${BROADCASTIFY_CALLS}, enabled: ${WITH_BROADCASTIFY_CALLS}")
message(STATUS " - Profiling:\t\trequested: ${PROFILING}, enabled: ${WITH_PROFILING}")
message(STATUS " - Icecast TLS support:\t${LIBSHOUT_HAS_TLS}")
diff --git a/src/broadcastify_calls.cpp b/src/broadcastify_calls.cpp
new file mode 100644
index 0000000..06b4a1b
--- /dev/null
+++ b/src/broadcastify_calls.cpp
@@ -0,0 +1,572 @@
+/*
+ * broadcastify_calls.cpp
+ * Broadcastify Calls API upload support
+ *
+ * Copyright (c) 2026 RTLSDR-Airband contributors
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "rtl_airband.h"
+
+#define BCFY_CALLS_URL "https://api.broadcastify.com/call-upload"
+#define BCFY_CALLS_DEV_URL "https://api.broadcastify.com/call-upload-dev"
+
+#define BCFY_DEFAULT_MAX_QUEUE_DEPTH 25
+#define BCFY_MAX_DRAIN_ON_SHUTDOWN 50
+#define BCFY_DEFAULT_MAX_CALL_DURATION 120 // seconds
+#define BCFY_RETRY_COUNT 3
+#define BCFY_POST_TIMEOUT 30L
+#define BCFY_PUT_TIMEOUT 60L
+
+enum upload_result {
+ UPLOAD_SUCCESS,
+ UPLOAD_FAIL_PERMANENT,
+ UPLOAD_FAIL_TRANSIENT,
+};
+
+#define BCFY_MP3_OUT_SAMPLERATE 8000
+#define BCFY_MP3_BITRATE 16
+// Generous buffer: 1.25 * bitrate * duration + padding
+// For encoding, we allocate based on sample count
+#define BCFY_MP3_BUF_SIZE(nsamples) ((size_t)(1.25 * BCFY_MP3_BITRATE * 1000 / 8 * ((double)(nsamples) / WAVE_RATE) + 7200 + 1024))
+
+static std::queue upload_queue;
+static pthread_mutex_t queue_mutex = PTHREAD_MUTEX_INITIALIZER;
+static pthread_cond_t queue_cond = PTHREAD_COND_INITIALIZER;
+static pthread_t upload_thread_id;
+static bool upload_thread_running = false;
+
+struct curl_write_ctx {
+ char* buf;
+ size_t len;
+ size_t capacity;
+};
+
+static size_t bcfy_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) {
+ struct curl_write_ctx* ctx = (struct curl_write_ctx*)userdata;
+ size_t bytes = size * nmemb;
+ if (ctx->len + bytes >= ctx->capacity) {
+ size_t new_cap = ctx->capacity * 2 + bytes;
+ char* new_buf = (char*)realloc(ctx->buf, new_cap);
+ if (!new_buf) return 0;
+ ctx->buf = new_buf;
+ ctx->capacity = new_cap;
+ }
+ memcpy(ctx->buf + ctx->len, ptr, bytes);
+ ctx->len += bytes;
+ ctx->buf[ctx->len] = '\0';
+ return bytes;
+}
+
+struct curl_read_ctx {
+ const unsigned char* data;
+ size_t len;
+ size_t offset;
+};
+
+static size_t bcfy_read_cb(char* ptr, size_t size, size_t nmemb, void* userdata) {
+ struct curl_read_ctx* ctx = (struct curl_read_ctx*)userdata;
+ size_t remaining = ctx->len - ctx->offset;
+ size_t to_copy = size * nmemb;
+ if (to_copy > remaining) to_copy = remaining;
+ memcpy(ptr, ctx->data + ctx->offset, to_copy);
+ ctx->offset += to_copy;
+ return to_copy;
+}
+
+static unsigned char* encode_mp3(bcfy_call_record* rec, size_t* out_len) {
+ lame_t lame = lame_init();
+ if (!lame) {
+ log(LOG_ERR, "Broadcastify Calls: lame_init failed\n");
+ return NULL;
+ }
+
+ lame_set_in_samplerate(lame, rec->sample_rate);
+ lame_set_out_samplerate(lame, BCFY_MP3_OUT_SAMPLERATE);
+ lame_set_VBR(lame, vbr_off);
+ lame_set_brate(lame, BCFY_MP3_BITRATE);
+ lame_set_num_channels(lame, 1);
+ lame_set_mode(lame, MONO);
+ lame_set_quality(lame, 2);
+ lame_set_highpassfreq(lame, 100);
+ lame_set_lowpassfreq(lame, 2500);
+
+ if (lame_init_params(lame) < 0) {
+ log(LOG_ERR, "Broadcastify Calls: lame_init_params failed\n");
+ lame_close(lame);
+ return NULL;
+ }
+
+ size_t mp3_buf_size = BCFY_MP3_BUF_SIZE(rec->sample_count);
+ unsigned char* mp3_buf = (unsigned char*)malloc(mp3_buf_size);
+ if (!mp3_buf) {
+ log(LOG_ERR, "Broadcastify Calls: failed to allocate MP3 buffer (%zu bytes)\n", mp3_buf_size);
+ lame_close(lame);
+ return NULL;
+ }
+
+ int mp3_bytes = lame_encode_buffer_ieee_float(
+ lame, rec->samples, NULL, (int)rec->sample_count,
+ mp3_buf, (int)mp3_buf_size
+ );
+
+ if (mp3_bytes < 0) {
+ log(LOG_ERR, "Broadcastify Calls: lame_encode_buffer_ieee_float returned %d\n", mp3_bytes);
+ free(mp3_buf);
+ lame_close(lame);
+ return NULL;
+ }
+
+ int flush_bytes = lame_encode_flush(lame, mp3_buf + mp3_bytes, (int)(mp3_buf_size - mp3_bytes));
+ if (flush_bytes < 0) {
+ log(LOG_ERR, "Broadcastify Calls: lame_encode_flush returned %d\n", flush_bytes);
+ free(mp3_buf);
+ lame_close(lame);
+ return NULL;
+ }
+
+ *out_len = (size_t)(mp3_bytes + flush_bytes);
+ lame_close(lame);
+ return mp3_buf;
+}
+
+static enum upload_result do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3_len) {
+ 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();
+ if (!curl) {
+ log(LOG_ERR, "Broadcastify Calls: curl_easy_init failed\n");
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ // Build multipart form data (API uses aws-lambda-multipart-parser)
+ curl_mime* mime = curl_mime_init(curl);
+
+ char buf_system_id[16], buf_duration[16], buf_ts[32], buf_tg[16], buf_freq[32];
+ snprintf(buf_system_id, sizeof(buf_system_id), "%d", rec->system_id);
+ snprintf(buf_duration, sizeof(buf_duration), "%.1f", rec->duration);
+ snprintf(buf_ts, sizeof(buf_ts), "%ld", (long)rec->ts);
+ snprintf(buf_tg, sizeof(buf_tg), "%d", rec->tg);
+ snprintf(buf_freq, sizeof(buf_freq), "%d", rec->freq);
+
+ curl_mimepart* part;
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "apiKey");
+ curl_mime_data(part, rec->api_key, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "systemId");
+ curl_mime_data(part, buf_system_id, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "callDuration");
+ curl_mime_data(part, buf_duration, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "ts");
+ curl_mime_data(part, buf_ts, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "tg");
+ curl_mime_data(part, buf_tg, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "freq");
+ curl_mime_data(part, buf_freq, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "src");
+ curl_mime_data(part, "0", CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "enc");
+ curl_mime_data(part, "mp3", CURL_ZERO_TERMINATED);
+
+ if (rec->test_mode) {
+ part = curl_mime_addpart(mime);
+ curl_mime_name(part, "test");
+ curl_mime_data(part, "1", CURL_ZERO_TERMINATED);
+ }
+
+ struct curl_write_ctx response = { NULL, 0, 0 };
+ response.buf = (char*)malloc(512);
+ response.capacity = 512;
+ response.buf[0] = '\0';
+
+ curl_easy_setopt(curl, CURLOPT_URL, api_url);
+ curl_easy_setopt(curl, CURLOPT_MIMEPOST, mime);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, bcfy_write_cb);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, BCFY_POST_TIMEOUT);
+ curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
+
+ CURLcode res = curl_easy_perform(curl);
+ long http_code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+ curl_mime_free(mime);
+ curl_easy_cleanup(curl);
+
+ log(LOG_INFO, "Broadcastify Calls: POST tg=%d freq=%d ts=%ld duration=%.1f -> HTTP %ld: %s\n",
+ rec->tg, rec->freq, (long)rec->ts, rec->duration, http_code,
+ (res == CURLE_OK && response.buf) ? response.buf : curl_easy_strerror(res));
+
+ if (res != CURLE_OK) {
+ log(LOG_WARNING, "Broadcastify Calls: POST failed: %s\n", curl_easy_strerror(res));
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ if (http_code >= 400 && http_code < 500) {
+ log(LOG_WARNING, "Broadcastify Calls: POST returned HTTP %ld: %s\n", http_code, response.buf);
+ free(response.buf);
+ return UPLOAD_FAIL_PERMANENT;
+ }
+
+ if (http_code >= 500) {
+ log(LOG_WARNING, "Broadcastify Calls: POST returned HTTP %ld (server error)\n", http_code);
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ // Parse response: "0 " or "1 SKIPPED..." or " "
+ if (response.len < 2) {
+ log(LOG_WARNING, "Broadcastify Calls: empty response from API\n");
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ if (response.buf[0] == '1' && response.buf[1] == ' ') {
+ const char* msg = response.buf + 2;
+ if (strstr(msg, "SKIPPED---ALREADY-RECEIVED-THIS-CALL") != NULL) {
+ 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 UPLOAD_SUCCESS; // not an error, another source already uploaded this call
+ }
+ // All other "1 ..." responses are permanent API errors, don't retry
+ log(LOG_WARNING, "Broadcastify Calls: API error: %s\n", response.buf);
+ free(response.buf);
+ return UPLOAD_FAIL_PERMANENT;
+ }
+
+ if (response.buf[0] != '0' || response.buf[1] != ' ') {
+ log(LOG_WARNING, "Broadcastify Calls: unexpected API response: %s\n", response.buf);
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ // Extract upload URL (everything after "0 ")
+ char* upload_url = response.buf + 2;
+ // Trim trailing whitespace
+ size_t url_len = strlen(upload_url);
+ while (url_len > 0 && (upload_url[url_len - 1] == '\n' || upload_url[url_len - 1] == '\r' || upload_url[url_len - 1] == ' ')) {
+ upload_url[--url_len] = '\0';
+ }
+
+ if (url_len == 0) {
+ log(LOG_WARNING, "Broadcastify Calls: empty upload URL in response\n");
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ // Step 2: PUT audio to the one-time upload URL
+ curl = curl_easy_init();
+ if (!curl) {
+ log(LOG_ERR, "Broadcastify Calls: curl_easy_init failed for PUT\n");
+ free(response.buf);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ struct curl_read_ctx read_ctx = { mp3_data, mp3_len, 0 };
+
+ struct curl_slist* headers = NULL;
+ headers = curl_slist_append(headers, "Content-Type: audio/mpeg");
+
+ curl_easy_setopt(curl, CURLOPT_URL, upload_url);
+ curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
+ curl_easy_setopt(curl, CURLOPT_READFUNCTION, bcfy_read_cb);
+ curl_easy_setopt(curl, CURLOPT_READDATA, &read_ctx);
+ curl_easy_setopt(curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)mp3_len);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, BCFY_PUT_TIMEOUT);
+ curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
+
+ res = curl_easy_perform(curl);
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ free(response.buf);
+
+ if (res != CURLE_OK) {
+ log(LOG_WARNING, "Broadcastify Calls: PUT failed: %s\n", curl_easy_strerror(res));
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ if (http_code >= 400) {
+ log(LOG_WARNING, "Broadcastify Calls: PUT returned HTTP %ld\n", http_code);
+ return UPLOAD_FAIL_TRANSIENT;
+ }
+
+ log(LOG_INFO, "Broadcastify Calls: uploaded call tg=%d freq=%d duration=%.1fs\n",
+ rec->tg, rec->freq, rec->duration);
+ return UPLOAD_SUCCESS;
+}
+
+static void encode_and_upload(bcfy_call_record* rec) {
+ size_t mp3_len = 0;
+ unsigned char* mp3_data = encode_mp3(rec, &mp3_len);
+ if (!mp3_data) {
+ return;
+ }
+
+ enum upload_result result = UPLOAD_FAIL_TRANSIENT;
+ for (int attempt = 0; attempt < BCFY_RETRY_COUNT && result == UPLOAD_FAIL_TRANSIENT; attempt++) {
+ if (attempt > 0) {
+ int delay = 1 << attempt; // 2s, 4s
+ log(LOG_INFO, "Broadcastify Calls: retry %d/%d in %ds\n", attempt + 1, BCFY_RETRY_COUNT, delay);
+ sleep(delay);
+ }
+ result = do_upload(rec, mp3_data, mp3_len);
+ }
+
+ if (result == UPLOAD_FAIL_PERMANENT) {
+ log(LOG_WARNING, "Broadcastify Calls: permanent failure uploading call tg=%d freq=%d, not retrying\n",
+ rec->tg, rec->freq);
+ } else if (result == UPLOAD_FAIL_TRANSIENT) {
+ log(LOG_WARNING, "Broadcastify Calls: failed to upload call tg=%d freq=%d after %d attempts\n",
+ rec->tg, rec->freq, BCFY_RETRY_COUNT);
+ }
+
+ free(mp3_data);
+}
+
+static void free_record(bcfy_call_record* rec) {
+ if (rec) {
+ free(rec->samples);
+ free(rec);
+ }
+}
+
+static void* upload_thread_func(void*) {
+ while (true) {
+ bcfy_call_record* rec = NULL;
+
+ pthread_mutex_lock(&queue_mutex);
+ while (upload_queue.empty() && upload_thread_running) {
+ pthread_cond_wait(&queue_cond, &queue_mutex);
+ }
+
+ if (!upload_thread_running && upload_queue.empty()) {
+ pthread_mutex_unlock(&queue_mutex);
+ break;
+ }
+
+ rec = upload_queue.front();
+ upload_queue.pop();
+ pthread_mutex_unlock(&queue_mutex);
+
+ if (rec) {
+ encode_and_upload(rec);
+ free_record(rec);
+ }
+ }
+
+ // Drain remaining items on shutdown
+ int drained = 0;
+ pthread_mutex_lock(&queue_mutex);
+ if (!upload_queue.empty()) {
+ int remaining = (int)upload_queue.size();
+ log(LOG_INFO, "Broadcastify Calls: draining up to %d queued calls before shutdown\n",
+ remaining < BCFY_MAX_DRAIN_ON_SHUTDOWN ? remaining : BCFY_MAX_DRAIN_ON_SHUTDOWN);
+ }
+ while (!upload_queue.empty() && drained < BCFY_MAX_DRAIN_ON_SHUTDOWN) {
+ bcfy_call_record* rec = upload_queue.front();
+ upload_queue.pop();
+ pthread_mutex_unlock(&queue_mutex);
+
+ encode_and_upload(rec);
+ free_record(rec);
+ drained++;
+
+ pthread_mutex_lock(&queue_mutex);
+ }
+ // Discard any remaining if we hit the drain limit
+ while (!upload_queue.empty()) {
+ bcfy_call_record* rec = upload_queue.front();
+ upload_queue.pop();
+ free_record(rec);
+ }
+ pthread_mutex_unlock(&queue_mutex);
+
+ if (drained > 0) {
+ log(LOG_INFO, "Broadcastify Calls: drained %d calls on shutdown\n", drained);
+ }
+
+ return NULL;
+}
+
+static void enqueue_call(bcfy_call_record* rec, int max_queue_depth) {
+ pthread_mutex_lock(&queue_mutex);
+
+ // Drop oldest if queue is full
+ if ((int)upload_queue.size() >= max_queue_depth) {
+ bcfy_call_record* old = upload_queue.front();
+ upload_queue.pop();
+ log(LOG_WARNING, "Broadcastify Calls: upload queue full (%d), dropping oldest call (tg=%d)\n", max_queue_depth, old->tg);
+ free_record(old);
+ }
+
+ upload_queue.push(rec);
+ pthread_cond_signal(&queue_cond);
+ pthread_mutex_unlock(&queue_mutex);
+}
+
+static void finalize_call(bcfy_calls_data* bdata, output_t* output) {
+ struct timeval now;
+ gettimeofday(&now, NULL);
+
+ double duration = (double)(now.tv_sec - bdata->call_start.tv_sec) +
+ (double)(now.tv_usec - bdata->call_start.tv_usec) / 1000000.0;
+
+ if (duration < (double)bdata->min_call_duration) {
+ // Too short, discard
+ bdata->sample_buf_len = 0;
+ output->active = false;
+ return;
+ }
+
+ bcfy_call_record* rec = (bcfy_call_record*)calloc(1, sizeof(bcfy_call_record));
+ if (!rec) {
+ log(LOG_ERR, "Broadcastify Calls: failed to allocate call record\n");
+ bdata->sample_buf_len = 0;
+ output->active = false;
+ return;
+ }
+
+ // Transfer ownership of sample buffer to the record
+ rec->samples = bdata->sample_buf;
+ rec->sample_count = bdata->sample_buf_len;
+ rec->sample_rate = WAVE_RATE;
+ rec->duration = duration;
+ rec->ts = bdata->call_start.tv_sec;
+ rec->tg = bdata->tg;
+ rec->freq = bdata->call_freq;
+ rec->api_key = bdata->api_key;
+ rec->system_id = bdata->system_id;
+ rec->use_dev_api = bdata->use_dev_api;
+ rec->test_mode = bdata->test_mode;
+
+ // Allocate a fresh buffer for the next call
+ bdata->sample_buf = (float*)calloc(bdata->sample_buf_capacity, sizeof(float));
+ bdata->sample_buf_len = 0;
+
+ output->active = false;
+
+ log(LOG_INFO, "Broadcastify Calls: call complete tg=%d freq=%d duration=%.1fs samples=%zu\n",
+ rec->tg, rec->freq, rec->duration, rec->sample_count);
+
+ enqueue_call(rec, bdata->max_queue_depth);
+}
+
+void bcfy_calls_init(void) {
+ curl_global_init(CURL_GLOBAL_ALL);
+
+ upload_thread_running = true;
+ pthread_create(&upload_thread_id, NULL, upload_thread_func, NULL);
+
+ log(LOG_INFO, "Broadcastify Calls: upload thread started\n");
+}
+
+void bcfy_calls_shutdown(void) {
+ log(LOG_INFO, "Broadcastify Calls: shutting down upload thread\n");
+
+ pthread_mutex_lock(&queue_mutex);
+ upload_thread_running = false;
+ pthread_cond_signal(&queue_cond);
+ pthread_mutex_unlock(&queue_mutex);
+
+ pthread_join(upload_thread_id, NULL);
+
+ curl_global_cleanup();
+ log(LOG_INFO, "Broadcastify Calls: shutdown complete\n");
+}
+
+void bcfy_calls_process(channel_t* channel, output_t* output) {
+ bcfy_calls_data* bdata = (bcfy_calls_data*)output->data;
+
+ if (channel->axcindicate != NO_SIGNAL) {
+ // Signal present — accumulate samples
+ if (!output->active) {
+ // Transmission just started
+ gettimeofday(&bdata->call_start, NULL);
+ bdata->call_freq = channel->freqlist[channel->freq_idx].frequency;
+ bdata->sample_buf_len = 0;
+ output->active = true;
+ }
+
+ // Grow buffer if needed
+ size_t needed = bdata->sample_buf_len + WAVE_BATCH;
+ if (needed > bdata->sample_buf_capacity) {
+ size_t new_cap = bdata->sample_buf_capacity * 2;
+ if (new_cap < needed) new_cap = needed;
+ float* new_buf = (float*)realloc(bdata->sample_buf, new_cap * sizeof(float));
+ if (!new_buf) {
+ log(LOG_ERR, "Broadcastify Calls: failed to grow sample buffer\n");
+ return;
+ }
+ bdata->sample_buf = new_buf;
+ bdata->sample_buf_capacity = new_cap;
+ }
+
+ memcpy(bdata->sample_buf + bdata->sample_buf_len, channel->waveout, WAVE_BATCH * sizeof(float));
+ bdata->sample_buf_len += WAVE_BATCH;
+
+ // Auto-split at max call duration
+ size_t max_samples = (size_t)WAVE_RATE * (size_t)bdata->max_call_duration;
+ if (bdata->max_call_duration > 0 && bdata->sample_buf_len >= max_samples) {
+ log(LOG_INFO, "Broadcastify Calls: max call duration reached, auto-splitting\n");
+ finalize_call(bdata, output);
+ // Start new call immediately since signal is still present
+ gettimeofday(&bdata->call_start, NULL);
+ bdata->call_freq = channel->freqlist[channel->freq_idx].frequency;
+ output->active = true;
+ }
+ } else if (output->active) {
+ // Signal just ended — finalize the call
+ finalize_call(bdata, output);
+ }
+}
+
+void bcfy_calls_disable(bcfy_calls_data* bdata) {
+ if (bdata) {
+ free(bdata->sample_buf);
+ bdata->sample_buf = NULL;
+ bdata->sample_buf_len = 0;
+ bdata->sample_buf_capacity = 0;
+ }
+}
diff --git a/src/broadcastify_calls.h b/src/broadcastify_calls.h
new file mode 100644
index 0000000..928382b
--- /dev/null
+++ b/src/broadcastify_calls.h
@@ -0,0 +1,69 @@
+/*
+ * broadcastify_calls.h
+ * Broadcastify Calls API upload support
+ *
+ * Copyright (c) 2026 RTLSDR-Airband contributors
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+#ifndef _BROADCASTIFY_CALLS_H
+#define _BROADCASTIFY_CALLS_H
+
+#include
+#include
+
+struct channel_t;
+struct output_t;
+
+struct bcfy_calls_data {
+ const char* api_key;
+ int system_id;
+ int tg;
+ float min_call_duration;
+ int max_call_duration; // seconds, 0 = no limit
+ int max_queue_depth;
+ bool use_dev_api;
+ bool test_mode;
+
+ // sample buffer for current call (owned, output thread only)
+ float* sample_buf;
+ size_t sample_buf_len;
+ size_t sample_buf_capacity;
+
+ // current call state
+ struct timeval call_start;
+ int call_freq;
+};
+
+struct bcfy_call_record {
+ float* samples; // owned sample buffer
+ size_t sample_count;
+ int sample_rate;
+ double duration;
+ time_t ts; // call start unix epoch
+ int tg;
+ int freq; // frequency in Hz
+ const char* api_key; // not owned, points to config
+ int system_id;
+ bool use_dev_api;
+ bool test_mode;
+};
+
+void bcfy_calls_init(void);
+void bcfy_calls_shutdown(void);
+void bcfy_calls_process(channel_t* channel, output_t* output);
+void bcfy_calls_disable(bcfy_calls_data* bdata);
+
+#endif /* _BROADCASTIFY_CALLS_H */
diff --git a/src/config.cpp b/src/config.cpp
index 7acdf6f..f9a438c 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -252,6 +252,45 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in
pdata->stream_name = strdup(buf);
}
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+ } else if (!strncmp(outs[o]["type"], "broadcastify_calls", 18)) {
+ if (parsing_mixers) {
+ cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: broadcastify_calls outputs are not supported for mixers\n";
+ error();
+ }
+ if (channel->freq_count > 1) {
+ cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: broadcastify_calls is not supported for scan mode channels\n";
+ error();
+ }
+ channel->outputs[oo].data = XCALLOC(1, sizeof(struct bcfy_calls_data));
+ channel->outputs[oo].type = O_BCFY_CALLS;
+
+ bcfy_calls_data* bdata = (bcfy_calls_data*)(channel->outputs[oo].data);
+
+ if (!outs[o].exists("api_key") || !outs[o].exists("system_id") || !outs[o].exists("tg")) {
+ cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: broadcastify_calls requires api_key, system_id, and tg\n";
+ error();
+ }
+
+ bdata->api_key = strdup(outs[o]["api_key"]);
+ bdata->system_id = (int)outs[o]["system_id"];
+ bdata->tg = (int)outs[o]["tg"];
+ bdata->min_call_duration = outs[o].exists("min_call_duration") ? (float)outs[o]["min_call_duration"] : 1.0f;
+ bdata->max_call_duration = outs[o].exists("max_call_duration") ? (int)outs[o]["max_call_duration"] : 120;
+ bdata->max_queue_depth = outs[o].exists("max_queue_depth") ? (int)outs[o]["max_queue_depth"] : 25;
+ bdata->use_dev_api = outs[o].exists("use_dev_api") ? (bool)outs[o]["use_dev_api"] : false;
+ bdata->test_mode = outs[o].exists("test_mode") ? (bool)outs[o]["test_mode"] : false;
+
+ bdata->sample_buf = NULL;
+ bdata->sample_buf_len = 0;
+ bdata->sample_buf_capacity = 0;
+
+ channel->outputs[oo].has_mp3_output = false;
+#else
+ } else if (!strncmp(outs[o]["type"], "broadcastify_calls", 18)) {
+ cerr << "Warning: broadcastify_calls output configured but not compiled in (requires -DBROADCASTIFY_CALLS=ON)\n";
+ continue;
+#endif /* WITH_BROADCASTIFY_CALLS */
} else {
if (parsing_mixers) {
cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: ";
diff --git a/src/config.h.in b/src/config.h.in
index 6f441f0..1b63746 100644
--- a/src/config.h.in
+++ b/src/config.h.in
@@ -27,6 +27,7 @@
#cmakedefine WITH_PULSEAUDIO
#cmakedefine NFM
#cmakedefine WITH_BCM_VC
+#cmakedefine WITH_BROADCASTIFY_CALLS
#cmakedefine LIBSHOUT_HAS_TLS
#cmakedefine LIBSHOUT_HAS_CONTENT_FORMAT
#define SINCOSF @SINCOSF@
diff --git a/src/output.cpp b/src/output.cpp
index 3f7e3a7..2facbff 100644
--- a/src/output.cpp
+++ b/src/output.cpp
@@ -583,6 +583,10 @@ void process_outputs(channel_t* channel, int cur_scan_freq) {
pulse_write_stream(pdata, channel->mode, channel->waveout, channel->waveout_r, (size_t)WAVE_BATCH * sizeof(float));
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+ } else if (channel->outputs[k].type == O_BCFY_CALLS) {
+ bcfy_calls_process(channel, &channel->outputs[k]);
+#endif /* WITH_BROADCASTIFY_CALLS */
}
}
}
@@ -612,6 +616,10 @@ void disable_channel_outputs(channel_t* channel) {
pulse_data* pdata = (pulse_data*)(output->data);
pulse_shutdown(pdata);
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+ } else if (output->type == O_BCFY_CALLS) {
+ bcfy_calls_disable((bcfy_calls_data*)(output->data));
+#endif /* WITH_BROADCASTIFY_CALLS */
}
}
}
diff --git a/src/rtl_airband.cpp b/src/rtl_airband.cpp
index 31f70af..20e2121 100644
--- a/src/rtl_airband.cpp
+++ b/src/rtl_airband.cpp
@@ -282,6 +282,13 @@ bool init_output(channel_t* channel, output_t* output) {
pulse_init();
pulse_setup((pulse_data*)(output->data), channel->mode);
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+ } else if (output->type == O_BCFY_CALLS) {
+ bcfy_calls_data* bdata = (bcfy_calls_data*)(output->data);
+ bdata->sample_buf_capacity = WAVE_RATE;
+ bdata->sample_buf = (float*)XCALLOC(bdata->sample_buf_capacity, sizeof(float));
+ bdata->sample_buf_len = 0;
+#endif /* WITH_BROADCASTIFY_CALLS */
}
return true;
@@ -869,6 +876,9 @@ int main(int argc, char* argv[]) {
devices = (device_t*)XCALLOC(device_count, sizeof(device_t));
shout_init();
+#ifdef WITH_BROADCASTIFY_CALLS
+ bcfy_calls_init();
+#endif /* WITH_BROADCASTIFY_CALLS */
if (do_syslog) {
openlog("rtl_airband", LOG_PID, LOG_DAEMON);
@@ -1138,6 +1148,10 @@ int main(int argc, char* argv[]) {
pthread_join(output_threads[i], NULL);
}
+#ifdef WITH_BROADCASTIFY_CALLS
+ bcfy_calls_shutdown();
+#endif /* WITH_BROADCASTIFY_CALLS */
+
for (int i = 0; i < device_count; i++) {
device_t* dev = devices + i;
for (int j = 0; j < dev->channel_count; j++) {
diff --git a/src/rtl_airband.h b/src/rtl_airband.h
index 4563e34..2beadc8 100644
--- a/src/rtl_airband.h
+++ b/src/rtl_airband.h
@@ -44,6 +44,10 @@
#include
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+#include "broadcastify_calls.h"
+#endif /* WITH_BROADCASTIFY_CALLS */
+
#include "filters.h"
#include "input-common.h" // input_t
#include "logging.h"
@@ -111,6 +115,10 @@ enum output_type {
,
O_PULSE
#endif /* WITH_PULSEAUDIO */
+#ifdef WITH_BROADCASTIFY_CALLS
+ ,
+ O_BCFY_CALLS
+#endif /* WITH_BROADCASTIFY_CALLS */
};
struct icecast_data {