Skip to content

Timing: backwards playback, buffer-span tiling, and one quantification grid - #917

Merged
jcelerier merged 33 commits into
masterfrom
fix/backwards-playback-dataflow
Aug 8, 2026
Merged

Timing: backwards playback, buffer-span tiling, and one quantification grid#917
jcelerier merged 33 commits into
masterfrom
fix/backwards-playback-dataflow

Conversation

@jcelerier

@jcelerier jcelerier commented Jul 30, 2026

Copy link
Copy Markdown
Member

29 commits, one per defect, each confirmed failing first. Companion to ossia/score#2163 and celtera/avendish#185.

The buffer-span hole (the audible one)

At any speed ≠ 1 a sample in each buffer was written by nobody. Frames became flicks, the flicks were floored into a model duration, and each consumer floored that back into samples — so at speed 1.234 a 64-frame buffer tiled as spans ending at 63 while the buffer held 64, and the gap moved around with the residue. The fix is to stop reconstructing: the producer carries start_sample/length_sample on the token and consumers read them, so spans are differences of one monotone model -> sample map and can neither overlap nor leave a hole.

The interval date now advances by flooring and carrying the fraction rather than ceil-ing; at speed 1.234 the old form drifted 1600 flicks per 2000 ticks.

Backwards playback

loop(), the physical-time mapping, the sub-tick offset of a backward overtick, quantification dates, the metronome and the Faust node all assumed a forward tick. Each is fixed separately and covered.

One quantification grid

The grid is now walked once, for every consumer. It restarts at each bar line — bar lines coming from the signature and from the musical_*_last_bar positions the host reports, which is what keeps a 7/8 bar from carrying a stale phase — and get_quantification_date() is literally the first element of get_quantification_dates() rather than a second implementation of it.

Two defects found by adversarial review and fixed here:

  • a point on the tick's musical end fired twice — its date truncated back into the tick and it fired again at frame 0 of the next. 46 double-fires in a 6000-tick sweep at 48 kHz / 512 / speed 0.5 / rate 1.5.
  • a grid point's sample was rounded twice — the date is already truncated to a whole flick, so flooring it into a frame rounded again. metronome() never did this, so an ossia click and an ossia quantized event on the same bar line could disagree by a sample. Points now carry their musical position and every consumer maps it through one physical_position().

Sound sync

Two long-standing defects, both pre-existing (a harness cherry-picked onto master reproduces them identically):

  • file_sample_for_model_time scaled by live_tempo / file_tempo, correct only at 120 BPM — a drop-in was up to 3.4 s off;
  • RubberBand's getStartDelay() was never trimmed, so every stretched sound came in 22–29 ms late.

Tests

TimingInvariantsTest (2,522,328 assertions, 20 cases) asserts the tiling invariant — active spans partition [0, bufferSize) exactly — across speeds, buffer sizes and nesting. SoundSyncTest measures click-track grid error directly. BackwardPlaybackTest and TokenRequestTest cover the rest.

Baseline is unchanged: ScenarioAlgoTest 398 / 1206 / 1327 fail identically on origin/master and predate all of this.

Known limitations, measured not hidden

  • RubberBand R2's start-delay trim is an empirical fit valid over ratio 0.52–1.17; at 3.33 the stream is ~43 ms late. Pinned as [!mayfail].
  • A running sound copy exits a tempo step permanently late; raw-mode drop-in under tempo automation is 44032 samples off.

@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 03:11 — with GitHub Actions Inactive
@jcelerier
jcelerier force-pushed the fix/backwards-playback-dataflow branch from 3f30a70 to 4b6bea7 Compare July 30, 2026 12:34
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:34 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:34 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:34 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:34 — with GitHub Actions Inactive
@jcelerier jcelerier changed the title Fix backwards playback: direction-agnostic buffer positions, loop() rewind, pure time_value negation Fix backwards playback: buffer positions, loop(), quantification, raw-mode tempo Jul 30, 2026
@jcelerier
jcelerier force-pushed the fix/backwards-playback-dataflow branch from 4b6bea7 to d36373e Compare July 30, 2026 12:57
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:57 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:57 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:57 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate July 30, 2026 12:57 — with GitHub Actions Inactive
jcelerier and others added 24 commits August 2, 2026 20:01
exec(), exec_mono_fx() and exec_synth() wrapped everything in
`if(tk.forward())`, so a negative speed left the output port untouched and
the effect chain went silent. timings() hands the same buffer span it would
going forward; only a paused tick has nothing to do.

The float path of do_exec() / do_exec_mono_fx() still ignores the tick
offset (its own `TODO offset !!!`), so an interval that does not start on a
buffer boundary lands at the wrong buffer position - unchanged, and equally
wrong in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BackwardPlaybackTest pins the unit-level contract: the physical-time
helpers are symmetric between +speed and -speed, to_physical_time_in_tick()
maps prev_date and date to the ends of the buffer span in both directions
and round-trips through its inverse, loop() conserves the requested
duration and tiles the buffer, get_quantification_dates() walks the mirror
interval when rewinding while get_quantification_date() reports nothing,
metronome() stays forward-only, and raw playback ignores the live tempo.

BackwardAudioTest drives a real scenario and graph: steady-state rewind
hands a plug-in node the whole buffer, the spans claimed by the active
intervals tile it exactly at a boundary - including when several
boundaries fall inside one buffer and the overtick cascade runs twice -
and rewinding a ramp through an effect yields the forward stream reversed
sample for sample.

Every one of the preceding commits has at least one case that fails
without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The metronome never fired while rewinding: tick_impl left the musical end
position equal to the start one on a backward tick, so the musical duration
came out as 0 and both branches bailed. Making the positions correct in
both directions then handed metronome() a negative duration, whose ratio
lands outside the tick - and the callbacks are buffer offsets - so it was
made forward-only rather than left to write out of range.

Mirror it instead. Going forward the tick enters the bar at
musical_end_last_bar; rewinding it goes back over the start of the bar it
is in, at musical_start_last_bar. Same for the quarter inside the bar:
end_quarter forward, start_quarter backwards. The ratio then comes out
positive either way, because the distance to the crossed date and the tick
duration are both negative when rewinding. A bar line halfway through a
buffer clicks on the same sample in both directions, which is what playing
that half of the buffer in reverse means.

Clamp the result into the tick while there. A date sitting exactly on the
end of a tick gives a ratio of 1, i.e. the one-past-the-end sample: the
metro node computes `count = d - start_sample`, gets 0, and drops the click
- so a downbeat landing exactly on a buffer boundary was silently lost
going forward too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_interval_backward() bailed out on "nothing to do if already at 0"
without registering a backward overtick, so the cascade that should stop
that interval and restart its predecessor never ran. Playing forward until
the playhead lands exactly on an interval boundary - the next interval has
started and is at date 0 - and then rewinding produced no audio at all, and
the playhead never moved again for the rest of the rewind. Rewinding across
a zero-length interval stalled the same way, and everything before it was
unreachable.

Letting it through costs nothing: `displacement < 0` is false, so the
existing clamp branch is taken, its `cst_old_date != 0` guard skips the
(empty) tick, and `ot` comes out as the whole remaining tick for the
cascade to hand to the previous interval.

This does not resurrect rewinding after the score has played to its end -
nothing is running then, so there is nothing to cascade from, which is
intended and now pinned by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TimingInvariantsTest drives the real scenario / interval / token_request
machinery over the parameter space rather than hand-picked cases: 2.5M
assertions covering the physical-time mapping at fourteen speeds, three
model-to-sample ratios and buffer sizes from 1 to 4096; loop() subdivision
over ~1600 combinations; exact buffer tiling for five interval layouts,
scaled root speeds, and a scenario nested inside an interval inside a
scenario; playhead return after a forward/backward round trip; quantifi-
cation reporting each grid point exactly once against an independently
computed grid for four signatures including 7/8 and 5/4; the metronome
likewise; and time_value's infinity arithmetic.

Two of them pin the cascade fix in the preceding commit - an interval
sitting exactly on a boundary, and a zero-length interval - and both fail
without it.

BackwardAudioTest gains the complementary case: after the score has played
to its end, rewinding stays silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An interval's buffer offset was produced in the scenario's frame but
consumed as if it were local, so physical_start divided it by the
interval's own speed on top of the parent's. An interval with
set_speed(2) started half a buffer early; one with set_speed(0.5) landed
past the buffer end and logged from the audio thread every boundary.

Scale the offset by the same factor the tick duration is scaled by, so
the child speed cancels, and convert the overtick and the offset handed
to the next interval back into the scenario's frame. That offset now
comes from the finishing interval's consumed duration rather than from
tick_ms minus the overtick, keeping the overtick's truncation out of the
next interval's start.
…vances at

tick_offset advances a tempo interval by (m_speed * tempo / root_tempo)
divided by the parent speed, because such an interval runs at its tempo
whatever the transport does. The max-duration clamp used get_speed(),
which omits that division, so with a tempo curve and a parent speed != 1
the two disagreed by exactly the parent speed: the clamp fired early and
handed the next interval a spurious overtick, overlapping its span.
physical_start truncated the offset down while physical_write_duration
ceiled the duration up, so a boundary one flick past a sample gave the
two intervals sharing it an overlapping sample: A[0,33) next to B[32,64).
Independent rounding in opposite directions, and dragged regions land off
a sample boundary as a matter of course.

Take every span as a difference of sample_at(), which floors: A's end is
then literally B's start, since B's offset is A's offset plus its
duration. physical_read_duration becomes a difference of the same map
over absolute model time, so consecutive ticks read consecutive samples.
to_physical_time_in_tick goes through it too, so a MIDI note or a
quantification date at a boundary lands on the sample the audio boundary
lands on.

A tick shorter than one sample now legitimately has a zero-length span.
The metronome placed its click with the span length and also gated
emission on it, so such a tick dropped the click entirely; only the
placement should depend on it.
The date advance rounded up every tick. Speeds that multiply out to whole
flicks were exact, but an arbitrary one drifted: 1.234 gained 1600 flicks
over 2000 buffers, and at 1e-9 the interval crept a whole flick per tick,
running roughly a million times too fast.

Floor instead and carry the fraction to the next tick. The date is then
exact to within the last floor, and speed 0 is the only case that does
not move, which is the honest definition of paused. Per-tick progress is
given up: a tick can now cover no flick at all, which needs a speed below
about 1e-6 at any real buffer size.

The clamp deciding whether a tick overruns an interval's duration used to
recompute the advance with its own expression; it now asks for the same
number the tick will use, so the two can no longer disagree. Rewinding
consumes the fraction the same way, so going out and back lands on the
date it started from instead of overshooting in both directions.
Three inconsistencies, all reachable only through an infinite duration,
which intervals use as their default maximum:

  inf == inf was true while inf <= inf and inf >= inf were both false;
  operator+=(int64_t) set an infinite value to zero where operator+
  saturates; and operator* multiplied the sentinel out to an arbitrary
  finite number, silently turning an endless interval into a bounded one.
The metronome fired at most once per tick, so any tick long enough to
step over two grid points dropped one. In 7/8 the third beat and the
following bar line are half a quarter apart, which a tick of 999 units
against a quarter of 1000 straddles: 46 of 55 quarters were reported.
Walk the grid instead and report each crossing in order.

A point sitting exactly on a tick boundary now belongs to the tick that
starts on it, where it is sample 0, rather than the one that ends on it,
where it could only be the last sample and so a sample early.
For a division shorter than a bar the grid was laid out once, from the
bar the tick started in. When the bar length is not a whole number of
divisions - 7/8 is 3.5 quarters, a half-note grid steps by 2 - that
phase carried across the bar line, so a tick spanning two bars reported
points that are not on the grid and skipped the bar lines, which are
always grid points. Over 0..8 quarters it gave 0, 2, 4, 6 instead of
0, 2, 3.5, 5.5, 7.

Walk bar by bar and lay the grid out from each bar line, which also
makes the reported index mean the same thing in every bar.
The read count is a floor over absolute model time; the write count is a
floor over the offset into the buffer. They are floors of differently
phased quantities, so a tick can cover a whole output sample while
consuming less than a whole input one - which is what the first tick of
any region not starting on a sample boundary does. A region 63.75 samples
into the buffer reads 0 and writes 1.

sound_sampler returned on read == 0, leaving a sample of silence there and
m_prev_date stale, which then looked like a seek to the following tick.
Gate on the write count alone, as sound_mmap and sound_libav already do.
There were three rules for where the quantification grid is. The plural
form restarted it at each bar line, the singular form ran it continuously
from the bar the tick started in, and the metronome synthesised its own.
In an irregular signature a quantized trigger and a Patternist on the
same score therefore snapped to different points at a bar line.

Put the bar lines in one place. They come from two sources and both
matter: the ones the signature implies, and the one the interval reported
for the far end of the tick - a signature change puts a bar line where
the arithmetic alone would not, and the grid has to restart there. That
case was dropped entirely by the metronome and is what made a downbeat
land as a quarter click, or vanish.

get_quantification_dates walks those segments, get_quantification_date is
its first element rather than a second implementation, and the metronome
walks the same segments and only decides bar-click against quarter-click
on top. The two separate implementations it used to call are gone.

The lower bound is compared exactly rather than within an epsilon: a tick
starting one ulp after a grid point must not report that point, which is
what distinguishes 'quantize at the start' from 'already past it'.
m_processed_frames accumulates span lengths, and a span length is never
negative: a backwards tick still writes a forwards-running block of
samples. So the counter cannot follow a rewind, and it was being handed
to plug-ins as the transport position.

These are two quantities that were sharing one variable. Keep the counter
as a steady count of audio pushed through the node, which is what it is
good for, and add the playhead position beside it, mapped from the tick's
own dates through the same model to sample map the spans come from - so
it goes back down when the timeline does.
The audio callback knows one thing for certain: this buffer is N frames.
The tick chain then loses it.  Frames become flicks, flicks are scaled by
the transport speed and floored to a whole model duration, scenarios
subtract integer dates, children floor again, and at the end every node
divides the surviving model dates back by the speed to find which samples
of the buffer it must write.  Whenever speed * buffer length is not a
whole number of flicks (940800 * 1.234 = 1160947.2), the floors do not
invert: the reconstruction lands one sample short of the cut that was
actually taken and the last sample of the buffer is written by nobody -
an audible click per buffer with any effect that overwrites its output.

So stop reconstructing.  token_request now carries start_sample and
length_sample: the exact span of the audio buffer the token stands for,
decided by whoever cut the tick and passed down verbatim.  -1 means the
producer did not know the buffer (hand-made tokens, non-audio drivers);
physical_start, physical_write_duration and exec_state_facade::timings
prefer the carried span and fall back to the historical computation
otherwise, so nothing changes until producers fill the fields in.

The operations that subdivide a token subdivide its span with it, from a
single monotone rounding of the model dates so pieces tile by
construction: loop() cuts on the accumulated loop amount, and
set_start_time / set_end_time (used by score's looper to split a tick on
a quantification date) split at the same fraction, one taking the head
and the other the tail.

The struct is copied per node per tick on the audio thread; the new
fields sit in what used to be padding, so it stays a 104-byte trivially
copyable POD, now enforced by static_asserts.
… time

The producers now fill the sample span that token_request carries.  The
root audio tick stamps its token with the one fact it owns - the buffer
is N frames starting at 0 - and every split of a tick from there down is
now made in samples first, with the model dates along for the ride,
instead of the model dates being authoritative and the samples divided
back out of them.

The root tick methods stamp the whole buffer.  time_interval's tick
entry points take the window of samples their tick stands for and hand
it to their processes verbatim; when a tick runs into the t=0 clamp
while rewinding (or out of it going forward), the window shrinks in
proportion to the part of the model span that survived, which is what
makes a rewind across zero cover exactly the samples that map to t >= 0.

scenario::run_interval receives its dispatch's window.  When an interval
ends inside the tick, the cut is one rounding of the window against the
parent-frame time the interval consumes before its max; the interval
takes the samples before the cut, the overtick records the cut, and
whatever the sync starts takes the samples after it, so the pieces of a
chain of intervals partition the buffer by construction, at any speed.
Previously the interval and its successor each rebuilt their spans by
dividing flick-quantised dates by the speed, and at speeds like 1.234
the two floors disagreed by a sample: the tail of the buffer belonged to
nobody and kept whatever the previous callback left there - one click
per buffer.  The backward path gets the same treatment through the
backward overticks.

The tiling test now drives the root interval the way the audio callback
does, with the frame count on the root token, and sweeps buffer sizes
16/64/256/512 with more awkward speeds, forward and back, plus awkward
speeds on child intervals.  The final position check asserts the true
property - within one flick of the exact product - instead of a ceil()
that the floor-with-carried-residue arithmetic never promised; that
assertion had never been reached while the tiling before it was red.
to_physical_time_in_tick still reconstructed a position from the model
dates while the audio span is now carried on the token. The two can
differ by a sample at awkward speeds, which puts a note on one side of a
buffer boundary and the audio it belongs to on the other; the midi node
only avoided trouble because it clamps into the span afterwards.

Place the date proportionally within the carried span instead. The
endpoints then hold by construction rather than by agreement: the start
of the tick is the first sample of the span and its end is one past the
last, which is what the physical-mapping invariants assert.
A sound added to an already-playing interval seeks via
file_sample_for_model_time(), which scaled the model date by
live_tempo / file_tempo. That formula was derived against a hand-made
token stream whose model clock advanced at the physical rate; the real
engine's model clock advances at live_tempo / root_tempo per physical
sample, while the stretcher consumes live_tempo / file_tempo file samples
per physical sample. The file position of a playing sound is therefore
model_date * root_tempo / file_tempo - independent of the transport
tempo, the speed and even a tempo curve, since both rates scale with the
live tempo together. The old scaling was only right when the live tempo
happened to be 120 BPM: at 140 BPM a file dropped four seconds into
playback seeked 45663 samples (about one second) past the position an
identical running file had reached, and every mode missed every click in
the sync harness.

Use root_tempo when stretching, in the three sound nodes. The overload
without transport info now goes through the same mapping, so seeks and
in-tick discontinuity recovery agree with drop-in; it needs nothing but
the file's own tempo. In raw mode the file advances at its own rate and
the model date must instead be unscaled by the live tempo when it is
known; without transport info the date is kept as-is, as before.

Measured with the SoundSyncTest click-track harness: the seek error is
now at most 1 sample across source tempi 90-140, transport tempi 120 and
140, speeds 1.0 and 1.234 and buffer sizes 64 and 512, and dropped
repitch/raw streams land within half a sample of the running copies.
In real-time mode RubberBand asks the caller to pad its input with
getPreferredStartPad() zeros and then discard getStartDelay() output
samples; the priming code fed the pad but never discarded anything, so
every rubberband-stretched sound came out getStartDelay() samples late -
about 1024 samples (23 ms) at 44.1 kHz, and a ratio-dependent amount on
top: 1151 samples at 90 against 140 BPM, 961 at 140 against 120. Raw and
repitched sounds have no such delay, so a stretched file flammed audibly
against everything else, and two stretched files at different source
tempos sat tens of samples apart from each other.

Make the documented recipe (BareRecipe) the production default. On top
of it, the R2 engine reports its start delay as aWindowSize/2 scaled
only by the pitch, never by the time ratio, while the sample where input
zero actually surfaces moves with the ratio; measured with the
click-track harness over ratios 0.52 to 1.17, the true position is
startDelay + ~0.375 * pad * (1 - ratio) within R2's own transient
jitter, so trim that much more or less. R3 accounts for the ratio by
itself and gets no correction.

Median click error against the ideal grid drops from 995..1270 samples
to at most 22.5 across the sweep; what remains is R2's transient
placement jitter (worst click 98.7 samples at ratio 0.52, 17 at typical
ratios), not a constant lag, and the long-run drift stays under 1.4
samples per minute.

The SoundTest sweeps that restore the production strategy after
overriding it now restore BareRecipe accordingly.
SoundSyncTest drives sound_ref through the token stream the engine
really produces - tempo scaled by the transport speed, token speed of
tempo / root_tempo, the model date advanced by floor-with-residue like
time_interval::take_step, and the carried buffer span when the tree has
it (detected at compile time, so the file also builds on revisions that
predate the span fields).

The signal is a click track, one full-scale sample per beat of the
file's native tempo. Sync error is then impulse position error: every
click of every output is located and compared against the absolute
position it must occupy, so two files that agree with each other while
both drifting off the grid still fail, which a pairwise correlation
would not catch. The earlier harness in SoundTest.cpp advanced the model
date at the physical rate with unit token speed, which only matches the
engine at 120 BPM; conclusions drawn through it at other tempi were
measured against a token stream that is never emitted.

Three cases: a sweep over source tempi 90-140, transport tempi 120 and
140, speeds 1.0 and 1.234, buffers 64 and 512 and the three stretch
families, each scenario playing one file from zero and dropping an
identical one mid-playback the way add_time_process does; a one-minute
run for real drift, since a slope fitted through R2's transient jitter
on an eight-second window measures the jitter rather than the tracking;
and a direct check of the drop-in seek invariant, file position =
model_date * root_tempo / file_tempo when stretching. The sweep prints
the per-scenario error table and asserts: repitch and raw within a
sample, rubberband within its R2 noise floor, seeks within one sample.

OSSIA_SOUND_SYNC_PRIME=<n> overrides the rubberband priming strategy for
the sweep, which is how the strategies were compared in the first place.
…urves

The sweep stopped at the 0.52..1.17 ratio range the R2 start-delay
correction was fitted on, at 44.1 kHz, and at constant tempo. Extend it
three ways and record what holds and what does not:

- 48 kHz, R3/HQ in the fitted range, and repitch at any ratio: clean.
  (The harness previously fed 48 kHz nodes data labelled 44100; fixed.)
- The rubberband engines outside the fitted range misplace the stream:
  R2 at time ratio 3.33 plays ~1880 samples late because the empirical
  0.375*pad*(1-ratio) term goes far negative and clamps to zero; R3 at
  the same ratio is ~570 samples early; both crush transients at ratio
  0.3. Kept [!mayfail] with the ideal bounds stated.
- Through a 90->180 BPM step, the dropped-in copy still lands exactly on
  the grid the curve implies - the seek's model-date invariant holds -
  but the *running* copy exits the step permanently late (repitch +158,
  R2 ~+640): the stretchers cross a ratio change on buffered input and
  never repay it. Raw mode cannot drop in across a tempo change at all:
  the current tempo mistakes the whole history for the present, measured
  at 44032 samples with this curve. Both kept [!mayfail] as documented
  limitations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGta2LPAedDP3Txvyq8kTW
…osition

A quantification point's date is truncated to a whole flick, so a consumer
that floored it into a sample rounded twice and landed a sample early
wherever that truncation crossed a sample boundary. The metronome never did
that - it maps the musical position straight to a sample - so an ossia click
and an ossia quantized event on the same bar line could disagree by a sample,
and so could an ossia node and an avendish plug-in, halp mapping the position
directly the way the metronome does.

Give the point the musical position it was computed from, and add
physical_position() as the one map from a position to a sample in the tick's
span. The metronome now goes through it rather than its own copy, and
get_physical_quantification_date() through it rather than through the date.
get_quantification_point() exposes the singular form for consumers that need
the sample; get_quantification_date() stays for the ones scheduling in model
time, where the date is what they want.

to_physical_time_in_tick() keeps its meaning - an arbitrary model date mapped
into the tick - and its callers, which pass dates that were never rounded to
the grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGta2LPAedDP3Txvyq8kTW
sound_sync_sweep required every click to be detected in every mode. The
deterministic resamplers do produce every one - raw and repitch measure exactly
zero error on CI - but RubberBand smears transients by an amount that depends
on the library version and its build options, and CI's is not the build these
bounds were measured against: at 90 -> 140 BPM three of its clicks fall under
the detector's threshold, and the test failed for a property it was never
meant to be testing.

Allow the stretcher a few undetected clicks and keep asserting what the test
is actually for - that the clicks it does find sit on the grid, that a dropped
file lands on the same grid as a running one, and that its seek matches the
running copy's file position exactly. The deterministic modes stay strict at
zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGta2LPAedDP3Txvyq8kTW
<windows.h> defines _WIN32_WINNT itself, through <sdkddkver.h>, whenever it is
not already set, so leaving it alone does not mean "no minimum" - it means each
translation unit gets one depending on whether it reached <windows.h> at all.
Headers that branch on it configure themselves differently from one file to the
next: Asio reads it to decide BOOST_ASIO_HAS_STD_ATOMIC_WAIT, which changes the
wait primitive it uses. That is an ODR violation, and nothing reports it because
both spellings mangle the same.

The defaults also disagree between toolchains: the Windows Kits header picks
0x0A00 while mingw-w64 picks _WIN32_WINNT_WS03, so a mingw build has been
configuring itself for Server 2003 wherever this was left alone. 0x0A00 is
already what MSVC gets by default, so this changes nothing there; it makes the
translation units that never reach <windows.h> agree with the ones that do.

Set at the top level, before any add_subdirectory, rather than only on the
ossia target: dependencies we add as subdirectories compile Asio too -
libremidi builds its own translation units on MSVC - and they are siblings of
ossia rather than consumers of it, so a PUBLIC definition never reaches them.
It stays on the target as well, PUBLIC, for projects consuming an installed
ossia. Not scoped to a compiler: the divergence is not a property of one.

WINVER and NTDDI_VERSION are deliberately left alone: sdkddkver.h derives
WINVER from _WIN32_WINNT and NTDDI_VERSION from the SDK, in both toolchains, so
setting them by hand only creates a way for them to disagree.

Reduced to two translation units against Boost 1.91 and VS2026 - a.cpp
including <windows.h> before Asio, b.cpp not - the namespaces they compute for
Asio differ, v103801_bdelmo against v103801_bdelm. With the pin they agree.

BOOST_ASIO_ENABLE_VERSION_NAMESPACE, which would also have settled the
duplicate asio_signal_handler score hits through its LSL addon, is deliberately
not enabled; ossia_setup.cmake records why. That collision is left as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGta2LPAedDP3Txvyq8kTW
jcelerier and others added 2 commits August 5, 2026 18:09
Brings in the Asio version-namespace fix: backends/net/config.hpp forward-
declares boost::asio::io_context, and since Boost 1.91 that has to name the
inline version namespace Asio itself uses, or every use of the type is
ambiguous between the declaration and the definition. Also picks up the guards
on NOMINMAX and WIN32_LEAN_AND_MEAN in the Windows backends, which redefined
them against anything that had already set them - Asio's config among others.

Needed by the commit that follows, which cannot enable the version namespace
without it.
Since 1.91, Asio names its global symbols through BOOST_ASIO_VERSIONED_NAME,
which with no version namespace expands to the bare asio_ prefix - byte for
byte the names standalone Asio uses. Before 1.91 they carried a boost_asio_
prefix and could not collide.

Where Asio is compiled separately we emit strong definitions of them, so
anything else in the link carrying its own standalone Asio collides with us.
score's LSL addon bundles Asio and compiles asio/impl/src.hpp, and the two meet
as

  lld-link : error : duplicate symbol: asio_signal_handler
    >>> defined at ossia_x64.lib(unity_0_cxx.obj)
    >>> defined at lsl.lib(asio_objects.obj)

which is what stopped score's MSVC job linking as soon as our Boost floor moved
to 1.91.

Enabling the version namespace renames ours - asio_v103801_bdemo_signal_handler
- and leaves standalone Asio's alone. It holds together only with the
_WIN32_WINNT pin above: the namespace tag encodes the Asio configuration, so
without a fixed configuration it varies per translation unit and the duplicate
symbol merely becomes an undefined one. It also requires that nothing declare
an Asio type outside that namespace, which is what the libremidi update in the
previous commit settles.

Set both at the top level, so that the dependencies we build as subdirectories
are configured the same way, and PUBLIC on the ossia target, because this is
part of our ABI rather than only of how we are built. The namespace is an
inline namespace, so it is baked into the mangled name of every Asio type our
headers expose: resolve.cpp explicitly instantiates resolve_sync_v4 for
boost::asio::ip::udp and ::tcp, and a consumer that disagrees about the
namespace names a different specialisation and does not link.

Verified against Boost 1.91: libossia builds clean with VS2026, ossia_x64.lib
exports asio_v103801_bdemo_signal_handler and no longer contains the bare
asio_signal_handler at all; and score builds and links clean against it,
2254/2254, with no undefined references.

Standalone Asio has no equivalent knob, still hardcoding the bare names as of
1.36, so the versioning has to come from our side. A no-op before 1.91.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGta2LPAedDP3Txvyq8kTW
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