Skip to content

Commit

Permalink
MIDISyncClock: Fix default event support(!) and add SCDoc help
Browse files Browse the repository at this point in the history
  • Loading branch information
jamshark70 committed Jan 7, 2019
1 parent 7cf12a4 commit bb3c90c
Show file tree
Hide file tree
Showing 2 changed files with 174 additions and 27 deletions.
103 changes: 103 additions & 0 deletions HelpSource/Classes/MIDISyncClock.schelp
@@ -0,0 +1,103 @@
TITLE:: MIDISyncClock
summary:: A clock that follows incoming MIDI clock messages
categories:: External Control>MIDI
related:: Classes/TempoClock

DESCRIPTION::
A substitute clock that follows MIDI clock messages from an external source, with some caveats:

If possible, for MIDI clock sync, it is recommended to use SuperCollider as the clock source, so that you can control for server latency. (See link::Classes/MIDIOut#-latency:: -- MIDI messages going out from SuperCollider can be delayed by an amount matching the server's latency. This will ensure better sync than is possible by responding to an external source.)

If it is absolutely necessary to follow an external source, use this class.

Subsection:: Usage

Recommended procedure:

Numberedlist::
## Be sure the clock source is not running.
## In SuperCollider (receiving machine), initialize MIDIClient (link::Classes/MIDIClient#*init::).
## Connect incoming port 0 to the device from which clock messages are coming (link::Classes/MIDIIn#*connect::).
## Initialize MIDISyncClock (link::Classes/MIDISyncClock#*init::).
## Start the clock source.
::

The clock source should begin by sending a MIDI clock "start" message, resetting MIDISyncClock to beat 0. This is essential for beat sync. If you start the clock source first, and then initialize MIDISyncClock, the beats will probably not line up.

NOTE:: MIDI clock "tick" messages emphasis::do not provide any information about the position within the bar::. The only way to be sure beats are synchronized is to initialize MIDISyncClock first, and then start the external source. This is a limitation in the MIDI protocol; there is nothing I can do about that. ::

NOTE:: Set the server's latency as low as possible. MIDI clock messages assume the receiving device will play instantaneously; there is no compensation for OSC messaging latency. ::

Subsection:: Limitations

list::
## The MIDI standard specifies 24 clock pulses per quarter note. MIDISyncClock does not attempt to interpolate scheduling times between pulses. Any events scheduled for a time in between pulses will fire on exactly the next pulse. Subdivisions of the beat other than duple and triple will therefore be slightly inexact (but, at 60 bpm, one pulse is about 42 ms, so any timing deviation will be hard to detect by ear).

## The tempo measurement is slightly unstable, generally within a percentage point. This is a necessary consequence of reacting to messages whose timing is not 100% accurate.

## Because the tempo measurement is unstable, MIDISyncClock cannot properly support link::Classes/TempoClock#-secs2beats::. Instead, it returns current beats. This is not a problem for normal usage (playing Tasks and patterns). It may be a problem if you need to coordinate the MIDI clock with other clocks at different tempi.
::


CLASSMETHODS::

Most of the methods of MIDISyncClock attempt compatibility with TempoClock. Consult link::Classes/TempoClock:: documentation for details on scheduling events and managing metrical position.

One exception is link::Classes/TempoClock#-tempo::. MIDISyncClock derives its tempo from the external source; for obvious reasons, then, you cannot override the tempo by code::tempo_::.

METHOD:: init
Initialize MIDISyncClock's internal state. Must be called before starting the external clock source.

PRIVATE:: barsPerBeat, baseBar, baseBarBeat, beatDur, beats, beats2secs, beatsPerBar, clear, dumpQueue, elapsedBeats, nextTimeOnGrid, play, queue, sched, schedAbs, seconds, secs2beats, setMeterAtBeat, startTime, tempo, tick, ticks, ticksPerBeat, ticksPerBeat



EXAMPLES::

This demonstration will generate MIDI clock messages within SuperCollider.

code::
// Initialize the MIDI objects first
MIDIClient.init; // step 2 above
MIDIIn.connectAll; // step 3
MIDISyncClock.init; // step 4

// This next block runs a clock source in SC.
// If you have an external clock source, use it AND SKIP THIS PART.
// Otherwise, see MIDIOut documentation for details on connecting
// SC MIDIOut to... itself :D

(
// You might need to change the string here: IAC MIDI on Mac?
d = MIDIClient.destinations.detect({ |ep| ep.device == "SuperCollider" });
m = MIDIOut.newByName(d.device, d.name);
Tdef(\mc).quant = -1;
Tdef(\mc, {
var tick = 1/24;
m.start;
loop {
m.midiClock;
tick.wait;
};
}).play;
ShutDown.add { Tdef(\mc).stop };
)

// Use the clock
// For an external source, set latency as low as possible
s.latency = 0.03;

// quant: -1 = start on a barline
(
p = Pbind(
\degree, Pseq([
-7,
Pwhite(0, 7, 15)
], inf),
\dur, 0.25,
\amp, Pseq([0.5, Pn(0.1, 15)], inf)
).play(MIDISyncClock, quant: -1);
)

p.stop;
::
98 changes: 71 additions & 27 deletions MIDISyncClock.sc 100755 → 100644
Expand Up @@ -8,54 +8,65 @@ MIDISyncClock {
classvar responseFuncs;

classvar <ticks, <beats, <startTime,
<tempo, <beatDur,
<beatsPerBar = 4, <barsPerBeat = 0.25, <baseBar, <baseBarBeat;
<tempo, <beatDur,
<beatsPerBar = 4, <barsPerBeat = 0.25, <baseBar, <baseBarBeat;

// private vars
classvar lastTickTime, <queue;
// private vars
classvar lastTickTime, <queue, medianRoutine, medianSize = 7;

*initClass {
responseFuncs = IdentityDictionary[
// tick
// tick
8 -> { |data|
var lastTickDelta, nextTime, task, tickIndex;
// use nextTime as temp var to calculate tempo
// this is inherently inaccurate; tempo will fluctuate slightly around base
nextTime = Main.elapsedTime;
var lastTickDelta, lastQueueTime, nextTime, task, tickIndex;
var saveClock;
// use nextTime as temp var to calculate tempo
// this is inherently inaccurate; tempo will fluctuate slightly around base
nextTime = SystemClock.seconds;
lastTickDelta = nextTime - (lastTickTime ? 0);
lastTickTime = nextTime;
tempo = (beatDur = lastTickDelta * ticksPerBeat).reciprocal;
beatDur = medianRoutine.next(lastTickDelta) * ticksPerBeat;
tempo = beatDur.reciprocal;

ticks = ticks + 1;
beats = ticks / ticksPerBeat;

// while loop needed because more than one thing may be scheduled for this tick
{ (queue.topPriority ?? { inf }) <= ticks }.while({
// perform the action, and check if it should be rescheduled
(nextTime = (task = queue.pop).awake(beats, this.seconds, this)).isNumber.if({
saveClock = thisThread.clock; // "should" be SystemClock
thisThread.clock = this;
// while loop needed because more than one thing may be scheduled for this tick
while {
lastQueueTime = queue.topPriority;
// if nil, queue is empty
lastQueueTime.notNil and: { lastQueueTime <= ticks }
} {
// perform the action, and check if it should be rescheduled
task = queue.pop;
nextTime = task.awake(lastQueueTime /*beats*/, this.seconds, this);
if(nextTime.isNumber) {
this.sched(nextTime, task, 0)
});
});
};
};
thisThread.clock = saveClock;
},
// start -- scheduler should be clear first
// start -- scheduler should be clear first
10 -> { |data|
startTime = lastTickTime = Main.elapsedTime;
beats = baseBar = baseBarBeat = 0;
ticks = -1; // because we expect a clock message to come next, should be 0
},
// stop
// stop
12 -> { |data|
this.clear;
}
];
}

*init {
// retrieve MIDI sources first
// assumes sources[0] is the MIDI clock source
// if not, you should init midiclient yourself and manually
// assign the right port to inport == 0
// using MIDIIn.connect(0, MIDIClient.sources[x])
// retrieve MIDI sources first
// assumes sources[0] is the MIDI clock source
// if not, you should init midiclient yourself and manually
// assign the right port to inport == 0
// using MIDIIn.connect(0, MIDIClient.sources[x])
MIDIClient.initialized.not.if({
MIDIClient.init;
MIDIClient.sources.do({ arg src, i;
Expand All @@ -65,6 +76,32 @@ MIDISyncClock {
MIDIIn.sysrt = { |src, index, data| MIDISyncClock.tick(index, data) };
queue = PriorityQueue.new;
beats = ticks = baseBar = baseBarBeat = 0;
medianRoutine = Routine { |inval|
var i, mid = medianSize div: 2,
values = Array(medianSize), order = Array(medianSize);
loop {
// if arrays are full, drop oldest
if(values.size == medianSize) {
i = order.minIndex;
values.removeAt(i);
order.removeAt(i);
order.size.do { |i| order[i] = order[i] - 1 }; // in place, avoid GC load
};
i = values.detectIndex { |item| item >= inval };
if(i.isNil) {
values = values.add(inval);
order = order.add(values.size);
} {
values = values.insert(i, inval);
order = order.insert(i, values.size);
};
if(values.size < medianSize) {
inval = values.blendAt((values.size - 1) * 0.5).yield;
} {
inval = values[mid].yield; // optimized, when it's full
};
};
};
}

*schedAbs { arg when, task;
Expand Down Expand Up @@ -111,17 +148,24 @@ MIDISyncClock {
}

*secs2beats { |seconds|
^seconds * tempo;
^beats
// A bit of a dodge here. This might break something.
// But 'tempo' is unstable so the following might lurch forward and back,
// causing even worse problems.
// ^seconds * tempo;
// So we will support the normal case: a stable, increasing time base.
// This works for patterns but it might f*** up code that expects to coordinate
// multiple clocks by converting all of their beats to seconds.
}

// elapsed time doesn't make sense because this clock only advances when told
// from outside - but, -play methods need elapsedBeats to calculate quant
// elapsed time doesn't make sense because this clock only advances when told
// from outside - but, -play methods need elapsedBeats to calculate quant
*elapsedBeats { ^beats }
*seconds { ^startTime.notNil.if(Main.elapsedTime - startTime, nil) }

*clear { queue.clear }

// for debugging
// for debugging
*dumpQueue {
{ queue.topPriority.notNil }.while({
Post << "\n" << queue.topPriority << "\n";
Expand Down

0 comments on commit bb3c90c

Please sign in to comment.