629 lines
17 KiB
Go
629 lines
17 KiB
Go
package sound
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBundledSoundsDecodeInProcess(t *testing.T) {
|
|
if !cueAvailability("default")["autocomplete"] {
|
|
t.Fatal("default theme does not offer the autocomplete cue")
|
|
}
|
|
for _, cue := range []string{"start", "stop", "autocomplete", "button", "checkbox_checked", "checkbox_mixed", "checkbox_unchecked", "combobox", "link", "link_visited", "radiobutton_checked", "radiobutton_unchecked", "togglebutton_checked", "togglebutton_unchecked", "switch_on", "switch_off", "browse_mode", "focus_mode"} {
|
|
t.Run(cue, func(t *testing.T) {
|
|
data, err := load_sound("default", cue)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The first GStreamer initialization can exceed one second on a
|
|
// cold process even when the cue itself is short.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGeneratedProgressSoundsDecodeInProcess(t *testing.T) {
|
|
for _, percentage := range []int{0, 50, 99, 100} {
|
|
data, err := soundData("default", fmt.Sprintf("generated-progress-tone:%d", percentage))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
err = (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, 0)
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatalf("percentage %d: %v", percentage, err)
|
|
}
|
|
}
|
|
if _, err := soundData("default", "generated-progress-tone:101"); err == nil {
|
|
t.Fatal("accepted invalid generated progress tone")
|
|
}
|
|
}
|
|
|
|
func TestBundledSoundPanDecodes(t *testing.T) {
|
|
data, err := load_sound("default", "button")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pan := range []float64{-1, 0, 0.35, 1} {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := (NativePlayer{Sink: "test"}).Play(ctx, data, 0.5, pan)
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatalf("pan %v: %v", pan, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThemePathsAndSizeAreBounded(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
for _, name := range []string{"..", "../other", "/tmp/other"} {
|
|
if _, err := load_sound(name, "start"); err == nil {
|
|
t.Fatalf("accepted theme %q", name)
|
|
}
|
|
if _, err := load_sound("default", name); err == nil {
|
|
t.Fatalf("accepted cue %q", name)
|
|
}
|
|
}
|
|
path := filepath.Join(os.Getenv("XDG_DATA_HOME"), "cthulhu", "sounds", "custom")
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "start.wav"), []byte("custom"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := load_sound("custom", "start")
|
|
if err != nil || string(data) != "custom" {
|
|
t.Fatalf("custom theme = %q, %v", data, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "start.wav"), make([]byte, maxSoundBytes+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := load_sound("custom", "start"); err == nil {
|
|
t.Fatal("accepted oversized sound")
|
|
}
|
|
}
|
|
|
|
type blockingPlayer struct {
|
|
started chan struct{}
|
|
calls int
|
|
}
|
|
|
|
func TestQueueReplacementDoesNotBlockConcurrentConsumer(t *testing.T) {
|
|
service := New(slog.New(slog.DiscardHandler), nil, "default", 0.5)
|
|
service.running = true
|
|
service.enabled = true
|
|
service.available = map[string]bool{"button": true}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
consumerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(consumerDone)
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-service.queue:
|
|
// The runtime receives before taking the playback mutex.
|
|
service.mutex.Lock()
|
|
service.mutex.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for range 100000 {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
service.Enqueue(Cue{Name: "button"})
|
|
}
|
|
}()
|
|
select {
|
|
case <-done:
|
|
cancel()
|
|
<-consumerDone
|
|
case <-time.After(5 * time.Second):
|
|
cancel()
|
|
// Release a blocked drain so both test goroutines can be joined.
|
|
service.queue <- queuedCue{}
|
|
<-done
|
|
<-consumerDone
|
|
t.Fatal("queue replacement blocked while the consumer waited for the mutex")
|
|
}
|
|
}
|
|
|
|
type interruptPlayer struct {
|
|
started chan struct{}
|
|
canceled chan struct{}
|
|
played chan []byte
|
|
once sync.Once
|
|
}
|
|
|
|
func (player *interruptPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
|
|
first := false
|
|
player.once.Do(func() { first = true; close(player.started) })
|
|
if first {
|
|
<-ctx.Done()
|
|
close(player.canceled)
|
|
return ctx.Err()
|
|
}
|
|
player.played <- data
|
|
return nil
|
|
}
|
|
|
|
func TestNewPresentationInterruptsOldCueAndDiscardsPendingCues(t *testing.T) {
|
|
player := &interruptPlayer{started: make(chan struct{}), canceled: make(chan struct{}), played: make(chan []byte, 2)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !service.Enqueue(Cue{Name: "button"}) {
|
|
t.Fatal("initial cue rejected")
|
|
}
|
|
select {
|
|
case <-player.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("initial cue did not start")
|
|
}
|
|
for range 20 {
|
|
if !service.Enqueue(Cue{Name: "link"}) {
|
|
t.Fatal("new link cue rejected")
|
|
}
|
|
}
|
|
select {
|
|
case <-player.canceled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("initial cue was not interrupted")
|
|
}
|
|
want, err := load_sound("default", "link")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case got := <-player.played:
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatal("played stale cue instead of latest link")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("latest link cue did not play")
|
|
}
|
|
if len(service.queue) != 0 {
|
|
t.Fatal("stale link cues remained queued")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
type configuredPlayback struct {
|
|
volume float64
|
|
pan float64
|
|
}
|
|
|
|
type configurationPlayer struct {
|
|
played chan configuredPlayback
|
|
}
|
|
|
|
type reconfigurePlayer struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
canceled chan struct{}
|
|
played chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (player *reconfigurePlayer) Play(ctx context.Context, _ []byte, _, _ float64) error {
|
|
first := false
|
|
player.once.Do(func() {
|
|
first = true
|
|
close(player.started)
|
|
})
|
|
if first {
|
|
select {
|
|
case <-player.release:
|
|
case <-ctx.Done():
|
|
close(player.canceled)
|
|
return ctx.Err()
|
|
}
|
|
return nil
|
|
}
|
|
player.played <- struct{}{}
|
|
return nil
|
|
}
|
|
|
|
func (player *configurationPlayer) Play(_ context.Context, _ []byte, volume, pan float64) error {
|
|
player.played <- configuredPlayback{volume: volume, pan: pan}
|
|
return nil
|
|
}
|
|
|
|
func TestCuePanIsValidatedAndPassedToPlayer(t *testing.T) {
|
|
player := &configurationPlayer{played: make(chan configuredPlayback, 2)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Enqueue(Cue{Name: "button", Pan: 0.5})
|
|
select {
|
|
case playback := <-player.played:
|
|
if playback.pan != 0.5 {
|
|
t.Fatalf("pan = %v, want 0.5", playback.pan)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("panned cue was not played")
|
|
}
|
|
service.Enqueue(Cue{Name: "button", Pan: 2})
|
|
select {
|
|
case playback := <-player.played:
|
|
if playback.pan != 0 {
|
|
t.Fatalf("invalid pan = %v, want centered", playback.pan)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("invalid-pan cue was not played")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCustomThemeCueSupportsPan(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
path := filepath.Join(dataHome, "cthulhu", "sounds", "custom")
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("custom wav"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "stop.wav"), []byte("custom stop"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
player := &configurationPlayer{played: make(chan configuredPlayback, 1)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "custom", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !service.Available("button") {
|
|
t.Fatal("custom theme cue was not available")
|
|
}
|
|
service.Enqueue(Cue{Name: "button", Pan: -1})
|
|
select {
|
|
case playback := <-player.played:
|
|
if playback.pan != -1 {
|
|
t.Fatalf("custom cue pan = %v, want -1", playback.pan)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("custom cue was not played")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestConfigureAppliesEnableAndVolumeToSubsequentCues(t *testing.T) {
|
|
player := &configurationPlayer{played: make(chan configuredPlayback, 2)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
service.Configure(Configuration{Enabled: false, Sink: "auto", Theme: "default", Volume: 0.5})
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Enqueue(Cue{Name: "button"})
|
|
if len(service.queue) != 0 {
|
|
t.Fatal("disabled service queued a cue")
|
|
}
|
|
service.Configure(Configuration{Enabled: true, Sink: "pulse", Theme: "default", Volume: 0.8})
|
|
service.Enqueue(Cue{Name: "button"})
|
|
select {
|
|
case playback := <-player.played:
|
|
if playback.volume != 0.8 {
|
|
t.Fatalf("volume = %v, want 0.8", playback.volume)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("configured cue was not played")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestConfigureCancelsActiveOldPolicyCue(t *testing.T) {
|
|
player := &reconfigurePlayer{started: make(chan struct{}), release: make(chan struct{}), canceled: make(chan struct{}), played: make(chan struct{}, 1)}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !service.Enqueue(Cue{Name: "button"}) {
|
|
t.Fatal("first cue was not admitted")
|
|
}
|
|
select {
|
|
case <-player.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first cue did not start")
|
|
}
|
|
service.Configure(Configuration{Enabled: false, Theme: "default", Volume: 0.5})
|
|
select {
|
|
case <-player.canceled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("active old-policy cue was not canceled")
|
|
}
|
|
select {
|
|
case <-player.played:
|
|
t.Fatal("old-policy pending cue played after reconfiguration")
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCueBatchAdmissionIsAtomic(t *testing.T) {
|
|
player := &blockingPlayer{started: make(chan struct{})}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !service.Enqueue(Cue{Name: "start"}) {
|
|
t.Fatal("blocking cue was not admitted")
|
|
}
|
|
select {
|
|
case <-player.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("sound worker did not start")
|
|
}
|
|
batch := make([]Cue, queueCapacity+1)
|
|
for index := range batch {
|
|
batch[index] = Cue{Name: "button"}
|
|
}
|
|
if service.EnqueueBatch(batch) {
|
|
t.Fatal("oversized presentation batch was admitted")
|
|
}
|
|
if len(service.queue) != 0 {
|
|
t.Fatal("part of rejected presentation batch was queued")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestPartialCustomThemeStopsWithoutShutdownCue(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
path := filepath.Join(dataHome, "cthulhu", "sounds", "partial")
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(path, "button.wav"), []byte("button"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := New(slog.New(slog.DiscardHandler), &configurationPlayer{played: make(chan configuredPlayback, 1)}, "partial", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (player *blockingPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error {
|
|
player.calls++
|
|
if player.calls == 1 {
|
|
close(player.started)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestSoundWorkerBoundsQueueAndStopsActiveCue(t *testing.T) {
|
|
player := &blockingPlayer{started: make(chan struct{})}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Enqueue(Cue{Name: "start"})
|
|
select {
|
|
case <-player.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("sound worker did not start")
|
|
}
|
|
batch := make([]Cue, queueCapacity+1)
|
|
for index := range batch {
|
|
batch[index] = Cue{Name: "button"}
|
|
}
|
|
service.EnqueueBatch(batch)
|
|
if service.dropped.Load() == 0 {
|
|
t.Fatal("overflow not counted")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.calls != 2 {
|
|
t.Fatalf("played %d cues; expected active cue then stop", player.calls)
|
|
}
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.calls != 2 {
|
|
t.Fatal("repeated stop played another cue")
|
|
}
|
|
}
|
|
|
|
func TestStopPlaysShutdownCueAfterOversizedPresentation(t *testing.T) {
|
|
stopData, err := load_sound("default", "stop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
player := &queueBlockingPlayer{stopData: stopData, entered: make(chan struct{})}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Enqueue(Cue{Name: "button"})
|
|
select {
|
|
case <-player.entered:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("sound worker did not start")
|
|
}
|
|
batch := make([]Cue, queueCapacity+1)
|
|
for index := range batch {
|
|
batch[index] = Cue{Name: "button"}
|
|
}
|
|
service.EnqueueBatch(batch)
|
|
if service.dropped.Load() == 0 {
|
|
t.Fatal("oversized presentation was not dropped")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.stopCalls != 1 {
|
|
t.Fatalf("played %d stop cues; expected 1", player.stopCalls)
|
|
}
|
|
if err := service.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.stopCalls != 1 {
|
|
t.Fatalf("repeated stop played %d stop cues; expected no more", player.stopCalls)
|
|
}
|
|
}
|
|
|
|
func TestStopTimeoutPreservesShutdownCueForRetry(t *testing.T) {
|
|
stopData, err := load_sound("default", "stop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
player := &stagedStopPlayer{stopData: stopData, entered: make(chan struct{}), release: make(chan struct{})}
|
|
service := New(slog.New(slog.DiscardHandler), player, "default", 0.5)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Enqueue(Cue{Name: "button"})
|
|
select {
|
|
case <-player.entered:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("sound worker did not start")
|
|
}
|
|
|
|
timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
defer cancelTimeout()
|
|
if err := service.Stop(timeoutContext); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("timed-out Stop error = %v; want deadline exceeded", err)
|
|
}
|
|
close(player.release)
|
|
|
|
retryContext, cancelRetry := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelRetry()
|
|
if err := service.Stop(retryContext); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.stopCalls != 1 {
|
|
t.Fatalf("played %d stop cues; expected 1 after retry", player.stopCalls)
|
|
}
|
|
}
|
|
|
|
type stopCountingPlayer struct {
|
|
stopData []byte
|
|
stopCalls int
|
|
}
|
|
|
|
type queueBlockingPlayer struct {
|
|
stopData []byte
|
|
entered chan struct{}
|
|
enterOnce sync.Once
|
|
stopCalls int
|
|
}
|
|
|
|
func (player *queueBlockingPlayer) Play(ctx context.Context, data []byte, _, _ float64) error {
|
|
if bytes.Equal(data, player.stopData) {
|
|
player.stopCalls++
|
|
return nil
|
|
}
|
|
player.enterOnce.Do(func() { close(player.entered) })
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
|
|
func (player *stopCountingPlayer) Play(_ context.Context, data []byte, _, _ float64) error {
|
|
if bytes.Equal(data, player.stopData) {
|
|
player.stopCalls++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type stagedStopPlayer struct {
|
|
stopData []byte
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
stopCalls int
|
|
}
|
|
|
|
func (player *stagedStopPlayer) Play(_ context.Context, data []byte, _, _ float64) error {
|
|
if bytes.Equal(data, player.stopData) {
|
|
player.stopCalls++
|
|
return nil
|
|
}
|
|
close(player.entered)
|
|
<-player.release
|
|
return nil
|
|
}
|
|
|
|
func TestCancelledNativeSoundDoesNotStart(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := (NativePlayer{Sink: "test"}).Play(ctx, []byte("invalid"), 0.5, 0); !errors.Is(err, context.Canceled) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestLiveSound(t *testing.T) {
|
|
if os.Getenv("CTHULHU_LIVE_SOUND") != "1" {
|
|
t.Skip("explicit live audio test")
|
|
}
|
|
for _, cue := range []string{"start", "stop"} {
|
|
data, err := load_sound("default", cue)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
err = (NativePlayer{Sink: "auto"}).Play(ctx, data, 0.5, 0)
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|