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
36 changes: 27 additions & 9 deletions internal/airplay/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 52 additions & 6 deletions internal/airplay/mirror_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down