Skip to content

EventBus

EventBus (src/utils/event-bus.js) is a small, dependency-free, synchronous publish/subscribe bus. It is how subsystems that must not import each other — the renderer and player.js, the settings panel and the audio graph, the module bar and the history stack — talk.

javascript
import { eventBus } from './utils/event-bus.js'

const off = eventBus.on('history:stackChanged', ({ canUndo, canRedo }) => {
  undoBtn.disabled = !canUndo
  redoBtn.disabled = !canRedo
})

// later
off()

eventBus is a shared singleton. The EventBus class is exported too, if you want a private bus.

API

on()

javascript
const off = eventBus.on(event, handler)

Subscribes, and returns an unsubscribe function. That return value is the idiomatic cleanup path in this codebase — src/settings/settings-panel.js collects them in a disposers array and calls them when the panel is torn down.

ParameterTypeDescription
eventstringTopic name
handlerfunctionCalled with whatever emit() passes

Returns: Function — call it to unsubscribe.

once()

javascript
const off = eventBus.once(event, handler)

Fires at most once, then unsubscribes itself (in a finally, so a throwing handler still gets removed). Also returns an unsubscribe function, for cancelling before the event arrives.

off()

javascript
eventBus.off(event, handler)

Removes one handler by identity. Removing the last handler for a topic deletes the topic.

emit()

javascript
eventBus.emit(event, ...args)

Synchronous. Handlers run in subscription order, on a snapshot of the listener set, so subscribing or unsubscribing inside a handler is safe. Every handler is wrapped in a try/catch: one that throws is logged ([event-bus] Handler error for "<event>":) and the rest still run.

By convention every event in this app carries one object argument, not a positional list.

listeners()

javascript
eventBus.listeners(event)  // → Function[] (an Array, not a Set)

size()

javascript
eventBus.size()  // → number of topics with at least one listener

clear()

javascript
eventBus.clear('some:event')  // drop that topic's handlers
eventBus.clear()              // drop everything

There is no wildcard matching

component:action is a naming discipline, nothing more. The bus does no prefix matching — you cannot subscribe to workspace:*. Event names are compared with strict equality.

Event catalogue

This is every topic in src/ at the current commit. Payloads are exact.

player

EventPayloadEmitted byConsumed by
player:invalidateModuleEndTimeCacheNote._notifyChange() — any expression or property changeplayer.js (recompute end time, reposition measure bars)
player:requestPausemodals, workspace commits, history restore, the note widgetplayer.js — stops playback before a mutating edit
player:octaveChange{ noteId, direction: 'up' | 'down' }the ▲/▼ arrows (note widget and canvas), the perf harnessplayer.js — applies the arrow interval to the frequency expression
player:selectNote{ noteId }note/measure creation, so the new note lands selectedplayer.js
player:importModuleAtTarget{ targetNoteId, moduleData, clientX, clientY }the module bar, when a library icon is dropped on a noteplayer.js — grafts the module in

history (undo / redo)

EventPayloadEmitted byConsumed by
history:capture{ label, snapshot, snapshotStr? }every undoable actionstore/history.js (push) and player.js (write the localStorage autosave)
history:seedIfEmpty{ label: 'Initial', snapshot, snapshotStr? }the same call sites, just before history:capturestore/history.js
history:undo / history:redothe module-bar buttons, the + menu buttons, Ctrl/⌘+Z and Ctrl/⌘+Ystore/history.js
history:requestRestore{ snapshot, source: 'undo' | 'redo', label }HistoryManagerplayer.js — rebuilds the module from the snapshot; also clears the selection
history:stackChanged{ undo, redo, canUndo, canRedo }HistoryManager, after every push/undo/redothe undo/redo buttons in the module bar and the + menu

workspace (canvas gestures → authoritative commit)

The WebGL2 workspace previews a gesture on the GPU and emits a commit event on release; player.js owns the module and writes the expression.

EventPayloadEmitted on
workspace:noteMoveCommit{ noteId, newStartSec }drag of a single note
workspace:groupMoveCommit{ ids, deltaSec }drag of a multi-selection
workspace:noteResizeCommit{ noteId, newDurationSec }drag of a note's right edge
workspace:measureResizeCommit{ measureId, newStartSec }drag of a measure bar
workspace:marqueeCommit{ ids, additive }marquee release — emitted even when ids is empty, so a drag across nothing clears the selection
workspace:multiSelectToggle{ id }long-press on a note (the touch path into multi-select)

All six are consumed by player.js.

settings

EventPayloadEmitted byConsumed by
settings:changed{ path, value, settings }path is dotted, e.g. 'audio.defaultInstrument'settingsStore on every writeplayer.js (audio graph, default instrument, theme), the note widget
settings:loaded{ settings }settingsStore, once, a microtask after constructionnothing today — the panel and player.js pull with settingsStore.get() instead
settings:panelToggled{ open }the settings panel, on open/closemain.js

A settings:changed handler must check path — it fires for every setting:

javascript
eventBus.on('settings:changed', ({ path }) => {
  if (path === 'audio.defaultInstrument' || path === 'audio' || path === '') {
    applyDefaultInstrument()
  }
})

modals (the note / measure widget)

EventPayloadEmitted byConsumed by
modals:show{ noteId, isMeasure }the widget, when it opens for a noteplayer.js
modals:clearedthe widget, when it closesplayer.js subscribes, but the handler is an empty placeholder — nothing happens today
modals:requestRefresh{ note, measureId, clickedElement }player.js and the widget's own controls, after a committhe widget — rebuilds itself in place
modals:initthe widget, oncenothing today

audio

EventPayloadEmitted byConsumed by
audio:masterVolumeInput{ value } (0-1)the top-bar volume slider, live during the dragthe Audio tab of the settings panel, so the two sliders track each other

The transport slider only writes the audio.masterVolume setting when the drag ends, so this event exists to carry the mid-drag echo that settings:changed cannot.

Patterns

Cleanup

Keep the unsubscribe function. There is no React here and no component lifecycle — a panel that is rebuilt without dropping its subscriptions leaks a handler per rebuild.

javascript
const disposers = []

function build() {
  disposers.push(eventBus.on('settings:changed', onSettings))
  disposers.push(eventBus.on('history:stackChanged', onHistory))
}

function destroy() {
  disposers.forEach((off) => off())
  disposers.length = 0
}

Emitting defensively

Most call sites wrap emit() in a try/catch, because the bus is imported into code paths that also run outside the browser (the Node perf benches):

javascript
try { eventBus.emit('player:requestPause') } catch {}

Debugging

javascript
const realEmit = eventBus.emit.bind(eventBus)
eventBus.emit = (event, ...args) => {
  console.log('[event]', event, args)
  realEmit(event, ...args)
}

Adding an event

  1. Name it subsystem:action — the subsystems in use are player, history, workspace, settings, modals, audio.
  2. Pass a single object payload.
  3. Emit from the subsystem that owns the fact; consume where the reaction belongs. The workspace never writes expressions; it emits a commit and lets player.js do it.
  4. Add it to the table above.

See also

  • Data Flow — how a gesture becomes an expression
  • Module ClassModule emits nothing; Note emits one event
  • Settings — what settings:changed paths correspond to

Released under the MIT License