diff --git a/examples/midi/Buy to the Beat - V2 License.md b/examples/midi/Buy to the Beat - V2 License.md new file mode 100644 index 0000000..2d36033 --- /dev/null +++ b/examples/midi/Buy to the Beat - V2 License.md @@ -0,0 +1,6 @@ +# Buy to the Beat - V2 + +Copyright (c) 2021 Robert Oost
+Soundcloud: https://soundcloud.com/sponsrob + +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) License. To view a copy of the license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/. diff --git a/examples/midi/Buy to the Beat - V2.mid b/examples/midi/Buy to the Beat - V2.mid new file mode 100644 index 0000000..37d195a Binary files /dev/null and b/examples/midi/Buy to the Beat - V2.mid differ diff --git a/examples/midi/Florestan-Basic-GM-GS License.md b/examples/midi/Florestan-Basic-GM-GS License.md new file mode 100644 index 0000000..a513800 --- /dev/null +++ b/examples/midi/Florestan-Basic-GM-GS License.md @@ -0,0 +1,7 @@ +# Florestan Basic GM GS + +Author: Nando Florestan + +This sound font is in the public domain. + +Source: [Internet Archive](https://archive.org/details/sf2-soundfonts-free-use) diff --git a/examples/midi/Florestan-Basic-GM-GS-by-Nando-Florestan(Public-Domain).sf2 b/examples/midi/Florestan-Basic-GM-GS-by-Nando-Florestan(Public-Domain).sf2 new file mode 100644 index 0000000..74f5e7d Binary files /dev/null and b/examples/midi/Florestan-Basic-GM-GS-by-Nando-Florestan(Public-Domain).sf2 differ diff --git a/examples/midi/main.go b/examples/midi/main.go new file mode 100644 index 0000000..b7bb837 --- /dev/null +++ b/examples/midi/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/gopxl/beep" + "github.com/gopxl/beep/midi" + "github.com/gopxl/beep/speaker" +) + +func main() { + var sampleRate beep.SampleRate = 44100 + + err := speaker.Init(sampleRate, sampleRate.N(time.Second/30)) + if err != nil { + log.Fatal(err) + } + + // Load a soundfont. + soundFontFile, err := os.Open("Florestan-Basic-GM-GS-by-Nando-Florestan(Public-Domain).sf2") + if err != nil { + log.Fatal(err) + } + soundFont, err := midi.NewSoundFont(soundFontFile) + if err != nil { + log.Fatal(err) + } + + // Load a midi track. + midiFile, err := os.Open("Buy to the Beat - V2.mid") + if err != nil { + log.Fatal(err) + } + s, format, err := midi.Decode(midiFile, soundFont, sampleRate) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Song duration: %v\n", format.SampleRate.D(s.Len())) + speaker.PlayAndWait(s) +} diff --git a/go.mod b/go.mod index cd9bdf1..5fe7c67 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,19 @@ go 1.21 require ( github.com/ebitengine/oto/v3 v3.2.0 + github.com/ebitengine/purego v0.7.1 github.com/gdamore/tcell/v2 v2.7.4 github.com/hajimehoshi/go-mp3 v0.3.4 github.com/jfreymuth/oggvorbis v1.0.5 github.com/mewkiz/flac v1.0.10 github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e github.com/pkg/errors v0.9.1 + github.com/samhocevar/go-meltysynth v0.0.0-20230403180939-aca4a036cb16 github.com/stretchr/testify v1.9.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/ebitengine/purego v0.7.1 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jfreymuth/vorbis v1.0.2 // indirect diff --git a/go.sum b/go.sum index 04608dd..0abb9c4 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samhocevar/go-meltysynth v0.0.0-20230403180939-aca4a036cb16 h1:slzh3BWJ6FyMM8gkDzDwHz+gjU4+82ldB6oPyYi82Ho= +github.com/samhocevar/go-meltysynth v0.0.0-20230403180939-aca4a036cb16/go.mod h1:J+GU4sgu3oAPHCceoTIXNKzFHSybNhF/LyFkWZlqhvE= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/midi/decode.go b/midi/decode.go new file mode 100644 index 0000000..057e5c7 --- /dev/null +++ b/midi/decode.go @@ -0,0 +1,139 @@ +// Package midi implements audio data decoding in MIDI format. +package midi + +import ( + "fmt" + "io" + + "github.com/pkg/errors" + "github.com/samhocevar/go-meltysynth/meltysynth" + + "github.com/gopxl/beep" +) + +const ( + midiNumChannels = 2 + midiPrecision = 4 +) + +// NewSoundFont reads a sound font containing instruments. A sound font is required in order to play MIDI files. +// +// NewSoundFont closes the supplied ReadCloser. +func NewSoundFont(r io.ReadCloser) (*SoundFont, error) { + sf, err := meltysynth.NewSoundFont(r) + if err != nil { + return nil, err + } + err = r.Close() + if err != nil { + return nil, err + } + return &SoundFont{sf}, nil +} + +type SoundFont struct { + sf *meltysynth.SoundFont +} + +// Decode takes a ReadCloser containing audio data in MIDI format and a SoundFont to synthesize the sounds +// and returns a beep.StreamSeeker, which streams the audio. +// +// Decode closes the supplied ReadCloser. +func Decode(rc io.ReadCloser, sf *SoundFont, sampleRate beep.SampleRate) (s beep.StreamSeeker, format beep.Format, err error) { + defer func() { + if err != nil { + err = errors.Wrap(err, "midi") + } + }() + + settings := meltysynth.NewSynthesizerSettings(int32(sampleRate)) + synth, err := meltysynth.NewSynthesizer(sf.sf, settings) + if err != nil { + return nil, beep.Format{}, err + } + + mf, err := meltysynth.NewMidiFile(rc) + if err != nil { + return nil, beep.Format{}, err + } + err = rc.Close() + if err != nil { + return nil, beep.Format{}, err + } + + seq := meltysynth.NewMidiFileSequencer(synth) + seq.Play(mf /*loop*/, false) + + format = beep.Format{ + SampleRate: sampleRate, + NumChannels: midiNumChannels, + Precision: midiPrecision, + } + + return &decoder{ + synth: synth, + mf: mf, + seq: seq, + sampleRate: sampleRate, + bufLeft: make([]float32, 512), + bufRight: make([]float32, 512), + }, format, nil +} + +type decoder struct { + synth *meltysynth.Synthesizer + mf *meltysynth.MidiFile + seq *meltysynth.MidiFileSequencer + sampleRate beep.SampleRate + bufLeft, bufRight []float32 + err error +} + +func (d *decoder) Stream(samples [][2]float64) (n int, ok bool) { + if d.err != nil { + return 0, false + } + + samplesLeft := d.Len() - d.Position() + if len(samples) > samplesLeft { + samples = samples[:samplesLeft] + } + + for len(samples) > 0 { + cn := len(d.bufLeft) + if cn > len(samples) { + cn = len(samples) + } + + d.seq.Render(d.bufLeft[:cn], d.bufRight[:cn]) + for i := 0; i < cn; i++ { + samples[i][0] = float64(d.bufLeft[i]) + samples[i][1] = float64(d.bufRight[i]) + } + + samples = samples[cn:] + n += cn + } + + return n, n > 0 +} + +func (d *decoder) Err() error { + return d.err +} + +func (d *decoder) Len() int { + return d.sampleRate.N(d.mf.GetLength()) +} + +func (d *decoder) Position() int { + return d.sampleRate.N(d.seq.Pos()) +} + +func (d *decoder) Seek(p int) error { + if p < 0 || d.Len() < p { + return fmt.Errorf("midi: seek position %v out of range [%v, %v]", p, 0, d.Len()) + } + d.seq.Seek(d.sampleRate.D(p)) + return nil +}