Files
yellowjacket/backend/player/volume_test.go
T
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00

265 lines
6.0 KiB
Go

package player
import (
"log/slog"
"math"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/mediacontrols"
)
func TestUserVolume_ToVolume(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input UserVolume
expected Volume
}{
{"min (0)", MinUserVol, MinVol},
{"max (100)", MaxUserVol, MaxVol},
{"default (50)", DefaultUserVol, -2.5},
{"quarter (25)", 25, -3.75},
{"three-quarter (75)", 75, -1.25},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.input.ToVolume()
if math.Abs(float64(got)-float64(tt.expected)) > 0.001 {
t.Errorf("UserVolume(%d).ToVolume() = %f, want %f", tt.input, got, tt.expected)
}
})
}
}
func TestVolume_ToUserVolume(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input Volume
expected UserVolume
}{
{"min (-5.0)", MinVol, MinUserVol},
{"max (0.0)", MaxVol, MaxUserVol},
{"midpoint (-2.5)", -2.5, 50},
{"quarter (-3.75)", -3.75, 25},
{"three-quarter (-1.25)", -1.25, 75},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.input.ToUserVolume()
if got != tt.expected {
t.Errorf("Volume(%f).ToUserVolume() = %d, want %d", tt.input, got, tt.expected)
}
})
}
}
func TestUserVolume_ToVolume_OutOfRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input UserVolume
}{
{"negative (-1)", -1},
{"over max (101)", 101},
{"way over (200)", 200},
{"far negative (-50)", -50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.input.ToVolume()
// Out-of-range returns zero-value Volume (0.0).
if got != 0.0 {
t.Errorf("UserVolume(%d).ToVolume() = %f, want 0.0 (zero-value)", tt.input, got)
}
})
}
}
func TestVolume_ToUserVolume_OutOfRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input Volume
}{
{"below min (-6.0)", -6.0},
{"above max (1.0)", 1.0},
{"far below (-10.0)", -10.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.input.ToUserVolume()
// Out-of-range returns zero-value UserVolume (0).
if got != 0 {
t.Errorf("Volume(%f).ToUserVolume() = %d, want 0 (zero-value)", tt.input, got)
}
})
}
}
func TestUserVolume_ToVolume_Roundtrip(t *testing.T) {
t.Parallel()
// The conversion uses float64 intermediates and int truncation
// (not rounding), so some values lose 1 unit in the roundtrip.
// This characterization test verifies the actual behavior:
// the result is always within ±1 of the original, and boundary
// values (0, 50, 100) are exact.
for i := UserVolume(0); i <= 100; i++ {
vol := i.ToVolume()
roundtripped := vol.ToUserVolume()
diff := int(roundtripped) - int(i)
if diff < -1 || diff > 1 {
t.Errorf(
"Roundtrip UserVolume(%d) -> Volume(%f) -> UserVolume(%d): "+
"drift %d exceeds ±1",
i, vol, roundtripped, diff,
)
}
}
// Verify key boundary values are exact.
exactCases := []UserVolume{MinUserVol, DefaultUserVol, MaxUserVol}
for _, uv := range exactCases {
vol := uv.ToVolume()
roundtripped := vol.ToUserVolume()
if roundtripped != uv {
t.Errorf(
"Exact roundtrip UserVolume(%d) -> Volume(%f) -> "+
"UserVolume(%d): want exact match",
uv, vol, roundtripped,
)
}
}
}
func TestClampVolume(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input UserVolume
expected UserVolume
}{
{"far below min", -10, MinUserVol},
{"at min", 0, 0},
{"middle", 50, 50},
{"at max", 100, 100},
{"above max", 150, MaxUserVol},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := clampVolume(tt.input)
if got != tt.expected {
t.Errorf("clampVolume(%d) = %d, want %d", tt.input, got, tt.expected)
}
})
}
}
func TestStateToMediaControls(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input State
expected mediacontrols.PlaybackState
}{
{"playing", Playing, mediacontrols.StatePlaying},
{"paused", Paused, mediacontrols.StatePaused},
{"stopped", Stopped, mediacontrols.StateStopped},
{"unknown", State("unknown"), mediacontrols.StateStopped},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := stateToMediaControls(tt.input)
if got != tt.expected {
t.Errorf("stateToMediaControls(%q) = %d, want %d", tt.input, got, tt.expected)
}
})
}
}
// TestSetDuck covers the property the duck rests on: the attenuation
// is applied to the output and is invisible to everything that asks
// what the volume is -- the event, the persisted state, a relative
// change. Getting that wrong would let one notification tone
// permanently rewrite the user's volume.
func TestSetDuck(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(80)
unducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= unducked {
t.Errorf(
"ducked output volume = %v, want less than %v",
p.volume.Volume, unducked,
)
}
if got := p.getUserVolume(); got != 80 {
t.Errorf("user volume while ducked = %d, want 80", got)
}
// A second duck must not stack: the offset is re-applied to the
// user's level, never subtracted again from the current output.
ducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume != ducked {
t.Errorf(
"duck applied twice = %v, want %v", p.volume.Volume, ducked,
)
}
// Changing the volume while ducked keeps the attenuation.
p.setVolumeLocked(60)
if got := p.getUserVolume(); got != 60 {
t.Errorf("user volume set while ducked = %d, want 60", got)
}
if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want {
t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want)
}
p.SetDuck(false)
if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want)
}
}