Skip to content

Commit 237fe7a

Browse files
authored
Merge pull request #982 from shorepine/fix-49-day-clock-wrap
Fix the 49.7-day millisecond-clock rollover
2 parents 372bd4c + be33031 commit 237fe7a

8 files changed

Lines changed: 242 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ amy-example
33
amy-message
44
failed_tests.txt
55
tests/tst
6+
tests/test_clock_wrap
67
amy-piano
78
src/build/
89
*.DS_Store*

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \
4545
-s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000
4646
PYTHON = python3
4747

48-
.PHONY: default all clean amy-module test web deploy-web godot-api c-api check-c-api
48+
.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api
4949

5050
default: $(TARGET)
5151
all: default
@@ -102,6 +102,21 @@ amy-piano: $(OBJECTS) src/amy-piano.o
102102
amy-message: $(OBJECTS) src/amy-message.o
103103
$(CC) $(CFLAGS) $(OBJECTS) src/amy-message.o -Wall $(LIBS) -o $@
104104

105+
# Plain C tests for things the audio-rendering suite can't reach -- e.g. clock
106+
# rollovers 50 days out, which you can only hit by fast-forwarding the counters.
107+
CTESTS = tests/test_clock_wrap
108+
109+
# Static pattern rules, so these win over the generic %.o: %.c above (which
110+
# would compile without -Isrc and fail to find amy.h).
111+
$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h
112+
$(CC) $(CFLAGS) -Isrc -c $< -o $@
113+
114+
$(CTESTS): %: %.o $(OBJECTS)
115+
$(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@
116+
117+
ctest: $(CTESTS)
118+
@for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done
119+
105120
amy-module: amy-example
106121
${EXTRA_PIP_ENV} ${PYTHON} -m pip install -r requirements.txt; touch src/amy.c; ${EXTRA_PIP_ENV} ${PYTHON} -m pip install . --force-reinstall --no-deps; cd ..
107122

@@ -172,3 +187,4 @@ clean:
172187
-rm -r src/patches.h
173188
-rm -f amy/constants.py
174189
-rm -f $(TARGET)
190+
-rm -f tests/*.o $(CTESTS)

src/amy.c

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -588,9 +588,12 @@ void add_delta_to_queue(struct delta *d, struct delta **queue) {
588588
return;
589589
}
590590

591-
// insert it into the sorted list for fast playback
591+
// insert it into the sorted list for fast playback.
592+
// Wrap-relative: at the 49.7-day rollover a note_off scheduled a few ms out
593+
// has a tiny d->time while its own note_on is still near 2^32, so a plain
594+
// `>=` sorted the release ahead of the attack and the note droned forever.
592595
struct delta **pptr = queue;
593-
while(*pptr && d->time >= (*pptr)->time)
596+
while(*pptr && AMY_TIME_GEQ(d->time, (*pptr)->time))
594597
pptr = &(*pptr)->next;
595598
new_d->next = *pptr;
596599
*pptr = new_d;
@@ -2009,7 +2012,7 @@ void amy_execute_delta() {
20092012

20102013
// find any deltas that need to be played from the (in-order) queue
20112014
struct delta *d = amy_global.delta_queue;
2012-
if(d && sysclock >= d->time) {
2015+
if(d && AMY_TIME_GEQ(sysclock, d->time)) {
20132016
play_delta(d);
20142017
d = delta_release(d);
20152018
amy_global.delta_qsize--;
@@ -2037,7 +2040,7 @@ void amy_execute_deltas() {
20372040

20382041
// find any deltas that need to be played from the (in-order) queue
20392042
struct delta *d = amy_global.delta_queue;
2040-
while(d && sysclock >= d->time) {
2043+
while(d && AMY_TIME_GEQ(sysclock, d->time)) {
20412044
play_delta(d);
20422045
d = delta_release(d);
20432046
amy_global.delta_qsize--;

src/amy.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,15 @@ amy_config_t amy_default_config();
998998
void amy_clear_event(amy_event *e);
999999
amy_event amy_default_event();
10001000
uint32_t amy_sysclock();
1001+
uint64_t amy_sysclock64();
1002+
1003+
// Wrap-relative comparison for the 32-bit millisecond clock. amy_sysclock()
1004+
// rolls over every 2^32 ms (49.7 days), so a plain `now >= then` strands every
1005+
// queued event at the rollover -- and, because the delta queue is time-sorted,
1006+
// misorders a note_off ahead of its own note_on, leaving the note stuck on.
1007+
// Valid as long as the two times are within 2^31 ms (~24.8 days) of each other,
1008+
// which holds for everything AMY schedules.
1009+
#define AMY_TIME_GEQ(a, b) ((int32_t)((uint32_t)(a) - (uint32_t)(b)) >= 0)
10011010
// CPU overload detection: platform render loops call this once per block.
10021011
void amy_overload_check(uint32_t render_us);
10031012
void amy_overload_failsafe();

src/api.c

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,23 @@ output_sample_type * amy_simple_fill_buffer() {
233233

234234

235235
// on all platforms, sysclock is based on total samples played, using audio out (i2s or etc) as system clock
236-
uint32_t amy_sysclock() {
237-
// Time is returned in integer milliseconds; wraps at 2^32 ms = 49.7 days.
236+
// 64-bit milliseconds since start. total_blocks is u32 and increments once per
237+
// AMY_BLOCK_SIZE samples, so this does not wrap for ~219 years at 44.1 kHz.
238+
// Anything that stores an absolute deadline and compares it later must use this
239+
// rather than amy_sysclock(); see sequencer_check_and_fill().
240+
uint64_t amy_sysclock64() {
238241
// Integer math: computing this through float quantizes the clock once
239242
// total samples exceed the 24-bit mantissa (~6 min at 48 kHz), and the
240243
// u32 samples-domain multiply wrapped after 2^32 samples (~25 h).
241-
return (uint32_t)(((uint64_t)amy_global.total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE);
244+
return ((uint64_t)amy_global.total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE;
245+
}
246+
247+
uint32_t amy_sysclock() {
248+
// Time is returned in integer milliseconds; wraps at 2^32 ms = 49.7 days.
249+
// This is the wire/event-facing clock and stays 32-bit for compatibility.
250+
// Consumers compare event times wrap-relative (AMY_TIME_GEQ), so the
251+
// rollover is handled rather than avoided.
252+
return (uint32_t)amy_sysclock64();
242253
}
243254

244255

@@ -290,6 +301,10 @@ void amy_add_event(amy_event *e) {
290301
uint32_t playback_time = amy_sysclock();
291302
if(AMY_IS_SET(e->time)) playback_time = e->time;
292303
playback_time += amy_global.latency_ms;
304+
// UINT32_MAX is the "unset" sentinel for a u32 field, and the clock does
305+
// land on it for one millisecond every 49.7 days. Nudge by 1ms so the
306+
// event doesn't read back as having no time at all.
307+
if(AMY_IS_UNSET(playback_time)) playback_time++;
293308
e->time = playback_time;
294309
amy_event_to_deltas_queue(e, 0, &amy_global.delta_queue);
295310
}

src/patches.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,8 @@ uint8_t patches_voices_for_event(amy_event *e, uint16_t voices[]) {
974974
uint32_t playback_time = amy_sysclock();
975975
if(AMY_IS_SET(e->time)) playback_time = e->time;
976976
playback_time += instrument_noteon_delay_ms(e->synth);
977+
// See amy_process_event(): dodge the u32 "unset" sentinel.
978+
if(AMY_IS_UNSET(playback_time)) playback_time++;
977979
e->time = playback_time;
978980
//fprintf(stderr, "synth %d note %d delay %d time %d\n", e->synth, (int)roundf(e->midi_note), instrument_noteon_delay_ms(e->synth), e->time);
979981
}

src/sequencer.c

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ void sequencer_recompute() {
7171
// 60000000 us/min / (bpm * ticks per beat); keep it single-precision -
7272
// unsuffixed double literals pull in software double emulation on 32-bit.
7373
amy_global.us_per_tick = (uint32_t) (60000000.0f / (amy_global.tempo * (float)AMY_SEQUENCER_PPQ));
74-
amy_global.next_amy_tick_us = (((uint64_t)amy_sysclock()) * 1000L) + (uint64_t)amy_global.us_per_tick;
74+
amy_global.next_amy_tick_us = (amy_sysclock64() * 1000ULL) + (uint64_t)amy_global.us_per_tick;
7575
}
7676

7777
static void sequencer_process_tick(void) {
@@ -145,7 +145,7 @@ void sequencer_midi_start() {
145145
}
146146
// Reset the tick timer to now so sequencer_check_and_fill doesn't try to
147147
// catch up all the ticks that elapsed while stopped.
148-
amy_global.next_amy_tick_us = (uint64_t)amy_sysclock() * 1000L;
148+
amy_global.next_amy_tick_us = amy_sysclock64() * 1000ULL;
149149
sequencer_running = true;
150150
midi_clock_out_start(); // tell downstream slaves, if we're the clock master
151151
}
@@ -173,7 +173,7 @@ void sequencer_external_clock_disable() {
173173
sequencer_running = true;
174174
// Re-anchor the tick timer to now so sequencer_check_and_fill doesn't try to
175175
// replay every tick that elapsed while we were on external clock.
176-
amy_global.next_amy_tick_us = (uint64_t)amy_sysclock() * 1000L;
176+
amy_global.next_amy_tick_us = amy_sysclock64() * 1000ULL;
177177
}
178178

179179
uint8_t sequencer_add_event(amy_event *e) {
@@ -220,7 +220,12 @@ void sequencer_check_and_fill() {
220220
// If we've fallen behind by more than 1 second (e.g. sequencer was stopped
221221
// and restarted, or a long blocking operation occurred), skip ahead instead
222222
// of processing hundreds of backed-up ticks at once.
223-
uint64_t now_us = (uint64_t)amy_sysclock() * 1000L;
223+
// next_amy_tick_us is a 64-bit accumulator, so it must be anchored to the
224+
// 64-bit clock. Seeding it from the 32-bit amy_sysclock() used to kill the
225+
// sequencer permanently at the 49.7-day rollover: now_us collapsed to ~0
226+
// while next_amy_tick_us stayed at ~4.3e12, and neither the catch-up guard
227+
// (which only handles falling behind) nor the tick loop could ever fire.
228+
uint64_t now_us = amy_sysclock64() * 1000ULL;
224229
if (now_us > amy_global.next_amy_tick_us + 1000000ULL) {
225230
amy_global.next_amy_tick_us = now_us;
226231
}

tests/test_clock_wrap.c

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Regression test for the 49.7-day millisecond-clock rollover.
2+
//
3+
// amy_sysclock() is u32 milliseconds, so it wraps every 2^32 ms = 49.71 days.
4+
// Two things used to break at that instant, and neither is reachable from the
5+
// audio-rendering test suite because you cannot render 50 days of samples --
6+
// so this test fast-forwards amy_global.total_blocks instead.
7+
//
8+
// 1. sequencer_check_and_fill() anchored its 64-bit next_amy_tick_us
9+
// accumulator to the 32-bit clock. At the rollover now_us collapsed to ~0
10+
// while next_amy_tick_us stayed at ~4.3e12 us, so the catch-up guard
11+
// (which only handles falling *behind*) and the tick loop both went quiet
12+
// and the sequencer stopped forever -- taking tulip.defer() and every
13+
// seq callback with it.
14+
//
15+
// 2. The delta queue is time-sorted with a plain `>=`. A note_off scheduled
16+
// a few ms past the rollover has a tiny d->time while its own note_on is
17+
// still near 2^32, so the release sorted ahead of the attack, fired
18+
// first, and left the note droning forever.
19+
//
20+
// Build/run with `make ctest`.
21+
22+
#include <stdio.h>
23+
#include <stdint.h>
24+
#include <inttypes.h>
25+
#include "amy.h"
26+
#include "sequencer.h"
27+
28+
static int failures = 0;
29+
30+
#define CHECK(cond, fmt, ...) do { \
31+
if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \
32+
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \
33+
} while (0)
34+
35+
// Blocks per second of synthesized audio.
36+
static const uint64_t BPS = AMY_SAMPLE_RATE / AMY_BLOCK_SIZE;
37+
38+
static uint32_t ticks_seen = 0;
39+
static void count_tick(uint32_t t) { (void)t; ticks_seen++; }
40+
41+
// Park the synth clock at an arbitrary uptime without rendering our way there.
42+
static void set_clock_ms(uint64_t ms) {
43+
amy_global.total_blocks = (uint32_t)((ms * AMY_SAMPLE_RATE) / (1000ULL * AMY_BLOCK_SIZE));
44+
amy_global.total_samples = amy_global.total_blocks * AMY_BLOCK_SIZE;
45+
}
46+
47+
// Run the real render loop for `secs` of audio. amy_simple_fill_buffer() does
48+
// execute_deltas -> render -> fill, and the fill is what advances total_blocks,
49+
// so this drives the clock exactly the way the audio callback does. Rendering
50+
// (not delta execution) is also what retires a finished note to SYNTH_OFF.
51+
static uint32_t advance_secs(double secs) {
52+
ticks_seen = 0;
53+
uint64_t n = (uint64_t)(BPS * secs);
54+
for (uint64_t i = 0; i < n; i++) amy_simple_fill_buffer();
55+
return ticks_seen;
56+
}
57+
58+
static int osc_audible(int osc) {
59+
return synth[osc] != NULL && synth[osc]->status == SYNTH_AUDIBLE;
60+
}
61+
62+
static void note_on(int osc, float midi_note, uint32_t time) {
63+
amy_event e = amy_default_event();
64+
e.time = time; e.osc = osc; e.wave = SINE; e.velocity = 1.0f; e.midi_note = midi_note;
65+
amy_add_event(&e);
66+
}
67+
68+
static void note_off(int osc, uint32_t time) {
69+
amy_event e = amy_default_event();
70+
e.time = time; e.osc = osc; e.velocity = 0;
71+
amy_add_event(&e);
72+
}
73+
74+
// 2^32 ms -- the instant amy_sysclock() rolls over.
75+
#define WRAP_MS 4294967296ULL
76+
77+
static void test_sequencer_survives_wrap(void) {
78+
printf("sequencer keeps ticking across the 49.7-day rollover\n");
79+
amy_global.config.amy_external_sequencer_hook = count_tick;
80+
81+
double expected_per_10s = 10.0 * 1000000.0 / amy_global.us_per_tick;
82+
83+
set_clock_ms(WRAP_MS - 30000); // 30 s out
84+
sequencer_recompute();
85+
uint32_t before = advance_secs(10);
86+
CHECK(before > expected_per_10s * 0.9,
87+
"before wrap: %u ticks/10s (expected ~%.0f)", before, expected_per_10s);
88+
89+
advance_secs(25); // cross it
90+
CHECK(amy_sysclock() < 30000, "sysclock rolled over: now %u", amy_sysclock());
91+
CHECK(amy_sysclock64() > WRAP_MS, "64-bit clock kept counting: %" PRIu64, amy_sysclock64());
92+
93+
uint32_t after = advance_secs(10);
94+
CHECK(after > expected_per_10s * 0.9,
95+
"after wrap: %u ticks/10s (expected ~%.0f)", after, expected_per_10s);
96+
97+
uint32_t much_later = advance_secs(60);
98+
CHECK(much_later > expected_per_10s * 5.9,
99+
"wrap+1min: %u ticks/60s (expected ~%.0f)", much_later, expected_per_10s * 6);
100+
101+
amy_global.config.amy_external_sequencer_hook = NULL;
102+
}
103+
104+
static void test_no_stuck_note_across_wrap(void) {
105+
printf("a note whose note_off lands past the rollover still stops\n");
106+
107+
set_clock_ms(WRAP_MS - 500);
108+
uint32_t t = amy_sysclock();
109+
note_on(0, 64, t);
110+
note_off(0, t + 1000); // this time value wraps past 2^32
111+
CHECK(t + 1000 < t, "note_off time wrapped as intended (on=%u off=%u)", t, t + 1000);
112+
113+
advance_secs(0.2);
114+
CHECK(osc_audible(0), "note is sounding before the wrap");
115+
116+
advance_secs(2.0); // well past both the wrap and the note_off
117+
CHECK(!osc_audible(0), "note stopped after the wrap (not stuck on)");
118+
}
119+
120+
static void test_scheduling_works_after_wrap(void) {
121+
printf("scheduling still works once the clock has rolled over\n");
122+
123+
set_clock_ms(WRAP_MS + 5000);
124+
note_on(1, 60, amy_sysclock());
125+
advance_secs(0.2);
126+
CHECK(osc_audible(1), "immediate note_on sounds");
127+
128+
note_off(1, amy_sysclock());
129+
advance_secs(0.5);
130+
CHECK(!osc_audible(1), "immediate note_off stops it");
131+
132+
note_on(2, 67, amy_sysclock() + 500);
133+
advance_secs(0.2);
134+
CHECK(!osc_audible(2), "note scheduled +500ms has not fired yet");
135+
advance_secs(0.6);
136+
CHECK(osc_audible(2), "note scheduled +500ms fired on time");
137+
}
138+
139+
static void test_sustained_note_across_sample_wrap(void) {
140+
// amy_global.total_samples is u32 and wraps every 2^32 samples (27.05 h at
141+
// 44.1 kHz). Envelope math is `total_samples - note_on_clock` in u32, which
142+
// is modular-correct, so this should be a non-event -- pin that down.
143+
printf("held note is unaffected by the 27-hour total_samples wrap\n");
144+
145+
uint32_t wrap_blocks = (uint32_t)((1ULL << 32) / AMY_BLOCK_SIZE);
146+
amy_global.total_blocks = wrap_blocks - (uint32_t)(BPS * 2);
147+
amy_global.total_samples = amy_global.total_blocks * AMY_BLOCK_SIZE;
148+
149+
note_on(3, 69, amy_sysclock());
150+
advance_secs(1.0);
151+
CHECK(osc_audible(3), "sounding before the sample-counter wrap");
152+
advance_secs(3.0);
153+
CHECK(amy_global.total_samples < BPS * AMY_BLOCK_SIZE * 4, "total_samples wrapped");
154+
CHECK(osc_audible(3), "still sounding after the sample-counter wrap");
155+
note_off(3, amy_sysclock());
156+
advance_secs(1.0);
157+
}
158+
159+
// examples.c calls this; the example binaries each define their own. This test
160+
// drives the clock by hand and never sleeps, so nothing here should reach it.
161+
void delay_ms(uint32_t ms) { (void)ms; }
162+
163+
int main(void) {
164+
amy_config_t c = amy_default_config();
165+
c.features.startup_bleep = 0;
166+
amy_start(c);
167+
168+
test_sequencer_survives_wrap();
169+
test_no_stuck_note_across_wrap();
170+
test_scheduling_works_after_wrap();
171+
test_sustained_note_across_sample_wrap();
172+
173+
if (failures) {
174+
printf("\n%d check(s) FAILED\n", failures);
175+
return 1;
176+
}
177+
printf("\nall clock-wrap checks passed\n");
178+
return 0;
179+
}

0 commit comments

Comments
 (0)