Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Version 4.0.0 (Oct 19, 2021):
is outside the band of the channel filter (thx @charlie-foxtrot).
* New output type `udp_stream` for sending uncompressed audio to another host
via UDP/IP (thx @charlie-foxtrot).
* New output type `srt` for streaming uncompressed audio over the SRT protocol.
* Added `multiple_output_threads` global option. When set to `true`, a separate
output thread is spawned for each device (thx @charlie-foxtrot).
* Modulation in scan mode is now configurable per channel (thx
Expand Down
68 changes: 68 additions & 0 deletions SRT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# SRT output

RTLSDR-Airband can send audio over the [SRT protocol](https://www.srtalliance.org/).
Building with SRT support requires the development files for **libsrt**.
When configuring with CMake leave the `-DSRT` option enabled (default)
and ensure `pkg-config` can locate the library. If libsrt is missing the
feature is disabled automatically.

The SRT output supports three audio formats controlled by the `format`
setting in the configuration:

- `pcm` (default) – raw 16‑bit signed PCM (standard format)
- `mp3` – encoded using libmp3lame
- `wav` – 16‑bit PCM with WAV header so players like VLC auto-detect the format

For low latency playback with ffplay:

```bash
# For mp3 or wav formats (auto-detected):
ffplay -fflags nobuffer -flags low_delay srt://<host>:<port>

# For pcm format (match the configured sample_rate, default 8kHz mono):
ffplay -fflags nobuffer -flags low_delay -f s16le -ar 8000 -ac 1 srt://<host>:<port>
```

## Configuration

```
outputs: (
{
type = "srt";
listen_address = "0.0.0.0";
listen_port = 8890;
format = "mp3"; # pcm|mp3|wav
mode = "live"; # live|raw (default: live)
sample_rate = 24000; # optional, default: native (8000 or 16000)
continuous = true; # optional, default false
}
);
```

`continuous` controls whether the stream pauses when the squelch is
closed. Set it to `true` if the receiving application does not handle
frequent reconnects well.

## SRT Mode

The `mode` setting controls SRT protocol behavior:

- `live` (default) – Standard SRT live mode with TSBPD (Timestamp-Based
Packet Delivery), packet drop, and NAK reports enabled. Compatible with
all SRT clients including gosrt, OBS, and other strict implementations.
Adds approximately 120ms latency.

- `raw` – Minimal latency mode with TSBPD disabled. Only works with lenient
clients like ffplay/ffmpeg. Use this if you need the absolute lowest
latency and only use ffplay for playback.

## Sample Rate

The `sample_rate` setting resamples audio output to the specified rate
using linear interpolation. This applies to `pcm` and `wav` formats only
(`mp3` uses its own encoding rate).

The default is the native rate (`8000` Hz, or `16000` Hz with NFM).
Set to `24000` for use with the OpenAI Realtime API which requires
24 kHz mono 16-bit PCM.

33 changes: 33 additions & 0 deletions config/srt_example.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Example configuration demonstrating SRT output
# Listens on 0.0.0.0:8890 for clients using the SRT protocol
# Clients can connect with:
# ffplay -ac 1 -ar 8000 -analyzeduration 0 -probesize 32 -f f32le srt://<host>:8890
# Refer to https://github.com/rtl-airband/RTLSDR-Airband/wiki for config syntax

devices:
(
{
type = "rtlsdr";
index = 0;
gain = 25;
centerfreq = 120.0;
correction = 80;
channels:
(
{
freq = 118.15;
outputs: (
{
type = "srt";
listen_address = "0.0.0.0";
listen_port = 8890;
# format can be "pcm" (default), "mp3" or "wav"
format = "mp3";
# stream continuously even when squelched
continuous = true;
}
);
}
);
}
);
15 changes: 15 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ set(WITH_SOAPYSDR FALSE)
option(PULSEAUDIO "Enable PulseAudio support" ON)
set(WITH_PULSEAUDIO FALSE)

option(SRT "Enable SRT output support" ON)
set(WITH_SRT FALSE)

option(PROFILING "Enable profiling with gperftools")
set(WITH_PROFILING FALSE)

Expand Down Expand Up @@ -177,6 +180,17 @@ if(PULSEAUDIO)
endif()
endif()

if(SRT)
pkg_check_modules(SRT srt)
if(SRT_FOUND)
list(APPEND rtl_airband_extra_sources srt_stream.cpp)
list(APPEND rtl_airband_extra_libs ${SRT_LIBRARIES})
list(APPEND rtl_airband_include_dirs ${SRT_INCLUDE_DIRS})
list(APPEND link_dirs ${SRT_LIBRARY_DIRS})
set(WITH_SRT TRUE)
endif()
endif()

if(PROFILING)
pkg_check_modules(PROFILING libprofiler)
if(PROFILING_FOUND)
Expand Down Expand Up @@ -261,6 +275,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 " - SRT output:\t\trequested: ${SRT}, enabled: ${WITH_SRT}")
message(STATUS " - Profiling:\t\trequested: ${PROFILING}, enabled: ${WITH_PROFILING}")
message(STATUS " - Icecast TLS support:\t${LIBSHOUT_HAS_TLS}")

Expand Down
97 changes: 97 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in
fdata->append = (!outs[o].exists("append")) || (bool)(outs[o]["append"]);
fdata->split_on_transmission = outs[o].exists("split_on_transmission") ? (bool)(outs[o]["split_on_transmission"]) : false;
fdata->include_freq = outs[o].exists("include_freq") ? (bool)(outs[o]["include_freq"]) : false;
if (fdata->split_on_transmission) {
fdata->min_rx_seconds = outs[o].exists("min_rx_seconds") ? (double)(outs[o]["min_rx_seconds"]) : 0.0;
if (outs[o].exists("post_write_script")) {
fdata->post_write_script = outs[o]["post_write_script"].c_str();
}
} else {
if (outs[o].exists("min_rx_seconds") || outs[o].exists("post_write_script")) {
cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: min_rx_seconds and post_write_script require split_on_transmission\n";
error();
}
}

channel->outputs[oo].has_mp3_output = true;

Expand Down Expand Up @@ -159,6 +170,8 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in
fdata->append = (!outs[o].exists("append")) || (bool)(outs[o]["append"]);
fdata->split_on_transmission = outs[o].exists("split_on_transmission") ? (bool)(outs[o]["split_on_transmission"]) : false;
fdata->include_freq = outs[o].exists("include_freq") ? (bool)(outs[o]["include_freq"]) : false;
fdata->min_rx_seconds = 0.0;
fdata->post_write_script.clear();
channel->needs_raw_iq = channel->has_iq_outputs = 1;

if (fdata->continuous && fdata->split_on_transmission) {
Expand Down Expand Up @@ -229,6 +242,90 @@ static int parse_outputs(libconfig::Setting& outs, channel_t* channel, int i, in
cerr << "missing dest_port\n";
error();
}
} else if (!strncmp(outs[o]["type"], "srt", 3)) {
channel->outputs[oo].data = XCALLOC(1, sizeof(struct srt_stream_data));
channel->outputs[oo].type = O_SRT;

srt_stream_data* sdata = (srt_stream_data*)channel->outputs[oo].data;

sdata->continuous = outs[o].exists("continuous") ? (bool)(outs[o]["continuous"]) : false;

if (outs[o].exists("listen_address")) {
sdata->listen_address = strdup(outs[o]["listen_address"]);
} else {
if (parsing_mixers) {
cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: ";
} else {
cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: ";
}
cerr << "missing listen_address\n";
error();
}

if (outs[o].exists("listen_port")) {
if (outs[o]["listen_port"].getType() == libconfig::Setting::TypeInt) {
char buffer[12];
sprintf(buffer, "%d", (int)outs[o]["listen_port"]);
sdata->listen_port = strdup(buffer);
} else {
sdata->listen_port = strdup(outs[o]["listen_port"]);
}
} else {
if (parsing_mixers) {
cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: ";
} else {
cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: ";
}
cerr << "missing listen_port\n";
error();
}

if (outs[o].exists("format")) {
const char* fmt = outs[o]["format"];
if (!strcmp(fmt, "mp3")) {
sdata->format = SRT_STREAM_MP3;
channel->outputs[oo].has_mp3_output = true;
} else if (!strcmp(fmt, "raw") || !strcmp(fmt, "pcm")) {
sdata->format = SRT_STREAM_PCM;
} else if (!strcmp(fmt, "wav")) {
sdata->format = SRT_STREAM_WAV;
} else {
if (parsing_mixers) {
cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: ";
} else {
cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: ";
}
cerr << "invalid SRT format, must be 'pcm', 'mp3' or 'wav'\n";
error();
}
} else {
sdata->format = SRT_STREAM_PCM;
}

if (outs[o].exists("sample_rate")) {
sdata->sample_rate = (int)outs[o]["sample_rate"];
} else {
sdata->sample_rate = WAVE_RATE;
}

if (outs[o].exists("mode")) {
const char* m = outs[o]["mode"];
if (!strcmp(m, "live")) {
sdata->srt_mode = SRT_MODE_LIVE;
} else if (!strcmp(m, "raw")) {
sdata->srt_mode = SRT_MODE_RAW;
} else {
if (parsing_mixers) {
cerr << "Configuration error: mixers.[" << i << "] outputs.[" << o << "]: ";
} else {
cerr << "Configuration error: devices.[" << i << "] channels.[" << j << "] outputs.[" << o << "]: ";
}
cerr << "invalid SRT mode, must be 'live' or 'raw'\n";
error();
}
} else {
sdata->srt_mode = SRT_MODE_LIVE;
}
#ifdef WITH_PULSEAUDIO
} else if (!strncmp(outs[o]["type"], "pulse", 5)) {
channel->outputs[oo].data = XCALLOC(1, sizeof(struct pulse_data));
Expand Down
1 change: 1 addition & 0 deletions src/config.h.in
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#cmakedefine WITH_SOAPYSDR
#cmakedefine WITH_PROFILING
#cmakedefine WITH_PULSEAUDIO
#cmakedefine WITH_SRT
#cmakedefine NFM
#cmakedefine WITH_BCM_VC
#cmakedefine LIBSHOUT_HAS_TLS
Expand Down
65 changes: 61 additions & 4 deletions src/output.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vorbis/vorbisenc.h>

Expand Down Expand Up @@ -157,6 +158,8 @@ lame_t airlame_init(mix_modes mixmode, int highpass, int lowpass) {
lame_set_quality(lame, 7);
lame_set_lowpassfreq(lame, lowpass);
lame_set_highpassfreq(lame, highpass);
/* Disable the bit reservoir to reduce encoder latency */
lame_set_disable_reservoir(lame, 1);
lame_set_out_samplerate(lame, MP3_RATE);
if (mixmode == MM_STEREO) {
lame_set_num_channels(lame, 2);
Expand Down Expand Up @@ -317,6 +320,8 @@ static void close_file(output_t* output) {
return;
}

double duration_sec = delta_sec(&fdata->open_time, &fdata->last_write_time);

// close all mp3 files for every output that has a lame context
if (fdata->type == O_FILE && fdata->f && output->lame) {
int encoded = lame_encode_flush_nogap(output->lame, output->lamebuf, LAMEBUF_SIZE);
Expand All @@ -337,7 +342,31 @@ static void close_file(output_t* output) {
if (fdata->f) {
fclose(fdata->f);
fdata->f = NULL;
rename_if_exists(fdata->file_path_tmp.c_str(), fdata->file_path.c_str());
bool keep = true;
if (fdata->split_on_transmission && fdata->min_rx_seconds > 0.0 && duration_sec < fdata->min_rx_seconds) {
keep = false;
}

if (keep) {
rename_if_exists(fdata->file_path_tmp.c_str(), fdata->file_path.c_str());
if (fdata->split_on_transmission && !fdata->post_write_script.empty()) {
pid_t pid = fork();
if (pid < 0) {
log(LOG_ERR, "Cannot fork for post_write_script: %s\n", strerror(errno));
} else if (pid == 0) {
pid_t pid2 = fork();
if (pid2 == 0) {
execl("/bin/sh", "sh", fdata->post_write_script.c_str(), fdata->file_path.c_str(), (char*)NULL);
_exit(1);
}
_exit(0);
} else {
waitpid(pid, NULL, 0);
}
}
} else {
unlink(fdata->file_path_tmp.c_str());
}
}
fdata->file_path.clear();
fdata->file_path_tmp.clear();
Expand Down Expand Up @@ -568,11 +597,30 @@ void process_outputs(channel_t* channel, int cur_scan_freq) {
if (sdata->continuous == false && channel->axcindicate == NO_SIGNAL) {
continue;
}
} else if (channel->outputs[k].type == O_SRT) {
srt_stream_data* sdata = (srt_stream_data*)channel->outputs[k].data;

if (channel->mode == MM_MONO) {
udp_stream_write(sdata, channel->waveout, (size_t)WAVE_BATCH * sizeof(float));
if (sdata->continuous == false && channel->axcindicate == NO_SIGNAL)
continue;

if (sdata->format == SRT_STREAM_MP3) {
const auto& lame = channel->outputs[k].lame;
const auto& lamebuf = channel->outputs[k].lamebuf;
int mp3_bytes = lame_encode_buffer_ieee_float(
lame, channel->waveout,
(channel->mode == MM_STEREO ? channel->waveout_r : NULL),
WAVE_BATCH, lamebuf, LAMEBUF_SIZE);
if (mp3_bytes < 0) {
log(LOG_WARNING, "lame_encode_buffer_ieee_float: %d\n", mp3_bytes);
} else if (mp3_bytes > 0) {
srt_stream_send_bytes(sdata, lamebuf, mp3_bytes);
}
} else {
udp_stream_write(sdata, channel->waveout, channel->waveout_r, (size_t)WAVE_BATCH * sizeof(float));
if (channel->mode == MM_MONO) {
srt_stream_write(sdata, channel->waveout, (size_t)WAVE_BATCH * sizeof(float));
} else {
srt_stream_write(sdata, channel->waveout, channel->waveout_r, (size_t)WAVE_BATCH * sizeof(float));
}
}

#ifdef WITH_PULSEAUDIO
Expand Down Expand Up @@ -607,6 +655,9 @@ void disable_channel_outputs(channel_t* channel) {
} else if (output->type == O_UDP_STREAM) {
udp_stream_data* sdata = (udp_stream_data*)output->data;
udp_stream_shutdown(sdata);
} else if (output->type == O_SRT) {
srt_stream_data* sdata = (srt_stream_data*)output->data;
srt_stream_shutdown(sdata);
#ifdef WITH_PULSEAUDIO
} else if (output->type == O_PULSE) {
pulse_data* pdata = (pulse_data*)(output->data);
Expand Down Expand Up @@ -989,6 +1040,12 @@ void* output_check_thread(void*) {
if (dev->input->state == INPUT_FAILED) {
udp_stream_shutdown(sdata);
}
} else if (dev->channels[j].outputs[k].type == O_SRT) {
srt_stream_data* sdata = (srt_stream_data*)dev->channels[j].outputs[k].data;

if (dev->input->state == INPUT_FAILED) {
srt_stream_shutdown(sdata);
}
#ifdef WITH_PULSEAUDIO
} else if (dev->channels[j].outputs[k].type == O_PULSE) {
pulse_data* pdata = (pulse_data*)(dev->channels[j].outputs[k].data);
Expand Down
Loading