Clocks and clips#
Goal: run a named beat clock, change its tempo while it plays, and loop a bar of notes against it into a synth or a MIDI output port.
A domain clock counts beats at a tempo. A clip holds a list of notes placed in beats and plays them against one clock, on the audio thread. Your code never schedules a single note: it edits the list and steers the tempo, and the engine fires each note in the audio block where the clock passes it.
This tutorial has no demo program. The snippets use only the calls shown, and the C API versions follow at the end.
Mental model#
A clock has a name, a tempo in BPM and a beat position. It starts at beat 0 when you create it.
The beat position is the running total of the tempo over time. A tempo change alters how fast the beats go by and never makes the position jump.
The engine advances every clock once per 128-sample block, on the audio thread, while a device is open (or on each
renderOfflineblock). The clocks keep moving when no sound is playing. They do not move beforeSystem().init()or while the engine is paused.A clip is bound to one clock and reads it at the start of every block. It fires the notes whose beat falls between the previous block and this one. A clip is therefore accurate to one block: about 2.9 ms at 44.1 kHz.
Clocks are shared by name. The host, any number of clips and every patch in
the process see the same "main" clock. Time, scheduling and clocks covers the
patcher side, and Threading model says which thread each call belongs
on.
Creating and steering a clock#
#include "yse.hpp"
YSE::System().init();
YSE::System().createClock("main", 120.f); // starts at beat 0, 120 BPM
YSE::System().setTempo("main", 90.f, 4.f); // glide to 90 BPM over 4 s
YSE::System().setTempo("main", 140.f); // jump to 140 BPM now
double beat = YSE::System().beatPosition("main");
float bpm = YSE::System().currentTempo("main");
createClock(name, bpm) returns false when the name is empty or a
clock of that name already exists. The existing clock then stays as it was:
the first registration wins. The tempo defaults to 120 BPM.
setTempo(name, bpm, rampSeconds) changes the tempo in a straight line
over rampSeconds, starting from the tempo the clock has at that moment,
so a new ramp can take over from one still running. With no ramp time, or
0, the change is instant. The engine picks up tempo requests once per block,
so when you call setTempo twice within one block only the second call
counts.
The tempo is not clamped:
0 pauses the clock. The beat position holds, and the clips on it stop firing until the tempo rises again.
A negative tempo runs it backwards. Clips fire nothing while the clock goes backwards. See Timing rules.
beatPosition and currentTempo are cheap reads. You can call them from
a UI thread every frame, for example to draw a playhead. Both return 0 for a
name that has no clock, and clockExists(name) tells the two cases apart.
Several clocks can run at once, each at its own tempo. They all advance from the same sample clock, so their ratios stay exact: a clock at 90 BPM and one at 120 BPM stay at 3:4 for as long as they run.
Playing a clip into a synth#
A clip needs a clock, a list of events and at least one target. Here the target is a synth playing behind a sound, as in Your first synth:
YSE::SYNTH::sineVoice voice; // must outlive the synth's setup
YSE::synth synth;
synth.create().addVoices(voice, 8);
YSE::sound sound;
sound.create(synth);
sound.play();
// One bar of four quarter notes.
// startBeat duration channel pitch velocity pitchBend
std::vector<YSE::clipEvent> bar = {
{0.0, 0.5, 1, 60, 0.8f, 0.f},
{1.0, 0.5, 1, 64, 0.8f, 0.f},
{2.0, 0.5, 1, 67, 0.8f, 0.f},
{3.0, 0.5, 1, 72, 0.8f, 0.f},
};
YSE::clip clip;
if (!clip.create("main")) {
// no clock called "main"
}
clip.setEvents(bar).loopLength(4.0).connect(synth).play();
Each clipEvent has:
startBeatThe beat within the loop where the note starts.
durationBeatsThe note length. The note-off comes this many beats after the note-on.
channelMIDI channel, 1 to 16.
pitchMIDI note number, 0 to 127.
velocity0 to 1.
pitchBend-1 to 1, 0 for none. A non-zero bend is sent as a pitch-wheel message on the note’s channel just before the note-on. Use it for microtonal pitches: the nearest semitone plus a bend.
create(clockName) binds the clip to a clock. It returns false if no
clock of that name exists. A failed create also unbinds the clip from the
clock it had, so it goes silent. You can bind again at any time, to the same
clock or another.
setEvents, loopLength, connect and play return the clip, so
they chain. All of them are control-thread calls.
Changing a clip while it plays#
setEvents replaces the whole list. The clip picks up the new list at the
next block, without locking the audio thread, so you can call it as often as
the music changes:
bar[3].pitch = 71; // edit your copy of the list ...
clip.setEvents(bar); // ... and publish it
A note that is sounding when the list changes still gets its note-off on time, even if the new list no longer contains it.
loopLength(beats) sets the loop. A length of 0 or less turns looping off,
so each event plays once. stop() ends playback and sends a note-off for
every note still sounding. play() starts again. isPlaying() reports
the state; it turns false as soon as you call stop(), before the
note-offs have gone out.
Timing rules#
Event times are measured on the clock, not from the moment you call
play(). That keeps clips in step with each other, but it has some
consequences:
Loops line up with the clock. A 4-beat loop plays its beat-0 event at clock beats 0, 4, 8 and so on. A clip started at clock beat 5.5 first plays its beat-2 event, at clock beat 6. Two clips with the same loop length on the same clock are always in phase, however far apart you start them.
A clip without a loop plays each event when the clock reaches its ``startBeat``. An event whose
startBeatthe clock has already passed never plays. To play a phrase once starting “now”, add the current position:startBeat = System().beatPosition("main") + offset.The first block after
play()only records where the clock is. Firing starts from the next block, so an event exactly on the clock position at that moment is skipped.A paused or reversed clock fires nothing. When the tempo is 0 or negative, the clip plays no new notes and sends no note-offs. Notes that are sounding wait until the clock passes their end again, or until you call
stop().Note-offs go before note-ons within a block, so a note that ends as the same pitch starts again is released first.
A tempo change reaches every clip on the clock in the next block. Nothing is rescheduled: a glide from 120 to 90 BPM bends every clip on that clock at once.
Targets#
A clip can play into several targets at once. It sends every note to all of them.
Synths. Call connect(synth) once for each synth, up to 8 per clip.
The notes reach the synth on the audio thread, in the block where they fire.
disconnect(synth) stops the routing. The synth must outlive the
connection: disconnect it, or destroy the clip, before you destroy the synth.
MIDI output ports. On Windows and Linux builds with
YSE_ENABLE_MIDI_DEVICE, a clip can also play to an external MIDI device,
up to 4 ports per clip:
YSE::midiOut out;
out.create(0); // open port 0 first; an unopened midiOut is ignored
clip.connect(out);
The audio thread still decides when each note fires. It stamps the message
with the time it should be sent and hands it to a separate sender thread,
which makes the actual MIDI call, so the audio callback never waits on the
device. The port stays open inside the engine, so you may destroy the
midiOut object after connecting. If the sender’s queue is full, a message
is dropped rather than making the audio thread wait.
Connecting more than 8 synths or 4 ports to one clip does nothing. There is no error, and the extra targets get no notes.
Lifetime#
A clip and its clock can be destroyed in either order.
System().destroyClock(name)removes the clock. A clip bound to it keeps a hold on the clock, but the clock stops advancing, so the clip stops firing. It does not crash and does not read freed memory.Creating a new clock with the same name does not reconnect old clips. Call
create(name)on each clip again to bind it to the new clock.Destroying a clip releases the notes it is sounding, as
stop()does. The note-offs go out in the next block, to its synths and MIDI ports. You may destroy a connected synth right after the clip: the release does not need the synth object any more.A clip may outlive
System().close(). Closing the session removes every clock, so the clip stops firing, but it stays safe to call and to destroy. It keeps its events, loop length and connections. In the next session, create the clock again and callcreate(name)on the clip to bind it.
Changed in version 3.0: Clips and patcher objects now share the lifetime of the clock they are
bound to, so destroyClock can no longer free a clock that is still in
use (issue #707). A clip that
is still alive at System().close() is no longer freed under its handle
(issue #974). Destroying a
clip releases its sounding notes; before, they kept sounding (issue #975).
Limits#
256 notes sounding at once per clip. Further note-ons are dropped.
Each event plays at most 64 times in one block. This only matters for a very short loop at a very high tempo.
8 synths and 4 MIDI ports per clip.
Clocks in a patch#
Patcher objects use the same clocks by name. .transport and
.setclock create clocks, and .tempo, .metro, .delay and the
other timed objects can count beats on them. A clip and a patch on the same
clock fire in the same block. See Time, scheduling and clocks.
The C API#
The C API has the same calls, in yse_system.h for clocks and
yse_clip.h for clips (see C ABI (language bindings)). YseClipEvent has the
same fields as YSE::clipEvent, in the same order:
#include "yse_c/yse_all.h"
YseSystem* sys = yse_system_get();
yse_system_init(sys);
yse_system_create_clock(sys, "main", 120.f);
YseSynth* synth = yse_synth_create();
yse_synth_add_voices_sine(synth, 8, 0, 0, 127, 0.01f, 0.1f, 0.7f, 0.3f);
YseSound* sound = yse_sound_create();
yse_synth_attach_to_sound(synth, sound, NULL, 1.0f);
yse_sound_play(sound);
YseClipEvent bar[4] = {
{0.0, 0.5, 1, 60, 0.8f, 0.f},
{1.0, 0.5, 1, 64, 0.8f, 0.f},
{2.0, 0.5, 1, 67, 0.8f, 0.f},
{3.0, 0.5, 1, 72, 0.8f, 0.f},
};
YseClip* clip = yse_clip_create();
if (yse_clip_bind(clip, "main") != YSE_OK) {
fprintf(stderr, "%s\n", yse_last_error());
}
yse_clip_set_events(clip, bar, 4);
yse_clip_set_loop_length(clip, 4.0);
yse_clip_connect_synth(clip, synth);
yse_clip_play(clip);
yse_system_set_tempo(sys, "main", 90.f, 4.f);
double beat = yse_system_beat_position(sys, "main");
/* teardown: the clip first (it releases its notes), then the sound,
then the synth */
yse_clip_destroy(clip);
yse_sound_destroy(sound);
yse_synth_destroy(synth);
yse_system_destroy_clock(sys, "main");
yse_system_close(sys);
The differences from C++:
yse_system_create_clockandyse_clip_bindreturn aYseStatus. On failure the reason is inyse_last_error().yse_clip_set_eventscopies the array, so you can reuse or free it after the call. PassNULLwith a count of 0 to clear the list.yse_clip_connect_midi_outtakes aYseMidiOut*opened withyse_midi_out_open. On builds without MIDI device support it does nothing and setsyse_last_error().
What you learned#
A domain clock is a named beat counter. Its tempo can jump, glide, pause and run backwards, and its beat position never jumps.
A clip plays a list of beat-timed notes against a clock, on the audio thread, accurate to one block.
Loops line up with the clock’s beats, not with the moment you press play.
setEventsswaps the list while the clip plays, and sounding notes still end on time.A clip and its clock can be destroyed in either order. Stopping or destroying a clip releases the notes it is sounding.
Next#
Time, scheduling and clocks: beat-synced objects in a patch.
Clocks and clips: the
YSE::clipreference.C ABI (language bindings): the C clip and clock functions.