I asked this question once here, but believe the question was unclear. However I’m having a hard time extracting the general problem from my specific case.
UPDATE: I’ve answered my own question below. Read on if you like reading stuff. Maybe is educational, maybe not Image may be NSFW.
Clik here to view.
I’m writing a music sequencer. Some existing components are:
- Sequence — A collection of SequenceEvents (notes, meter, tempo, etc.)
- Sequencer — Manages playback timing
- ScoreView — Renders a sequence
- NeckView — Shows playback on guitar neck
- GUI — Manages smaller controls including clock displays of current play position
Right now, all these components are very loosely coupled, using pub/sub to send and respond to events. The exception is that the Sequencer holds a reference to a Sequence, and publishes events when the logical “playhead” moves or when sequence events are encountered. Sequence queries need to be fast during live playback, so having a single component manage such queries is important.
Some planned features require that multiple components show data related to the current playhead position, as well as a “step region”, which is a time slice larger than a single time position, e.g. a measure.
The question that arises for me is how to best represent “playhead” and “step region”. I don’t like the idea that each component would maintain duplicate state of these time positions and associated sequence events. Even having each component maintain references to “playhead” and “step region” objects feels like a DRY violation. However simply consuming transient events doesn’t work well. Consider this example:
- The Sequencer advances to measure 3, beat 1, and dispatches (publishes) an event.
- ScoreView handles this event, moving its playhead display.
- Sequencer queries its Sequence for event data at 3:1.0, finds a 3 note chord, dispatches 3 note events.
- Synth handles these events, playing the notes.
- NeckView handles these events, animating the notes. So far, all works well. But…
- The user edits the score, adding a 4th note at 3:1.0
- Sequence dispatches a sequenceEventAdded event.
How does the NeckView know that the added note is at the “current” playhead time? Or how does it know that the added note was not at the current playhead but within the current step region, also displayed?
This is one example illustrating the problem I’m more broadly trying to solve. Having multiple components maintain their own “playhead” and “step region” data seems like redundant responsibilities and state. But doing this all with transient events starts to feel like too many event types, not a great fit either.
My best attempt to genericize this is to ask, what are good ways to represent shared mutable state? The “playhead” and “step region” both represent slices of time in a Sequence, but within those time slices, sequence events could be added/removed and observing components need to know this.
Every way I look at this feels wrong to me. Another thought is to have actual objects for a Playhead and StepRegion. Each comprises time point(s) and a subset of sequence events from the Sequence. Each could listen to the Sequence for added/removed events and re-publish these. However this approach means that multiple components now have references to a Playhead and StepRegion, which feels like a duplication of responsibilities. But if only the Sequencer holds these objects (which manages timing and logically does have a current playback position), then how do other components know to update when notes are added/removed within these time regions (as opposed to other time regions of the Sequence)?
The more I try to ask this question the dumber it sounds, but I wouldn’t ask if the answer were obvious to me! Here are some multiple choice solutions:
- Sequence dispatches playhead and stepRegion events. If sequence data changes for either of these time slices, it dispatches a new playhead and stepRegion event, each containing the current corresponding sequence data. Observing components like NeckView can treat these events as idempotent and remain stateless regarding timing.
- Add Playhead and StepRegion classes. Multiple components can hold references to these and listen to added/removed events from these instances. This involves a fair amount of new Objects during playback, probably not a big GC concern, but smells bad.
- Playhead and StepRegion are singletons. This is worse version of #2, and requires these objects maintain references to the Sequence as well as observe events from the Sequencer and re-publish events from both. Testing becomes problematic.
- Sequence’s added/removed events contain a reference to the Sequence object. While this seems like a sane and a common approach, it invites multiple components to do potentially expensive querying (redundantly) on the sequence as often as every tick during real-time playback. This is one reason only Sequencer directly manages a Sequence.
- All observing components maintain their own time points for playhead, stepRegionStart, stepRegionEnd. Any sequenceEventAdded/Removed events can be handled or ignored accordingly. This means redundant state, but that state can completely be updated via events.
This is a long question. Any thoughts?