play.go (4242B)
1 // Package play provides synchronous audio playback for PCM produced by 2 // the nativeaudio package. 3 // 4 // It is deliberately a separate package. Decoding is the reason 5 // nativeaudio exists, and most callers want PCM to hand to an audio 6 // stack they have already chosen. Keeping playback here means the core 7 // package does not drag an output stack into those programs. 8 package play 9 10 import ( 11 "bytes" 12 "fmt" 13 "sync" 14 "time" 15 16 "git.sr.ht/~jackmordaunt/nativeaudio" 17 "github.com/ebitengine/oto/v3" 18 ) 19 20 var ( 21 mu sync.Mutex 22 shared *oto.Context 23 sharedFmt nativeaudio.Format 24 ) 25 26 // sharedContext returns the process-wide oto context, creating it on 27 // first use. oto permits exactly one context per process and fixes its 28 // sample rate and channel count at creation, so every file played after 29 // the first must share the first file's format. 30 func sharedContext(f nativeaudio.Format) (*oto.Context, error) { 31 mu.Lock() 32 defer mu.Unlock() 33 if shared != nil { 34 if f != sharedFmt { 35 return nil, fmt.Errorf("playback context is fixed at %d Hz, %d channel(s) by the first file played; cannot play %d Hz, %d channel(s) in the same process", 36 sharedFmt.SampleRate, sharedFmt.Channels, f.SampleRate, f.Channels) 37 } 38 return shared, nil 39 } 40 ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ 41 SampleRate: f.SampleRate, 42 ChannelCount: f.Channels, 43 Format: oto.FormatSignedInt16LE, 44 }) 45 if err != nil { 46 return nil, fmt.Errorf("starting playback context: %w", err) 47 } 48 <-ready 49 shared, sharedFmt = ctx, f 50 return ctx, nil 51 } 52 53 // PCM plays signed 16-bit little-endian PCM synchronously, returning 54 // once the audio has finished playing. 55 func PCM(pcm []byte, format nativeaudio.Format) error { 56 if format.BytesPerSample != 2 { 57 return fmt.Errorf("playback: unsupported sample size %d bytes, want 2", format.BytesPerSample) 58 } 59 ctx, err := sharedContext(format) 60 if err != nil { 61 return err 62 } 63 if err := ctx.Resume(); err != nil { 64 return fmt.Errorf("resuming playback context: %w", err) 65 } 66 player := ctx.NewPlayer(bytes.NewReader(pcm)) 67 player.Play() 68 // IsPlaying stays true until the source is exhausted AND oto's 69 // internal buffer (half a second by default) has been played out, or 70 // the player fails. Waiting only for the source to hit EOF would cut 71 // off the tail of the audio. 72 // 73 // It can also stay true forever on a machine whose audio output never 74 // makes progress, which is what a CI runner with no sound device 75 // looks like. The audio's own length is the natural bound, with 76 // enough slack for the buffer and for a slow start. 77 budget := Duration(pcm, format) 78 budget += budget/4 + 10*time.Second 79 deadline := time.Now().Add(budget) 80 for player.IsPlaying() { 81 if time.Now().After(deadline) { 82 player.Close() 83 ctx.Suspend() 84 return fmt.Errorf("playback did not finish within %s; the audio device is not making progress", budget.Round(time.Second)) 85 } 86 time.Sleep(10 * time.Millisecond) 87 } 88 err = player.Err() 89 if cerr := player.Close(); err == nil && cerr != nil { 90 err = fmt.Errorf("closing player: %w", cerr) 91 } 92 if serr := ctx.Suspend(); err == nil && serr != nil { 93 err = fmt.Errorf("suspending playback context: %w", serr) 94 } 95 return err 96 } 97 98 // File decodes an audio file and plays it once, synchronously. 99 func File(path string) error { 100 d, err := nativeaudio.New() 101 if err != nil { 102 return err 103 } 104 defer d.Close() 105 pcm, format, err := d.DecodeFile(path) 106 if err != nil { 107 return fmt.Errorf("decoding %q: %w", path, err) 108 } 109 return PCM(pcm, format) 110 } 111 112 // Data decodes compressed audio and plays it once, synchronously. 113 func Data(compressed []byte) error { 114 d, err := nativeaudio.New() 115 if err != nil { 116 return err 117 } 118 defer d.Close() 119 pcm, format, err := d.Decode(compressed) 120 if err != nil { 121 return fmt.Errorf("decoding: %w", err) 122 } 123 return PCM(pcm, format) 124 } 125 126 // Duration reports how long the PCM will take to play. 127 // 128 // It is what bounds the wait in [PCM], and is useful to callers sizing a 129 // timeout of their own. 130 func Duration(pcm []byte, format nativeaudio.Format) time.Duration { 131 perSecond := format.SampleRate * format.Channels * format.BytesPerSample 132 if perSecond <= 0 { 133 return 0 134 } 135 return time.Duration(len(pcm)) * time.Second / time.Duration(perSecond) 136 }