// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package sound import ( "context" "encoding/binary" "log/slog" "testing" "time" ) func TestPositionTonesDecodeAndRiseInPitch(t *testing.T) { previousCrossings := 0 for _, position := range []float64{0, 0.5, 1} { data, err := soundData("default", PositionCueName(position)) if err != nil { t.Fatal(err) } if binary.LittleEndian.Uint32(data[24:]) != 22050 || binary.LittleEndian.Uint16(data[22:]) != 1 || len(data) != 44+1323*2 { t.Fatal("position tone is not 60 ms mono PCM at 22050 Hz") } crossings := 0 previousSample := int16(0) for offset := 44; offset < len(data); offset += 2 { sample := int16(binary.LittleEndian.Uint16(data[offset:])) if previousSample < 0 && sample >= 0 { crossings++ } previousSample = sample } if crossings <= previousCrossings { t.Fatalf("pitch failed to rise at %g: %d cycles", position, crossings) } previousCrossings = crossings ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.3, -0.5) cancel() if err != nil { t.Fatal(err) } } for _, value := range []string{"NaN", "+Inf", "-1", "1.1", "bad"} { if _, err := soundData("default", positionCuePrefix+value); err == nil { t.Fatalf("accepted position %s", value) } } } func TestPositionSoundUsesObjectSoundPolicy(t *testing.T) { player := &configurationPlayer{played: make(chan configuredPlayback, 8)} service := New(slog.New(slog.DiscardHandler), player, "default", 0.3) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { if err := service.Stop(context.Background()); err != nil { t.Error(err) } }) cue := Cue{Name: PositionCueName(0.5), Pan: -0.5} if !service.Enqueue(cue) { t.Fatal("position cue was rejected") } select { case playback := <-player.played: if playback.volume != 0.3 || playback.pan != -0.5 { t.Fatalf("position playback = %+v", playback) } case <-time.After(time.Second): t.Fatal("position tone did not reach player") } service.mutex.Lock() cached := len(service.cache) service.mutex.Unlock() if cached != 0 { t.Fatal("continuous position values populated the sound cache") } for _, configuration := range []Configuration{ {Enabled: false, Theme: "default", Volume: 0.3}, {Enabled: true, Theme: "none", Volume: 0.3}, {Enabled: true, Theme: "default", Volume: 0}, } { service.Configure(configuration) if service.Available(cue.Name) || service.Enqueue(cue) { t.Fatalf("position ignored policy: %+v", configuration) } if !service.Available(ProgressCueName(50)) { t.Fatal("position policy affected progress tones") } } service.Configure(Configuration{Enabled: true, Theme: "custom", Volume: 0.3}) if !service.Available(cue.Name) { t.Fatal("generated position requires a theme asset") } }