I like to give lots of context, but also to state the basic question up top as concisely as possible. Feel free to read a little or a lot.
I'm working on a music sequencer, and have been happy so far to be using a publish/subscribe approach to help decouple the major components of the application, which has helped testing and refactoring. However I'm wondering how components can share awareness of certain state without all adding a dependency on some new singleton OR abusing (published) events to each cache their own copies of this state. The specific example is the "playhead"; this and another example are explained below.
Up until now, a playhead has been trivially represented as an instance of the immutable Timecode class, which has a measure, beat, and tick (essentially sub-beat unit). Components can subscribe to "beat" events and grab its timecode to update their internal "playhead" property. But as I get into other functionality like editing, I see components that track the playhead will also want to track the SequenceEvents active at the playhead time (e.g. notes). Also I need to add functionality for a "step region" which is a time segment (say, 1 measure), similar to the playhead but not a single moment in time, that components will similarly need to track.
I've been stumped as to how to represent such objects in my application without introducing tighter coupling. The current approach where components listen for events does not seem a good fit. Right now, the Sequencer component emits a "beat" event which subscribers can use to update their playheads. Enhancing this event to include SequenceEvent data for notes, etc. smells bad as events typically are meant to be transient and consumed as they occur, not so that event consumers can update private state. Especially when that private state would be identical in each consumer. Also, other events than "beat" could signal a change of the playhead, e.g. the user clicks on another part of the score.
If not events, any other approach seems to have multiple components dependent on some new "playhead" component, with bidirectional binding (that is, several components could both detect changes to the playhead and set its state). Even using dependency injection to reduce the dependencies for the sake of testing, this feels wrong to me.
In case even more context helps illustrate the problem, read on...
The app currently has several immutable data types that larger components may be dependent on (or may use without knowing their type): Timecode, SequenceEvent, Meter, Tempo, Stret (string/fret object), Pitch. Larger components which do NOT have construction-time dependencies include:
- Sequence: Represents a score, can be queried to iterate or random access sequence events.
- Sequencer: Handles playback and timing, currently emits the "beat" and "note" events.
- ScoreView: Renders a sequence as tablature (sheet music), and visually shows the playhead.
- NeckView: Shows a guitar neck with actively playing notes.
- GUI: This contains a number of simpler controls, including clock displays that update with the playhead. This component also instantiates ScoreView and NeckView components, but does not wire them to each other in any way.
All run-time "wiring" is internal to components, via PubSub.subscribe('eventName', handler). PubSub has pros and cons, but this has worked well to a point. However some cases where this approach breaks down are:
1) The guitar neck should show "static" markers for currently "selected" notes, in addition to playing active markers that animate when notes are played. This requires tracking state of which notes (strets) have start times at the current playhead.
2) Clicking on the guitar neck should toggle (add/remove) notes from the active sequence at the current playhead position. This would also be updated in the score view.
3) A new feature allows a "step region", which allows stepping through the score in increments like whole measures. So the current step could be larger than a single time point, and the neck and score views should be able to display static markers for all notes within the step region. This seems to be a duplicate of the same problem that applies to the playhead.
Yet another approach that smells bad is to have these new entities all managed/owned by the Sequencer component, and have it publish events specifically like "playheadUpdated" and "stepRegionUpdated" that contain all the necessary data. This seems bad again because of the misuse of events to update redundant state in several components. This might be mitigated if the event referenced an immutable (or at least, not subject to change) object (e.g. for "playhead state"), maybe it would be less bad. But this approach still has the problem that other components cannot change the playhead or step region without publishing a request for the Sequencer to handle this change. Seems very wrong.
Maybe others have solved this exact type of problem more elegantly than I'm thinking. Any pointers are appreciated.