8 Commits

Author SHA1 Message Date
2af3583f6f Ogg streaming + better tests 2022-06-27 03:36:39 +04:00
daf46e5d41 Properly stream wav files+reduce effect of oto bug 2022-06-27 01:54:22 +04:00
8f19e800f1 Make int16->uint16 conversion more accurate 2022-06-26 20:04:06 +04:00
aa751bbedd Update docs to mention ogg 2022-06-26 06:21:35 +04:00
989547c14e Ogg file support 2022-06-26 06:19:58 +04:00
2d0f00175a Break some comments in README 2022-06-26 03:38:02 +04:00
2251ba1a39 Docs + SeekToTime comment 2022-06-26 03:34:04 +04:00
0963aec024 Tidy 2022-06-26 02:34:29 +04:00
10 changed files with 535 additions and 23 deletions

197
README.md
View File

@ -1,2 +1,197 @@
# wavy
Wavy is a high-level sound library for Go built on top of https://github.com/hajimehoshi/oto
Wavy is a high-level, easy to use, and cross-platform Go sound library built on top of <https://github.com/hajimehoshi/oto>.
Wavy supports both streaming sounds from disk and playing from memory.
- [wavy](#wavy)
- [Supported Platforms](#supported-platforms)
- [Supported audio formats](#supported-audio-formats)
- [Usage](#usage)
- [Installation](#installation)
- [Basics](#basics)
- [Controls](#controls)
## Supported Platforms
Supported platforms are:
- Windows
- macOS
- Linux
- FreeBSD
- OpenBSD
- Android
- iOS
- WebAssembly
If you are using iOS or Linux please check [this](https://github.com/hajimehoshi/oto#prerequisite).
## Supported audio formats
- MP3 (`.mp3`)
- Wav (`.wav`/`.wave`)
- OGG (`.ogg`)
## Usage
### Installation
First install Wavy with `go get github.com/bloeys/wavy`, and if you are using iOS or Linux check [this](https://github.com/hajimehoshi/oto#prerequisite).
### Basics
You can start playing sounds with a few lines:
```go
import (
"github.com/bloeys/wavy"
)
func main() {
//At the start if your program you should init wavy and tell it the
//sampling rate of your sounds (usually 44100), the number of
//channels (usually 2) and the number of bytes per channel (usually 2).
//
//These settings will be used for all sounds regardless of their actual settings
err := wavy.Init(wavy.SampleRate_44100, wavy.SoundChannelCount_2, wavy.SoundBitDepth_2)
if err != nil {
panic("Failed to init wavy. Err: " + err.Error())
}
//Here we load a sound into memory
mySound, err := wavy.NewSoundMem("./my-sound.mp3")
if err != nil {
panic("Failed to create new sound. Err: " + err.Error())
}
//Now we set volume of this sound to 50% then play the sound
//and wait for it to finish (PlayAsync plays in the background)
mySound.SetVolume(0.5)
mySound.PlaySync()
//Since the sound finished playing, lets reset to start
//by seeking to 0% then play again. Seeking to 0.5 then playing
//will start from the middle the sound.
mySound.SeekToPercent(0)
mySound.PlayAsync()
//The sound is playing the background, so lets wait for it to finish
mySound.Wait()
}
```
If you are dealing with large sound files you might want to stream from a file (play as you go), this will only
use a small amount of memory, but is less flexible and might be slower to seek.
Here is an example streaming a sound:
```go
//Here we load a sound into memory
mySound, err := wavy.NewSoundStreaming("./my-sound.mp3")
if err != nil {
panic("Failed to create new sound. Err: " + err.Error())
}
//Rest is the same...
```
### Controls
Once you have loaded a sound you can:
- Pause/Resume
- Set volume per sound
- Play synchronously or asynchronously
- Loop a number of times or infinitely
- Check total play time and remaining time
- Seek to any position (by percent or time) of the sound even when its already playing
- Wait for a sound to finish playing once
- Wait for a looping sound to finish all its repeats
- (only in-memory) Load it once but have many versions play from different positions simultaneously (e.g. one gun starting to shoot, another ending its shot sound)
- (only in-memory) Take a short clip from a sound (e.g. keep only the first half of the sound)
Code examples of everything:
```go
//Load wav into memory
mySound, err := wavy.NewSoundMem("./my-sound.wav")
if err != nil {
panic("Failed to create new sound. Err: " + err.Error())
}
//Play for ~1s then Pause
mySound.PlayAsync()
time.sleep(1 * time.Second)
mySound.Pause()
//Resume and play till end
mySound.PlaySync()
//Set volume to 25%
mySound.SetVolume(0.25)
//Play the sound three times and wait for all 3 plays to finish.
//Negative numbers will play infinitely till paused
mySound.LoopAsync(3)
mySound.WaitLoop()
//Check playtime
println("Time to play full sound:", mySound.TotalTime().Seconds())
println("Time remaining till sound finishes:", mySound.RemainingTime().Seconds())
//Play sound from the middle
mySound.SeekToPercent(0.5)
mySound.PlaySync()
//Play sound from time=5s
mySound.SeekToTime(5 * time.Second)
mySound.PlaySync()
//Wait for sound to finish if started async
mySound.Wait()
//Start looping infinitely then stop
mySound.LoopAsync(-1)
time.Sleep(1 * time.Second)
mySound.Pause()
//
// Things only possible for in-memory sounds
//
//1. Playing sound many times simultaneously without loading it multiple times
//We reuse the underlying sound data but get two independent sounds with their own controls!
//This operation is fast so you can do it a lot
mySound2 := CopyInMemSound(mySound)
//Set one to play from the beginning and the other to play from the middle
mySound.SeekToPercent(0)
mySound2.SeekToPercent(0.5)
//Play both simultaneously
mySound.PlayAsync()
mySound2.PlayAsync()
//Wait for both to finish
mySound.Wait()
mySound2.Wait()
//2. Cut parts of a sound
//Here we get a new sound that only has the first half of the sound.
//This operation is very quick and does not duplicate the underlying data
clippedSound := ClipInMemSoundPercent(mySound, 0, 0.5)
clippedSound.PlaySync()
```
Aside from per sound controls, there are a few global controls:
```go
wavy.PauseAllSounds()
wavy.ResumeAllSounds()
```

4
go.mod
View File

@ -3,13 +3,15 @@ module github.com/bloeys/wavy
go 1.18
require (
github.com/go-audio/wav v1.1.0
github.com/hajimehoshi/go-mp3 v0.3.3
github.com/hajimehoshi/oto/v2 v2.1.0
github.com/jfreymuth/oggvorbis v1.0.3
)
require (
github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/go-audio/wav v1.1.0 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect
)

4
go.sum
View File

@ -9,6 +9,10 @@ github.com/hajimehoshi/go-mp3 v0.3.3/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0Ubt
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/hajimehoshi/oto/v2 v2.1.0 h1:/h+UkbKzhD7xBHOQlWgKUplBPZ+J4DK3P2Y7g2UF1X4=
github.com/hajimehoshi/oto/v2 v2.1.0/go.mod h1:9i0oYbpJ8BhVGkXDKdXKfFthX1JUNfXjeTp944W8TGM=
github.com/jfreymuth/oggvorbis v1.0.3 h1:MLNGGyhOMiVcvea9Dp5+gbs2SAwqwQbtrWnonYa0M0Y=
github.com/jfreymuth/oggvorbis v1.0.3/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=

75
ogg_streamer.go Executable file
View File

@ -0,0 +1,75 @@
package wavy
import (
"io"
"os"
"sync"
"github.com/jfreymuth/oggvorbis"
)
var _ io.ReadSeeker = &OggStreamer{}
type OggStreamer struct {
F *os.File
Dec *oggvorbis.Reader
//TODO: This is currently needed because of https://github.com/hajimehoshi/oto/issues/171
//We should be able to delete once its resolved
mutex sync.Mutex
}
func (ws *OggStreamer) Read(outBuf []byte) (floatsRead int, err error) {
ws.mutex.Lock()
readerBuf := make([]float32, len(outBuf)/2)
floatsRead, err = ws.Dec.Read(readerBuf)
F32ToUnsignedPCM16(readerBuf[:floatsRead], outBuf)
ws.mutex.Unlock()
return floatsRead * 2, err
}
func (ws *OggStreamer) Seek(offset int64, whence int) (int64, error) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
//This is because ogg expects position in samples not bytes
offset /= BytesPerSample
switch whence {
case io.SeekStart:
if err := ws.Dec.SetPosition(offset); err != nil {
return 0, err
}
case io.SeekCurrent:
if err := ws.Dec.SetPosition(ws.Dec.Position() + offset); err != nil {
return 0, err
}
case io.SeekEnd:
if err := ws.Dec.SetPosition(ws.Dec.Length() + offset); err != nil {
return 0, err
}
}
return ws.Dec.Position() * BytesPerSample, nil
}
//Size returns number of bytes
func (ws *OggStreamer) Size() int64 {
return ws.Dec.Length() * BytesPerSample
}
func NewOggStreamer(f *os.File, dec *oggvorbis.Reader) *OggStreamer {
return &OggStreamer{
F: f,
Dec: dec,
mutex: sync.Mutex{},
}
}

View File

@ -6,6 +6,7 @@ const (
SoundType_Unknown SoundType = iota
SoundType_MP3
SoundType_WAV
SoundType_OGG
)
type SampleRate int

BIN
test_audio_files/camera.mp3 Executable file

Binary file not shown.

BIN
test_audio_files/camera.ogg Executable file

Binary file not shown.

94
wav_streamer.go Executable file
View File

@ -0,0 +1,94 @@
package wavy
import (
"io"
"os"
"sync"
"github.com/go-audio/wav"
)
var _ io.ReadSeeker = &WavStreamer{}
type WavStreamer struct {
F *os.File
Dec *wav.Decoder
Pos int64
PCMStart int64
//TODO: This is currently needed because of https://github.com/hajimehoshi/oto/issues/171
//We should be able to delete once its resolved
mutex sync.Mutex
}
func (ws *WavStreamer) Read(outBuf []byte) (bytesRead int, err error) {
ws.mutex.Lock()
bytesRead, err = ws.Dec.PCMChunk.Read(outBuf)
ws.Pos += int64(bytesRead)
ws.mutex.Unlock()
return bytesRead, err
}
func (ws *WavStreamer) Seek(offset int64, whence int) (int64, error) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
//This will only seek the underlying file but not the actual decoder because it can't seek
n, err := ws.Dec.Seek(offset, whence)
if err != nil {
return n, err
}
//Since underlying decoder can't seek back, if the requested movement is back we have to rewind the decoder
//then seek forward to the requested position.
if n < ws.Pos {
err = ws.Dec.Rewind()
if err != nil {
return 0, err
}
//Anything before PCMStart is not valid sound, so the minimum seek back we allow is PCMStart
if n < ws.PCMStart {
n = ws.PCMStart
} else {
n, err = ws.Dec.Seek(offset, whence)
if err != nil {
return n, err
}
}
}
ws.Pos = n
return n, err
}
//Size returns number of bytes
func (ws *WavStreamer) Size() int64 {
return ws.Dec.PCMLen()
}
func NewWavStreamer(f *os.File, wavDec *wav.Decoder) (*WavStreamer, error) {
err := wavDec.FwdToPCM()
if err != nil {
return nil, err
}
//The actual data starts somewhat within the file, not at 0
currPos, err := wavDec.Seek(0, 1)
if err != nil {
return nil, err
}
return &WavStreamer{
F: f,
Dec: wavDec,
Pos: currPos,
PCMStart: currPos,
mutex: sync.Mutex{},
}, nil
}

127
wavy.go
View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"math"
"os"
"path"
"time"
@ -12,6 +13,7 @@ import (
"github.com/go-audio/wav"
"github.com/hajimehoshi/go-mp3"
"github.com/hajimehoshi/oto/v2"
"github.com/jfreymuth/oggvorbis"
)
//SoundInfo contains static info about a loaded sound file
@ -19,7 +21,6 @@ type SoundInfo struct {
Type SoundType
Mode SoundMode
//Size is the sound's size in bytes
Size int64
}
@ -232,19 +233,23 @@ func (s *Sound) IsPlaying() bool {
//percent is clamped [0,1], so passing <0 is the same as zero, and >1 is the same as 1
func (s *Sound) SeekToPercent(percent float64) {
if !s.IsPlaying() {
s.Player.Reset()
}
percent = clamp01F64(percent)
s.Data.Seek(int64(float64(s.Info.Size)*percent), io.SeekStart)
}
func (s *Sound) SeekToTime(t time.Duration) {
//NOTE: Due to https://github.com/hajimehoshi/oto/issues/171, it is safer to seek before reset so we don't seek while a read is happening.
//This can still happen though if for example sound was paused midway then seeked, as read would be getting called
if !s.IsPlaying() {
s.Player.Reset()
}
}
//SeekToTime moves the current position of the sound to the given duration.
//For example if you use t=5*time.Second then play you will start from 5th second.
//
//This can be used while the sound is playing.
//
//t is clamped between [0, totalTime]
func (s *Sound) SeekToTime(t time.Duration) {
byteCount := ByteCountFromPlayTime(t)
if byteCount < 0 {
@ -254,6 +259,10 @@ func (s *Sound) SeekToTime(t time.Duration) {
}
s.Data.Seek(byteCount, io.SeekStart)
if !s.IsPlaying() {
s.Player.Reset()
}
}
func (s *Sound) IsClosed() bool {
@ -373,7 +382,7 @@ func NewSoundStreaming(fpath string) (s *Sound, err error) {
},
}
err = soundFromReaderSeeker(file, s)
err = soundFromFile(file, s)
if err != nil {
return nil, getLoadingErr(fpath, err)
}
@ -381,6 +390,49 @@ func NewSoundStreaming(fpath string) (s *Sound, err error) {
return s, nil
}
func soundFromFile(f *os.File, s *Sound) error {
if s.Info.Type == SoundType_MP3 {
dec, err := mp3.NewDecoder(f)
if err != nil {
return err
}
s.Data = dec
s.Player = Ctx.NewPlayer(dec)
s.Info.Size = dec.Length()
} else if s.Info.Type == SoundType_WAV {
ws, err := NewWavStreamer(f, wav.NewDecoder(f))
if err != nil {
return err
}
s.Data = ws
s.Player = Ctx.NewPlayer(ws)
s.Info.Size = ws.Size()
} else if s.Info.Type == SoundType_OGG {
oggReader, err := oggvorbis.NewReader(f)
if err != nil {
return err
}
oggStreamer := NewOggStreamer(f, oggReader)
s.Data = oggStreamer
s.Player = Ctx.NewPlayer(oggStreamer)
s.Info.Size = oggStreamer.Size()
}
if s.Data == nil {
panic("invalid sound type. This is probably a bug!")
}
return nil
}
//NewSoundMem loads the entire sound file into memory
func NewSoundMem(fpath string) (s *Sound, err error) {
@ -402,7 +454,7 @@ func NewSoundMem(fpath string) (s *Sound, err error) {
},
}
err = soundFromReaderSeeker(bytesReader, s)
err = decodeSoundFromReaderSeeker(bytesReader, s)
if err != nil {
return nil, getLoadingErr(fpath, err)
}
@ -414,7 +466,9 @@ func getLoadingErr(fpath string, err error) error {
return fmt.Errorf("failed to load '%s' with err '%s'", fpath, err.Error())
}
func soundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
//decodeSoundFromReaderSeeker reads and decodes till EOF, and places the final
//PCM16 data in a buffer, thus producing an in-memory sound
func decodeSoundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
if s.Info.Type == SoundType_MP3 {
@ -432,8 +486,6 @@ func soundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
s.Data = sb
s.Player = Ctx.NewPlayer(sb)
s.Info.Size = int64(len(sb.Data))
return nil
} else if s.Info.Type == SoundType_WAV {
wavDec := wav.NewDecoder(r)
@ -451,10 +503,24 @@ func soundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
s.Data = sb
s.Player = Ctx.NewPlayer(sb)
s.Info.Size = int64(len(sb.Data))
return nil
} else if s.Info.Type == SoundType_OGG {
soundData, _, err := oggvorbis.ReadAll(r)
if err != nil {
return err
}
sb := &SoundBuffer{Data: F32ToUnsignedPCM16(soundData, nil)}
s.Data = sb
s.Player = Ctx.NewPlayer(sb)
s.Info.Size = int64(len(sb.Data))
}
panic("invalid sound type")
if s.Data == nil {
panic("invalid sound type. This is probably a bug!")
}
return nil
}
func GetSoundFileType(fpath string) SoundType {
@ -465,6 +531,8 @@ func GetSoundFileType(fpath string) SoundType {
return SoundType_MP3
case ".wav", ".wave":
return SoundType_WAV
case ".ogg":
return SoundType_OGG
default:
return SoundType_Unknown
}
@ -528,3 +596,32 @@ func clamp01F64(x float64) float64 {
return x
}
//F32ToUnsignedPCM16 takes PCM data stored as float32 between [-1, 1]
//and returns a byte array of uint16, where each two subsequent bytes represent one uint16.
func F32ToUnsignedPCM16(fs []float32, outBuf []byte) []byte {
if outBuf == nil {
outBuf = make([]byte, len(fs)*2)
}
for i := 0; i < len(fs); i++ {
//Remap [-1,1]->[-32768, 32767], then re-interprets the int16 as a uint16.
//With this, the negative values are mapped into the higher half of the uint16 range,
//while positive values remain unchanged
x := fs[i]
var u16 uint16
if x < 0 {
u16 = uint16(x * -math.MinInt16)
} else {
u16 = uint16(x * math.MaxInt16)
}
baseIndex := i * 2
outBuf[baseIndex] = byte(u16 >> 0)
outBuf[baseIndex+1] = byte(u16 >> 8)
}
return outBuf
}

View File

@ -7,19 +7,29 @@ import (
"github.com/bloeys/wavy"
)
func TestSound(t *testing.T) {
func TestWavy(t *testing.T) {
t.Run("Init", TestInit)
t.Run("MP3", MP3Subtest)
t.Run("Wav", WavSubtest)
t.Run("Ogg", OggSubtest)
}
func TestInit(t *testing.T) {
err := wavy.Init(wavy.SampleRate_44100, wavy.SoundChannelCount_2, wavy.SoundBitDepth_2)
if err != nil {
t.Errorf("Failed to init wavy. Err: %s\n", err)
return
}
}
func MP3Subtest(t *testing.T) {
const fatihaFilepath = "./test_audio_files/Fatiha.mp3"
const tadaFilepath = "./test_audio_files/tada.mp3"
const fatihaLenMS = 55484
//Streaming
//Mp3 streaming
s, err := wavy.NewSoundStreaming(fatihaFilepath)
if err != nil {
t.Errorf("Failed to load streaming sound with path '%s'. Err: %s\n", fatihaFilepath, err)
@ -47,7 +57,7 @@ func TestSound(t *testing.T) {
return
}
//In-Memory
//Mp3 in-memory
s, err = wavy.NewSoundMem(fatihaFilepath)
if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", fatihaFilepath, err)
@ -75,7 +85,7 @@ func TestSound(t *testing.T) {
return
}
//Memory 'tada.mp3'
//'tada.mp3' memory
s, err = wavy.NewSoundMem(tadaFilepath)
if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", tadaFilepath, err)
@ -103,15 +113,49 @@ func TestSound(t *testing.T) {
s3 := wavy.ClipInMemSoundPercent(s2, 0, 0.25)
s3.LoopAsync(3)
s3.WaitLoop()
}
func WavSubtest(t *testing.T) {
//Wav
const wavFPath = "./test_audio_files/camera.wav"
s, err = wavy.NewSoundMem(wavFPath)
s, err := wavy.NewSoundMem(wavFPath)
if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", wavFPath, err)
return
}
s.PlaySync()
//Wav streaming
s, err = wavy.NewSoundStreaming(wavFPath)
if err != nil {
t.Errorf("Failed to load streaming sound with path '%s'. Err: %s\n", wavFPath, err)
return
}
s.PlaySync()
s.SeekToPercent(0.5)
s.PlaySync()
}
func OggSubtest(t *testing.T) {
const oggFPath = "./test_audio_files/camera.ogg"
s, err := wavy.NewSoundMem(oggFPath)
if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", oggFPath, err)
return
}
s.PlaySync()
//Ogg streaming
s, err = wavy.NewSoundStreaming(oggFPath)
if err != nil {
t.Errorf("Failed to load streaming sound with path '%s'. Err: %s\n", oggFPath, err)
return
}
s.PlaySync()
s.SeekToPercent(.5)
s.PlaySync()
}
func TestByteCountFromPlayTime(t *testing.T) {