Timing: backwards playback, buffer-span tiling, and one quantification grid - #917
Merged
Conversation
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Failure
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Failure
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Error
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 03:11 — with
GitHub Actions
Inactive
jcelerier
force-pushed
the
fix/backwards-playback-dataflow
branch
from
July 30, 2026 12:34
3f30a70 to
4b6bea7
Compare
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Error
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:34 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:35 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:35 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:35 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
July 30, 2026 12:35 — with
GitHub Actions
Error
jcelerier
force-pushed
the
fix/backwards-playback-dataflow
branch
from
July 30, 2026 12:57
4b6bea7 to
d36373e
Compare
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:57 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:57 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:57 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
July 30, 2026 12:57 — with
GitHub Actions
Inactive
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
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
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.
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_sampleon the token and consumers read them, so spans are differences of one monotonemodel -> samplemap 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_barpositions the host reports, which is what keeps a 7/8 bar from carrying a stale phase — andget_quantification_date()is literally the first element ofget_quantification_dates()rather than a second implementation of it.Two defects found by adversarial review and fixed here:
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 onephysical_position().Sound sync
Two long-standing defects, both pre-existing (a harness cherry-picked onto
masterreproduces them identically):file_sample_for_model_timescaled bylive_tempo / file_tempo, correct only at 120 BPM — a drop-in was up to 3.4 s off;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.SoundSyncTestmeasures click-track grid error directly.BackwardPlaybackTestandTokenRequestTestcover the rest.Baseline is unchanged:
ScenarioAlgoTest398 / 1206 / 1327 fail identically onorigin/masterand predate all of this.Known limitations, measured not hidden
[!mayfail].