jazz-soft / JZZ

MIDI library for Node.js and web-browsers
http://jazz-soft.net/doc/JZZ/
MIT License
519 stars 27 forks source link

Sending synchronized midi clock to midi port #67

Open Boscop opened 1 year ago

Boscop commented 1 year ago

Hi, thanks for this great library! I'm using it to send midi to VCV Rack, and I need to send clock messages in sync with the midi playback at 24 clocks per beat, using the Impromptu clocked module as slave in VCV like this:

image

But JZZ Player currently doesn't send any clock. Is there a way to register a custom callback/hook for every time the midi player advances its state, so that I can pass a function that sends clock signals when each next clock frame is reached?

Before switching to JZZ, I was using my own code (Rust compiled to wasm) where I was successfully sending clock like this (called in my player's step function, which accounts for any number of ticks being passed since the last call):

// Sent midi beat clock 24 times per beat
let cur_midi_beat_clock_frame = (self.playhead_tick as u64 * 24 / self.smf.ticks_per_beat as u64) as i32;
// Potentially send multiple clock msgs per step if many ticks passed since last call
for _ in self.midi_beat_clock_frame .. cur_midi_beat_clock_frame {
    // Send clock msg
    send_live(&self.midi_output, LiveEvent::Realtime(SystemRealtime::TimingClock));
}
self.midi_beat_clock_frame = cur_midi_beat_clock_frame;

(The initial value of midi_beat_clock_frame is -1 so that it already sends the first clock on tick 0.) That way, I can have my other modules (e.g. delay or rhythmic stuff) in VCV synced to my midi file's grid. JZZ seems great, it even properly takes into account tempo change meta messages, which other JS midi players don't do, and it seems it also responds to RPNs for setting pitchbend range, which is great. Also I like that it supports all GM instruments, timidity doesn't. Just the one thing missing is the midi clock :) I'm not suggesting sending clock by default, but it could be either built-in/optional (with constructor option flag) or via a custom onStep callback (such that the callback can access the player instance and send a msg to it that will get sent still in this current frame!).

Btw, I also like that JZZ Player supports jumping to a tick position, other js midi players don't. In my use case, I set a custom loop (not looping the full song but custom section) so I need this jumping functionality. Although I noticed if the loop len is not a beat/bar multiple, it messes up the clock sync in VCV. So I need to figure out how to reset the clock properly on a jump. Maybe by sending a clock stop message when the playhead reaches the loop end and sending a clock start message when after jumping the playhead reaches a beat or bar boundary (in most cases, the jump target tick is such a boundary because loop len will be quantized to beats/bars). If you know anything about midi clock, I'd appreciate if you could tell me how to handle this situation properly :) But this is only my use case, I don't expect JZZ to support non-full-song looping because I can implement it on top of its API. I just mention this use case because it affects how the clock messages need to be sent.

jazz-soft commented 1 year ago

Great idea! I need to think how to implement it...

Boscop commented 1 year ago

Btw, I ran some tests with Reaper, to see which messages it sends when clock is enabled:

image

Reaper clock dump:

// I press play with song pointer being at the start (tick 0 basically):
Realtime(Start) // Sent because song pointer is at tick 0
Realtime(TimingClock) // Sent at 24 PPQN
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(Stop) // I pressed pause
Common(SongPosition(u14(0))) // After pressing pause, Reaper's song pointer jumps back to where it was before I pressed play, and communicates this to the clock consumer via the `SongPosition` meta message (telling a connected device how many 16th notes have elapsed since the beginning of a song)
Common(SongPosition(u14(16))) // In Reaper I manually set the song pointer to one bar after start (16 16th notes = 1 bar)
// I press play: Reaper sends port reset messages
channel: u4(0), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(0), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(1), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(1), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(2), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(2), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(3), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(3), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(4), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(4), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(5), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(5), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(6), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(6), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(7), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(7), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(8), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(8), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(9), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(9), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(10), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(10), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(11), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(11), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(12), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(12), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(13), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(13), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(14), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(14), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(15), message: Controller { controller: u7(120), value: u7(0) }
channel: u4(15), message: Controller { controller: u7(123), value: u7(0) }
channel: u4(0), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(1), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(2), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(3), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(4), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(5), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(6), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(7), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(8), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(9), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(10), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(11), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(12), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(13), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(14), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(15), message: PitchBend { bend: PitchBend(u14(8192)) }
channel: u4(0), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(1), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(2), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(3), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(4), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(5), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(6), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(7), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(8), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(9), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(10), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(11), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(12), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(13), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(14), message: Controller { controller: u7(64), value: u7(0) }
channel: u4(15), message: Controller { controller: u7(64), value: u7(0) }
Realtime(Continue) // Then it sends `continue` instead of `start`, because it plays from SPP that's not at tick 0! (see below)
Realtime(TimingClock) // Playing...
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(Stop) // I paused again
Common(SongPosition(u14(16))) // Reaper moves the SPP moves back to where it was before starting playback
---
// Now testing looping behavior:
Realtime(TimingClock) // Playing, SPP is shortly before loop end
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(Stop) // At loop end, Reaper sends Stop
Common(SongPosition(u14(16))) // Reaper jumps to loop start
Realtime(Continue) // Reaper tells clock consumer to continue from given SPP
Realtime(TimingClock) // Playing...
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(TimingClock)
Realtime(Stop) // I paused playback
Common(SongPosition(u14(16))) // Reaper moves the SPP moves back to where it was before starting playback

I found a good explanation here: http://midi.teragonaudio.com/tech/midispec/seq.htm

A MIDI Start always begins playback at MIDI Beat 0 (ie, the very beginning of the song). So, when a slave receives a MIDI Start, it automatically resets its "Song Position" to 0. If the master needs to start playback at some other point (as set by a Song Position Pointer message), then a MIDI Continue message is sent instead of MIDI Start. Like a MIDI Start, the MIDI Continue is immediately followed by a MIDI Clock "downbeat" in order to start playback then. The only difference with MIDI Continue is that this downbeat won't necessarily be the very start of the song. The downbeat will be at whichever point the playback was set via a Song Position Pointer message or at the point when a MIDI Stop message was sent (whichever message last occurred). What this implies is that a slave must always remember its "current song position" in terms of MIDI Beats. The slave should keep track of the nearest previous MIDI beat at which it stopped playback (ie, its stopped "Song Position"), in the anticipation that a MIDI Continue might be received next.