diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index ff7f83f..4923fe5 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -642,17 +642,27 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig } } - // Set volume to 0 dB (full scale). Positive dB values are invalid here and - // current receivers may interpret them as zero gain. - volumeBody := audioVolumeBody(false) - _, _, err = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil) - if err != nil { - dbg("[SETUP] SET_PARAMETER volume failed (non-fatal): %v", err) + // Only set the receiver's volume when this session actually carries audio. + // `volume: 0.000000` is 0 dB — full scale in AirPlay, not silence — so a + // video-only session would force the receiver to maximum on every connect. + // Sending -144 instead would be equally destructive of the user's setting; + // a session that transmits no audio has no use for the receiver's audio + // state and should leave its volume untouched. + if cfg.NoAudio { + dbg("[SETUP] no-audio session: skipping SET_PARAMETER volume") } else { - dbg("[SETUP] SET_PARAMETER volume=0 sent") + // Positive dB values are invalid here and current receivers may + // interpret them as zero gain. Real senders send the sender's own + // slider value; 0 dB is this sender's fixed choice. + volumeBody := audioVolumeBody(false) + if _, _, err := c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil); err != nil { + dbg("[SETUP] SET_PARAMETER volume failed (non-fatal): %v", err) + } else { + dbg("[SETUP] SET_PARAMETER volume=0 sent") + } + // Send volume twice (pcap shows real senders do this) + _, _, _ = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil) } - // Send volume twice (pcap shows real senders do this) - _, _, _ = c.rtspRequest("SET_PARAMETER", audioURI, "text/parameters", volumeBody, nil) if timingProtocol == timingProtocolPTP { // PTP uses the receiver's fixed 319/320 ports. The first socket was only @@ -1741,6 +1751,14 @@ func (s *MirrorSession) SetAudioMuted(muted bool) error { if s == nil || s.client == nil || s.sessionURI == "" { return fmt.Errorf("audio control unavailable") } + // A session started with audio disabled must not write the receiver's + // volume either: unmuting sends 0 dB — full scale — which would discard + // whatever the user had set, exactly as the setup path once did. The + // session negotiates an audio stream even in this mode, so HasAudio() is + // not sufficient to tell the two apart. + if s.noAudio { + return fmt.Errorf("audio control unavailable: session was started with audio disabled") + } if _, _, err := s.client.rtspRequest("SET_PARAMETER", s.sessionURI, "text/parameters", audioVolumeBody(muted), nil); err != nil { return fmt.Errorf("set audio muted=%t: %w", muted, err) diff --git a/internal/airplay/mirror_setup_test.go b/internal/airplay/mirror_setup_test.go index a66ac5f..c57825d 100644 --- a/internal/airplay/mirror_setup_test.go +++ b/internal/airplay/mirror_setup_test.go @@ -201,12 +201,52 @@ func TestSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T) { {name: "skip record", skipRecord: true}, } { t.Run(test.name, func(t *testing.T) { - testSetupMirrorNoAudioStillNegotiatesAudioSession(t, test.skipRecord) + testSetupMirrorAudioSessionNegotiation(t, audioSessionCase{skipRecord: test.skipRecord, noAudio: true}) }) } } -func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord bool) { +// A session that DOES carry audio must still set the receiver's volume, so the +// -no-audio guard narrows that behaviour rather than removing it. +func TestSetupMirrorWithAudioSetsReceiverVolume(t *testing.T) { + for _, test := range []struct { + name string + skipRecord bool + }{ + {name: "record", skipRecord: false}, + {name: "skip record", skipRecord: true}, + } { + t.Run(test.name, func(t *testing.T) { + testSetupMirrorAudioSessionNegotiation(t, audioSessionCase{skipRecord: test.skipRecord, noAudio: false}) + }) + } +} + +// A no-audio session must not be able to write the receiver's volume through +// the daemon's mute control either: unmuting sends 0 dB, which is full scale. +func TestSetAudioMutedRefusesWhenSessionHasNoAudio(t *testing.T) { + for _, muted := range []bool{true, false} { + session := &MirrorSession{client: &AirPlayClient{}, sessionURI: "rtsp://example/session", noAudio: true} + err := session.SetAudioMuted(muted) + if err == nil { + t.Fatalf("SetAudioMuted(%v) = nil, want a refusal for a no-audio session", muted) + } + // Assert the REASON, not merely that something failed: without the + // guard this call still errors (no connection), so an error alone + // would pass on the unfixed code. + if !strings.Contains(err.Error(), "audio disabled") { + t.Fatalf("SetAudioMuted(%v) error = %q, want a refusal naming the disabled audio", muted, err) + } + } +} + +type audioSessionCase struct { + skipRecord bool + noAudio bool +} + +func testSetupMirrorAudioSessionNegotiation(t *testing.T, test audioSessionCase) { + skipRecord, noAudio := test.skipRecord, test.noAudio eventListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen event channel: %v", err) @@ -448,12 +488,12 @@ func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord } defer client.Close() - session, err := client.SetupMirror(ctx, StreamConfig{FPS: 30, NoAudio: true}) + session, err := client.SetupMirror(ctx, StreamConfig{FPS: 30, NoAudio: noAudio}) if err != nil { - t.Fatalf("SetupMirror(no audio): %v", err) + t.Fatalf("SetupMirror(noAudio=%v): %v", noAudio, err) } if !session.HasAudio() { - t.Fatal("expected no-audio session setup to keep the negotiated audio stream state") + t.Fatalf("noAudio=%v: expected the negotiated audio stream state to survive setup", noAudio) } if !skipRecord && session.timestampBias != 250*time.Millisecond { t.Fatalf("session timestamp bias = %v, want RECORD Audio-Latency of 250ms", session.timestampBias) @@ -488,7 +528,13 @@ func testSetupMirrorNoAudioStillNegotiatesAudioSession(t *testing.T, skipRecord recordIndex = len(wantMethods) wantMethods = append(wantMethods, "RECORD") } - wantMethods = append(wantMethods, "SET_PARAMETER", "SET_PARAMETER", "POST", "TEARDOWN") + // The two volume SET_PARAMETERs are sent only when the session carries + // audio: `volume: 0.000000` is 0 dB — full scale — so a video-only + // session would force the receiver to maximum. + if !noAudio { + wantMethods = append(wantMethods, "SET_PARAMETER", "SET_PARAMETER") + } + wantMethods = append(wantMethods, "POST", "TEARDOWN") got := make([]rtspTestRequest, 0, len(wantMethods)) for range wantMethods { select {