Skip to content

Commit 095300d

Browse files
josiahbryanomarroth
authored andcommitted
mirror: preserve receiver volume without audio (#33)
1 parent ae06722 commit 095300d

2 files changed

Lines changed: 84 additions & 16 deletions

File tree

internal/airplay/mirror.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -964,17 +964,27 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
964964
}
965965
}
966966

967-
// Set volume to 0 dB (full scale). Positive dB values are invalid here and
968-
// current receivers may interpret them as zero gain.
969-
volumeBody := audioVolumeBody(false)
970-
_, _, err = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil)
971-
if err != nil {
972-
dbg("[SETUP] SET_PARAMETER volume failed (non-fatal): %v", err)
967+
// Only set the receiver's volume when this session actually carries audio.
968+
// `volume: 0.000000` is 0 dB — full scale in AirPlay, not silence — so a
969+
// video-only session would force the receiver to maximum on every connect.
970+
// Sending -144 instead would be equally destructive of the user's setting;
971+
// a session that transmits no audio has no use for the receiver's audio
972+
// state and should leave its volume untouched.
973+
if cfg.NoAudio {
974+
dbg("[SETUP] no-audio session: skipping SET_PARAMETER volume")
973975
} else {
974-
dbg("[SETUP] SET_PARAMETER volume=0 sent")
976+
// Positive dB values are invalid here and current receivers may
977+
// interpret them as zero gain. Real senders send the sender's own
978+
// slider value; 0 dB is this sender's fixed choice.
979+
volumeBody := audioVolumeBody(false)
980+
if _, _, err := c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil); err != nil {
981+
dbg("[SETUP] SET_PARAMETER volume failed (non-fatal): %v", err)
982+
} else {
983+
dbg("[SETUP] SET_PARAMETER volume=0 sent")
984+
}
985+
// Send volume twice (pcap shows real senders do this)
986+
_, _, _ = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil)
975987
}
976-
// Send volume twice (pcap shows real senders do this)
977-
_, _, _ = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil)
978988

979989
if timingProtocol == timingProtocolPTP {
980990
// PTP uses the receiver's fixed 319/320 ports. The first socket was only
@@ -2191,6 +2201,14 @@ func (s *MirrorSession) SetAudioMuted(muted bool) error {
21912201
if s == nil || s.client == nil || s.sessionURI == "" {
21922202
return fmt.Errorf("audio control unavailable")
21932203
}
2204+
// A session started with audio disabled must not write the receiver's
2205+
// volume either: unmuting sends 0 dB — full scale — which would discard
2206+
// whatever the user had set, exactly as the setup path once did. The
2207+
// session negotiates an audio stream even in this mode, so HasAudio() is
2208+
// not sufficient to tell the two apart.
2209+
if s.noAudio {
2210+
return fmt.Errorf("audio control unavailable: session was started with audio disabled")
2211+
}
21942212

21952213
if _, _, err := s.client.rtspRequest("SET_PARAMETER", s.sessionURI, "text/parameters", audioVolumeBody(muted), nil); err != nil {
21962214
return fmt.Errorf("set audio muted=%t: %w", muted, err)

internal/airplay/mirror_setup_test.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,12 +372,52 @@ func TestSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T) {
372372
{name: "skip record", skipRecord: true},
373373
} {
374374
t.Run(test.name, func(t *testing.T) {
375-
testSetupMirrorNoAudioStillNegotiatesAudioSession(t, test.skipRecord)
375+
testSetupMirrorAudioSessionNegotiation(t, audioSessionCase{skipRecord: test.skipRecord, noAudio: true})
376376
})
377377
}
378378
}
379379

380-
func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord bool) {
380+
// A session that DOES carry audio must still set the receiver's volume, so the
381+
// -no-audio guard narrows that behaviour rather than removing it.
382+
func TestSetupMirrorWithAudioSetsReceiverVolume(t *testing.T) {
383+
for _, test := range []struct {
384+
name string
385+
skipRecord bool
386+
}{
387+
{name: "record", skipRecord: false},
388+
{name: "skip record", skipRecord: true},
389+
} {
390+
t.Run(test.name, func(t *testing.T) {
391+
testSetupMirrorAudioSessionNegotiation(t, audioSessionCase{skipRecord: test.skipRecord, noAudio: false})
392+
})
393+
}
394+
}
395+
396+
// A no-audio session must not be able to write the receiver's volume through
397+
// the daemon's mute control either: unmuting sends 0 dB, which is full scale.
398+
func TestSetAudioMutedRefusesWhenSessionHasNoAudio(t *testing.T) {
399+
for _, muted := range []bool{true, false} {
400+
session := &MirrorSession{client: &AirPlayClient{}, sessionURI: "rtsp://example/session", noAudio: true}
401+
err := session.SetAudioMuted(muted)
402+
if err == nil {
403+
t.Fatalf("SetAudioMuted(%v) = nil, want a refusal for a no-audio session", muted)
404+
}
405+
// Assert the REASON, not merely that something failed: without the
406+
// guard this call still errors (no connection), so an error alone
407+
// would pass on the unfixed code.
408+
if !strings.Contains(err.Error(), "audio disabled") {
409+
t.Fatalf("SetAudioMuted(%v) error = %q, want a refusal naming the disabled audio", muted, err)
410+
}
411+
}
412+
}
413+
414+
type audioSessionCase struct {
415+
skipRecord bool
416+
noAudio bool
417+
}
418+
419+
func testSetupMirrorAudioSessionNegotiation(t *testing.T, test audioSessionCase) {
420+
skipRecord, noAudio := test.skipRecord, test.noAudio
381421
eventListener, err := net.Listen("tcp", "127.0.0.1:0")
382422
if err != nil {
383423
t.Fatalf("listen event channel: %v", err)
@@ -652,14 +692,18 @@ func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord
652692
defer client.Close()
653693
// -no-audio is also the escape hatch for receivers that advertise no screen
654694
// audio codec we can encode. Timing and video setup must remain usable.
655-
client.info = &ReceiverInfo{SupportedFormats: StreamFormats{ScreenStream: 0x800000}}
695+
screenStreamFormats := FormatMask(0x40000) // ALAC
696+
if noAudio {
697+
screenStreamFormats = 0x800000 // deliberately unsupported
698+
}
699+
client.info = &ReceiverInfo{SupportedFormats: StreamFormats{ScreenStream: screenStreamFormats}}
656700

657-
session, err := client.SetupMirror(ctx, StreamConfig{FPS: 30, NoAudio: true})
701+
session, err := client.SetupMirror(ctx, StreamConfig{FPS: 30, NoAudio: noAudio})
658702
if err != nil {
659-
t.Fatalf("SetupMirror(no audio): %v", err)
703+
t.Fatalf("SetupMirror(noAudio=%v): %v", noAudio, err)
660704
}
661705
if !session.HasAudio() {
662-
t.Fatal("expected no-audio session setup to keep the negotiated audio stream state")
706+
t.Fatalf("noAudio=%v: expected the negotiated audio stream state to survive setup", noAudio)
663707
}
664708
if session.timestampBias != defaultVideoLatencyNormal {
665709
t.Fatalf("session timestamp bias = %v, want %v", session.timestampBias, defaultVideoLatencyNormal)
@@ -697,7 +741,13 @@ func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord
697741
recordIndex = len(wantMethods)
698742
wantMethods = append(wantMethods, "RECORD")
699743
}
700-
wantMethods = append(wantMethods, "SET_PARAMETER", "SET_PARAMETER", "POST", "TEARDOWN")
744+
// The two volume SET_PARAMETERs are sent only when the session carries
745+
// audio: `volume: 0.000000` is 0 dB — full scale — so a video-only
746+
// session would force the receiver to maximum.
747+
if !noAudio {
748+
wantMethods = append(wantMethods, "SET_PARAMETER", "SET_PARAMETER")
749+
}
750+
wantMethods = append(wantMethods, "POST", "TEARDOWN")
701751
got := make([]rtspTestRequest, 0, len(wantMethods))
702752
for range wantMethods {
703753
select {

0 commit comments

Comments
 (0)