Skip to content

Clock fixes - #53

Merged
danigb merged 9 commits into
mainfrom
clock
Sep 7, 2026
Merged

danigb merged 9 commits into
mainfrom
clock

Conversation

@danigb

@danigb danigb commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Made with Claude

The whole clock - the phase accumulator, the wrap, the gate - lived inside
`ClockWorkletProcessor.process()`, so running it meant standing up an
`AudioWorkletProcessor` stub first, and `sampleRate` was a worklet global that
a test could not vary. That is why every assertion about this module is made
one sample per block at 16384 Hz, the one rate where a beat is a whole number
of blocks.

`createClock(sampleRate)` is the shape `lfo` and `polyblep-oscillator` use: a
closure over the state returning a function the processor calls once per block.
`worklet.ts` is now the thin processor - constructor, `process()`,
`parameterDescriptors`, `DISPOSE`.

Pure refactor. The algorithm is transcribed rather than rewritten, including
the parts that are wrong: rendered over bpm in {0, 1, 120, 137.3, 1000} x
pulseWidth in {0, 0.25, 0.5, 1} at 44100 and 16384 for 60 s each, both outputs
are equal sample-for-sample to the pre-refactor build. `worklet.test.ts` passes
unmodified, which is the other half of that proof.

`dsp.test.ts` drives 16384, 44100 and 48000 from one file with no stub.
`processor.ts` 807 -> 765 bytes.
A clock's job is to be on time and nothing measured whether this one is. The
reason is one line at the top of `worklet.test.ts`: 16384 Hz, where a beat at
120 BPM is exactly 64 blocks, the phase reaches 1.0 precisely and wraps on a
block boundary every time. A clock rendering one value per render quantum is
indistinguishable there from one rendering per sample.

`dsp.test.ts` now drives 44100 and 48000 across bpm 60/120/137.3/400 - no pair
divides - with sample-indexed helpers: `risingEdgesOf`, `beatPeriods`,
`deviationMs`, and a `renderEdges` that keeps indices rather than a 26 M-sample
buffer, so 600 s x 8 configurations costs ~400 ms.

Measured and now asserted:

- jitter bounded by one render quantum: deviation in [-2.902, 0.000] ms at
  44100/120, one-sided negative because the fill takes its value from the top
  of the block. Ticket 03 tightens this to one sample.
- no cumulative drift: the last beat's deviation is drawn from the same bounded
  set as the first, and the period straddles the ideal (22016 or 22144 against
  22050) rather than sitting to one side of it.
- two clocks hold a constant offset: born 37 blocks apart, all 1200 beats are
  4736 samples apart, first to last.

`packages/euclid/src/clock-skew.test.ts` puts the cross-package skew on record:
128 samples, 2.90 ms, at every subdivision. It is the first test here that
drives two packages together - the worklet stub locally, `clock`'s `dsp.ts` by
relative path, no new dependency - and it is written to fail when ticket 03
closes the skew.

The README said two `Clock` nodes drift. They do not, and the distinction is
what makes an alignment inlet possible: an offset is repairable, drift is not.

Verified the net catches a broken clock: multiplying the increment by 1.0001
fails both the jitter and the drift assertions.
The clock advanced its phase by a whole render quantum and then filled the
block with a single number, so the "0...1 rising ramp" the README promised was
a 172-step staircase at 120 BPM and 44.1 kHz. Three defects came out of that
one line and one change closes all three.

Measured, at 44100 and 48000 across bpm 60/120/137.3/400 over 600 s:

- jitter: deviation from ideal beat time was [-2.902, 0.000] ms - one render
  quantum. It is now [-1, 0] samples, 23 us. Still no cumulative drift, which
  the block-constant version also got right and which must not regress.
- skew: `Clock.gate` and a `Euclid` on the same clock were 128 samples apart,
  because the gate was taken post-wrap and the phase pre-wrap, and `Euclid`
  could not see the 1.0 plateau as a boundary. They are now identical - the
  same accumulator read at the same sample, so there is nothing left to
  disagree about.
- subdivisions: 600 BPM x 20 gave 579 of 800 hits and 1000 BPM x 20 - the
  declared maximum tempo - gave 76 of 1333. Both are now exact.

The wrap is `>= 1` rather than `> 1`, so the phase is `[0, 1)` and the
one-block plateau at exactly 1.0 is gone. The loop emits before it advances:
`phase[i]` is the fraction of the beat elapsed *at* sample i, which puts a beat
boundary on the sample it is due on. Advancing first - the shape the ticket
sketched - is within the one-sample bound but biases every edge a sample early,
and a bias is worse than a rounding.

Two tests defended the defect and are gone rather than adjusted. Both were
oracle tests that re-implemented the block-constant expression and asserted
equality with it; one carried the comment "Euclid subdivides the clock by
multiplying this ramp, so it cannot move", which has the reasoning backwards -
a per-sample ramp is the one that survives multiplication. They were ticket
01's proof that extracting the engine changed nothing, they did that job, and
they should not be restored. The `run()` helpers that read one sample per block
now collect whole blocks.

`euclid`'s own suite passes unmodified, which is success criterion 8 and the
evidence that the two packages agreed all along and only the data was wrong.
Its `clock` a-rate justification in params.ts was aspirational until now.

Cost, from the new `benchmarks/clock-rate/`: +0.23 us/block worst case against
a 2667 us budget - under 0.01% of one core. The ratio is 4-5x, which is a fair
description of replacing a memset with five operations per sample, and only the
absolute number is a cost.
`pulseWidth: 1` was inside the declared range and disabled the module. The gate
is `phase < width`, the phase is `[0, 1)`, so a width of 1 is true for every
phase the clock can produce: 400 of 400 blocks high, one envelope attack and
then silence forever. Under the library's own contract - a trigger is the
transition to positive - a gate that never falls can never fire anything again.
`0` is the honest end of the range and reads as "no gate"; `1` read as "the
widest gate" and delivered "no more gates".

Both modules now cap the width to leave one render quantum of every cycle low,
so 1 means the widest gate that still retriggers. The cap is tempo-aware
because a static one cannot be: 0.9941 at 120 BPM, 0.9512 at 1000 BPM. Lowering
`maxValue` to 0.99 was the two-character alternative and moves the cliff rather
than removing it - 0.99 still latches at 1000 BPM, where it leaves 600 us low.

`Euclid` needed its own clamp and the clock-side one could not cover it: it
applies its own `pulseWidth` to its own subdivided phase, and measured, a
`Euclid` at 1 on a corrected clock still latched. It has no increment of its
own, but `clock` is a-rate, so two adjacent samples of the ramp are the
increment and `subdivision` multiplies it. A pair straddling the wrap gives a
negative delta; at most one wrap falls inside a block at the tempi `Clock`
declares, so a second candidate is enough.

The clamp asks for a quantum plus one sample. The threshold is compared at
discrete samples and euclid reads its increment back off a Float32 ramp, so
asking for exactly 128 lands on 127 often enough to be worth the sample.

Swept `pulseWidth` 0 to 1 in 0.01 steps across bpm 60/120/400/1000 at 44100 and
48000: every setting falls at least once per beat and no low run is shorter
than a quantum. `pulseWidth: 0` still emits no gate at all. Nothing below 0.95
moves by a sample at any tempo in range, which is what makes this a guard rail
rather than a behaviour change.
A clock's phase origin was "whenever the node was constructed" and nothing
could change it. Two clocks born 37 blocks apart hold that 4736-sample offset
for as long as they live - they do not drift, so nothing self-corrects, and
they never converge.

One level down it is sharper. Each `Euclid` keeps a private step counter
starting at 0 whenever *that node* was built, so two of them on one clock play
different rotations of the same pattern. Reproduced the audit's sweep exactly:
28 of 32 birth offsets diverge. `EuclidExample.tsx` builds two on one `Clock`
and they agree only because both are constructed in the same synchronous block;
a third pattern added from a UI callback gets a private downbeat.

Both modules get an a-rate `reset`. On `Clock` a rising edge returns the phase
to 0 on that sample - detected before the emit, so the phase *is* 0 there and
the gate rises there rather than a block later. On `Euclid` it arms the next
step boundary to be step 0.

The off-by-one the ticket warned about is real and the obvious spelling has it.
`step()` advances the counter *on* the boundary, so `current = 0` with
`prevClock = 1` advances to step 1. It aims one short instead:
`current = pattern.length - 1`.

Measured, as the inverse of the same sweep: 0 of 32 offsets diverge once both
are reset from one signal, and the 28/32 control is kept alongside it so the
sweep is not silently toothless. Two clocks born 37 blocks apart and reset from
one pulse are sample-identical over 600 s - 26 M samples, zero mismatches.

Edge triggered via `createGateDetector`, which is the fifth module to use it
for exactly this: a held-high reset fires once, not once per sample, and the
test for that is what proves the detector was used rather than a level check.
A reset does not wake a stopped clock - `bpm: 0` is how this library stops one
and there is no second answer to that question, so no `run` inlet.

This is a mechanism, not a policy. Two nodes still free-run independently
unless somebody wires them together; making agreement the default would mean a
global timeline, which is a larger decision than this folder should make.
Nothing in synthlet could express "the first beat of the bar", and no consumer
could work it out for itself. Subdividing a clock is multiplying its phase - a
pure function of the instantaneous value, available to anything with no setup.
Division is not the mirror image: the bar position is a *count*, and the beat
ramp during beat 1 is bit-identical to the ramp during beat 3, so the
information is not in the signal at all.

Choosing that origin privately is the actual defect. Counting is ten lines;
agreeing on where to start counting is not. Two counters built at different
times disagree about where bar 1 is, permanently and silently, while each is
individually correct - which is finding 7 of the audit, already shipped one
level down in `Euclid`'s private step counter. `Clock` owns the phase origin,
so it owns the bar.

`beatsPerBar` (0-32, default 4, k-rate), a bar phase on output 2 and a downbeat
gate on output 3. Both are the beat pair one level up and are here for the same
reason a beat needs both: a phase is what you subdivide or read a position
from, a gate is what a trigger wants.

Provably additive, which is what makes the rest safe: outputs 0 and 1 are
sample-identical to the pre-ticket build over 60 s across bpm {0, 1, 120,
137.3, 1000} x pulseWidth {0, 0.25, 0.5, 1} x beatsPerBar {0, 4} at 44100 and
48000.

`.downbeat` is a subset of `.gate` by construction - same width, same phase,
same sample - so `downbeat > 0` implies `gate > 0` everywhere, asserted
directly. A clock starts on a downbeat and a reset returns it to one; a reset
that zeroed the phase but not the counter would mean two different things
depending on which output you watched.

The beat counter is unbounded and the bar position is `beats % beatsPerBar`, so
changing the parameter re-phases the grid rather than emitting a spurious
downbeat or swallowing one. Both the position *and* the divisor are latched at
the beat boundary: reading the divisor live jumps the bar phase mid-beat, and a
downstream `Euclid` reads that jump as a wrap and answers with a spurious step.

`generate` takes the processor's `outputs` array rather than four positional
buffers - four outputs and four parameters is too many arguments to keep
straight, and `lfo`'s dsp already takes `parameters` for the same reason. The
loop collapsed from two bodies to one with four hoisted flags.

Cost, re-measured: +1.09 us/block worst case against a 2667 us budget, 0.04% of
one core, up from +0.23 with two outputs. The bar phase's per-sample divide is
free - 0.9244 vs 0.9269 us/block against a hoisted reciprocal, so the hoist was
tried, measured and reverted, and dsp.ts says so where the divide is.
`processor.ts` 1107 -> 1320 bytes.

The ticket's `Euclid(ac, { clock: clock.bar, steps: 8 })` example is wrong and
the docs do not repeat it: `subdivision` is what divides a cycle into steps,
`steps` selects the pattern. Eight steps per bar is `subdivision: 8`.
Six tickets each updated the docs for their own change, which kept the package
from being described wrongly. It did not produce a page describing the finished
module well, and it left three sentences behind that were true when written:

- "Both are rendered from one accumulator" - there are four outputs;
- "That is also why the gate is a second output" - likewise;
- "what repairs it is an alignment inlet, which this module does not have yet" -
  it has had one since `reset` landed, two sections further down the same file.

The README is now the ticket's six sections in order: what it is, usage,
outputs, parameters, timing, sync. The outputs table is the thing prose could
not do - four outputs across two levels - and the timing section follows
`polyblep`'s framing: the method, the figures, the statement that they are this
repository's own numbers comparable only within it, and the test that holds each
one, because a number in a README that no test defends is a number that will
quietly stop being true.

A reader can now answer "how do I make two patterns start together?" from the
README alone, which is what audit findings 5 and 7 are both really about.

The docs page carries the recipes rather than the reasoning: a downbeat accent,
subdividing a beat or a bar, two patterns sharing a reset, two clocks aligned.
Its example is 120 BPM rather than the `bpm: 10` it opened with.

`ClockExample.tsx` is a clave on `.gate` and a cowbell on `.downbeat` with a
beats-per-bar control - the smallest patch that makes a bar audible, and the
same `Compound` shape `EuclidExample` uses. `gates-and-triggers.mdx` gains
`.downbeat` in its table and the note that it is a subset of `.gate`.

Checked mechanically rather than from memory: every one of the four parameters
and three exposed outputs appears on both pages, nothing on either page is
absent from `PARAMS` or the `exposes` map, and the four test names cited in the
timing table exist. `grep drift` over the package and the site returns only "does
not drift" and "no cumulative drift".

`package.json` is unchanged. Its metadata is complete, and its `homepage`
pointing at `/docs/clock` rather than `/docs/sequencers/clock` is how all 25
packages are - repo-wide, not a clock defect.
@danigb
danigb merged commit 099795c into main Sep 7, 2026
1 check passed
@danigb
danigb deleted the clock branch September 7, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant