From 9f030c02bb970fcec50b959e2816eae60bc56879 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 16 Oct 2026 08:00:31 -0400 Subject: [PATCH 1/2] Keep a segment's time and position math from overflowing in a run of months offset_us() and time_position() formed a sample count times 10^6 and a microsecond count times the rate in int64, which overflowed once one segment had run about 278 days at 384 kHz or 6 years at 48 kHz. Both now split the count at a multiple of the divisor (scaled_floor() and scaled_ceil() in sched/timeline.h), so only the remainder's product is formed. A resampled instance's start position and a drain's output count in GroupAudio had the same kind of product and use them too. --- src/group_audio.cpp | 9 ++-- src/sched/timeline.cpp | 4 +- src/sched/timeline.h | 18 ++++++++ tests/test_sched_timeline.cpp | 78 +++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/group_audio.cpp b/src/group_audio.cpp index 41337c3..ccf0859 100644 --- a/src/group_audio.cpp +++ b/src/group_audio.cpp @@ -655,7 +655,7 @@ bool GroupAudio::begin(Instance& instance, const JobView& view) { const auto step_in = static_cast(in.rate / common); const auto step_out = static_cast(out_rate / common); const auto base = static_cast(in.position); - const int64_t at = base + sched::floor_div((first - origin) * in.rate, out_rate); + const int64_t at = base + sched::scaled_floor(first - origin, in.rate, out_rate); // History of the same input: the staged segment holding the start, while staged int64_t lowest = static_cast(view.retain_from); for (const StagedSegment& staged : view.segments) { @@ -851,9 +851,10 @@ bool GroupAudio::finish_drain(Instance& instance, uint64_t drain_end) { } // The output sample count from the exact drain position, round((P_end - p) * r_out / r_in) past // a position p the stretch maps exactly, so rounding never accumulates - const uint64_t span = drain_end - instance.origin_position; - const auto total = instance.origin_output + - static_cast(((span * 2 * out_rate) + in_rate) / (2 * in_rate)); + const auto span = static_cast(drain_end - instance.origin_position); + const int64_t total = + instance.origin_output + + sched::scaled_round(span, static_cast(out_rate), static_cast(in_rate)); if (instance.produced > total) { this->cut_to(instance, total); } diff --git a/src/sched/timeline.cpp b/src/sched/timeline.cpp index 0bf6df8..89907d1 100644 --- a/src/sched/timeline.cpp +++ b/src/sched/timeline.cpp @@ -35,7 +35,7 @@ constexpr int64_t PS_PER_SECOND = 1000000000000; /// `floor((p - p_j) * 10^6 / r_j)` for the given segment int64_t offset_us(const Segment& segment, uint64_t position) { const int64_t samples = static_cast(position - segment.position); - return floor_div(samples * US_PER_SECOND, segment.rate); + return scaled_floor(samples, US_PER_SECOND, segment.rate); } } // namespace @@ -130,7 +130,7 @@ uint64_t time_position(const Anchor& anchor, int64_t unshifted_us) { // first segment extrapolates it const int64_t position = static_cast(segment.position) + - ceil_div((unshifted_us - segment.unshifted_us) * int64_t{segment.rate}, US_PER_SECOND); + scaled_ceil(unshifted_us - segment.unshifted_us, segment.rate, US_PER_SECOND); return static_cast(std::max(position, 0)); } diff --git a/src/sched/timeline.h b/src/sched/timeline.h index aebe0e6..9d0562d 100644 --- a/src/sched/timeline.h +++ b/src/sched/timeline.h @@ -37,6 +37,24 @@ constexpr int64_t ceil_div(int64_t n, int64_t d) { return -floor_div(-n, d); } +/// @brief `floor(n * mul / div)` for mul and div positive with `2 * div * mul` within int64: n is +/// split at a multiple of div, so `n * mul` is never formed and only a result past int64 overflows +constexpr int64_t scaled_floor(int64_t n, int64_t mul, int64_t div) { + const int64_t whole = floor_div(n, div); + return (whole * mul) + (((n - (whole * div)) * mul) / div); +} + +/// @brief `ceil(n * mul / div)`, split as scaled_floor() splits it +constexpr int64_t scaled_ceil(int64_t n, int64_t mul, int64_t div) { + return -scaled_floor(-n, mul, div); +} + +/// @brief `n * mul / div` rounded half up, split as scaled_floor() splits it +constexpr int64_t scaled_round(int64_t n, int64_t mul, int64_t div) { + const int64_t whole = floor_div(n, div); + return (whole * mul) + ((2 * (n - (whole * div)) * mul + div) / (2 * div)); +} + /// @brief The smallest multiple of `multiple` (positive) at or above n constexpr int64_t round_up(int64_t n, int64_t multiple) { return ceil_div(n, multiple) * multiple; diff --git a/tests/test_sched_timeline.cpp b/tests/test_sched_timeline.cpp index 35c5845..36520e5 100644 --- a/tests/test_sched_timeline.cpp +++ b/tests/test_sched_timeline.cpp @@ -290,6 +290,84 @@ TEST(SchedTimeline, SegmentOutputIndexMonthsIntoARun) { } } +// The products split at a multiple of the divisor round as the whole product would, past the 2^63 +// it reaches +TEST(SchedTimeline, ScaledDivisionRounding) { + struct Row { + const char* name; + int64_t n; + int64_t mul; + int64_t div; + int64_t floor; + int64_t ceil; + int64_t round; + }; + const Row rows[] = { + {"Control: a half", 7, 3, 2, 10, 11, 11}, + {"Control: exact", 6, 3, 2, 9, 9, 9}, + {"a negative half rounds up", -7, 3, 2, -11, -10, -10}, + // 10^6 / 384,000 is 2.604 + {"negative, below the half", -1, 1000000, 384000, -3, -2, -3}, + {"300 days of 384 kHz samples and one, as microseconds", 9953280000001, 1000000, 384000, + 25920000000002, 25920000000003, 25920000000003}, + // 0.384 of a sample past + {"300 days and a microsecond, as 384 kHz samples", 25920000000001, 384000, 1000000, + 9953280000000, 9953280000001, 9953280000000}, + {"four years of 192 kHz samples and one, at 384 kHz", 24219648000001, 384000, 192000, + 48439296000002, 48439296000002, 48439296000002}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + EXPECT_EQ(sched::scaled_floor(row.n, row.mul, row.div), row.floor); + EXPECT_EQ(sched::scaled_ceil(row.n, row.mul, row.div), row.ceil); + EXPECT_EQ(sched::scaled_round(row.n, row.mul, row.div), row.round); + } +} + +// One segment months or years long maps positions, times and cells as a short one does, where a +// sample count times 10^6, or a microsecond count times the rate, passes 2^63 +TEST(SchedTimeline, TimeAndPositionYearsIntoOneSegment) { + constexpr int64_t DAY_S = 86400; + constexpr int64_t YEAR_S = 365 * DAY_S; + struct Row { + const char* name; + uint32_t rate; + uint64_t p0; + uint64_t position; + int64_t unshifted_us; + int64_t cell; + uint64_t cell_start; + }; + const Row rows[] = { + {"Control: a second at 384 kHz", 384000, 0, 384000, 1000000, 50, 384000}, + // 10^6 / 384,000 is 2.604 us + {"Control: one sample past it", 384000, 0, 384001, 1000002, 50, 384000}, + {"Control: before p0, extrapolated back", 384000, 1000, 999, -3, -1, 0}, + {"300 days at 384 kHz", 384000, 0, 300 * DAY_S * 384000, 300 * DAY_S * sched::US_PER_SECOND, + 300 * DAY_S * sched::CELLS_PER_SECOND, 300 * DAY_S * 384000}, + {"one sample past it", 384000, 0, (300 * DAY_S * 384000) + 1, + (300 * DAY_S * sched::US_PER_SECOND) + 2, 300 * DAY_S * sched::CELLS_PER_SECOND, + 300 * DAY_S * 384000}, + // 10^6 / 48,000 is 20.83 us + {"seven years at 48 kHz, one sample past", 48000, 0, (7 * YEAR_S * 48000) + 1, + (7 * YEAR_S * sched::US_PER_SECOND) + 20, 7 * YEAR_S * sched::CELLS_PER_SECOND, + 7 * YEAR_S * 48000}, + // 10^6 / 44,100 is 22.68 us + {"seven years at 44.1 kHz, one sample past", 44100, 0, (7 * YEAR_S * 44100) + 1, + (7 * YEAR_S * sched::US_PER_SECOND) + 22, 7 * YEAR_S * sched::CELLS_PER_SECOND, + 7 * YEAR_S * 44100}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + const Anchor anchor = sched::make_anchor(0, row.p0, row.rate, 2); + EXPECT_EQ(sched::unshifted_time(anchor, row.position), row.unshifted_us); + // Each sample's time is its own at these rates, so the time maps back to the sample + EXPECT_EQ(sched::time_position(anchor, row.unshifted_us), row.position); + EXPECT_EQ(sched::cell_at(anchor, row.unshifted_us), row.cell); + EXPECT_EQ(sched::cell_position(anchor, row.cell), row.cell_start); + } +} + // An instance starts on its own frame boundary, and past a shift whose index its frame length does // not divide, so none of its frames straddles one TEST(SchedTimeline, InstanceStartPastAStraddledShift) { From c77de33a8731bf94eee427986b36196a12d6f384 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 16 Oct 2026 08:15:48 -0400 Subject: [PATCH 2/2] State the scaled division helpers' precondition exactly The bound covers scaled_round()'s numerator, and the doc no longer claims that only a result past int64 can overflow. --- src/sched/timeline.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sched/timeline.h b/src/sched/timeline.h index 9d0562d..3fc0224 100644 --- a/src/sched/timeline.h +++ b/src/sched/timeline.h @@ -37,8 +37,8 @@ constexpr int64_t ceil_div(int64_t n, int64_t d) { return -floor_div(-n, d); } -/// @brief `floor(n * mul / div)` for mul and div positive with `2 * div * mul` within int64: n is -/// split at a multiple of div, so `n * mul` is never formed and only a result past int64 overflows +/// @brief `floor(n * mul / div)` for mul and div positive with `(2 * mul + 1) * div` within int64: +/// n is split at a multiple of div, so `n * mul` is never formed constexpr int64_t scaled_floor(int64_t n, int64_t mul, int64_t div) { const int64_t whole = floor_div(n, div); return (whole * mul) + (((n - (whole * div)) * mul) / div);