2

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 :)

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:

  1. The Sequencer advances to measure 3, beat 1, and dispatches (publishes) an event.
  2. ScoreView handles this event, moving its playhead display.
  3. Sequencer queries its Sequence for event data at 3:1.0, finds a 3 note chord, dispatches 3 note events.
  4. Synth handles these events, playing the notes.
  5. NeckView handles these events, animating the notes. So far, all works well. But...
  6. The user edits the score, adding a 4th note at 3:1.0
  7. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

closed as unclear what you're asking by Scant Roger, user40980, GlenH7 Dec 9 '15 at 22:06

Please clarify your specific problem or add additional details to highlight exactly what you need. As it's currently written, it’s hard to tell exactly what you're asking. See the How to Ask page for help clarifying this question. If this question can be reworded to fit the rules in the help center, please edit the question.

  • 1
    And the problem you've found with #1 is...? – cHao Apr 1 '14 at 4:04
  • 1
    I don't like that if a single note is added in the current step region, an entirely new stepRegion event would be dispatched, causing unnecessary work in listeners. It feels like a hack, conflating sequence mutations with changes to the playhead or step region. – Jason Boyd Apr 1 '14 at 4:09
  • The first thing you need to look at is concurrency. Is your problem space multi-threaded, or is everything supposed to be a single event-loop thread? – user1703394 Apr 1 '14 at 7:32
  • 1
    It's in Javascript, so single-threaded. Exception is the audio engine. Basically concurrency is a non-issue. – Jason Boyd Apr 1 '14 at 11:56
3

If anyone else finds this question relevant, I'm following-up with my chosen solution. Bottom line: when you can't find a simple solution to a seemingly simple problem, it may be a sign that analysis and refactoring of the existing design is needed.

Analyzing my code, I realized that the Sequence class had fairly overloaded accessors that as designed could not be implemented without high cyclomatic complexity. By overloaded, I mean the various ways to access sequence data included a number of conditional parameters like whether to seek ahead to next available data, whether to include the start and/or end timecodes of ranges, whether to filter based on data type, etc. Also some methods were implicitly optimized for faster access. These all grew out of real use cases but the resulting complexity, even with good test coverage, made me increasingly hesitant to add to this.

I refactored into a simpler TimedCollection class that simplifies iteration and random access, with seek(timecode), peek():Timecode, next(), prev(), current() methods, which is explicitly optimized for sequential access. Sequence uses multiple TimedCollections for heterogeneous sequence data, and exposes its own iterator methods that handle returning any notes, logical tempo and meter, for every non-empty timecode. By separating iteration from the "document model", the code is much more readable, with a smaller number of methods, clearer tests, etc.

Following this refactor, I ended up doing a variation of #1 and #5 from above, having Sequencer dispatch an event for a playhead change, which consuming components can optionally retain. Sequence fires events on mutation (e.g. note added, note removed), and observing components can check against the current playhead timecode as needed. My concerns about how "correct" the solution is mostly disappeared when the underlying code became simpler and therefore easier to maintain. Put another way, when there too much complexity in the core models, there was a temptation to add more objects like "playhead" to encapsulate the inevitable percolating of that complexity through the application -- deal with edge cases and such. With simpler code a playhead doesn't require any logic and is just an anonymous object pointing to a few pieces of (immutable) data.

Kudos for reading this far. You must be bored.

Not the answer you're looking for? Browse other questions tagged or ask your own question.