From 55d79d25be1ed46c62370a670b24a65f5bb23c5e Mon Sep 17 00:00:00 2001 From: Andres Date: Sun, 3 May 2026 21:21:06 +0200 Subject: [PATCH 1/4] Fix bugs and optimize latency for Sonos/UPnP devices Bug fixes: - Add missing va_end(args) in HandleRAOP (all paths) - Heap-allocate ProtocolInfo (was string literal, leaked on strdup path) - Free ProtocolInfo in DelMRDevice - Set Device->Running after ProtocolInfo assignment (prevent NULL deref) - Initialize TransitionPoll to 0 in AddMRDevice Sonos-specific optimizations: - Pre-stage SetAVTransportURI at STREAM time (was deferred to PLAY) - Auto-set HTTPLength=-3 (chunked) for Sonos devices only - Set duration to 24h instead of 1ms (prevents premature stop) - Enable drift correction by default General improvements: - Fast polling (50ms x 8 ticks) after state transitions - Reduce volume echo suppression from 1000ms to 500ms - Dynamic sample rate in PCM ProtocolInfo --- airupnp/src/airupnp.c | 83 ++++++++++++++++++++++++++----------------- airupnp/src/airupnp.h | 1 + airupnp/src/mr_util.c | 2 ++ 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/airupnp/src/airupnp.c b/airupnp/src/airupnp.c index 5ad90fc..3a6283b 100644 --- a/airupnp/src/airupnp.c +++ b/airupnp/src/airupnp.c @@ -75,7 +75,7 @@ tMRConfig glMRConfig = { "flac", // Codec true, // Metadata "", // RTP:HTTP Latency (0 = use AirPlay requested) - false, // drift + true, // drift {0, 0, 0, 0, 0, 0 }, // MAC "", // artwork }; @@ -221,6 +221,8 @@ static bool _ProcessQueue(struct sMR *Device); #define STATE_POLL (500) #define MAX_ACTION_ERRORS (5) #define MIN_POLL (min(TRACK_POLL, STATE_POLL)) +#define FAST_POLL (50) +#define FAST_POLL_COUNT (8) static void *MRThread(void *args) { int elapsed, wakeTimer = MIN_POLL; unsigned last; @@ -234,7 +236,12 @@ static void *MRThread(void *args) { // context is valid as long as thread runs pthread_mutex_lock(&p->Mutex); - wakeTimer = (p->State != STOPPED) ? MIN_POLL / 2: MIN_POLL * 10; + if (p->TransitionPoll) { + wakeTimer = FAST_POLL; + p->TransitionPoll--; + } else { + wakeTimer = (p->State != STOPPED) ? MIN_POLL / 2 : MIN_POLL * 10; + } LOG_SDEBUG("[%p]: UPnP thread timer %d %d", p, elapsed, wakeTimer); p->StatePoll += elapsed; @@ -277,14 +284,35 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) { va_start(args, event); // this is async, so need to check context validity - if (!CheckAndLock(owner)) return; + if (!CheckAndLock(owner)) { + va_end(args); + return; + } switch (event) { - case RAOP_STREAM: - // a PLAY will come later, so we'll do the load at that time + case RAOP_STREAM: { LOG_INFO("[%p]: Stream", Device); Device->RaopState = event; + + // pre-stage the URI so Sonos can pre-connect before PLAY arrives + uint16_t port = va_arg(args, uint32_t); + char *uri, *mp3radio = ""; + static int stream_count; + + if ((strcasestr(Device->Config.Codec, "mp3") || strcasestr(Device->Config.Codec, "aac")) && *Device->Service[TOPOLOGY_IDX].ControlURL) { + mp3radio = "x-rincon-mp3radio://"; + LOG_INFO("[%p]: Sonos live stream", Device); + } + + char codec[16] = "flac"; + (void) !sscanf(Device->Config.Codec, "%15[^:]", codec); + (void) !asprintf(&uri, "%shttp://%s:%u/stream-%u.%s", mp3radio, inet_ntoa(glHost), port, stream_count++, codec); + + LOG_INFO("[%p]: uPNP pre-stage setURI %s", Device, uri); + AVTSetURI(Device, uri, &Device->MetaData, Device->ProtocolInfo); + free(uri); break; + } case RAOP_STOP: // this is TEARDOWN, so far there is always a FLUSH before LOG_INFO("[%p]: Stop", Device); @@ -303,26 +331,8 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) { } break; case RAOP_PLAY: { - if (Device->RaopState != RAOP_PLAY) { - uint16_t port = va_arg(args, uint32_t); - char* uri, * mp3radio = ""; - static int count; - - if ((strcasestr(Device->Config.Codec, "mp3") || strcasestr(Device->Config.Codec, "aac")) && *Device->Service[TOPOLOGY_IDX].ControlURL) { - mp3radio = "x-rincon-mp3radio://"; - LOG_INFO("[%p]: Sonos live stream", Device); - } - - char codec[16] = "flac"; - (void) !sscanf(Device->Config.Codec, "%15[^:]", codec); - (void) !asprintf(&uri, "%shttp://%s:%u/stream-%u.%s", mp3radio, inet_ntoa(glHost), port, count++, codec); - - LOG_INFO("[%p]: uPNP setURI %s (cookie %p)", Device, uri, Device->seqN); - AVTSetURI(Device, uri, &Device->MetaData, Device->ProtocolInfo); - free(uri); - } - AVTPlay(Device); + Device->TransitionPoll = FAST_POLL_COUNT; // don't set volume, a RAOP_VOLUME will be sent by the controller Device->RaopState = event; @@ -335,7 +345,7 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) { uint32_t now = gettime_ms(); // discard echo commands - if (now < Device->VolumeStampRx + 1000) break; + if (now < Device->VolumeStampRx + 500) break; Device->VolumeStampTx = now; // Sonos group volume API is unreliable, need to create our own @@ -373,6 +383,7 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) { break; } + va_end(args); pthread_mutex_unlock(&Device->Mutex); } @@ -440,7 +451,7 @@ static void ProcessEvent(Upnp_EventType EventType, const void *_Event, void *Coo double Volume = atoi(r), GroupVolume; uint32_t now = gettime_ms(); - if (Volume != (int) Device->Volume && now > Master->VolumeStampTx + 1000) { + if (Volume != (int) Device->Volume && now > Master->VolumeStampTx + 500) { Device->Volume = Volume; Master->VolumeStampRx = now; GroupVolume = CalcGroupVolume(Master); @@ -933,6 +944,7 @@ static bool AddMRDevice(struct sMR *Device, char *UDN, IXML_Document *DescDoc, c Device->Elapsed = 0; Device->seqN = NULL; Device->TrackPoll = Device->StatePoll = 0; + Device->TransitionPoll = 0; Device->Volume = 0; Device->Actions = NULL; Device->Master = NULL; @@ -973,25 +985,30 @@ static bool AddMRDevice(struct sMR *Device, char *UDN, IXML_Document *DescDoc, c // set remaining items now that we are sure if (*Device->Service[TOPOLOGY_IDX].ControlURL) { - Device->MetaData.duration = 1; + Device->MetaData.duration = 86400000; Device->MetaData.title = "Streaming from AirConnect"; + // Sonos supports chunked transfer encoding for FLAC/WAV + if (!Device->Config.HTTPLength || Device->Config.HTTPLength == -1) Device->Config.HTTPLength = -3; } else { Device->MetaData.remote_title = "Streaming from AirConnect"; } if (*Device->Config.ArtWork) Device->MetaData.artwork = Device->Config.ArtWork; - Device->Running = true; // string is already zero-terminated if (friendlyName) strncpy(Device->friendlyName, friendlyName, sizeof(Device->friendlyName) - 1); if (!*Device->Config.Name) sprintf(Device->Config.Name, glNameFormat, friendlyName); queue_init(&Device->ActionQueue, false, NULL); // set protocolinfo (will be used for some HTTP response) - if (strcasestr(Device->Config.Codec, "pcm")) Device->ProtocolInfo = "http-get:*:audio/L16;rate=44100;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"; - else if (strcasestr(Device->Config.Codec, "wav")) Device->ProtocolInfo = "http-get:*:audio/wav:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"; - else if (strcasestr(Device->Config.Codec, "aac")) Device->ProtocolInfo = "http-get:*:audio/aac:DLNA.ORG_PN=AAC_ADTS;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"; - else if (strcasestr(Device->Config.Codec, "mp3")) Device->ProtocolInfo = "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"; - else Device->ProtocolInfo = "http-get:*:audio/flac:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"; + if (strcasestr(Device->Config.Codec, "pcm")) { + (void) !asprintf(&Device->ProtocolInfo, "http-get:*:audio/L16;rate=%u;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", + Device->MetaData.sample_rate ? Device->MetaData.sample_rate : 44100); + } else if (strcasestr(Device->Config.Codec, "wav")) Device->ProtocolInfo = strdup("http-get:*:audio/wav:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); + else if (strcasestr(Device->Config.Codec, "aac")) Device->ProtocolInfo = strdup("http-get:*:audio/aac:DLNA.ORG_PN=AAC_ADTS;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); + else if (strcasestr(Device->Config.Codec, "mp3")) Device->ProtocolInfo = strdup("http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); + else Device->ProtocolInfo = strdup("http-get:*:audio/flac:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); + + Device->Running = true; if (!memcmp(Device->Config.mac, "\0\0\0\0\0\0", 6)) { char ip[32]; diff --git a/airupnp/src/airupnp.h b/airupnp/src/airupnp.h index e2c6006..bdee892 100644 --- a/airupnp/src/airupnp.h +++ b/airupnp/src/airupnp.h @@ -83,6 +83,7 @@ struct sMR { void *WaitCookie, *StartCookie; cross_queue_t ActionQueue; unsigned TrackPoll, StatePoll; + unsigned TransitionPoll; struct sService Service[NB_SRV]; struct sAction *Actions; struct sMR *Master; diff --git a/airupnp/src/mr_util.c b/airupnp/src/mr_util.c index 817aa64..c2d8be4 100644 --- a/airupnp/src/mr_util.c +++ b/airupnp/src/mr_util.c @@ -149,6 +149,8 @@ void DelMRDevice(struct sMR *p) { // kick-up all sleepers and join player's thread crossthreads_wake(); + free(p->ProtocolInfo); + p->ProtocolInfo = NULL; pthread_mutex_unlock(&p->Mutex); pthread_join(p->Thread, NULL); From 04d5ae460e5d90d67b347b89a4a1d9873bada3f6 Mon Sep 17 00:00:00 2001 From: Andres Date: Sun, 3 May 2026 21:31:09 +0200 Subject: [PATCH 2/4] Update DLNA ProtocolInfo flags and populate DIDL metadata Inspired by swyh-rs DLNA compatibility approach: - Add DLNA.ORG_PN profile names (FLAC, WAV, LPCM) to ProtocolInfo - Change DLNA.ORG_OP from 00 to 01 (byte-seek supported) - Update DLNA.ORG_FLAGS to 01700000 (streaming-transfer + background-transfer + connection-stalling + DLNA v1.5) - Populate sampleFrequency/bitsPerSample/channels in metadata so DIDL element includes audio format attributes for all codecs, not just PCM --- airupnp/src/airupnp.c | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/airupnp/src/airupnp.c b/airupnp/src/airupnp.c index 3a6283b..7e93fdf 100644 --- a/airupnp/src/airupnp.c +++ b/airupnp/src/airupnp.c @@ -992,6 +992,10 @@ static bool AddMRDevice(struct sMR *Device, char *UDN, IXML_Document *DescDoc, c } else { Device->MetaData.remote_title = "Streaming from AirConnect"; } + // populate audio format metadata so DIDL includes sampleFrequency/bitsPerSample + if (!Device->MetaData.sample_rate) Device->MetaData.sample_rate = 44100; + if (!Device->MetaData.sample_size) Device->MetaData.sample_size = 16; + if (!Device->MetaData.channels) Device->MetaData.channels = 2; if (*Device->Config.ArtWork) Device->MetaData.artwork = Device->Config.ArtWork; // string is already zero-terminated @@ -999,14 +1003,16 @@ static bool AddMRDevice(struct sMR *Device, char *UDN, IXML_Document *DescDoc, c if (!*Device->Config.Name) sprintf(Device->Config.Name, glNameFormat, friendlyName); queue_init(&Device->ActionQueue, false, NULL); - // set protocolinfo (will be used for some HTTP response) + // set protocolinfo — flags modeled after swyh-rs for streaming compatibility + // OP=01: byte-seek supported, CI=0: not transcoded + // FLAGS=01700000: streaming-transfer + background-transfer + connection-stalling + DLNA v1.5 if (strcasestr(Device->Config.Codec, "pcm")) { - (void) !asprintf(&Device->ProtocolInfo, "http-get:*:audio/L16;rate=%u;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", + (void) !asprintf(&Device->ProtocolInfo, "http-get:*:audio/L16;rate=%u;channels=2:DLNA.ORG_PN=LPCM;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000", Device->MetaData.sample_rate ? Device->MetaData.sample_rate : 44100); - } else if (strcasestr(Device->Config.Codec, "wav")) Device->ProtocolInfo = strdup("http-get:*:audio/wav:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); - else if (strcasestr(Device->Config.Codec, "aac")) Device->ProtocolInfo = strdup("http-get:*:audio/aac:DLNA.ORG_PN=AAC_ADTS;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); - else if (strcasestr(Device->Config.Codec, "mp3")) Device->ProtocolInfo = strdup("http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); - else Device->ProtocolInfo = strdup("http-get:*:audio/flac:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000"); + } else if (strcasestr(Device->Config.Codec, "wav")) Device->ProtocolInfo = strdup("http-get:*:audio/wav:DLNA.ORG_PN=WAV;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"); + else if (strcasestr(Device->Config.Codec, "aac")) Device->ProtocolInfo = strdup("http-get:*:audio/aac:DLNA.ORG_PN=AAC_ADTS;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"); + else if (strcasestr(Device->Config.Codec, "mp3")) Device->ProtocolInfo = strdup("http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"); + else Device->ProtocolInfo = strdup("http-get:*:audio/flac:DLNA.ORG_PN=FLAC;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000"); Device->Running = true; From 0695f88917ef6638d25d5d5e7f8aff30d4932301 Mon Sep 17 00:00:00 2001 From: Andres Date: Sun, 3 May 2026 22:16:53 +0200 Subject: [PATCH 3/4] Add guard against duplicate RAOP_STREAM events RECORD can arrive without a prior TEARDOWN in edge cases. Skip pre-staging setURI if the device is already in RAOP_STREAM state. --- airupnp/src/airupnp.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/airupnp/src/airupnp.c b/airupnp/src/airupnp.c index 7e93fdf..187a77d 100644 --- a/airupnp/src/airupnp.c +++ b/airupnp/src/airupnp.c @@ -292,6 +292,10 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) { switch (event) { case RAOP_STREAM: { LOG_INFO("[%p]: Stream", Device); + if (Device->RaopState == RAOP_STREAM) { + LOG_INFO("[%p]: already in RAOP_STREAM, skipping pre-stage", Device); + break; + } Device->RaopState = event; // pre-stage the URI so Sonos can pre-connect before PLAY arrives From eaa2ac3ab83e837109309151fbafe4b316c0b741 Mon Sep 17 00:00:00 2001 From: Andres Date: Sun, 3 May 2026 22:17:06 +0200 Subject: [PATCH 4/4] Auto-switch Sonos from FLAC to WAV for faster playback start Sonos buffers a fixed number of bytes before playback begins. FLAC's compression means fewer bytes per second, so the buffer takes 2-3x longer to fill compared to WAV. Switching to WAV on Sonos devices significantly reduces startup latency. Only applies when the default codec is flac and the device is detected as Sonos (via TOPOLOGY service). Users who override the codec in per-device config are unaffected. --- airupnp/src/airupnp.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/airupnp/src/airupnp.c b/airupnp/src/airupnp.c index 187a77d..bd233ab 100644 --- a/airupnp/src/airupnp.c +++ b/airupnp/src/airupnp.c @@ -993,6 +993,12 @@ static bool AddMRDevice(struct sMR *Device, char *UDN, IXML_Document *DescDoc, c Device->MetaData.title = "Streaming from AirConnect"; // Sonos supports chunked transfer encoding for FLAC/WAV if (!Device->Config.HTTPLength || Device->Config.HTTPLength == -1) Device->Config.HTTPLength = -3; + // Sonos buffers a fixed number of BYTES before playback starts; WAV's higher + // byte rate fills the buffer ~2-3x faster than FLAC, reducing startup latency + if (!strcasecmp(Device->Config.Codec, "flac")) { + LOG_INFO("[%p]: Sonos detected, switching codec from flac to wav for lower latency", Device); + strcpy(Device->Config.Codec, "wav"); + } } else { Device->MetaData.remote_title = "Streaming from AirConnect"; }