From 81a6d1aceaee55d12f96eb959ea4323f926b571f Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:48:30 +0300 Subject: [PATCH] feat: upgrade oto and output float audio --- CHANGELOG.md | 7 +++ audio.go | 46 ++++++++++++---- cmd/sointu-play/main.go | 11 ++-- cmd/sointu-track/main.go | 23 ++++---- go.mod | 6 +-- go.sum | 20 +++---- oto/convertbuffer.go | 32 ----------- oto/convertbuffer_test.go | 46 ---------------- oto/oto.go | 108 +++++++++++++++++++++++++++----------- 9 files changed, 146 insertions(+), 153 deletions(-) delete mode 100644 oto/convertbuffer.go delete mode 100644 oto/convertbuffer_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 412f53c..3ec99af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). wrong scaling ([#150][i150]) - Empty patch should not crash the native synth ([#148][i148]) +### Changed +- The stand-alone apps now output floating point sound, as made possible by + upgrading oto-library to latest version. This way the tracker sound output + matches the compiled output better, as usually compiled intros output sound in + floating point. This might be important if OS sound drivers apply some audio + enhancemenets e.g. compressors to the audio. + ## [0.4.1] ### Added - Clicking the parameter slider also selects that parameter ([#112][i112]) diff --git a/audio.go b/audio.go index 699f861..51fd25f 100644 --- a/audio.go +++ b/audio.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "math" ) @@ -13,22 +14,30 @@ type ( // sample represented by [2]float32. [0] is left channel, [1] is right AudioBuffer [][2]float32 - // AudioOutput represents something where we can send audio e.g. audio output. - // WriteAudio should block if not ready to accept audio e.g. buffer full. - AudioOutput interface { - WriteAudio(buffer AudioBuffer) error - Close() error + CloserWaiter interface { + io.Closer + Wait() } - // AudioContext represents the low-level audio drivers. There should be at most - // one AudioContext at a time. The interface is implemented at least by + // AudioContext represents the low-level audio drivers. There should be at + // most one AudioContext at a time. The interface is implemented at least by // oto.OtoContext, but in future we could also mock it. // - // AudioContext is used to create one or more AudioOutputs with Output(); each - // can be used to output separate sound & closed when done. + // AudioContext is used to play one or more AudioSources. Playing can be + // stopped by closing the returned io.Closer. AudioContext interface { - Output() AudioOutput - Close() error + Play(r AudioSource) CloserWaiter + } + + // AudioSource is an interface for reading audio samples into an + // AudioBuffer. Returns error if the buffer is not filled. + AudioSource interface { + ReadAudio(buf AudioBuffer) error + } + + BufferSource struct { + buffer AudioBuffer + pos int } // Synth represents a state of a synthesizer, compiled from a Patch. @@ -145,6 +154,21 @@ func (buffer AudioBuffer) Fill(synth Synth) error { return nil } +func (b AudioBuffer) Source() *BufferSource { + return &BufferSource{buffer: b} +} + +// ReadAudio reads audio samples from an AudioSource into an AudioBuffer. +// Returns an error when the buffer is fully consumed. +func (a *BufferSource) ReadAudio(buf AudioBuffer) error { + n := copy(buf, a.buffer[a.pos:]) + a.pos += n + if a.pos >= len(a.buffer) { + return io.EOF + } + return nil +} + // Wav converts an AudioBuffer into a valid WAV-file, returned as a []byte // array. // diff --git a/cmd/sointu-play/main.go b/cmd/sointu-play/main.go index 47714f2..aea297a 100644 --- a/cmd/sointu-play/main.go +++ b/cmd/sointu-play/main.go @@ -43,6 +43,7 @@ func main() { *play = true // if the user gives nothing to output, then the default behaviour is just to play the file } var audioContext sointu.AudioContext + var playWaiter sointu.CloserWaiter if *play { var err error audioContext, err = oto.NewContext() @@ -50,7 +51,6 @@ func main() { fmt.Fprintf(os.Stderr, "could not acquire oto AudioContext: %v\n", err) os.Exit(1) } - defer audioContext.Close() } process := func(filename string) error { output := func(extension string, contents []byte) error { @@ -98,11 +98,7 @@ func main() { return fmt.Errorf("sointu.Play failed: %v", err) } if *play { - output := audioContext.Output() - defer output.Close() - if err := output.WriteAudio(buffer); err != nil { - return fmt.Errorf("error playing: %v", err) - } + playWaiter = audioContext.Play(buffer.Source()) } if *rawOut { raw, err := buffer.Raw(*pcm) @@ -122,6 +118,9 @@ func main() { return fmt.Errorf("error outputting .wav file: %v", err) } } + if *play { + playWaiter.Wait() + } return nil } retval := 0 diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 093e107..ce17f8b 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -28,6 +28,16 @@ func (NullContext) BPM() (bpm float64, ok bool) { return 0, false } +type PlayerAudioSource struct { + *tracker.Player + playerProcessContext tracker.PlayerProcessContext +} + +func (p *PlayerAudioSource) ReadAudio(buf sointu.AudioBuffer) error { + p.Player.Process(buf, p.playerProcessContext) + return nil +} + var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`") var memprofile = flag.String("memprofile", "", "write memory profile to `file`") @@ -49,7 +59,6 @@ func main() { fmt.Println(err) os.Exit(1) } - defer audioContext.Close() recoveryFile := "" if configDir, err := os.UserConfigDir(); err == nil { recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") @@ -63,18 +72,10 @@ func main() { f.Close() } tracker := gioui.NewTracker(model) - output := audioContext.Output() - defer output.Close() - go func() { - buf := make(sointu.AudioBuffer, 1024) - ctx := NullContext{} - for { - player.Process(buf, ctx) - output.WriteAudio(buf) - } - }() + audioCloser := audioContext.Play(&PlayerAudioSource{player, NullContext{}}) go func() { tracker.Main() + audioCloser.Close() if *cpuprofile != "" { pprof.StopCPUProfile() f.Close() diff --git a/go.mod b/go.mod index e481779..7fdf306 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( gioui.org v0.7.1 gioui.org/x v0.7.1 github.com/Masterminds/sprig v2.22.0+incompatible - github.com/hajimehoshi/oto v0.6.6 + github.com/ebitengine/oto/v3 v3.3.0 golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 golang.org/x/text v0.16.0 gopkg.in/yaml.v2 v2.3.0 @@ -20,6 +20,7 @@ require ( git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect github.com/Masterminds/goutils v1.1.0 // indirect github.com/Masterminds/semver v1.5.0 // indirect + github.com/ebitengine/purego v0.8.0 // indirect github.com/go-text/typesetting v0.1.1 // indirect github.com/godbus/dbus/v5 v5.0.6 // indirect github.com/google/uuid v1.1.2 // indirect @@ -31,8 +32,7 @@ require ( golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/image v0.18.0 // indirect - golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/sys v0.25.0 // indirect pipelined.dev/pipe v0.11.0 // indirect pipelined.dev/signal v0.10.0 // indirect ) diff --git a/go.sum b/go.sum index a54cf4d..af20c33 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,12 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/oto/v3 v3.3.0 h1:34lJpJLqda0Iee9g9p8RWtVVwBcOOO2YSIS2x4yD1OQ= +github.com/ebitengine/oto/v3 v3.3.0/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= +github.com/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE= +github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= +github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= +github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo= github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI= github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY= @@ -27,8 +33,6 @@ github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hajimehoshi/oto v0.6.6 h1:HYSZ8cYZqOL4iHugvbcfhNN2smiSOsBMaoSBi4nnWcw= -github.com/hajimehoshi/oto v0.6.6/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= @@ -44,22 +48,14 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/oto/convertbuffer.go b/oto/convertbuffer.go deleted file mode 100644 index cbe9610..0000000 --- a/oto/convertbuffer.go +++ /dev/null @@ -1,32 +0,0 @@ -package oto - -import ( - "math" - - "github.com/vsariola/sointu" -) - -// FloatBufferTo16BitLE is a naive helper method to convert []float32 buffers to -// 16-bit little-endian, but encoded in byte buffer -// -// Appends the encoded bytes into "to" slice, allowing you to preallocate the -// capacity or just use nil -func FloatBufferTo16BitLE(from sointu.AudioBuffer, to []byte) []byte { - for _, v := range from { - left := to16BitSample(v[0]) - right := to16BitSample(v[1]) - to = append(to, byte(left&255), byte(left>>8), byte(right&255), byte(right>>8)) - } - return to -} - -// convert float32 to int16, clamping to min and max -func to16BitSample(v float32) int16 { - if v < -1.0 { - return -math.MaxInt16 - } - if v > 1.0 { - return math.MaxInt16 - } - return int16(v * math.MaxInt16) -} diff --git a/oto/convertbuffer_test.go b/oto/convertbuffer_test.go deleted file mode 100644 index f5697d4..0000000 --- a/oto/convertbuffer_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package oto_test - -import ( - "reflect" - "testing" - - "github.com/vsariola/sointu" - "github.com/vsariola/sointu/oto" -) - -func TestFloatBufferToBytes(t *testing.T) { - floats := sointu.AudioBuffer{{0, 0.000489128}, {0, 0.0019555532}, {0, 0.0043964}, {0, 0.007806882}, {0, 0.012180306}, {0, 0.017508084}, {0, 0.023779746}, {0, 0.030982954}, {0, 0.039103523}, {0, 0.04812544}, {0, 0.05803088}, {0, 0.068800256}, {0, 0.08041221}, {0, 0.09284368}, {0, 0.10606992}, {0, 0.120064534}, {0, 0.13479951}, {0, 0.1502453}, {0, 0.16637078}, {0, 0.18314338}, {0, 0.20052913}, {0, 0.21849263}, {0, 0.23699719}, {0, 0.2560048}, {0, 0.27547634}, {0, 0.29537144}, {0, 0.31564865}, {0, 0.33626547}, {0, 0.35717854}, {0, 0.37834346}, {0, 0.39971504}, {0, 0.4212474}, {0, 0.4428938}, {0, 0.46460703}, {0, 0.48633927}, {0, 0.50804216}, {0, 0.52966696}, {0, 0.5511646}, {0, 0.57248586}, {0, 0.5935812}, {0, 0.6144009}, {0, 0.63489544}, {0, 0.6550152}, {0, 0.67471063}, {0, 0.6939326}, {0, 0.712632}, {0, 0.7307603}, {0, 0.7482692}, {0, 0.7651111}, {0, 0.7812389}} - bytes := []byte{0x0, 0x0, 0x10, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, 0x90, 0x0, 0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x8f, 0x1, 0x0, 0x0, 0x3d, 0x2, 0x0, 0x0, 0xb, 0x3, 0x0, 0x0, 0xf7, 0x3, 0x0, 0x0, 0x1, 0x5, 0x0, 0x0, 0x28, 0x6, 0x0, 0x0, 0x6d, 0x7, 0x0, 0x0, 0xce, 0x8, 0x0, 0x0, 0x4a, 0xa, 0x0, 0x0, 0xe2, 0xb, 0x0, 0x0, 0x93, 0xd, 0x0, 0x0, 0x5e, 0xf, 0x0, 0x0, 0x40, 0x11, 0x0, 0x0, 0x3b, 0x13, 0x0, 0x0, 0x4b, 0x15, 0x0, 0x0, 0x71, 0x17, 0x0, 0x0, 0xaa, 0x19, 0x0, 0x0, 0xf7, 0x1b, 0x0, 0x0, 0x55, 0x1e, 0x0, 0x0, 0xc4, 0x20, 0x0, 0x0, 0x42, 0x23, 0x0, 0x0, 0xce, 0x25, 0x0, 0x0, 0x66, 0x28, 0x0, 0x0, 0xa, 0x2b, 0x0, 0x0, 0xb7, 0x2d, 0x0, 0x0, 0x6d, 0x30, 0x0, 0x0, 0x29, 0x33, 0x0, 0x0, 0xeb, 0x35, 0x0, 0x0, 0xb0, 0x38, 0x0, 0x0, 0x77, 0x3b, 0x0, 0x0, 0x3f, 0x3e, 0x0, 0x0, 0x7, 0x41, 0x0, 0x0, 0xcb, 0x43, 0x0, 0x0, 0x8c, 0x46, 0x0, 0x0, 0x46, 0x49, 0x0, 0x0, 0xf9, 0x4b, 0x0, 0x0, 0xa4, 0x4e, 0x0, 0x0, 0x43, 0x51, 0x0, 0x0, 0xd6, 0x53, 0x0, 0x0, 0x5c, 0x56, 0x0, 0x0, 0xd2, 0x58, 0x0, 0x0, 0x36, 0x5b, 0x0, 0x0, 0x88, 0x5d, 0x0, 0x0, 0xc6, 0x5f, 0x0, 0x0, 0xee, 0x61, 0x0, 0x0, 0xfe, 0x63} - converted := oto.FloatBufferTo16BitLE(floats, nil) - for i, v := range converted { - if bytes[i] != v { - t.Fail() - t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i) - } - } - if !reflect.DeepEqual(converted, bytes) { - t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE") - } -} - -func TestFloatBufferToBytesLimits(t *testing.T) { - floats := sointu.AudioBuffer{{0, 1}, {-1, 0.999}, {-0.999, 0}} - bytes := []byte{ - 0x0, 0x0, - 0xFF, 0x7F, // float 1 = 0x7FFF = 0111111111111111 - 0x01, 0x80, // float -1 = 0x8001 = 1000000000000001 - 0xDE, 0x7F, // float 0.999 = 0x7FDE = 0111111111011110 - 0x22, 0x80, // float -0.999 = 0x8022 = 1000000000100010 - 0x0, 0x0, - } - converted := oto.FloatBufferTo16BitLE(floats, nil) - for i, v := range converted { - if bytes[i] != v { - t.Fail() - t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i) - } - } - if !reflect.DeepEqual(converted, bytes) { - t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE") - } -} diff --git a/oto/oto.go b/oto/oto.go index 17e7183..d0028a8 100644 --- a/oto/oto.go +++ b/oto/oto.go @@ -1,55 +1,99 @@ package oto import ( + "encoding/binary" + "errors" "fmt" + "math" + "sync" - "github.com/hajimehoshi/oto" + "github.com/ebitengine/oto/v3" "github.com/vsariola/sointu" ) -type OtoContext oto.Context -type OtoOutput struct { - player *oto.Player - tmpBuffer []byte -} +const latency = 2048 // in samples at 44100 Hz = ~46 ms -func (c *OtoContext) Output() sointu.AudioOutput { - return &OtoOutput{player: (*oto.Context)(c).NewPlayer(), tmpBuffer: make([]byte, 0)} -} +type ( + OtoContext oto.Context -const otoBufferSize = 8192 + OtoPlayer struct { + player *oto.Player + reader *OtoReader + } + + OtoReader struct { + audioSource sointu.AudioSource + tmpBuffer sointu.AudioBuffer + waitGroup sync.WaitGroup + err error + errMutex sync.RWMutex + } +) -// NewPlayer creates and initializes a new OtoPlayer func NewContext() (*OtoContext, error) { - context, err := oto.NewContext(44100, 2, 2, otoBufferSize) + op := oto.NewContextOptions{} + op.SampleRate = 44100 + op.ChannelCount = 2 + op.Format = oto.FormatFloat32LE + context, readyChan, err := oto.NewContext(&op) if err != nil { return nil, fmt.Errorf("cannot create oto context: %w", err) } + <-readyChan return (*OtoContext)(context), nil } -func (c *OtoContext) Close() error { - if err := (*oto.Context)(c).Close(); err != nil { - return fmt.Errorf("cannot close oto context: %w", err) - } - return nil +func (c *OtoContext) Play(r sointu.AudioSource) sointu.CloserWaiter { + reader := &OtoReader{audioSource: r} + reader.waitGroup.Add(1) + player := (*oto.Context)(c).NewPlayer(reader) + player.SetBufferSize(latency * 8) + player.Play() + return OtoPlayer{player: player, reader: reader} } -// Play implements the audio.Player interface for OtoPlayer -func (o *OtoOutput) WriteAudio(floatBuffer sointu.AudioBuffer) (err error) { - // we reuse the old capacity tmpBuffer by setting its length to zero. then, - // we save the tmpBuffer so we can reuse it next time - o.tmpBuffer = FloatBufferTo16BitLE(floatBuffer, o.tmpBuffer[:0]) - if _, err := o.player.Write(o.tmpBuffer); err != nil { - return fmt.Errorf("cannot write to player: %w", err) - } - return nil +func (o OtoPlayer) Wait() { + o.reader.waitGroup.Wait() } -// Close disposes of resources -func (o *OtoOutput) Close() error { - if err := o.player.Close(); err != nil { - return fmt.Errorf("cannot close oto player: %w", err) - } - return nil +func (o OtoPlayer) Close() error { + o.reader.closeWithError(errors.New("OtoPlayer was closed")) + return o.player.Close() +} + +func (o *OtoReader) Read(b []byte) (n int, err error) { + o.errMutex.RLock() + if o.err != nil { + o.errMutex.RUnlock() + return 0, o.err + } + o.errMutex.RUnlock() + if len(b)%8 != 0 { + return o.closeWithError(fmt.Errorf("oto: Read buffer length must be a multiple of 8")) + } + samples := len(b) / 8 + if samples > len(o.tmpBuffer) { + o.tmpBuffer = append(o.tmpBuffer, make(sointu.AudioBuffer, samples-len(o.tmpBuffer))...) + } else if samples < len(o.tmpBuffer) { + o.tmpBuffer = o.tmpBuffer[:samples] + } + err = o.audioSource.ReadAudio(o.tmpBuffer) + if err != nil { + return o.closeWithError(err) + } + for i := range o.tmpBuffer { + binary.LittleEndian.PutUint32(b[i*8:], math.Float32bits(o.tmpBuffer[i][0])) + binary.LittleEndian.PutUint32(b[i*8+4:], math.Float32bits(o.tmpBuffer[i][1])) + } + return samples * 8, nil +} + +func (o *OtoReader) closeWithError(err error) (int, error) { + o.errMutex.Lock() + defer o.errMutex.Unlock() + if o.err == nil { + o.err = err + o.waitGroup.Done() + } + return 0, err }