From 1e72399eff216a6f79e358c2dd905aad41be2a9a Mon Sep 17 00:00:00 2001 From: lindsay Date: Sat, 28 Mar 2026 08:48:18 -0500 Subject: [PATCH 1/3] Add Broadcastify Calls output type 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) --- Broadcastify-Calls-Output.md | 191 +++++++++++ config/broadcastify_calls.conf | 78 +++++ src/CMakeLists.txt | 16 + src/broadcastify_calls.cpp | 558 +++++++++++++++++++++++++++++++++ src/broadcastify_calls.h | 69 ++++ src/config.cpp | 35 +++ src/config.h.in | 1 + src/output.cpp | 8 + src/rtl_airband.cpp | 14 + src/rtl_airband.h | 8 + 10 files changed, 978 insertions(+) create mode 100644 Broadcastify-Calls-Output.md create mode 100644 config/broadcastify_calls.conf create mode 100644 src/broadcastify_calls.cpp create mode 100644 src/broadcastify_calls.h 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..85c3eeb --- /dev/null +++ b/src/broadcastify_calls.cpp @@ -0,0 +1,558 @@ +/* + * 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 + +#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 bool 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 false; + } + + // 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 false; + } + + 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 false; // permanent error, don't retry + } + + 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 + } + + // 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 false; + } + + 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 true; // 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 true; + } + + if (response.buf[0] != '0' || response.buf[1] != ' ') { + log(LOG_WARNING, "Broadcastify Calls: unexpected API response: %s\n", response.buf); + free(response.buf); + return false; + } + + // 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 false; + } + + // 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 false; + } + + 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 false; + } + + if (http_code >= 400) { + log(LOG_WARNING, "Broadcastify Calls: PUT returned HTTP %ld\n", http_code); + return false; + } + + log(LOG_INFO, "Broadcastify Calls: uploaded call tg=%d freq=%d duration=%.1fs\n", + rec->tg, rec->freq, rec->duration); + return true; +} + +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; + } + + bool success = false; + for (int attempt = 0; attempt < BCFY_RETRY_COUNT && !success; 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); + } + success = do_upload(rec, mp3_data, mp3_len); + } + + if (!success) { + 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); + 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..a166f95 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -252,6 +252,41 @@ 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 = 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; + 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; +#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 { From ddc982e8a73af7c71826e3a358c9f98b66e8cb4b Mon Sep 17 00:00:00 2001 From: lindsay Date: Fri, 3 Apr 2026 15:29:58 -0500 Subject: [PATCH 2/3] Address PR review feedback for Broadcastify Calls output - 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) --- src/broadcastify_calls.cpp | 48 ++++++++++++++++++++++++-------------- src/config.cpp | 10 +++++--- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/broadcastify_calls.cpp b/src/broadcastify_calls.cpp index 85c3eeb..f50956f 100644 --- a/src/broadcastify_calls.cpp +++ b/src/broadcastify_calls.cpp @@ -41,6 +41,12 @@ #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 @@ -147,14 +153,14 @@ static unsigned char* encode_mp3(bcfy_call_record* rec, size_t* out_len) { return mp3_buf; } -static bool do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3_len) { +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 false; + return UPLOAD_FAIL_TRANSIENT; } // Build multipart form data (API uses aws-lambda-multipart-parser) @@ -232,26 +238,26 @@ static bool do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3 if (res != CURLE_OK) { log(LOG_WARNING, "Broadcastify Calls: POST failed: %s\n", curl_easy_strerror(res)); free(response.buf); - return false; + 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 false; // permanent error, don't retry + 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 false; // transient, caller will retry + 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 false; + return UPLOAD_FAIL_TRANSIENT; } if (response.buf[0] == '1' && response.buf[1] == ' ') { @@ -260,18 +266,18 @@ static bool do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3 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 + 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 true; + 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 false; + return UPLOAD_FAIL_TRANSIENT; } // Extract upload URL (everything after "0 ") @@ -293,7 +299,7 @@ static bool do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3 if (!curl) { log(LOG_ERR, "Broadcastify Calls: curl_easy_init failed for PUT\n"); free(response.buf); - return false; + return UPLOAD_FAIL_TRANSIENT; } struct curl_read_ctx read_ctx = { mp3_data, mp3_len, 0 }; @@ -318,17 +324,17 @@ static bool do_upload(bcfy_call_record* rec, unsigned char* mp3_data, size_t mp3 if (res != CURLE_OK) { log(LOG_WARNING, "Broadcastify Calls: PUT failed: %s\n", curl_easy_strerror(res)); - return false; + return UPLOAD_FAIL_TRANSIENT; } if (http_code >= 400) { log(LOG_WARNING, "Broadcastify Calls: PUT returned HTTP %ld\n", http_code); - return false; + 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 true; + return UPLOAD_SUCCESS; } static void encode_and_upload(bcfy_call_record* rec) { @@ -338,17 +344,20 @@ static void encode_and_upload(bcfy_call_record* rec) { return; } - bool success = false; - for (int attempt = 0; attempt < BCFY_RETRY_COUNT && !success; attempt++) { + 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); } - success = do_upload(rec, mp3_data, mp3_len); + result = do_upload(rec, mp3_data, mp3_len); } - if (!success) { + 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); } @@ -390,6 +399,11 @@ static void* upload_thread_func(void*) { // 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(); diff --git a/src/config.cpp b/src/config.cpp index a166f95..f9a438c 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -273,9 +273,9 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in } 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; + 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; @@ -286,6 +286,10 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in 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) { From eba2755d50c0aa1fc7ee6e4a19fb3221e5d4c1fb Mon Sep 17 00:00:00 2001 From: lindsay Date: Fri, 3 Apr 2026 16:07:03 -0500 Subject: [PATCH 3/3] Fix missed return type conversion in do_upload 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) --- src/broadcastify_calls.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/broadcastify_calls.cpp b/src/broadcastify_calls.cpp index f50956f..06b4a1b 100644 --- a/src/broadcastify_calls.cpp +++ b/src/broadcastify_calls.cpp @@ -291,7 +291,7 @@ static enum upload_result do_upload(bcfy_call_record* rec, unsigned char* mp3_da if (url_len == 0) { log(LOG_WARNING, "Broadcastify Calls: empty upload URL in response\n"); free(response.buf); - return false; + return UPLOAD_FAIL_TRANSIENT; } // Step 2: PUT audio to the one-time upload URL