Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 24 additions & 8 deletions midi/midi_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package midi

import (
"fmt"
"sync"
"time"

"github.com/emicklei/melrose/core"
Expand All @@ -11,6 +12,7 @@ import (

type midiEvent struct {
echoString string
whichAry [4]int64
which []int64
onoff int64
channel int
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
105 changes: 59 additions & 46 deletions midi/output_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand All @@ -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
}
11 changes: 0 additions & 11 deletions midi/play_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading