From 6c90ba2067d7bda5a139223bea919373fa9aab64 Mon Sep 17 00:00:00 2001 From: Veikko Sariola Date: Sat, 24 Oct 2020 23:25:23 +0300 Subject: [PATCH] Implement a song struct to hold all the information of a single song (corresponding one .asm file) and Render function for it. --- bridge/bridge.go | 18 ++++++- song/song.go | 135 ++++++++++++++++++++++++++++++++++++++++++++++ song/song_test.go | 64 ++++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 song/song.go create mode 100644 song/song_test.go diff --git a/bridge/bridge.go b/bridge/bridge.go index f1cecc2..7618e61 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -28,6 +28,16 @@ type Instrument struct { Units []Unit } +type Patch []Instrument + +func (p Patch) TotalVoices() int { + ret := 0 + for _, i := range p { + ret += i.NumVoices + } + return ret +} + var ( // cannot be const as the rhs are not known at compile-time Add = Opcode(C.su_add_id) Addp = Opcode(C.su_addp_id) @@ -100,7 +110,7 @@ func (s *SynthState) Render(buffer []float32, maxRows int, callback func()) (int return maxSamples - remaining, nil } -func (s *SynthState) SetPatch(patch []Instrument) error { +func (s *SynthState) SetPatch(patch Patch) error { totalVoices := 0 commands := make([]Opcode, 0) values := make([]byte, 0) @@ -138,7 +148,7 @@ func (s *SynthState) SetPatch(patch []Instrument) error { return nil } -func (s *SynthState) Trigger(voice int, note int) { +func (s *SynthState) Trigger(voice int, note byte) { cs := (*C.SynthState)(s) cs.Synth.Voices[voice] = C.Voice{} cs.Synth.Voices[voice].Note = C.int(note) @@ -149,6 +159,10 @@ func (s *SynthState) Release(voice int) { cs.Synth.Voices[voice].Release = 1 } +func (s *SynthState) SetSamplesPerRow(spr int) { + s.SamplesPerRow = C.uint(spr) +} + func NewSynthState() *SynthState { s := new(SynthState) s.RandSeed = 1 diff --git a/song/song.go b/song/song.go new file mode 100644 index 0000000..b401db4 --- /dev/null +++ b/song/song.go @@ -0,0 +1,135 @@ +package song + +import ( + "errors" + + "github.com/vsariola/sointu/bridge" +) + +type Track struct { + NumVoices int + Sequence []byte +} + +type Song struct { + BPM int + Patterns [][]byte + Tracks []Track + Patch bridge.Patch + Samples int // -1 means calculate automatically, but you can also set it manually +} + +func NewSong(bpm int, patterns [][]byte, tracks []Track, patch bridge.Patch) (*Song, error) { + s := new(Song) + s.BPM = bpm + s.Patterns = patterns + s.Tracks = tracks + s.Patch = patch + err := s.Validate() + if err != nil { + return nil, err + } + s.Samples = -1 + return s, nil +} + +func (s *Song) Validate() error { + if s.BPM < 1 { + return errors.New("BPM should be > 0") + } + for i := range s.Patterns[:len(s.Patterns)-1] { + if len(s.Patterns[i]) != len(s.Patterns[i+1]) { + return errors.New("Every pattern should have the same length") + } + } + for i := range s.Tracks[:len(s.Patterns)-1] { + if len(s.Tracks[i].Sequence) != len(s.Tracks[i+1].Sequence) { + return errors.New("Every track should have the same sequence length") + } + } + totalTrackVoices := 0 + for _, track := range s.Tracks { + totalTrackVoices += track.NumVoices + for _, p := range track.Sequence { + if p < 0 || int(p) >= len(s.Patterns) { + return errors.New("Tracks use a non-existing pattern") + } + } + } + if totalTrackVoices > s.Patch.TotalVoices() { + return errors.New("Tracks use too many voices") + } + return nil +} + +func (s *Song) PatternRows() int { + return len(s.Patterns[0]) +} + +func (s *Song) SequenceLength() int { + return len(s.Tracks[0].Sequence) +} + +func (s *Song) TotalRows() int { + return s.PatternRows() * s.SequenceLength() +} + +func (s *Song) SamplesPerRow() int { + return 44100 * 60 / (s.BPM * 4) +} + +func (s *Song) FirstTrackVoice(track int) int { + ret := 0 + for _, t := range s.Tracks[:track] { + ret += t.NumVoices + } + return ret +} + +func (s *Song) Render() ([]float32, error) { + err := s.Validate() + if err != nil { + return nil, err + } + synth := bridge.NewSynthState() + synth.SetPatch(s.Patch) + synth.SetSamplesPerRow(44100 * 60 / (s.BPM * 4)) + curVoices := make([]int, len(s.Tracks)) + for i := range curVoices { + curVoices[i] = s.FirstTrackVoice(i) + } + processRow := func(row int) { + if row >= s.TotalRows() { + return + } + patternRow := row % s.PatternRows() + pattern := row / s.PatternRows() + for t := range s.Tracks { + note := s.Patterns[pattern][patternRow] + if note == 1 { // anything but hold causes an action. + continue // TODO: can hold be actually something else than 1? + } + synth.Release(curVoices[t]) + if note > 1 { + curVoices[t]++ + first := s.FirstTrackVoice(t) + if curVoices[t] >= first+s.Tracks[t].NumVoices { + curVoices[t] = first + } + synth.Trigger(curVoices[t], note) + } + } + } + samples := s.Samples + if samples < 0 { + samples = s.TotalRows() * s.SamplesPerRow() + } + buffer := make([]float32, samples*2) + row := 0 + processRow(0) + synth.Render(buffer, s.TotalRows(), func() { + row++ + processRow(row) + }) + return buffer, nil +} diff --git a/song/song_test.go b/song/song_test.go new file mode 100644 index 0000000..d803f7e --- /dev/null +++ b/song/song_test.go @@ -0,0 +1,64 @@ +package song_test + +import ( + "bytes" + "encoding/binary" + "io/ioutil" + "path" + "runtime" + "testing" + + "github.com/vsariola/sointu/bridge" + "github.com/vsariola/sointu/song" +) + +const BPM = 100 +const SAMPLE_RATE = 44100 +const TOTAL_ROWS = 16 +const SAMPLES_PER_ROW = SAMPLE_RATE * 4 * 60 / (BPM * 16) + +const su_max_samples = SAMPLES_PER_ROW * TOTAL_ROWS + +// const bufsize = su_max_samples * 2 + +func TestSongRender(t *testing.T) { + patch := []bridge.Instrument{ + bridge.Instrument{1, []bridge.Unit{ + bridge.Unit{bridge.Envelope, []byte{32, 32, 64, 64, 128}}, + bridge.Unit{bridge.Oscillat, []byte{64, 64, 0, 96, 64, 128, 0x40}}, + bridge.Unit{bridge.Mulp, []byte{}}, + bridge.Unit{bridge.Envelope, []byte{32, 32, 64, 64, 128}}, + bridge.Unit{bridge.Oscillat, []byte{72, 64, 64, 64, 96, 128, 0x40}}, + bridge.Unit{bridge.Mulp, []byte{}}, + bridge.Unit{bridge.Out.Stereo(), []byte{128}}, + }}} + patterns := [][]byte{{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0}} + tracks := []song.Track{song.Track{1, []byte{0}}} + song, err := song.NewSong(100, patterns, tracks, patch) + if err != nil { + t.Fatalf("NewSong failed: %v", err) + } + buffer, err := song.Render() + if err != nil { + t.Fatalf("Render failed: %v", err) + } + _, filename, _, _ := runtime.Caller(0) + expectedb, err := ioutil.ReadFile(path.Join(path.Dir(filename), "..", "tests", "expected_output", "test_oscillat_sine.raw")) + if err != nil { + t.Fatalf("cannot read expected: %v", err) + } + var createdbuf bytes.Buffer + err = binary.Write(&createdbuf, binary.LittleEndian, buffer) + if err != nil { + t.Fatalf("error converting buffer: %v", err) + } + createdb := createdbuf.Bytes() + if len(createdb) != len(expectedb) { + t.Fatalf("buffer length mismatch, got %v, expected %v", len(createdb), len(expectedb)) + } + for i, v := range createdb { + if expectedb[i] != v { + t.Fatalf("byte mismatch @ %v, got %v, expected %v", i, v, expectedb[i]) + } + } +}