Skip to content

Commit 960bfa5

Browse files
authored
Merge pull request #289 from tobsch/fix/sendspin-connect-format-align
WIP: align engine to client-negotiated format on Spotify Connect re-plays (fix noise)
2 parents e037775 + d775fb1 commit 960bfa5

1 file changed

Lines changed: 32 additions & 7 deletions

File tree

src/adapters/outputs/sendspin/sendspinOutput.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ export class SendspinOutput implements ZoneOutput {
167167
bitDepth: audioOutputSettings.pcmBitDepth,
168168
};
169169

170+
/**
171+
* Last format the *client* explicitly negotiated (via onFormatChanged).
172+
* `negotiatedFormat` gets reset to the stream default (often the 44.1 kHz engine
173+
* default) by onIdentified on every (re)connect, so reading it at play time can
174+
* report 44.1 kHz even though the client really wants e.g. 48 kHz/24-bit. The
175+
* engine then starts at 44.1 kHz and immediately restarts (reason=replace) when
176+
* the client renegotiates — that mid-stream restart races the source and can
177+
* leave a started-but-starved stream (audible dmix loop / noise). This value
178+
* survives reconnects so getPreferredOutput() advertises the real rate and the
179+
* engine starts aligned. See PR description.
180+
*/
181+
private lastClientNegotiatedFormat: SendspinFormat | null = null;
170182
/** Actual output format of the current ffmpeg pipeline. */
171183
private activeOutputFormat: SendspinFormat | null = null;
172184
private activeCodecHeader: string | null = null;
@@ -218,7 +230,11 @@ export class SendspinOutput implements ZoneOutput {
218230
this.clientConnected = true;
219231
this.clientState = null;
220232
this.externalSourceActive = false;
221-
this.negotiatedFormat = this.normalizeFormat(sendspinSession.getStreamFormat());
233+
// Reconnect (e.g. Connect churn on track change) seeds the stream default,
234+
// but if the client already negotiated a real format keep that — otherwise we
235+
// start the pipeline at 44.1k here and restart once onFormatChanged re-fires.
236+
this.negotiatedFormat =
237+
this.lastClientNegotiatedFormat ?? this.normalizeFormat(sendspinSession.getStreamFormat());
222238
if (!this.isOwner()) {
223239
// Avoid multiple zones fighting over the same Sendspin client.
224240
return;
@@ -269,6 +285,9 @@ export class SendspinOutput implements ZoneOutput {
269285
},
270286
onFormatChanged: (_session: SendspinSession, format: PlayerFormat) => {
271287
this.negotiatedFormat = this.normalizeFormat(format);
288+
// Remember the client's explicitly-requested format so it survives a later
289+
// onIdentified reset and getPreferredOutput() can advertise the real rate.
290+
this.lastClientNegotiatedFormat = this.negotiatedFormat;
272291
// Restart stream with the newly requested format.
273292
void this.startStream({ preserveAnchor: false, formatOverride: this.negotiatedFormat });
274293
},
@@ -418,17 +437,23 @@ export class SendspinOutput implements ZoneOutput {
418437
}
419438

420439
public getPreferredOutput(): PreferredOutput {
421-
const preferredPrebuffer = this.computePrebufferBytes(this.negotiatedFormat);
440+
// Prefer the client's last explicitly-negotiated format. negotiatedFormat is
441+
// reset to the stream default by onIdentified on (re)connect, so relying on it
442+
// here makes the engine start at the default rate and then restart on the
443+
// format mismatch (reason=replace) — which can starve the stream into an
444+
// audible dmix loop. lastClientNegotiatedFormat survives reconnects.
445+
const fmt = this.lastClientNegotiatedFormat ?? this.negotiatedFormat;
446+
const preferredPrebuffer = this.computePrebufferBytes(fmt);
422447
return {
423448
profile:
424-
this.negotiatedFormat.codec === AudioCodec.OPUS
449+
fmt.codec === AudioCodec.OPUS
425450
? 'opus'
426-
: this.negotiatedFormat.codec === AudioCodec.FLAC
451+
: fmt.codec === AudioCodec.FLAC
427452
? 'flac'
428453
: 'pcm',
429-
sampleRate: this.negotiatedFormat.sampleRate,
430-
channels: this.negotiatedFormat.channels,
431-
bitDepth: this.negotiatedFormat.bitDepth,
454+
sampleRate: fmt.sampleRate,
455+
channels: fmt.channels,
456+
bitDepth: fmt.bitDepth,
432457
prebufferBytes: preferredPrebuffer,
433458
};
434459
}

0 commit comments

Comments
 (0)