Repository navigation
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Made with Claude