From 377132321fa0b354494bad14b3d9caa1915c4e68 Mon Sep 17 00:00:00 2001 From: Veikko Sariola Date: Tue, 10 Nov 2020 20:05:03 +0200 Subject: [PATCH] feat(go4k): Implement .asm exporting. --- go4k/algorithms.go | 33 ++++++ go4k/asmformat.go | 229 ++++++++++++++++++++++++++++++++++++++++- go4k/asmformat_test.go | 65 +++++++++++- go4k/go4k.go | 20 ++++ 4 files changed, 342 insertions(+), 5 deletions(-) diff --git a/go4k/algorithms.go b/go4k/algorithms.go index 0051fb9..666b371 100644 --- a/go4k/algorithms.go +++ b/go4k/algorithms.go @@ -131,3 +131,36 @@ func ConstructDelayTimeTable(patch Patch) ([]int, [][]int) { } return delayTable, unitindices } + +// ConstructSampleOffsetTable collects the sample offests from +// all sample-based oscillators and collects them in a table, +// so that they appear in the table only once. Returns the collected +// table and [][]int array where element [i][j] is the index in the +// table by instrument i / unit j (units other than sample oscillators +// have the value 0) +func ConstructSampleOffsetTable(patch Patch) ([]SampleOffset, [][]int) { + unitindices := make([][]int, len(patch)) + var offsetTable []SampleOffset + offsetMap := map[SampleOffset]int{} + for i, instr := range patch { + unitindices[i] = make([]int, len(instr.Units)) + for j, unit := range instr.Units { + if unit.Type == "oscillator" && unit.Parameters["type"] == Sample { + offset := SampleOffset{ + Start: unit.Parameters["start"], + LoopStart: unit.Parameters["loopstart"], + LoopLength: unit.Parameters["looplength"], + } + if ind, ok := offsetMap[offset]; ok { + unitindices[i][j] = ind // the sample has been already added to table, reuse the index + } else { + ind = len(offsetTable) + unitindices[i][j] = ind + offsetMap[offset] = ind + offsetTable = append(offsetTable, offset) + } + } + } + } + return offsetTable, unitindices +} diff --git a/go4k/asmformat.go b/go4k/asmformat.go index ae57ff6..efb4fe6 100644 --- a/go4k/asmformat.go +++ b/go4k/asmformat.go @@ -4,15 +4,14 @@ import ( "bufio" "errors" "fmt" - "io" "regexp" "strconv" "strings" ) -func ParseAsm(reader io.Reader) (*Song, error) { +func DeserializeAsm(asmcode string) (*Song, error) { var bpm int - scanner := bufio.NewScanner(reader) + scanner := bufio.NewScanner(strings.NewReader(asmcode)) patterns := make([][]byte, 0) tracks := make([]Track, 0) var patch Patch @@ -186,3 +185,227 @@ func ParseAsm(reader io.Reader) (*Song, error) { s := Song{BPM: bpm, Patterns: patterns, Tracks: tracks, Patch: patch, SongLength: -1} return &s, nil } + +func SerializeAsm(song *Song) (string, error) { + paramorder := map[string][]string{ + "add": []string{}, + "addp": []string{}, + "pop": []string{}, + "loadnote": []string{}, + "mul": []string{}, + "mulp": []string{}, + "push": []string{}, + "xch": []string{}, + "distort": []string{"drive"}, + "hold": []string{"holdfreq"}, + "crush": []string{"resolution"}, + "gain": []string{"gain"}, + "invgain": []string{"invgain"}, + "filter": []string{"frequency", "resonance", "lowpass", "bandpass", "highpass", "negbandpass", "neghighpass"}, + "clip": []string{}, + "pan": []string{"panning"}, + "delay": []string{"pregain", "dry", "feedback", "damp", "delay", "count", "notetracking"}, + "compressor": []string{"attack", "release", "invgain", "threshold", "ratio"}, + "speed": []string{}, + "out": []string{"gain"}, + "outaux": []string{"outgain", "auxgain"}, + "aux": []string{"gain", "channel"}, + "send": []string{"amount", "voice", "unit", "port", "sendpop"}, + "envelope": []string{"attack", "decay", "sustain", "release", "gain"}, + "noise": []string{"shape", "gain"}, + "oscillator": []string{"transpose", "detune", "phase", "color", "shape", "gain", "type", "lfo", "unison"}, + "loadval": []string{"value"}, + "receive": []string{}, + "in": []string{"channel"}, + } + indentation := 0 + indent := func() string { + return strings.Repeat(" ", indentation*4) + } + var b strings.Builder + println := func(format string, params ...interface{}) { + if len(format) > 0 { + fmt.Fprintf(&b, "%v", indent()) + fmt.Fprintf(&b, format, params...) + } + fmt.Fprintf(&b, "\n") + } + align := func(table [][]string, format string) [][]string { + var maxwidth []int + // find the maximum width of each column + for _, row := range table { + for k, elem := range row { + l := len(elem) + if len(maxwidth) <= k { + maxwidth = append(maxwidth, l) + } else { + if maxwidth[k] < l { + maxwidth[k] = l + } + } + } + } + // align each column, depending on the specified formatting + for _, row := range table { + for k, elem := range row { + l := len(elem) + var f byte + if k >= len(format) { + f = format[len(format)-1] // repeat the last format specifier for all remaining columns + } else { + f = format[k] + } + switch f { + case 'n': // no alignment + row[k] = elem + case 'l': // left align + row[k] = elem + strings.Repeat(" ", maxwidth[k]-l) + case 'r': // right align + row[k] = strings.Repeat(" ", maxwidth[k]-l) + elem + } + } + } + return table + } + printTable := func(table [][]string) { + indentation++ + for _, row := range table { + println("%v %v", row[0], strings.Join(row[1:], ",")) + } + indentation-- + } + delayTable, delayIndices := ConstructDelayTimeTable(song.Patch) + sampleTable, sampleIndices := ConstructSampleOffsetTable(song.Patch) + // The actual printing starts here + println("%%define BPM %d", song.BPM) + // delay modulation is pretty much the only %define that the asm preprocessor cannot figure out + // as the preprocessor has no clue if a SEND modulates a delay unit. So, unfortunately, for the + // time being, we need to figure during export if INCLUDE_DELAY_MODULATION needs to be defined. + delaymod := false + for i, instrument := range song.Patch { + for j, unit := range instrument.Units { + if unit.Type == "send" { + targetInstrument := i + if unit.Parameters["voice"] > 0 { + v, err := song.Patch.InstrumentForVoice(unit.Parameters["voice"] - 1) + if err != nil { + return "", fmt.Errorf("INSTRUMENT #%v / SEND #%v targets voice %v, which does not exist", i, j, unit.Parameters["voice"]) + } + targetInstrument = v + } + if unit.Parameters["unit"] < 0 || unit.Parameters["unit"] >= len(song.Patch[targetInstrument].Units) { + return "", fmt.Errorf("INSTRUMENT #%v / SEND #%v target unit %v out of range", i, j, unit.Parameters["unit"]) + } + if song.Patch[targetInstrument].Units[unit.Parameters["unit"]].Type == "delay" && unit.Parameters["port"] == 5 { + delaymod = true + } + } + } + } + if delaymod { + println("%%define INCLUDE_DELAY_MODULATION") + } + println("") + println("%%include \"sointu/header.inc\"\n") + var patternTable [][]string + for _, pattern := range song.Patterns { + row := []string{"PATTERN"} + for _, v := range pattern { + if v == 1 { + row = append(row, "HLD") + } else { + row = append(row, strconv.Itoa(int(v))) + } + } + patternTable = append(patternTable, row) + } + println("BEGIN_PATTERNS") + printTable(align(patternTable, "lr")) + println("END_PATTERNS\n") + var trackTable [][]string + for _, track := range song.Tracks { + row := []string{"TRACK", fmt.Sprintf("VOICES(%d)", track.NumVoices)} + for _, v := range track.Sequence { + row = append(row, strconv.Itoa(int(v))) + } + trackTable = append(trackTable, row) + } + println("BEGIN_TRACKS") + printTable(align(trackTable, "lr")) + println("END_TRACKS\n") + println("BEGIN_PATCH") + indentation++ + for i, instrument := range song.Patch { + var instrTable [][]string + for j, unit := range instrument.Units { + stereomono := "MONO" + if unit.Stereo { + stereomono = "STEREO" + } + row := []string{fmt.Sprintf("SU_%v", strings.ToUpper(unit.Type)), stereomono} + for _, parname := range paramorder[unit.Type] { + if unit.Type == "oscillator" && unit.Parameters["type"] == Sample && parname == "color" { + row = append(row, fmt.Sprintf("COLOR(%v)", strconv.Itoa(sampleIndices[i][j]))) + } else if unit.Type == "delay" && parname == "count" { + count := len(unit.DelayTimes) + if unit.Stereo { + count /= 2 + } + row = append(row, fmt.Sprintf("COUNT(%v)", strconv.Itoa(count))) + } else if unit.Type == "delay" && parname == "delay" { + row = append(row, fmt.Sprintf("DELAY(%v)", strconv.Itoa(delayIndices[i][j]))) + } else if unit.Type == "oscillator" && parname == "type" { + switch unit.Parameters["type"] { + case Sine: + row = append(row, "TYPE(SINE)") + case Trisaw: + row = append(row, "TYPE(TRISAW)") + case Pulse: + row = append(row, "TYPE(PULSE)") + case Gate: + row = append(row, "TYPE(GATE)") + case Sample: + row = append(row, "TYPE(SAMPLE)") + } + } else if v, ok := unit.Parameters[parname]; ok { + row = append(row, fmt.Sprintf("%v(%v)", strings.ToUpper(parname), strconv.Itoa(int(v)))) + } else { + return "", fmt.Errorf("The parameter map for unit %v does not contain %v, even though it should", unit.Type, parname) + } + } + instrTable = append(instrTable, row) + } + println("BEGIN_INSTRUMENT VOICES(%d)", instrument.NumVoices) + printTable(align(instrTable, "ln")) + println("END_INSTRUMENT") + } + indentation-- + println("END_PATCH\n") + if len(delayTable) > 0 { + var delStrTable [][]string + for _, v := range delayTable { + row := []string{"DELTIME", strconv.Itoa(int(v))} + delStrTable = append(delStrTable, row) + } + println("BEGIN_DELTIMES") + printTable(align(delStrTable, "lr")) + println("END_DELTIMES\n") + } + if len(sampleTable) > 0 { + var samStrTable [][]string + for _, v := range sampleTable { + samStrTable = append(samStrTable, []string{ + "SAMPLE_OFFSET", + fmt.Sprintf("START(%d)", v.Start), + fmt.Sprintf("LOOPSTART(%d)", v.LoopStart), + fmt.Sprintf("LOOPLENGTH(%d)", v.LoopLength), + }) + } + println("BEGIN_SAMPLE_OFFSETS") + printTable(align(samStrTable, "r")) + println("END_SAMPLE_OFFSETS\n") + } + println("%%include \"sointu/footer.inc\"") + ret := b.String() + return ret, nil +} diff --git a/go4k/asmformat_test.go b/go4k/asmformat_test.go index ce1dbaf..d780ce0 100644 --- a/go4k/asmformat_test.go +++ b/go4k/asmformat_test.go @@ -28,11 +28,11 @@ func TestAllAsmFiles(t *testing.T) { basename := filepath.Base(filename) testname := strings.TrimSuffix(basename, path.Ext(basename)) t.Run(testname, func(t *testing.T) { - file, err := os.Open(filename) + asmcode, err := ioutil.ReadFile(filename) if err != nil { t.Fatalf("cannot read the .asm file: %v", filename) } - song, err := go4k.ParseAsm(file) + song, err := go4k.DeserializeAsm(string(asmcode)) if err != nil { t.Fatalf("could not parse the .asm file: %v", err) } @@ -70,6 +70,67 @@ func TestAllAsmFiles(t *testing.T) { } } +func TestSerializingAllAsmFiles(t *testing.T) { + bridge.Init() + _, myname, _, _ := runtime.Caller(0) + files, err := filepath.Glob(path.Join(path.Dir(myname), "..", "tests", "*.asm")) + if err != nil { + t.Fatalf("cannot glob files in the test directory: %v", err) + } + for _, filename := range files { + basename := filepath.Base(filename) + testname := strings.TrimSuffix(basename, path.Ext(basename)) + t.Run(testname, func(t *testing.T) { + asmcode, err := ioutil.ReadFile(filename) + if err != nil { + t.Fatalf("cannot read the .asm file: %v", filename) + } + song, err := go4k.DeserializeAsm(string(asmcode)) // read the asm + if err != nil { + t.Fatalf("could not parse the .asm file: %v", err) + } + str, err := go4k.SerializeAsm(song) // serialize again + if err != nil { + t.Fatalf("Could not serialize asm file: %v", err) + } + song, err = go4k.DeserializeAsm(str) // deserialize again. The rendered song should still give same results. + if err != nil { + t.Fatalf("could not parse the serialized asm code: %v", err) + } + synth, err := bridge.Synth(song.Patch) + if err != nil { + t.Fatalf("Compiling patch failed: %v", err) + } + buffer, err := go4k.Play(synth, *song) + if err != nil { + t.Fatalf("Play failed: %v", err) + } + if os.Getenv("GO4K_TEST_SAVE_OUTPUT") == "YES" { + outputpath := path.Join(path.Dir(myname), "actual_output") + if _, err := os.Stat(outputpath); os.IsNotExist(err) { + os.Mkdir(outputpath, 0755) + } + outFileName := path.Join(path.Dir(myname), "actual_output", testname+".raw") + outfile, err := os.OpenFile(outFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + defer outfile.Close() + if err != nil { + t.Fatalf("Creating file failed: %v", err) + } + var createdbuf bytes.Buffer + err = binary.Write(&createdbuf, binary.LittleEndian, buffer) + if err != nil { + t.Fatalf("error converting buffer: %v", err) + } + _, err = outfile.Write(createdbuf.Bytes()) + if err != nil { + log.Fatal(err) + } + } + compareToRaw(t, buffer, testname+".raw") + }) + } +} + func compareToRaw(t *testing.T, buffer []float32, rawname string) { _, filename, _, _ := runtime.Caller(0) expectedb, err := ioutil.ReadFile(path.Join(path.Dir(filename), "..", "tests", "expected_output", rawname)) diff --git a/go4k/go4k.go b/go4k/go4k.go index 85b42f5..b89805d 100644 --- a/go4k/go4k.go +++ b/go4k/go4k.go @@ -38,6 +38,20 @@ func (p Patch) TotalVoices() int { return ret } +func (patch Patch) InstrumentForVoice(voice int) (int, error) { + if voice < 0 { + return 0, errors.New("voice cannot be negative") + } + for i, instr := range patch { + if voice < instr.NumVoices { + return i, nil + } else { + voice -= instr.NumVoices + } + } + return 0, errors.New("voice number is beyond the total voices of an instrument") +} + type Track struct { NumVoices int Sequence []byte @@ -57,6 +71,12 @@ func Render(synth Synth, buffer []float32) error { return err } +type SampleOffset struct { + Start int + LoopStart int + LoopLength int +} + // UnitParameter documents one parameter that an unit takes type UnitParameter struct { Name string // thould be found with this name in the Unit.Parameters map