diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..8c01642 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,41 @@ +# Melrōse Architecture & Design Decisions + +This document details the architectural and design decisions behind the `melrōse` project. + +## 1. Custom Domain-Specific Language (DSL) +Instead of adopting a general-purpose language (like Lua or Python) or a data format (like JSON or YAML), Melrōse uses a custom declarative functional language. +* **Why:** Music composition is highly mathematical and compositional. A functional DSL allows expressing complex musical structures (sequences, chords, parallel tracks) concisely. +* **Implementation:** It leverages `github.com/expr-lang/expr` to evaluate expressions securely and natively in Go. This allows defining custom functions (e.g., `sequence`, `chord`, `transpose`) that immediately map to Go structs, avoiding complex parsing boilerplate while giving high performance. + +## 2. Abstraction over Core Musical Primitives +The core domain model revolves around a unified interface: `Sequenceable`. +* **Why:** Everything in Melrōse that makes a sound fundamentally resolves down to a sequence of notes (`[][]Note`, representing successive groups of simultaneously played notes). +* **Implementation:** Functions like `note()`, `chord()`, `sequence()`, and operations like `transpose()`, `reverse()`, and `stretch()` all implement `Sequenceable` or return objects that do. This allows infinite functional composition (e.g., `transpose(2, stretch(2, sequence('c e g')))`). + +## 3. Immutable and Functional Operations +Operations in the `op` package do not mutate the incoming sequence. They create lightweight wrappers or generate new sequences. +* **Why:** Immutability guarantees that when a sequence is played on multiple tracks or used in multiple loops, applying a modifier (like `dynamic`) to one instance does not unpredictably alter the original variable. It ensures safety during live-coding. + +## 4. Timeline-Based Event Scheduling +Playback does not rely on simple procedural `time.Sleep` calls. +* **Why:** If the system just slept between notes, it would block execution and make it impossible to interrupt or dynamically alter playback (crucial for live-coding). +* **Implementation:** The `core.Timeline` stores `TimelineEvent`s scheduled in the future. A background `Beatmaster` goroutine sweeps the timeline to dispatch events (like `note_on` and `note_off`) to the `AudioDevice`. This separates evaluation from playback, allowing a user to run new code without glitching the audio. + +## 5. Live Variable Delegation +Loops and tracks evaluate their contents lazily during playback. +* **Why:** For live performance, if a user redefines a variable `p = sequence("c d e")` to `p = sequence("c d e f")`, a running loop `loop(p)` must adapt instantly on its next iteration. +* **Implementation:** The DSL parser wraps variables in `VariableStorage`. Loops point to these dynamic variable proxies rather than statically copied `Sequence` instances. This forms the foundation of the live-coding experience. + +## 6. Abstracted Audio Transport Layer +The `AudioDevice` and `midi/transport` interfaces hide MIDI driver specifics. +* **Why:** Standardizing the transport layer makes the core engine platform-agnostic. +* **Implementation:** Melrōse can be compiled using `gitlab.com/gomidi/rtmididrv` for native macOS/Linux/Windows playback, but also features a `wasm.go` build tag implementation. This allows the same composition engine to run inside a web browser or WebAssembly runtime without native C bindings. + +## 7. Client-Server Ideology +Melrōse provides both an interactive CLI (`ui/cli` via `liner`) and a headless HTTP Server (`server/lang.go`). +* **Why:** Live-coding in a terminal is limited. By exposing a `/v1/statements` endpoint, Melrōse delegates heavy IDE features (syntax highlighting, hot reloading, snippet execution) to external clients, such as the official Visual Studio Code extension or an MCP (Model Context Protocol) server. +* **Implementation:** The Go binary acts as a background runtime engine, receiving AST expressions via HTTP, evaluating them in the shared `Context`, and piping output to the MIDI timeline. + +## 8. Encapsulated Context +A `core.Context` object is threaded through almost all evaluators and playback components. +* **Why:** It contains the `VariableStorage`, `AudioDevice`, `LoopController` (Beatmaster), and environment limits. Instead of relying on global variables, this design makes it trivial to write deterministic unit tests, run multiple independent sessions, or tear down a live-coding setup cleanly. diff --git a/midi/midi_event.go b/midi/midi_event.go index 161620d..789c1b7 100644 --- a/midi/midi_event.go +++ b/midi/midi_event.go @@ -2,6 +2,7 @@ package midi import ( "fmt" + "sync" "time" "github.com/emicklei/melrose/core" @@ -11,6 +12,7 @@ import ( type midiEvent struct { echoString string + whichAry [4]int64 which []int64 onoff int64 channel int @@ -20,13 +22,32 @@ type midiEvent struct { mustHandle core.Condition } -func (m midiEvent) NoteChangesDo(callback func(core.NoteChange)) { +var midiEventPool = sync.Pool{ + New: func() any { + return &midiEvent{} + }, +} + +func getMidiEvent() *midiEvent { + m := midiEventPool.Get().(*midiEvent) + m.echoString = "" + m.which = m.whichAry[:0] + m.mustHandle = nil + return m +} + +func putMidiEvent(m *midiEvent) { + midiEventPool.Put(m) +} + +func (m *midiEvent) NoteChangesDo(callback func(core.NoteChange)) { for _, each := range m.which { callback(core.NewNoteChange(m.onoff == noteOn, each, m.velocity)) } } -func (m midiEvent) Handle(tim *core.Timeline, when time.Time) { +func (m *midiEvent) Handle(tim *core.Timeline, when time.Time) { + defer putMidiEvent(m) // TODO not sure if the noteOn check is correct if m.mustHandle != nil && m.onoff == noteOn && !m.mustHandle() { return @@ -42,7 +63,7 @@ func (m midiEvent) Handle(tim *core.Timeline, when time.Time) { } } -func (m midiEvent) log(status int64, when time.Time) { +func (m *midiEvent) log(status int64, when time.Time) { onoff := " on" if m.onoff == noteOff { onoff = "off" @@ -51,11 +72,6 @@ func (m midiEvent) log(status int64, when time.Time) { when.Format("04:05.000"), m.device, m.channel, onoff, status, m.which, m.velocity, m.echoString) } -func (m midiEvent) asNoteoff() midiEvent { - m.onoff = noteOff - return m -} - type restEvent struct { echoString string mustHandle core.Condition diff --git a/midi/output_device.go b/midi/output_device.go index c7ef445..52616a9 100644 --- a/midi/output_device.go +++ b/midi/output_device.go @@ -114,13 +114,16 @@ func (d *OutputDevice) Play(condition core.Condition, seq core.Sequenceable, bpm } // more than one note if canCombineEvent(eachGroup) { - event := combinedMidiEvent(d.id, channel, eachGroup, d.stream) + onEvent, offEvent := combinedMidiEvents(d.id, channel, eachGroup, d.stream) if d.echo { - event.echoString = core.StringFromNoteGroup(eachGroup) + echoStr := core.StringFromNoteGroup(eachGroup) + onEvent.echoString = echoStr + offEvent.echoString = echoStr } actualDuration := durationOfGroup(eachGroup, wholeNoteDuration) - event.mustHandle = condition - moment = scheduleOnOffEvents(d, event, actualDuration, moment) + onEvent.mustHandle = condition + offEvent.mustHandle = condition + moment = scheduleOnOffEvents(d, onEvent, offEvent, actualDuration, moment) continue } // not combinable group of more than one note @@ -158,44 +161,44 @@ func scheduleOneNote(device *OutputDevice, condition core.Condition, channel int actualDuration := time.Duration(float32(whole) * note.DurationFactor()) return moment.Add(actualDuration) } - // midi variable length note? - if fixed, ok := note.NonFractionBasedDuration(); ok { - event := midiEvent{ - which: []int64{int64(note.MIDI())}, - onoff: noteOn, - device: device.id, - channel: channel, - velocity: int64(note.Velocity), - out: device.stream, - mustHandle: condition, - } - if device.echo { - event.echoString = note.String() - } - return scheduleOnOffEvents(device, event, fixed, moment) - } - // normal note - event := midiEvent{ - which: []int64{int64(note.MIDI())}, - onoff: noteOn, - device: device.id, - channel: channel, - velocity: int64(note.Velocity), - out: device.stream, - mustHandle: condition, + + onEvent := getMidiEvent() + onEvent.onoff = noteOn + onEvent.device = device.id + onEvent.channel = channel + onEvent.velocity = int64(note.Velocity) + onEvent.out = device.stream + onEvent.mustHandle = condition + onEvent.which = append(onEvent.which, int64(note.MIDI())) + if device.echo { + onEvent.echoString = note.String() } + + offEvent := getMidiEvent() + offEvent.onoff = noteOff + offEvent.device = device.id + offEvent.channel = channel + offEvent.velocity = int64(note.Velocity) + offEvent.out = device.stream + offEvent.mustHandle = condition + offEvent.which = append(offEvent.which, int64(note.MIDI())) if device.echo { - event.echoString = note.String() + offEvent.echoString = note.String() } - actualDuration := time.Duration(float32(whole) * note.DurationFactor()) - return scheduleOnOffEvents(device, event, actualDuration, moment) + // midi variable length note? + if fixed, ok := note.NonFractionBasedDuration(); ok { + return scheduleOnOffEvents(device, onEvent, offEvent, fixed, moment) + } + + actualDuration := time.Duration(float32(whole) * note.DurationFactor()) + return scheduleOnOffEvents(device, onEvent, offEvent, actualDuration, moment) } -func scheduleOnOffEvents(device *OutputDevice, event midiEvent, duration time.Duration, at time.Time) time.Time { - device.timeline.Schedule(event, at) +func scheduleOnOffEvents(device *OutputDevice, onEvent *midiEvent, offEvent *midiEvent, duration time.Duration, at time.Time) time.Time { + device.timeline.Schedule(onEvent, at) moment := at.Add(duration) - device.timeline.Schedule(event.asNoteoff(), moment) + device.timeline.Schedule(offEvent, moment) return moment } @@ -214,7 +217,7 @@ func canCombineEvent(notes []core.Note) bool { } // Pre: notes not empty -func combinedMidiEvent(deviceID int, channel int, notes []core.Note, stream transport.MIDIOut) midiEvent { +func combinedMidiEvents(deviceID int, channel int, notes []core.Note, stream transport.MIDIOut) (*midiEvent, *midiEvent) { // first note makes fraction and velocity velocity := notes[0].Velocity if velocity > 127 { @@ -223,16 +226,26 @@ func combinedMidiEvent(deviceID int, channel int, notes []core.Note, stream tran if velocity < 1 { velocity = core.Normal } - nrs := []int64{} + + onEvent := getMidiEvent() + onEvent.onoff = noteOn + onEvent.device = deviceID + onEvent.channel = channel + onEvent.velocity = int64(velocity) + onEvent.out = stream + + offEvent := getMidiEvent() + offEvent.onoff = noteOff + offEvent.device = deviceID + offEvent.channel = channel + offEvent.velocity = int64(velocity) + offEvent.out = stream + for _, each := range notes { - nrs = append(nrs, int64(each.MIDI())) - } - return midiEvent{ - which: nrs, - onoff: noteOn, - device: deviceID, - channel: channel, - velocity: int64(velocity), - out: stream, + n := int64(each.MIDI()) + onEvent.which = append(onEvent.which, n) + offEvent.which = append(offEvent.which, n) } + + return onEvent, offEvent } diff --git a/midi/play_test.go b/midi/play_test.go index 6d2cc67..36813de 100644 --- a/midi/play_test.go +++ b/midi/play_test.go @@ -22,17 +22,6 @@ func TestDurations(t *testing.T) { } } -func TestEventNoteOff(t *testing.T) { - on := midiEvent{onoff: noteOn} - off := on.asNoteoff() - if got, want := on.onoff, noteOn; got != want { - t.Errorf("got [%v] want [%v]", got, want) - } - if got, want := off.onoff, noteOff; got != want { - t.Errorf("got [%v] want [%v]", got, want) - } -} - func Test_canCombineEvent(t *testing.T) { type args struct { notes []core.Note