Skip to content

Commit

Permalink
Merge pull request #167 from gopxl/midi
Browse files Browse the repository at this point in the history
Add MIDI decoder
  • Loading branch information
MarkKremer authored Jul 31, 2024
2 parents bfe9021 + 387e323 commit 20e0303
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 1 deletion.
6 changes: 6 additions & 0 deletions examples/midi/Buy to the Beat - V2 License.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Buy to the Beat - V2

Copyright (c) 2021 Robert Oost<br>
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/.
Binary file added examples/midi/Buy to the Beat - V2.mid
Binary file not shown.
7 changes: 7 additions & 0 deletions examples/midi/Florestan-Basic-GM-GS License.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file not shown.
44 changes: 44 additions & 0 deletions examples/midi/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
139 changes: 139 additions & 0 deletions midi/decode.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 20e0303

Please sign in to comment.