11 Commits
v1.0.0 ... dev

Author SHA1 Message Date
eade35695f Put spaces after // 2022-07-22 17:47:05 +04:00
3d3cdf1e32 Use oto v2.2.0+use oto seeker+remove mutexes 2022-07-22 17:44:49 +04:00
0021d0a7dc Convert init into subtest 2022-06-27 21:26:14 +04:00
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
11 changed files with 600 additions and 109 deletions

197
README.md
View File

@ -1,2 +1,197 @@
# wavy # 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()
```

8
go.mod
View File

@ -3,13 +3,15 @@ module github.com/bloeys/wavy
go 1.18 go 1.18
require ( require (
github.com/go-audio/wav v1.1.0
github.com/hajimehoshi/go-mp3 v0.3.3 github.com/hajimehoshi/go-mp3 v0.3.3
github.com/hajimehoshi/oto/v2 v2.1.0 github.com/hajimehoshi/oto/v2 v2.2.0
github.com/jfreymuth/oggvorbis v1.0.3
) )
require ( require (
github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff 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 golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e // indirect
) )

8
go.sum
View File

@ -9,6 +9,12 @@ 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 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 h1:/h+UkbKzhD7xBHOQlWgKUplBPZ+J4DK3P2Y7g2UF1X4=
github.com/hajimehoshi/oto/v2 v2.1.0/go.mod h1:9i0oYbpJ8BhVGkXDKdXKfFthX1JUNfXjeTp944W8TGM= github.com/hajimehoshi/oto/v2 v2.1.0/go.mod h1:9i0oYbpJ8BhVGkXDKdXKfFthX1JUNfXjeTp944W8TGM=
github.com/hajimehoshi/oto/v2 v2.2.0 h1:qhTriSacJ/2pdONRa90hjTvpEZH7xIP4W3itwYyE1Uk=
github.com/hajimehoshi/oto/v2 v2.2.0/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
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/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/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= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
@ -16,4 +22,6 @@ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e h1:NHvCuwuS43lGnYhten69ZWqi2QOj/CiDNcKbVqwVoew=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

62
ogg_streamer.go Executable file
View File

@ -0,0 +1,62 @@
package wavy
import (
"io"
"os"
"github.com/jfreymuth/oggvorbis"
)
var _ io.ReadSeeker = &OggStreamer{}
type OggStreamer struct {
F *os.File
Dec *oggvorbis.Reader
}
func (ws *OggStreamer) Read(outBuf []byte) (floatsRead int, err error) {
readerBuf := make([]float32, len(outBuf)/2)
floatsRead, err = ws.Dec.Read(readerBuf)
F32ToUnsignedPCM16(readerBuf[:floatsRead], outBuf)
return floatsRead * 2, err
}
func (ws *OggStreamer) Seek(offset int64, whence int) (int64, error) {
// 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,
}
}

View File

@ -5,7 +5,7 @@ import (
"io" "io"
) )
//Pre-defined errors // Pre-defined errors
var ( var (
ErrInvalidWhence = errors.New("invalid whence value. Must be: io.SeekStart, io.SeekCurrent, or io.SeekEnd") ErrInvalidWhence = errors.New("invalid whence value. Must be: io.SeekStart, io.SeekCurrent, or io.SeekEnd")
ErrNegativeSeekPos = errors.New("negative seeker position") ErrNegativeSeekPos = errors.New("negative seeker position")
@ -16,11 +16,11 @@ var _ io.ReadSeeker = &SoundBuffer{}
type SoundBuffer struct { type SoundBuffer struct {
Data []byte Data []byte
//Pos is the starting position of the next read // Pos is the starting position of the next read
Pos int64 Pos int64
} }
//Read only returns io.EOF when bytesRead==0 and no more input is available // Read only returns io.EOF when bytesRead==0 and no more input is available
func (sb *SoundBuffer) Read(outBuf []byte) (bytesRead int, err error) { func (sb *SoundBuffer) Read(outBuf []byte) (bytesRead int, err error) {
bytesRead = copy(outBuf, sb.Data[sb.Pos:]) bytesRead = copy(outBuf, sb.Data[sb.Pos:])
@ -32,10 +32,10 @@ func (sb *SoundBuffer) Read(outBuf []byte) (bytesRead int, err error) {
return bytesRead, nil return bytesRead, nil
} }
//Seek returns the new position. // Seek returns the new position.
//An error is only returned if the whence is invalid or if the resulting position is negative. // An error is only returned if the whence is invalid or if the resulting position is negative.
// //
//If the resulting position is >=len(SoundBuffer.Data) then future Read() calls will return io.EOF // If the resulting position is >=len(SoundBuffer.Data) then future Read() calls will return io.EOF
func (sb *SoundBuffer) Seek(offset int64, whence int) (int64, error) { func (sb *SoundBuffer) Seek(offset int64, whence int) (int64, error) {
newPos := sb.Pos newPos := sb.Pos
@ -58,10 +58,10 @@ func (sb *SoundBuffer) Seek(offset int64, whence int) (int64, error) {
return sb.Pos, nil return sb.Pos, nil
} }
//Copy returns a new SoundBuffer that uses the same `Data` but with an independent ReadSeeker. // Copy returns a new SoundBuffer that uses the same `Data` but with an independent ReadSeeker.
//This allows you to have many readers all reading from different positions of the same buffer. // This allows you to have many readers all reading from different positions of the same buffer.
// //
//The new buffer will have its starting position set to io.SeekStart (`Pos=0`) // The new buffer will have its starting position set to io.SeekStart (`Pos=0`)
func (sb *SoundBuffer) Copy() *SoundBuffer { func (sb *SoundBuffer) Copy() *SoundBuffer {
return &SoundBuffer{ return &SoundBuffer{
Data: sb.Data, Data: sb.Data,

View File

@ -6,6 +6,7 @@ const (
SoundType_Unknown SoundType = iota SoundType_Unknown SoundType = iota
SoundType_MP3 SoundType_MP3
SoundType_WAV SoundType_WAV
SoundType_OGG
) )
type SampleRate int 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.

83
wav_streamer.go Executable file
View File

@ -0,0 +1,83 @@
package wavy
import (
"io"
"os"
"github.com/go-audio/wav"
)
var _ io.ReadSeeker = &WavStreamer{}
type WavStreamer struct {
F *os.File
Dec *wav.Decoder
Pos int64
PCMStart int64
}
func (ws *WavStreamer) Read(outBuf []byte) (bytesRead int, err error) {
bytesRead, err = ws.Dec.PCMChunk.Read(outBuf)
ws.Pos += int64(bytesRead)
return bytesRead, err
}
func (ws *WavStreamer) Seek(offset int64, whence int) (int64, error) {
// 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,
}, nil
}

268
wavy.go
View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"os" "os"
"path" "path"
"time" "time"
@ -12,33 +13,34 @@ import (
"github.com/go-audio/wav" "github.com/go-audio/wav"
"github.com/hajimehoshi/go-mp3" "github.com/hajimehoshi/go-mp3"
"github.com/hajimehoshi/oto/v2" "github.com/hajimehoshi/oto/v2"
"github.com/jfreymuth/oggvorbis"
) )
//SoundInfo contains static info about a loaded sound file // SoundInfo contains static info about a loaded sound file
type SoundInfo struct { type SoundInfo struct {
Type SoundType Type SoundType
Mode SoundMode Mode SoundMode
//Size is the sound's size in bytes
Size int64 Size int64
} }
type Sound struct { type Sound struct {
Player oto.Player Player oto.Player
Info SoundInfo PlayerSeeker io.Seeker
Info SoundInfo
//File is the file descriptor of the sound file being streamed. // File is the file descriptor of the sound file being streamed.
//This is only set if sound is streamed, and is kept to ensure GC doesn't hit it // This is only set if sound is streamed, and is kept to ensure GC doesn't hit it
File *os.File File *os.File
//Data is an io.ReadSeeker over an open file or over a buffer containing the uncompressed sound file. // Data is an io.ReadSeeker over an open file or over a buffer containing the uncompressed sound file.
//Becomes nil after close // Becomes nil after close
Data io.ReadSeeker Data io.ReadSeeker
IsLooping bool IsLooping bool
} }
//Those values are set after Init // Those values are set after Init
var ( var (
Ctx *oto.Context Ctx *oto.Context
@ -50,13 +52,13 @@ var (
BytesPerSecond int64 BytesPerSecond int64
) )
//Pre-defined errors // Pre-defined errors
var ( var (
errUnknownSoundType = errors.New("unknown sound type. Sound file extension must be one of: .mp3") errUnknownSoundType = errors.New("unknown sound type. Sound file extension must be one of: .mp3")
) )
//Init prepares the default audio device and does any required setup. // Init prepares the default audio device and does any required setup.
//It must be called before loading any sounds // It must be called before loading any sounds
func Init(sr SampleRate, chanCount SoundChannelCount, bitDepth SoundBitDepth) error { func Init(sr SampleRate, chanCount SoundChannelCount, bitDepth SoundBitDepth) error {
otoCtx, readyChan, err := oto.NewContext(int(sr), int(chanCount), int(bitDepth)) otoCtx, readyChan, err := oto.NewContext(int(sr), int(chanCount), int(bitDepth))
@ -76,30 +78,30 @@ func Init(sr SampleRate, chanCount SoundChannelCount, bitDepth SoundBitDepth) er
return nil return nil
} }
//Wait blocks until sound finishes playing. If the sound is not playing Wait returns immediately. // Wait blocks until sound finishes playing. If the sound is not playing Wait returns immediately.
//In the worst case (Wait sleeping then sound immediately paused), Wait will block ~4% of the total play time. // In the worst case (Wait sleeping then sound immediately paused), Wait will block ~4% of the total play time.
//In most other cases Wait should be accurate to ~1ms. // In most other cases Wait should be accurate to ~1ms.
// //
//If you want to wait for all loops to finish then use WaitLoop // If you want to wait for all loops to finish then use WaitLoop
func (s *Sound) Wait() { func (s *Sound) Wait() {
if !s.IsPlaying() { if !s.IsPlaying() {
return return
} }
//We wait the remaining time in 25 chunks so that if the sound was paused since wait was called we don't keep blocking // We wait the remaining time in 25 chunks so that if the sound was paused since wait was called we don't keep blocking
sleepTime := s.RemainingTime() / 25 sleepTime := s.RemainingTime() / 25
for s.Player.IsPlaying() { for s.Player.IsPlaying() {
time.Sleep(sleepTime) time.Sleep(sleepTime)
} }
//If there is anything left it should be tiny so we check frequently // If there is anything left it should be tiny so we check frequently
for s.Player.IsPlaying() { for s.Player.IsPlaying() {
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
} }
} }
//WaitLoop waits until the sound is no longer looping // WaitLoop waits until the sound is no longer looping
func (s *Sound) WaitLoop() { func (s *Sound) WaitLoop() {
for s.IsLooping { for s.IsLooping {
@ -107,21 +109,21 @@ func (s *Sound) WaitLoop() {
} }
} }
//PlayAsync plays the sound in the background and returns. // PlayAsync plays the sound in the background and returns.
func (s *Sound) PlayAsync() { func (s *Sound) PlayAsync() {
s.Player.Play() s.Player.Play()
} }
//PlaySync calls PlayAsync() followed by Wait() // PlaySync calls PlayAsync() followed by Wait()
func (s *Sound) PlaySync() { func (s *Sound) PlaySync() {
s.PlayAsync() s.PlayAsync()
s.Wait() s.Wait()
} }
//LoopAsync plays the sound 'timesToPlay' times. // LoopAsync plays the sound 'timesToPlay' times.
//If timesToPlay<0 then it is played indefinitely until paused // If timesToPlay<0 then it is played indefinitely until paused
//If timesToPlay==0 then the sound is not played. // If timesToPlay==0 then the sound is not played.
//If a sound is already playing then it will be paused then resumed in a looping manner // If a sound is already playing then it will be paused then resumed in a looping manner
func (s *Sound) LoopAsync(timesToPlay int) { func (s *Sound) LoopAsync(timesToPlay int) {
if timesToPlay == 0 { if timesToPlay == 0 {
@ -149,7 +151,7 @@ func (s *Sound) LoopAsync(timesToPlay int) {
s.Wait() s.Wait()
//Check is here because we don't want to seek back if we got paused // Check is here because we don't want to seek back if we got paused
if !s.IsLooping { if !s.IsLooping {
break break
} }
@ -165,7 +167,7 @@ func (s *Sound) LoopAsync(timesToPlay int) {
timesToPlay-- timesToPlay--
s.Wait() s.Wait()
//Check is here because we don't want to seek back if we got paused // Check is here because we don't want to seek back if we got paused
if !s.IsLooping { if !s.IsLooping {
break break
} }
@ -179,14 +181,14 @@ func (s *Sound) LoopAsync(timesToPlay int) {
}() }()
} }
//TotalTime returns the time taken to play the entire sound. // TotalTime returns the time taken to play the entire sound.
//Safe to use after close // Safe to use after close
func (s *Sound) TotalTime() time.Duration { func (s *Sound) TotalTime() time.Duration {
return PlayTimeFromByteCount(s.Info.Size) return PlayTimeFromByteCount(s.Info.Size)
} }
//RemainingTime returns the time left in the clip, which is affected by pausing/resetting/seeking of the sound. // RemainingTime returns the time left in the clip, which is affected by pausing/resetting/seeking of the sound.
//Returns zero after close // Returns zero after close
func (s *Sound) RemainingTime() time.Duration { func (s *Sound) RemainingTime() time.Duration {
if s.IsClosed() { if s.IsClosed() {
@ -199,8 +201,8 @@ func (s *Sound) RemainingTime() time.Duration {
return PlayTimeFromByteCount(s.Info.Size - currBytePos) return PlayTimeFromByteCount(s.Info.Size - currBytePos)
} }
//SetVolume must be between 0 and 1 (both inclusive). Other values will panic. // SetVolume must be between 0 and 1 (both inclusive). Other values will panic.
//The default volume is 1. // The default volume is 1.
func (s *Sound) SetVolume(newVol float64) { func (s *Sound) SetVolume(newVol float64) {
if newVol < 0 || newVol > 1 { if newVol < 0 || newVol > 1 {
@ -210,7 +212,7 @@ func (s *Sound) SetVolume(newVol float64) {
s.Player.SetVolume(newVol) s.Player.SetVolume(newVol)
} }
//Volume returns the current volume // Volume returns the current volume
func (s *Sound) Volume() float64 { func (s *Sound) Volume() float64 {
return s.Player.Volume() return s.Player.Volume()
} }
@ -224,28 +226,26 @@ func (s *Sound) IsPlaying() bool {
return s.Player.IsPlaying() return s.Player.IsPlaying()
} }
//SeekToPercent moves the current position of the sound to the given percentage of the total sound length. // SeekToPercent moves the current position of the sound to the given percentage of the total sound length.
//For example, if a sound is 10s long and percent=0.5 then when the sound is played it will start from 5s. // For example, if a sound is 10s long and percent=0.5 then when the sound is played it will start from 5s.
// //
//This can be used while the sound is playing. // This can be used while the sound is playing.
// //
//percent is clamped [0,1], so passing <0 is the same as zero, and >1 is the same as 1 // 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) { func (s *Sound) SeekToPercent(percent float64) {
if !s.IsPlaying() {
s.Player.Reset()
}
percent = clamp01F64(percent) percent = clamp01F64(percent)
s.Data.Seek(int64(float64(s.Info.Size)*percent), io.SeekStart) s.PlayerSeeker.Seek(int64(float64(s.Info.Size)*percent), io.SeekStart)
} }
// 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) { func (s *Sound) SeekToTime(t time.Duration) {
if !s.IsPlaying() {
s.Player.Reset()
}
byteCount := ByteCountFromPlayTime(t) byteCount := ByteCountFromPlayTime(t)
if byteCount < 0 { if byteCount < 0 {
byteCount = 0 byteCount = 0
@ -253,15 +253,15 @@ func (s *Sound) SeekToTime(t time.Duration) {
byteCount = s.Info.Size byteCount = s.Info.Size
} }
s.Data.Seek(byteCount, io.SeekStart) s.PlayerSeeker.Seek(byteCount, io.SeekStart)
} }
func (s *Sound) IsClosed() bool { func (s *Sound) IsClosed() bool {
return s.Data == nil return s.Data == nil
} }
//Close will clean underlying resources, and the 'Ctx' and 'Bytes' fields will be made nil. // Close will clean underlying resources, and the 'Ctx' and 'Bytes' fields will be made nil.
//Repeated calls are no-ops // Repeated calls are no-ops
func (s *Sound) Close() error { func (s *Sound) Close() error {
if s.IsClosed() { if s.IsClosed() {
@ -291,10 +291,10 @@ func (s *Sound) Close() error {
return fdErr return fdErr
} }
//CopyInMemSound returns a new sound object that has identitcal info and uses the same underlying data, but with independent play controls (e.g. one playing at the start while one is in the middle). // CopyInMemSound returns a new sound object that has identitcal info and uses the same underlying data, but with independent play controls (e.g. one playing at the start while one is in the middle).
//Since the sound data is not copied this function is very fast. // Since the sound data is not copied this function is very fast.
// //
//Panics if the sound is not in-memory // Panics if the sound is not in-memory
func CopyInMemSound(s *Sound) *Sound { func CopyInMemSound(s *Sound) *Sound {
if s.Info.Mode != SoundMode_Memory { if s.Info.Mode != SoundMode_Memory {
@ -307,15 +307,16 @@ func CopyInMemSound(s *Sound) *Sound {
p.SetVolume(s.Volume()) p.SetVolume(s.Volume())
return &Sound{ return &Sound{
Player: p, Player: p,
File: nil, PlayerSeeker: p.(io.Seeker),
Data: sb, File: nil,
Info: s.Info, Data: sb,
Info: s.Info,
} }
} }
//ClipInMemSoundPercent is like CopyInMemSound but produces a sound that plays only between from and to. // ClipInMemSoundPercent is like CopyInMemSound but produces a sound that plays only between from and to.
//fromPercent and toPercent must be between 0 and 1 // fromPercent and toPercent must be between 0 and 1
func ClipInMemSoundPercent(s *Sound, fromPercent, toPercent float64) *Sound { func ClipInMemSoundPercent(s *Sound, fromPercent, toPercent float64) *Sound {
if s.Info.Mode != SoundMode_Memory { if s.Info.Mode != SoundMode_Memory {
@ -335,10 +336,11 @@ func ClipInMemSoundPercent(s *Sound, fromPercent, toPercent float64) *Sound {
p.SetVolume(s.Volume()) p.SetVolume(s.Volume())
return &Sound{ return &Sound{
Player: p, Player: p,
File: nil, PlayerSeeker: p.(io.Seeker),
Data: sb, File: nil,
Info: s.Info, Data: sb,
Info: s.Info,
} }
} }
@ -350,8 +352,8 @@ func ResumeAllSounds() {
Ctx.Resume() Ctx.Resume()
} }
//NewSoundStreaming plays sound by streaming from a file, so no need to load the entire file into memory. // NewSoundStreaming plays sound by streaming from a file, so no need to load the entire file into memory.
//Good for large sound files // Good for large sound files
func NewSoundStreaming(fpath string) (s *Sound, err error) { func NewSoundStreaming(fpath string) (s *Sound, err error) {
soundType := GetSoundFileType(fpath) soundType := GetSoundFileType(fpath)
@ -359,7 +361,7 @@ func NewSoundStreaming(fpath string) (s *Sound, err error) {
return nil, errUnknownSoundType return nil, errUnknownSoundType
} }
//We read file but don't close so the player can stream the file any time later // We read file but don't close so the player can stream the file any time later
file, err := os.Open(fpath) file, err := os.Open(fpath)
if err != nil { if err != nil {
return nil, err return nil, err
@ -373,7 +375,7 @@ func NewSoundStreaming(fpath string) (s *Sound, err error) {
}, },
} }
err = soundFromReaderSeeker(file, s) err = soundFromFile(file, s)
if err != nil { if err != nil {
return nil, getLoadingErr(fpath, err) return nil, getLoadingErr(fpath, err)
} }
@ -381,7 +383,53 @@ func NewSoundStreaming(fpath string) (s *Sound, err error) {
return s, nil return s, nil
} }
//NewSoundMem loads the entire sound file into memory 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.PlayerSeeker = s.Player.(io.Seeker)
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.PlayerSeeker = s.Player.(io.Seeker)
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.PlayerSeeker = s.Player.(io.Seeker)
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) { func NewSoundMem(fpath string) (s *Sound, err error) {
soundType := GetSoundFileType(fpath) soundType := GetSoundFileType(fpath)
@ -402,7 +450,7 @@ func NewSoundMem(fpath string) (s *Sound, err error) {
}, },
} }
err = soundFromReaderSeeker(bytesReader, s) err = decodeSoundFromReaderSeeker(bytesReader, s)
if err != nil { if err != nil {
return nil, getLoadingErr(fpath, err) return nil, getLoadingErr(fpath, err)
} }
@ -414,7 +462,9 @@ func getLoadingErr(fpath string, err error) error {
return fmt.Errorf("failed to load '%s' with err '%s'", fpath, err.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 { if s.Info.Type == SoundType_MP3 {
@ -431,9 +481,8 @@ func soundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
sb := &SoundBuffer{Data: finalBuf} sb := &SoundBuffer{Data: finalBuf}
s.Data = sb s.Data = sb
s.Player = Ctx.NewPlayer(sb) s.Player = Ctx.NewPlayer(sb)
s.PlayerSeeker = s.Player.(io.Seeker)
s.Info.Size = int64(len(sb.Data)) s.Info.Size = int64(len(sb.Data))
return nil
} else if s.Info.Type == SoundType_WAV { } else if s.Info.Type == SoundType_WAV {
wavDec := wav.NewDecoder(r) wavDec := wav.NewDecoder(r)
@ -450,11 +499,27 @@ func soundFromReaderSeeker(r io.ReadSeeker, s *Sound) error {
sb := &SoundBuffer{Data: finalBuf} sb := &SoundBuffer{Data: finalBuf}
s.Data = sb s.Data = sb
s.Player = Ctx.NewPlayer(sb) s.Player = Ctx.NewPlayer(sb)
s.PlayerSeeker = s.Player.(io.Seeker)
s.Info.Size = int64(len(sb.Data))
} 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.PlayerSeeker = s.Player.(io.Seeker)
s.Info.Size = int64(len(sb.Data)) s.Info.Size = int64(len(sb.Data))
return nil
} }
panic("invalid sound type") if s.Data == nil {
panic("invalid sound type. This is probably a bug!")
}
return nil
} }
func GetSoundFileType(fpath string) SoundType { func GetSoundFileType(fpath string) SoundType {
@ -465,22 +530,24 @@ func GetSoundFileType(fpath string) SoundType {
return SoundType_MP3 return SoundType_MP3
case ".wav", ".wave": case ".wav", ".wave":
return SoundType_WAV return SoundType_WAV
case ".ogg":
return SoundType_OGG
default: default:
return SoundType_Unknown return SoundType_Unknown
} }
} }
//ReadAllFromReader takes an io.Reader and reads until error or io.EOF. // ReadAllFromReader takes an io.Reader and reads until error or io.EOF.
// //
//If io.EOF is reached then read bytes are returned with a nil error. // If io.EOF is reached then read bytes are returned with a nil error.
//If the reader returns an error that's not io.EOF then everything read till that point is returned along with the error // If the reader returns an error that's not io.EOF then everything read till that point is returned along with the error
// //
//readingBufSize is the buffer used to read from reader.Read(). Bigger values might read more efficiently. // readingBufSize is the buffer used to read from reader.Read(). Bigger values might read more efficiently.
//If readingBufSize<4096 then readingBufSize is set to 4096 // If readingBufSize<4096 then readingBufSize is set to 4096
// //
//ouputBufSize is used to set the capacity of the final buffer to be returned. This can greatly improve performance // ouputBufSize is used to set the capacity of the final buffer to be returned. This can greatly improve performance
//if you know the size of the output. It is allowed to have an outputBufSize that's smaller or larger than what the reader // if you know the size of the output. It is allowed to have an outputBufSize that's smaller or larger than what the reader
//ends up returning // ends up returning
func ReadAllFromReader(reader io.Reader, readingBufSize, ouputBufSize uint64) ([]byte, error) { func ReadAllFromReader(reader io.Reader, readingBufSize, ouputBufSize uint64) ([]byte, error) {
if readingBufSize < 4096 { if readingBufSize < 4096 {
@ -503,19 +570,19 @@ func ReadAllFromReader(reader io.Reader, readingBufSize, ouputBufSize uint64) ([
} }
} }
//PlayTimeFromByteCount returns the time taken to play this many bytes // PlayTimeFromByteCount returns the time taken to play this many bytes
func PlayTimeFromByteCount(byteCount int64) time.Duration { func PlayTimeFromByteCount(byteCount int64) time.Duration {
//timeToPlayInMs = timeToPlayInSec * 1000 = byteCount / bytesPerSecond * 1000 // timeToPlayInMs = timeToPlayInSec * 1000 = byteCount / bytesPerSecond * 1000
lenInMs := float64(byteCount) / float64(BytesPerSecond) * 1000 lenInMs := float64(byteCount) / float64(BytesPerSecond) * 1000
return time.Duration(lenInMs) * time.Millisecond return time.Duration(lenInMs) * time.Millisecond
} }
//PlayTimeFromByteCount returns how many bytes are needed to produce a sound that takes t time to play // PlayTimeFromByteCount returns how many bytes are needed to produce a sound that takes t time to play
func ByteCountFromPlayTime(t time.Duration) int64 { func ByteCountFromPlayTime(t time.Duration) int64 {
return t.Milliseconds() * BytesPerSecond / 1000 return t.Milliseconds() * BytesPerSecond / 1000
} }
//clampF64 [min,max] // clampF64 [min,max]
func clamp01F64(x float64) float64 { func clamp01F64(x float64) float64 {
if x < 0 { if x < 0 {
@ -528,3 +595,32 @@ func clamp01F64(x float64) float64 {
return x 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" "github.com/bloeys/wavy"
) )
func TestSound(t *testing.T) { func TestWavy(t *testing.T) {
t.Run("Init", InitSubtest)
t.Run("MP3", MP3Subtest)
t.Run("Wav", WavSubtest)
t.Run("Ogg", OggSubtest)
}
func InitSubtest(t *testing.T) {
err := wavy.Init(wavy.SampleRate_44100, wavy.SoundChannelCount_2, wavy.SoundBitDepth_2) err := wavy.Init(wavy.SampleRate_44100, wavy.SoundChannelCount_2, wavy.SoundBitDepth_2)
if err != nil { if err != nil {
t.Errorf("Failed to init wavy. Err: %s\n", err) t.Errorf("Failed to init wavy. Err: %s\n", err)
return return
} }
}
func MP3Subtest(t *testing.T) {
const fatihaFilepath = "./test_audio_files/Fatiha.mp3" const fatihaFilepath = "./test_audio_files/Fatiha.mp3"
const tadaFilepath = "./test_audio_files/tada.mp3" const tadaFilepath = "./test_audio_files/tada.mp3"
const fatihaLenMS = 55484 const fatihaLenMS = 55484
//Streaming // Mp3 streaming
s, err := wavy.NewSoundStreaming(fatihaFilepath) s, err := wavy.NewSoundStreaming(fatihaFilepath)
if err != nil { if err != nil {
t.Errorf("Failed to load streaming sound with path '%s'. Err: %s\n", fatihaFilepath, err) 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 return
} }
//In-Memory // Mp3 in-memory
s, err = wavy.NewSoundMem(fatihaFilepath) s, err = wavy.NewSoundMem(fatihaFilepath)
if err != nil { if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", fatihaFilepath, err) 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 return
} }
//Memory 'tada.mp3' // 'tada.mp3' memory
s, err = wavy.NewSoundMem(tadaFilepath) s, err = wavy.NewSoundMem(tadaFilepath)
if err != nil { if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", tadaFilepath, err) t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", tadaFilepath, err)
@ -83,17 +93,17 @@ func TestSound(t *testing.T) {
} }
s.PlaySync() s.PlaySync()
//Test repeat playing // Test repeat playing
s2 := wavy.CopyInMemSound(s) s2 := wavy.CopyInMemSound(s)
s2.SetVolume(0.25) s2.SetVolume(0.25)
//Already finished, should not play // Already finished, should not play
s.PlaySync() s.PlaySync()
//Should play from beginning // Should play from beginning
s2.PlaySync() s2.PlaySync()
//Test seek and play // Test seek and play
s2.SeekToPercent(0.2) s2.SeekToPercent(0.2)
s2.PlaySync() s2.PlaySync()
@ -103,15 +113,49 @@ func TestSound(t *testing.T) {
s3 := wavy.ClipInMemSoundPercent(s2, 0, 0.25) s3 := wavy.ClipInMemSoundPercent(s2, 0, 0.25)
s3.LoopAsync(3) s3.LoopAsync(3)
s3.WaitLoop() s3.WaitLoop()
}
func WavSubtest(t *testing.T) {
//Wav
const wavFPath = "./test_audio_files/camera.wav" const wavFPath = "./test_audio_files/camera.wav"
s, err = wavy.NewSoundMem(wavFPath)
s, err := wavy.NewSoundMem(wavFPath)
if err != nil { if err != nil {
t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", wavFPath, err) t.Errorf("Failed to load memory sound with path '%s'. Err: %s\n", wavFPath, err)
return return
} }
s.PlaySync() 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) { func TestByteCountFromPlayTime(t *testing.T) {