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
101 changes: 67 additions & 34 deletions airupnp/src/airupnp.c
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ tMRConfig glMRConfig = {
"flac", // Codec
true, // Metadata
"", // RTP:HTTP Latency (0 = use AirPlay requested)
false, // drift
true, // drift

@philippe44 philippe44 May 3, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

No, it is false by default and user can change it to true with -r. Such change would break compatibility with existing versions

{0, 0, 0, 0, 0, 0 }, // MAC
"", // artwork
};
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -277,14 +284,39 @@ 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: {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I'm not 100% that RAOP_STREAM is not sent multiple times in some cases. Have you verified that?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Looking at it again, RECORD can arrive without a prior TEARDOWN in some edge cases. I'll add a guard so setURI only fires if the device isn't already in RAOP_STREAM state.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Be really careful with these as there are many funky cases

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
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);
Expand All @@ -303,26 +335,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;
Expand All @@ -335,7 +349,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
Expand Down Expand Up @@ -373,6 +387,7 @@ void HandleRAOP(void *owner, raopsr_event_t event, ...) {
break;
}

va_end(args);
pthread_mutex_unlock(&Device->Mutex);
}

Expand Down Expand Up @@ -440,7 +455,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);
Expand Down Expand Up @@ -933,6 +948,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;
Expand Down Expand Up @@ -973,25 +989,42 @@ 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;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Are you totally sure of that. AFAIR, old Sonos devices fail with chunked on flac

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Tested on a Play:1 (S1 hardware) and chunked FLAC works fine. swyh-rs and your LMS-uPnP Sonos profile both default to chunked for FLAC/WAV too. The known problem is chunked MP3 specifically. But I've only tested on my own hardware so if you've seen it break on other models I trust your experience over mine.

@philippe44 philippe44 May 3, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Having said that, I can't overwrite user's decision on HTTP length so I can't force it to -3 no matter what. If user wants flac, then he can add chunked.

// Sonos buffers a fixed number of BYTES before playback starts; WAV's higher

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

No you can't do that either, that would mean that user's config is overwritten, event if not silently. It is up to users to decide to use wav on their Sonos if they want lower latencies.

// 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";
}
// populate audio format metadata so DIDL includes sampleFrequency/bitsPerSample

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I'm not sure this is needed except for raw pcm

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;

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";
// set protocolinfo — flags modeled after swyh-rs for streaming compatibility

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This one is a very touchy topic. I had soooo many compatibility issues with protocolinfo and various players that I can't change that.

// 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=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_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;

if (!memcmp(Device->Config.mac, "\0\0\0\0\0\0", 6)) {
char ip[32];
Expand Down
1 change: 1 addition & 0 deletions airupnp/src/airupnp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions airupnp/src/mr_util.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down