nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

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 }