@@ -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