diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..2e24fd93 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,158 @@ +name: Android AMY + +on: + push: + branches: + - integration/amy_android + pull_request: + paths: + - "android/**" + - "src/**" + - "tests/test_amy_unix_socket.c" + - "tests/run_amy_unix_socket_test.sh" + - "tests/check_android_audio_capture.py" + - "tests/test_android_service_contract.py" + - ".github/workflows/android.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + socket-transport: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Verify Android service contract + run: python3 tests/test_android_service_contract.py + - name: Build and run private Unix socket test + run: bash tests/run_amy_unix_socket_test.sh + + android-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Verify Android service contract + run: python3 tests/test_android_service_contract.py + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: android-actions/setup-android@v3 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "ndk;27.2.12479018" \ + "cmake;3.22.1" + + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.13" + + - name: Build AMY Android AAR and hello-world APK + working-directory: android + run: gradle :amy-service:assembleDebug :hello-world:assembleDebug --stacktrace + + - name: Verify transport-only hello-world packaging + run: | + APK=android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + test -s "$APK" + ! unzip -l "$APK" | grep -q 'libamy_hello_client.so' + test ! -e android/hello-world/src/main/cpp + + - name: Upload AMY Android AAR + uses: actions/upload-artifact@v4 + with: + name: amy-service-debug-aar + path: android/amy-service/build/outputs/aar/amy-service-debug.aar + if-no-files-found: error + + - name: Upload AMY hello-world APK + uses: actions/upload-artifact@v4 + with: + name: amy-hello-world-debug-apk + path: android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + if-no-files-found: error + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Emulator end-to-end smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_2 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-boot-anim + script: | + adb uninstall org.amy.hello >/dev/null 2>&1 || true + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-first.log + grep -q 'connected to amy.sock' /tmp/amy-first.log + test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-first.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-first.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-first.log + grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + grep -q 'Audio capture complete:' /tmp/amy-first.log + + adb uninstall org.amy.hello + adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk + adb shell run-as org.amy.hello mkdir -p files + adb shell run-as org.amy.hello touch files/amy-audio-capture.enable + adb logcat -c + adb shell am start -W -n org.amy.hello/.MainActivity + sleep 10 + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 + grep -q 'AMY output route: deviceId=' /tmp/amy-second.log + grep -q 'connected to amy.sock' /tmp/amy-second.log + test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 + ! grep -q 'C scale failed' /tmp/amy-second.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-second.log + test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 + grep -q 'wire: v0n60l1Z' /tmp/amy-second.log + grep -q 'wire: v0n72l1Z' /tmp/amy-second.log + grep -q 'Audio capture complete:' /tmp/amy-second.log + + mkdir -p android/audio-capture + adb exec-out run-as org.amy.hello cat files/amy-render.wav > android/audio-capture/amy-render.wav + adb exec-out run-as org.amy.hello cat files/amy-oboe.wav > android/audio-capture/amy-oboe.wav + adb exec-out run-as org.amy.hello cat files/amy-audio-levels.txt > android/audio-capture/amy-audio-levels.txt + test -s android/audio-capture/amy-render.wav + test -s android/audio-capture/amy-oboe.wav + test -s android/audio-capture/amy-audio-levels.txt + cat android/audio-capture/amy-audio-levels.txt + + - name: Analyze captured AMY and Oboe audio levels + run: | + python3 tests/check_android_audio_capture.py \ + android/audio-capture/amy-render.wav \ + android/audio-capture/amy-oboe.wav + + - name: Upload Android audio captures + uses: actions/upload-artifact@v4 + with: + name: amy-android-audio-capture + path: android/audio-capture/ + if-no-files-found: error diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..f1911af5 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -87,6 +87,9 @@ jobs: - name: Validate GDScript parses run: gdparse godot/amy.gd + - name: Test Godot backend lifecycle signals + run: python tests/test_godot_backend_signals.py + - name: Check godot/amy.gd is in sync with amy/__init__.py run: | if ! git diff --quiet -- godot/amy.gd; then @@ -111,7 +114,7 @@ jobs: python-version: '3.13' - name: Check generated C API bindings are in sync - run: make check-c-api + run: make check-c-api js-api-test godot-build: # Build the Godot GDExtension for Linux. amy_midi.c is excluded from the diff --git a/.github/workflows/unix-socket.yml b/.github/workflows/unix-socket.yml new file mode 100644 index 00000000..a62761f2 --- /dev/null +++ b/.github/workflows/unix-socket.yml @@ -0,0 +1,21 @@ +name: Unix socket transport + +on: + pull_request: + paths: + - 'src/amy_unix_socket.c' + - 'src/amy_unix_socket.h' + - 'tests/test_amy_unix_socket.c' + - 'tests/run_amy_unix_socket_test.sh' + - '.github/workflows/unix-socket.yml' + +permissions: + contents: read + +jobs: + linux-socket-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Compile and run Unix socket transport test + run: bash tests/run_amy_unix_socket_test.sh diff --git a/.gitignore b/.gitignore index db16b158..77fa66f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ tests/tst tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds +tests/test_sequencer_sequences +tests/test_sequencer_oom +tests/test_sequencer_concurrency tests/test_bus_config tests/test_patch_slots tests/test_synth_readout diff --git a/Makefile b/Makefile index f849e7dc..32062694 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \ -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000 PYTHON = python3 -.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api +.PHONY: default all clean amy-module test ctest build-config-test web deploy-web godot-api c-api check-c-api js-api-test default: $(TARGET) all: default @@ -83,6 +83,9 @@ check-c-api: $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check +js-api-test: + node tests/test_js_api.js + SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \ src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \ src/delay.c src/log2_exp2.c src/patches.c src/transfer.c src/sequencer.c \ @@ -96,8 +99,14 @@ HEADERS_BUILD := $(filter-out src/patches.h,$(HEADERS)) PYTHONS = $(wildcard *.py) +# The grep below takes every NUMERIC #define out of amy.h. AMY_BLOCK_SIZE is +# the one derived define -- (1 << BLOCK_SIZE_BITS), since the block has to be +# a power of two and the bits are the knob -- so it is spelt out afterwards +# from the BLOCK_SIZE_BITS that landed, or amy.render() and the generated JS +# API would lose it. src/patches.h: $(PYTHONS) $(HEADERS_BUILD) cat src/amy.h | sed -e 's@^//.*@@' | tr '\t' ' ' | egrep 'define +[^ ]+ +[.0-9-]+' | sed -e 's/\([-0-9][0-9]*\.[0-9]*\)f.*/\1/' | awk '{print $$2 "=" $$3}' > amy/constants.py + echo "AMY_BLOCK_SIZE=$$((1 << $$(sed -n 's/^BLOCK_SIZE_BITS=//p' amy/constants.py | tail -1)))" >> amy/constants.py ${PYTHON} -m amy.headers %.o: %.c $(HEADERS) src/patches.h @@ -124,27 +133,94 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ + tests/test_sequencer_sequences \ + tests/test_sequencer_oom \ + tests/test_sequencer_concurrency \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ - tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope + tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope \ + tests/test_ignore_note_offs tests/test_shared_reverb \ + tests/test_reverb_limit # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h +SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency +INSTRUMENT_SPECIAL_TEST = tests/test_ignore_note_offs +REVERB_SPECIAL_TEST = tests/test_reverb_limit +SPECIAL_TESTS = $(SEQUENCE_SPECIAL_TESTS) $(INSTRUMENT_SPECIAL_TEST) $(REVERB_SPECIAL_TEST) + +$(addsuffix .o,$(filter-out $(SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(CTESTS): %: %.o $(OBJECTS) +$(filter-out $(SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ -ctest: $(CTESTS) +# Build only the sequencer and its OOM test with the test-only allocation hook; +# every other test and every production target uses the ordinary object. +tests/sequencer_testing_impl.o: src/sequencer.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ + +tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +$(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ + +# Compile amy.c once with a small embedded-style built-in reverb ceiling. The +# rest of AMY is unchanged because the ceiling only guards effect allocation. +tests/amy_reverb_limit_impl.o: src/amy.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_MAX_REVERBS=1 -c $< -o $@ + +tests/test_reverb_limit.o: tests/test_reverb_limit.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_MAX_REVERBS=1 -Isrc -c $< -o $@ + +tests/test_reverb_limit: tests/test_reverb_limit.o tests/amy_reverb_limit_impl.o $(filter-out src/amy.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/amy.o,$(OBJECTS)) tests/amy_reverb_limit_impl.o $< -Wall $(LIBS) -o $@ + +# Read internal pool occupancy in this test without parsing stderr or relying +# on platform-specific file-descriptor redirection. +tests/instrument_testing_impl.o: src/instrument.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_INSTRUMENT_TESTING -c $< -o $@ + +tests/test_ignore_note_offs.o: tests/test_ignore_note_offs.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_INSTRUMENT_TESTING -Isrc -c $< -o $@ + +$(INSTRUMENT_SPECIAL_TEST): %: %.o tests/instrument_testing_impl.o $(filter-out src/instrument.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/instrument.o,$(OBJECTS)) tests/instrument_testing_impl.o $< -Wall $(LIBS) -o $@ + +build-config-test: + $(CC) $(CFLAGS) -Isrc \ + -DEXPECT_AMY_BLOCK_SIZE=256 -DEXPECT_BLOCK_SIZE_BITS=8 \ + -DEXPECT_AMY_SAMPLE_RATE=44100 \ + tests/test_build_config.c -o tests/test_build_config_default + ./tests/test_build_config_default + $(CC) $(CFLAGS) -Isrc \ + -DBLOCK_SIZE_BITS=7 -DAMY_SAMPLE_RATE=48000 \ + -DEXPECT_AMY_BLOCK_SIZE=128 -DEXPECT_BLOCK_SIZE_BITS=7 \ + -DEXPECT_AMY_SAMPLE_RATE=48000 \ + tests/test_build_config.c -o tests/test_build_config_embedded + ./tests/test_build_config_embedded + $(CC) $(CFLAGS) -Isrc \ + -DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 \ + -DEXPECT_AMY_BLOCK_SIZE=128 -DEXPECT_BLOCK_SIZE_BITS=7 \ + -DEXPECT_AMY_SAMPLE_RATE=48000 \ + tests/test_build_config.c -o tests/test_build_config_legacy + ./tests/test_build_config_legacy + +ctest: build-config-test $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done amy-module: amy-example ${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 .. test: amy-module + ${PYTHON} tests/test_sequence_api.py ${PYTHON} -m amy.test + ${PYTHON} tests/test_python_offline_live.py qtest: amy-module ${PYTHON} -m amy.test quiet @@ -217,3 +293,4 @@ clean: -rm -f amy/constants.py -rm -f $(TARGET) -rm -f tests/*.o $(CTESTS) + -rm -f tests/test_build_config_default tests/test_build_config_embedded tests/test_build_config_legacy diff --git a/README.md b/README.md index 77d9e83e..6678ff46 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,12 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) + * [**Windows `M_PI` portability note**](docs/windows-m-pi-portability.md) + * [**Porting AMY and local-service transports**](docs/porting.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) AMY supports @@ -111,6 +114,12 @@ In Python: >>> # play MIDI notes using system MIDI ``` +`amy.live(audio=False, ...)` applies the same runtime configuration without +starting a system-audio callback. This is intended for deterministic offline +rendering with `c_amy.render_to_list()`; omitting `audio` retains the existing +live-audio behavior. Runtime allocation options such as `max_buses`, +`max_reverb_rooms`, and the sequencer limits use the same keyword interface. + In C: ```c @@ -171,11 +180,14 @@ It's good to understand what wire messages are but you don't need to construct t * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) * [**AMY in Godot**](docs/godot.md) + * [**Porting AMY and local-service transports**](docs/porting.md) * [**AMY on Windows**](windows/README.md) + * [**Windows `M_PI` portability note**](docs/windows-m-pi-portability.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) [![shore pine sound systems discord](https://raw.githubusercontent.com/shorepine/tulipcc/main/docs/pics/shorepine100.png) **Chat about AMY on our Discord!**](https://discord.gg/TzBFkUb8pG) diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..0dc44f46 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -13,7 +13,7 @@ # .github/workflows/release.yml, which rewrites the line below in the same commit # it tags -- so amy.version always matches the release tag it shipped in. Edit # with the workflow, not by hand. -version = '1.2.163' +version = '1.2.164' # BEGIN GENERATED - scripts/gen_amy_c_api.py # One backend resolver per C API function: prefer the CPython c_amy @@ -240,8 +240,164 @@ def str_of_int(arg): return str(int(arg)) +def _list_values(value): + """Return a wire-list argument as individual values for validation.""" + if isinstance(value, str): + return value.split(',') + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +_SEQUENCE_UINT32_MAX = (1 << 32) - 1 +_SEQUENCE_MAX_INTERVAL = (1 << 31) - 1 + + +def _sequence_uint32(value, name, allow_template=False): + """Return one exact sequence integer without lossy numeric coercion.""" + if allow_template and isinstance(value, str) and value.startswith('%'): + return value + if isinstance(value, bool): + raise ValueError('%s must be a non-negative integer.' % name) + if isinstance(value, int): + result = value + elif isinstance(value, str) and value.strip().isdigit(): + result = int(value.strip()) + else: + raise ValueError('%s must be a non-negative integer.' % name) + if result < 0: + raise ValueError('%s must be non-negative.' % name) + if result > _SEQUENCE_UINT32_MAX: + raise ValueError('%s must be in uint32 range.' % name) + return result + + +def _sequence_interval(value, name, allow_template=False): + result = _sequence_uint32(value, name, allow_template=allow_template) + if isinstance(result, str): + return result + if result > _SEQUENCE_MAX_INTERVAL: + raise ValueError('%s must not exceed 2147483647 ticks.' % name) + return result + + +def _message_ticks(value): + values = _list_values(value) + if not 1 <= len(values) <= 3: + raise ValueError('ticks needs tick, optional period, and optional tag.') + names = ('ticks tick', 'ticks period', 'ticks tag') + normalized = [] + numeric = [] + for index, item in enumerate(values): + # Empty list fields have always meant zero on the AMY wire. Preserve + # that spelling as well as the meaning; the tutorial and existing + # callers use ticks=",period,tag" for a tick-zero event. + if item is None or (isinstance(item, str) and not item.strip()): + normalized.append(item) + numeric.append(0) + else: + parsed = _sequence_uint32(item, names[index]) + normalized.append(parsed) + numeric.append(parsed) + # tick < period is a reusable-sequence invariant. Legacy untagged two- + # field scheduling retains its historical wire behavior. + if (len(numeric) == 3 and numeric[1] + and numeric[0] >= numeric[1]): + raise ValueError('ticks tick must be below its nonzero period.') + return normalized + + +def _sequence_control_values(value): + """Validate the low-level ``HC`` payload without blocking templates.""" + values = _list_values(value) + if len(values) < 2: + raise ValueError('sequence_control needs at least tag and action.') + values[0] = _sequence_uint32( + values[0], 'sequence_control tag', allow_template=True) + raw_action = values[1] + if isinstance(raw_action, str) and raw_action.startswith('%'): + # Command templates substitute the token before AMY parses HC. The + # resulting wire value must still be the integer 0, 1, or 2. + if not 2 <= len(values) <= 4: + raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') + for index in range(2, len(values)): + values[index] = _sequence_interval( + values[index], 'templated sequence_control field', + allow_template=True) + return values + if isinstance(raw_action, int) and not isinstance(raw_action, bool): + action = raw_action + elif isinstance(raw_action, str) and raw_action.isdigit(): + action = int(raw_action) + else: + raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if len(values) not in (2, 3): + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + elif action == SEQUENCE_CONTROL_GATE: + if len(values) not in (3, 4): + raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') + else: + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + values[1] = action + field_names = ('sequence_control duration', 'sequence_control alignment_period') \ + if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) + for index, name in enumerate(field_names, start=2): + if index < len(values): + values[index] = _sequence_interval( + values[index], name, allow_template=True) + return values + + +def _normalize_sequence_action(kwargs): + """Translate a named sequence action into the existing HC primitive.""" + if 'sequence' not in kwargs: + for key in ('action', 'duration', 'alignment_period'): + if key in kwargs: + raise ValueError('%s is only valid with sequence.' % key) + return kwargs + if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: + raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') + extra = set(kwargs) - { + 'sequence', 'action', 'duration', 'alignment_period', 'ticks' + } + if extra: + raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') + if 'action' not in kwargs: + raise ValueError("sequence needs action='start', 'stop', or 'gate'.") + tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') + alignment = _sequence_interval( + kwargs.get('alignment_period', 0), 'Sequence alignment_period') + action_name = kwargs['action'] + actions = { + 'stop': SEQUENCE_CONTROL_STOP, + 'start': SEQUENCE_CONTROL_START, + 'gate': SEQUENCE_CONTROL_GATE, + } + if not isinstance(action_name, str) or action_name not in actions: + raise ValueError("Sequence action must be 'start', 'stop', or 'gate'.") + action = actions[action_name] + if action == SEQUENCE_CONTROL_GATE: + if 'duration' not in kwargs: + raise ValueError("Sequence action='gate' needs a duration in ticks.") + duration = _sequence_interval( + kwargs['duration'], 'Sequence gate duration') + control = (tag, action, duration, alignment) + else: + if 'duration' in kwargs: + raise ValueError('Sequence duration is only valid with action=\'gate\'.') + control = (tag, action, alignment) + normalized = {} + if 'ticks' in kwargs: + normalized['ticks'] = kwargs['ticks'] + normalized['sequence_control'] = control + return normalized + + _KW_MAP_LIST = [ # Order matters because patch_string must come last. - # 'ticks' must come first: 'H' is recognized only as first char in wire message. + # Sequence/ticks headers must come first: 'H' is only recognized as the + # first wire character. sequence_control follows a ticks + # header when it is used as that scheduled event's payload. ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -252,7 +408,11 @@ def str_of_int(arg): ('mod_source', 'LL'), ('eq', 'xL'), ('filter_type', 'GI'), ('ratio', 'IF'), ('latency_ms', 'NI'), ('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'), ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), - ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), + ('algorithm', 'oI'), ('chorus', 'kL'), + ('reverb_room', 'hRL'), ('reverb_send', 'hSL'), ('reverb', 'hL'), + ('echo', 'ML'), ('patch', 'KI'), + ('sequence_reset', 'HRI'), + ('sequence_control', 'HCL'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), @@ -277,6 +437,9 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS + kwargs = _normalize_sequence_action(kwargs) + if kwargs.get('ticks') is not None: + kwargs['ticks'] = _message_ticks(kwargs['ticks']) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: @@ -296,6 +459,20 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') + outer_sequence_keys = {'ticks', 'sequence_reset'} & kwargs.keys() + if len(outer_sequence_keys) > 1: + raise ValueError('Use only one of sequence_reset or ticks in a message.') + if 'sequence_reset' in kwargs and len(kwargs) != 1: + raise ValueError('sequence_reset must be sent as a standalone message.') + if 'sequence_reset' in kwargs: + kwargs['sequence_reset'] = _sequence_uint32( + kwargs['sequence_reset'], 'sequence_reset tag') + if 'sequence_control' in kwargs: + if set(kwargs) - {'sequence_control', 'ticks'}: + raise ValueError('sequence_control can only be combined with ticks.') + kwargs['sequence_control'] = _sequence_control_values( + kwargs['sequence_control']) + # Validity check all the passed args. prioritized_keys = [] for key, arg in kwargs.items(): @@ -373,6 +550,51 @@ def send(**kwargs): send_raw(m) +def _sequence_ticks(value): + """Normalize a stored-sequence event's local (tick, period) tuple.""" + if isinstance(value, str): + values = value.split(',') + elif isinstance(value, (list, tuple)): + values = list(value) + else: + values = [value] + if not 1 <= len(values) <= 2: + raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') + tick = _sequence_uint32(values[0], 'Stored sequence tick') + period = _sequence_uint32(values[1], 'Stored sequence period') \ + if len(values) == 2 else 0 + if period and tick >= period: + raise ValueError('A stored sequence tick must be below its nonzero period.') + return tick, period + + +def define_sequence(tag, events): + """Replace one reusable tagged sequence with ordinary AMY events. + + Each event is a mapping accepted by :func:`message` and must contain a + local ``ticks`` value with one or two fields. All event messages are + validated before the reset is sent, then the definition is written as a + per-tag reset followed by explicit cumulative event appends. Executions + which already started keep their previous immutable definition. + """ + sequence_tag = _sequence_uint32(tag, 'Sequence tag') + event_messages = [] + for event in events: + values = dict(event) + if 'ticks' not in values: + raise ValueError('Every stored sequence event needs a ticks value.') + if 'sequence_reset' in values: + raise ValueError('Stored sequence events cannot contain sequence authoring commands.') + tick, period = _sequence_ticks(values.pop('ticks')) + if not values: + raise ValueError('Every stored sequence event needs an AMY payload.') + event_messages.append(message(ticks=(tick, period, sequence_tag), **values)) + + send_raw(message(sequence_reset=sequence_tag)) + for event_message in event_messages: + send_raw(event_message) + + # Plots a time domain and spectra of audio def show(data): import matplotlib.pyplot as plt diff --git a/amy/constants.py b/amy/constants.py index ef33569a..ad745374 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -1,10 +1,7 @@ MAX_FILENAME_LEN=127 -AMY_BLOCK_SIZE=128 BLOCK_SIZE_BITS=7 -AMY_BLOCK_SIZE=256 BLOCK_SIZE_BITS=8 AMY_SAMPLE_RATE=48000 -AMY_SAMPLE_RATE=48000 AMY_SAMPLE_RATE=44100 PCM_AMY_SAMPLE_RATE=22050 AMY_TRANSFER_TYPE_NONE=0 @@ -124,6 +121,9 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +SEQUENCE_CONTROL_STOP=0 +SEQUENCE_CONTROL_START=1 +SEQUENCE_CONTROL_GATE=2 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 @@ -162,3 +162,4 @@ AMY_AUDIO_DEVICE_OUT=0 AMY_AUDIO_DEVICE_IN=1 AMY_NUM_MIDI_CHANNELS=16 +AMY_BLOCK_SIZE=256 diff --git a/amy/examples.py b/amy/examples.py index 3cf35772..309516ec 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,18 +257,14 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Add patterns - # Hi hat every 1/8th note - amy.send(ticks=[0, 24, 0], osc=2, vel=2.0) - - # Bass drum every quarter note - amy.send(ticks=[0, 96, 1], osc=0, vel=1.0) - - # Snare every quarter note, counterphase to BD - amy.send(ticks=[24, 96, 2], osc=1, vel=1.0) - - # Cow once every other cycle - amy.send(ticks=[0, 192, 3], osc=3, vel=1.0) + # Store all parts as one reusable pattern, then start it explicitly. + amy.define_sequence(0, [ + dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note + dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter + dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare + dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle + ]) + amy.send(sequence=0, action='start', alignment_period=1) def example_fm(): amy.reset() diff --git a/amy/headers.py b/amy/headers.py index 64103892..188883a0 100644 --- a/amy/headers.py +++ b/amy/headers.py @@ -235,6 +235,40 @@ def _write_int16_carray(p, data, column=15): p.write(" %s,\n" % (",".join([("%d" % (d)).ljust(8) for d in data[-rem:]]))) +def generate_gamma9001_blob_c(c_path, sounds_dir='sounds/gamma9001', + pcm_AMY_SAMPLE_RATE=22050): + """Write only the linkable Gamma9001 PCM blob for native host builds. + + Unlike ``generate_gamma9001_headers()``, this entry point does not rewrite + tracked headers. A CMake target can therefore generate one private source + file per ABI/build directory without two concurrent Android builds racing + over files in the source checkout. + """ + import json + manifest = json.load(open(os.path.join(sounds_dir, 'manifest.json'))) + bin_entries = [m for m in manifest if m['bank'] != GAMMA9001_ROM_BANK] + entries = [ + (m, _read_wav_mono16( + os.path.join(sounds_dir, m['file']), pcm_AMY_SAMPLE_RATE)) + for m in bin_entries + ] + frames = sum(len(data) for _, data in entries) + parent = os.path.dirname(c_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(c_path, 'w') as p: + p.write("// Automatically generated by amy.headers.generate_gamma9001_blob_c()\n") + p.write("// The Gamma9001 drums.bin blob as C data; see src/pcm_gamma9001.h for the map.\n") + p.write("#include \n") + p.write("const int16_t gamma9001_pcm_data[%d] = {\n" % frames) + for m, data in entries: + p.write(" // %s: %s\n" % (m['bank'], m['name'])) + _write_int16_carray(p, data) + p.write("};\n") + print("gamma9001: %d samples (%.2f MB) -> %s" % ( + len(entries), frames * 2 / 1e6, c_path)) + + def generate_gamma9001_headers(sounds_dir='sounds/gamma9001', bin_path='build/drums.bin', pcm_AMY_SAMPLE_RATE=22050): import json @@ -338,19 +372,10 @@ def generate_gamma9001_headers(sounds_dir='sounds/gamma9001', bin_path='build/dr p.close() # drums.bin as a linkable C array, for targets that bake the banks into the - # binary (the wasm build). ESP32-S3 flashes drums.bin as a partition instead. + # binary (web, CPython and native host builds). ESP32-S3 flashes drums.bin + # as a partition instead. c_path = os.path.join(os.path.dirname(bin_path), 'drums_bin.c') - p = open(c_path, 'w') - p.write("// Automatically generated by amy.headers.generate_gamma9001_headers()\n") - p.write("// The Gamma9001 drums.bin blob as C data; see src/pcm_gamma9001.h for the map.\n") - p.write("#include \n") - p.write("const int16_t gamma9001_pcm_data[%d] = {\n" % offset) - for m in bin_entries: - data = _read_wav_mono16(os.path.join(sounds_dir, m['file']), pcm_AMY_SAMPLE_RATE) - p.write(" // %s: %s\n" % (m['bank'], m['name'])) - _write_int16_carray(p, data) - p.write("};\n") - p.close() + generate_gamma9001_blob_c(c_path, sounds_dir, pcm_AMY_SAMPLE_RATE) print("gamma9001: %d ROM samples -> pcm_gamma808.h, %d samples (%.2f MB) -> %s + pcm_gamma9001.h + %s" % (len(rom), len(bin_entries), offset * 2 / 1e6, bin_path, c_path)) @@ -1222,6 +1247,13 @@ def generate_all(): def main(): + if 'gamma9001-blob-c' in sys.argv: + index = sys.argv.index('gamma9001-blob-c') + if len(sys.argv) != index + 2: + raise SystemExit( + 'usage: python -m amy.headers gamma9001-blob-c OUTPUT.c') + generate_gamma9001_blob_c(sys.argv[index + 1]) + return if 'gamma9001' in sys.argv: generate_gamma9001_headers() return diff --git a/amy/test.py b/amy/test.py index 2539b32e..116440ff 100644 --- a/amy/test.py +++ b/amy/test.py @@ -2034,7 +2034,7 @@ def __init__(self): self.default_synths = True def run(self): - amy_send_at(time=100, ticks='20,24,0', synth=1, note=64, vel=1) + amy_send_at(time=100, ticks='20,24', synth=1, note=64, vel=1) class TestSequencedSynthDrums(AmyTest): @@ -2046,7 +2046,7 @@ def __init__(self): def run(self): # The sequencer working on the SYNTH_FLAGS_NOTES_VIA_MIDI synth 10 (38 = Acoustic Snare). - amy_send_at(time=100, ticks='20,24,0', synth=10, note=38, vel=1) + amy_send_at(time=100, ticks='20,24', synth=10, note=38, vel=1) class TestSequencerOsc(AmyTest): @@ -2058,10 +2058,10 @@ class TestSequencerOsc(AmyTest): def run(self): amy_send_at(time=0, osc=0, wave=amy.SINE, freq=1000) # Absolute-tick events: note on at tick 20 (~231 ms), off at tick 40 (~463 ms). - amy.send(osc=0, vel=1, ticks="20,0,1") - amy.send(osc=0, vel=0, ticks="40,0,2") + amy.send(osc=0, vel=1, ticks="20") + amy.send(osc=0, vel=0, ticks="40") # Periodic event: a lower note every 60 ticks, lands once at ~694 ms. - amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60,3") + amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60") amy_send_at(time=900, osc=1, vel=0) @@ -2341,4 +2341,3 @@ def main(argv): if __name__ == "__main__": main(sys.argv) - diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..2bc4ff62 --- /dev/null +++ b/android/README.md @@ -0,0 +1,249 @@ +# AMY Android Oboe service + +This directory builds a generic Android AAR that hosts AMY in an unexported +`:amy` service process. The service owns Oboe/AAudio output and receives native +AMY wire messages through the private pathname Unix transport implemented by +`src/amy_unix_socket.[ch]`. + +```text +Android client process + | + | AF_UNIX / SOCK_SEQPACKET + | /amy.sock + | one AMY wire message per packet + v +Android :amy service process + | + +-- amy_unix_socket receiver thread + +-- fixed 64-packet SPSC queue + +-- AMY C engine + +-- Oboe low-latency callback + | + v + AAudio +``` + +The AAR is embedded in an Android application package. Its private +`AmyAutoStartProvider` starts the separate `:amy` service process as part of +Android package initialization; client application code does not start or stop +AMY. A client can therefore be Java/Kotlin, native code, Godot, Qt, another +framework, or any other environment that can package an Android AAR and open an +Android Unix-domain `SOCK_SEQPACKET` socket. No AMY headers, AMY source, JNI +bindings, or language-specific AMY API are required in the client code. + +The service declaration uses `android:exported="false"` and +`android:process=":amy"`. Consequently the service runs in a separate process +from the client while remaining in the same Android application package and +under the same application UID. + +The service only accepts the exact pathname `/amy.sock`. +The native transport creates that node mode `0600` and additionally verifies +accepted peers with `SO_PEERCRED` against the service effective UID. The AAR +must therefore be packaged into the same application/UID as the client; this is +intentional and preserves the private-socket security model. See +`docs/android_unix_socket.md` for the transport/security contract. + +## Audio profile + +The Android native build uses AMY's existing 48 kHz / 128-frame build profile +and defines `AMY_NO_MINIAUDIO`; Oboe is the sole audio backend. This LB release +profile also defines `GAMMA9001`. CMake invokes the stdlib-only +`python -m amy.headers gamma9001-blob-c` generator for each ABI build directory +and links that private generated source into the service, so presets 0-18 use +the Gamma808 ROM and presets 256-391 use the complete Gamma9001 sample blob. + +The marker-gated CI capture records eight seconds from both AMY's rendered +samples and the exact buffer handed to Oboe. This leaves a packaged framework +runtime enough startup time before UI-driven notes while remaining a one-shot, +test-only path; ordinary applications never allocate the capture buffers. + +Oboe requests: + +- stereo signed 16-bit output +- 48 kHz +- `PerformanceMode::LowLatency` +- `SharingMode::Exclusive` +- callback-driven output + +The callback size is not assumed to equal 128 frames. The native adapter keeps +only the unconsumed tail of the current AMY block and calls +`amy_simple_fill_buffer()` exactly when another AMY block is required. It does +not add an extra 128-frame output ring. + +Before each new AMY block the callback drains up to 64 already-queued socket +packets and passes them to `amy_add_message()`. The socket thread itself never +calls AMY and never participates in audio rendering. + +AMY is started with its internal platform audio disabled and with AMY rendering +owned by the Oboe callback thread. The current Android build configuration +reserves 336 addressable oscillators, 11 runtime buses, two shared aux returns, +and 16 Karplus-Strong oscillators. These are service-host capacities, not +wire-protocol extensions: clients continue to send ordinary AMY messages and +may use any smaller layout. + +## JNI boundary + +JNI exists only inside the service implementation. `AmyService` calls the +native library to start and stop AMY/Oboe and to report its actual Oboe output +device. Musical control never crosses JNI: notes, patches, sequencer commands, +and other control are unchanged AMY wire packets sent through `amy.sock`. + +The client-facing architecture is deliberately transport-only: + +```text +client application -> amy.sock -> AMY/Oboe service +``` + +The minimal Java hello-world demonstrates this literally with Android's public +`LocalSocket(SOCKET_SEQPACKET)` API. It neither imports `AmyService` nor loads a +native client library. + +## Socket client contract + +Use `AF_UNIX` + `SOCK_SEQPACKET` and send one logical AMY request per packet. +For example the payload of three consecutive packets may be: + +```text +K28i2Z +n60l1i2Z +n60l0i2Z +``` + +Do not add stream framing or depend on newline boundaries. Packet boundaries +are preserved by `SOCK_SEQPACKET`. + +The pathname also serves as the engine readiness boundary. `amy.sock` is not +created until Oboe has started and the realtime audio callback has executed at +least once. A client may therefore retry `connect()` while the service starts; +once `connect()` succeeds it may begin sending AMY wire packets immediately. +No fixed Android-startup sleep is required. + +The socket is bidirectional. The Android engine currently consumes ordinary AMY +wire commands; the existing `amy_unix_socket_send()` path is ready for compact +introspection/status replies when that functionality is integrated. + +## Client integration + +A client application needs to: + +1. package the `amy-service` AAR/module in the Android application; +2. obtain the application's actual private files directory rather than + hard-code `/data/user/...`; +3. retry an `AF_UNIX` / `SOCK_SEQPACKET` connection to `/amy.sock` + until the service publishes its ready socket; +4. send one ordinary AMY wire message per packet; +5. optionally receive response packets over the same bidirectional socket; +6. reconnect cleanly when its own Android/application lifecycle requires it. + +Starting AMY is deliberately absent from the client contract. The packaged AAR +owns that Android lifecycle responsibility. + +## Building the AAR + +Requirements used by CI: + +- JDK 17 +- Android SDK platform 36 +- Android NDK 27.2.12479018 (r27c) +- CMake 3.22.1 +- Gradle 8.13 +- Android Gradle Plugin 8.13.2 +- Oboe 1.10.0 (Prefab dependency) + +From the repository root: + +```bash +cd android +gradle :amy-service:assembleDebug +``` + +The production Android service build targets `arm64-v8a`. Output is below: + +```text +android/amy-service/build/outputs/aar/ +``` + +## Tests + +The private socket regression test is: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +It validates packet round-trip, mode/ownership, `EMSGSIZE` behavior, +oversized-packet rejection, cleanup, and protection against deleting an +existing non-socket path. `tests/test_android_service_contract.py` additionally +guards the AAR's private-process manifest, socket-only client boundary, and the +336-oscillator/11-bus integration profile without requiring an Android SDK. + +`.github/workflows/android.yml` runs that regression plus a complete Android +AAR/NDK/Oboe build and emulator end-to-end test. The emulator arms its own +one-shot audio-capture marker before starting the client; the hello-world +application itself remains transport-only. + +## Downstream PySide6 package findings + +The service AAR from this release was also packaged and released in the +downstream [LB Omnichord Android application][lb-android-package]. That client +is useful as a framework-integration reference, but its Qt and Python packaging +workarounds are not part of AMY's portable service contract. + +The successful package used Python 3.11 and the official +`pyside6-android-deploy` command with matching PySide6 and shiboken6 6.11.2 +Android wheels. The command generated the Qt deployment files and +`buildozer.spec`; the downstream build then added this AAR and its Oboe Prefab +dependency to the generated Gradle package. Qt's command uses +Buildozer/python-for-android as host-side packaging tools. Kivy is not an +application or runtime dependency and is not included in the APK. + +Those tools were reproducible only as one pinned set: Android SDK 36, NDK +27.2.12479018, python-for-android commit +`3762c88c56e3443efb8eba2a02a2604b680240fd`, and Cython 0.29.36. The build also +had to expose the modern SDK manager at Buildozer 1.5's expected legacy path +and add python-for-android's local `libs` directory to Gradle repositories so +the AAR supplied with `--add-aar` could be resolved. The package regression +checks the requested AAR and wheel ABIs, verifies that the APK contains the +AMY/Oboe and matching CPython/shiboken libraries, and rejects an accidental +in-process `c_amy` or `libamy.so` frontend binding. + +On Android the Qt client discovers the application-private files directory +with `QStandardPaths` and appends `amy.sock`; it does not hard-code an Android +user or `/data/user/...` path. The frontend and unexported `:amy` service then +remain separate processes under the same application UID. + +python-for-android extracts its private Python/Qt payload on first launch. In +an emulator that extraction can consume a measured audio window, and an +occasional Qt/JNI startup race can terminate that first process. The downstream +test therefore retries only an unmeasured extraction warm-up, force-stops the +whole package, and keeps the subsequent measured UI/audio launch single-shot. +This avoids hiding failures in the behavior under test. + +A Linux-hosted emulator may print a host PulseAudio (`pa`) warning even though +the Android application never uses PulseAudio. The downstream gate separately +requires the guest service to report Oboe/AAudio, captures the signed-16-bit +samples rendered by AMY and handed to Oboe, and requires them to match exactly. +It also checks non-silence and clipping independently. Its `-26 dBFS` floor is +specific to LB Omnichord's deliberate `V1` master limit (20 dB below AMY's raw +`V10` unity setting) plus 6 dB of patch/phase headroom; it is not a general AMY +test threshold. + +[LB Omnichord release R20260830T153747][lb-release] passed that packaged +PySide6 test with 384000 stereo frames at 48 kHz, no clipping, and zero sample +mismatches between AMY and Oboe. Its arm64 APK is CI debug-signed for sideload +and emulator testing, not for a store or stable update channel. Physical +touchscreen, speaker, route-change and latency validation remains outstanding. + +## Hardware-test items + +The first device tests should measure: + +1. command-to-audio latency; +2. negotiated Oboe callback/device buffer sizes; +3. xruns during patch changes and heavy reverb/delay loads; +4. suspend/resume and audio-device changes; +5. whether executing rare heavy AMY commands at a block boundary needs further + separation from the realtime callback. + +[lb-android-package]: https://github.com/linuxificator/LB_Omnichord/blob/f8724328b2e679533c7f3b97cee939e009b7eba7/amysynth_version/qt_frontend/packaging/android/README.md +[lb-release]: https://github.com/linuxificator/LB_Omnichord/releases/tag/R20260830T153747 diff --git a/android/amy-service/build.gradle.kts b/android/amy-service/build.gradle.kts new file mode 100644 index 00000000..f95cd743 --- /dev/null +++ b/android/amy-service/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "org.amy.audio" + compileSdk = 36 + ndkVersion = "27.2.12479018" + + defaultConfig { + minSdk = 26 + + // arm64-v8a is the production target. x86_64 is included on this + // hello-world branch so CI can run the same AMY/Oboe service in the + // hardware-accelerated Android emulator. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + arguments += "-DANDROID_STL=c++_shared" + cppFlags += "-std=c++17" + } + } + } + + buildFeatures { + prefab = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("com.google.oboe:oboe:1.10.0") +} diff --git a/android/amy-service/src/main/AndroidManifest.xml b/android/amy-service/src/main/AndroidManifest.xml new file mode 100644 index 00000000..682656e0 --- /dev/null +++ b/android/amy-service/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..27208fa7 --- /dev/null +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.22.1) +project(amy_android LANGUAGES C CXX) + +find_package(oboe REQUIRED CONFIG) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +set(AMY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../..") +set(AMY_SRC "${AMY_ROOT}/src") +set(GAMMA9001_PCM_C "${CMAKE_CURRENT_BINARY_DIR}/drums_bin.c") +file(GLOB_RECURSE GAMMA9001_PCM_INPUTS CONFIGURE_DEPENDS + "${AMY_ROOT}/sounds/gamma9001/*.wav" +) + +# Generate the large linkable sample blob inside each ABI's private build +# directory. The tracked maps remain source inputs; parallel Android ABI +# builds never rewrite or share a generated C file in the checkout. +add_custom_command( + OUTPUT "${GAMMA9001_PCM_C}" + COMMAND "${Python3_EXECUTABLE}" -m amy.headers + gamma9001-blob-c "${GAMMA9001_PCM_C}" + WORKING_DIRECTORY "${AMY_ROOT}" + DEPENDS + "${AMY_ROOT}/amy/headers.py" + "${AMY_ROOT}/sounds/gamma9001/manifest.json" + ${GAMMA9001_PCM_INPUTS} + COMMENT "Generating Gamma9001 PCM blob" + VERBATIM +) +set_source_files_properties("${GAMMA9001_PCM_C}" PROPERTIES GENERATED TRUE) + +set(AMY_SOURCES + ${AMY_SRC}/algorithms.c + ${AMY_SRC}/amy.c + ${AMY_SRC}/amy_unix_socket.c + ${AMY_SRC}/delay.c + ${AMY_SRC}/envelope.c + ${AMY_SRC}/filters.c + ${AMY_SRC}/parse.c + ${AMY_SRC}/sequencer.c + ${AMY_SRC}/transfer.c + ${AMY_SRC}/midi_mappings.c + ${AMY_SRC}/custom.c + ${AMY_SRC}/patches.c + ${AMY_SRC}/oscillators.c + ${AMY_SRC}/interp_partials.c + ${AMY_SRC}/pcm.c + ${AMY_SRC}/log2_exp2.c + ${AMY_SRC}/instrument.c + ${AMY_SRC}/amy_midi.c + ${AMY_SRC}/api.c + ${AMY_SRC}/cv_trigger.c +) + +add_library(amy_android SHARED + amy_android.cpp + amy_android_capture.cpp + amy_android_profile.cpp + ${GAMMA9001_PCM_C} + ${AMY_SOURCES} +) + +target_include_directories(amy_android PRIVATE + ${AMY_SRC} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +# AMY_DAISY selects AMY's existing 48 kHz / 128-frame compile profile. Android +# owns both the audio and MIDI device layers, so no Daisy device implementation +# is linked: AMY_NO_MINIAUDIO leaves Oboe as the sole audio backend and +# AMY_HOST_MIDI leaves run_midi/stop_midi/midi_out to amy_android.cpp. +# delay.c already provides qspi_malloc/qspi_free under AMY_DAISY. pcm.c needs +# declarations for those helpers, so force only the compatibility declarations +# into C translation units; do not link a second allocator implementation. +target_compile_definitions(amy_android PRIVATE + AMY_ANDROID=1 + AMY_DAISY=1 + AMY_HOST_MIDI=1 + AMY_NO_MINIAUDIO=1 + AMY_WAVETABLE=1 + GAMMA9001=1 +) + +target_compile_options(amy_android PRIVATE + $<$:-include;${CMAKE_CURRENT_SOURCE_DIR}/amy_android_daisy_alloc.h;-O3;-Wall;-Wextra;-Wno-unused-parameter;-Wno-float-conversion> + $<$:-O3;-Wall;-Wextra;-Wno-unused-parameter> +) + +target_compile_features(amy_android PRIVATE c_std_11 cxx_std_17) + +target_link_libraries(amy_android PRIVATE + oboe::oboe + android + log + m +) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp new file mode 100644 index 00000000..0e62e3bd --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -0,0 +1,369 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "amy_android_capture.h" + +extern "C" { +#include "amy.h" +#include "amy_unix_socket.h" +#ifdef GAMMA9001 +extern const int16_t gamma9001_pcm_data[]; +#endif +} + +#define LOG_TAG "AmyAndroid" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* + * AMY's generic API always calls these platform hooks. The Android build does + * not use AMY's miniaudio/I2S platform layer; Oboe owns the output stream and + * calls amy_simple_fill_buffer() directly. + */ +extern "C" void amy_platform_init(void) {} +extern "C" void amy_platform_deinit(void) {} +extern "C" void amy_update_tasks(void) {} +extern "C" int16_t *amy_render_audio(void) { return nullptr; } +extern "C" size_t amy_i2s_write(const uint8_t *, size_t) { return 0; } + +/* + * AMY_HOST_MIDI makes the embedder own the MIDI device layer. This Android + * service is controlled by AMY wire messages rather than a MIDI device, so the + * lifecycle hooks are no-ops. Preserve AMY's optional outgoing MIDI hook even + * though no platform MIDI port is opened here. + */ +extern "C" void run_midi(void) {} +extern "C" void stop_midi(void) {} +extern "C" void midi_out(uint8_t *bytes, uint16_t len) { + if (amy_global.config.amy_external_midi_output_hook != nullptr) { + amy_global.config.amy_external_midi_output_hook(bytes, len); + } +} + +namespace { + +constexpr int kMaxCommandsPerBlock = 64; +constexpr int kAudioReadyTimeoutMs = 2000; +constexpr int kAudioReadyPollMs = 2; +constexpr uint16_t kIntegrationMaxOscillators = 336; +constexpr uint16_t kIntegrationMaxBuses = 11; +constexpr uint16_t kIntegrationMaxReverbRooms = 2; +constexpr uint32_t kIntegrationMaxSequencerTags = 1280; +constexpr uint32_t kIntegrationMaxSequenceEvents = 64; +constexpr uint32_t kIntegrationMaxSequenceExecutions = 40; + +class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, + public oboe::AudioStreamErrorCallback { +public: + int start(const char *socketPath) { + if (socketPath == nullptr || socketPath[0] == '\0') return -EINVAL; + if (mRunning.load(std::memory_order_acquire)) return -EALREADY; + + amy_config_t config = amy_default_config(); + config.audio = AMY_AUDIO_IS_NONE; + config.features.audio_in = 0; + config.features.default_synths = 0; + config.features.startup_bleep = 0; + /* + * The integration AAR must accommodate clients with large, explicitly + * addressed oscillator and bus layouts. Keep this runtime profile in + * sync with the documented Android service contract. + */ + config.max_oscs = kIntegrationMaxOscillators; + config.max_buses = kIntegrationMaxBuses; + config.max_reverb_rooms = kIntegrationMaxReverbRooms; + config.max_sequencer_tags = kIntegrationMaxSequencerTags; + config.max_sequence_events = kIntegrationMaxSequenceEvents; + config.max_sequence_executions = kIntegrationMaxSequenceExecutions; + /* Keep AMY rendering entirely on Oboe's realtime callback thread. */ + config.platform.multicore = 0; + config.platform.multithread = 0; + /* Physical-string clients can require many simultaneous KS voices. */ + config.ks_oscs = 16; + +#ifdef GAMMA9001 + amy_set_gamma9001_pcm(gamma9001_pcm_data); +#endif + amy_start(config); + mAmyStarted = true; + + // The helper remains dormant unless the hello-world test has created + // its one-shot private capture marker. It captures the exact samples + // returned by AMY and the exact I16 samples handed to Oboe. + mCapture = std::make_unique( + socketPath, AMY_SAMPLE_RATE, AMY_NCHANS); + + oboe::AudioStreamBuilder builder; + builder.setDirection(oboe::Direction::Output); + builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); + builder.setSharingMode(oboe::SharingMode::Exclusive); + builder.setFormat(oboe::AudioFormat::I16); + builder.setChannelCount(AMY_NCHANS); + builder.setSampleRate(AMY_SAMPLE_RATE); + builder.setUsage(oboe::Usage::Game); + builder.setContentType(oboe::ContentType::Music); + builder.setDataCallback(this); + builder.setErrorCallback(this); + + oboe::Result result = builder.openStream(mStream); + if (result != oboe::Result::OK || !mStream) { + LOGE("Oboe openStream failed: %s", oboe::convertToText(result)); + stopAmy(); + return static_cast(result); + } + + if (mStream->getFormat() != oboe::AudioFormat::I16 || + mStream->getChannelCount() != AMY_NCHANS || + mStream->getSampleRate() != AMY_SAMPLE_RATE) { + LOGE("Unexpected Oboe format: format=%d channels=%d rate=%d", + static_cast(mStream->getFormat()), + mStream->getChannelCount(), + mStream->getSampleRate()); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ERANGE; + } + + LOGI("Oboe output: deviceId=%d sharing=%d performance=%d usage=%d content=%d framesPerBurst=%d capacity=%d", + mStream->getDeviceId(), + static_cast(mStream->getSharingMode()), + static_cast(mStream->getPerformanceMode()), + static_cast(mStream->getUsage()), + static_cast(mStream->getContentType()), + mStream->getFramesPerBurst(), + mStream->getBufferCapacityInFrames()); + + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + mAudioCallbackSeen.store(false, std::memory_order_release); + mRunning.store(true, std::memory_order_release); + + result = mStream->requestStart(); + if (result != oboe::Result::OK) { + LOGE("Oboe requestStart failed: %s", oboe::convertToText(result)); + mRunning.store(false, std::memory_order_release); + mStream->close(); + mStream.reset(); + stopAmy(); + return static_cast(result); + } + + // Do not publish amy.sock until the realtime audio callback has actually + // executed. This makes successful socket connect a useful readiness + // boundary for generic clients, including the first launch after install. + int waitedMs = 0; + while (!mAudioCallbackSeen.load(std::memory_order_acquire) && + mRunning.load(std::memory_order_acquire) && + waitedMs < kAudioReadyTimeoutMs) { + std::this_thread::sleep_for(std::chrono::milliseconds(kAudioReadyPollMs)); + waitedMs += kAudioReadyPollMs; + } + + if (!mAudioCallbackSeen.load(std::memory_order_acquire)) { + LOGE("Timed out waiting for first Oboe audio callback"); + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return -ETIMEDOUT; + } + + if (!mRunning.load(std::memory_order_acquire)) { + LOGE("Oboe stream stopped before AMY socket became ready"); + mStream->close(); + mStream.reset(); + stopAmy(); + return -EIO; + } + + amy_unix_socket_server_t *socket = nullptr; + int socketResult = amy_unix_socket_start(&socket, socketPath); + if (socketResult != 0) { + mRunning.store(false, std::memory_order_release); + mStream->requestStop(); + mStream->close(); + mStream.reset(); + stopAmy(); + return socketResult; + } + mSocket.store(socket, std::memory_order_release); + + LOGI("AMY/Oboe started: %d Hz, %d-frame AMY blocks, %u oscs, %u buses, socket=%s", + AMY_SAMPLE_RATE, AMY_BLOCK_SIZE, + static_cast(config.max_oscs), + static_cast(config.max_buses), socketPath); + return 0; + } + + int32_t outputDeviceId() const { + return mStream ? mStream->getDeviceId() : -1; + } + + void stop() { + mRunning.store(false, std::memory_order_release); + + if (mStream) { + mStream->requestStop(); + mStream->close(); + mStream.reset(); + } + + // No callback can touch the capture buffers after the stream closes. + if (mCapture) { + mCapture->stop(); + mCapture.reset(); + } + + cleanupSocketAndAmy(); + mAudioCallbackSeen.store(false, std::memory_order_release); + mBlock = nullptr; + mBlockFrame = AMY_BLOCK_SIZE; + } + + oboe::DataCallbackResult onAudioReady( + oboe::AudioStream *, + void *audioData, + int32_t numFrames) override { + int16_t *output = static_cast(audioData); + if (!mRunning.load(std::memory_order_acquire)) { + std::memset(output, 0, + static_cast(numFrames) * AMY_NCHANS * sizeof(int16_t)); + return oboe::DataCallbackResult::Stop; + } + + mAudioCallbackSeen.store(true, std::memory_order_release); + if (mCapture && mCapture->enabled()) mCapture->beginCallback(numFrames); + + int32_t outputFrame = 0; + while (outputFrame < numFrames) { + if (mBlock == nullptr || mBlockFrame >= AMY_BLOCK_SIZE) { + drainCommands(); + mBlock = amy_simple_fill_buffer(); + mBlockFrame = 0; + if (mBlock == nullptr) { + std::memset(output + outputFrame * AMY_NCHANS, 0, + static_cast(numFrames - outputFrame) * + AMY_NCHANS * sizeof(int16_t)); + break; + } + } + + const int32_t available = AMY_BLOCK_SIZE - mBlockFrame; + const int32_t frames = std::min(available, numFrames - outputFrame); + if (mCapture && mCapture->enabled()) { + mCapture->captureAmyChunk( + mBlock + mBlockFrame * AMY_NCHANS, frames, outputFrame); + } + std::memcpy( + output + outputFrame * AMY_NCHANS, + mBlock + mBlockFrame * AMY_NCHANS, + static_cast(frames) * AMY_NCHANS * sizeof(int16_t)); + outputFrame += frames; + mBlockFrame += frames; + } + + if (mCapture && mCapture->enabled()) mCapture->finishCallback(output, numFrames); + return oboe::DataCallbackResult::Continue; + } + + void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override { + mRunning.store(false, std::memory_order_release); + LOGE("Oboe stream closed after error: %s", oboe::convertToText(error)); + /* Lifecycle owner may restart the service; no work is done on Oboe's error thread. */ + } + +private: + void drainCommands() { + amy_unix_socket_server_t *socket = mSocket.load(std::memory_order_acquire); + if (socket == nullptr) return; + + char command[MAX_MESSAGE_LEN]; + for (int count = 0; count < kMaxCommandsPerBlock; ++count) { + int length = amy_unix_socket_receive(socket, command, sizeof(command)); + if (length <= 0) break; + amy_add_message(command); + } + } + + void stopAmy() { + if (mAmyStarted) { + amy_stop(); + mAmyStarted = false; + } + } + + void cleanupSocketAndAmy() { + amy_unix_socket_server_t *socket = + mSocket.exchange(nullptr, std::memory_order_acq_rel); + if (socket != nullptr) { + uint32_t overruns = amy_unix_socket_queue_overruns(socket); + uint32_t oversize = amy_unix_socket_oversize_packets(socket); + uint32_t rejected = amy_unix_socket_rejected_peers(socket); + if (overruns || oversize || rejected) { + LOGE("AMY socket diagnostics: overruns=%u oversize=%u rejected=%u", + overruns, oversize, rejected); + } + amy_unix_socket_stop(socket); + } + stopAmy(); + } + + std::atomic mRunning{false}; + std::atomic mAudioCallbackSeen{false}; + bool mAmyStarted = false; + std::atomic mSocket{nullptr}; + std::shared_ptr mStream; + std::unique_ptr mCapture; + int16_t *mBlock = nullptr; + int32_t mBlockFrame = AMY_BLOCK_SIZE; +}; + +std::mutex gLifecycleMutex; +std::unique_ptr gEngine; + +} // namespace + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeStart(JNIEnv *env, jclass, jstring socketPath) { + if (socketPath == nullptr) return -EINVAL; + + const char *path = env->GetStringUTFChars(socketPath, nullptr); + if (path == nullptr) return -ENOMEM; + + std::lock_guard guard(gLifecycleMutex); + if (gEngine) gEngine->stop(); + gEngine = std::make_unique(); + int result = gEngine->start(path); + if (result != 0) gEngine.reset(); + + env->ReleaseStringUTFChars(socketPath, path); + return result; +} + +extern "C" JNIEXPORT jint JNICALL +Java_org_amy_audio_AmyService_nativeGetOutputDeviceId(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + return gEngine ? gEngine->outputDeviceId() : -1; +} + +extern "C" JNIEXPORT void JNICALL +Java_org_amy_audio_AmyService_nativeStop(JNIEnv *, jclass) { + std::lock_guard guard(gLifecycleMutex); + if (gEngine) { + gEngine->stop(); + gEngine.reset(); + } +} diff --git a/android/amy-service/src/main/cpp/amy_android_capture.cpp b/android/amy-service/src/main/cpp/amy_android_capture.cpp new file mode 100644 index 00000000..7a94dce3 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.cpp @@ -0,0 +1,281 @@ +#include "amy_android_capture.h" + +#include + +#include +#include +#include +#include + +#include + +#define LOG_TAG "AmyAudioCapture" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +// Leave enough room for a packaged framework client to extract/start its +// runtime and still exercise real UI-driven notes. Four seconds was enough +// for the Java hello-world, but could end during a Qt/Python synth attack. +constexpr int32_t kCaptureSeconds = 8; +constexpr const char *kEnableMarker = "amy-audio-capture.enable"; +constexpr const char *kAmyWave = "amy-render.wav"; +constexpr const char *kOboeWave = "amy-oboe.wav"; +constexpr const char *kStatsFile = "amy-audio-levels.txt"; + +std::string joinPath(const std::string &directory, const char *name) { + return directory + "/" + name; +} + +void writeLe16(FILE *file, uint16_t value) { + const uint8_t bytes[2] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +void writeLe32(FILE *file, uint32_t value) { + const uint8_t bytes[4] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + static_cast((value >> 16) & 0xff), + static_cast((value >> 24) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +bool writeWave(const std::string &path, + const std::vector &samples, + int32_t frames, + int32_t sampleRate, + int32_t channels) { + FILE *file = std::fopen(path.c_str(), "wb"); + if (file == nullptr) return false; + + const uint32_t sampleCount = static_cast(frames * channels); + const uint32_t dataBytes = sampleCount * sizeof(int16_t); + const uint32_t byteRate = static_cast(sampleRate * channels * sizeof(int16_t)); + const uint16_t blockAlign = static_cast(channels * sizeof(int16_t)); + + std::fwrite("RIFF", 4, 1, file); + writeLe32(file, 36u + dataBytes); + std::fwrite("WAVE", 4, 1, file); + std::fwrite("fmt ", 4, 1, file); + writeLe32(file, 16); + writeLe16(file, 1); // PCM + writeLe16(file, static_cast(channels)); + writeLe32(file, static_cast(sampleRate)); + writeLe32(file, byteRate); + writeLe16(file, blockAlign); + writeLe16(file, 16); + std::fwrite("data", 4, 1, file); + writeLe32(file, dataBytes); + std::fwrite(samples.data(), sizeof(int16_t), sampleCount, file); + + const bool ok = std::fclose(file) == 0; + return ok; +} + +struct LevelStats { + int32_t peak = 0; + double rms = 0.0; + double peakDbfs = -200.0; + double rmsDbfs = -200.0; +}; + +LevelStats levelStats(const std::vector &samples, int32_t sampleCount) { + LevelStats result; + if (sampleCount <= 0) return result; + + long double sumSquares = 0.0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t value = samples[static_cast(i)]; + const int32_t magnitude = value == -32768 ? 32768 : std::abs(value); + result.peak = std::max(result.peak, magnitude); + const long double sample = static_cast(value); + sumSquares += sample * sample; + } + + result.rms = std::sqrt(static_cast(sumSquares / sampleCount)); + if (result.peak > 0) { + result.peakDbfs = 20.0 * std::log10(static_cast(result.peak) / 32768.0); + } + if (result.rms > 0.0) { + result.rmsDbfs = 20.0 * std::log10(result.rms / 32768.0); + } + return result; +} + +} // namespace + +AmyAndroidAudioCapture::AmyAndroidAudioCapture( + const char *socketPath, int32_t sampleRate, int32_t channels) + : mSampleRate(sampleRate), mChannels(channels) { + if (socketPath == nullptr || sampleRate <= 0 || channels <= 0) return; + + std::string path(socketPath); + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) return; + mDirectory = path.substr(0, slash); + + const std::string marker = joinPath(mDirectory, kEnableMarker); + if (access(marker.c_str(), F_OK) != 0) return; + + // The marker is one-shot. The hello-world app recreates it for each clean + // launch; ordinary users of the AAR never pay the capture cost. + unlink(marker.c_str()); + unlink(joinPath(mDirectory, kAmyWave).c_str()); + unlink(joinPath(mDirectory, kOboeWave).c_str()); + unlink(joinPath(mDirectory, kStatsFile).c_str()); + + mTargetFrames = sampleRate * kCaptureSeconds; + const size_t sampleCount = static_cast(mTargetFrames) * channels; + try { + mAmySamples.resize(sampleCount); + mOboeSamples.resize(sampleCount); + } catch (...) { + LOGE("Unable to allocate Android audio capture buffers"); + mAmySamples.clear(); + mOboeSamples.clear(); + return; + } + + mEnabled = true; + mWriter = std::thread(&AmyAndroidAudioCapture::writerLoop, this); + LOGI("Audio capture armed: %d frames, %d Hz, %d channels", + mTargetFrames, mSampleRate, mChannels); +} + +AmyAndroidAudioCapture::~AmyAndroidAudioCapture() { + stop(); +} + +void AmyAndroidAudioCapture::beginCallback(int32_t numFrames) { + if (!mEnabled || mWriterReady.load(std::memory_order_acquire) || numFrames <= 0) { + mCallbackFrames = 0; + return; + } + + const int32_t remaining = mTargetFrames - mFramesCaptured; + mCallbackStartFrame = mFramesCaptured; + mCallbackFrames = std::min(numFrames, std::max(remaining, 0)); +} + +void AmyAndroidAudioCapture::captureAmyChunk( + const int16_t *samples, int32_t frames, int32_t outputFrame) { + if (!mEnabled || samples == nullptr || frames <= 0 || mCallbackFrames <= 0) return; + if (outputFrame < 0 || outputFrame >= mCallbackFrames) return; + + const int32_t copyFrames = std::min(frames, mCallbackFrames - outputFrame); + const size_t destinationSample = + static_cast(mCallbackStartFrame + outputFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mAmySamples.data() + destinationSample, + samples, + sampleCount * sizeof(int16_t)); +} + +void AmyAndroidAudioCapture::finishCallback( + const int16_t *oboeOutput, int32_t numFrames) { + if (!mEnabled || oboeOutput == nullptr || numFrames <= 0 || mCallbackFrames <= 0) return; + + const int32_t copyFrames = std::min(numFrames, mCallbackFrames); + const size_t destinationSample = static_cast(mCallbackStartFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mOboeSamples.data() + destinationSample, + oboeOutput, + sampleCount * sizeof(int16_t)); + + mFramesCaptured += copyFrames; + mCallbackFrames = 0; + + if (mFramesCaptured >= mTargetFrames) { + mWriterReady.store(true, std::memory_order_release); + mWriterCv.notify_one(); + } +} + +void AmyAndroidAudioCapture::stop() { + if (!mEnabled || mStopped) return; + mStopped = true; + + { + std::lock_guard lock(mWriterMutex); + if (mFramesCaptured > 0) { + mWriterReady.store(true, std::memory_order_release); + } + mWriterStop = true; + } + mWriterCv.notify_one(); + if (mWriter.joinable()) mWriter.join(); +} + +void AmyAndroidAudioCapture::writerLoop() { + std::unique_lock lock(mWriterMutex); + mWriterCv.wait(lock, [this] { + return mWriterReady.load(std::memory_order_acquire) || mWriterStop; + }); + const bool shouldWrite = + mWriterReady.load(std::memory_order_acquire) && mFramesCaptured > 0; + lock.unlock(); + + if (shouldWrite) writeCaptureFiles(); +} + +void AmyAndroidAudioCapture::writeCaptureFiles() { + const int32_t frames = std::min(mFramesCaptured, mTargetFrames); + const int32_t sampleCount = frames * mChannels; + if (frames <= 0 || sampleCount <= 0) return; + + const std::string amyPath = joinPath(mDirectory, kAmyWave); + const std::string oboePath = joinPath(mDirectory, kOboeWave); + const std::string statsPath = joinPath(mDirectory, kStatsFile); + + const bool amyOk = writeWave(amyPath, mAmySamples, frames, mSampleRate, mChannels); + const bool oboeOk = writeWave(oboePath, mOboeSamples, frames, mSampleRate, mChannels); + + const LevelStats amy = levelStats(mAmySamples, sampleCount); + const LevelStats oboe = levelStats(mOboeSamples, sampleCount); + + int32_t maxAbsDiff = 0; + int32_t mismatchSamples = 0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t a = mAmySamples[static_cast(i)]; + const int32_t b = mOboeSamples[static_cast(i)]; + const int32_t difference = std::abs(a - b); + maxAbsDiff = std::max(maxAbsDiff, difference); + if (difference != 0) ++mismatchSamples; + } + + FILE *stats = std::fopen(statsPath.c_str(), "w"); + if (stats != nullptr) { + std::fprintf(stats, "sample_rate=%d\n", mSampleRate); + std::fprintf(stats, "channels=%d\n", mChannels); + std::fprintf(stats, "frames=%d\n", frames); + std::fprintf(stats, "amy_peak=%d\n", amy.peak); + std::fprintf(stats, "amy_peak_dbfs=%.3f\n", amy.peakDbfs); + std::fprintf(stats, "amy_rms=%.3f\n", amy.rms); + std::fprintf(stats, "amy_rms_dbfs=%.3f\n", amy.rmsDbfs); + std::fprintf(stats, "oboe_peak=%d\n", oboe.peak); + std::fprintf(stats, "oboe_peak_dbfs=%.3f\n", oboe.peakDbfs); + std::fprintf(stats, "oboe_rms=%.3f\n", oboe.rms); + std::fprintf(stats, "oboe_rms_dbfs=%.3f\n", oboe.rmsDbfs); + std::fprintf(stats, "max_abs_diff=%d\n", maxAbsDiff); + std::fprintf(stats, "mismatch_samples=%d\n", mismatchSamples); + std::fclose(stats); + } + + if (!amyOk || !oboeOk || stats == nullptr) { + LOGE("Audio capture write failed: amy=%d oboe=%d stats=%d", + amyOk, oboeOk, stats != nullptr); + return; + } + + LOGI("Audio capture complete: frames=%d AMY peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); Oboe peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); mismatches=%d maxdiff=%d", + frames, + amy.peak, amy.peakDbfs, amy.rms, amy.rmsDbfs, + oboe.peak, oboe.peakDbfs, oboe.rms, oboe.rmsDbfs, + mismatchSamples, maxAbsDiff); +} diff --git a/android/amy-service/src/main/cpp/amy_android_capture.h b/android/amy-service/src/main/cpp/amy_android_capture.h new file mode 100644 index 00000000..e77f00c0 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class AmyAndroidAudioCapture { +public: + AmyAndroidAudioCapture(const char *socketPath, int32_t sampleRate, int32_t channels); + ~AmyAndroidAudioCapture(); + + bool enabled() const { return mEnabled; } + + // Called only from Oboe's realtime callback. These methods allocate no + // memory, perform no file I/O, and never take the writer mutex. + void beginCallback(int32_t numFrames); + void captureAmyChunk(const int16_t *samples, int32_t frames, int32_t outputFrame); + void finishCallback(const int16_t *oboeOutput, int32_t numFrames); + + // Called after the Oboe stream has stopped. A partial capture is still + // written, which makes diagnostics useful even on early shutdown/error. + void stop(); + +private: + void writerLoop(); + void writeCaptureFiles(); + + bool mEnabled = false; + bool mStopped = false; + int32_t mSampleRate = 0; + int32_t mChannels = 0; + int32_t mTargetFrames = 0; + int32_t mFramesCaptured = 0; + int32_t mCallbackStartFrame = 0; + int32_t mCallbackFrames = 0; + + std::string mDirectory; + std::vector mAmySamples; + std::vector mOboeSamples; + + std::mutex mWriterMutex; + std::condition_variable mWriterCv; + std::atomic mWriterReady{false}; + bool mWriterStop = false; + std::thread mWriter; +}; diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c new file mode 100644 index 00000000..a6108f35 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.c @@ -0,0 +1,11 @@ +#include "amy_android_daisy_alloc.h" + +#include + +void *qspi_malloc(size_t size) { + return malloc(size); +} + +void qspi_free(void *ptr) { + free(ptr); +} diff --git a/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h new file mode 100644 index 00000000..7237295e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_daisy_alloc.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void *qspi_malloc(size_t size); +void qspi_free(void *ptr); + +#ifdef __cplusplus +} +#endif diff --git a/android/amy-service/src/main/cpp/amy_android_profile.cpp b/android/amy-service/src/main/cpp/amy_android_profile.cpp new file mode 100644 index 00000000..ba37373e --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_profile.cpp @@ -0,0 +1,10 @@ +extern "C" { +#include "amy.h" +} + +static_assert(AMY_SAMPLE_RATE == 48000, + "Android AMY service requires a 48 kHz AMY build"); +static_assert(AMY_BLOCK_SIZE == 128, + "Android AMY service requires 128-frame AMY blocks"); +static_assert(AMY_NCHANS == 2, + "Android AMY service expects stereo AMY output"); diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java b/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java new file mode 100644 index 00000000..07d1e46a --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyAutoStartProvider.java @@ -0,0 +1,37 @@ +package org.amy.audio; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; + +import java.io.File; + +/** + * Android package lifecycle hook that starts the independent :amy process. + * Client application code never starts or stops AmyService; clients only + * connect to filesDir/amy.sock and exchange AMY wire packets. + */ +public final class AmyAutoStartProvider extends ContentProvider { + @Override + public boolean onCreate() { + Context context = getContext(); + if (context == null) return false; + + File socket = new File(context.getFilesDir(), AmyService.DEFAULT_SOCKET_NAME); + Intent intent = new Intent(context, AmyService.class); + intent.putExtra(AmyService.EXTRA_SOCKET_PATH, socket.getAbsolutePath()); + context.startService(intent); + return true; + } + + @Override public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { return null; } + @Override public String getType(Uri uri) { return null; } + @Override public Uri insert(Uri uri, ContentValues values) { return null; } + @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; } + @Override public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { return 0; } +} diff --git a/android/amy-service/src/main/java/org/amy/audio/AmyService.java b/android/amy-service/src/main/java/org/amy/audio/AmyService.java new file mode 100644 index 00000000..98ff90e7 --- /dev/null +++ b/android/amy-service/src/main/java/org/amy/audio/AmyService.java @@ -0,0 +1,172 @@ +package org.amy.audio; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.IBinder; +import android.util.Log; + +import java.io.File; +import java.io.IOException; + +/** + * Unexported same-UID service hosting native AMY + Oboe in a separate process. + * + * Musical control never crosses JNI. The host opens the private pathname Unix + * SOCK_SEQPACKET socket and sends one AMY wire message per packet. JNI is only + * used to start/stop the native engine and report its actual Oboe output device. + */ +public final class AmyService extends Service { + private static final String TAG = "AmyService"; + + public static final String EXTRA_SOCKET_PATH = "org.amy.audio.extra.SOCKET_PATH"; + public static final String DEFAULT_SOCKET_NAME = "amy.sock"; + + static { + System.loadLibrary("amy_android"); + } + + private boolean running; + private String runningSocketPath; + + private static native int nativeStart(String socketPath); + private static native int nativeGetOutputDeviceId(); + private static native void nativeStop(); + + /** Start the private AMY process using filesDir/amy.sock. */ + public static void start(Context context) { + File socket = new File(context.getFilesDir(), DEFAULT_SOCKET_NAME); + Intent intent = new Intent(context, AmyService.class); + intent.putExtra(EXTRA_SOCKET_PATH, socket.getAbsolutePath()); + context.startService(intent); + } + + /** Stop the private AMY process. */ + public static void stop(Context context) { + context.stopService(new Intent(context, AmyService.class)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + + String requested = intent.getStringExtra(EXTRA_SOCKET_PATH); + if (requested == null) { + requested = new File(getFilesDir(), DEFAULT_SOCKET_NAME).getAbsolutePath(); + } + + final String socketPath; + try { + socketPath = validatePrivateSocketPath(requested); + } catch (IOException | SecurityException ex) { + Log.e(TAG, "Refusing AMY socket path", ex); + stopSelf(startId); + return START_NOT_STICKY; + } + + // Starting the same service again is normal Android lifecycle behavior. + // Do not tear down an active audio engine and disconnect its socket + // client merely because another equivalent startService() arrived. + if (running && socketPath.equals(runningSocketPath)) { + Log.i(TAG, "AMY already running on private socket " + socketPath); + return START_NOT_STICKY; + } + + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + + int result = nativeStart(socketPath); + if (result != 0) { + Log.e(TAG, "nativeStart failed: " + result); + stopSelf(startId); + return START_NOT_STICKY; + } + + running = true; + runningSocketPath = socketPath; + Log.i(TAG, "AMY listening on private socket " + socketPath); + logOutputRoute(nativeGetOutputDeviceId()); + return START_NOT_STICKY; + } + + private void logOutputRoute(int deviceId) { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + " (AudioManager unavailable)"); + return; + } + + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getId() == deviceId) { + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=" + audioDeviceTypeName(device.getType()) + + " product=" + String.valueOf(device.getProductName())); + return; + } + } + + Log.i(TAG, "AMY output route: deviceId=" + deviceId + + " type=UNRESOLVED_DEFAULT_OR_DEVICE"); + } + + private static String audioDeviceTypeName(int type) { + switch (type) { + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + return "BUILTIN_EARPIECE"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + return "BUILTIN_SPEAKER"; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + return "WIRED_HEADSET"; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return "WIRED_HEADPHONES"; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + return "BLUETOOTH_SCO"; + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + return "BLUETOOTH_A2DP"; + case AudioDeviceInfo.TYPE_HDMI: + return "HDMI"; + case AudioDeviceInfo.TYPE_USB_DEVICE: + return "USB_DEVICE"; + case AudioDeviceInfo.TYPE_USB_ACCESSORY: + return "USB_ACCESSORY"; + default: + return "TYPE_" + type; + } + } + + private String validatePrivateSocketPath(String requested) throws IOException { + File files = getFilesDir().getCanonicalFile(); + File socket = new File(requested).getCanonicalFile(); + File parent = socket.getParentFile(); + if (parent == null || !parent.equals(files)) { + throw new SecurityException("AMY socket must be directly inside app filesDir"); + } + if (!DEFAULT_SOCKET_NAME.equals(socket.getName())) { + throw new SecurityException("AMY socket filename must be " + DEFAULT_SOCKET_NAME); + } + return socket.getAbsolutePath(); + } + + @Override + public void onDestroy() { + if (running) { + nativeStop(); + running = false; + runningSocketPath = null; + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..a2cc8b72 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("com.android.library") version "8.13.2" apply false +} diff --git a/android/hello-world/README.md b/android/hello-world/README.md new file mode 100644 index 00000000..8a280813 --- /dev/null +++ b/android/hello-world/README.md @@ -0,0 +1,65 @@ +# AMY Android Hello World + +Minimal Android application proving the generic AMY Android service end to end while keeping the client completely transport-only. + +The hello-world application code does **not** import `AmyService`, does not call a start/stop API, does not load an AMY/JNI client library, and does not compile any AMY source. The `amy-service` AAR is packaged in the APK; its private Android lifecycle provider starts the separate `:amy` process. `MainActivity` only opens the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket and sends ordinary AMY wire messages. + +On launch the client: + +1. retries a pure-Java `android.net.LocalSocket(SOCKET_SEQPACKET)` connection to `/amy.sock` until the independent AMY/Oboe process publishes its ready socket; +2. sends `v0w0V10.0Z` to configure raw oscillator 0 as a sine wave at full AMY master gain; +3. waits 30 ms so setup is committed on a fresh AMY instance before the first note-on; +4. sends wire commands for C4, D4, E4, F4, G4, A4, B4, C5; +5. shows `C scale complete` when all packets have been sent. + +Each Java `OutputStream.write()` is one complete AMY wire request on the `SOCK_SEQPACKET` socket. There is no AMY-specific client API between the application and the wire transport. + +This is the intended framework boundary: + +```text +application/framework code + | + | ordinary AMY wire packets + v +/amy.sock (AF_UNIX / SOCK_SEQPACKET) + | + v +independent Android :amy process -> AMY -> Oboe/AAudio +``` + +The service remains in the same Android application package/UID because the socket is deliberately private (`0600` plus same-UID peer validation). A framework therefore needs only a way to package the Android service AAR and open an Android Unix-domain socket; it does not need AMY headers, AMY source, JNI bindings, or a language-specific AMY API. + +## Wire sequence + +Setup: + +```text +v0w0V10.0Z +``` + +`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. + +Notes use MIDI note numbers and velocity, e.g. middle C: + +```text +v0n60l1Z +v0l0Z +``` + +The complete scale is MIDI notes `60, 62, 64, 65, 67, 69, 71, 72`. + +## Build + +From `android/`: + +```bash +gradle :hello-world:assembleDebug +``` + +APK: + +```text +hello-world/build/outputs/apk/debug/hello-world-debug.apk +``` + +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. CI, not the example app, arms the test-only audio-capture marker before each launch. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The audio-level regression verifies the raw AMY render stream and the exact signed-16-bit callback buffer handed to Oboe are sample-for-sample identical and checks their measured peak/RMS level. diff --git a/android/hello-world/build.gradle.kts b/android/hello-world/build.gradle.kts new file mode 100644 index 00000000..5d5c4bd9 --- /dev/null +++ b/android/hello-world/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "org.amy.hello" + compileSdk = 36 + + defaultConfig { + applicationId = "org.amy.hello" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + // Package the independent :amy Android service in the APK. MainActivity + // has no Java/JNI dependency on AmyService or on AMY itself; it only uses + // the app-private amy.sock wire transport. + implementation(project(":amy-service")) +} diff --git a/android/hello-world/src/main/AndroidManifest.xml b/android/hello-world/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c3f384c --- /dev/null +++ b/android/hello-world/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java new file mode 100644 index 00000000..6b979024 --- /dev/null +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -0,0 +1,150 @@ +package org.amy.hello; + +import android.app.Activity; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; +import android.os.Bundle; +import android.util.Log; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final String TAG = "AmyHelloWorld"; + private static final String SOCKET_NAME = "amy.sock"; + private static final int CONNECT_ATTEMPTS = 100; + private static final long CONNECT_RETRY_MS = 50; + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private static final int[] NOTES = {60, 62, 64, 65, 67, 69, 71, 72}; + + private TextView status; + private Button playButton; + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(48, 48, 48, 48); + + TextView title = new TextView(this); + title.setText("AMY Hello World"); + title.setTextSize(28); + title.setGravity(Gravity.CENTER); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + status = new TextView(this); + status.setText("Connecting to AMY..."); + status.setTextSize(18); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + statusParams.setMargins(0, 40, 0, 40); + root.addView(status, statusParams); + + playButton = new Button(this); + playButton.setText("Play C scale"); + playButton.setOnClickListener(v -> playScale()); + root.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + setContentView(root); + + if (state == null) { + playScale(); + } else { + status.setText("AMY socket client ready"); + } + } + + private void playScale() { + playButton.setEnabled(false); + status.setText("Playing C major scale..."); + String socketPath = new File(getFilesDir(), SOCKET_NAME).getAbsolutePath(); + + EXECUTOR.execute(() -> { + try (LocalSocket socket = connectWithRetry(socketPath)) { + OutputStream output = socket.getOutputStream(); + + // Raw oscillator 0, sine wave, full AMY master gain. + // Every write is one complete AMY wire request and therefore + // one SOCK_SEQPACKET packet. The client does not call AMY or + // control the AMY service lifecycle. + sendWire(output, "v0w0V10.0Z"); + Thread.sleep(30); + + for (int note : NOTES) { + sendWire(output, "v0n" + note + "l1Z"); + Thread.sleep(350); + sendWire(output, "v0l0Z"); + Thread.sleep(80); + } + + Log.i(TAG, "C scale complete"); + setResultText("C scale complete"); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + Log.e(TAG, "C scale failed: interrupted", ex); + setResultText("AMY/socket error: interrupted"); + } catch (IOException ex) { + Log.e(TAG, "C scale failed", ex); + setResultText("AMY/socket error: " + ex.getMessage()); + } + }); + } + + private LocalSocket connectWithRetry(String socketPath) + throws IOException, InterruptedException { + IOException lastError = null; + LocalSocketAddress address = new LocalSocketAddress( + socketPath, LocalSocketAddress.Namespace.FILESYSTEM); + + for (int attempt = 0; attempt < CONNECT_ATTEMPTS; ++attempt) { + LocalSocket socket = new LocalSocket(LocalSocket.SOCKET_SEQPACKET); + try { + socket.connect(address); + Log.i(TAG, "connected to amy.sock"); + return socket; + } catch (IOException ex) { + lastError = ex; + try { + socket.close(); + } catch (IOException ignored) { + } + Thread.sleep(CONNECT_RETRY_MS); + } + } + + throw new IOException("timed out connecting to amy.sock", lastError); + } + + private static void sendWire(OutputStream output, String wire) throws IOException { + byte[] payload = wire.getBytes(StandardCharsets.US_ASCII); + output.write(payload); + output.flush(); + Log.i(TAG, "wire: " + wire); + } + + private void setResultText(String text) { + runOnUiThread(() -> { + if (isDestroyed()) return; + status.setText(text); + playButton.setEnabled(true); + }); + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..d17aab50 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "amy-android" +include(":amy-service") +include(":hello-world") diff --git a/docs/amy.js b/docs/amy.js index c2755adf..707ee2db 100644 --- a/docs/amy.js +++ b/docs/amy.js @@ -440,7 +440,6 @@ function amy_send(params, log) { // Constants from amy/constants.py (mirrors amy.SINE, amy.FILTER_LPF, etc.) var AMY = { MAX_FILENAME_LEN: 127, - AMY_BLOCK_SIZE: 256, BLOCK_SIZE_BITS: 8, AMY_SAMPLE_RATE: 44100, PCM_AMY_SAMPLE_RATE: 22050, @@ -595,7 +594,8 @@ var AMY = { AMYBOARD_MIDI_IN: 21, AMY_AUDIO_DEVICE_OUT: 0, AMY_AUDIO_DEVICE_IN: 1, - AMY_NUM_MIDI_CHANNELS: 16 + AMY_NUM_MIDI_CHANNELS: 16, + AMY_BLOCK_SIZE: 256 }; if (typeof globalThis !== "undefined") { diff --git a/docs/android_unix_socket.md b/docs/android_unix_socket.md new file mode 100644 index 00000000..0a0de556 --- /dev/null +++ b/docs/android_unix_socket.md @@ -0,0 +1,118 @@ +# Android private `amy.sock` transport + +`src/amy_unix_socket.c` provides a small Linux/Android pathname `AF_UNIX` +transport intended for a stand-alone AMY + Oboe Android process. + +The Android application should choose a pathname below its private internal +storage directory, for example conceptually: + +``` +/data/user/0//files/amy.sock +``` + +Do not hard-code that example path. Obtain the application's actual internal +files directory from Android and pass the resulting full pathname to the native +AMY process/service. + +## Security properties + +The server: + +- uses `AF_UNIX` + `SOCK_SEQPACKET` rather than TCP/UDP; +- creates the socket pathname mode `0600`; +- on Linux/Android accepts only peers whose `SO_PEERCRED` UID equals the + server's effective UID; +- removes a stale socket only when it is a socket owned by the same UID; +- never removes an existing regular file or foreign-owned socket; +- supports one connected client at a time. + +The Android private app-data parent directory remains the primary sandbox +boundary. Socket mode and peer credentials are defense in depth. + +## Realtime ownership + +The socket receiver thread never calls AMY. Each received `SOCK_SEQPACKET` +message is copied into a fixed 64-entry SPSC queue. There is no allocation in +the dequeue path. + +The AMY/Oboe owner should drain the queue at a safe block boundary: + +```c +#include "amy.h" +#include "amy_unix_socket.h" + +static amy_unix_socket_server_t *amy_socket; + +void process_amy_socket(void) { + char message[MAX_MESSAGE_LEN]; + for (;;) { + int len = amy_unix_socket_receive( + amy_socket, message, sizeof(message)); + if (len <= 0) break; + amy_add_message(message); + } +} +``` + +For an Oboe backend, call `process_amy_socket()` immediately before producing a +new AMY render block, not from the socket thread. + +A packet payload may omit a terminating NUL; the dequeue API adds one. Keep a +single AMY wire command or other logical request in each packet. Maximum packet +payload is `MAX_MESSAGE_LEN - 1` bytes. + +## Bidirectional replies + +`amy_unix_socket_send()` sends one `SOCK_SEQPACKET` reply to the current +client. It is non-blocking and intended for control/status/introspection paths, +not for the realtime audio callback. + +This means the compact introspection protocol can later use the same connection: + +``` +Qt -> AMY ?iv +AMY -> Qt !iv1 +``` + +The socket transport itself intentionally does not depend on the introspection +implementation, so the two branches can be reviewed and merged independently. + +## Starting and stopping + +```c +amy_unix_socket_server_t *server = NULL; +int rc = amy_unix_socket_start(&server, socket_path); +if (rc < 0) { + // rc is -errno +} + +// ... run AMY/Oboe ... + +amy_unix_socket_stop(server); +``` + +Stopping joins the receiver thread and removes the socket pathname. + +## Diagnostics + +These counters can be queried from a non-realtime diagnostics path: + +- `amy_unix_socket_queue_overruns()` +- `amy_unix_socket_oversize_packets()` +- `amy_unix_socket_rejected_peers()` + +A queue overrun means the AMY/control owner is not draining packets quickly +enough. The transport drops the new packet rather than blocking the receiver or +allocating more memory. + +## Host regression test + +On Linux: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +The test verifies round-trip packet transport, socket mode/ownership, +non-consuming `EMSGSIZE` behavior, oversized-packet rejection, pathname cleanup, +and refusal to delete a pre-existing regular file. diff --git a/docs/api.md b/docs/api.md index 0a19e45f..4f0398e4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,7 +203,16 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | +| `max_reverb_rooms` | Int | 0 | Number of optional shared aux returns. The historical name is retained for source compatibility. Zero preserves the historical per-bus reverb path. | +| `reverb_room_memory` | `void **` | `NULL` | Optional array of one caller-owned arena per aux return. A null entry uses AMY's configured heaps. This lets embedded hosts keep each built-in reverb in a dedicated SRAM bank. | +| `reverb_room_memory_bytes` | bytes | 0 | Size of every supplied return arena. A 128 KiB arena holds the current stereo reverb network and its block workspace. An external return only uses the block workspace. | +| `reverb_diagnostics` | `0=off, 1=on` | Off | Store per-return and total-stage timing counters for later retrieval. Nothing is printed in the realtime path. | +| `aux_return_external` | `uint8_t *` | `NULL` | Optional `max_reverb_rooms`-element selector. A nonzero entry replaces that return's built-in reverb with the host callback below. | +| `amy_external_aux_return_process_hook` | fn ptr | `NULL` | Realtime host callback that replaces selected return blocks in place. It must not block, allocate or perform I/O. | +| `amy_external_aux_return_user_data` | pointer | `NULL` | Opaque host value passed to the external aux-return callback. | +| `max_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | +| `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | +| `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `max_voices` | Int | 64 | How many voices | | `max_synths` | Int | 64 | How many synths | | `max_memory_patches` | Int | 32 | How many in memory patches to supprot | @@ -478,10 +487,55 @@ Default AMY has 4 buses, 0..3. Set `max_buses` in `amy_config_t` before `amy_st | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `h` | `reverb_level, reverb_liveness, reverb_damping, reverb_xover_hz` | `reverb` | float[,float,float,float] | Reverb parameters -- level, liveness, damping, xover: Level is for output mix; +| `hR` | `reverb_room, reverb_room_level, reverb_room_liveness, reverb_room_damping, reverb_room_xover_hz` | `reverb_room` | int,float[,float,float,float] | Configure a built-in shared reverb: return index, level, liveness, damping and crossover. Shared returns must first be enabled with `max_reverb_rooms`. | +| `hS` | `reverb_send_room, reverb_send_level` | `reverb_send` | int,float | Route the selected bus to a shared aux return with a weighted post-fader send. A send of zero excludes the bus while retaining its return selection. | | `k` | `chorus_level, chorus_max_delay, chorus_lfo_freq, chorus_depth` | `chorus` | float[,float,float,float] | Chorus parameters -- level, delay, freq, depth: Level is for output mix (0 to turn off); delay is max in samples (320); freq is LFO rate in Hz (0.5); depth is proportion of max delay (0.5). | | `M` | `echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef` | `echo` | float[,int,int,float,float] | Echo parameters -- level, delay_ms, max_delay_ms, feedback, filter_coef (-1 is HPF, 0 is flat, +1 is LPF). | | `x` | `eq_l, eq_m, eq_h` |`eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Hz) -15 to 15. 0 is off. default 0. | +#### Shared aux returns + +Per-bus `reverb`/`h` remains the default and is unchanged. A host that needs +many buses but only a few end-effect instances can instead enable shared +returns in `amy_config_t`. By default each return owns one reverb delay +network; any number of buses can feed it: + +```python +amy.send(reverb_room=[0, 0.6, 0.85, 0.5, 3000]) +amy.send(bus=0, reverb_send=[0, 1.0]) +amy.send(bus=1, reverb_send=[0, 0.35]) +amy.send(bus=2, reverb_send=[0, 0.0]) # dry bus; room selection retained +``` + +The equivalent wire messages are `hR0,0.6,0.85,0.5,3000Z`, +`y0hS0,1Z`, `y1hS0,0.35Z`, and `y2hS0,0Z`. Sends are post-fader: changing a +bus volume changes both its dry signal and what it contributes to the room. +The return is added once to the final mix, so buses sharing a built-in reverb +also share its tail and room parameters. + +The routing is deliberately an aux-send/return abstraction, not a requirement +that every return be a room simulation. A C host can mark an entry in +`aux_return_external` and process that return's accumulated block in place with +`amy_external_aux_return_process_hook`. That permits a lighter reverb or a +different end effect without changing AMY's bus summation. External returns +do not allocate an AMY reverb network; their parameters and wet level belong +to the host callback. `hS` still controls each bus's weighted send. `hR` only +configures built-in AMY reverbs. + +`AMY_MAX_REVERBS` is an optional compile-time ceiling for memory-intensive +built-in reverb networks. It counts both shared built-in returns and legacy +per-bus reverbs; external returns do not count. Its default (`UINT16_MAX`) +places no practical restriction on desktop hosts. A constrained target can, +for example, compile with `-DAMY_MAX_REVERBS=2` while retaining any number of +external returns supported by its runtime configuration. + +On ESP with multicore rendering, returns 0 and 1 are processed concurrently on +the existing two pinned audio/render tasks. Additional returns are processed +serially. `amy_reverb_diagnostics_get()` and +`amy_reverb_stage_diagnostics_get()` take lock-free snapshots of counters +collected by those tasks; `amy_reverb_diagnostics_print()` is intended to be +called later from a low-priority control task, never from the audio callback. + Distortion (`GC`/`GF`/`GH`/`GD`/`GM`) runs per bus too, first in the bus FX chain -- before EQ, chorus, echo and reverb. It has no bus-specific commands: the `G` commands above address a bus whenever the event that carries them names no oscillator. #### Distortion scope @@ -503,7 +557,9 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | +| `HC` | — | `sequence_control` | tag,action[,alignment] or tag,gate,duration[,alignment] | Stop (`action=0`), start (`action=1`), align, or temporarily gate (`action=2`) a reusable tagged sequence. Actions are integers, not velocity or fractional values. Python callers use the named `action='stop'`, `'start'`, or `'gate'`; gate also requires `duration`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/billie_jean.md b/docs/billie_jean.md index 56a38cad..3ec4ea56 100644 --- a/docs/billie_jean.md +++ b/docs/billie_jean.md @@ -293,7 +293,7 @@ timed_note chord_notes[] = { }; ``` -We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns - `e.ticks[1]` is a repeat period and `e.ticks[2]` a tag you can use to replace or cancel an entry - but here we only need the one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. +We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns with `e.ticks[1]`, while `e.ticks[2]` adds the event to a reusable tagged sequence; here we only need the untagged one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. ```C float amy_ticks_per_tick = 24.0f; diff --git a/docs/godot.md b/docs/godot.md index 3918c4f7..8efeca6e 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -35,6 +35,10 @@ git clone --branch godot-4.4-stable https://github.com/godotengine/godot-cpp.git The script builds the native GDExtension library and copies everything into `your_project/addons/amy/`. +See the [Windows `M_PI` portability note](windows-m-pi-portability.md) for the +MSVC build failure introduced by the PCM time-stretch Hann window, why older +Windows builds were unaffected, and how the guarded fallback was validated. + If you want to point to a `godot-cpp` checkout in a different location: ```bash @@ -52,8 +56,12 @@ var amy: Amy func _ready(): amy = Amy.new() + amy.backend_ready.connect(_on_amy_ready) + amy.backend_error.connect(_on_amy_error) add_child(amy) - await get_tree().process_frame # let AMY initialize + +func _on_amy_ready(): + # The selected backend can now accept messages. # Play a 440 Hz sine wave amy.send({"osc": 0, "wave": Amy.SINE, "freq": 440, "vel": 1.0}) @@ -69,8 +77,16 @@ func _ready(): # Or use wire protocol directly amy.send_raw("v3w0f880l0.5") + +func _on_amy_error(message: String): + push_error(message) ``` +Connect the signals before `add_child(amy)`: the native backend can become +ready synchronously during `_ready()`. `backend_ready` is emitted once the +native or web backend accepts messages. `backend_error(message)` reports a +missing native extension or a web-backend startup timeout. + ### 4. Configure AMY (optional) Set [config properties](api.md) on the `Amy` node **before** adding it to the tree: @@ -136,6 +152,16 @@ Or run locally: `python3 -m http.server` from your `dist` folder and go to `loc - **Web:** AMY runs as its own WASM module with Web Audio API AudioWorklets. The `Amy` GDScript class detects `OS.get_name() == "Web"` and sends wire messages via `JavaScriptBridge` instead of the native extension. +### Android reference implementation + +Android is not built or maintained in this repository. A complete external +[Godot Android service integration](https://github.com/linuxificator/amy/tree/upstream/godot-android) +demonstrates the same `Amy` Dictionary-to-wire API with AMY running in a +separate Oboe service process. The lower-level +[Android Oboe reference](https://github.com/linuxificator/amy/tree/upstream/android-oboe) +contains the service and private Unix-socket transport. See +[porting notes](porting.md) for the reusable boundary and verified build flags. + ## API Reference @@ -180,6 +206,11 @@ Send a raw AMY wire-protocol message (e.g. `"v0w0f440l1"`). Stop all sound immediately. +### Signals + +- `backend_ready`: the selected backend is ready to accept AMY messages. +- `backend_error(message)`: backend initialization failed. + ### Constants **Wave types:** `Amy.SINE`, `Amy.PULSE`, `Amy.SAW_DOWN`, `Amy.SAW_UP`, `Amy.TRIANGLE`, `Amy.NOISE`, `Amy.KS`, `Amy.PCM`, `Amy.ALGO`, `Amy.PARTIAL`, `Amy.WAVETABLE`, `Amy.CUSTOM`, `Amy.WAVE_OFF` diff --git a/docs/lb_omnichord_release_contract.md b/docs/lb_omnichord_release_contract.md new file mode 100644 index 00000000..444cb8a9 --- /dev/null +++ b/docs/lb_omnichord_release_contract.md @@ -0,0 +1,105 @@ +# LB Omnichord AMY release contract + +LB Omnichord consumes AMY from a fork release branch named +`releases/amy_omnichord_RT`. The consumer records both the +branch and exact commit SHA. The branch explains provenance; the SHA is the +immutable build input used by every platform package. + +The fork's `main` remains a fast-forward mirror of `shorepine/amy` `main`. +Generic changes are developed on a clean upstream-directed branch. A release +branch starts from that clean work and layers only the tested platform and +application profile on top; it is never itself offered upstream. + +## Current line + +`releases/amy_omnichord_R20260905T133309` starts from fork branch +`rework/sequencer` at `3872b4be16af4f486c8f3259d44478ee7174864f`, the +source offered in Shorepine PR 1151. That source in turn starts from Shorepine +main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`. + +The release layers on: + +- the private Unix-socket service and Android Oboe integration; +- socket receiver backpressure protection; +- the Gamma9001 hosted drum-bank profile; +- deterministic offline CPython startup for tests; +- ignored-note-off bookkeeping suitable for indefinitely running one-shot + percussion synths; +- 336 oscillators, 11 buses, and two shared aux returns; and +- 1,280 sequence tags, 64 events per definition and 40 active or + alignment-pending executions. + +The abandoned bus-mixer experiment is not part of this line. The 11-bus +setting only enlarges AMY's existing generic bus capacity; it introduces no +private mixer, routing API or musical policy. + +## Sequence boundary + +The clean `rework/sequencer` branch contains only generic AMY behavior: + +- untagged one- and two-field `ticks` retain direct scheduling; +- a tagged `ticks=(tick, period, tag)` event cumulatively extends a stopped, + reusable sequence definition; +- `sequence_reset` clears a future definition; +- `sequence_control` starts, stops or gates executions, with optional + alignment on AMY's own clock; +- finite executions may overlap and each execution retains its immutable + definition snapshot; +- publication and deferred reclamation keep clone/free work out of the render + path; and +- a sequence may start another sequence, while bounded execution capacity + prevents cyclic graphs from recursing without limit. + +LB Omnichord owns all musical policy: instrument roles, fills, arpeggios, +sequence/tag allocation and replacement boundaries. The frontend remains a +wire-protocol client and never imports or calls AMY engine internals. + +The high tag capacity stores the complete rhythm catalogue. It does not create +1,280 players: definitions and executions allocate from separate bounded +resources, and only authored definitions consume event storage. + +## Platform boundary + +On Android, the Qt frontend and the unexported `:amy` service are separate +processes under the same application UID. The frontend discovers the +application-private socket path and sends only AMY wire messages. Audio is +rendered by the service and handed to Oboe/AAudio. The service is built at +48 kHz with 128-frame stereo blocks. + +Desktop Linux and macOS use the same frontend wire protocol over a private +Unix socket. Windows uses its wrapper/named-pipe transport. The AMY command +stream and frontend synthesis logic stay platform-independent. + +The Android AAR defines `GAMMA9001` and generates its linkable sample blob in +a private per-ABI build directory. Native downstream builds use the same +`gamma9001-blob-c` generator and link its output while defining `GAMMA9001`. +Consequently PCM presets 0-18 mean the Gamma808 ROM and presets 256-391 use +the Gamma9001 sample set on all hosted release targets. + +The CPython `AMY_PCM_BANK` selector is release/build policy rather than +generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the hosted +Omnichord profile selects Gamma9001. Both choices force a fresh extension +build because they share an output filename. + +`amy.live(audio=False, ...)` is the deterministic host-test mode. The default +remains live miniaudio, preserving existing callers. Offline mode prevents a +system-audio callback and a deterministic renderer from consuming the same +AMY stream concurrently. + +The release also keeps compile-time embedded audio geometry configurable, +including the already characterized 48 kHz / 128-sample ESP32-P4 frame size. +Physical ESP32-P4 timing, heap and DMA validation remains a separate hardware +gate and is not implied by hosted tests. + +## Release procedure + +1. Verify fork main exactly matches the chosen Shorepine main. +2. Test generic work on the clean upstream-directed branch. +3. Start a new immutable release branch at that exact generic commit. +4. Add only required fork integrations in diagnostic commits. +5. Run native AMY, wire/socket, PCM-bank, offline and Android contract tests. +6. Pin the final release branch and SHA once in LB Omnichord's release-input + manifest and update its human-readable platform documents. +7. Reinstall that exact AMY SHA and run LB Omnichord's generic and + platform-specific suites. +8. Record the exact AMY SHA in release notes. diff --git a/docs/midi.md b/docs/midi.md index 6ef8988e..96f0f6e3 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -81,9 +81,9 @@ Because an `AMY_MIDI` osc emits MIDI in response to ordinary note events, you ca amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender once # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. -amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each 48-tick period -amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each 48-tick period +amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 +amy.send(osc=0, note=60, vel=0, ticks="24,48,1") +amy.send(sequence=1, action='start', alignment_period=48) ``` -AMY keeps sending those MIDI messages out the port at the configured tempo until you remove them (by their `tag`) or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. - +AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/porting.md b/docs/porting.md new file mode 100644 index 00000000..5d121263 --- /dev/null +++ b/docs/porting.md @@ -0,0 +1,183 @@ +# Porting AMY and local-service transports + +AMY's C engine can run inside an application or in a separate local process. +The second form is useful when a framework or language should remain a +wire-protocol client and a small native service should own AMY and the audio +device. + +This page records portable pieces and verified porting results. The complete +Android, Godot Android, and Windows applications linked below are external +reference implementations; their platform-specific build trees are not part +of the core AMY repository. + +## Embedding boundary + +A native host normally: + +1. creates an `amy_config_t` with `amy_default_config()`; +2. selects the host's audio and MIDI ownership before calling `amy_start()`; +3. delivers complete AMY wire messages through `amy_add_message()` at a safe + control or render boundary; +4. obtains audio with `amy_simple_fill_buffer()` when the host owns rendering; +5. calls `amy_stop()` during shutdown. + +Keep blocking IPC away from the realtime audio callback. If a receiver thread +accepts commands, move them through a bounded queue and let the AMY/audio owner +drain that queue between render blocks. + +An embedded build can select its audio geometry without modifying AMY sources: + +```text +-DAMY_BLOCK_SIZE=128 -DAMY_SAMPLE_RATE=48000 +``` + +`BLOCK_SIZE_BITS` is derived for supported power-of-two block sizes (64, 128, +256, or 512), and a mismatched explicit value is rejected at compile time. On +ESP-IDF, the generic I2S adapter additionally accepts +`AMY_ESP_I2S_PHILIPS_FORMAT`, `AMY_ESP_I2S_DMA_DESC_NUM`, and +`AMY_ESP_I2S_DMA_FRAME_NUM`. If none of these definitions is supplied, AMY's +existing block, sample-rate, I2S-format, and DMA defaults are unchanged. + +## Linux/Android packet transport + +`src/amy_unix_socket.[ch]` implements a local pathname `AF_UNIX` / +`SOCK_SEQPACKET` server for Linux and Android. It is transport-only: its thread +does not call AMY. + +The server provides: + +- one logical request per packet, up to `MAX_MESSAGE_LEN - 1` bytes; +- a fixed 64-packet single-producer/single-consumer queue; +- one connected client at a time; +- pathname mode `0600` and same-effective-UID peer checks with `SO_PEERCRED`; +- refusal to replace a live listener or remove a non-socket/reused pathname; +- non-blocking dequeue and reply calls; +- counters for queue overruns, oversized packets, and rejected clients. + +The render owner can drain commands immediately before a new AMY block: + +```c +char message[MAX_MESSAGE_LEN]; +for (;;) { + int length = amy_unix_socket_receive(server, message, sizeof(message)); + if (length <= 0) break; + amy_add_message(message); +} +``` + +`amy_unix_socket_send()` supports replies from a non-realtime control/status +path. It must not be called from the audio callback. + +Run the AddressSanitizer/UndefinedBehaviorSanitizer host regression on Linux: + +```bash +bash tests/run_amy_unix_socket_test.sh +``` + +The test covers maximum and oversized packets, non-consuming `EMSGSIZE`, queue +ordering/overrun behavior, connection replacement/rejection, reconnects, +permissions, active/stale paths, and safe shutdown cleanup. + +On unsupported platforms these functions return `-ENOTSUP`. A stream or +platform-native IPC adapter can preserve the same higher-level rule: one +complete AMY wire request is delivered to the AMY owner at a safe boundary. + +## Verified Android NDK recipe + +The external [Android Oboe service reference][android-oboe] demonstrates that +the AMY core compiles for Android NDK without changes to its synthesis sources. +That build uses: + +```text +AMY_DAISY=1 +AMY_HOST_MIDI=1 +AMY_NO_MINIAUDIO=1 +AMY_WAVETABLE=1 +``` + +`AMY_DAISY` selects AMY's existing 48 kHz / 128-frame profile. +`AMY_NO_MINIAUDIO` lets Oboe own audio, and `AMY_HOST_MIDI` lets the service +supply the MIDI lifecycle hooks. The Oboe callback calls +`amy_simple_fill_buffer()` only when it needs another AMY block and drains the +socket queue before that block. + +One declaration-only compatibility header is force-included in the C +translation units so `pcm.c` sees allocators already supplied by `delay.c` +under `AMY_DAISY`: + +```c +#include + +void *qspi_malloc(size_t size); +void qspi_free(void *ptr); +``` + +Do not link a second allocator implementation. + +The Android audio-level regression also caught an important gain detail: +AMY's `V` bus/master control is a `0..10` scale, and final mixdown multiplies +it by `0.1`. Therefore `V2.0` is 20% linear gain, while `V10.0` is full master +gain. This differs from oscillator velocity/amplitude and per-synth `iV`. + +The reference branch includes the Gradle AAR, private `:amy` service, Oboe +adapter, transport-only Java client, emulator tests, and captured AMY-to-Oboe +audio comparison. Those framework-specific files remain outside the core AMY +tree. + +## Godot lifecycle and Android reference + +The shared `godot/amy.gd` wrapper exposes two platform-independent signals: + +- `backend_ready`, emitted after the selected native or web backend can accept + messages; +- `backend_error(message)`, emitted if backend initialization fails. + +Connect them before adding the `Amy` node to the scene tree, because a native +backend may become ready synchronously: + +```gdscript +var amy := Amy.new() +amy.backend_ready.connect(func(): amy.send({"osc": 0, "note": 60, "vel": 1})) +amy.backend_error.connect(func(message: String): push_error(message)) +add_child(amy) +``` + +The external [Godot Android reference][godot-android] uses the same signals and +Dictionary-to-wire encoder while keeping AMY in the separate Android service. +It contains the AAR packaging example and Android emulator validation. The +Android backend itself is not part of the core Godot addon. + +## Verified Windows named-pipe adapter + +Windows local IPC did not require changes to AMY. The native +[LB Omnichord Windows service][windows-service] compiles the normal AMY C +sources and implements the transport entirely in its host wrapper: + +- `CreateNamedPipeA()` creates one private byte-mode pipe instance with + `PIPE_REJECT_REMOTE_CLIENTS`; +- the [launcher][windows-launcher] supplies a unique per-run pipe name and + publishes readiness only after the pipe and AMY exist; +- the [Qt client][windows-client] uses `QLocalSocket` and writes LF-framed + records because a Windows named pipe is a byte stream rather than a + `SOCK_SEQPACKET` endpoint; +- the service buffers partial/multiple `ReadFile()` results, requires each + completed request to end in `Z`, then calls `amy_add_message()`; +- AMY remains in a separate native service process and owns miniaudio output. + +The [Windows build target][windows-cmake], [packaging regression][windows-test], +and [Windows Server 2025 release test][windows-ci] compile the service, run an +offline `amy_simple_fill_buffer()` self-test, and exercise the packaged +Qt-to-pipe-to-AMY boundary. These hosted tests prove compilation, command +delivery, non-silent offline rendering, and process cleanup; they do not prove +physical audio, MIDI, latency, or dropout behavior. See the [full Windows +design and validation notes][windows-doc] for those limits. + +[android-oboe]: https://github.com/linuxificator/amy/tree/upstream/android-oboe +[godot-android]: https://github.com/linuxificator/amy/tree/upstream/godot-android +[windows-service]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/amy_service.c +[windows-launcher]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/run_windows.ps1 +[windows-client]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/code/amy_transport.py +[windows-cmake]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/packaging/windows/CMakeLists.txt +[windows-test]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/tests/test_packaging.py +[windows-ci]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/.github/workflows/desktop-release.yml +[windows-doc]: https://github.com/linuxificator/LB_Omnichord/blob/387776cffad7394c1fcf6add1ced5d3e69a8d382/amysynth_version/qt_frontend/docs/WINDOWS_NATIVE.md diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md new file mode 100644 index 00000000..dc812fe8 --- /dev/null +++ b/docs/sequencer-sequences-abstractions.md @@ -0,0 +1,150 @@ +# Reusable sequence abstractions and implementation + +## Public abstractions + +### Definition + +A three-value `ticks=(tick, period, tag)` event contributes one ordinary AMY +event to the reusable definition identified by `tag`. Repeating the tag +accumulates events. Ticks in a definition are local to each execution. + +`amy.define_sequence(tag, events)` is a Python replace-as-a-list convenience: +it validates every event, resets the future definition, and then sends the +tagged events. `sequence_reset=tag` resets only the definition used by future +starts. It does not rewrite an execution which already started. + +### Execution + +The action `start` creates an execution with its own local start tick. Several +finite executions of the same definition may overlap. The action `stop` +selects all executions of the tag which are active when the action is issued. +If the stop is aligned to a later boundary, an execution started after the +stop request is not implicitly captured by it. + +An execution containing only period-zero events is finite and retires after +its greatest local tick. If any event has a nonzero period, the execution +repeats until stopped. + +### Gate + +The action `gate` suppresses ordinary event dispatch for a duration while +local phase advances. It does not stop audio which is already ringing. +Sequence-control events continue to run while gated, allowing a finite +controller sequence to restore or change another sequence without being +blocked by its own gate. + +Suppression is deliberately event-agnostic: an ordinary event which falls in +the gated interval is skipped and is not replayed later. This includes +note-offs and parameter-restoration events. A definition which requires such +an event for cleanup should keep it outside the gated interval or put the +complete gesture in a separately started finite sequence. + +Gate duration and control alignment are limited to 2,147,483,647 ticks. This +keeps every pending boundary within the unambiguous half-range of AMY's +wrapping 32-bit tick comparisons. Once an execution has reached its start it +is latched as started, so an indefinitely running periodic sequence continues +across subsequent clock wraparounds. + +### Composition + +A stored payload may be an ordinary AMY event or a control for another +sequence. A finite sequence can therefore launch note gestures, control a +periodic sequence for a fixed number of repeats, or coordinate several +independent phrases. Cycles are not recursively expanded through C call +frames: each successful start occupies a slot in the bounded execution pool, +so a cyclic graph fails further starts once that pool is full and remains +recoverable through stop or reset. + +## Event ordering and ownership + +For a given tick, sequence controls are processed before ordinary events. A +stop on a boundary therefore prevents the ordinary event on that boundary, +and a child start can include the child's local tick-zero event on the same +tick. + +Stopping an execution cancels its future payloads. AMY cannot synthesize a +generic inverse for arbitrary events: a payload may change a filter, load a +patch, start another sequence, or send a note. If a phrase must complete a +release, store that release in a finite child and stop the parent which creates +future children. If the caller intentionally stops the child itself, its +remaining payloads are intentionally cancelled. + +## Immutable snapshots + +A definition and an execution have different lifetimes. Once an execution +starts, it holds a reference to the exact definition version it observed. +Changing the tag publishes a new version for future starts; existing +executions continue to read their old versions. This prevents a live edit from +removing a pending note-off or changing another payload halfway through a +phrase. + +The implementation uses copy-on-write snapshot semantics. A definition owned +only by its tag can be appended in place. If an execution or competing writer +also holds it, an editor pins that source and constructs a complete candidate +copy. This is the data-versioning rule; it is not by itself sufficient for a +real-time audio thread because copying and freeing are variable-time work. + +## RCU-like publication and deferred reclamation + +Candidate construction happens outside `amy_queue_lock`. After cloning the +events and their wire strings, the editor briefly reacquires the lock and +publishes the candidate only if the tag still points to the source it cloned. +Publication is therefore a checked pointer swap. If another writer won the +race, the losing writer discards its private candidate outside the lock and +retries from the newly published definition. Concurrent cumulative writers do +not silently lose one another's events. + +Executions act as readers by retaining references to their immutable versions. +When the render path releases the last reference, it does not free the event +array or its strings. It links the definition onto an intrusive retired list, +which requires no allocation. A later non-rendering command boundary detaches +that list under the lock and performs destruction after releasing the lock. +Internally fired sequence payloads bypass the public command boundary so they +cannot accidentally reclaim memory from the render path. + +This is an RCU-like publication and reclamation scheme with explicit reference +counts, not a tracing garbage collector. Copy-on-write still describes how a +new immutable version is created; RCU-like publication describes how readers +continue safely and how old versions are retired without waiting or freeing on +the audio path. + +Two fixed ping-pong buffers are insufficient. Multiple overlapping or +indefinitely repeating executions may retain more than two historical +generations while additional edits are published. Explicit references allow +exactly the generations which remain in use to survive. A general garbage +collector would add machinery without improving that already-known ownership. + +## Why this matters on ESP32 + +At 48 kHz with 128-sample render blocks, one block represents approximately +2.67 ms. Heap allocation, copying many variable-length wire strings, heap +coalescing, PSRAM/cache latency, and destruction of an entire definition are +not usefully bounded operations within that deadline. Performing them while +holding the lock shared with sequence rendering can turn an infrequent live +edit into an audio dropout. + +The current design limits the shared-lock publication step to reference +updates, validation, and a pointer swap. The render path releases references +and links retired objects without allocating or freeing. This removes the +known variable-time definition work from the render critical section. + +That architecture reduces and bounds the source-level risk; it is not a claim +that every ESP32 configuration is proven hard real-time. Final assurance still +requires measurement on the target board with the intended sample rate, block +size, memory capabilities, effects load, concurrent authoring traffic, heap +low-water mark, and worst observed render deadline. + +## Capacity and per-tick cost + +`max_sequencer_tags` bounds definition identities. `max_sequence_events` +bounds events in one definition, and `max_sequence_executions` bounds active +or alignment-pending executions. Definitions allocate lazily. The tick loop +visits active executions and directly scheduled entries, not every inactive +definition. + +Allocation failure, a full definition, an unavailable execution slot, an +invalid tag, and malformed action shapes fail with diagnostics. A failed +publication leaves the previously published definition intact. + +See [Status and compatibility](sequencer-sequences-status.md) for validated +behavior, platform limits, and migration guidance. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md new file mode 100644 index 00000000..348a6f07 --- /dev/null +++ b/docs/sequencer-sequences-howto.md @@ -0,0 +1,165 @@ +# Reusable sequence how-to + +This example preloads two arpeggios, starts one, and switches to the other on a +musical boundary. Python is the primary interface; the equivalent wire +messages are collected afterward. + +AMY's sequencer uses 48 ticks per quarter note. The example gives every note +an 18-tick gate and uses 48 ticks as its switching boundary. + +## 1. Define complete note gestures + +Store each note-on together with its note-off in a finite sequence: + +```python +import amy + +amy.define_sequence(20, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(18,), synth=1, note=60, vel=0), +]) + +amy.define_sequence(21, [ + dict(ticks=(0,), synth=1, note=64, vel=1), + dict(ticks=(18,), synth=1, note=64, vel=0), +]) +``` + +Both definitions contain only period-zero events. Each start therefore creates +a finite execution which retires after its tick-18 note-off. + +## 2. Define two arpeggios + +The slower arpeggio starts the two note gestures half a quarter note apart. +The faster one starts them an eighth note apart: + +```python +amy.define_sequence(30, [ + dict(ticks=(0, 48), sequence=20, + action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, + action='start', alignment_period=1), +]) + +amy.define_sequence(31, [ + dict(ticks=(0, 24), sequence=20, + action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, + action='start', alignment_period=1), +]) +``` + +The nonzero periods make these parent executions repeat until explicitly +stopped. A stored sequence may contain ordinary AMY events or controls for +other sequences. + +## 3. Start and switch + +```python +amy.send(sequence=30, action='start', alignment_period=48) + +# Later: stop the old parent and start the new one at the same boundary. +amy.send(sequence=30, action='stop', alignment_period=48) +amy.send(sequence=31, action='start', alignment_period=48) +``` + +The stop prevents sequence 30 from launching another child at the selected +boundary. A note gesture launched before that boundary is an independent +execution, so it still sends its original note-off. The caller does not need +to mirror AMY's tick count or remember pending releases. + +Start may be sent again while an earlier finite execution of the same tag is +active. Each execution has its own local start tick and immutable definition +snapshot. + +## 4. Stop playback + +```python +amy.send(sequence=31, action='stop', alignment_period=48) +``` + +Stopping a parent cancels its future child launches. Stopping a leaf such as +sequence 20 instead deliberately cancels the future events of every selected +active leaf execution, including any pending note-off. This lets the caller +choose between a graceful parent stop and explicit truncation. + +
+Equivalent wire messages + +`H,,Z` appends a normal event to a reusable +definition. `HRZ` resets future contents. `HC` uses action `0` for stop, +`1` for start, and `2` for gate. + +```text +HR20Z +H0,0,20n60l1i1Z +H18,0,20n60l0i1Z + +HR21Z +H0,0,21n64l1i1Z +H18,0,21n64l0i1Z + +HR30Z +H0,48,30HC20,1,1Z +H24,48,30HC21,1,1Z + +HR31Z +H0,24,31HC20,1,1Z +H12,24,31HC21,1,1Z + +HC30,1,48Z +HC30,0,48Z +HC31,1,48Z +HC31,0,48Z +``` + +The final field of each `HC` message is the alignment period. Direct controls +with alignment `0` or `1` act on the next available sequencer tick; a larger +value selects the next global tick divisible by that value. + +
+ +## Temporarily gate one layer + +Suppose sequence 50 is a running periodic percussion layer. Suppress its +ordinary events for one quarter note without stopping its local clock: + +```python +amy.send( + sequence=50, + action='gate', + duration=48, + alignment_period=1, +) +``` + +After 48 ticks, ordinary event dispatch resumes on the original phase. Audio +which was already ringing is not cut off. A zero-duration gate removes the +current gate at the selected boundary: + +Gate skips every ordinary event in the interval rather than postponing it. In +particular, a note-off or parameter reset inside the interval will not run +later. Keep state-restoring events outside the gate or package a complete +note-on/note-off gesture in its own finite sequence. + +```python +amy.send( + sequence=50, + action='gate', + duration=0, + alignment_period=1, +) +``` + +
+Equivalent gate wire messages + +```text +HC50,2,48,1Z +HC50,2,0,1Z +``` + +
+ +For the complete lifecycle and reset rules, see +[Reusable sequences](sequencer-sequences.md). diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md new file mode 100644 index 00000000..84a6fe78 --- /dev/null +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -0,0 +1,81 @@ +# Musical use cases for reusable sequences + +Reusable sequences let a caller define a collection of ordinary AMY events +once and launch that collection as one musical unit. AMY gives no musical +meaning to a sequence tag: a sequence may contain notes, parameter changes, or +controls for other sequences. + +## Preloaded fills and phrases + +A rhythm engine can preload each fill or phrase as a finite sequence. Its live +schedule then needs only a sequence start instead of another copy of every +event in the phrase. This keeps controller traffic and controller code small +even when the phrase catalogue is large. + +An execution retains the definition with which it started. Rebuilding the +stored definition affects later starts but does not alter a phrase already in +progress. The caller therefore does not need to stream the phrase repeatedly, +calculate when it ends, or track which definition version is sounding. + +## Arpeggios with complete note ownership + +A short finite sequence can hold a note-on together with its matching +note-off. A periodic parent sequence can start these note-pair sequences in an +arpeggio pattern. + +Stopping or replacing the parent prevents later child starts. Children which +already started remain independent and deliver their original note-offs. A +live change of rate, direction, voicing, or harmony can therefore be expressed +without mirroring AMY's clock or maintaining pending-note state in the caller. + +Starting the same child again while an older execution is active is valid. +This permits note gates to overlap their trigger interval. If a caller instead +wants to truncate every active instance of the child, it can explicitly stop +the child's tag. + +## Temporarily thinning a rhythm + +A repeating percussion layer can be stored as a periodic sequence. The `gate` +action suppresses its ordinary event dispatch for a chosen number of ticks +while local phase continues. When the gate expires, the layer resumes where it +would otherwise have been. + +This action does not silence audio which is already ringing. It controls +future event dispatch and continues to process sequence-control events, so a +controller sequence cannot gate away its own recovery. The caller decides +which tags represent musical layers; AMY implements only generic action, +duration, and phase behavior. + +Ordinary events inside the interval are skipped, not delayed. For material +with a required note-off or parameter restoration, the author must place that +cleanup outside the gate or express the complete gesture as a separate finite +sequence. This keeps gate semantics independent of any particular instrument +or application. + +## A fixed number of repeats + +An event with a nonzero period repeats until its execution is stopped. To play +it exactly `N` times, a finite controller sequence can start the periodic +sequence at local tick zero and stop it at `N * period`. + +Sequence controls are processed before ordinary events on the same tick, so +the event at the stop boundary is not dispatched. This composes finite and +periodic sequences without adding a separate repeat-counter state. + +## Parameter automation and compound gestures + +Stored events are not limited to notes. A finite sequence can apply filter, +amplitude, pan, effects, patch, or other AMY changes at local ticks. This can +represent a reusable automation curve or a compound control gesture. AMY does +not invent inverse events when such an execution is stopped; the definition +must contain any restoration required by the caller's musical intent. + +## Live definition changes + +A controller can stop future launches, reset a tag, append a replacement +definition, and start it at a selected alignment. Executions which began before +the change keep their immutable snapshots; later starts use the replacement. + +The controller continues to own musical policy and the ordering of the edit. +It does not need to own definition versions, phrase completion, sequence phase, +or note-release bookkeeping. diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md new file mode 100644 index 00000000..7abee1b4 --- /dev/null +++ b/docs/sequencer-sequences-status.md @@ -0,0 +1,172 @@ +# Reusable sequence status and compatibility + +This document records the implemented interface, the compatibility boundary, +and the validation which still depends on a particular target or downstream +application. It describes the reusable-sequence model in this source tree. + +## Implemented interface + +Python callers normally use named actions: + +```python +amy.send(sequence=40, action='start', alignment_period=48) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +`amy.define_sequence(tag, events)` is the validated replace-as-a-list helper. +The corresponding lower-level fields are `sequence_reset` and +`sequence_control`. JavaScript and Godot bindings expose those lower-level +fields through the generated API. + +The wire protocol uses: + +| Operation | Wire shape | Meaning | +| --- | --- | --- | +| append | `Htick,period,tagZ` | Add an ordinary event to a definition | +| reset | `HRtagZ` | Clear the definition used by future starts | +| stop | `HCtag,0,alignmentZ` | Stop the selected executions | +| start | `HCtag,1,alignmentZ` | Create an execution | +| gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | + +The numeric action is deliberately a three-value action rather than a boolean +or a note velocity. Fractional values are rejected for every sequence tag, +tick, period, duration and alignment field. Tags, ticks and periods use uint32; +duration and alignment are capped at 2,147,483,647 ticks for wrap-safe pending +boundaries. + +## Compatibility summary + +| Existing use | Status | Required action | +| --- | --- | --- | +| Untagged `ticks=(tick,)` | Compatible | None | +| Untagged `ticks=(tick, period)` | Compatible | None | +| Empty zero fields such as `ticks=",period,tag"` | Compatible | None | +| Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | +| A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| C `amy_event.ticks` with `TICKS_TAG` set | Changed like any tagged event | Build the definition, then issue an explicit start | +| Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | +| C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | +| Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | + +The intentional breaking change is limited to tagged scheduling. A tag now +identifies a stopped, cumulative definition: repeated tagged writes append, +and playback begins only after an explicit start. This replaces two properties +of the earlier tagged-event behavior, where a later write replaced the event +and the tagged event was active immediately. + +## Migrating a replaceable tagged event + +If the tag was only being used as a replace/remove handle, the smallest +migration is to omit it and keep using direct one-off or periodic scheduling. + +If the contents need to remain addressable as a reusable sequence, replace +them explicitly: + +```python +amy.send(sequence=tag, action='stop', alignment_period=period) +amy.define_sequence(tag, events) +amy.send(sequence=tag, action='start', alignment_period=period) +``` + +The low-level wire equivalent is: + +```text +HC,0,Z +HRZ +H,,Z +... +HC,1,Z +``` + +An aligned stop captures the executions which exist when the command is sent. +Replacing the definition changes future starts, while an execution which +already began retains its immutable snapshot. This lets a wrapper migrate +without tracking AMY's current tick, active note state, or definition version. +The wrapper must still choose its musical update boundary: replacing on the +next full period is simple and phase-stable, but may have more latency than an +application-specific mid-cycle update. + +One known first-party consumer of the replace-on-tag behavior is Tulip's +`AMYSequenceEvent` wrapper. Its `update()` and `remove()` operations need the +explicit stop/reset/append/start lifecycle above. That migration is localized, +but its live-edit boundary is a product choice and should be tested together +with the consumers of that wrapper. + +## Other source-compatibility details + +`amy_config_t` appends `max_sequence_events` and +`max_sequence_executions`. Appending preserves the offsets of existing +members, but changing the size of a public C structure is not a binary ABI +promise. Applications should be recompiled against the matching header and +library. As with other AMY configuration, begin with `amy_default_config()` so +new fields receive supported defaults. + +Limits are explicit. `max_sequencer_tags` bounds identities, +`max_sequence_events` bounds one definition, and +`max_sequence_executions` bounds active or alignment-pending executions. +Exhaustion, invalid tags, malformed actions, publication allocation failure, +and cyclic start graphs reject the affected operation without corrupting the +previously published generation. Callers which deliberately choose small +limits should treat a rejected operation as a normal bounded-resource failure. + +A multi-message upload is not a wire-level transaction. `define_sequence()` +validates every Python event before sending its reset, but a target-side +capacity or transport failure during the subsequent messages can leave the +successfully accepted prefix as the new definition. A protocol which needs +acknowledged all-or-nothing remote upload must add that acknowledgement above +AMY's one-way wire command stream; after a detected failure, reset the tag +before retrying. + +Resetting a definition does not stop an execution which already holds a +snapshot. `RESET_TIMEBASE` removes active and pending executions while +retaining definitions. `RESET_SEQUENCER` clears direct events, definitions, +and executions. + +## Automated validation + +The host test suite covers: + +- unchanged one- and two-value direct scheduling; +- cumulative definitions, explicit reset, finite and repeating executions; +- overlapping executions and more than two simultaneously retained snapshot + generations; +- same-tick control ordering, alignment, tick rollover, gate phase, and global + reset behavior; +- current-execution capture for aligned stop and gate; +- arbitrary payloads, sequence composition, bounded cycles, and exhausted + execution pools; +- allocation failure during pool initialization, new-definition creation and + candidate cloning, with recovery and no partial single-event publication; +- two competing writers, including checked publication and retry; +- Python validation and exact wire serialization; +- executable JavaScript serialization and generated binding freshness. + +The reusable-sequence C tests run as part of `make ctest`. Python API coverage +is in `tests/test_sequence_api.py`, and generated API checks are included in +`make check-c-api` and `make js-api-test`. + +## Target-dependent validation still required + +The ownership design keeps definition allocation, cloning, string copying, +and destruction off the render path and outside the shared render-lock +critical section. That is a source-level real-time property, not a substitute +for measuring a complete device. + +On an ESP32 target, validate the intended sample rate, block and DMA sizes, +memory capabilities, effects load, and authoring traffic. Record maximum +render time, missed DMA deadlines, publication critical-section time, heap +low-water mark, largest free block, and maximum retired-list depth. At 48 kHz +and 128 samples, the block deadline is approximately 2.67 ms. + +Generated Godot source is checked for freshness and syntax when the parser is +available. An executable Godot runtime behavior test remains target-dependent; +the sequence behavior itself is implemented in the common C core. + +See [Abstractions and implementation](sequencer-sequences-abstractions.md) for +the snapshot publication and deferred-reclamation design. + +The independently discovered MSVC build failure and its portable correction +are documented in [Windows portability of the PCM Hann-window +constant](windows-m-pi-portability.md). That correction does not change any +reusable-sequence behavior. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md new file mode 100644 index 00000000..843eb398 --- /dev/null +++ b/docs/sequencer-sequences.md @@ -0,0 +1,112 @@ +# Reusable sequences + +A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending +more than one event with the same tag accumulates those events, in the same way +that repeated `synth=` messages configure one synth. Tagged events use local +ticks and remain inactive until the sequence is started. + +Untagged `ticks` events keep their direct scheduling behavior on the global +sequencer clock. + +## Defining a sequence + +The Python convenience API replaces all future contents at a tag: + +```python +amy.define_sequence(40, [ + dict(ticks=(0,), synth=2, note=60, vel=1), + dict(ticks=(12,), synth=2, note=60, vel=0), +]) +``` + +Each event uses normal AMY keyword arguments. Its `ticks` value is local to the +start of the sequence and contains `tick` plus an optional `period`. +`define_sequence()` validates every event, resets the tag, then sends ordinary +tagged `ticks` messages: + +```python +amy.send(sequence_reset=40) +amy.send(ticks=(0, 0, 40), synth=2, note=60, vel=1) +amy.send(ticks=(12, 0, 40), synth=2, note=60, vel=0) +``` + +Repeating tag `40` accumulates both events. `sequence_reset=40` explicitly +replaces the definition; the empty wire form `H0,0,40Z` is an equivalent reset. +With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. + +## Starting and stopping + +```python +amy.send(sequence=40, action='start', alignment_period=1) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +The named actions expose the complete control model: `start` creates an +execution, `stop` terminates the selected executions, and `gate` temporarily +suppresses their ordinary events for the required `duration`. `vel` keeps its +usual meaning of note velocity. At the lower-level `sequence_control` API and +on the wire, actions use integers: stop `0`, start `1`, and gate `2`. +Fractional values are invalid. The optional `alignment_period` is the alignment +quantum. `0` or `1` acts at the next available sequencer tick for a direct +command. A larger value selects the next global tick divisible by that period. +When a sequenced parent starts a child, the child's local tick zero participates +in the same tick. + +A start creates a bounded execution. Finite executions of one tag may overlap, +so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets +all executions of that tag which are active when the command is sent. If the +stop is aligned to a future boundary, a separate execution started after that +command does not inherit its pending stop. This avoids hidden per-tag control +state. Stopping a parent prevents future child starts, while children already +started retain their own event pairs. + +## Finite and repeating lifetime + +Lifetime follows directly from the periods of the stored events: + +- a definition containing only `period=0` events is finite and retires after + its last event; +- an event with nonzero `period` repeats on its local period until stopped; +- a finite controller sequence can start a periodic child and stop it after a + chosen number of periods. + +## Temporary event gating + +```python +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +This suppresses ordinary event dispatch from active executions of tag `40` for +24 ticks. Local phase continues, and dispatch resumes on the original phase. +Audio already ringing is not cut off. Sequence-control payloads remain active, +so a controller sequence can still complete its lifecycle. Duration zero +removes a gate at the selected boundary. + +Gated ordinary events are skipped and are not replayed. That rule also applies +to note-offs and parameter-restoration events. Keep required cleanup outside +the interval or in a separately started finite gesture. Duration and alignment +must not exceed 2,147,483,647 ticks so their boundaries remain unambiguous +across the wrapping 32-bit tick clock. + +## Reset behavior + +- `amy.send(sequence_reset=tag)` removes the future definition. Active + executions retain the snapshot they started with and may finish. +- `RESET_TIMEBASE` discards active or pending executions because their absolute + activation ticks cannot be rebased, but retains stored definitions. +- `RESET_SEQUENCER` clears untagged events, tagged definitions, and executions. + +## Capacity and realtime behavior + +`max_sequencer_tags` bounds public tag identities. `max_sequence_events` bounds +the number of events in one definition, and `max_sequence_executions` bounds +active or alignment-pending executions. Definitions allocate only when used; +inactive definitions are not scanned on each tick. + +See the [implementation model](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and +[step-by-step examples](sequencer-sequences-howto.md). The +[status and compatibility guide](sequencer-sequences-status.md) records the +intentional tagged-scheduling change, migration path, test coverage, and +target-dependent validation boundary. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..fbf646ab 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -221,12 +221,13 @@ AMY starts a musical sequencer that works on `ticks` from startup. You can reset Ticks run at 48 PPQ at the set tempo. The tempo defaults to 108 BPM. This means there are 108 quarter notes a minute, and `48 * 108 = 5184` ticks a minute, 86 ticks a second. The tempo can be changed with `amy.send(tempo=120)`. -You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All three values are optional past `tick`: +You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. +`period` and `tag` are optional. As in other AMY list fields, an empty numeric +field means zero, so `ticks=",24,7"` is the compact spelling for a tick-zero +event with period 24 and tag 7: ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now -amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7 -amy.send(osc=0, ticks="0,0,7") # cancel tag 7 amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable amy.reset() # Stop everything ``` @@ -237,9 +238,30 @@ You can schedule repeating events (like a step sequencer or drum machine) with ` For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on. -`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. +`tag` is optional. Without one, an event is scheduled directly on the global +sequencer clock and cannot be addressed individually. With a tag, the event is +added to a reusable sequence and its tick becomes local to each start of that +sequence. Repeating a tag accumulates events; reset the tag explicitly before +replacing its contents. -If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](docs/api.md) to any function. This will be called at every tick with the current tick number as an argument. +If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. + +### Reusable tagged sequences + +A sequencer tag holds one or more ordinary events with local tick values. +Repeated three-value `ticks=(tick, period, tag)` messages cumulate behind the +same tag. `amy.define_sequence(tag, events)` is the convenient replace-as-a-list +operation. `sequence_control` starts, stops, aligns, or temporarily gates an +active tagged sequence. Component periods define looping; a definition +containing only period-zero events finishes after its last event. + +See [Reusable sequences](sequencer-sequences.md) for the concise API +and lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and a +[step-by-step Python example](sequencer-sequences-howto.md). See +[status and compatibility](sequencer-sequences-status.md) when migrating +existing tagged scheduling or configuring a target build. ## Core oscillators @@ -475,7 +497,3 @@ amy.start_sample(preset=1024, source=amy.SAMPLE_FROM_OUTPUT, max_frames=11025, m amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play back AUDIO_IN sample an octave higher amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - - - - diff --git a/docs/tutorial.html b/docs/tutorial.html index 6f0bdcae..035cdf50 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,16 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. +amy.send(sequence=1, action='start', alignment_period=1) +amy.send(sequence=2, action='start', alignment_period=1)
-

You can remove or update sequence events by addressing their tag number

+

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(ticks=",,1") # remove the eighth note sequence -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, note=70, ticks=",48,2") # change the quarter note event +amy.send(sequence=1, action='stop', alignment_period=1) +amy.send(sequence_reset=1) +amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") +amy.send(sequence=1, action='start', alignment_period=1)

For patterns you want to also address their "slots", which is the offset within the pattern, like this

+amy.send(sequence=1, action='stop', alignment_period=1) +amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,2") # ninth slot of a 16 1/8th note drum machine +amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence +amy.send(sequence=1, action='start', alignment_period=384)
@@ -287,5 +294,3 @@ < - - diff --git a/docs/upgrading.md b/docs/upgrading.md index 6701af81..ed09f90a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -3,6 +3,57 @@ Here we will post breaking APIs between releases of AMY and tips on porting. +## Reusable sequencer sequences (unreleased) + +For the complete compatibility matrix, migration lifecycle, validation status, +and target-dependent checks, see +[Reusable sequence status and compatibility](sequencer-sequences-status.md). + +Supplying the same tag in more than one three-value `ticks=` message now +accumulates all those events into a stopped reusable sequence. Previously, a +later event replaced the earlier event at that tag. This intentional change +makes a tag behave like a synth identity: repeated messages build it up. + +Tagged events therefore no longer begin repeating merely because they were +defined. Callers which used a unique tag as the replace/remove identity of one +automatically active event must either use tagless direct scheduling or adapt +their wrapper to reset, define, and explicitly start that tag. Updating such a +wrapper should stop the old execution, reset the future definition, append the +replacement events, and start it at the required alignment boundary. + +Code which only needs direct one-off or periodic scheduling should omit the +tag and keep using one- or two-value `ticks`: + +```python +amy.send(ticks=(20,), synth=1, note=60, vel=1) +amy.send(ticks=(24,), synth=1, note=60, vel=0) +``` + +To replace a tagged definition, reset it explicitly before appending its new +events. The Python helper validates the complete replacement before sending +anything: + +```python +amy.define_sequence(7, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(12,), synth=1, note=60, vel=0), +]) +amy.send(sequence=7, action='start', alignment_period=1) +``` + +Sequence control is an explicit action, not a note velocity. Use +`action='start'`, `action='stop'`, or `action='gate'` in the Python convenience +API; gate additionally requires `duration`. The corresponding low-level and +wire action values are the integers `1`, `0`, and `2`; fractional values are +rejected. + +The C configuration adds `max_sequence_events` and +`max_sequence_executions`. They are appended to `amy_config_t`; initialize the +structure with `amy_default_config()` and then override named fields, as in all +current AMY examples. Recompile applications together with the updated AMY +headers and library whenever the public configuration structure changes. + + ## 1.0.X -> 1.1.X This is a big change that moves a lot of stuff you used to have to do yourself into AMY itself -- voice and synth handling, note stealing, MIDI, I2S, sequencer. @@ -69,5 +120,3 @@ void loop() { e.patch_number = 1024; patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - - diff --git a/docs/windows-m-pi-portability.md b/docs/windows-m-pi-portability.md new file mode 100644 index 00000000..3dfa184e --- /dev/null +++ b/docs/windows-m-pi-portability.md @@ -0,0 +1,74 @@ +# Windows portability of the PCM Hann-window constant + +## Symptom + +The native Godot addon build on Windows failed while compiling `src/pcm.c` +with MSVC: + +```text +error C2065: 'M_PI': undeclared identifier +``` + +Linux and macOS builds of the same source succeeded. + +## Cause + +ISO C does not require `` to define `M_PI`. Many Unix toolchains +expose it as an extension, whereas MSVC exposes it only under additional +preprocessor conditions. + +AMY commit `73b6fece` added a Hann-window calculation for PCM time stretching: + +```c +cosf(2.0f * (float)M_PI * (float)i / (float)PCM_STRETCH_GRAIN) +``` + +Older Windows builds succeeded because their source predated that sampler +change. In particular, the last successful upstream three-platform Godot run, +[32322968524](https://github.com/shorepine/amy/actions/runs/32322968524), +used head `fa14fa2e`, which did not contain commit `73b6fece`. Current-main run +[33349266667](https://github.com/shorepine/amy/actions/runs/33349266667) +used head `0fb0a00b`: its Linux and macOS jobs passed, but its Windows job +failed at the new `M_PI` expression. + +The failure is therefore independent of the reusable-sequence implementation. +It became visible while that work was being validated on all three Godot +desktop targets. + +## Portable correction + +`src/pcm.c` now supplies the conventional constant only when the toolchain has +not already supplied it: + +```c +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif +``` + +The guard has no effect on platforms that already define `M_PI`. On MSVC it +provides the missing compile-time value; the existing calculation explicitly +casts it to `float`. The Hann window is initialized once by `pcm_init()`, so +the correction adds no render-path work and does not change the algorithm. + +Defining `_USE_MATH_DEFINES` instead would make the common source depend on +MSVC-specific include ordering. Computing pi through a trigonometric function +would add unnecessary runtime work. The guarded constant is the smallest +portable correction for the existing expression. + +The correction is isolated in commit `397488b3` so it remains reviewable and +revertible independently of sequencer behavior. + +## Validation + +With the guarded fallback, fork run +[33954151514](https://github.com/linuxificator/amy/actions/runs/33954151514) +successfully built both Windows Godot debug and release libraries and uploaded +the resulting artifact. Linux and macOS Godot debug and release builds had +already passed from the same AMY source before the fallback was applied; their +preprocessors already supplied `M_PI`, so the guarded definition is inactive +there. + +This is a build-portability correction. It does not alter the reusable- +sequence API, wire format, timing, publication model, or compatibility rules. + diff --git a/experiments/sampler/play_cleanbreaks.py b/experiments/sampler/play_cleanbreaks.py index e094b0b4..34f170de 100644 --- a/experiments/sampler/play_cleanbreaks.py +++ b/experiments/sampler/play_cleanbreaks.py @@ -73,7 +73,6 @@ def main(): # Line tick 0 up with the first note-on (loading above consumed time). amy.send(reset=amy.RESET_TIMEBASE) t = 0 # ticks - tag = 1 print(f"\n when bars native break") for i, e in enumerate(picks): fit = e['bars'] * BAR_TICKS @@ -85,8 +84,7 @@ def main(): # past by the first render); play the opener directly. amy.send(**kw) else: - amy.send(ticks=[t, 0, tag], **kw) - tag += 1 + amy.send(ticks=[t], **kw) t += fit us_per_tick = int(60000000.0 / (args.bpm * PPQ)) # matches sequencer.c total = int(t * us_per_tick / 1e6 * SR) diff --git a/experiments/sampler/play_sampler.py b/experiments/sampler/play_sampler.py index a5e189dc..6cf4f527 100644 --- a/experiments/sampler/play_sampler.py +++ b/experiments/sampler/play_sampler.py @@ -187,7 +187,7 @@ def demo_hits(args): # Quantized to sequencer ticks live (PPQ/4 ticks per 16th). amy.send(tempo=args.bpm) for i, k in enumerate(order): - amy.send(ticks=[int(i * PPQ / 4), 0, i + 1], osc=(i % 24) + 1, + amy.send(ticks=[int(i * PPQ / 4)], osc=(i % 24) + 1, wave=amy.PCM, preset=presets[k], vel=1) time.sleep(len(order) * step + 2) return @@ -233,7 +233,7 @@ def demo_loops(args): amy.send(**kw) # ...and let the sequencer re-trigger every `fit` ticks after that. if args.loops > 1: - amy.send(ticks=[0, fit, i + 1], **kw) + amy.send(ticks=[0, fit], **kw) # "N loops" = N cycles of the longest break. total_ticks = max(l[4] for l in loops) * args.loops total = int(total_ticks * tick_samples(args.bpm)) diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..844aefd7 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -14,6 +14,11 @@ extends Node ## Or use wire protocol directly: ## amy.send_raw("v0w0f440l1") +## Emitted after the selected native or web backend is ready for messages. +signal backend_ready +## Emitted when the selected backend cannot initialize. +signal backend_error(message: String) + # ============================================================ # Wave types # ============================================================ @@ -103,7 +108,9 @@ func _init_native() -> void: _synth = ClassDB.instantiate(&"AmySynth") add_child(_synth) else: - push_warning("AmySynth GDExtension not loaded — audio disabled") + var message := "AmySynth GDExtension not loaded — audio disabled" + push_warning(message) + backend_error.emit(message) return # Apply config before starting @@ -132,6 +139,7 @@ func _init_native() -> void: _stream_player.play() _playback = _stream_player.get_stream_playback() as AudioStreamGeneratorPlayback _started = true + backend_ready.emit() func _init_web() -> void: # Pass config to JS bridge before AMY starts @@ -144,9 +152,12 @@ func _init_web() -> void: if ready: _started = true print("AMY web synth ready") + backend_ready.emit() return await get_tree().create_timer(0.1).timeout - push_warning("AMY web module failed to load after 10 s") + var message := "AMY web module failed to load after 10 s" + push_warning(message) + backend_error.emit(message) func _process(_delta: float) -> void: if _started and not _is_web: @@ -336,9 +347,13 @@ var _KW_MAP: Dictionary = { "disk_sample": ["zF", "L"], "algorithm": ["o", "I"], "chorus": ["k", "L"], + "reverb_room": ["hR", "L"], + "reverb_send": ["hS", "L"], "reverb": ["h", "L"], "echo": ["M", "L"], "patch": ["K", "I"], + "sequence_reset": ["HR", "I"], + "sequence_control": ["HC", "L"], "external_channel": ["W", "I"], "portamento": ["m", "I"], "tempo": ["j", "F"], @@ -411,34 +426,38 @@ var _KW_PRIORITY: Dictionary = { "disk_sample": 41, "algorithm": 42, "chorus": 43, - "reverb": 44, - "echo": 45, - "patch": 46, - "external_channel": 47, - "portamento": 48, - "tempo": 49, - "sequencer_run": 50, - "external_midi_sync": 51, - "synth": 52, - "pedal": 53, - "synth_flags": 54, - "num_voices": 55, - "oscs_per_voice": 56, - "synth_level": 57, - "to_synth": 58, - "grab_midi_notes": 59, - "note_source_channel": 60, - "synth_delay": 61, - "preset": 62, - "num_partials": 63, - "start_sample": 64, - "stop_sample": 65, - "bus": 66, - "mode": 67, - "midi_cc": 68, - "midi_note_cmd": 69, - "cv_trigger": 70, - "patch_string": 71, + "reverb_room": 44, + "reverb_send": 45, + "reverb": 46, + "echo": 47, + "patch": 48, + "sequence_reset": 49, + "sequence_control": 50, + "external_channel": 51, + "portamento": 52, + "tempo": 53, + "sequencer_run": 54, + "external_midi_sync": 55, + "synth": 56, + "pedal": 57, + "synth_flags": 58, + "num_voices": 59, + "oscs_per_voice": 60, + "synth_level": 61, + "to_synth": 62, + "grab_midi_notes": 63, + "note_source_channel": 64, + "synth_delay": 65, + "preset": 66, + "num_partials": 67, + "start_sample": 68, + "stop_sample": 69, + "bus": 70, + "mode": 71, + "midi_cc": 72, + "midi_note_cmd": 73, + "cv_trigger": 74, + "patch_string": 75, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/library.properties b/library.properties index a5f94b67..e413db6d 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=AMY Synthesizer -version=1.2.163 +version=1.2.164 author=Brian Whitman , DAn Ellis maintainer=Brian Whitman sentence=AMY, the Music Synthesizer Library diff --git a/pyproject.toml b/pyproject.toml index 51a808f1..90620e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amy" -version = "1.2.163" +version = "1.2.164" description = "AMY synthesizer" readme = "README.md" dependencies = ['numpy', 'soundfile'] diff --git a/setup.py b/setup.py index d9ea96ac..50687b11 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ from distutils.core import setup, Extension from setuptools import find_packages +from setuptools.command.build_ext import build_ext import glob import os import subprocess @@ -20,13 +21,21 @@ # the web build: generate build/drums_bin.c from sounds/gamma9001/ and link it. gamma_manifest = os.path.join('sounds', 'gamma9001', 'manifest.json') gamma_drums_bin_c = os.path.join('build', 'drums_bin.c') -if os.path.exists(gamma_manifest): +use_gamma9001 = os.environ.get('AMY_PCM_BANK', 'gamma9001').lower() != 'tiny' +if use_gamma9001 and os.path.exists(gamma_manifest): if not os.path.exists(gamma_drums_bin_c) or \ os.path.getmtime(gamma_drums_bin_c) < os.path.getmtime(gamma_manifest): subprocess.check_call([sys.executable, '-m', 'amy.headers', 'gamma9001']) sources.append(gamma_drums_bin_c) comp_args.append("-DGAMMA9001") +class AmyBuildExt(build_ext): + def finalize_options(self): + super().finalize_options() + # The output filename is identical for both banks. Always rebuild so a + # preceding build of the other variant can never be reused silently. + self.force = True + if os.uname()[0] == 'Darwin': frameworks = ['CoreAudio', 'AudioToolbox', 'AudioUnit', 'CoreFoundation', 'CoreMIDI', 'Cocoa'] sources += ['src/macos_midi.m'] @@ -43,4 +52,5 @@ setup(name = "amy", packages=find_packages(include=['amy']), - ext_modules=[extension_mod]) + ext_modules=[extension_mod], + cmdclass={'build_ext': AmyBuildExt}) diff --git a/src/amy.c b/src/amy.c index 6919023c..1e86b891 100644 --- a/src/amy.c +++ b/src/amy.c @@ -9,6 +9,8 @@ // AMY_DEBUG) the profiler. #ifdef ESP_PLATFORM #include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" int64_t amy_get_us() { return esp_timer_get_time(); } #elif defined PICO_ON_DEVICE #include "pico/time.h" @@ -401,16 +403,296 @@ void config_chorus(uint16_t bus, float level, uint16_t max_delay, float lfo_freq } bool alloc_reverb_delay_lines(uint16_t bus) { - if (amy_global.bus[bus]->reverb.rev == NULL) + if (amy_global.bus[bus]->reverb.rev == NULL) { + if (amy_global.allocated_reverbs >= AMY_MAX_REVERBS) { + fprintf(stderr, + "cannot allocate reverb on bus %u: AMY_MAX_REVERBS=%u\n", + bus, (unsigned)AMY_MAX_REVERBS); + return false; + } amy_global.bus[bus]->reverb.rev = new_reverb(); - return init_stereo_reverb(amy_global.bus[bus]->reverb.rev); + if (amy_global.bus[bus]->reverb.rev == NULL + || !init_stereo_reverb(amy_global.bus[bus]->reverb.rev)) { + delete_reverb(amy_global.bus[bus]->reverb.rev); + amy_global.bus[bus]->reverb.rev = NULL; + return false; + } + ++amy_global.allocated_reverbs; + } + return true; } void dealloc_reverb_delay_lines(uint16_t bus) { if (amy_global.bus[bus]->reverb.rev != NULL) { deinit_stereo_reverb(amy_global.bus[bus]->reverb.rev); delete_reverb(amy_global.bus[bus]->reverb.rev); + amy_global.bus[bus]->reverb.rev = NULL; + if (amy_global.allocated_reverbs > 0) --amy_global.allocated_reverbs; + } +} + +static amy_reverb_diagnostic_t reverb_stage_diagnostic; +static volatile uint32_t reverb_stage_diagnostic_seq; + +static uint32_t reverb_current_core_mask(void) { +#ifdef ESP_PLATFORM + int core = xPortGetCoreID(); + return (core >= 0 && core < 32) ? (1u << core) : 0; +#else + return 1u; +#endif +} + +static void reverb_diagnostic_record(volatile uint32_t *seq, + amy_reverb_diagnostic_t *diagnostic, + uint32_t elapsed_us) { + ++*seq; + amy_memory_fence(); + ++diagnostic->calls; + diagnostic->total_us += elapsed_us; + if (elapsed_us > diagnostic->max_us) diagnostic->max_us = elapsed_us; + if (elapsed_us > AMY_BLOCK_US) ++diagnostic->deadline_misses; + diagnostic->core_mask |= reverb_current_core_mask(); + amy_memory_fence(); + ++*seq; +} + +static bool reverb_diagnostic_snapshot(volatile uint32_t *seq, + amy_reverb_diagnostic_t *source, + amy_reverb_diagnostic_t *result) { + if (result == NULL) return false; + for (int attempt = 0; attempt < 8; ++attempt) { + uint32_t before = *seq; + if (before & 1u) continue; + amy_memory_fence(); + *result = *source; + amy_memory_fence(); + if (before == *seq) return true; + } + return false; +} + +static bool init_reverb_room(uint16_t room) { + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + state->effect.level = 0; + state->effect.liveness = REVERB_DEFAULT_LIVENESS; + state->effect.damping = REVERB_DEFAULT_DAMPING; + state->effect.xover_hz = REVERB_DEFAULT_XOVER_HZ; + state->external_effect = amy_global.config.aux_return_external != NULL + && amy_global.config.aux_return_external[room] != 0; + + void *arena = NULL; + if (amy_global.config.reverb_room_memory != NULL) + arena = amy_global.config.reverb_room_memory[room]; + if (state->external_effect) { + size_t block_bytes = sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS; + if (amy_global.config.amy_external_aux_return_process_hook == NULL) { + fprintf(stderr, + "aux return %u is external but has no process hook\n", room); + return false; + } + if (arena != NULL) { + if (amy_global.config.reverb_room_memory_bytes < block_bytes) { + fprintf(stderr, + "external aux return %u needs at least %zu arena bytes\n", + room, block_bytes); + return false; + } + state->arena = arena; + state->arena_bytes = amy_global.config.reverb_room_memory_bytes; + state->arena_used = block_bytes; + state->block = (SAMPLE *)arena; + } else { + state->block = (SAMPLE *)malloc_caps( + block_bytes, amy_global.config.ram_caps_block); + state->block_heap_owned = 1; + if (state->block == NULL) { + fprintf(stderr, "unable to allocate external aux return %u\n", + room); + return false; + } + } + bzero(state->block, block_bytes); + return true; + } + + if (amy_global.allocated_reverbs >= AMY_MAX_REVERBS) { + fprintf(stderr, + "cannot allocate shared reverb %u: AMY_MAX_REVERBS=%u\n", + room, (unsigned)AMY_MAX_REVERBS); + return false; } + if (arena != NULL) { + state->arena = arena; + state->arena_bytes = amy_global.config.reverb_room_memory_bytes; + state->effect.rev = new_reverb_in_arena( + arena, state->arena_bytes, &state->block, &state->arena_used); + if (state->effect.rev == NULL) { + fprintf(stderr, + "shared reverb room %u does not fit its %zu-byte arena\n", + room, state->arena_bytes); + return false; + } + } else { + state->effect.rev = new_reverb(); + state->block = (SAMPLE *)malloc_caps( + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + amy_global.config.ram_caps_block); + state->block_heap_owned = 1; + if (state->effect.rev == NULL || state->block == NULL + || !init_stereo_reverb(state->effect.rev)) { + fprintf(stderr, "unable to allocate shared reverb room %u\n", room); + if (state->effect.rev != NULL) { + deinit_stereo_reverb(state->effect.rev); + delete_reverb(state->effect.rev); + state->effect.rev = NULL; + } + if (state->block_heap_owned) { + free(state->block); + state->block = NULL; + state->block_heap_owned = 0; + } + return false; + } + bzero(state->block, + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + } + ++amy_global.allocated_reverbs; + config_stereo_reverb(state->effect.rev, state->effect.liveness, + state->effect.xover_hz, state->effect.damping); + return true; +} + +static void deinit_reverb_room(shared_reverb_state_t *state) { + if (state == NULL) return; + bool built_in_reverb = state->effect.rev != NULL; + if (state->effect.rev != NULL) { + deinit_stereo_reverb(state->effect.rev); + delete_reverb(state->effect.rev); + } + if (state->block_heap_owned) free(state->block); + if (built_in_reverb && amy_global.allocated_reverbs > 0) + --amy_global.allocated_reverbs; + *state = (shared_reverb_state_t){0}; +} + +void config_reverb_room(uint16_t room, float level, float liveness, + float damping, float xover_hz) { + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) { + fprintf(stderr, "aux return %u is not configured (max %u)\n", + room, amy_global.config.max_reverb_rooms); + return; + } + if (amy_global.reverb_rooms[room].external_effect) { + fprintf(stderr, + "aux return %u is externally processed, not a built-in reverb\n", + room); + return; + } + reverb_state_t *effect = &amy_global.reverb_rooms[room].effect; + if (AMY_IS_UNSET(level)) level = S2F(effect->level); + if (AMY_IS_UNSET(liveness)) liveness = effect->liveness; + if (AMY_IS_UNSET(damping)) damping = effect->damping; + if (AMY_IS_UNSET(xover_hz)) xover_hz = effect->xover_hz; + if (!isfinite(level) || level < 0) level = 0; + effect->level = F2S(level); + effect->liveness = liveness; + effect->damping = damping; + effect->xover_hz = xover_hz; + config_stereo_reverb(effect->rev, liveness, xover_hz, damping); +} + +void config_reverb_send(uint16_t bus, uint16_t room, float level) { + bus = amy_validate_bus(bus); + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) { + fprintf(stderr, "aux return %u is not configured (max %u)\n", + room, amy_global.config.max_reverb_rooms); + return; + } + if (AMY_IS_UNSET(level)) level = S2F(amy_global.bus[bus]->reverb_send_level); + if (!isfinite(level)) { + fprintf(stderr, "aux send level must be finite\n"); + return; + } + if (level < 0) level = 0; + amy_global.bus[bus]->reverb_send_room = room; + amy_global.bus[bus]->reverb_send_level = F2S(level); +} + +void amy_process_reverb_room(uint16_t room) { + if (room >= amy_global.config.max_reverb_rooms) return; + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + if (state->block == NULL) return; + uint64_t started = amy_global.config.reverb_diagnostics ? amy_get_us() : 0; + if (state->external_effect) { + amy_global.config.amy_external_aux_return_process_hook( + room, state->block, AMY_BLOCK_SIZE, + amy_global.config.amy_external_aux_return_user_data); + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&state->diagnostic_seq, + &state->diagnostic, + (uint32_t)(amy_get_us() - started)); + return; + } + if (state->effect.rev == NULL) return; + // A disabled return cannot contribute to the mix. Avoid walking all of + // its delay memory, but keep processing an enabled room through silent + // input so an existing tail decays naturally. + if (state->effect.level == 0) return; + stereo_reverb_wet(state->effect.rev, state->block, + AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, + state->block, + AMY_NCHANS > 1 ? state->block + AMY_BLOCK_SIZE : NULL, + AMY_BLOCK_SIZE, state->effect.level); + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&state->diagnostic_seq, &state->diagnostic, + (uint32_t)(amy_get_us() - started)); +} + +void amy_process_reverb_rooms(void) { + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) + amy_process_reverb_room(room); +} + +bool amy_reverb_diagnostics_get(uint16_t room, + amy_reverb_diagnostic_t *result) { + if (room >= amy_global.config.max_reverb_rooms + || amy_global.reverb_rooms == NULL) return false; + return reverb_diagnostic_snapshot( + &amy_global.reverb_rooms[room].diagnostic_seq, + &amy_global.reverb_rooms[room].diagnostic, result); +} + +bool amy_reverb_stage_diagnostics_get(amy_reverb_diagnostic_t *result) { + return reverb_diagnostic_snapshot(&reverb_stage_diagnostic_seq, + &reverb_stage_diagnostic, result); +} + +void amy_reverb_diagnostics_print(void) { + amy_reverb_diagnostic_t diagnostic; + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (!amy_reverb_diagnostics_get(room, &diagnostic)) continue; + fprintf(stderr, + "AMY reverb room %u: calls=%" PRIu64 " avg_us=%" PRIu64 + " max_us=%" PRIu32 " deadline_misses=%" PRIu32 + " core_mask=0x%" PRIx32 " arena=%zu/%zu\n", + room, diagnostic.calls, + diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, + diagnostic.max_us, diagnostic.deadline_misses, + diagnostic.core_mask, amy_global.reverb_rooms[room].arena_used, + amy_global.reverb_rooms[room].arena_bytes); + } + if (amy_reverb_stage_diagnostics_get(&diagnostic)) + fprintf(stderr, + "AMY reverb stage: calls=%" PRIu64 " avg_us=%" PRIu64 + " max_us=%" PRIu32 " deadline_misses=%" PRIu32 + " core_mask=0x%" PRIx32 "\n", + diagnostic.calls, + diagnostic.calls ? diagnostic.total_us / diagnostic.calls : 0, + diagnostic.max_us, diagnostic.deadline_misses, + diagnostic.core_mask); } void config_reverb(uint16_t bus, float level, float liveness, float damping, float xover_hz) { @@ -498,6 +780,8 @@ void bus_reset(uint16_t bus) { amy_global.bus[bus]->dist_state[c].hold_count = 0; amy_global.bus[bus]->dist_state[c].hpf_yn1 = 0; } + amy_global.bus[bus]->reverb_send_room = AMY_REVERB_ROOM_NONE; + amy_global.bus[bus]->reverb_send_level = 0; if (AMY_HAS_CHORUS) config_chorus(bus, CHORUS_DEFAULT_LEVEL, CHORUS_DEFAULT_MAX_DELAY, CHORUS_DEFAULT_LFO_FREQ, CHORUS_DEFAULT_MOD_DEPTH); if (AMY_HAS_REVERB) config_reverb(bus, REVERB_DEFAULT_LEVEL, REVERB_DEFAULT_LIVENESS, REVERB_DEFAULT_DAMPING, REVERB_DEFAULT_XOVER_HZ); @@ -528,6 +812,7 @@ int8_t global_init(amy_config_t c) { amy_global.i2s_is_in_background = 0; amy_global.delta_queue = NULL; amy_global.delta_qsize = 0; + amy_global.allocated_reverbs = 0; // The per-bus tables are sized from max_buses; nothing about a bus is a // fixed-width array any more. amy_global.volume = (float *)malloc_caps(sizeof(float) * amy_global.config.max_buses, @@ -536,10 +821,31 @@ int8_t global_init(amy_config_t c) { amy_global.config.ram_caps_synth); amy_global.bus = (bus_state_t **)malloc_caps(sizeof(bus_state_t *) * amy_global.config.max_buses, amy_global.config.ram_caps_synth); - if (amy_global.volume == NULL || amy_global.volume_scale == NULL || amy_global.bus == NULL) { + amy_global.reverb_rooms = NULL; + if (amy_global.config.max_reverb_rooms > 0) + amy_global.reverb_rooms = (shared_reverb_state_t *)malloc_caps( + sizeof(shared_reverb_state_t) * amy_global.config.max_reverb_rooms, + amy_global.config.ram_caps_synth); + if (amy_global.volume == NULL || amy_global.volume_scale == NULL + || amy_global.bus == NULL + || (amy_global.config.max_reverb_rooms > 0 + && amy_global.reverb_rooms == NULL)) { fprintf(stderr, "unable to alloc %d buses\n", amy_global.config.max_buses); return -1; } + if (amy_global.reverb_rooms != NULL) + bzero(amy_global.reverb_rooms, + sizeof(shared_reverb_state_t) + * amy_global.config.max_reverb_rooms); + reverb_stage_diagnostic = (amy_reverb_diagnostic_t){0}; + reverb_stage_diagnostic_seq = 0; + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (!init_reverb_room(room)) { + for (uint16_t initialized = 0; initialized <= room; ++initialized) + deinit_reverb_room(&amy_global.reverb_rooms[initialized]); + return -1; + } + } for (int bus = 0; bus < amy_global.config.max_buses; ++bus) amy_global.volume[bus] = 1.0f; amy_global.pitch_bend = 0; @@ -584,13 +890,17 @@ int8_t global_init(amy_config_t c) { void global_deinit(void) { for (int bus = 0; bus < amy_global.config.max_buses; ++bus) filters_deinit(bus); + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) + deinit_reverb_room(&amy_global.reverb_rooms[room]); free(amy_global.bus[0]); // One allocation for every bus_state; bus[i] points into it. free(amy_global.bus); free(amy_global.volume_scale); free(amy_global.volume); + free(amy_global.reverb_rooms); amy_global.bus = NULL; amy_global.volume_scale = NULL; amy_global.volume = NULL; + amy_global.reverb_rooms = NULL; } // Drive rides a log2 rail, like freq and filter freq. The wire and the CONST @@ -756,7 +1066,13 @@ bool osc_ref_within_voice(int rel_osc, uint16_t oscs_per_voice, const char *what #define EVENT_TO_DELTA_FREQ_COEFS(FIELD, FLAG) \ EVENT_TO_DELTA_COEFS_COEF0_SPECIAL(FIELD, FLAG, logfreq_of_freq) -static void flush_due_deltas(); // definition next to amy_execute_deltas() +static uint32_t flush_due_deltas(); // definition next to amy_execute_deltas() +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +uint32_t amy_last_executed_delta_count; +uint32_t amy_last_sequencer_us; +uint32_t amy_last_flush_us; +uint16_t amy_last_audible_osc_count[2]; +#endif // Take the distortion fields out of an event once they have been turned into // deltas, so no later pass over the same event can spend them a second time @@ -784,6 +1100,16 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe d.time = e->time; if(AMY_IS_UNSET(e->time)) { d.time = 0; } + // Shared room configuration is global rather than bus- or osc-scoped. + // The room id rides delta.osc, matching how bus ids are carried below. + if (AMY_IS_SET(e->reverb_room)) { + d.osc = e->reverb_room; + EVENT_TO_DELTA_F(reverb_room_level, REVERB_ROOM_LEVEL) + EVENT_TO_DELTA_F(reverb_room_liveness, REVERB_ROOM_LIVENESS) + EVENT_TO_DELTA_F(reverb_room_damping, REVERB_ROOM_DAMPING) + EVENT_TO_DELTA_F(reverb_room_xover_hz, REVERB_ROOM_XOVER_HZ) + } + // If this is a bus-directed event, use d->osc to store the bus number instead. if (event_addresses_bus(e)) { // Store the target bus in d.osc. Either bus is specified, or synth is specified and has a bus, or default. @@ -809,6 +1135,8 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe EVENT_TO_DELTA_F(reverb_liveness, REVERB_LIVENESS) EVENT_TO_DELTA_F(reverb_damping, REVERB_DAMPING) EVENT_TO_DELTA_F(reverb_xover_hz, REVERB_XOVER_HZ) + EVENT_TO_DELTA_I(reverb_send_room, REVERB_SEND_ROOM) + EVENT_TO_DELTA_F(reverb_send_level, REVERB_SEND_LEVEL) // The distortion fields serve both scopes; naming no osc is what // puts them at bus scope. Only the CONST coef of drive and mix // reaches a bus - the modulation coefs need per-note sources a bus @@ -844,7 +1172,7 @@ void amy_event_to_deltas_queue(amy_event *e, uint16_t base_osc, uint16_t oscs_pe // Settle pending deltas without running the sequencer tick // service - this can execute on any sending thread (see // flush_due_deltas). - flush_due_deltas(); + (void)flush_due_deltas(); patches_load_patch(e); } // Execute any other commands in this event. @@ -1298,7 +1626,9 @@ int8_t oscs_init() { algo_init(); patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); - sequencer_init(amy_global.config.max_sequencer_tags); + sequencer_init(amy_global.config.max_sequencer_tags, + amy_global.config.max_sequence_events, + amy_global.config.max_sequence_executions); if(pcm_samples) pcm_init(); if(AMY_HAS_CUSTOM) custom_init(); // synth and msynth are now pointers to arrays of pointers to dynamically-allocated synth structures. @@ -1874,6 +2204,12 @@ void play_delta(struct delta *d) { if(d->param == REVERB_LIVENESS) config_reverb(bus, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); if(d->param == REVERB_DAMPING) config_reverb(bus, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT); if(d->param == REVERB_XOVER_HZ) config_reverb(bus, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f); + if(d->param == REVERB_ROOM_LEVEL) config_reverb_room(d->osc, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_LIVENESS) config_reverb_room(d->osc, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_DAMPING) config_reverb_room(d->osc, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f, AMY_UNSET_FLOAT); + if(d->param == REVERB_ROOM_XOVER_HZ) config_reverb_room(d->osc, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, AMY_UNSET_FLOAT, d->data.f); + if(d->param == REVERB_SEND_ROOM) config_reverb_send(bus, d->data.i, AMY_UNSET_FLOAT); + if(d->param == REVERB_SEND_LEVEL) config_reverb_send(bus, amy_global.bus[bus]->reverb_send_room, d->data.f); // Per-bus distortion: same range rules as the per-osc stage (clamped here // so dist_process_bus doesn't range-check per block). if(d->param == BUS_DIST_CLIP_EN) { @@ -2195,6 +2531,23 @@ void mix_with_pan(SAMPLE *stereo_dest, SAMPLE *mono_src, float pan_start, float AMY_PROFILE_STOP(MIX_WITH_PAN) } +// The common bus-routing primitive: scale one non-interleaved block into a +// destination block. replace=true starts a mix; false adds another member of +// the same weighted subset. Keeping dry mix, aux sends and effect returns on +// this one path preserves their fixed-point summation semantics and gives +// targets one kernel to optimize. +static AMY_IRAM_ATTR void mix_bus_block(SAMPLE *dest, const SAMPLE *source, + SAMPLE gain, bool replace) { + int samples = AMY_BLOCK_SIZE * AMY_NCHANS; + if (replace) { + for (int i = 0; i < samples; ++i) + dest[i] = MUL8_SS(gain, source[i]); + } else { + for (int i = 0; i < samples; ++i) + dest[i] += MUL8_SS(gain, source[i]); + } +} + // Test if the specified osc is in its release phase (i.e., note-off has been received). #define OSC_IN_RELEASE(osc) (AMY_IS_SET(synth[osc]->note_off_clock)) @@ -2319,11 +2672,17 @@ SAMPLE render_osc_wave(uint16_t osc, uint8_t core, SAMPLE* buf) { AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { AMY_PROFILE_START(AMY_RENDER) +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint16_t audible_osc_count = 0; +#endif for(int bus = 0; bus <= amy_global.highest_bus; ++bus) bzero(fbl[core][bus], sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); SAMPLE max_max = 0; for(uint16_t osc=start; oscstatus == SYNTH_AUDIBLE) { // skip oscs that are silent or mod sources from playback +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + ++audible_osc_count; +#endif uint16_t bus = synth[osc]->bus; bzero(per_osc_fb[core][bus], AMY_BLOCK_SIZE * sizeof(SAMPLE)); SAMPLE max_val = render_osc_wave(osc, core, per_osc_fb[core][bus]); @@ -2374,6 +2733,9 @@ AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { } // end if audible } core_max[core] = max_max; +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + if (core < 2) amy_last_audible_osc_count[core] = audible_osc_count; +#endif if(AMY_HAS_CHORUS && core == 0) { for(int bus = 0; bus <= amy_global.highest_bus; ++bus) { @@ -2403,21 +2765,24 @@ AMY_IRAM_ATTR void amy_render(uint16_t start, uint16_t end, uint8_t core) { // service is rendering-context-only (unguarded RMW on next_amy_tick_us, and // the external hook expects audio-thread context). Everything here is under // the queue lock - safe from any thread. -static void flush_due_deltas() { +static uint32_t flush_due_deltas() { // check to see which sounds to play uint32_t sysclock = amy_sysclock(); amy_grab_lock(); // find any deltas that need to be played from the (in-order) queue struct delta *d = amy_global.delta_queue; + uint32_t executed = 0; while(d && AMY_TIME_GEQ(sysclock, d->time)) { play_delta(d); d = delta_release(d); amy_global.delta_qsize--; + ++executed; } amy_global.delta_queue = d; amy_release_lock(); + return executed; } // this takes scheduled deltas and plays them at the right time @@ -2425,10 +2790,22 @@ void amy_execute_deltas() { AMY_PROFILE_START(AMY_EXECUTE_DELTAS) // Advance the sequencer on AMY (sample) time and play any due sequence // events, so sequencing works in any rendering context, real-time or not. +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t diagnostic_started_us = amy_get_us(); +#endif sequencer_check_and_fill(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequencer_us = (uint32_t)(amy_get_us() - diagnostic_started_us); +#endif // Make sure any CV-triggered events are added to delta queue update_external_cv_in(); - flush_due_deltas(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + diagnostic_started_us = amy_get_us(); + amy_last_executed_delta_count = flush_due_deltas(); + amy_last_flush_us = (uint32_t)(amy_get_us() - diagnostic_started_us); +#else + (void)flush_due_deltas(); +#endif AMY_PROFILE_STOP(AMY_EXECUTE_DELTAS) } @@ -2451,6 +2828,104 @@ void amy_block_processed(void) { #endif } +// Process AMY's existing effect chain for one bus, then add that bus to its +// configured shared-reverb input. Buses are independent up to this point, so +// a platform may divide complete subsets over render cores without changing +// the DSP or routing model. +static AMY_IRAM_ATTR void amy_process_bus_builtin(uint16_t bus) { + if (amy_global.bus[bus]->dist.stages) + dist_process_bus(bus, fbl[0][bus]); + + if (amy_global.bus[bus]->eq.eq[0] != F2S(1.0f) + || amy_global.bus[bus]->eq.eq[1] != F2S(1.0f) + || amy_global.bus[bus]->eq.eq[2] != F2S(1.0f)) + parametric_eq_process(bus, fbl[0][bus]); + + if (AMY_HAS_CHORUS + && amy_global.bus[bus]->chorus.level > 0 + && amy_global.bus[bus]->chorus.chorus_delay_lines[0] != NULL) { + SAMPLE scale = F2S(1.0f); + for (int16_t c = 0; c < AMY_NCHANS; ++c) { + apply_variable_delay( + fbl[0][bus] + c * AMY_BLOCK_SIZE, + amy_global.bus[bus]->chorus.chorus_delay_lines[c], + amy_global.bus[bus]->chorus.delay_mod, scale, + amy_global.bus[bus]->chorus.level, 0); + scale = -scale; + } + } + + if (AMY_HAS_ECHO + && amy_global.bus[bus]->echo.level > 0 + && amy_global.bus[bus]->echo.echo_delay_lines[0] != NULL) { + for (int16_t c = 0; c < AMY_NCHANS; ++c) + apply_fixed_delay( + fbl[0][bus] + c * AMY_BLOCK_SIZE, + amy_global.bus[bus]->echo.echo_delay_lines[c], + amy_global.bus[bus]->echo.delay_samples, + amy_global.bus[bus]->echo.level, + amy_global.bus[bus]->echo.feedback, + amy_global.bus[bus]->echo.filter_coef); + } + + uint16_t room = amy_global.bus[bus]->reverb_send_room; + SAMPLE send = amy_global.bus[bus]->reverb_send_level; + if (room < amy_global.config.max_reverb_rooms && send != 0) { + SAMPLE gain = MUL8_SS(send, amy_global.volume_scale[bus]); + mix_bus_block(amy_global.reverb_rooms[room].block, + fbl[0][bus], gain, false); + } + + if (AMY_HAS_REVERB + && amy_global.bus[bus]->reverb.level > 0 + && amy_global.bus[bus]->reverb.rev != NULL + && amy_global.bus[bus]->reverb.rev->delay_1 != NULL) { + if (AMY_NCHANS == 1) { + stereo_reverb(amy_global.bus[bus]->reverb.rev, + fbl[0][bus], NULL, fbl[0][bus], NULL, + AMY_BLOCK_SIZE, + amy_global.bus[bus]->reverb.level); + } else { + stereo_reverb(amy_global.bus[bus]->reverb.rev, + fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, + fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, + AMY_BLOCK_SIZE, + amy_global.bus[bus]->reverb.level); + } + } +} + +static uint8_t amy_bus_partition(uint16_t bus, uint8_t partitions) { + uint16_t room = amy_global.bus[bus]->reverb_send_room; + if (room < amy_global.config.max_reverb_rooms) + return (uint8_t)(room % partitions); + return (uint8_t)(bus % partitions); +} + +void AMY_IRAM_ATTR amy_process_bus_subset(uint8_t partition, + uint8_t partitions) { + if (partitions == 0 || partitions > AMY_MAX_CORES + || partition >= partitions) return; + for (uint16_t bus = 0; bus <= amy_global.highest_bus; ++bus) { + if (amy_bus_partition(bus, partitions) == partition) + amy_process_bus_builtin(bus); + } +} + +static void amy_process_bus_post_hook(uint16_t bus) { + if (amy_global.config.amy_external_bus_postprocess_hook != NULL) + amy_global.config.amy_external_bus_postprocess_hook( + bus, fbl[0][bus], AMY_BLOCK_SIZE); +#ifdef __EMSCRIPTEN__ + EM_ASM({ + if (typeof amy_bus_postprocess_js_hook === 'function') { + if (!Module.wasmMemory) Module.wasmMemory = wasmMemory; + amy_bus_postprocess_js_hook($0, $1, $2, $3, Module); + } + }, bus, fbl[0][bus], AMY_BLOCK_SIZE, AMY_NCHANS); +#endif +} + int16_t * amy_fill_buffer() { AMY_PROFILE_START(AMY_FILL_BUFFER) // A requested timebase reset lands here, between blocks on the render @@ -2476,6 +2951,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_sequence_reset_timebase(); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; @@ -2502,79 +2978,63 @@ int16_t * amy_fill_buffer() { // Apply global processing only if there is some signal. //if (max_val > 0) { // NO - see #629 // apply the eq filters if there is some signal and EQ is non-default. - for (int bus=0; bus <= amy_global.highest_bus; ++bus) { - // Per-bus distortion, first so echo/reverb take clean tails. - if (amy_global.bus[bus]->dist.stages) { - dist_process_bus(bus, fbl[0][bus]); - } - // Per-bus EQ - if (amy_global.bus[bus]->eq.eq[0] != F2S(1.0f) || amy_global.bus[bus]->eq.eq[1] != F2S(1.0f) || amy_global.bus[bus]->eq.eq[2] != F2S(1.0f)) { - parametric_eq_process(bus, fbl[0][bus]); - } - if(AMY_HAS_CHORUS) { - // apply per-bus chorus. - if(amy_global.bus[bus]->chorus.level > 0 && amy_global.bus[bus]->chorus.chorus_delay_lines[0] != NULL) { - // apply time-varying delays to both chans. - // delay_mod_val, the modulated delay amount, is set up before calling render_*. - SAMPLE scale = F2S(1.0f); - for (int16_t c=0; c < AMY_NCHANS; ++c) { - apply_variable_delay(fbl[0][bus] + c * AMY_BLOCK_SIZE, amy_global.bus[bus]->chorus.chorus_delay_lines[c], - amy_global.bus[bus]->chorus.delay_mod, scale, amy_global.bus[bus]->chorus.level, 0); - // flip delay direction for alternating channels. - scale = -scale; - } - } - } - //} - if (AMY_HAS_ECHO) { - // Apply per-bus echo. - if (amy_global.bus[bus]->echo.level > 0 && amy_global.bus[bus]->echo.echo_delay_lines[0] != NULL ) { - for (int16_t c=0; c < AMY_NCHANS; ++c) { - apply_fixed_delay(fbl[0][bus] + c * AMY_BLOCK_SIZE, amy_global.bus[bus]->echo.echo_delay_lines[c], amy_global.bus[bus]->echo.delay_samples, amy_global.bus[bus]->echo.level, amy_global.bus[bus]->echo.feedback, amy_global.bus[bus]->echo.filter_coef); - } - } - } - if(AMY_HAS_REVERB) { - // apply per-bus reverb. - if(amy_global.bus[bus]->reverb.level > 0 && amy_global.bus[bus]->reverb.rev != NULL && amy_global.bus[bus]->reverb.rev->delay_1 != NULL) { - if(AMY_NCHANS == 1) { - stereo_reverb(amy_global.bus[bus]->reverb.rev, fbl[0][bus], NULL, fbl[0][bus], NULL, AMY_BLOCK_SIZE, amy_global.bus[bus]->reverb.level); - } else { - stereo_reverb(amy_global.bus[bus]->reverb.rev, fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, fbl[0][bus], fbl[0][bus] + AMY_BLOCK_SIZE, AMY_BLOCK_SIZE, amy_global.bus[bus]->reverb.level); - } - } - } - if(amy_global.config.amy_external_bus_postprocess_hook != NULL) { - amy_global.config.amy_external_bus_postprocess_hook(bus, fbl[0][bus], AMY_BLOCK_SIZE); - } - #ifdef __EMSCRIPTEN__ - // Web version of the bus postprocess hook (see the hooks table in - // docs/api.md): a JS function may process the bus buffer in place - // (buf is nchans sequential channel blocks of len samples). Runs on - // the AudioWorklet thread; Module is this scope's instance (its - // wasmMemory/exports let hook JS reach this module's memory). - EM_ASM({ - if (typeof amy_bus_postprocess_js_hook === 'function') { - // In worker/worklet scopes the glue never attaches the - // wasmMemory runtime export to Module; hook JS needs it. - if (!Module.wasmMemory) Module.wasmMemory = wasmMemory; - amy_bus_postprocess_js_hook($0, $1, $2, $3, Module); - } - }, bus, fbl[0][bus], AMY_BLOCK_SIZE, AMY_NCHANS); - #endif - } // end of per-bus FX - // global volume is supposed to max out at 10, so scale by 0.1. - SAMPLE *volume_scale = amy_global.volume_scale; // max_buses long, allocated at start. + // Global volume is the existing per-bus gain for both the dry summation + // and post-fader aux subsets. Compute it once for this block. + SAMPLE *volume_scale = amy_global.volume_scale; for (int bus = 0; bus <= amy_global.highest_bus; ++bus) volume_scale[bus] = MUL4_SS(F2S(0.1f), F2S(amy_global.volume[bus])); + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + if (amy_global.reverb_rooms[room].block != NULL) + bzero(amy_global.reverb_rooms[room].block, + sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + } + bool serial_bus_hooks = + amy_global.config.amy_external_bus_postprocess_hook != NULL; +#ifdef __EMSCRIPTEN__ + // A JS hook can only be discovered inside the worklet call itself. + serial_bus_hooks = true; +#endif + if (!serial_bus_hooks) { +#ifdef ESP_PLATFORM + amy_platform_process_bus_subsets(); +#else + amy_process_bus_subset(0, 1); +#endif + } else { + for (int bus = 0; bus <= amy_global.highest_bus; ++bus) { + amy_process_bus_builtin(bus); + amy_process_bus_post_hook(bus); + } + } + + if (amy_global.config.max_reverb_rooms > 0) { + uint64_t reverb_stage_started = + amy_global.config.reverb_diagnostics ? amy_get_us() : 0; +#ifdef ESP_PLATFORM + amy_platform_process_reverb_rooms(); +#else + amy_process_reverb_rooms(); +#endif + if (amy_global.config.reverb_diagnostics) + reverb_diagnostic_record(&reverb_stage_diagnostic_seq, + &reverb_stage_diagnostic, + (uint32_t)(amy_get_us() - reverb_stage_started)); + } + // Reuse bus 0's now-consumed render buffer as the standard AMY master bus. + // The dry buses and wet returns are just weighted subsets through the same + // kernel; no second application-specific mixer or extra block copy exists. + SAMPLE *master_bus = fbl[0][AMY_DEFAULT_BUS]; + for (int bus = 0; bus <= amy_global.highest_bus; ++bus) + mix_bus_block(master_bus, fbl[0][bus], volume_scale[bus], bus == 0); + for (uint16_t room = 0; room < amy_global.config.max_reverb_rooms; ++room) { + SAMPLE *room_block = amy_global.reverb_rooms[room].block; + if (room_block != NULL) + mix_bus_block(master_bus, room_block, F2S(1.0f), false); + } + for(int16_t i=0; i < AMY_BLOCK_SIZE; ++i) { for (int16_t c=0; c < AMY_NCHANS; ++c) { - - SAMPLE fsample = 0; - for (int bus = 0; bus <= amy_global.highest_bus; ++bus) { - // Convert the mixed sample into the int16 range, applying overall gain. - fsample += MUL8_SS(volume_scale[bus], fbl[0][bus][i + c * AMY_BLOCK_SIZE]); - } + SAMPLE fsample = master_bus[i + c * AMY_BLOCK_SIZE]; // One-pole high-pass filter to remove large low-frequency excursions from // some FM patches. b = [1 -1]; a = [1 -0.995] diff --git a/src/amy.h b/src/amy.h index 3022cade..d25df5da 100644 --- a/src/amy.h +++ b/src/amy.h @@ -39,6 +39,14 @@ extern pthread_mutex_t amy_queue_lock; #endif #endif +static inline void amy_memory_fence(void) { +#ifdef _WIN32 + MemoryBarrier(); +#else + __sync_synchronize(); +#endif +} + #ifdef ESP_PLATFORM // PRIu8 is normally hu, but the clang we're using doesn't seem to understand it. #undef PRIu8 @@ -72,21 +80,51 @@ extern const uint32_t pcm_wavetable_len; -// Set block size and SR. We try for 256/44100, but some platforms don't let us: +// Set block size and SR. We try for 256/44100, but some platforms don't let us. +// The block is a POWER OF TWO -- the per-block amplitude and pan ramps are +// SHIFTR(delta, BLOCK_SIZE_BITS), not a divide -- so a host chooses it in +// BITS, at compile time: -DBLOCK_SIZE_BITS=7 is a 128-sample block, 6 is 64. +// AMY_BLOCK_SIZE remains accepted for existing embedded build recipes. +// Left alone it is 8 (256 samples), or 7 (128) on Daisy, exactly as before. +#if defined(AMY_BLOCK_SIZE) && !defined(BLOCK_SIZE_BITS) +#if AMY_BLOCK_SIZE == 32 +#define BLOCK_SIZE_BITS (5) +#elif AMY_BLOCK_SIZE == 64 +#define BLOCK_SIZE_BITS (6) +#elif AMY_BLOCK_SIZE == 128 +#define BLOCK_SIZE_BITS (7) +#elif AMY_BLOCK_SIZE == 256 +#define BLOCK_SIZE_BITS (8) +#elif AMY_BLOCK_SIZE == 512 +#define BLOCK_SIZE_BITS (9) +#elif AMY_BLOCK_SIZE == 1024 +#define BLOCK_SIZE_BITS (10) +#else +#error "AMY_BLOCK_SIZE must be a power of two from 32 through 1024" +#endif +#endif +#ifndef BLOCK_SIZE_BITS #ifdef AMY_DAISY -#define AMY_BLOCK_SIZE 128 -#define BLOCK_SIZE_BITS 7 // log2 of BLOCK_SIZE +#define BLOCK_SIZE_BITS 7 #else -#define AMY_BLOCK_SIZE 256 -#define BLOCK_SIZE_BITS 8 // log2 of BLOCK_SIZE +#define BLOCK_SIZE_BITS 8 +#endif +#endif +#if BLOCK_SIZE_BITS < 5 || BLOCK_SIZE_BITS > 10 +#error "BLOCK_SIZE_BITS must be 5..10 (a block of 32..1024 samples)" +#endif +#ifndef AMY_BLOCK_SIZE +#define AMY_BLOCK_SIZE (1 << BLOCK_SIZE_BITS) +#elif AMY_BLOCK_SIZE != (1 << BLOCK_SIZE_BITS) +#error "AMY_BLOCK_SIZE and BLOCK_SIZE_BITS describe different block sizes" #endif -#ifdef AMY_DAISY -#define AMY_SAMPLE_RATE 48000 -#elif defined __EMSCRIPTEN__ +#ifndef AMY_SAMPLE_RATE +#if defined(AMY_DAISY) || defined(__EMSCRIPTEN__) #define AMY_SAMPLE_RATE 48000 #else -#define AMY_SAMPLE_RATE 44100 +#define AMY_SAMPLE_RATE 44100 +#endif #endif #define PCM_AMY_SAMPLE_RATE 22050 @@ -129,6 +167,19 @@ extern void amy_set_gamma9001_pcm(const int16_t * data); #define AMY_DEFAULT_NUM_BUSES 4 #define AMY_DEFAULT_BUS 0 +// Shared reverbs are optional aux-return rooms. With max_reverb_rooms == 0, +// AMY retains its historical inline per-bus reverb behavior exactly. A host +// may mark individual returns as external and replace the built-in reverb +// with another in-place effect through amy_external_aux_return_process_hook. +#define AMY_REVERB_ROOM_NONE UINT16_MAX + +// Compile-time ceiling for allocated built-in reverb networks, including both +// shared returns and historical inline per-bus reverbs. Embedded hosts can set +// this lower to make their memory/performance envelope explicit. +#ifndef AMY_MAX_REVERBS +#define AMY_MAX_REVERBS UINT16_MAX +#endif + // How many external CV inputs to contemplate. #define AMY_MAX_CV_IN 2 @@ -364,6 +415,10 @@ enum coefs{ #define TICKS_PERIOD 1 #define TICKS_TAG 2 +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 + // Reset masks #define RESET_SEQUENCER 4096 #define RESET_ALL_OSCS 8192 @@ -465,6 +520,12 @@ enum params{ REVERB_LIVENESS, REVERB_DAMPING, REVERB_XOVER_HZ, + REVERB_ROOM_LEVEL, + REVERB_ROOM_LIVENESS, + REVERB_ROOM_DAMPING, + REVERB_ROOM_XOVER_HZ, + REVERB_SEND_ROOM, + REVERB_SEND_LEVEL, // Per-bus distortion stage; bus in delta.osc like the params above. // Same per-stage enables as the per-osc stage, and the same event fields // feed both - which of the two an event reaches is its own scope, but the @@ -686,6 +747,16 @@ typedef struct amy_event { float reverb_liveness; float reverb_damping; float reverb_xover_hz; + // hRroom,level,liveness,damping,xover configures a built-in shared reverb. + uint16_t reverb_room; + float reverb_room_level; + float reverb_room_liveness; + float reverb_room_damping; + float reverb_room_xover_hz; + // yBUS hSreturn,level sends one bus to one shared aux return. A zero level + // is the explicit off state and does not disturb an effect's existing tail. + uint16_t reverb_send_room; + float reverb_send_level; } amy_event; // Distortion stage. Split from synthinfo so the same shaper can run at any @@ -954,6 +1025,34 @@ typedef struct { int8_t capture_device_id; int8_t playback_device_id; + // Append new configuration fields here so existing members retain their + // offsets for callers compiled against an earlier amy_config_t layout. + uint32_t max_sequence_events; + uint32_t max_sequence_executions; + + // Optional shared aux-return rooms. reverb_room_memory may point to + // max_reverb_rooms caller-owned arenas, each reverb_room_memory_bytes + // long. A NULL entry falls back to AMY's configured heaps. Supplying + // fixed arenas lets an embedded host reserve isolated SRAM banks. The + // historical max_reverb_rooms name is retained for source compatibility. + uint16_t max_reverb_rooms; + void **reverb_room_memory; + size_t reverb_room_memory_bytes; + // Collect lock-free timing counters for later readout. Disabled by + // default so production builds pay no timer-read cost in the audio path. + uint8_t reverb_diagnostics; + + // Optional max_reverb_rooms-byte selector. A nonzero entry makes that + // return externally processed instead of allocating AMY's built-in + // reverb. The realtime callback receives the accumulated post-fader send + // block and replaces it in place with the return signal. + const uint8_t *aux_return_external; + void (*amy_external_aux_return_process_hook)(uint16_t return_index, + SAMPLE *block, + uint16_t frames, + void *user_data); + void *amy_external_aux_return_user_data; + } amy_config_t; typedef struct eq_state { @@ -969,6 +1068,10 @@ typedef struct reverb_params { SAMPLE lpfcoef; SAMPLE lpfgain; SAMPLE liveness; + // Heap-backed bus reverbs own both this object and their delay lines. + // Shared rooms may instead live entirely inside a caller-supplied arena. + uint8_t heap_owned; + uint8_t delay_lines_heap_owned; } reverb_params_t; typedef struct reverb_state { @@ -979,6 +1082,86 @@ typedef struct reverb_state { reverb_params_t *rev; } reverb_state_t; +typedef struct amy_reverb_diagnostic { + uint64_t calls; + uint64_t total_us; + uint32_t max_us; + uint32_t deadline_misses; + uint32_t core_mask; +} amy_reverb_diagnostic_t; + +typedef struct amy_esp_load_diagnostic { + uint64_t execute_sum_us; + uint64_t sequencer_sum_us; + uint64_t flush_sum_us; + uint64_t sequence_root_sum_us; + uint64_t sequence_control_sum_us; + uint64_t sequence_event_sum_us; + uint64_t sequence_tick_sum; + uint64_t render_sum_us; + uint64_t render_core_sum_us[2]; + uint64_t audible_osc_sum[2]; + uint64_t fill_sum_us; + uint64_t total_sum_us; + uint32_t execute_max_us; + uint32_t sequencer_max_us; + uint32_t flush_max_us; + uint32_t sequence_root_max_us; + uint32_t sequence_control_max_us; + uint32_t sequence_event_max_us; + uint32_t sequence_tick_max; + uint32_t render_max_us; + uint32_t render_core_max_us[2]; + uint32_t audible_osc_max[2]; + uint32_t fill_max_us; + uint32_t total_max_us; + uint32_t total_near_deadline; + uint32_t total_deadline_misses; + uint64_t missed_execute_sum_us; + uint64_t missed_sequencer_sum_us; + uint64_t missed_flush_sum_us; + uint64_t missed_sequence_root_sum_us; + uint64_t missed_sequence_control_sum_us; + uint64_t missed_sequence_event_sum_us; + uint64_t missed_sequence_tick_sum; + uint64_t missed_render_sum_us; + uint64_t missed_fill_sum_us; + uint64_t missed_total_sum_us; + uint64_t executed_delta_sum; + uint64_t missed_executed_delta_sum; + uint32_t missed_execute_max_us; + uint32_t missed_sequencer_max_us; + uint32_t missed_flush_max_us; + uint32_t missed_sequence_root_max_us; + uint32_t missed_sequence_control_max_us; + uint32_t missed_sequence_event_max_us; + uint32_t missed_sequence_tick_max; + uint32_t missed_render_max_us; + uint32_t missed_fill_max_us; + uint32_t executed_delta_max; + uint32_t missed_executed_delta_max; + uint64_t missed_audible_osc_sum[2]; + uint32_t missed_audible_osc_max[2]; + uint64_t i2s_unpaced_blocks; + uint32_t overload_debt_max_us; + uint32_t overload_yields; + uint32_t blocks; +} amy_esp_load_diagnostic_t; + +typedef struct shared_reverb_state { + reverb_state_t effect; + SAMPLE *block; // non-interleaved stereo send accumulator / wet return + void *arena; + size_t arena_bytes; + size_t arena_used; + uint8_t block_heap_owned; + uint8_t external_effect; + // One realtime writer updates these counters; a low-priority reader uses + // diagnostic_seq as a sequence lock and never blocks the audio task. + volatile uint32_t diagnostic_seq; + amy_reverb_diagnostic_t diagnostic; +} shared_reverb_state_t; + typedef struct chorus_config { SAMPLE level; // How much of the delayed signal to mix in to the output, typ F2S(0.5). int32_t max_delay; // Max delay when modulating. Must be <= DELAY_LINE_LEN @@ -1003,6 +1186,8 @@ typedef struct bus_state { // State of fixed dc-blocking HPF eq_state_t eq; reverb_state_t reverb; + uint16_t reverb_send_room; + SAMPLE reverb_send_level; chorus_config_t chorus; echo_config_t echo; // Distortion, first in the bus FX chain; per-channel state per @@ -1020,6 +1205,7 @@ typedef struct global_state { float pitch_bend; // Legacy global pitch bend, will be subsumed per-synth (instrument). uint16_t delta_qsize; + uint16_t allocated_reverbs; struct delta * delta_queue; // start of the sorted queue of deltas to execute. int16_t latency_ms; float tempo; @@ -1055,6 +1241,10 @@ typedef struct global_state { // Per-bus output gain, recomputed each block from volume[]; max_buses entries. SAMPLE *volume_scale; + // Optional shared aux-return reverbs. Each room owns exactly one delay + // network and one block workspace, regardless of how many buses send it. + shared_reverb_state_t *reverb_rooms; + // Smoothed microseconds per render execution. uint32_t render_us; uint16_t overload_count; // Consecutive over-threshold blocks. @@ -1128,6 +1318,24 @@ void amy_oom(const char *fmt, ...); // Returns the bus, or AMY_DEFAULT_BUS (with a complaint) if it's out of range. uint16_t amy_validate_bus(int bus); void config_reverb(uint16_t bus, float level, float liveness, float damping, float xover_hz); +void config_reverb_room(uint16_t room, float level, float liveness, + float damping, float xover_hz); +void config_reverb_send(uint16_t bus, uint16_t room, float level); +void amy_process_reverb_room(uint16_t room); +void amy_process_reverb_rooms(void); +void amy_process_bus_subset(uint8_t partition, uint8_t partitions); +#ifdef ESP_PLATFORM +void amy_platform_process_bus_subsets(void); +void amy_platform_process_reverb_rooms(void); +#endif +bool amy_reverb_diagnostics_get(uint16_t room, + amy_reverb_diagnostic_t *result); +bool amy_reverb_stage_diagnostics_get(amy_reverb_diagnostic_t *result); +void amy_reverb_diagnostics_print(void); +#ifdef ESP_PLATFORM +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result); +void amy_esp_load_diagnostics_print(void); +#endif void config_chorus(uint16_t bus, float level, uint16_t max_delay, float lfo_freq, float depth); void config_echo(uint16_t bus, float level, float delay_ms, float max_delay_ms, float feedback, float filter_coef); void osc_note_on(uint16_t osc, float initial_freq); @@ -1160,6 +1368,10 @@ uint32_t ms_to_samples(uint32_t ms) ; // API void amy_add_message(char *message); +// Internal render-side ingress, used by CV triggers. It deliberately avoids +// variable-time sequence reclamation and gives sequence controls the current +// render tick rather than pretending they came from an external caller. +void amy_add_message_from_render(char *message); // Parse and play a stored wire message now (a fired sequencer entry). void amy_play_message(char *message); // Like amy_add_message but the data is treated as coming from an external diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..d77fd1c9 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -48,9 +48,13 @@ var AMY_KW_MAP = { disk_sample: {wire: "zF", type: "L"}, algorithm: {wire: "o", type: "I"}, chorus: {wire: "k", type: "L"}, + reverb_room: {wire: "hR", type: "L"}, + reverb_send: {wire: "hS", type: "L"}, reverb: {wire: "h", type: "L"}, echo: {wire: "M", type: "L"}, patch: {wire: "K", type: "I"}, + sequence_reset: {wire: "HR", type: "I"}, + sequence_control: {wire: "HC", type: "L"}, external_channel: {wire: "W", type: "I"}, portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, @@ -123,34 +127,38 @@ var AMY_KW_PRIORITY = { disk_sample: 41, algorithm: 42, chorus: 43, - reverb: 44, - echo: 45, - patch: 46, - external_channel: 47, - portamento: 48, - tempo: 49, - sequencer_run: 50, - external_midi_sync: 51, - synth: 52, - pedal: 53, - synth_flags: 54, - num_voices: 55, - oscs_per_voice: 56, - synth_level: 57, - to_synth: 58, - grab_midi_notes: 59, - note_source_channel: 60, - synth_delay: 61, - preset: 62, - num_partials: 63, - start_sample: 64, - stop_sample: 65, - bus: 66, - mode: 67, - midi_cc: 68, - midi_note_cmd: 69, - cv_trigger: 70, - patch_string: 71 + reverb_room: 44, + reverb_send: 45, + reverb: 46, + echo: 47, + patch: 48, + sequence_reset: 49, + sequence_control: 50, + external_channel: 51, + portamento: 52, + tempo: 53, + sequencer_run: 54, + external_midi_sync: 55, + synth: 56, + pedal: 57, + synth_flags: 58, + num_voices: 59, + oscs_per_voice: 60, + synth_level: 61, + to_synth: 62, + grab_midi_notes: 63, + note_source_channel: 64, + synth_delay: 65, + preset: 66, + num_partials: 67, + start_sample: 68, + stop_sample: 69, + bus: 70, + mode: 71, + midi_cc: 72, + midi_note_cmd: 73, + cv_trigger: 74, + patch_string: 75 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; @@ -286,7 +294,6 @@ function amy_send(params, log) { // Constants from amy/constants.py (mirrors amy.SINE, amy.FILTER_LPF, etc.) var AMY = { MAX_FILENAME_LEN: 127, - AMY_BLOCK_SIZE: 256, BLOCK_SIZE_BITS: 8, AMY_SAMPLE_RATE: 44100, PCM_AMY_SAMPLE_RATE: 22050, @@ -406,6 +413,9 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + SEQUENCE_CONTROL_STOP: 0, + SEQUENCE_CONTROL_START: 1, + SEQUENCE_CONTROL_GATE: 2, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, @@ -441,7 +451,8 @@ var AMY = { AMYBOARD_MIDI_IN: 21, AMY_AUDIO_DEVICE_OUT: 0, AMY_AUDIO_DEVICE_IN: 1, - AMY_NUM_MIDI_CHANNELS: 16 + AMY_NUM_MIDI_CHANNELS: 16, + AMY_BLOCK_SIZE: 256 }; if (typeof globalThis !== "undefined") { diff --git a/src/amy_midi.c b/src/amy_midi.c index c6e132af..bba875cf 100644 --- a/src/amy_midi.c +++ b/src/amy_midi.c @@ -614,7 +614,8 @@ void esp_poll_midi(void) { } } -void run_midi_task() { +void run_midi_task(void *pvParameters) { + (void)pvParameters; while(1) { esp_poll_midi(); diff --git a/src/amy_unix_socket.c b/src/amy_unix_socket.c new file mode 100644 index 00000000..d059dee6 --- /dev/null +++ b/src/amy_unix_socket.c @@ -0,0 +1,526 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#if defined(__linux__) || defined(__ANDROID__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define AMY_UNIX_SOCKET_POLL_MS 50 +#define AMY_UNIX_SOCKET_BACKPRESSURE_POLL_MS 1 + +struct amy_unix_socket_packet { + uint16_t len; + char data[MAX_MESSAGE_LEN]; +}; + +struct amy_unix_socket_server { + int listen_fd; + int client_fd; + pthread_t thread; + pthread_mutex_t client_lock; + bool thread_started; + volatile uint32_t running; + + char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; + dev_t path_device; + ino_t path_inode; + bool path_bound; + + struct amy_unix_socket_packet queue[AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + volatile uint32_t write_index; + volatile uint32_t read_index; + + volatile uint32_t queue_overruns; + volatile uint32_t oversize_packets; + volatile uint32_t rejected_peers; +}; + +static uint32_t load_u32(const volatile uint32_t *value) { + return __atomic_load_n(value, __ATOMIC_ACQUIRE); +} + +static void store_u32(volatile uint32_t *value, uint32_t new_value) { + __atomic_store_n(value, new_value, __ATOMIC_RELEASE); +} + +static void increment_u32(volatile uint32_t *value) { + __atomic_add_fetch(value, 1u, __ATOMIC_RELAXED); +} + +static int set_nonblocking_cloexec(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) return -errno; + + flags = fcntl(fd, F_GETFD, 0); + if (flags < 0) return -errno; + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) return -errno; + return 0; +} + +static void fill_socket_address(struct sockaddr_un *addr, const char *path) { + size_t path_len = strlen(path); + memset(addr, 0, sizeof(*addr)); + addr->sun_family = AF_UNIX; + memcpy(addr->sun_path, path, path_len + 1u); +} + +static int remove_owned_stale_socket(const char *path) { + struct stat st; + if (lstat(path, &st) < 0) { + return errno == ENOENT ? 0 : -errno; + } + + if (!S_ISSOCK(st.st_mode)) return -EEXIST; + if (st.st_uid != geteuid()) return -EPERM; + + // Do not steal the pathname from a live same-UID server. A pathname socket + // left behind after a crash refuses a connection; a listening server + // accepts it. The short-lived probe may be accepted and immediately see + // EOF, but it cannot replace or interrupt an existing client. + int probe_fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (probe_fd < 0) return -errno; + + struct sockaddr_un addr; + fill_socket_address(&addr, path); + int connect_rc = connect(probe_fd, (struct sockaddr *)&addr, sizeof(addr)); + int connect_errno = errno; + close(probe_fd); + + if (connect_rc == 0) return -EADDRINUSE; + if (connect_errno == ENOENT) return 0; + if (connect_errno != ECONNREFUSED) return -connect_errno; + + if (unlink(path) < 0) return -errno; + return 0; +} + +static int remember_bound_socket(amy_unix_socket_server_t *server) { + struct stat st; + if (lstat(server->path, &st) < 0) return -errno; + if (!S_ISSOCK(st.st_mode) || st.st_uid != geteuid()) return -EPERM; + + server->path_device = st.st_dev; + server->path_inode = st.st_ino; + server->path_bound = true; + return 0; +} + +static void remove_bound_socket(amy_unix_socket_server_t *server) { + if (!server->path_bound) return; + + // Only unlink the exact filesystem node created by this server. This + // avoids deleting a regular file or a replacement socket if the pathname + // was removed and reused while the server was running. + struct stat st; + if (lstat(server->path, &st) == 0 && + S_ISSOCK(st.st_mode) && + st.st_uid == geteuid() && + st.st_dev == server->path_device && + st.st_ino == server->path_inode) { + unlink(server->path); + } + server->path_bound = false; +} + +static bool peer_has_same_uid(int fd) { + struct ucred cred; + socklen_t len = sizeof(cred); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) < 0) { + return false; + } + return cred.uid == geteuid(); +} + +static void close_client_locked(amy_unix_socket_server_t *server) { + if (server->client_fd >= 0) { + shutdown(server->client_fd, SHUT_RDWR); + close(server->client_fd); + server->client_fd = -1; + } +} + +static void close_client(amy_unix_socket_server_t *server) { + pthread_mutex_lock(&server->client_lock); + close_client_locked(server); + pthread_mutex_unlock(&server->client_lock); +} + +static void queue_packet(amy_unix_socket_server_t *server, + const char *data, + size_t len) { + if (len == 0) return; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + return; + } + + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + if ((uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY) { + increment_u32(&server->queue_overruns); + return; + } + + struct amy_unix_socket_packet *slot = + &server->queue[write_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + memcpy(slot->data, data, len); + slot->data[len] = '\0'; + slot->len = (uint16_t)len; + + store_u32(&server->write_index, write_index + 1u); +} + +static bool packet_queue_is_full(const amy_unix_socket_server_t *server) { + uint32_t write_index = load_u32(&server->write_index); + uint32_t read_index = load_u32(&server->read_index); + return (uint32_t)(write_index - read_index) >= + AMY_UNIX_SOCKET_QUEUE_CAPACITY; +} + +static void receive_client_packets(amy_unix_socket_server_t *server, + int client_fd) { + for (;;) { + // Leave unread packets in the kernel socket queue when the bounded + // realtime handoff queue is full. The connected sender then receives + // normal socket backpressure instead of a successful write for a + // control message that this process discarded. + if (packet_queue_is_full(server)) return; + + char packet[MAX_MESSAGE_LEN]; + ssize_t received = recv(client_fd, + packet, + sizeof(packet), + MSG_DONTWAIT | MSG_TRUNC); + if (received > 0) { + if ((size_t)received > AMY_UNIX_SOCKET_MAX_PACKET) { + increment_u32(&server->oversize_packets); + } else { + queue_packet(server, packet, (size_t)received); + } + continue; + } + + if (received == 0) { + close_client(server); + return; + } + + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + + close_client(server); + return; + } +} + +static void accept_clients(amy_unix_socket_server_t *server) { + for (;;) { + int fd = accept(server->listen_fd, NULL, NULL); + if (fd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + if (errno == EINTR) continue; + return; + } + + if (set_nonblocking_cloexec(fd) < 0 || !peer_has_same_uid(fd)) { + increment_u32(&server->rejected_peers); + close(fd); + continue; + } + + pthread_mutex_lock(&server->client_lock); + if (server->client_fd >= 0) { + increment_u32(&server->rejected_peers); + close(fd); + } else { + server->client_fd = fd; + } + pthread_mutex_unlock(&server->client_lock); + } +} + +static int current_client_fd(amy_unix_socket_server_t *server) { + int fd; + pthread_mutex_lock(&server->client_lock); + fd = server->client_fd; + pthread_mutex_unlock(&server->client_lock); + return fd; +} + +static void *socket_thread(void *arg) { + amy_unix_socket_server_t *server = + (amy_unix_socket_server_t *)arg; + + while (load_u32(&server->running)) { + struct pollfd fds[2]; + nfds_t count = 1; + + fds[0].fd = server->listen_fd; + fds[0].events = POLLIN; + fds[0].revents = 0; + + int client_fd = current_client_fd(server); + bool queue_full = + client_fd >= 0 && packet_queue_is_full(server); + if (client_fd >= 0 && !queue_full) { + fds[1].fd = client_fd; + fds[1].events = POLLIN; + fds[1].revents = 0; + count = 2; + } + + // Do not poll a readable client while the handoff queue is full: that + // would spin. Recheck quickly so the consumer can release + // backpressure without adding a full control-poll interval of latency. + int timeout_ms = queue_full + ? AMY_UNIX_SOCKET_BACKPRESSURE_POLL_MS + : AMY_UNIX_SOCKET_POLL_MS; + int ready = poll(fds, count, timeout_ms); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + + if (fds[0].revents & POLLIN) accept_clients(server); + + if (count == 2) { + if (fds[1].revents & POLLIN) { + receive_client_packets(server, client_fd); + } + if (fds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) { + close_client(server); + } + } + } + + close_client(server); + return NULL; +} + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + if (out_server == NULL || path == NULL || path[0] == '\0') return -EINVAL; + *out_server = NULL; + + size_t path_len = strlen(path); + if (path_len >= sizeof(((struct sockaddr_un *)0)->sun_path)) { + return -ENAMETOOLONG; + } + + int rc = remove_owned_stale_socket(path); + if (rc < 0) return rc; + + amy_unix_socket_server_t *server = calloc(1, sizeof(*server)); + if (server == NULL) return -ENOMEM; + + server->listen_fd = -1; + server->client_fd = -1; + memcpy(server->path, path, path_len + 1u); + + int mutex_rc = pthread_mutex_init(&server->client_lock, NULL); + if (mutex_rc != 0) { + free(server); + return -mutex_rc; + } + + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (fd < 0) { + rc = -errno; + goto fail; + } + server->listen_fd = fd; + + rc = set_nonblocking_cloexec(fd); + if (rc < 0) goto fail; + + struct sockaddr_un addr; + fill_socket_address(&addr, path); + + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + rc = -errno; + goto fail; + } + + rc = remember_bound_socket(server); + if (rc < 0) goto fail; + + // The Android app-data parent directory is already sandboxed. Mode 0600 + // additionally makes filesystem pathname access same-UID only. + if (chmod(path, S_IRUSR | S_IWUSR) < 0) { + rc = -errno; + goto fail; + } + + if (listen(fd, 1) < 0) { + rc = -errno; + goto fail; + } + + store_u32(&server->running, 1u); + int thread_rc = pthread_create(&server->thread, NULL, + socket_thread, server); + if (thread_rc != 0) { + rc = -thread_rc; + store_u32(&server->running, 0u); + goto fail; + } + server->thread_started = true; + + *out_server = server; + return 0; + +fail: + if (server->listen_fd >= 0) close(server->listen_fd); + remove_bound_socket(server); + pthread_mutex_destroy(&server->client_lock); + free(server); + return rc; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + if (server == NULL) return; + + store_u32(&server->running, 0u); + if (server->thread_started) { + pthread_join(server->thread, NULL); + } + + if (server->listen_fd >= 0) { + close(server->listen_fd); + server->listen_fd = -1; + } + + remove_bound_socket(server); + pthread_mutex_destroy(&server->client_lock); + free(server); +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + if (server == NULL || out == NULL) return -EINVAL; + + uint32_t read_index = load_u32(&server->read_index); + uint32_t write_index = load_u32(&server->write_index); + if (read_index == write_index) return 0; + + const struct amy_unix_socket_packet *slot = + &server->queue[read_index % AMY_UNIX_SOCKET_QUEUE_CAPACITY]; + size_t len = slot->len; + if (out_len <= len) return -EMSGSIZE; + + memcpy(out, slot->data, len); + out[len] = '\0'; + store_u32(&server->read_index, read_index + 1u); + return (int)len; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + if (server == NULL || (data == NULL && len != 0)) return -EINVAL; + if (len > AMY_UNIX_SOCKET_MAX_PACKET) return -EMSGSIZE; + + pthread_mutex_lock(&server->client_lock); + int fd = server->client_fd; + if (fd < 0) { + pthread_mutex_unlock(&server->client_lock); + return -ENOTCONN; + } + + ssize_t sent = send(fd, data, len, + MSG_DONTWAIT | MSG_NOSIGNAL); + int saved_errno = errno; + pthread_mutex_unlock(&server->client_lock); + + if (sent < 0) return -saved_errno; + return (int)sent; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->queue_overruns); +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->oversize_packets); +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + return server == NULL ? 0u : load_u32(&server->rejected_peers); +} + +#else + +#include + +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path) { + (void)out_server; + (void)path; + return -ENOTSUP; +} + +void amy_unix_socket_stop(amy_unix_socket_server_t *server) { + (void)server; +} + +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len) { + (void)server; + (void)out; + (void)out_len; + return -ENOTSUP; +} + +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len) { + (void)server; + (void)data; + (void)len; + return -ENOTSUP; +} + +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server) { + (void)server; + return 0u; +} + +#endif diff --git a/src/amy_unix_socket.h b/src/amy_unix_socket.h new file mode 100644 index 00000000..97db712d --- /dev/null +++ b/src/amy_unix_socket.h @@ -0,0 +1,73 @@ +#ifndef AMY_UNIX_SOCKET_H +#define AMY_UNIX_SOCKET_H + +#include +#include + +#include "amy.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Private pathname AF_UNIX transport for local AMY control. +// +// Typical service topology: +// application process <-> amy.sock <-> native AMY/audio process +// +// The socket thread never calls AMY. It only copies complete SOCK_SEQPACKET +// packets into this fixed SPSC queue. When that queue is full, packets remain +// unread in the kernel socket queue so normal socket backpressure preserves +// every accepted control message. The audio/control owner drains packets +// explicitly at a safe point (for example, immediately before rendering the +// next AMY block) and may then pass them to amy_add_message(). +// +// One connected client is supported at a time. On Linux/Android, accepted +// peers must have the same effective UID as the server process. The pathname +// is created mode 0600. A stale socket is removed only when it is owned by the +// same UID and refuses a connection; a live listener, an existing non-socket +// path, and any pathname that replaces the running server's node are preserved. + +#define AMY_UNIX_SOCKET_QUEUE_CAPACITY 64u +#define AMY_UNIX_SOCKET_MAX_PACKET ((size_t)MAX_MESSAGE_LEN - 1u) + +typedef struct amy_unix_socket_server amy_unix_socket_server_t; + +// Start a server at path. Returns 0 on success or -errno on failure. +// out_server is set only on success. +int amy_unix_socket_start(amy_unix_socket_server_t **out_server, + const char *path); + +// Stop the receiver thread, close any client, unlink the socket pathname and +// free the server. Safe to call with NULL. +void amy_unix_socket_stop(amy_unix_socket_server_t *server); + +// Non-blocking dequeue for the AMY/control owner. +// Returns payload length (>0), 0 when no packet is queued, or -errno. +// On success out is NUL-terminated; packet payloads themselves need not carry +// a trailing NUL. If out_len is too small, returns -EMSGSIZE and leaves the +// packet queued. +int amy_unix_socket_receive(amy_unix_socket_server_t *server, + char *out, + size_t out_len); + +// Send one reply packet to the currently connected client. This is intended +// for non-realtime status/introspection replies, not the audio callback. +// Returns bytes sent or -errno. The accepted client socket is non-blocking. +int amy_unix_socket_send(amy_unix_socket_server_t *server, + const void *data, + size_t len); + +// Diagnostic counters. They are monotonic until the server is stopped. +uint32_t amy_unix_socket_queue_overruns( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_oversize_packets( + const amy_unix_socket_server_t *server); +uint32_t amy_unix_socket_rejected_peers( + const amy_unix_socket_server_t *server); + +#ifdef __cplusplus +} +#endif + +#endif // AMY_UNIX_SOCKET_H diff --git a/src/api.c b/src/api.c index fd70fbef..83def091 100644 --- a/src/api.c +++ b/src/api.c @@ -2,6 +2,7 @@ // C callable entry points to amy #include "amy.h" +#include "sequencer.h" amy_config_t amy_default_config() { amy_config_t c; @@ -48,6 +49,15 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; + c.max_sequence_events = 64; + c.max_sequence_executions = 32; + c.max_reverb_rooms = 0; + c.reverb_room_memory = NULL; + c.reverb_room_memory_bytes = 0; + c.reverb_diagnostics = 0; + c.aux_return_external = NULL; + c.amy_external_aux_return_process_hook = NULL; + c.amy_external_aux_return_user_data = NULL; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -203,6 +213,13 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->reverb_liveness); AMY_UNSET(e->reverb_damping); AMY_UNSET(e->reverb_xover_hz); + AMY_UNSET(e->reverb_room); + AMY_UNSET(e->reverb_room_level); + AMY_UNSET(e->reverb_room_liveness); + AMY_UNSET(e->reverb_room_damping); + AMY_UNSET(e->reverb_room_xover_hz); + AMY_UNSET(e->reverb_send_room); + AMY_UNSET(e->reverb_send_level); AMY_UNSET(e->oscs_per_voice); } @@ -291,6 +308,7 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Transfer status can't change mid-message, so the whole string is // one chunk of transfer payload. parse_transfer_message(message, (uint16_t)strlen(message)); + sequencer_reclaim_retired(); return; } // Fast pre-check of this message for a leading 'H' (ticks) scheduling @@ -301,6 +319,10 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Not scheduled: parse and play every command in the message now. amy_play_message(message); } + // Public wire ingestion is a control-side boundary. Sequence playback uses + // amy_play_message()/handle_ticks_message() directly, so it can never enter + // this reclamation path from the render thread. + sequencer_reclaim_retired(); } // given a wire message string play / schedule the event directly (WIRE API) @@ -308,6 +330,16 @@ void amy_add_message(char *message) { amy_add_message_with_sysex_flag(message, /* sysex */ false); } +void amy_add_message_from_render(char *message) { + if (message[0] == 'H') { + handle_ticks_message_with_origin( + message, SEQUENCER_ORIGIN_RENDER, + amy_global.sequencer_tick_count); + } else { + amy_play_message(message); + } +} + // Like amy_add_message but marks the message as coming from an external // sysex source so the transfer routing in amy_message_is_transfer_chunk() // applies. diff --git a/src/cv_trigger.c b/src/cv_trigger.c index 4f9bd6a4..3fd3ae13 100644 --- a/src/cv_trigger.c +++ b/src/cv_trigger.c @@ -116,7 +116,7 @@ void cv_trigger_generate_events(float *cv_inputs) { char message[AMY_WIRE_COMMAND_LEN]; substitute_midi_special_values(message, cv_trig->message_template, 0, 0, note); //fprintf(stderr, "update_external_cv_in: message %s\n", message); - amy_add_message(message); + amy_add_message_from_render(message); } } } else if ((polarity * cv_val) < (polarity * cv_trig->thresh_reset)) { diff --git a/src/delay.c b/src/delay.c index 33dd253c..a16345e5 100644 --- a/src/delay.c +++ b/src/delay.c @@ -199,12 +199,15 @@ void apply_fixed_delay(SAMPLE *block, delay_line_t *delay_line, uint32_t delay_s reverb_params_t *new_reverb() { reverb_params_t *rev = malloc_caps(sizeof(reverb_params_t), amy_global.config.ram_caps_synth); + if (rev == NULL) return NULL; bzero(rev, sizeof(reverb_params_t)); + rev->heap_owned = 1; + rev->delay_lines_heap_owned = 1; return rev; } void delete_reverb(reverb_params_t *rev) { - if(rev) free(rev); + if(rev && rev->heap_owned) free(rev); } void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossover_hz, float damping) { @@ -241,6 +244,7 @@ void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossove bool init_stereo_reverb(reverb_params_t *rev) { + if (rev == NULL) return false; if (rev->delay_1 != NULL) return true; // already initialised @@ -269,18 +273,107 @@ bool init_stereo_reverb(reverb_params_t *rev) { } void deinit_stereo_reverb(reverb_params_t *rev) { - if (rev->delay_1 != NULL) { - free(rev->delay_1); rev->delay_1 = NULL; - free(rev->delay_2); rev->delay_2 = NULL; - free(rev->delay_3); rev->delay_3 = NULL; - free(rev->delay_4); rev->delay_4 = NULL; - free(rev->ref_1); rev->ref_1 = NULL; - free(rev->ref_2); rev->ref_2 = NULL; - free(rev->ref_3); rev->ref_3 = NULL; - free(rev->ref_4); rev->ref_4 = NULL; - free(rev->ref_5); rev->ref_5 = NULL; - free(rev->ref_6); rev->ref_6 = NULL; - } + if (rev == NULL) return; +#define RELEASE_REVERB_LINE(FIELD) do { \ + if (rev->delay_lines_heap_owned && rev->FIELD != NULL) \ + free_delay_line(rev->FIELD); \ + rev->FIELD = NULL; \ + } while (0) + RELEASE_REVERB_LINE(delay_1); + RELEASE_REVERB_LINE(delay_2); + RELEASE_REVERB_LINE(delay_3); + RELEASE_REVERB_LINE(delay_4); + RELEASE_REVERB_LINE(ref_1); + RELEASE_REVERB_LINE(ref_2); + RELEASE_REVERB_LINE(ref_3); + RELEASE_REVERB_LINE(ref_4); + RELEASE_REVERB_LINE(ref_5); + RELEASE_REVERB_LINE(ref_6); +#undef RELEASE_REVERB_LINE +} + +typedef struct { + uint8_t *next; + uint8_t *end; +} reverb_arena_cursor_t; + +static void *reverb_arena_take(reverb_arena_cursor_t *cursor, size_t bytes, + size_t alignment) { + uintptr_t aligned = ((uintptr_t)cursor->next + alignment - 1) + & ~(uintptr_t)(alignment - 1); + if (aligned > (uintptr_t)cursor->end + || bytes > (size_t)((uintptr_t)cursor->end - aligned)) return NULL; + cursor->next = (uint8_t *)(aligned + bytes); + return (void *)aligned; +} + +static delay_line_t *reverb_arena_delay_line(reverb_arena_cursor_t *cursor, + int len, int fixed_delay) { + if (is_power_of_two(len) < 0) return NULL; + delay_line_t *line = reverb_arena_take( + cursor, sizeof(delay_line_t), _Alignof(delay_line_t)); + SAMPLE *samples = reverb_arena_take( + cursor, (size_t)len * sizeof(SAMPLE), _Alignof(SAMPLE)); + if (line == NULL || samples == NULL) return NULL; + *line = (delay_line_t){ + .samples = samples, + .len = len, + .log_2_len = is_power_of_two(len), + .fixed_delay = fixed_delay, + .next_in = 0, + }; + bzero(samples, (size_t)len * sizeof(SAMPLE)); + return line; +} + +reverb_params_t *new_reverb_in_arena(void *arena, size_t arena_bytes, + SAMPLE **workspace, size_t *used_bytes) { + if (workspace != NULL) *workspace = NULL; + if (used_bytes != NULL) *used_bytes = 0; + if (arena == NULL || arena_bytes == 0 || workspace == NULL) return NULL; + + reverb_arena_cursor_t cursor = { + .next = (uint8_t *)arena, + .end = (uint8_t *)arena + arena_bytes, + }; + reverb_params_t *rev = reverb_arena_take( + &cursor, sizeof(reverb_params_t), _Alignof(reverb_params_t)); + if (rev == NULL) return NULL; + bzero(rev, sizeof(*rev)); + + // Keep the block input/output beside the delay network. On banked SRAM + // targets this guarantees the complete hot working set belongs to the + // room's reserved arena rather than the general heap. + *workspace = reverb_arena_take( + &cursor, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + _Alignof(SAMPLE)); + if (*workspace == NULL) return NULL; + bzero(*workspace, sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS); + +#define ARENA_REVERB_LINE(FIELD, LEN, DELAY) \ + do { \ + rev->FIELD = reverb_arena_delay_line(&cursor, (LEN), (DELAY)); \ + if (rev->FIELD == NULL) return NULL; \ + } while (0) + ARENA_REVERB_LINE(delay_1, DELAY_POW2, DELAY1SAMPS); + ARENA_REVERB_LINE(delay_2, DELAY_POW2, DELAY2SAMPS); + ARENA_REVERB_LINE(delay_3, DELAY_POW2, DELAY3SAMPS); + ARENA_REVERB_LINE(delay_4, DELAY_POW2, DELAY4SAMPS); + ARENA_REVERB_LINE(ref_1, 4096, REF1SAMPS); + ARENA_REVERB_LINE(ref_2, 2048, REF2SAMPS); + ARENA_REVERB_LINE(ref_3, 2048, REF3SAMPS); + ARENA_REVERB_LINE(ref_4, 1024, REF4SAMPS); + ARENA_REVERB_LINE(ref_5, 1024, REF5SAMPS); + ARENA_REVERB_LINE(ref_6, 1024, REF6SAMPS); +#undef ARENA_REVERB_LINE + + rev->heap_owned = 0; + rev->delay_lines_heap_owned = 0; + config_stereo_reverb( + rev, INITIAL_LIVENESS, INITIAL_XOVER_HZ, INITIAL_DAMPING); + if (used_bytes != NULL) + *used_bytes = (size_t)(cursor.next - (uint8_t *)arena); + return rev; } // Cache one delay line's state in locals for the reverb loop, the same way @@ -308,7 +401,9 @@ void deinit_stereo_reverb(reverb_params_t *rev) { #define DL_WRITE(P, val) do { P##_s[P##_n] = (val); P##_n = (P##_n + 1) & P##_m; } while (0) #define DL_READ(P) (P##_s[(P##_n - P##_f) & P##_m]) -void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, int n_samples, SAMPLE level) { +static void stereo_reverb_core(reverb_params_t *rev, SAMPLE *r_in, + SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, + int n_samples, SAMPLE level, bool include_dry) { // Stereo reverb. *{r,l}_in each point to n_samples input samples. // n_samples are written to {r,l}_out. // Recreate @@ -376,12 +471,13 @@ void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_o SAMPLE d1 = DL_READ(dl1); d1 = LPF(d1, &f1state, lpfcoef, lpfgain, liveness); d1 += r_acc; - *r_out++ = in_r + MUL8_SS(level, d1); + *r_out++ = (include_dry ? in_r : 0) + MUL8_SS(level, d1); SAMPLE d2 = DL_READ(dl2); d2 = LPF(d2, &f2state, lpfcoef, lpfgain, liveness); d2 += l_acc; - if (l_out != NULL) *l_out++ = in_l + MUL8_SS(level, d2); + if (l_out != NULL) + *l_out++ = (include_dry ? in_l : 0) + MUL8_SS(level, d2); SAMPLE d3 = DL_READ(dl3); d3 = LPF(d3, &f3state, lpfcoef, lpfgain, liveness); @@ -412,3 +508,17 @@ void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_o rev->f3state = f3state; rev->f4state = f4state; } + +void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level) { + stereo_reverb_core( + rev, r_in, l_in, r_out, l_out, n_samples, level, true); +} + +void stereo_reverb_wet(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level) { + stereo_reverb_core( + rev, r_in, l_in, r_out, l_out, n_samples, level, false); +} diff --git a/src/delay.h b/src/delay.h index d1dfdc4c..69162c45 100644 --- a/src/delay.h +++ b/src/delay.h @@ -14,10 +14,18 @@ void apply_variable_delay(SAMPLE *block, delay_line_t *delay_line, SAMPLE *delay void apply_fixed_delay(SAMPLE *block, delay_line_t *delay_line, uint32_t delay_samples, SAMPLE mix_level, SAMPLE feedback, SAMPLE filter_coef); reverb_params_t *new_reverb(); +// Construct a complete reverb plus its block workspace inside one fixed arena. +// No allocation from the general heap occurs. Returns NULL when the arena is +// too small; used_bytes reports the exact high-water mark on success. +reverb_params_t *new_reverb_in_arena(void *arena, size_t arena_bytes, + SAMPLE **workspace, size_t *used_bytes); void delete_reverb(reverb_params_t *rev); void config_stereo_reverb(reverb_params_t *rev, float a_liveness, float crossover_hz, float damping); bool init_stereo_reverb(reverb_params_t *rev); void deinit_stereo_reverb(reverb_params_t *rev); void stereo_reverb(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, SAMPLE *r_out, SAMPLE *l_out, int n_samples, SAMPLE level); +void stereo_reverb_wet(reverb_params_t *rev, SAMPLE *r_in, SAMPLE *l_in, + SAMPLE *r_out, SAMPLE *l_out, int n_samples, + SAMPLE level); #endif // !_DELAY_H diff --git a/src/i2s.c b/src/i2s.c index 110bea10..f6c8a47e 100644 --- a/src/i2s.c +++ b/src/i2s.c @@ -70,6 +70,12 @@ i2s_chan_handle_t rx_handle; // default ESP setup i2s amy_err_t esp32_setup_i2s(void) { i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER); +#ifdef AMY_ESP_I2S_DMA_DESC_NUM + chan_cfg.dma_desc_num = AMY_ESP_I2S_DMA_DESC_NUM; +#endif +#ifdef AMY_ESP_I2S_DMA_FRAME_NUM + chan_cfg.dma_frame_num = AMY_ESP_I2S_DMA_FRAME_NUM; +#endif if(AMY_HAS_AUDIO_IN) { i2s_new_channel(&chan_cfg, &tx_handle, &rx_handle); } else { @@ -81,7 +87,11 @@ amy_err_t esp32_setup_i2s(void) { #ifdef I2S_32BIT i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AMY_SAMPLE_RATE), +#ifdef AMY_ESP_I2S_PHILIPS_FORMAT + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO), +#else .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO), +#endif .gpio_cfg = { .mclk = (amy_global.config.i2s_mclk == -1)? I2S_GPIO_UNUSED : amy_global.config.i2s_mclk, .bclk = amy_global.config.i2s_bclk, @@ -98,7 +108,11 @@ amy_err_t esp32_setup_i2s(void) { #else // 16 bit I2S i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AMY_SAMPLE_RATE), +#ifdef AMY_ESP_I2S_PHILIPS_FORMAT + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), +#else .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), +#endif .gpio_cfg = { .mclk = (amy_global.config.i2s_mclk == -1)? I2S_GPIO_UNUSED : amy_global.config.i2s_mclk, .bclk = amy_global.config.i2s_bclk, @@ -264,11 +278,386 @@ TaskHandle_t amy_update_handle = NULL; // caller combine the worker's half-written buffer. static SemaphoreHandle_t esp_render_done_sem = NULL; +typedef enum { + AMY_WORKER_RENDER_OSCS = 0, + AMY_WORKER_BUS_SUBSET_0, + AMY_WORKER_REVERB_ROOM_0, +} amy_worker_job_t; + +static volatile amy_worker_job_t amy_worker_job = AMY_WORKER_RENDER_OSCS; + +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +static amy_esp_load_diagnostic_t esp_load_diagnostic; +static volatile uint32_t esp_load_diagnostic_seq; +static amy_esp_load_diagnostic_t esp_load_print_baseline; +static volatile uint32_t esp_render_core_us[2]; +extern uint32_t amy_last_executed_delta_count; +extern uint32_t amy_last_sequencer_us; +extern uint32_t amy_last_flush_us; +extern uint16_t amy_last_audible_osc_count[2]; +extern uint32_t amy_last_sequence_root_us; +extern uint32_t amy_last_sequence_control_us; +extern uint32_t amy_last_sequence_event_us; +extern uint32_t amy_last_sequence_tick_count; + +static void esp_load_diagnostic_record(uint32_t execute_us, + uint32_t render_us, + uint32_t fill_us, + uint32_t total_us, + uint32_t blocked_us, + uint32_t overload_debt_us, + bool overload_yielded) { + amy_esp_load_diagnostic_t *stats = &esp_load_diagnostic; + ++esp_load_diagnostic_seq; + amy_memory_fence(); + stats->execute_sum_us += execute_us; + stats->sequencer_sum_us += amy_last_sequencer_us; + stats->flush_sum_us += amy_last_flush_us; + stats->sequence_root_sum_us += amy_last_sequence_root_us; + stats->sequence_control_sum_us += amy_last_sequence_control_us; + stats->sequence_event_sum_us += amy_last_sequence_event_us; + stats->sequence_tick_sum += amy_last_sequence_tick_count; + stats->render_sum_us += render_us; + for (uint8_t core = 0; core < 2; ++core) { + uint32_t core_us = esp_render_core_us[core]; + stats->render_core_sum_us[core] += core_us; + if (core_us > stats->render_core_max_us[core]) + stats->render_core_max_us[core] = core_us; + stats->audible_osc_sum[core] += amy_last_audible_osc_count[core]; + if (amy_last_audible_osc_count[core] > stats->audible_osc_max[core]) + stats->audible_osc_max[core] = amy_last_audible_osc_count[core]; + } + stats->fill_sum_us += fill_us; + stats->total_sum_us += total_us; + stats->executed_delta_sum += amy_last_executed_delta_count; + if (blocked_us < 150) ++stats->i2s_unpaced_blocks; + if (overload_debt_us > stats->overload_debt_max_us) + stats->overload_debt_max_us = overload_debt_us; + if (overload_yielded) ++stats->overload_yields; + if (amy_last_executed_delta_count > stats->executed_delta_max) + stats->executed_delta_max = amy_last_executed_delta_count; + if (execute_us > stats->execute_max_us) stats->execute_max_us = execute_us; + if (amy_last_sequencer_us > stats->sequencer_max_us) + stats->sequencer_max_us = amy_last_sequencer_us; + if (amy_last_flush_us > stats->flush_max_us) + stats->flush_max_us = amy_last_flush_us; + if (amy_last_sequence_root_us > stats->sequence_root_max_us) + stats->sequence_root_max_us = amy_last_sequence_root_us; + if (amy_last_sequence_control_us > stats->sequence_control_max_us) + stats->sequence_control_max_us = amy_last_sequence_control_us; + if (amy_last_sequence_event_us > stats->sequence_event_max_us) + stats->sequence_event_max_us = amy_last_sequence_event_us; + if (amy_last_sequence_tick_count > stats->sequence_tick_max) + stats->sequence_tick_max = amy_last_sequence_tick_count; + if (render_us > stats->render_max_us) stats->render_max_us = render_us; + if (fill_us > stats->fill_max_us) stats->fill_max_us = fill_us; + if (total_us > stats->total_max_us) stats->total_max_us = total_us; + if (total_us >= (AMY_BLOCK_US * 9u) / 10u) + ++stats->total_near_deadline; + if (total_us > AMY_BLOCK_US) { + ++stats->total_deadline_misses; + stats->missed_execute_sum_us += execute_us; + stats->missed_sequencer_sum_us += amy_last_sequencer_us; + stats->missed_flush_sum_us += amy_last_flush_us; + stats->missed_sequence_root_sum_us += amy_last_sequence_root_us; + stats->missed_sequence_control_sum_us += amy_last_sequence_control_us; + stats->missed_sequence_event_sum_us += amy_last_sequence_event_us; + stats->missed_sequence_tick_sum += amy_last_sequence_tick_count; + stats->missed_render_sum_us += render_us; + stats->missed_fill_sum_us += fill_us; + stats->missed_total_sum_us += total_us; + stats->missed_executed_delta_sum += amy_last_executed_delta_count; + if (execute_us > stats->missed_execute_max_us) + stats->missed_execute_max_us = execute_us; + if (amy_last_sequencer_us > stats->missed_sequencer_max_us) + stats->missed_sequencer_max_us = amy_last_sequencer_us; + if (amy_last_flush_us > stats->missed_flush_max_us) + stats->missed_flush_max_us = amy_last_flush_us; + if (amy_last_sequence_root_us > stats->missed_sequence_root_max_us) + stats->missed_sequence_root_max_us = amy_last_sequence_root_us; + if (amy_last_sequence_control_us + > stats->missed_sequence_control_max_us) + stats->missed_sequence_control_max_us = + amy_last_sequence_control_us; + if (amy_last_sequence_event_us > stats->missed_sequence_event_max_us) + stats->missed_sequence_event_max_us = amy_last_sequence_event_us; + if (amy_last_sequence_tick_count > stats->missed_sequence_tick_max) + stats->missed_sequence_tick_max = amy_last_sequence_tick_count; + if (render_us > stats->missed_render_max_us) + stats->missed_render_max_us = render_us; + if (fill_us > stats->missed_fill_max_us) + stats->missed_fill_max_us = fill_us; + if (amy_last_executed_delta_count > stats->missed_executed_delta_max) + stats->missed_executed_delta_max = amy_last_executed_delta_count; + for (uint8_t core = 0; core < 2; ++core) { + stats->missed_audible_osc_sum[core] += + amy_last_audible_osc_count[core]; + if (amy_last_audible_osc_count[core] + > stats->missed_audible_osc_max[core]) + stats->missed_audible_osc_max[core] = + amy_last_audible_osc_count[core]; + } + } + ++stats->blocks; + amy_memory_fence(); + ++esp_load_diagnostic_seq; +} + +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { + if (result == NULL) return false; + for (int attempt = 0; attempt < 8; ++attempt) { + uint32_t before = esp_load_diagnostic_seq; + if (before & 1u) continue; + amy_memory_fence(); + *result = esp_load_diagnostic; + amy_memory_fence(); + if (before == esp_load_diagnostic_seq) return true; + } + return false; +} + +void amy_esp_load_diagnostics_print(void) { + amy_esp_load_diagnostic_t stats; + if (!amy_esp_load_diagnostics_get(&stats) || stats.blocks == 0) { + fprintf(stderr, "AMY ESP load: no samples\n"); + return; + } + uint32_t blocks = stats.blocks; + uint32_t interval_blocks = blocks - esp_load_print_baseline.blocks; + uint64_t interval_execute_us = + stats.execute_sum_us - esp_load_print_baseline.execute_sum_us; + uint64_t interval_render_us = + stats.render_sum_us - esp_load_print_baseline.render_sum_us; + uint64_t interval_render_core_us[2] = { + stats.render_core_sum_us[0] + - esp_load_print_baseline.render_core_sum_us[0], + stats.render_core_sum_us[1] + - esp_load_print_baseline.render_core_sum_us[1], + }; + uint64_t interval_fill_us = + stats.fill_sum_us - esp_load_print_baseline.fill_sum_us; + uint64_t interval_total_us = + stats.total_sum_us - esp_load_print_baseline.total_sum_us; + uint32_t interval_near = + stats.total_near_deadline - esp_load_print_baseline.total_near_deadline; + uint32_t interval_misses = + stats.total_deadline_misses + - esp_load_print_baseline.total_deadline_misses; + uint64_t interval_unpaced = + stats.i2s_unpaced_blocks + - esp_load_print_baseline.i2s_unpaced_blocks; + uint32_t interval_yields = + stats.overload_yields - esp_load_print_baseline.overload_yields; + uint64_t interval_missed_execute_us = + stats.missed_execute_sum_us + - esp_load_print_baseline.missed_execute_sum_us; + uint64_t interval_missed_render_us = + stats.missed_render_sum_us + - esp_load_print_baseline.missed_render_sum_us; + uint64_t interval_missed_fill_us = + stats.missed_fill_sum_us + - esp_load_print_baseline.missed_fill_sum_us; + uint64_t interval_missed_total_us = + stats.missed_total_sum_us + - esp_load_print_baseline.missed_total_sum_us; + uint64_t interval_delta_count = + stats.executed_delta_sum + - esp_load_print_baseline.executed_delta_sum; + uint64_t interval_missed_delta_count = + stats.missed_executed_delta_sum + - esp_load_print_baseline.missed_executed_delta_sum; + uint64_t interval_sequencer_us = + stats.sequencer_sum_us - esp_load_print_baseline.sequencer_sum_us; + uint64_t interval_flush_us = + stats.flush_sum_us - esp_load_print_baseline.flush_sum_us; + uint64_t interval_missed_sequencer_us = + stats.missed_sequencer_sum_us + - esp_load_print_baseline.missed_sequencer_sum_us; + uint64_t interval_missed_flush_us = + stats.missed_flush_sum_us + - esp_load_print_baseline.missed_flush_sum_us; + uint64_t interval_sequence_root_us = + stats.sequence_root_sum_us + - esp_load_print_baseline.sequence_root_sum_us; + uint64_t interval_sequence_control_us = + stats.sequence_control_sum_us + - esp_load_print_baseline.sequence_control_sum_us; + uint64_t interval_sequence_event_us = + stats.sequence_event_sum_us + - esp_load_print_baseline.sequence_event_sum_us; + uint64_t interval_sequence_ticks = + stats.sequence_tick_sum - esp_load_print_baseline.sequence_tick_sum; + uint64_t interval_missed_sequence_root_us = + stats.missed_sequence_root_sum_us + - esp_load_print_baseline.missed_sequence_root_sum_us; + uint64_t interval_missed_sequence_control_us = + stats.missed_sequence_control_sum_us + - esp_load_print_baseline.missed_sequence_control_sum_us; + uint64_t interval_missed_sequence_event_us = + stats.missed_sequence_event_sum_us + - esp_load_print_baseline.missed_sequence_event_sum_us; + uint64_t interval_missed_sequence_ticks = + stats.missed_sequence_tick_sum + - esp_load_print_baseline.missed_sequence_tick_sum; + uint64_t interval_audible_osc[2]; + uint64_t interval_missed_audible_osc[2]; + for (uint8_t core = 0; core < 2; ++core) { + interval_audible_osc[core] = + stats.audible_osc_sum[core] + - esp_load_print_baseline.audible_osc_sum[core]; + interval_missed_audible_osc[core] = + stats.missed_audible_osc_sum[core] + - esp_load_print_baseline.missed_audible_osc_sum[core]; + } + fprintf(stderr, + "AMY ESP load: blocks=%u avg_us execute=%u render=%u fill=%u total=%u " + "max_us execute=%u render=%u fill=%u total=%u " + "near_deadline=%u deadline_misses=%u " + "interval_blocks=%u " + "interval_avg_us execute=%u render=%u fill=%u total=%u " + "interval_near_deadline=%u interval_deadline_misses=%u " + "i2s_unpaced=%" PRIu64 " interval_i2s_unpaced=%" PRIu64 " " + "overload_debt_max_us=%u overload_yields=%u " + "interval_overload_yields=%u " + "miss_avg_us execute=%u render=%u fill=%u total=%u " + "miss_stage_max_us execute=%u render=%u fill=%u " + "interval_render_core_avg_us core0=%u core1=%u " + "render_core_max_us core0=%u core1=%u " + "deltas_avg=%u deltas_max=%u " + "miss_deltas_avg=%u miss_deltas_max=%u " + "interval_execute_detail_avg_us sequencer=%u flush=%u " + "execute_detail_max_us sequencer=%u flush=%u " + "miss_execute_detail_avg_us sequencer=%u flush=%u " + "miss_execute_detail_max_us sequencer=%u flush=%u " + "audible_oscs_avg core0=%u core1=%u " + "audible_oscs_max core0=%u core1=%u " + "miss_audible_oscs_avg core0=%u core1=%u " + "miss_audible_oscs_max core0=%u core1=%u " + "sequence_tick_avg_us root=%u control=%u event=%u " + "sequence_stage_max_us root=%u control=%u event=%u ticks=%u " + "miss_sequence_tick_avg_us root=%u control=%u event=%u " + "miss_sequence_stage_max_us root=%u control=%u event=%u ticks=%u\n", + (unsigned)blocks, + (unsigned)(stats.execute_sum_us / blocks), + (unsigned)(stats.render_sum_us / blocks), + (unsigned)(stats.fill_sum_us / blocks), + (unsigned)(stats.total_sum_us / blocks), + (unsigned)stats.execute_max_us, (unsigned)stats.render_max_us, + (unsigned)stats.fill_max_us, (unsigned)stats.total_max_us, + (unsigned)stats.total_near_deadline, + (unsigned)stats.total_deadline_misses, + (unsigned)interval_blocks, + (unsigned)(interval_blocks + ? interval_execute_us / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_render_us / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_fill_us / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_total_us / interval_blocks : 0), + (unsigned)interval_near, (unsigned)interval_misses, + stats.i2s_unpaced_blocks, + interval_unpaced, + (unsigned)stats.overload_debt_max_us, + (unsigned)stats.overload_yields, + (unsigned)interval_yields, + (unsigned)(interval_misses + ? interval_missed_execute_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_render_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_fill_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_total_us / interval_misses : 0), + (unsigned)stats.missed_execute_max_us, + (unsigned)stats.missed_render_max_us, + (unsigned)stats.missed_fill_max_us, + (unsigned)(interval_blocks + ? interval_render_core_us[0] / interval_blocks : 0), + (unsigned)(interval_blocks + ? interval_render_core_us[1] / interval_blocks : 0), + (unsigned)stats.render_core_max_us[0], + (unsigned)stats.render_core_max_us[1], + (unsigned)(interval_blocks + ? interval_delta_count / interval_blocks : 0), + (unsigned)stats.executed_delta_max, + (unsigned)(interval_misses + ? interval_missed_delta_count / interval_misses : 0), + (unsigned)stats.missed_executed_delta_max, + (unsigned)(interval_blocks + ? interval_sequencer_us / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_flush_us / interval_blocks : 0), + (unsigned)stats.sequencer_max_us, + (unsigned)stats.flush_max_us, + (unsigned)(interval_misses + ? interval_missed_sequencer_us / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_flush_us / interval_misses : 0), + (unsigned)stats.missed_sequencer_max_us, + (unsigned)stats.missed_flush_max_us, + (unsigned)(interval_blocks ? interval_audible_osc[0] / interval_blocks : 0), + (unsigned)(interval_blocks ? interval_audible_osc[1] / interval_blocks : 0), + (unsigned)stats.audible_osc_max[0], + (unsigned)stats.audible_osc_max[1], + (unsigned)(interval_misses + ? interval_missed_audible_osc[0] / interval_misses : 0), + (unsigned)(interval_misses + ? interval_missed_audible_osc[1] / interval_misses : 0), + (unsigned)stats.missed_audible_osc_max[0], + (unsigned)stats.missed_audible_osc_max[1], + (unsigned)(interval_sequence_ticks + ? interval_sequence_root_us / interval_sequence_ticks : 0), + (unsigned)(interval_sequence_ticks + ? interval_sequence_control_us / interval_sequence_ticks : 0), + (unsigned)(interval_sequence_ticks + ? interval_sequence_event_us / interval_sequence_ticks : 0), + (unsigned)stats.sequence_root_max_us, + (unsigned)stats.sequence_control_max_us, + (unsigned)stats.sequence_event_max_us, + (unsigned)stats.sequence_tick_max, + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_root_us + / interval_missed_sequence_ticks : 0), + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_control_us + / interval_missed_sequence_ticks : 0), + (unsigned)(interval_missed_sequence_ticks + ? interval_missed_sequence_event_us + / interval_missed_sequence_ticks : 0), + (unsigned)stats.missed_sequence_root_max_us, + (unsigned)stats.missed_sequence_control_max_us, + (unsigned)stats.missed_sequence_event_max_us, + (unsigned)stats.missed_sequence_tick_max); + esp_load_print_baseline = stats; +} +#else +bool amy_esp_load_diagnostics_get(amy_esp_load_diagnostic_t *result) { + if (result != NULL) *result = (amy_esp_load_diagnostic_t){0}; + return false; +} + +void amy_esp_load_diagnostics_print(void) { + fprintf(stderr, "AMY ESP load diagnostics were not compiled in\n"); +} +#endif + // Render the second core void esp_render_task( void * pvParameters) { while(1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // from esp_render_on_cores - amy_render(0, AMY_OSCS/2, 1); + if (amy_worker_job == AMY_WORKER_BUS_SUBSET_0) + amy_process_bus_subset(0, 2); + else if (amy_worker_job == AMY_WORKER_REVERB_ROOM_0) + amy_process_reverb_room(0); + else { +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t started = amy_get_us(); +#endif + amy_render(0, AMY_OSCS/2, 1); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_render_core_us[xPortGetCoreID()] = + (uint32_t)(amy_get_us() - started); +#endif + } // Tell the caller we're done. xSemaphoreGive(esp_render_done_sem); // to esp_render_on_cores } @@ -278,9 +667,17 @@ void esp_render_on_cores() { // Call amy_render on all the oscs, using multicore if available. if (amy_global.config.platform.multicore) { // Tell the other core to start rendering. + amy_worker_job = AMY_WORKER_RENDER_OSCS; xTaskNotifyGive(amy_render_handle); // to esp_render_task // Render me +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t started = amy_get_us(); +#endif amy_render(AMY_OSCS/2, AMY_OSCS, 0); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_render_core_us[xPortGetCoreID()] = + (uint32_t)(amy_get_us() - started); +#endif // Wait for the other core to finish xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); // from esp_render_task } else { @@ -289,6 +686,37 @@ void esp_render_on_cores() { } } +void amy_platform_process_bus_subsets(void) { + if (amy_global.config.platform.multicore) { + // Keep every room's complete source subset on one core. This avoids + // shared accumulator writes and leaves its room input hot for the + // matching reverb job that follows. + amy_worker_job = AMY_WORKER_BUS_SUBSET_0; + xTaskNotifyGive(amy_render_handle); + amy_process_bus_subset(1, 2); + xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); + } else { + amy_process_bus_subset(0, 1); + } +} + +void amy_platform_process_reverb_rooms(void) { + uint16_t rooms = amy_global.config.max_reverb_rooms; + if (rooms == 0) return; + if (rooms >= 2 && amy_global.config.platform.multicore) { + // Reuse the already-pinned render worker after oscillator rendering: + // room 0 runs on core 0 while the fill task runs room 1 on core 1. + amy_worker_job = AMY_WORKER_REVERB_ROOM_0; + xTaskNotifyGive(amy_render_handle); + amy_process_reverb_room(1); + xSemaphoreTake(esp_render_done_sem, portMAX_DELAY); + for (uint16_t room = 2; room < rooms; ++room) + amy_process_reverb_room(room); + } else { + amy_process_reverb_rooms(); + } +} + #ifdef I2S_32BIT static int32_t block32[AMY_BLOCK_SIZE * AMY_NCHANS]; #define I2S_BYTES_PER_SAMPLE 4 @@ -327,7 +755,13 @@ static int64_t _rl_last_print = 0; static int32_t _rl_render_us = 0; #endif // ARDUINO_SPEEDTEST -void esp_fill_audio_buffer_task() { +void esp_fill_audio_buffer_task(void *pvParameters) { + (void)pvParameters; + // A single expensive block is not proof of sustained overload. DMA can + // absorb that jitter, provided a cheaper following block earns the time + // back. Track only the unpaced render-time debt so the overload escape + // below is reserved for a workload whose average really cannot keep up. + uint32_t overload_debt_us = 0; while(1) { int64_t t; uint32_t blocked_us = 0; @@ -342,13 +776,31 @@ void esp_fill_audio_buffer_task() { int64_t _rl_start_t = esp_timer_get_time(); #endif // ARDUINO_SPEEDTEST // Get ready to render +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t stage_started_us = amy_get_us(); +#endif amy_execute_deltas(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t execute_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif // Render on whichever cores we have available. +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + stage_started_us = amy_get_us(); +#endif esp_render_on_cores(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t render_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif // Write to i2s +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + stage_started_us = amy_get_us(); +#endif output_sample_type *block = amy_fill_buffer(); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint32_t fill_us = (uint32_t)(amy_get_us() - stage_started_us); +#endif uint32_t busy_us = (uint32_t)(amy_get_us() - t); AMY_PROFILE_STOP(AMY_ESP_FILL_BUFFER) @@ -384,18 +836,42 @@ void esp_fill_audio_buffer_task() { // i2s DMA write (or the update-sync wait) above, which is when lower-priority // tasks on this core get to run. amy_overload_check(busy_us); - // If rendering genuinely can't keep up (a block costs at least its own - // real-time budget) AND the audio output didn't block, we're past - // overloaded, and this max-priority task would starve everything else - // on this core (USB, MIDI, the host app). Audio is already breaking - // up, so give the rest of the system a tick. - // - // Both conditions matter: with a small DMA ring a healthy just-in-time - // iteration can also see blocked_us == 0, and one tick here (10 ms at - // a 100 Hz tick rate) can be bigger than the whole ring -- a single - // spurious delay underruns it, the drained ring makes the next write - // not block either, and the delay re-arms forever (#1118). - if (busy_us >= AMY_BLOCK_US && blocked_us < 150) vTaskDelay(1); + // A blocked write means DMA is full and therefore clears any prior + // render-time debt. While the write is unpaced, accumulate only the + // amount over budget and repay it with subsequent under-budget blocks. + // This lets DMA absorb isolated sequencer/event bursts instead of + // turning each one into a much larger scheduler-induced dropout. + if (blocked_us >= 150) { + overload_debt_us = 0; + } else if (busy_us > AMY_BLOCK_US) { + uint32_t overrun_us = busy_us - AMY_BLOCK_US; + if (UINT32_MAX - overload_debt_us < overrun_us) + overload_debt_us = UINT32_MAX; + else + overload_debt_us += overrun_us; + } else { + uint32_t recovered_us = AMY_BLOCK_US - busy_us; + overload_debt_us = recovered_us >= overload_debt_us + ? 0 : overload_debt_us - recovered_us; + } + + // Yield only after sustained unpaced overload has accumulated at least + // the delay we are about to impose. At that point audio is already + // falling behind on average; yielding prevents this max-priority loop + // from starving USB/MIDI and the host application. + const uint32_t scheduler_tick_us = + (1000000u + configTICK_RATE_HZ - 1u) / configTICK_RATE_HZ; + bool overload_yielded = overload_debt_us >= scheduler_tick_us; + uint32_t recorded_overload_debt_us = overload_debt_us; + if (overload_yielded) { + overload_debt_us = 0; + vTaskDelay(1); + } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + esp_load_diagnostic_record(execute_us, render_us, fill_us, busy_us, + blocked_us, recorded_overload_debt_us, + overload_yielded); +#endif } } diff --git a/src/instrument.c b/src/instrument.c index 0e1a2a39..e3bf0c86 100644 --- a/src/instrument.c +++ b/src/instrument.c @@ -223,6 +223,11 @@ void instrument_free(struct instrument_info *instrument) { } void _instrument_push_note_forgotten(struct instrument_info *instrument, uint16_t note) { + // Forgotten notes exist only to absorb their eventual note-offs. A synth + // which explicitly ignores note-offs (typically a one-shot drum synth) + // has nothing to match, and may otherwise fill this bounded pool forever. + if (instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) return; + int available_index = -1; for (int i = 0; i < FORGOTTEN_POOL_SIZE; ++i) { if (instrument->forgotten_notes[i] == note) { @@ -294,6 +299,10 @@ uint16_t _instrument_voice_off(struct instrument_info *instrument, uint16_t voic uint16_t instrument_note_off(struct instrument_info *instrument, uint16_t note) { uint16_t voice = _instrument_voice_for_note(instrument, note); if (voice == _INSTRUMENT_NO_VOICE) { + // A late note-off for an already stolen one-shot is expected when + // note-offs are ignored; no forgotten-note entry is kept for it. + if (instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) + return _INSTRUMENT_NO_VOICE; // Don't report an unmatched note-off if it was a victim of stealing. if (!_instrument_pop_note_forgotten(instrument, note) && !(instrument->flags & SYNTH_FLAGS_NO_NOTE_WARNINGS)) @@ -551,9 +560,26 @@ uint32_t instrument_get_flags(int instrument_number) { void instrument_set_flags(int instrument_number, uint32_t flags) { if (!instrument_number_exists(instrument_number, "set_flags")) return; struct instrument_info *instrument = instruments[instrument_number]; + if ((flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS) + && !(instrument->flags & SYNTH_FLAGS_IGNORE_NOTE_OFFS)) { + // Entries accumulated under the old policy can never be needed once + // note-offs are ignored, and must not become stale if flags change. + _instrument_reset_forgotten_pool(instrument); + } instrument->flags = flags; } +#ifdef AMY_INSTRUMENT_TESTING +int instrument_test_forgotten_note_slots(int instrument_number) { + if (!instrument_number_exists(instrument_number, NULL)) return -1; + struct instrument_info *instrument = instruments[instrument_number]; + int occupied = 0; + for (int i = 0; i < FORGOTTEN_POOL_SIZE; ++i) + if (instrument->forgotten_notes[i] != _INSTRUMENT_NO_NOTE) ++occupied; + return occupied; +} +#endif + uint16_t instrument_noteon_delay_ms(int instrument_number) { if (!instrument_number_exists(instrument_number, "noteon_delay")) return 0; struct instrument_info *instrument = instruments[instrument_number]; diff --git a/src/parse.c b/src/parse.c index 436a4549..9ec3d4ec 100644 --- a/src/parse.c +++ b/src/parse.c @@ -5,6 +5,7 @@ #include "transfer.h" // for amy_dump_state_to_sysex, amy_dump_file_to_sysex #include // for isalpha(). #include +#include #if defined(TULIP) || defined(AMYBOARD) #include "py/runtime.h" #endif @@ -514,6 +515,49 @@ int amy_parse_dist_layer_message(char *message, amy_event *e) { return 1; // skip the sub-command letter. } +// Parser for the reverb family. A numeric payload keeps the historical +// per-bus h command. hR configures one built-in shared +// reverb; hS addresses the aux send on the event's bus. Keeping these under h +// preserves the established wire family without consuming unrelated top-level +// letters. +static int amy_parse_reverb_layer_message(char *message, amy_event *e) { + if (message[0] != 'R' && message[0] != 'S') { + float values[4]; + parse_list_float( + message, values, 4, AMY_UNSET_VALUE(e->reverb_level)); + e->reverb_level = values[0]; + e->reverb_liveness = values[1]; + e->reverb_damping = values[2]; + e->reverb_xover_hz = values[3]; + return 0; + } + + char command = *message++; + float values[5]; + parse_list_float(message, values, command == 'R' ? 5 : 2, + AMY_UNSET_FLOAT); + if (!isfinite(values[0]) || values[0] < 0.0f + || values[0] >= (float)AMY_REVERB_ROOM_NONE + || values[0] != floorf(values[0])) { + fprintf(stderr, + "invalid aux return index: expected an integer 0..65534\n"); + return 1; + } + + uint16_t room = (uint16_t)values[0]; + if (command == 'R') { + e->reverb_room = room; + e->reverb_room_level = values[1]; + e->reverb_room_liveness = values[2]; + e->reverb_room_damping = values[3]; + e->reverb_room_xover_hz = values[4]; + } else { + e->reverb_send_room = room; + e->reverb_send_level = values[1]; + } + return 1; // skip R/S in the outer scanner +} + // Parse a sample-load parameter list ('z'/'zS' messages): comma-separated // unsigned integers, except the midinote field which may be fractional (e.g. // a sample tuned 4 cents sharp of C4 is "60.04"). parse_list_uint32_t cannot @@ -704,29 +748,167 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static bool sequence_uint32(const char *cursor, const char **end, + uint32_t *value) { + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor)) return false; + errno = 0; + char *parsed_end = NULL; + unsigned long long parsed = strtoull(cursor, &parsed_end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return false; + while (*parsed_end == ' ') ++parsed_end; + *value = (uint32_t)parsed; + *end = parsed_end; + return true; +} + +static int sequence_control_uint_tail(const char *cursor, uint32_t *values, + int capacity) { + int count = 0; + while (*cursor == ',') { + ++cursor; + if (count == capacity + || !sequence_uint32(cursor, &cursor, &values[count])) + return -1; + count++; + } + if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; + return count; +} + +static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], + const char **payload) { + int count = 0; + while (count < 3) { + const char *field = cursor; + while (*field == ' ') ++field; + if (*field == ',') { + // The generic AMY list syntax uses an empty field for zero. Keep + // accepting H,period,tag and H,,tag legacy spellings. + values[count] = 0; + cursor = field; + } else if (!sequence_uint32(cursor, &cursor, &values[count])) { + return -1; + } + count++; + if (*cursor != ',') break; + if (count == 3) return -1; + cursor++; + const char *next = cursor; + while (*next == ' ') ++next; + // A trailing comma did not add another value in the legacy parser. + if (*next == '\0' || isalpha((unsigned char)*next)) { + cursor = next; + break; + } + } + if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; + *payload = cursor; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command // is only ever honored as the first command of a message. -void handle_ticks_message(char *message) { +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick) { assert(message[0] == 'H'); + if (message[1] == 'C') { + // HCtag,action[,alignment_period], for stop=0 or start=1. + // HCtag,gate,duration[,alignment_period] + const char *tag_end = NULL; + uint32_t tag = 0; + bool tag_valid = sequence_uint32(message + 2, &tag_end, &tag); + const char *action_start = tag_valid && *tag_end == ',' + ? tag_end + 1 : ""; + const char *action_end = NULL; + uint32_t action = 0; + bool action_valid = sequence_uint32( + action_start, &action_end, &action); + const char *tail = action_valid ? action_end : ""; + uint32_t rest[2] = {0, 0}; + int rest_count = action_valid + ? sequence_control_uint_tail(tail, rest, 2) : -1; + if (!tag_valid || *tag_end != ',' + || !action_valid || rest_count < 0) { + fprintf(stderr, + "invalid sequence_control: expected " + "HCtag,action[,alignment_period] (stop=0, start=1) or " + "HCtag,gate,duration[,alignment_period]\n"); + return; + } + + uint32_t value = 0; + uint32_t alignment = 0; + bool shape_valid = false; + if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_START) { + shape_valid = rest_count <= 1; + if (rest_count == 1) alignment = rest[0]; + } else if (action == SEQUENCE_CONTROL_GATE) { + shape_valid = rest_count >= 1 && rest_count <= 2; + value = rest[0]; + if (rest_count == 2) alignment = rest[1]; + } else { + shape_valid = false; + } + + if (!shape_valid) { + fprintf(stderr, + "invalid sequence_control: action must be stop=0, " + "start=1, or use " + "gate=2 with a duration; tag, duration, and " + "alignment must be non-negative integers\n"); + } else { + sequencer_sequence_control_with_origin( + tag, action, value, alignment, origin, current_tick); + } + return; + } + if (message[1] == 'R') { + // HRtag: clear the future stored events for this tag. Already-active + // immutable sequence executions are intentionally unaffected. + const char *end = NULL; + uint32_t tag = 0; + if (!sequence_uint32(message + 2, &end, &tag) + || (*end != '\0' && (*end != 'Z' || end[1] != '\0'))) + fprintf(stderr, "invalid sequence reset: expected HRtag\n"); + else + sequencer_sequence_reset_with_origin(tag, origin); + return; + } + uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); - uint16_t schedule_len = 1 + _next_alpha(message + 1); - char *payload = message + schedule_len; - uint16_t payload_len = (uint16_t)strlen(payload); - char *stripped = (char *)malloc_caps(payload_len + 1, amy_global.config.ram_caps_events); + const char *payload = NULL; + int num_vals = sequence_ticks_prefix(message + 1, ticks, &payload); + if (num_vals < 1) { + fprintf(stderr, + "invalid ticks command: expected Htick[,period[,tag]]payload, " + "HCtag,action, or HRtag\n"); + return; + } + size_t payload_len = strlen(payload); + char *stripped = payload_len >= UINT32_MAX ? NULL + : (char *)malloc_caps((uint32_t)(payload_len + 1), + amy_global.config.ram_caps_events); if (stripped == NULL) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - // A tag is only "given" if all 3 values were present; fewer + // A root tag is only "given" if all 3 values were present; fewer // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); + sequencer_add_wire_with_origin( + ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped, origin); } } +void handle_ticks_message(char *message) { + handle_ticks_message_with_origin(message, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + // given a string return a parsed event // // Transfer payloads never reach here: amy_add_message() traps them before @@ -769,15 +951,10 @@ int amy_parse_message(char * message, amy_event *e) { /* g used for Alles for client # */ // 'H' is the ticks= schedule command, it's caught in amy_add_message before this. //case 'H': parse_list_uint32_t(arg, e->ticks, 3, 0); break; - case 'h': if (AMY_HAS_REVERB) { - float reverb_params[4]; - parse_list_float(arg, reverb_params, 4, AMY_UNSET_VALUE(e->reverb_level)); - e->reverb_level = reverb_params[0]; - e->reverb_liveness = reverb_params[1]; - e->reverb_damping = reverb_params[2]; - e->reverb_xover_hz = reverb_params[3]; - } - break; + case 'h': + if (AMY_HAS_REVERB) + pos += amy_parse_reverb_layer_message(arg, e); + break; /* i is used by alles for sync index -- but only for sync messages -- ok to use here but test */ case 'i': pos += amy_parse_synth_layer_message(arg, e); break; // Skip over second cmd letter, if any, or entire MIDI CC code string. case 'I': e->ratio = atoff(arg); break; @@ -906,4 +1083,3 @@ int amy_parse_message(char * message, amy_event *e) { // Return exactly how many characters we used. return pos; } - diff --git a/src/patches.c b/src/patches.c index 63c07b5d..fb04d866 100644 --- a/src/patches.c +++ b/src/patches.c @@ -418,6 +418,49 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { _EPRINT_VALS_5(e->echo_level, e->echo_delay_ms, e->echo_max_delay_ms, e->echo_feedback, e->echo_filter_coef, "echo_{level,delay,max,fb,filt}", "M"); _EPRINT_VALS_5(e->chorus_level, e->chorus_max_delay, e->chorus_lfo_freq, e->chorus_depth, AMY_UNSET_FLOAT, "chorus_{level,delay,lfo,depth}", "k"); _EPRINT_VALS_5(e->reverb_level, e->reverb_liveness, e->reverb_damping, e->reverb_xover_hz, AMY_UNSET_FLOAT, "reverb_{level,live,damp,xover}", "h"); + if (AMY_IS_SET(e->reverb_room)) { + if (wirecode) { + snprintf(s, len - (size_t)(s - s_entry), "hR%u", e->reverb_room); + s += strlen(s); +#define APPEND_ROOM_FLOAT(FIELD) do { \ + snprintf(s, len - (size_t)(s - s_entry), ","); \ + s += strlen(s); \ + if (AMY_IS_SET(e->FIELD)) { \ + snprintfloat3dp(s, len - (size_t)(s - s_entry), e->FIELD); \ + s += strlen(s); \ + } \ + } while (0) + APPEND_ROOM_FLOAT(reverb_room_level); + APPEND_ROOM_FLOAT(reverb_room_liveness); + APPEND_ROOM_FLOAT(reverb_room_damping); + APPEND_ROOM_FLOAT(reverb_room_xover_hz); +#undef APPEND_ROOM_FLOAT + } else { + snprintf(s, len - (size_t)(s - s_entry), + "reverb_room=%u level=%f live=%f damp=%f xover=%f ", + e->reverb_room, e->reverb_room_level, + e->reverb_room_liveness, e->reverb_room_damping, + e->reverb_room_xover_hz); + s += strlen(s); + } + } + if (AMY_IS_SET(e->reverb_send_room)) { + if (wirecode) { + snprintf(s, len - (size_t)(s - s_entry), "hS%u,", + e->reverb_send_room); + s += strlen(s); + if (AMY_IS_SET(e->reverb_send_level)) { + snprintfloat3dp(s, len - (size_t)(s - s_entry), + e->reverb_send_level); + s += strlen(s); + } + } else { + snprintf(s, len - (size_t)(s - s_entry), + "reverb_send=%u,%f ", e->reverb_send_room, + e->reverb_send_level); + s += strlen(s); + } + } if (wirecode && (s - s_entry) > 0) { snprintf(s, len - (size_t)(s - s_entry), "Z"); s += strlen(s); } @@ -459,6 +502,8 @@ bool event_addresses_bus(amy_event *e) { _RET_TRUE_IF_5_F_SET(echo_level, echo_delay_ms, echo_max_delay_ms, echo_feedback, echo_filter_coef); _RET_TRUE_IF_5_F_SET(chorus_level, chorus_max_delay, chorus_lfo_freq, chorus_depth, chorus_depth); _RET_TRUE_IF_5_F_SET(reverb_level, reverb_liveness, reverb_damping, reverb_xover_hz, reverb_xover_hz); + _RET_TRUE_IF_SET(reverb_send_room); + _RET_TRUE_IF_SET(reverb_send_level); // Distortion addresses a bus only when the event names no osc; naming one // makes the same fields osc-scope (see event_addresses_oscs). // Not _RET_TRUE_IF_5_F_SET: the int fields' unset sentinels cast to @@ -635,6 +680,12 @@ struct delta *deltas_to_event(struct delta *queue, struct amy_event *event) { _CASE_F(reverb_liveness, REVERB_LIVENESS) _CASE_F(reverb_damping, REVERB_DAMPING) _CASE_F(reverb_xover_hz, REVERB_XOVER_HZ) + case REVERB_ROOM_LEVEL: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_level = queue->data.f; break; + case REVERB_ROOM_LIVENESS: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_liveness = queue->data.f; break; + case REVERB_ROOM_DAMPING: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_damping = queue->data.f; break; + case REVERB_ROOM_XOVER_HZ: event->reverb_room = queue->osc; AMY_UNSET(event->osc); event->reverb_room_xover_hz = queue->data.f; break; + case REVERB_SEND_ROOM: event->bus = queue->osc; AMY_UNSET(event->osc); event->reverb_send_room = queue->data.i; break; + case REVERB_SEND_LEVEL: event->bus = queue->osc; AMY_UNSET(event->osc); event->reverb_send_level = queue->data.f; break; // Bus distortion comes back through the same event fields the per-osc // stage uses; the event's own osc says which scope it will be read at // on the way back in, exactly as it does for VOLUME below. @@ -847,6 +898,11 @@ void set_event_for_bus_fx(amy_event *event, uint16_t bus, global_state_t *state) event->reverb_liveness = state->bus[bus]->reverb.liveness; event->reverb_damping = state->bus[bus]->reverb.damping; event->reverb_xover_hz = state->bus[bus]->reverb.xover_hz; + if (state->bus[bus]->reverb_send_room != AMY_REVERB_ROOM_NONE) { + event->reverb_send_room = state->bus[bus]->reverb_send_room; + event->reverb_send_level = + S2F(state->bus[bus]->reverb_send_level); + } // Chorus event->chorus_level = S2F(state->bus[bus]->chorus.level); event->chorus_max_delay = state->bus[bus]->chorus.max_delay; @@ -874,6 +930,15 @@ void set_event_for_bus_fx(amy_event *event, uint16_t bus, global_state_t *state) } } +static void set_event_for_reverb_room(amy_event *event, uint16_t room, + global_state_t *state) { + event->reverb_room = room; + event->reverb_room_level = S2F(state->reverb_rooms[room].effect.level); + event->reverb_room_liveness = state->reverb_rooms[room].effect.liveness; + event->reverb_room_damping = state->reverb_rooms[room].effect.damping; + event->reverb_room_xover_hz = state->reverb_rooms[room].effect.xover_hz; +} + int num_oscs_for_voice(int voice) { uint16_t osc = voice_to_base_osc[voice]; @@ -975,17 +1040,26 @@ void *yield_synth_commands(uint8_t instr_num, char *s, size_t len, bool include_ void *yield_bus_commands(char *s, size_t len, void *state) { - // Like yield_synth_commands, returns just the commands for the FX + // Like yield_synth_commands, returns bus FX followed by shared room + // configuration so a state dump can restore the complete mix graph. int state_val = (intptr_t)state; - if (state_val > amy_global.highest_bus) { + int bus_count = amy_global.highest_bus + 1; + int end = bus_count + amy_global.config.max_reverb_rooms; + if (state_val >= end) { state_val = 0; - } else { + } else if (state_val < bus_count) { // Return a wire command to set up a bus. uint16_t bus = state_val; amy_event e = amy_default_event(); set_event_for_bus_fx(&e, bus, &amy_global); sprint_event(&e, s, len, /* wirecode= */ true); ++state_val; + } else { + uint16_t room = state_val - bus_count; + amy_event e = amy_default_event(); + set_event_for_reverb_room(&e, room, &amy_global); + sprint_event(&e, s, len, /* wirecode= */ true); + ++state_val; } return (void *)(intptr_t)state_val; } diff --git a/src/pcm.c b/src/pcm.c index 552085b7..c1468904 100644 --- a/src/pcm.c +++ b/src/pcm.c @@ -3,6 +3,10 @@ #include "amy.h" #include "transfer.h" +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + #ifdef __EMSCRIPTEN__ #include "emscripten.h" #endif diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..d31ff7fe 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -21,7 +21,12 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) long lv = 0; long long llv = 0; - if (strcmp(key, "chorus") == 0) { + if (strcmp(key, "audio") == 0) { + int enabled = PyObject_IsTrue(value); + if (enabled < 0) return -1; + cfg->audio = enabled ? AMY_AUDIO_IS_MINIAUDIO : AMY_AUDIO_IS_NONE; + return 0; + } else if (strcmp(key, "chorus") == 0) { lv = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; cfg->features.chorus = (lv != 0); @@ -79,6 +84,15 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_buses = (uint16_t)lv; return 0; + } else if (strcmp(key, "max_reverb_rooms") == 0) { + lv = PyLong_AsLong(value); + if (PyErr_Occurred()) return -1; + if (lv < 0 || lv > UINT16_MAX) { + PyErr_SetString(PyExc_ValueError, "max_reverb_rooms must be in range [0, 65535]"); + return -1; + } + cfg->max_reverb_rooms = (uint16_t)lv; + return 0; } else if (strcmp(key, "ks_oscs") == 0) { lv = PyLong_AsLong(value); if (PyErr_Occurred()) return -1; @@ -97,6 +111,24 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_sequencer_tags = (uint32_t)llv; return 0; + } else if (strcmp(key, "max_sequence_events") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_events must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_events = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_executions") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_executions must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_executions = (uint32_t)llv; + return 0; } else if (strcmp(key, "max_voices") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; @@ -153,6 +185,10 @@ static PyObject * live_wrapper(PyObject *self, PyObject *args, PyObject *kwargs) // running AMY: a rejected kwarg then leaves audio playing instead of // silently killing it (and leaving AMY stopped for the next live() call). amy_config_t amy_config = amy_default_config(); + // live() has always meant system audio by default. Callers which render + // deterministically with render_to_list() can opt out of the independent + // miniaudio callback while retaining every runtime sizing kwarg. + amy_config.audio = AMY_AUDIO_IS_MINIAUDIO; Py_ssize_t pos = 0; PyObject *key_obj = NULL; PyObject *value_obj = NULL; @@ -170,7 +206,6 @@ static PyObject * live_wrapper(PyObject *self, PyObject *args, PyObject *kwargs) } } - amy_config.audio = AMY_AUDIO_IS_MINIAUDIO; amy_stop(); amy_start(amy_config); // initializes amy Py_RETURN_NONE; diff --git a/src/sequencer.c b/src/sequencer.c index 243bafd1..39ac5d9b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -1,12 +1,21 @@ #include "sequencer.h" #include "amy.h" +#include + #ifdef __EMSCRIPTEN__ #include #endif uint32_t sequencer_ticks() { return amy_global.sequencer_tick_count; } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC +uint32_t amy_last_sequence_root_us; +uint32_t amy_last_sequence_control_us; +uint32_t amy_last_sequence_event_us; +uint32_t amy_last_sequence_tick_count; +#endif + // Sequenced ticks events are stored as the raw wire-message string (with its // leading 'H' command stripped) plus the scheduling metadata needed to play // it back. The string is only parsed when the entry comes due. @@ -21,10 +30,10 @@ typedef struct sequence_info_t { int32_t next_active; } sequence_info_t; -struct sequence_info_t *sequences = NULL; // An array indexed by tag. -int32_t max_sequences = 0; // Number of user-addressable tags. -// Head of the ascending list of occupied slots (user tags and anonymous -// entries alike); -1 when nothing is scheduled. This replaces `highest_tag`, +struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. +uint32_t max_sequences = 0; // Number of user-addressable tags. +// Head of the ascending list of occupied anonymous slots; -1 when nothing is +// scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag // made every tick scan that far for the rest of the session, long after that // sequence was cleared. The anonymous pool made that the common case, not a @@ -33,11 +42,8 @@ int32_t max_sequences = 0; // Number of user-addressable tags. // end of the table permanently. The cost is proportional to what is // scheduled now. int32_t first_active = -1; -// Anonymous (no-tag) entries live past the user-addressable tag range, at -// indices [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS), so a -// user-supplied tag (bounds-checked against max_sequences) can never reach -// or clobber one. Allocated round-robin; a new anonymous entry silently -// evicts the oldest one once the pool wraps around. +// Anonymous (no-tag) entries have their own fixed pool. Allocated round-robin; +// a new anonymous entry silently evicts the oldest once the pool wraps. #define AMY_ANON_SEQUENCE_SLOTS 256 static int32_t anon_cursor = 0; static volatile bool sequencer_running = true; @@ -47,7 +53,353 @@ static volatile bool sequencer_external_clock = false; // flag makes those nested calls no-ops so a tick is never processed twice. static volatile bool wire_firing = false; -void sequencer_init(int max_sequencer_tags) { +// Reusable sequences use the same public tag space as legacy root events. A +// definition is copy-on-write: executions retain the exact event list they +// started with while cumulative edits become the definition for future starts. +typedef struct stored_sequence_event_t { + char *wire; + uint32_t tick; + uint32_t period; +} stored_sequence_event_t; + +typedef struct stored_sequence_definition_t { + stored_sequence_event_t *events; + // Events stay in append order above for compatibility. This separate + // stable tick order lets finite sequences and the common case where all + // events share one period advance cursors instead of rescanning every + // event on every sequencer tick. + uint32_t *event_order; + uint32_t event_count; + uint32_t last_one_shot_tick; + // UINT32_MAX means the definition mixes periods and uses the generic + // append-order scan. Zero is a finite sequence; any other value is the + // period shared by every event in the fast path. + uint32_t schedule_period; + bool has_periodic_event; + bool has_control_event; + bool has_regular_event; + uint32_t refs; + // Zero-reference definitions are linked here by the render path. A + // non-rendering sequence API call detaches the complete list under the + // queue lock and performs the variable-time frees after releasing it. + struct stored_sequence_definition_t *next_retired; +} stored_sequence_definition_t; + +typedef struct stored_sequence_execution_t { + stored_sequence_definition_t *definition; + uint32_t tag; + uint32_t start_tick; + uint32_t stop_tick; + uint32_t gate_change_tick; + uint32_t gate_duration; + uint32_t gate_end_tick; + uint32_t controls_processed_tick; + bool occupied; + bool started; + bool stop_pending; + bool gate_change_pending; + bool gated; + bool controls_processed; + uint32_t next_control_order; + uint32_t next_event_order; +} stored_sequence_execution_t; + +static stored_sequence_definition_t **stored_sequences = NULL; +static stored_sequence_execution_t *sequence_executions = NULL; +static uint32_t *occupied_execution_bits = NULL; +static uint32_t *control_execution_bits = NULL; +static uint32_t *regular_execution_bits = NULL; +static uint32_t execution_bit_words = 0; +static uint32_t max_stored_sequence_events = 0; +static uint32_t max_stored_sequence_executions = 0; +static size_t stored_sequence_event_bytes = 0; +static size_t stored_sequence_order_bytes = 0; +static stored_sequence_definition_t *retired_sequence_definitions = NULL; + +#ifdef AMY_SEQUENCE_TESTING +static int32_t stored_sequence_allocations_before_failure = -1; +static void (*stored_sequence_after_pin_hook)(void) = NULL; + +void sequencer_test_fail_allocation_after(int32_t successful_allocations) { + stored_sequence_allocations_before_failure = successful_allocations; +} + +void sequencer_test_set_after_pin_hook(void (*hook)(void)) { + stored_sequence_after_pin_hook = hook; +} +#endif + +static void *stored_sequence_allocate(size_t size, uint32_t caps) { +#ifdef AMY_SEQUENCE_TESTING + if (stored_sequence_allocations_before_failure == 0) return NULL; + if (stored_sequence_allocations_before_failure > 0) + stored_sequence_allocations_before_failure--; +#endif + if (size > UINT32_MAX) return NULL; + return malloc_caps((uint32_t)size, caps); +} + +static bool checked_array_size(uint32_t count, size_t element_size, + size_t *bytes) { + if (element_size == 0 || element_size > UINT32_MAX + || count > UINT32_MAX / element_size) + return false; + *bytes = (size_t)count * element_size; + return true; +} + +static void stored_sequence_definition_destroy( + stored_sequence_definition_t *definition) { + if (definition == NULL) return; + for (uint32_t i = 0; i < definition->event_count; ++i) + if (definition->events[i].wire != NULL) free(definition->events[i].wire); + free(definition->events); + free(definition->event_order); + free(definition); +} + +// References are changed only while amy_queue_lock is held. Return the object +// which reached zero so the caller can either retire it (render path) or free +// it after dropping the lock (control path). +static stored_sequence_definition_t *stored_sequence_definition_unref_locked( + stored_sequence_definition_t *definition) { + if (definition == NULL) return NULL; + assert(definition->refs != 0); + definition->refs--; + return definition->refs == 0 ? definition : NULL; +} + +static void stored_sequence_definition_retire_locked( + stored_sequence_definition_t *definition) { + stored_sequence_definition_t *retired = + stored_sequence_definition_unref_locked(definition); + if (retired == NULL) return; + retired->next_retired = retired_sequence_definitions; + retired_sequence_definitions = retired; +} + +static void stored_sequence_definition_destroy_list( + stored_sequence_definition_t *definition) { + while (definition != NULL) { + stored_sequence_definition_t *next = definition->next_retired; + stored_sequence_definition_destroy(definition); + definition = next; + } +} + +// External API boundaries call this after parsing. Render-side dispatch only +// retires definitions; it never enters this variable-time destruction path. +void sequencer_reclaim_retired(void) { + amy_grab_lock(); + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + amy_release_lock(); + stored_sequence_definition_destroy_list(retired); +} + +static bool sequence_origin_may_reclaim(sequencer_origin_t origin) { + return origin == SEQUENCER_ORIGIN_EXTERNAL; +} + +static stored_sequence_definition_t * +stored_sequence_definition_release_locked( + stored_sequence_definition_t *definition, + sequencer_origin_t origin) { + if (sequence_origin_may_reclaim(origin)) + return stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_retire_locked(definition); + return NULL; +} + +static stored_sequence_definition_t *stored_sequence_definition_new(void) { + stored_sequence_definition_t *definition = + (stored_sequence_definition_t *)stored_sequence_allocate( + sizeof(stored_sequence_definition_t), + amy_global.config.ram_caps_synth); + if (definition == NULL) return NULL; + definition->events = (stored_sequence_event_t *)stored_sequence_allocate( + stored_sequence_event_bytes, amy_global.config.ram_caps_synth); + if (definition->events == NULL) { + free(definition); + return NULL; + } + definition->event_order = (uint32_t *)stored_sequence_allocate( + stored_sequence_order_bytes, amy_global.config.ram_caps_synth); + if (definition->event_order == NULL) { + free(definition->events); + free(definition); + return NULL; + } + memset(definition->events, 0, stored_sequence_event_bytes); + definition->event_count = 0; + definition->last_one_shot_tick = 0; + definition->schedule_period = 0; + definition->has_periodic_event = false; + definition->has_control_event = false; + definition->has_regular_event = false; + definition->refs = 1; + definition->next_retired = NULL; + return definition; +} + +static char *stored_sequence_wire_copy(const char *wire) { + size_t len = strlen(wire); + char *copy = (char *)stored_sequence_allocate( + len + 1, amy_global.config.ram_caps_events); + if (copy != NULL) memcpy(copy, wire, len + 1); + return copy; +} + +static stored_sequence_definition_t *stored_sequence_definition_clone( + const stored_sequence_definition_t *source) { + stored_sequence_definition_t *copy = stored_sequence_definition_new(); + if (copy == NULL) return NULL; + if (source == NULL) return copy; + copy->event_count = source->event_count; + copy->last_one_shot_tick = source->last_one_shot_tick; + copy->schedule_period = source->schedule_period; + copy->has_periodic_event = source->has_periodic_event; + copy->has_control_event = source->has_control_event; + copy->has_regular_event = source->has_regular_event; + memcpy(copy->event_order, source->event_order, + source->event_count * sizeof(*copy->event_order)); + for (uint32_t i = 0; i < source->event_count; ++i) { + const stored_sequence_event_t *from = &source->events[i]; + copy->events[i].wire = stored_sequence_wire_copy(from->wire); + if (copy->events[i].wire == NULL) { + stored_sequence_definition_destroy(copy); + return NULL; + } + copy->events[i].tick = from->tick; + copy->events[i].period = from->period; + } + return copy; +} + +static void stored_sequence_execution_release_deferred( + stored_sequence_execution_t *execution) { + if (!execution->occupied) return; + uint32_t slot = (uint32_t)(execution - sequence_executions); + uint32_t word = slot / 32; + uint32_t mask = 1u << (slot % 32); + if (occupied_execution_bits != NULL) + occupied_execution_bits[word] &= ~mask; + if (control_execution_bits != NULL) + control_execution_bits[word] &= ~mask; + if (regular_execution_bits != NULL) + regular_execution_bits[word] &= ~mask; + stored_sequence_definition_t *definition = execution->definition; + memset(execution, 0, sizeof(*execution)); + stored_sequence_definition_retire_locked(definition); +} + +static void stored_sequence_executions_reset(void) { + if (sequence_executions == NULL) return; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_execution_release_deferred(&sequence_executions[i]); +} + +static void stored_sequences_clear_definitions(void) { + if (stored_sequences == NULL) return; + for (uint32_t i = 0; i < max_sequences; ++i) { + stored_sequence_definition_retire_locked( + stored_sequences[i]); + stored_sequences[i] = NULL; + } +} + +static void stored_sequences_deinit(void) { + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); + if (stored_sequences != NULL) { + free(stored_sequences); + stored_sequences = NULL; + } + if (sequence_executions != NULL) { + free(sequence_executions); + sequence_executions = NULL; + } + if (occupied_execution_bits != NULL) { + free(occupied_execution_bits); + occupied_execution_bits = NULL; + } + if (control_execution_bits != NULL) { + free(control_execution_bits); + control_execution_bits = NULL; + } + if (regular_execution_bits != NULL) { + free(regular_execution_bits); + regular_execution_bits = NULL; + } + execution_bit_words = 0; + max_stored_sequence_events = 0; + max_stored_sequence_executions = 0; + stored_sequence_event_bytes = 0; + stored_sequence_order_bytes = 0; + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + stored_sequence_definition_destroy_list(retired); +} + +static void stored_sequences_init(uint32_t events, uint32_t executions) { + max_stored_sequence_events = events; + max_stored_sequence_executions = executions; + if (max_sequences == 0 || events == 0 || executions == 0) return; + + size_t slot_bytes = 0; + size_t execution_bytes = 0; + size_t execution_bit_bytes = 0; + execution_bit_words = executions / 32 + (executions % 32 != 0); + if (!checked_array_size(max_sequences, + sizeof(*stored_sequences), &slot_bytes) + || !checked_array_size(events, sizeof(stored_sequence_event_t), + &stored_sequence_event_bytes) + || !checked_array_size(events, sizeof(uint32_t), + &stored_sequence_order_bytes) + || !checked_array_size(executions, + sizeof(stored_sequence_execution_t), + &execution_bytes) + || !checked_array_size(execution_bit_words, sizeof(uint32_t), + &execution_bit_bytes)) { + fprintf(stderr, + "stored sequence configuration exceeds addressable memory: " + "tags=%" PRIu32 ", events=%" PRIu32 + ", executions=%" PRIu32 "\n", + max_sequences, events, executions); + stored_sequences_deinit(); + return; + } + stored_sequences = (stored_sequence_definition_t **)stored_sequence_allocate( + slot_bytes, amy_global.config.ram_caps_synth); + if (stored_sequences != NULL) + memset(stored_sequences, 0, slot_bytes); + sequence_executions = (stored_sequence_execution_t *)stored_sequence_allocate( + execution_bytes, amy_global.config.ram_caps_block); + if (sequence_executions != NULL) + memset(sequence_executions, 0, execution_bytes); + occupied_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + control_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + regular_execution_bits = (uint32_t *)stored_sequence_allocate( + execution_bit_bytes, amy_global.config.ram_caps_block); + if (occupied_execution_bits != NULL) + memset(occupied_execution_bits, 0, execution_bit_bytes); + if (control_execution_bits != NULL) + memset(control_execution_bits, 0, execution_bit_bytes); + if (regular_execution_bits != NULL) + memset(regular_execution_bits, 0, execution_bit_bytes); + if (stored_sequences == NULL || sequence_executions == NULL + || occupied_execution_bits == NULL || control_execution_bits == NULL + || regular_execution_bits == NULL) { + amy_oom("stored sequences: out of memory\n"); + stored_sequences_deinit(); + return; + } +} + +void sequencer_init(uint32_t max_sequencer_tags, uint32_t sequence_events, + uint32_t sequence_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). sequencer_running = true; @@ -55,16 +407,16 @@ void sequencer_init(int max_sequencer_tags) { wire_firing = false; anon_cursor = 0; max_sequences = max_sequencer_tags; - int32_t total_slots = max_sequences + AMY_ANON_SEQUENCE_SLOTS; - sequences = (struct sequence_info_t *)malloc_caps(total_slots * sizeof(struct sequence_info_t), + sequences = (struct sequence_info_t *)malloc_caps(AMY_ANON_SEQUENCE_SLOTS * sizeof(struct sequence_info_t), amy_global.config.ram_caps_synth); - for (int32_t i = 0; i < total_slots; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { sequences[i].wire = NULL; sequences[i].tick = 0; sequences[i].period = 0; sequences[i].next_active = -1; } first_active = -1; + stored_sequences_init(sequence_events, sequence_execution_count); // We are read to go. sequencer_recompute(); } @@ -72,7 +424,7 @@ void sequencer_init(int max_sequencer_tags) { void sequencer_reset() { // Remove all events (tagged and anonymous). No lock here: this is called // from play_delta() (RESET_SEQUENCER), which already runs under the amy lock. - for (int32_t i = 0; i < max_sequences + AMY_ANON_SEQUENCE_SLOTS; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { if (sequences[i].wire) { free(sequences[i].wire); sequences[i].wire = NULL; @@ -82,6 +434,8 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); } void sequencer_deinit() { @@ -91,18 +445,49 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + stored_sequences_deinit(); +} + +void sequencer_sequence_reset_timebase() { + // Absolute activation/control ticks cannot be meaningfully rebased across + // a timebase reset. Stored definitions remain available for relaunch. + stored_sequence_executions_reset(); } void sequencer_debug() { int32_t n_active = 0; for (int32_t t = first_active; t != -1; t = sequences[t].next_active) ++n_active; - fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); + fprintf(stderr, "sequencer: max_sequences %" PRIu32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { - fprintf(stderr, "sequence tag %" PRIi32"%s tick %" PRIu32 " period %"PRIu32 " wire \"%s\"\n", - tag, tag >= max_sequences ? " (anon)" : "", sequences[tag].tick, sequences[tag].period, sequences[tag].wire); + fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 + " period %" PRIu32 " wire \"%s\"\n", + tag, sequences[tag].tick, sequences[tag].period, + sequences[tag].wire); } } + if (sequence_executions == NULL) return; + uint32_t stored_active = 0; + for (uint32_t slot = 0; slot < max_stored_sequence_executions; ++slot) { + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied) continue; + stored_sequence_definition_t *definition = execution->definition; + ++stored_active; + fprintf(stderr, + "stored execution slot %" PRIu32 " tag %" PRIu32 + " events %" PRIu32 " schedule_period %" PRIu32 + " periodic %u controls %u regular %u start %" PRIu32 + " elapsed %" PRIu32 "\n", + slot, execution->tag, definition->event_count, + definition->schedule_period, + definition->has_periodic_event ? 1u : 0u, + definition->has_control_event ? 1u : 0u, + definition->has_regular_event ? 1u : 0u, + execution->start_tick, + amy_global.sequencer_tick_count - execution->start_tick); + } + fprintf(stderr, "stored executions active %" PRIu32 "/%" PRIu32 "\n", + stored_active, max_stored_sequence_executions); } /* The occupied slots, threaded through the table as an ASCENDING list. @@ -171,25 +556,40 @@ void sequencer_recompute() { // Store a wire message in the sequencer. Takes ownership of wire (malloc'd). // // has_tag false means tag wasn't supplied by the caller (a 1- or 2-value -// ticks= form): the entry is allocated round-robin from the anonymous pool -// instead of the given tag value, so it's stored but not addressable or -// individually cancelable. has_tag true is the normal tag-indexed form: tick -// and period both zero clears that tag's entry (the only way to cancel one). +// ticks= form): the entry is allocated round-robin from the anonymous pool, so +// it is stored but not addressable or individually cancelable. has_tag true +// appends to the reusable definition at that tag; an empty tick-zero message +// resets the definition. // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. -uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire) { +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin) { if (sequences == NULL) { // sequencer_init hasn't run free(wire); return 0; } if (has_tag) { - if (tag >= (uint32_t)max_sequences) { - fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIi32"\n", + if (tag >= max_sequences) { + fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", tag, tick, period, max_sequences); free(wire); return 0; } + // Tagged ticks are the events of the reusable sequence identified by + // that tag. Repeating a tag therefore accumulates events, matching + // the way repeated synth= messages build one synth. The historical + // empty H0,0,tag form remains a convenient spelling for per-tag reset; + // with a payload, tick zero is an ordinary (and essential) local + // one-shot event. + if (tick == 0 && period == 0 + && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { + free(wire); + return sequencer_sequence_reset_with_origin(tag, origin); + } + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, origin); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -197,16 +597,16 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } - tag = (uint32_t)(max_sequences + anon_cursor); + tag = (uint32_t)anon_cursor; anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); - // Release any existing message for this tag, even if we're just going to rewrite it. + // Reuse the selected anonymous slot, evicting its previous message. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; sequences[tag].tick = 0; sequences[tag].period = 0; - active_unlink(tag); // out of the list while it has nothing in it + active_unlink((int32_t)tag); // Anonymous slots are bounded to 0..255. if (tick == 0 && period == 0) { // Non-schedulable event: just clear the tag. amy_release_lock(); free(wire); @@ -235,13 +635,587 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha sequences[tag].tick = tick; sequences[tag].period = period; sequences[tag].wire = wire; - active_link(tag); // ...and back in, now that it has a message again + active_link((int32_t)tag); // ...and back in, now that it has a message again amy_release_lock(); return 1; } +uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, + bool has_tag, char *wire) { + return sequencer_add_wire_with_origin( + tick, period, tag, has_tag, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { + if (stored_sequences == NULL || tag >= max_sequences) return NULL; + return &stored_sequences[tag]; +} + +static void stored_sequence_definition_append_owned( + stored_sequence_definition_t *definition, uint32_t tick, + uint32_t period, char *wire) { + uint32_t event_index = definition->event_count; + if (event_index == 0) + definition->schedule_period = period; + else if (definition->schedule_period != period) + definition->schedule_period = UINT32_MAX; + definition->event_count++; + stored_sequence_event_t *event = &definition->events[event_index]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (wire[0] == 'H' && wire[1] == 'C') + definition->has_control_event = true; + else + definition->has_regular_event = true; + if (period != 0) { + definition->has_periodic_event = true; + } else { + if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; + } + // Insert after existing events at the same tick. Event storage remains in + // caller append order. The order is used only when all periods match, so + // tick order is the exact chronological order within each finite run or + // periodic cycle. + uint32_t order_index = event_index; + while (order_index != 0) { + uint32_t previous = definition->event_order[order_index - 1]; + if (definition->events[previous].tick <= tick) break; + definition->event_order[order_index] = previous; + --order_index; + } + definition->event_order[order_index] = event_index; +} + +// A candidate owns the incoming wire in its final event. If publication loses +// a race, detach that event before destroying the private candidate so the +// same caller-owned wire can be retried against the newly published version. +static void stored_sequence_candidate_discard( + stored_sequence_definition_t *candidate, char *wire) { + if (candidate != NULL && candidate->event_count != 0) { + stored_sequence_event_t *event = + &candidate->events[candidate->event_count - 1]; + if (event->wire == wire) { + event->wire = NULL; + candidate->event_count--; + } + } + stored_sequence_definition_destroy(candidate); +} + +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot append event: sequence tag %" PRIu32 + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + free(wire); + return 0; + } + if (wire == NULL || wire[0] == '\0' || wire[0] == 'Z') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": event payload is empty\n", tag); + free(wire); + return 0; + } + if (wire[0] == 'H' && wire[1] != 'C') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": only HC sequence-control payloads may be composed\n", tag); + free(wire); + return 0; + } + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": tick %" PRIu32 " must be below period %" PRIu32 "\n", + tag, tick, period); + free(wire); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); +#ifdef AMY_SEQUENCE_TESTING + bool test_pin_hook_called = false; +#endif + for (;;) { + amy_grab_lock(); + stored_sequence_definition_t *source = *slot; + if (source != NULL + && source->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + + // No execution or other writer can observe a refs==1 definition, so + // appending the already-allocated incoming wire is a bounded mutation. + // This keeps bulk preload O(n) instead of cloning on every event. + if (source != NULL && source->refs == 1) { + stored_sequence_definition_append_owned(source, tick, period, + wire); + amy_release_lock(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); + return 1; + } + + // Pin a shared source before leaving the lock. From this point it is + // immutable, so allocation and all copying can happen without holding + // up the render thread. + if (source != NULL) source->refs++; + amy_release_lock(); + +#ifdef AMY_SEQUENCE_TESTING + // Tests use this one-shot rendezvous to make two writers clone the + // same pinned generation. It is absent from production builds. + if (!test_pin_hook_called && stored_sequence_after_pin_hook != NULL) { + test_pin_hook_called = true; + stored_sequence_after_pin_hook(); + } +#endif + + stored_sequence_definition_t *candidate = source == NULL + ? stored_sequence_definition_new() + : stored_sequence_definition_clone(source); + if (candidate == NULL) { + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + amy_grab_lock(); + dead = stored_sequence_definition_release_locked(source, + origin); + amy_release_lock(); + } + stored_sequence_definition_destroy(dead); + amy_oom("stored sequence edit: out of memory\n"); + free(wire); + return 0; + } + stored_sequence_definition_append_owned(candidate, tick, period, wire); + + amy_grab_lock(); + if (*slot == source) { + *slot = candidate; + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + // Drop the old slot ownership and our temporary writer pin. + dead = stored_sequence_definition_release_locked(source, + origin); + stored_sequence_definition_t *after_pin = + stored_sequence_definition_release_locked(source, + origin); + if (after_pin != NULL) dead = after_pin; + } + amy_release_lock(); + stored_sequence_definition_destroy(dead); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); + return 1; + } + + // Another writer published first. Keep the caller's wire, release our + // source pin, discard the private candidate outside the lock, and retry + // against the new cumulative definition. + stored_sequence_definition_t *dead = source == NULL ? NULL + : stored_sequence_definition_release_locked(source, origin); + amy_release_lock(); + stored_sequence_candidate_discard(candidate, wire); + stored_sequence_definition_destroy(dead); + } +} + +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot reset sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot reset sequence: tag %" PRIu32 + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (origin == SEQUENCER_ORIGIN_STORED) { + fprintf(stderr, "sequence %" PRIu32 + " cannot reset definitions from a stored sequence event\n", + tag); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + amy_grab_lock(); + stored_sequence_definition_t *definition = *slot; + *slot = NULL; + stored_sequence_definition_t *dead = NULL; + dead = stored_sequence_definition_release_locked(definition, origin); + amy_release_lock(); + stored_sequence_definition_destroy(dead); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + return 1; +} + +uint8_t sequencer_sequence_reset(uint32_t tag) { + return sequencer_sequence_reset_with_origin( + tag, SEQUENCER_ORIGIN_EXTERNAL); +} + +static uint32_t sequence_control_tick(uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick) { + // A control fired by the root sequencer participates in this tick. A + // control arriving between ticks begins no earlier than the next tick. + uint32_t tick = origin == SEQUENCER_ORIGIN_EXTERNAL + ? amy_global.sequencer_tick_count + 1 + : current_tick; + if (alignment_period != 0) { + uint32_t remainder = tick % alignment_period; + if (remainder != 0) { + uint32_t delta = alignment_period - remainder; + // The visible uint32 clock restarts at zero on rollover, and zero + // is an alignment boundary for every period. Do not carry a + // pre-rollover modulo phase into the wrapped clock. + tick = delta > UINT32_MAX - tick ? 0 : tick + delta; + } + } + return tick; +} + +uint8_t sequencer_sequence_control_with_origin( + uint32_t tag, uint32_t action, uint32_t value, + uint32_t alignment_period, sequencer_origin_t origin, + uint32_t current_tick) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot control sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot control sequence %" PRIu32 + ": valid tags are [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (alignment_period > INT32_MAX) { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": alignment %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, alignment_period, INT32_MAX); + return 0; + } + if (action == SEQUENCE_CONTROL_GATE && value > INT32_MAX) { + fprintf(stderr, "cannot gate sequence %" PRIu32 + ": duration %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, value, INT32_MAX); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + uint8_t result = 0; + amy_grab_lock(); + if (action == SEQUENCE_CONTROL_START) { + if (*slot == NULL || (*slot)->event_count == 0) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": its definition is empty\n", tag); + } else { + uint32_t start_tick = sequence_control_tick( + alignment_period, origin, current_tick); + stored_sequence_execution_t *available = NULL; + uint32_t available_slot = 0; + for (uint32_t word = 0; + word < execution_bit_words && available == NULL; ++word) { + uint32_t occupied = occupied_execution_bits[word]; + if (occupied == UINT32_MAX) continue; + for (uint32_t bit = 0; bit < 32; ++bit) { + uint32_t slot_index = word * 32 + bit; + if (slot_index >= max_stored_sequence_executions) break; + if ((occupied & (1u << bit)) == 0) { + available_slot = slot_index; + available = &sequence_executions[slot_index]; + break; + } + } + } + if (available == NULL) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": all %" PRIu32 " execution slots are occupied\n", + tag, max_stored_sequence_executions); + } else { + memset(available, 0, sizeof(*available)); + available->definition = *slot; + available->definition->refs++; + available->tag = tag; + available->start_tick = start_tick; + available->occupied = true; + uint32_t word = available_slot / 32; + uint32_t mask = 1u << (available_slot % 32); + occupied_execution_bits[word] |= mask; + if (available->definition->has_control_event) + control_execution_bits[word] |= mask; + if (available->definition->has_regular_event) + regular_execution_bits[word] |= mask; + result = 1; + } + } + } else if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_GATE) { + uint32_t control_tick = sequence_control_tick( + alignment_period, origin, current_tick); + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; + if (!execution->occupied || execution->tag != tag) + continue; + if (action == SEQUENCE_CONTROL_STOP) { + execution->stop_tick = control_tick; + execution->stop_pending = true; + } else { + execution->gate_change_tick = control_tick; + execution->gate_duration = value; + execution->gate_change_pending = true; + } + result = 1; + } + } else { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": action %" PRIu32 " is unknown; valid actions are " + "stop=0, start=1, gate=2\n", tag, action); + } + amy_release_lock(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + return result; +} + +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + return sequencer_sequence_control_with_origin( + tag, action, value, alignment_period, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + +static bool stored_sequence_event_hits(const stored_sequence_event_t *event, + uint32_t local_tick) { + return event->period != 0 ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool stored_sequence_event_is_control( + const stored_sequence_event_t *event) { + return strncmp(event->wire, "HC", 2) == 0; +} + +static void sequence_play_wire_now(char *wire, sequencer_origin_t origin, + uint32_t current_tick) { + if (wire[0] == 'H') + handle_ticks_message_with_origin(wire, origin, current_tick); + else amy_play_message(wire); +} + +static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { + sequence_play_wire_now( + (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); +} + +static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, + bool controls) { + amy_grab_lock(); + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied) { + amy_release_lock(); + return false; + } + if (!execution->started) { + if (!AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + execution->started = true; + } + uint32_t elapsed = tick - execution->start_tick; + stored_sequence_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } + if (controls) { + if (!definition->has_control_event) { + amy_release_lock(); + return false; + } + if (execution->controls_processed + && execution->controls_processed_tick == tick) { + amy_release_lock(); + return false; + } + // Mark before dispatch: a control graph may stop/reuse this slot, and a + // newly created execution in that slot must remain distinguishable. + execution->controls_processed = true; + execution->controls_processed_tick = tick; + } else { + if (!definition->has_regular_event) { + if (!definition->has_periodic_event + && elapsed == definition->last_one_shot_tick) + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; + } + if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) + execution->gated = false; + } + bool suppress = !controls && execution->gated; + uint32_t ordered_start = 0; + uint32_t ordered_end = 0; + bool ordered_schedule = definition->schedule_period != UINT32_MAX; + uint32_t schedule_tick = elapsed; + uint32_t *cursor = controls ? &execution->next_control_order + : &execution->next_event_order; + if (ordered_schedule && definition->schedule_period != 0) { + schedule_tick %= definition->schedule_period; + if (schedule_tick == 0) *cursor = 0; + } + if (ordered_schedule) { + ordered_start = *cursor; + ordered_end = ordered_start; + while (ordered_end < definition->event_count) { + uint32_t event_index = definition->event_order[ordered_end]; + if (definition->events[event_index].tick > schedule_tick) break; + ++ordered_end; + } + // Advance before dispatch because an HC payload can recursively stop + // or recycle this slot. A gate deliberately consumes suppressed due + // events rather than replaying them after the gate opens. + *cursor = ordered_end; + } + definition->refs++; + amy_release_lock(); + + bool dispatched_control = false; + if (!suppress) { + if (ordered_schedule) { + for (uint32_t order_index = ordered_start; + order_index < ordered_end; ++order_index) { + uint32_t event_index = + definition->event_order[order_index]; + stored_sequence_event_t *event = + &definition->events[event_index]; + if (event->tick == schedule_tick + && stored_sequence_event_is_control(event) == controls) { + if (controls) dispatched_control = true; + stored_sequence_play_wire(event->wire, tick); + } + } + } else { + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = + &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) { + if (controls) dispatched_control = true; + stored_sequence_play_wire(event->wire, tick); + } + } + } + } + + bool finite_complete = !controls && !definition->has_periodic_event + && elapsed == definition->last_one_shot_tick; + amy_grab_lock(); + stored_sequence_definition_retire_locked(definition); + if (finite_complete && execution->occupied + && execution->definition == definition + && execution->start_tick == tick - elapsed) + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + // The control traversal needs another pass only when a due control may + // have started an execution in an already-visited slot. Merely visiting a + // periodic control definition must not force a second full slot scan. + return controls && dispatched_control; +} + +static uint32_t stored_sequence_active_word(bool controls, uint32_t word) { + amy_grab_lock(); + uint32_t bits = controls ? control_execution_bits[word] + : regular_execution_bits[word]; + amy_release_lock(); + return bits; +} + +static void stored_sequence_process_controls(uint32_t tick) { + // A control can start an execution in a lower-numbered slot already passed + // by this scan. Repeat until no due execution remains unvisited. At most one + // control visit per configured slot is allowed per tick; this both covers + // every simultaneously active execution and bounds stop/reuse cycles. + uint32_t visits_left = max_stored_sequence_executions; + bool progressed; + do { + progressed = false; + for (uint32_t word = 0; + word < execution_bit_words && visits_left != 0; ++word) { + uint32_t bits = stored_sequence_active_word(true, word); + for (uint32_t bit = 0; + bit < 32 && bits != 0 && visits_left != 0; ++bit) { + uint32_t mask = 1u << bit; + if ((bits & mask) == 0) continue; + bits &= ~mask; + uint32_t slot = word * 32 + bit; + if (slot >= max_stored_sequence_executions) break; + if (stored_sequence_process_slot(slot, tick, true)) { + visits_left--; + progressed = true; + } + } + } + } while (progressed && visits_left != 0); +} + +static void stored_sequence_process_events(uint32_t tick) { + for (uint32_t word = 0; word < execution_bit_words; ++word) { + uint32_t bits = stored_sequence_active_word(false, word); + for (uint32_t bit = 0; bit < 32 && bits != 0; ++bit) { + uint32_t mask = 1u << bit; + if ((bits & mask) == 0) continue; + bits &= ~mask; + uint32_t slot = word * 32 + bit; + if (slot >= max_stored_sequence_executions) break; + stored_sequence_process_slot(slot, tick, false); + } + } +} + static void sequencer_process_tick(void) { - amy_global.sequencer_tick_count++; +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + uint64_t diagnostic_stage_started_us = amy_get_us(); + ++amy_last_sequence_tick_count; +#endif + // External sequence controls take their next-tick snapshot under this same + // lock, so current-tick versus next-tick activation has one ordering point. + amy_grab_lock(); + uint32_t tick = ++amy_global.sequencer_tick_count; + amy_release_lock(); midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode // Guard nested check-and-fire calls (via a fired message's own parse) // while still processing this tick's fires; restore on the way out. @@ -257,7 +1231,7 @@ static void sequencer_process_tick(void) { bool hit = false; bool delete = false; if(sequences[tag].period != 0) { // period set - uint32_t offset = amy_global.sequencer_tick_count % sequences[tag].period; + uint32_t offset = tick % sequences[tag].period; if (offset == sequences[tag].tick) hit = true; } else { // Test for absolute tick (no period set). <= rather than ==: @@ -268,7 +1242,7 @@ static void sequencer_process_tick(void) { // playing. <= lets it fire on the next tick instead, matching // the play-it-late rule sequencer_add_wire() uses for a // one-off that is already due when it arrives. - if (sequences[tag].tick <= amy_global.sequencer_tick_count) { hit = true; delete = true; } + if (sequences[tag].tick <= tick) { hit = true; delete = true; } } if(hit) { // Take the message out (one-shot) or a copy of it (repeating) @@ -285,7 +1259,10 @@ static void sequencer_process_tick(void) { active_unlink(tag); } else { size_t len = strlen(sequences[tag].wire); - wire = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + wire = len >= UINT32_MAX ? NULL + : (char *)malloc_caps( + (uint32_t)(len + 1), + amy_global.config.ram_caps_events); if (wire != NULL) memcpy(wire, sequences[tag].wire, len + 1); else amy_oom("sequencer fire"); } @@ -293,16 +1270,35 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_play_message(wire); + sequence_play_wire_now( + wire, SEQUENCER_ORIGIN_RENDER, tick); free(wire); } } } tag = next; } +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_root_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); + diagnostic_stage_started_us = amy_get_us(); +#endif + // Composed controls take effect before ordinary stored-sequence events on + // the same tick. This lets a parent stop a child without one extra onset. + stored_sequence_process_controls(tick); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_control_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); + diagnostic_stage_started_us = amy_get_us(); +#endif + stored_sequence_process_events(tick); +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_event_us += + (uint32_t)(amy_get_us() - diagnostic_stage_started_us); +#endif wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { - amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); + amy_global.config.amy_external_sequencer_hook(tick); } } @@ -338,7 +1334,9 @@ void sequencer_midi_start() { // If external clock was not previously enabled, keep using internal clock // so the sequencer advances on its own without needing F8 ticks. if (sequencer_external_clock) { + amy_grab_lock(); amy_global.sequencer_tick_count = 0; + amy_release_lock(); } // Reset the tick timer to now so sequencer_check_and_fill doesn't try to // catch up all the ticks that elapsed while stopped. @@ -377,6 +1375,12 @@ void sequencer_external_clock_disable() { // amy_sysclock(), which counts rendered samples, so the sequencer advances on // AMY time in any rendering context (live, offline, tests). void sequencer_check_and_fill() { +#ifdef AMY_ESP_LOAD_DIAGNOSTIC + amy_last_sequence_root_us = 0; + amy_last_sequence_control_us = 0; + amy_last_sequence_event_us = 0; + amy_last_sequence_tick_count = 0; +#endif if (sequences == NULL) return; // sequencer_init hasn't run if (sequencer_external_clock) return; if (wire_firing) return; // nested via a fired message's own parse diff --git a/src/sequencer.h b/src/sequencer.h index d073e642..3902e13a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,23 +5,72 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences); +void sequencer_init(uint32_t max_num_sequences, uint32_t max_sequence_events, + uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); void sequencer_recompute(); void sequencer_check_and_fill(); // called once per block from amy_execute_deltas() +// Destroy zero-reference immutable sequence definitions retired by the render +// path. The caller must be a control/non-render thread. +void sequencer_reclaim_retired(); + +// Internal dispatch origin. External commands start no earlier than the next +// tick and may reclaim retired definitions. Render-originated commands use the +// supplied current tick and may only retire storage. A stored event is also +// prohibited from editing sequence definitions while they are being walked. +typedef enum sequencer_origin_t { + SEQUENCER_ORIGIN_EXTERNAL = 0, + SEQUENCER_ORIGIN_RENDER, + SEQUENCER_ORIGIN_STORED +} sequencer_origin_t; + +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif // Store a wire message (with its leading 'H' already stripped) in the -// sequencer. If has_tag is true, it's stored under tag (replacing/clearing -// any existing entry there, addressable later by that same tag); clears the -// tag if tick and period are both 0. If has_tag is false, it's stored -// anonymously (round-robin in a small reserved pool) and can't be addressed -// or cancelled by any tag. Takes ownership of wire. +// sequencer. If has_tag is true, append it to the reusable sequence identified +// by tag. An empty tick=period=0 command clears that sequence; the same timing +// with a payload appends a local tick-zero event. If has_tag is false, store it +// anonymously (round-robin in a small reserved pool) for immediate sequencer +// playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin); +// Append one ordinary ticks event to the reusable sequence identified by tag. +// Takes ownership of wire. A tick=period=0 event is a valid one-shot when its +// wire payload is nonempty. +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire); +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin); +// Clear the future definition at tag. Executions which already started retain +// their immutable definition and may finish. +uint8_t sequencer_sequence_reset(uint32_t tag); +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin); +// sequence_control is [tag, action, alignment_period] for stop/start or +// [tag, gate, duration, alignment_period]. +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period); +uint8_t sequencer_sequence_control_with_origin(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick); +void sequencer_sequence_reset_timebase(); +#ifdef AMY_SEQUENCE_TESTING +void sequencer_test_fail_allocation_after(int32_t successful_allocations); +void sequencer_test_set_after_pin_hook(void (*hook)(void)); +#endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/check_android_audio_capture.py b/tests/check_android_audio_capture.py new file mode 100644 index 00000000..0e834448 --- /dev/null +++ b/tests/check_android_audio_capture.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +import argparse +import array +import math +import sys +import wave + + +def read_wave(path): + with wave.open(path, "rb") as wav: + channels = wav.getnchannels() + sample_width = wav.getsampwidth() + sample_rate = wav.getframerate() + frames = wav.getnframes() + raw = wav.readframes(frames) + + if sample_width != 2: + raise ValueError(f"{path}: expected 16-bit PCM, got {sample_width * 8} bits") + + samples = array.array("h") + samples.frombytes(raw) + if sys.byteorder != "little": + samples.byteswap() + return channels, sample_rate, frames, samples + + +def levels(samples): + if not samples: + return 0, 0.0, -200.0, -200.0, 0 + + peak = max(abs(int(sample)) for sample in samples) + sum_squares = sum(int(sample) * int(sample) for sample in samples) + rms = math.sqrt(sum_squares / len(samples)) + peak_dbfs = 20.0 * math.log10(peak / 32768.0) if peak else -200.0 + rms_dbfs = 20.0 * math.log10(rms / 32768.0) if rms else -200.0 + clipped = sum(sample in (-32768, 32767) for sample in samples) + return peak, rms, peak_dbfs, rms_dbfs, clipped + + +def main(): + parser = argparse.ArgumentParser( + description="Compare AMY renderer samples with the I16 buffer handed to Oboe" + ) + parser.add_argument("amy_wave") + parser.add_argument("oboe_wave") + parser.add_argument( + "--min-peak-dbfs", + type=float, + default=-6.0, + help="fail when either capture peak is below this value (default: -6 dBFS)", + ) + args = parser.parse_args() + + amy_channels, amy_rate, amy_frames, amy = read_wave(args.amy_wave) + oboe_channels, oboe_rate, oboe_frames, oboe = read_wave(args.oboe_wave) + + expected = (2, 48000) + if (amy_channels, amy_rate) != expected: + raise SystemExit( + f"AMY capture format mismatch: {amy_channels} channels @ {amy_rate} Hz" + ) + if (oboe_channels, oboe_rate) != expected: + raise SystemExit( + f"Oboe capture format mismatch: {oboe_channels} channels @ {oboe_rate} Hz" + ) + if amy_frames != oboe_frames or len(amy) != len(oboe): + raise SystemExit( + f"capture length mismatch: AMY={amy_frames} frames Oboe={oboe_frames} frames" + ) + + mismatch_samples = 0 + max_abs_diff = 0 + for source, output in zip(amy, oboe): + difference = abs(int(source) - int(output)) + if difference: + mismatch_samples += 1 + max_abs_diff = max(max_abs_diff, difference) + + amy_peak, amy_rms, amy_peak_dbfs, amy_rms_dbfs, amy_clipped = levels(amy) + oboe_peak, oboe_rms, oboe_peak_dbfs, oboe_rms_dbfs, oboe_clipped = levels(oboe) + + print(f"frames={amy_frames} channels={amy_channels} sample_rate={amy_rate}") + print( + f"AMY : peak={amy_peak:5d} {amy_peak_dbfs:7.2f} dBFS " + f"RMS={amy_rms:9.2f} {amy_rms_dbfs:7.2f} dBFS clipped={amy_clipped}" + ) + print( + f"Oboe: peak={oboe_peak:5d} {oboe_peak_dbfs:7.2f} dBFS " + f"RMS={oboe_rms:9.2f} {oboe_rms_dbfs:7.2f} dBFS clipped={oboe_clipped}" + ) + print(f"sample mismatches={mismatch_samples} max_abs_diff={max_abs_diff}") + + if mismatch_samples != 0: + raise SystemExit( + "Oboe callback buffer is not byte-for-byte identical to the AMY render stream" + ) + if amy_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"AMY peak {amy_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if oboe_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"Oboe peak {oboe_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if amy_clipped or oboe_clipped: + raise SystemExit( + f"full-scale clipping detected: AMY={amy_clipped} Oboe={oboe_clipped} samples" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/run_amy_unix_socket_test.sh b/tests/run_amy_unix_socket_test.sh new file mode 100644 index 00000000..b5941d03 --- /dev/null +++ b/tests/run_amy_unix_socket_test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +out="$(mktemp "${TMPDIR:-/tmp}/amy-unix-socket-test.XXXXXX")" +trap 'rm -f "$out"' EXIT + +cc \ + -std=c11 \ + -O1 \ + -g \ + -Wall \ + -Wextra \ + -Werror \ + -pthread \ + -fsanitize=address,undefined \ + -fno-omit-frame-pointer \ + -I"$repo_root/src" \ + "$repo_root/src/amy_unix_socket.c" \ + "$repo_root/tests/test_amy_unix_socket.c" \ + -o "$out" + +ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \ +UBSAN_OPTIONS=halt_on_error=1 \ + "$out" diff --git a/tests/test_amy_unix_socket.c b/tests/test_amy_unix_socket.c new file mode 100644 index 00000000..0da00535 --- /dev/null +++ b/tests/test_amy_unix_socket.c @@ -0,0 +1,403 @@ +#define _GNU_SOURCE + +#include "amy_unix_socket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define WAIT_STEPS 2000 +#define WAIT_US 1000 + +typedef uint32_t (*counter_fn)(const amy_unix_socket_server_t *server); + +static void socket_address(struct sockaddr_un *addr, const char *path) { + memset(addr, 0, sizeof(*addr)); + addr->sun_family = AF_UNIX; + assert(strlen(path) < sizeof(addr->sun_path)); + strcpy(addr->sun_path, path); +} + +static int connect_client(const char *path) { + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(fd >= 0); + + struct sockaddr_un addr; + socket_address(&addr, path); + assert(connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0); + return fd; +} + +static int wait_receive(amy_unix_socket_server_t *server, + char *buffer, + size_t buffer_len) { + for (int i = 0; i < WAIT_STEPS; ++i) { + int rc = amy_unix_socket_receive(server, buffer, buffer_len); + if (rc != 0) return rc; + usleep(WAIT_US); + } + return -ETIMEDOUT; +} + +static ssize_t wait_client_receive(int fd, void *buffer, size_t len) { + for (int i = 0; i < WAIT_STEPS; ++i) { + ssize_t rc = recv(fd, buffer, len, MSG_DONTWAIT); + if (rc >= 0) return rc; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + return -1; + } + usleep(WAIT_US); + } + errno = ETIMEDOUT; + return -1; +} + +static void wait_counter(counter_fn counter, + amy_unix_socket_server_t *server, + uint32_t expected) { + for (int i = 0; i < WAIT_STEPS; ++i) { + if (counter(server) >= expected) return; + usleep(WAIT_US); + } + assert(counter(server) >= expected); +} + +static void wait_until_disconnected(amy_unix_socket_server_t *server) { + const char probe[] = "x"; + for (int i = 0; i < WAIT_STEPS; ++i) { + int rc = amy_unix_socket_send(server, probe, sizeof(probe) - 1u); + if (rc == -ENOTCONN) return; + assert(rc == (int)(sizeof(probe) - 1u) || rc == -EPIPE || + rc == -ECONNRESET); + usleep(WAIT_US); + } + assert(amy_unix_socket_send(server, probe, sizeof(probe) - 1u) == + -ENOTCONN); +} + +static void make_temp_path(char *dir_template, + char *path, + size_t path_len) { + char *dir = mkdtemp(dir_template); + assert(dir != NULL); + assert(chmod(dir, 0700) == 0); + int written = snprintf(path, path_len, "%s/amy.sock", dir); + assert(written > 0 && (size_t)written < path_len); +} + +static void remove_temp_dir(const char *path) { + char dir[256]; + size_t len = strlen(path); + assert(len < sizeof(dir)); + memcpy(dir, path, len + 1u); + char *slash = strrchr(dir, '/'); + assert(slash != NULL); + *slash = '\0'; + assert(rmdir(dir) == 0); +} + +static void send_packet(int fd, const void *data, size_t len) { + assert(send(fd, data, len, MSG_NOSIGNAL) == (ssize_t)len); +} + +static void test_invalid_arguments(void) { + char output[MAX_MESSAGE_LEN]; + amy_unix_socket_server_t *server = NULL; + + assert(amy_unix_socket_start(NULL, "/tmp/unused.sock") == -EINVAL); + assert(amy_unix_socket_start(&server, NULL) == -EINVAL); + assert(amy_unix_socket_start(&server, "") == -EINVAL); + + char long_path[512]; + memset(long_path, 'x', sizeof(long_path)); + long_path[0] = '/'; + long_path[sizeof(long_path) - 1u] = '\0'; + assert(amy_unix_socket_start(&server, long_path) == -ENAMETOOLONG); + assert(server == NULL); + + assert(amy_unix_socket_receive(NULL, output, sizeof(output)) == -EINVAL); + assert(amy_unix_socket_receive(NULL, NULL, 0) == -EINVAL); + assert(amy_unix_socket_send(NULL, "x", 1) == -EINVAL); + assert(amy_unix_socket_send(NULL, NULL, 0) == -EINVAL); + assert(amy_unix_socket_queue_overruns(NULL) == 0); + assert(amy_unix_socket_oversize_packets(NULL) == 0); + assert(amy_unix_socket_rejected_peers(NULL) == 0); + amy_unix_socket_stop(NULL); +} + +static void test_round_trip_limits_and_permissions(void) { + char dir_template[] = "/tmp/amy-unix-roundtrip-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + assert(server != NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISSOCK(st.st_mode)); + assert((st.st_mode & 0777) == 0600); + assert(st.st_uid == geteuid()); + assert(amy_unix_socket_send(server, "x", 1) == -ENOTCONN); + + int client = connect_client(path); + const char command[] = "n60l1i2Z"; + send_packet(client, command, strlen(command)); + + char received[MAX_MESSAGE_LEN]; + int rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(command)); + assert(strcmp(received, command) == 0); + + // A too-small destination leaves the packet at the head of the queue. + const char second[] = "K28i2Z"; + send_packet(client, second, strlen(second)); + for (int i = 0; i < WAIT_STEPS; ++i) { + rc = amy_unix_socket_receive(server, received, 4); + if (rc != 0) break; + usleep(WAIT_US); + } + assert(rc == -EMSGSIZE); + rc = amy_unix_socket_receive(server, received, sizeof(received)); + assert(rc == (int)strlen(second)); + assert(strcmp(received, second) == 0); + + char maximum[MAX_MESSAGE_LEN]; + memset(maximum, 'm', AMY_UNIX_SOCKET_MAX_PACKET); + send_packet(client, maximum, AMY_UNIX_SOCKET_MAX_PACKET); + rc = wait_receive(server, received, sizeof(received)); + assert(rc == (int)AMY_UNIX_SOCKET_MAX_PACKET); + assert(memcmp(received, maximum, AMY_UNIX_SOCKET_MAX_PACKET) == 0); + assert(received[AMY_UNIX_SOCKET_MAX_PACKET] == '\0'); + + assert(amy_unix_socket_send(server, maximum, MAX_MESSAGE_LEN) == + -EMSGSIZE); + rc = amy_unix_socket_send(server, maximum, AMY_UNIX_SOCKET_MAX_PACKET); + assert(rc == (int)AMY_UNIX_SOCKET_MAX_PACKET); + + char reply[MAX_MESSAGE_LEN]; + ssize_t reply_len = wait_client_receive(client, reply, sizeof(reply)); + assert(reply_len == (ssize_t)AMY_UNIX_SOCKET_MAX_PACKET); + assert(memcmp(reply, maximum, AMY_UNIX_SOCKET_MAX_PACKET) == 0); + + close(client); + amy_unix_socket_stop(server); + assert(lstat(path, &st) < 0 && errno == ENOENT); + remove_temp_dir(path); +} + +static void test_oversize_packet_is_dropped(void) { + char dir_template[] = "/tmp/amy-unix-oversize-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + char packet[MAX_MESSAGE_LEN]; + memset(packet, 'x', sizeof(packet)); + send_packet(client, packet, sizeof(packet)); + wait_counter(amy_unix_socket_oversize_packets, server, 1); + assert(amy_unix_socket_oversize_packets(server) == 1); + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + + close(client); + amy_unix_socket_stop(server); + remove_temp_dir(path); +} + +static void test_full_queue_applies_backpressure_and_preserves_order(void) { + char dir_template[] = "/tmp/amy-unix-queue-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int client = connect_client(path); + + const uint32_t extra = 8; + for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY + extra; ++i) { + char packet[32]; + int len = snprintf(packet, sizeof(packet), "packet-%03u", i); + assert(len > 0 && (size_t)len < sizeof(packet)); + send_packet(client, packet, (size_t)len); + } + + // Let the receiver reach its bounded in-process capacity before the + // consumer starts. Excess packets must remain in the kernel socket queue, + // not be read and discarded. + usleep(150000); + + for (uint32_t i = 0; i < AMY_UNIX_SOCKET_QUEUE_CAPACITY + extra; ++i) { + char expected[32]; + int expected_len = snprintf(expected, sizeof(expected), + "packet-%03u", i); + char received[MAX_MESSAGE_LEN]; + int rc = wait_receive(server, received, sizeof(received)); + assert(rc == expected_len); + assert(strcmp(received, expected) == 0); + } + assert(amy_unix_socket_queue_overruns(server) == 0); + + char received[MAX_MESSAGE_LEN]; + assert(amy_unix_socket_receive(server, received, sizeof(received)) == 0); + close(client); + amy_unix_socket_stop(server); + remove_temp_dir(path); +} + +static void test_only_one_client_and_reconnect(void) { + char dir_template[] = "/tmp/amy-unix-clients-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + int first = connect_client(path); + + char received[MAX_MESSAGE_LEN]; + send_packet(first, "first", 5); + assert(wait_receive(server, received, sizeof(received)) == 5); + assert(strcmp(received, "first") == 0); + + int rejected = connect_client(path); + wait_counter(amy_unix_socket_rejected_peers, server, 1); + assert(amy_unix_socket_rejected_peers(server) == 1); + + // Rejecting a second connection must not disturb the established client. + send_packet(first, "still-first", 11); + assert(wait_receive(server, received, sizeof(received)) == 11); + assert(strcmp(received, "still-first") == 0); + close(rejected); + + close(first); + wait_until_disconnected(server); + + int second = connect_client(path); + send_packet(second, "second", 6); + assert(wait_receive(server, received, sizeof(received)) == 6); + assert(strcmp(received, "second") == 0); + + close(second); + amy_unix_socket_stop(server); + remove_temp_dir(path); +} + +static void test_live_socket_is_not_stolen(void) { + char dir_template[] = "/tmp/amy-unix-live-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *first_server = NULL; + assert(amy_unix_socket_start(&first_server, path) == 0); + int client = connect_client(path); + send_packet(client, "before", 6); + + char received[MAX_MESSAGE_LEN]; + assert(wait_receive(first_server, received, sizeof(received)) == 6); + + amy_unix_socket_server_t *second_server = NULL; + assert(amy_unix_socket_start(&second_server, path) == -EADDRINUSE); + assert(second_server == NULL); + + send_packet(client, "after", 5); + assert(wait_receive(first_server, received, sizeof(received)) == 5); + assert(strcmp(received, "after") == 0); + + close(client); + amy_unix_socket_stop(first_server); + remove_temp_dir(path); +} + +static void test_owned_stale_socket_is_replaced(void) { + char dir_template[] = "/tmp/amy-unix-stale-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + int stale = socket(AF_UNIX, SOCK_SEQPACKET, 0); + assert(stale >= 0); + struct sockaddr_un addr; + socket_address(&addr, path); + assert(bind(stale, (struct sockaddr *)&addr, sizeof(addr)) == 0); + close(stale); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + amy_unix_socket_stop(server); + + struct stat st; + assert(lstat(path, &st) < 0 && errno == ENOENT); + remove_temp_dir(path); +} + +static void test_existing_regular_file_is_never_removed(void) { + char dir_template[] = "/tmp/amy-unix-file-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == -EEXIST); + assert(server == NULL); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + assert(unlink(path) == 0); + remove_temp_dir(path); +} + +static void test_stop_preserves_replacement_path(void) { + char dir_template[] = "/tmp/amy-unix-replaced-XXXXXX"; + char path[256]; + make_temp_path(dir_template, path, sizeof(path)); + + amy_unix_socket_server_t *server = NULL; + assert(amy_unix_socket_start(&server, path) == 0); + assert(unlink(path) == 0); + + int fd = open(path, O_CREAT | O_WRONLY | O_EXCL, 0600); + assert(fd >= 0); + close(fd); + + amy_unix_socket_stop(server); + + struct stat st; + assert(lstat(path, &st) == 0); + assert(S_ISREG(st.st_mode)); + assert(unlink(path) == 0); + remove_temp_dir(path); +} + +int main(void) { + test_invalid_arguments(); + test_round_trip_limits_and_permissions(); + test_oversize_packet_is_dropped(); + test_full_queue_applies_backpressure_and_preserves_order(); + test_only_one_client_and_reconnect(); + test_live_socket_is_not_stolen(); + test_owned_stale_socket_is_replaced(); + test_existing_regular_file_is_never_removed(); + test_stop_preserves_replacement_path(); + puts("amy unix socket tests passed"); + return 0; +} diff --git a/tests/test_android_service_contract.py b/tests/test_android_service_contract.py new file mode 100644 index 00000000..4b0eb355 --- /dev/null +++ b/tests/test_android_service_contract.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Source-level guard for the Android AAR's public integration contract.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] + + +def require(pattern: str, text: str, label: str) -> None: + if re.search(pattern, text, flags=re.MULTILINE) is None: + raise AssertionError(f"Android service contract is missing {label}") + + +def main() -> None: + engine = (ROOT / "android/amy-service/src/main/cpp/amy_android.cpp").read_text() + capture = ( + ROOT / "android/amy-service/src/main/cpp/amy_android_capture.cpp" + ).read_text() + gradle = (ROOT / "android/amy-service/build.gradle.kts").read_text() + cmake = ( + ROOT / "android/amy-service/src/main/cpp/CMakeLists.txt" + ).read_text() + manifest = (ROOT / "android/amy-service/src/main/AndroidManifest.xml").read_text() + hello = (ROOT / "android/hello-world/src/main/java/org/amy/hello/MainActivity.java").read_text() + + require(r"kIntegrationMaxOscillators\s*=\s*336\s*;", engine, + "the 336-oscillator host capacity") + require(r"kIntegrationMaxBuses\s*=\s*11\s*;", engine, + "the 11-bus host capacity") + require(r"config\.max_oscs\s*=\s*kIntegrationMaxOscillators\s*;", engine, + "runtime oscillator configuration") + require(r"config\.max_buses\s*=\s*kIntegrationMaxBuses\s*;", engine, + "runtime bus configuration") + require(r"kIntegrationMaxReverbRooms\s*=\s*2\s*;", engine, + "the two shared aux returns") + require(r"config\.max_reverb_rooms\s*=\s*kIntegrationMaxReverbRooms\s*;", + engine, "runtime shared aux-return configuration") + require(r"kIntegrationMaxSequencerTags\s*=\s*1280\s*;", engine, + "the shared live-event and stored-sequence tag capacity") + require(r"config\.max_sequencer_tags\s*=\s*kIntegrationMaxSequencerTags\s*;", + engine, "runtime sequencer-tag configuration") + require(r"config\.max_sequence_events\s*=\s*kIntegrationMaxSequenceEvents\s*;", + engine, "runtime per-sequence event configuration") + require(r"config\.max_sequence_executions\s*=\s*kIntegrationMaxSequenceExecutions\s*;", + engine, "runtime sequence-execution configuration") + require(r"kIntegrationMaxSequenceExecutions\s*=\s*40\s*;", engine, + "characterized Omnichord sequence-execution capacity") + require(r"kCaptureSeconds\s*=\s*8\s*;", capture, + "the framework-safe eight-second audio capture window") + require(r'ndkVersion\s*=\s*"27\.2\.12479018"', gradle, + "the PySide-compatible Android NDK r27c") + require(r"gamma9001-blob-c", cmake, + "per-ABI Gamma9001 blob generation") + require(r"GAMMA9001=1", cmake, + "the Gamma9001 AMY compile profile") + require(r"\$\{GAMMA9001_PCM_C\}", cmake, + "the linked Gamma9001 PCM source") + require(r"amy_set_gamma9001_pcm\(gamma9001_pcm_data\)", engine, + "Gamma9001 PCM registration before AMY starts") + require(r"android:process=\":amy\"", manifest, "the separate :amy process") + require(r"android:exported=\"false\"", manifest, "a private Android component") + require(r"\$\{applicationId\}\.amy-autostart", manifest, + "an application-scoped provider authority") + + forbidden_client_symbols = ("AmyService", "System.loadLibrary", "native ") + for symbol in forbidden_client_symbols: + if symbol in hello: + raise AssertionError( + f"transport-only hello-world unexpectedly contains {symbol!r}" + ) + + print("Android service contract OK: private :amy process, socket-only client, " + "Gamma9001 PCM, 336 oscillators, 11 buses, 1280 sequencer tags, " + "8-second test capture") + + +if __name__ == "__main__": + main() diff --git a/tests/test_build_config.c b/tests/test_build_config.c new file mode 100644 index 00000000..a5a1977a --- /dev/null +++ b/tests/test_build_config.c @@ -0,0 +1,24 @@ +#include "amy.h" + +#ifndef EXPECT_AMY_BLOCK_SIZE +#error "EXPECT_AMY_BLOCK_SIZE is required" +#endif + +#ifndef EXPECT_BLOCK_SIZE_BITS +#error "EXPECT_BLOCK_SIZE_BITS is required" +#endif + +#ifndef EXPECT_AMY_SAMPLE_RATE +#error "EXPECT_AMY_SAMPLE_RATE is required" +#endif + +_Static_assert(AMY_BLOCK_SIZE == EXPECT_AMY_BLOCK_SIZE, + "unexpected AMY block size"); +_Static_assert(BLOCK_SIZE_BITS == EXPECT_BLOCK_SIZE_BITS, + "block-size shift does not match the selected block size"); +_Static_assert(AMY_SAMPLE_RATE == EXPECT_AMY_SAMPLE_RATE, + "unexpected AMY sample rate"); + +int main(void) { + return 0; +} diff --git a/tests/test_godot_backend_signals.py b/tests/test_godot_backend_signals.py new file mode 100644 index 00000000..1b2b5489 --- /dev/null +++ b/tests/test_godot_backend_signals.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Static regression for Amy.gd's platform-independent backend lifecycle.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +AMY_GD = ROOT / "godot" / "amy.gd" + + +def function_body(source: str, name: str) -> str: + lines = source.splitlines() + signature = f"func {name}(" + start = next( + index for index, line in enumerate(lines) if line.startswith(signature) + ) + body: list[str] = [] + for line in lines[start + 1 :]: + if line and not line.startswith(("\t", " ")): + break + body.append(line) + return "\n".join(body) + + +class GodotBackendSignalContract(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = AMY_GD.read_text(encoding="utf-8") + + def test_public_signal_signatures_are_stable(self) -> None: + self.assertEqual(self.source.count("signal backend_ready\n"), 1) + self.assertEqual( + self.source.count("signal backend_error(message: String)\n"), 1 + ) + + def test_native_backend_reports_success_and_failure(self) -> None: + body = function_body(self.source, "_init_native") + self.assertIn('var message := "AmySynth GDExtension not loaded', body) + self.assertIn("backend_error.emit(message)", body) + self.assertIn("_started = true", body) + self.assertIn("backend_ready.emit()", body) + self.assertLess( + body.index("_started = true"), body.index("backend_ready.emit()") + ) + + def test_web_backend_reports_success_and_timeout(self) -> None: + body = function_body(self.source, "_init_web") + self.assertIn("_started = true", body) + self.assertIn("backend_ready.emit()", body) + self.assertIn('var message := "AMY web module failed to load', body) + self.assertIn("backend_error.emit(message)", body) + self.assertLess( + body.index("_started = true"), body.index("backend_ready.emit()") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ignore_note_offs.c b/tests/test_ignore_note_offs.c new file mode 100644 index 00000000..005c93a3 --- /dev/null +++ b/tests/test_ignore_note_offs.c @@ -0,0 +1,73 @@ +// Regression coverage for synths which intentionally ignore note-offs. +// +// A one-shot drum synth can receive an unlimited series of note-ons without +// matching note-offs. Voice stealing must not put those notes into the +// bounded forgotten-note pool: its only purpose is to absorb note-offs which +// this synth has explicitly said will be ignored. + +#include +#include +#include "amy.h" + +static int failures = 0; + +#define CHECK(cond, message) do { \ + if (cond) { printf(" ok %s\n", message); } \ + else { printf(" FAIL %s\n", message); failures++; } \ +} while (0) + +static void render_a_bit(void) { + for (int i = 0; i < 4; ++i) amy_simple_fill_buffer(); +} + +static void send(const char *message) { + amy_add_message((char *)message); + render_a_bit(); +} + +extern int instrument_test_forgotten_note_slots(int instrument_number); + +static void test_ignored_note_offs_do_not_fill_forgotten_pool(void) { + printf("ignored note-offs require no forgotten-note bookkeeping\n"); + // This is the shape used by a small polyphonic one-shot PCM drum synth: + // four voices, one oscillator per voice, and no note-offs by design. + send("i0iv4in1if2Z"); + for (int note = 1; note <= 64; ++note) { + char message[32]; + snprintf(message, sizeof(message), "n%dl1i0Z", note); + send(message); + } + + CHECK(instrument_test_forgotten_note_slots(0) == 0, + "64 one-shot onsets leave the pool empty"); +} + +static void test_ordinary_synths_still_track_stolen_notes(void) { + printf("ordinary synths retain forgotten-note matching\n"); + send("i1iv4in1Z"); + for (int note = 1; note <= 5; ++note) { + char message[32]; + snprintf(message, sizeof(message), "n%dl1i1Z", note); + send(message); + } + CHECK(instrument_test_forgotten_note_slots(1) == 1, + "one stolen ordinary note occupies one pool slot"); +} + +// examples.o wants this from amy-example.c; every ctest stubs it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + render_a_bit(); + + test_ignored_note_offs_do_not_fill_forgotten_pool(); + test_ordinary_synths_still_track_stolen_notes(); + + amy_stop(); + if (failures) { printf("%d FAILURES\n", failures); return 1; } + printf("all ok\n"); + return 0; +} diff --git a/tests/test_js_api.js b/tests/test_js_api.js new file mode 100644 index 00000000..8382b2c0 --- /dev/null +++ b/tests/test_js_api.js @@ -0,0 +1,22 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); + +require(path.join(__dirname, "..", "src", "amy_api.generated.js")); + +assert.equal( + amy_message({sequence_control: [7, 1, 48]}), + "HC7,1,48Z" +); +assert.equal( + amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), + "H0,48,3HC7,1,1Z" +); +assert.equal(amy_message({sequence_reset: 7}), "HR7Z"); +assert.equal( + amy_message({sequence_control: [7, AMY.SEQUENCE_CONTROL_GATE, 24, 1]}), + "HC7,2,24,1Z" +); + +console.log("JavaScript reusable-sequence API checks passed"); diff --git a/tests/test_pcm_bank_build_contract.py b/tests/test_pcm_bank_build_contract.py new file mode 100644 index 00000000..9dfb66a5 --- /dev/null +++ b/tests/test_pcm_bank_build_contract.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Static guard for LB's release-only CPython PCM-bank selector.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SETUP = (ROOT / "setup.py").read_text(encoding="utf-8") + + +def main() -> None: + required = ( + "AMY_PCM_BANK", + "use_gamma9001", + "comp_args.append(\"-DGAMMA9001\")", + "class AmyBuildExt(build_ext):", + "self.force = True", + "cmdclass={'build_ext': AmyBuildExt}", + ) + missing = [value for value in required if value not in SETUP] + if missing: + raise AssertionError(f"missing PCM-bank build contract: {missing}") + print("PCM-bank build contract OK: tiny is selectable, Gamma9001 stays default") + + +if __name__ == "__main__": + main() diff --git a/tests/test_python_offline_live.py b/tests/test_python_offline_live.py new file mode 100644 index 00000000..6945d617 --- /dev/null +++ b/tests/test_python_offline_live.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Smoke-test configured CPython rendering without a competing audio thread.""" + +from __future__ import annotations + +import time + +import amy +import c_amy + + +def main() -> int: + c_amy.live( + audio=False, + default_synths=0, + max_sequencer_tags=1280, + max_sequence_events=64, + max_sequence_executions=40, + max_reverb_rooms=2, + ) + + before = amy.ticks_ms() + time.sleep(0.05) + after_sleep = amy.ticks_ms() + if after_sleep != before: + raise AssertionError( + "audio=False advanced AMY without an explicit render: " + f"{before} -> {after_sleep}" + ) + + amy.send(osc=0, wave=amy.SINE, freq=440, vel=1) + amy.send(reverb_room=[1, 0.35, 0.8, 0.5, 3000]) + amy.send(bus=0, reverb_send=[1, 0.5]) + peak = 0 + for _ in range(8): + block = c_amy.render_to_list() + peak = max(peak, max((abs(int(sample)) for sample in block), default=0)) + if peak <= 0: + raise AssertionError("offline render produced no audio") + if amy.ticks_ms() <= after_sleep: + raise AssertionError("explicit offline renders did not advance AMY time") + + # A high sequence tag proves that audio=False retained live()'s configurable + # engine sizing instead of falling back to the import-time defaults. + amy.define_sequence(1000, [dict(ticks=(0,), osc=0, vel=0)]) + amy.send(sequence_control=(1000, amy.SEQUENCE_CONTROL_START)) + + # CPython validates this runtime allocation dimension before stopping an + # already-running engine, just like the other live() sizing arguments. + try: + c_amy.live(audio=False, max_reverb_rooms=-1) + except ValueError as exc: + if "max_reverb_rooms" not in str(exc): + raise AssertionError(f"unclear shared-reverb validation: {exc}") from exc + else: + raise AssertionError("negative max_reverb_rooms was accepted") + + # The rejected call above must not have stopped or replaced this engine. + if not c_amy.render_to_list(): + raise AssertionError("rejected live() call stopped the current engine") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_reverb_limit.c b/tests/test_reverb_limit.c new file mode 100644 index 00000000..b82a5a5a --- /dev/null +++ b/tests/test_reverb_limit.c @@ -0,0 +1,68 @@ +// Compile-time ceiling for memory-intensive built-in reverb networks. + +#include +#include "amy.h" + +static int failures; +static uint8_t external_selector[1] = { 1 }; + +#define CHECK(c, message) do { \ + if (c) printf(" ok %s\n", message); \ + else { printf(" FAIL %s\n", message); ++failures; } \ +} while (0) + +static void passthrough_return(uint16_t return_index, SAMPLE *block, + uint16_t frames, void *user_data) { + (void)return_index; + (void)block; + (void)frames; + (void)user_data; +} + +static amy_config_t test_config(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_reverb_rooms = 1; + return config; +} + +static void test_builtin_ceiling(void) { + puts("built-in shared return consumes the configured ceiling"); + amy_config_t config = test_config(); + amy_start(config); + CHECK(amy_global.allocated_reverbs == 1, + "one shared built-in reverb is allocated"); + config_reverb(0, 0.5f, 0.8f, 0.4f, 3000.0f); + CHECK(amy_global.bus[0]->reverb.rev == NULL, + "a legacy per-bus reverb cannot exceed the ceiling"); + CHECK(amy_global.bus[0]->reverb.level == 0, + "a rejected per-bus reverb remains disabled"); + amy_stop(); +} + +static void test_external_return_does_not_count(void) { + puts("external return leaves the built-in allowance available"); + amy_config_t config = test_config(); + config.aux_return_external = external_selector; + config.amy_external_aux_return_process_hook = passthrough_return; + amy_start(config); + CHECK(amy_global.allocated_reverbs == 0, + "external return consumes no built-in reverb slot"); + config_reverb(0, 0.5f, 0.8f, 0.4f, 3000.0f); + CHECK(amy_global.bus[0]->reverb.rev != NULL, + "legacy per-bus reverb can use the remaining slot"); + CHECK(amy_global.allocated_reverbs == 1, + "per-bus allocation is counted"); + amy_stop(); +} + +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + CHECK(AMY_MAX_REVERBS == 1, "test uses an embedded-style ceiling of one"); + test_builtin_ceiling(); + test_external_return_does_not_count(); + if (failures) return 1; + puts("all reverb ceiling checks passed"); + return 0; +} diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py new file mode 100644 index 00000000..2b6f6ae3 --- /dev/null +++ b/tests/test_sequence_api.py @@ -0,0 +1,111 @@ +"""Small, audio-independent checks for the reusable-sequence Python API.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import amy + + +def expect_error(fragment, fn): + try: + fn() + except ValueError as exc: + assert fragment in str(exc), str(exc) + else: + raise AssertionError("expected ValueError containing %r" % fragment) + + +def main(): + assert amy.message(ticks=(0, 0, 7), synth=1, note=60, vel=1) \ + == "H0,0,7n60l1i1Z" + assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ + == "HC7,1,48Z" + assert amy.message(sequence_control=("%v", "%v", "%v", "%v")) \ + == "HC%v,%v,%v,%vZ" + assert amy.message(ticks=(0, 48, 3), + sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ + == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, action="start") == "HC7,1,0Z" + assert amy.message(sequence=7, action="stop", alignment_period=48) \ + == "HC7,0,48Z" + assert amy.message(sequence=7, action="gate", duration=24, + alignment_period=1) == "HC7,2,24,1Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, action="start", + alignment_period=1) == "H0,48,3HC7,1,1Z" + assert amy.message(sequence_reset=7) == "HR7Z" + assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ + == "H1,4,2n60l1i1Z" + assert amy.message(ticks=",24,2", osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(None, 24, 2), osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(4, 4), osc=1) == "H4,4v1Z" + + sent = [] + old_override = amy.override_send + amy.override_send = sent.append + try: + amy.define_sequence(7, [ + {"ticks": (0,), "synth": 1, "note": 60, "vel": 1}, + {"ticks": (3, 8), "synth": 1, "note": 60, "vel": 0}, + ]) + finally: + amy.override_send = old_override + assert sent == [ + "HR7Z", + "H0,0,7n60l1i1Z", + "H3,8,7n60l0i1Z", + ] + + expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4, 2), osc=1)) + expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) + expect_error("only be combined", lambda: amy.message( + sequence_control=(2, 1), synth=1)) + expect_error("only be combined", lambda: amy.message( + ticks=(0,), sequence_control=(2, 1), synth=1)) + expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) + expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) + expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) + expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) + expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("tag", lambda: amy.message(sequence_control=(1.5, 1))) + expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) + expect_error("uint32", lambda: amy.message( + sequence_control=(2, 2, 1 << 32))) + expect_error("2147483647", lambda: amy.message( + sequence_control=(2, 2, 1 << 31))) + expect_error("tag", lambda: amy.message(sequence_reset=1.5)) + expect_error("tag", lambda: amy.message(sequence=True, action="start")) + expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) + expect_error("duration", lambda: amy.message( + sequence=2, action="gate", duration=1.5)) + expect_error("alignment", lambda: amy.message( + sequence=2, action="start", alignment_period=1.5)) + expect_error("2147483647", lambda: amy.message( + sequence=2, action="start", alignment_period=1 << 31)) + expect_error("needs action", lambda: amy.message(sequence=2)) + expect_error("can only be combined", lambda: amy.message( + sequence=2, action="start", synth=1)) + expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) + expect_error("only valid", lambda: amy.message(action="start", synth=1)) + expect_error("start", lambda: amy.message(sequence=2, action=True)) + expect_error("start", lambda: amy.message(sequence=2, action=1)) + expect_error("duration", lambda: amy.message(sequence=2, action="gate")) + expect_error("only valid", lambda: amy.message( + sequence=2, action="start", duration=1)) + expect_error("non-negative", lambda: amy.message( + sequence=2, action="gate", duration=-1)) + expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) + expect_error("needs an AMY payload", lambda: amy.define_sequence( + 2, [{"ticks": (0,)}])) + expect_error("tick", lambda: amy.define_sequence( + 2, [{"ticks": (1.5,), "osc": 1}])) + expect_error("period", lambda: amy.define_sequence( + 2, [{"ticks": (1, 1 << 32), "osc": 1}])) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sequencer_active.c b/tests/test_sequencer_active.c index 4fd7087b..e0895ca1 100644 --- a/tests/test_sequencer_active.c +++ b/tests/test_sequencer_active.c @@ -1,21 +1,8 @@ -// The sequencer's per-tick cost should track what is SCHEDULED, not what -// tag number happened to be used. -// -// sequencer_process_tick() used to sweep 0..highest_tag, and highest_tag -// was a high-water mark that only ever grew — cleared sequences never -// brought it down. So one event parked at a high tag made every tick -// scan that far for the rest of the session, and raising -// max_sequencer_tags made the worst case proportionally worse. The -// anonymous pool made this the common case, not a corner: anonymous -// ticks= entries are allocated round-robin at indices past -// max_sequences, so a burst of one-shots pinned the mark at the very -// end of the table permanently. The occupied slots are threaded through -// the table as an ascending list now. -// -// The headline check here is an INVARIANT rather than a benchmark: one -// sequence at tag 0 and one sequence at tag max-1 must cost the same, -// because both are one sequence. Under the old sweep the second cost -// ~max times the first. +// The sequencer's per-tick cost should track active work, not the numeric value +// of a public tag. Tagged definitions are stored separately from the small +// anonymous direct-scheduling pool, and active executions occupy a bounded +// pool. Consequently one sequence at tag 0 and one at tag max-1 have the same +// scan cost. // // Build/run with `make ctest`. @@ -53,10 +40,12 @@ static void seq_note_on(int32_t tag, int osc) { e.ticks[TICKS_PERIOD] = 16; e.ticks[TICKS_TAG] = (uint32_t)tag; amy_add_event(&e); + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_START, 0, 0); } -// Clearing is a send to the same tag with neither tick nor period. +// Stop active playback, then clear the future definition. static void seq_clear(int32_t tag) { + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_STOP, 0, 0); amy_event e = amy_default_event(); e.ticks[TICKS_TICK] = 0; e.ticks[TICKS_PERIOD] = 0; @@ -105,10 +94,8 @@ static void test_out_of_order_and_clear(void) { all_off(); } -// Anonymous entries (1- or 2-value ticks=, no tag) live past the user tag -// range. They should fire once, disappear, and — with the active list — -// leave no lasting per-tick cost behind. Under the old sweep, one -// anonymous entry pinned the scan at the far end of the table forever. +// Anonymous entries (1- or 2-value ticks=, no tag) use a separate pool. They +// should fire once, disappear, and leave no lasting per-tick cost behind. static void test_anonymous_one_shots(void) { printf("anonymous one-shots fire once and leave the list empty\n"); sequencer_reset(); diff --git a/tests/test_sequencer_bounds.c b/tests/test_sequencer_bounds.c index 960e3989..ba886364 100644 --- a/tests/test_sequencer_bounds.c +++ b/tests/test_sequencer_bounds.c @@ -1,13 +1,12 @@ // Regression test for the sequencer tag bounds check. // -// User-addressable tags index `sequences[0 .. max_sequences-1]`, and the -// anonymous pool lives immediately after, at -// [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS). An earlier +// User-addressable tags once indexed `sequences[0 .. max_sequences-1]`, with +// the anonymous pool immediately after it. An earlier // version of the sequencer guarded with `tag > max_sequences` (and read // the tag into an int32_t), which let tag == max_sequences write one // entry past the user range — in those days one element past the whole -// allocation, a heap overflow; today it would silently clobber an -// anonymous entry instead. sequencer_add_wire() now checks +// allocation, a heap overflow. Tagged definitions and anonymous direct events +// now use separate storage, and sequencer_add_wire() still checks // `tag >= (uint32_t)max_sequences` unsigned, which also disposes of the // negative-reindex case: a tag past INT32_MAX stays a huge unsigned // value and fails the same compare, so it can never index backwards. @@ -76,16 +75,14 @@ static int audible(int osc) { return synth[osc] != NULL && synth[osc]->status == SYNTH_AUDIBLE; } -// Whether a tag was accepted is observable two ways: the sequence fires -// (osc goes audible), and something is in the active list at all. -extern int32_t first_active; - static int accepted(uint32_t tag) { sequencer_reset(); seq_note_on_at_tag(tag, 0); + int scheduled = sequencer_sequence_control( + tag, SEQUENCE_CONTROL_START, 0, 0); advance_secs(0.5); int fired = audible(0); - int scheduled = (first_active != -1); + sequencer_sequence_control(tag, SEQUENCE_CONTROL_STOP, 0, 0); seq_clear(tag); all_off(); sequencer_reset(); @@ -110,9 +107,7 @@ static void test_tag_bounds(void) { CHECK(!accepted(0x80000000u), "a tag past INT32_MAX is rejected"); } -// An out-of-range user tag must not clobber the anonymous pool that sits -// right past the user range. Occupy anonymous slot 0 (the entry a -// too-lenient check would land tag==max on), then try to overwrite it. +// An out-of-range user tag must not affect the separate anonymous pool. static void test_no_anon_clobber(void) { printf("an out-of-range tag can't clobber an anonymous entry\n"); sequencer_reset(); diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c new file mode 100644 index 00000000..45313155 --- /dev/null +++ b/tests/test_sequencer_concurrency.c @@ -0,0 +1,174 @@ +// Deterministic two-writer publication/retry regression test. + +#include +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static pthread_mutex_t rendezvous_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t rendezvous_changed = PTHREAD_COND_INITIALIZER; +static int writers_at_pin = 0; +static int release_writers = 0; +static int a_hits = 0; +static int b_hits = 0; +static int control_failures = 0; +static int edit_failures = 0; + +static void after_source_pin(void) { + pthread_mutex_lock(&rendezvous_lock); + writers_at_pin++; + if (writers_at_pin == 2) { + release_writers = 1; + pthread_cond_broadcast(&rendezvous_changed); + } else { + while (!release_writers) + pthread_cond_wait(&rendezvous_changed, &rendezvous_lock); + } + pthread_mutex_unlock(&rendezvous_lock); +} + +typedef struct writer_args_t { + uint32_t tick; + const char *wire; + uint8_t result; +} writer_args_t; + +static void *append_event(void *opaque) { + writer_args_t *args = (writer_args_t *)opaque; + args->result = sequencer_sequence_add_wire( + 1, args->tick, 0, strdup(args->wire)); + return NULL; +} + +static void mark_hook(const char *code) { + if (!strcmp(code, "writer-a")) a_hits++; + if (!strcmp(code, "writer-b")) b_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void test_losing_writer_retries_cumulatively(void) { + printf("two writers publishing from one generation both survive\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbaseZ")), + "base definition exists"); + CHECK(sequencer_sequence_add_wire(1, 6, 0, strdup("zPtailZ")), + "base definition has a finite tail"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "an execution pins the shared source generation"); + + writer_args_t a = {2, "zPwriter-aZ", 0}; + writer_args_t b = {4, "zPwriter-bZ", 0}; + pthread_t a_thread; + pthread_t b_thread; + sequencer_test_set_after_pin_hook(after_source_pin); + CHECK(pthread_create(&a_thread, NULL, append_event, &a) == 0, + "writer A starts"); + CHECK(pthread_create(&b_thread, NULL, append_event, &b) == 0, + "writer B starts"); + pthread_join(a_thread, NULL); + pthread_join(b_thread, NULL); + sequencer_test_set_after_pin_hook(NULL); + CHECK(a.result && b.result, "both competing edits report success"); + + a_hits = 0; + b_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "the cumulatively published generation starts"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 6); + CHECK(a_hits == 1 && b_hits == 1, + "the losing compare/retry path loses and duplicates no event"); +} + +static void *advance_render_ticks(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) sequencer_midi_clock_tick(); + return NULL; +} + +static void *change_sequence_gate_and_definition(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) { + if (!sequencer_sequence_control( + 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) + control_failures++; + // Resetting the future definition must not disturb the immutable + // snapshot currently read by the render thread. Rebuild it each time + // so publication and reclamation race with real tick processing. + if (!sequencer_sequence_reset(2) + || !sequencer_sequence_add_wire( + 2, 0, 1, strdup("zPthread-pulseZ"))) + edit_failures++; + } + return NULL; +} + +static void test_render_and_control_threads_share_no_sequence_context(void) { + printf("render ticks and external controls keep separate context\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(2, 0, 1, strdup("zPthread-pulseZ")), + "periodic definition exists"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_START, 0, 0), + "periodic execution starts"); + + uint32_t iterations = 2000; + pthread_t render_thread; + pthread_t control_thread; + control_failures = 0; + edit_failures = 0; + CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, + &iterations) == 0, + "render thread starts"); + CHECK(pthread_create(&control_thread, NULL, + change_sequence_gate_and_definition, + &iterations) == 0, + "control thread starts"); + pthread_join(render_thread, NULL); + pthread_join(control_thread, NULL); + + CHECK(control_failures == 0, + "all concurrent controls target the active execution"); + CHECK(edit_failures == 0, + "concurrent future-definition replacement remains available"); + CHECK(sequencer_sequence_reset(2), + "external reset is not confused with stored-event dispatch"); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_losing_writer_retries_cumulatively(); + test_render_and_control_threads_share_no_sequence_context(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall concurrent sequence publication checks passed\n"); + return 0; +} diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c new file mode 100644 index 00000000..ab32a993 --- /dev/null +++ b/tests/test_sequencer_oom.c @@ -0,0 +1,126 @@ +// Allocation-failure regression tests for immutable sequence publication. + +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static int base_hits = 0; +static int unexpected_hits = 0; + +static void mark_hook(const char *code) { + if (!strcmp(code, "base-head") || !strcmp(code, "base-tail")) + base_hits++; + if (!strcmp(code, "must-not-publish")) unexpected_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void define_base(void) { + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbase-headZ")), + "base head is defined"); + CHECK(sequencer_sequence_add_wire(1, 4, 0, strdup("zPbase-tailZ")), + "base tail is defined"); +} + +static void test_initialization_allocation_failures(amy_config_t config) { + printf("partial sequence-pool initialization fails closed\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_test_fail_allocation_after(fail_after); + amy_start(config); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "pool allocation failure %" PRIi32 " disables definitions", + fail_after); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "pool allocation failure %" PRIi32 " disables executions", + fail_after); + amy_stop(); + } +} + +static void test_new_definition_allocation_failures(void) { + printf("new-definition allocation failure leaves an empty tag\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_reset(); + sequencer_test_fail_allocation_after(fail_after); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "definition allocation failure %" PRIi32 " rejects the append", + fail_after); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "definition allocation failure %" PRIi32 + " publishes no empty candidate", fail_after); + } +} + +static void test_clone_allocation_failures_preserve_source(void) { + printf("every clone allocation failure preserves the published definition\n"); + // Clone allocation order: definition, event array, then two wire strings. + for (int32_t fail_after = 0; fail_after < 4; ++fail_after) { + sequencer_reset(); + define_base(); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "source execution pins the definition (failure %" PRIi32 ")", + fail_after); + + char *incoming = strdup("zPmust-not-publishZ"); + sequencer_test_fail_allocation_after(fail_after); + uint8_t appended = sequencer_sequence_add_wire(1, 2, 0, incoming); + sequencer_test_fail_allocation_after(-1); + CHECK(!appended, "allocation failure %" PRIi32 " rejects the edit", + fail_after); + + base_hits = 0; + unexpected_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "old definition remains startable"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(base_hits >= 2 && unexpected_hits == 0, + "failure %" PRIi32 " publishes neither a partial nor corrupt edit", + fail_after); + } +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + test_initialization_allocation_failures(config); + amy_start(config); + + test_new_definition_allocation_failures(); + test_clone_allocation_failures_preserve_source(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequence allocation-failure checks passed\n"); + return 0; +} diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c new file mode 100644 index 00000000..1de10137 --- /dev/null +++ b/tests/test_sequencer_sequences.c @@ -0,0 +1,775 @@ +// Regression and behavior tests for reusable tagged sequencer sequences. + +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[32]; + uint32_t tick; +} mark_t; + +static mark_t marks[256]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + memset(marks, 0, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static uint32_t next_boundary(uint32_t now, uint32_t quantum) { + uint32_t remainder = now % quantum; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +static int mark_at(const char *name, uint32_t tick) { + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; + return 0; +} + +static int marks_named(const char *name) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name)) count++; + return count; +} + +static void test_untagged_ticks_and_cumulative_tags(void) { + printf("untagged root ticks and cumulative tagged sequences\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H,4zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), + "an omitted tick remains a tick-zero legacy list field"); + CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); + sequencer_reset(); + + CHECK(sequencer_add_wire(4, 4, 0, false, strdup("zPlegacy-periodZ")), + "untagged tick equal to period retains legacy acceptance"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H,4,8zPomitted-local-zeroZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC8,1,4Z"); + clock_to(start); + CHECK(mark_at("omitted-local-zero", start), + "H,period,tag remains a reusable tick-zero event"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H0,0,9zPfirstZ"); + amy_add_message("H2,0,9zPsecondZ"); + start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC9,1,4Z"); + clock_to(start + 2); + CHECK(mark_at("first", start) && mark_at("second", start + 2), + "repeating a tag cumulates ordinary events into one sequence"); +} + +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events retain three-value ticks\n"); + amy_event event = amy_default_event(); + event.osc = 2; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 3; + event.ticks[TICKS_PERIOD] = 8; + event.ticks[TICKS_TAG] = 7; + char wire[MAX_MESSAGE_LEN]; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7", 6) == 0, + "C ticks serialization remains three values: %s", wire); +} + +static void test_repeated_tag_and_one_shot_lifetime(void) { + printf("repeated tagged events accumulate and finite events retire\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,10zPzeroZ"); + amy_add_message("H2,0,10zPtwoZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("zero", start), "local tick zero fires at activation"); + CHECK(mark_at("two", start + 2), "a second event shares the same tag"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "period-zero sequence events fire once and execution retires"); +} + +static void test_out_of_order_one_shots_keep_musical_order(void) { + printf("finite events use tick order and preserve same-tick append order\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H4,0,15zPtailZ"); + amy_add_message("H0,0,15zPheadZ"); + amy_add_message("H2,0,15zPmiddle-firstZ"); + amy_add_message("H2,0,15zPmiddle-secondZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(mark_count == 4, "all four out-of-order events fired once"); + CHECK(mark_count == 4 + && !strcmp(marks[0].name, "head") + && !strcmp(marks[1].name, "middle-first") + && !strcmp(marks[2].name, "middle-second") + && !strcmp(marks[3].name, "tail"), + "tick order is chronological and same-tick order is stable"); +} + +static void test_uniform_periodic_events_keep_musical_order(void) { + printf("uniform periodic events use tick order and stable ties\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H3,8,15zPlateZ"); + amy_add_message("H1,8,15zPearly-firstZ"); + amy_add_message("H1,8,15zPearly-secondZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 11); + CHECK(mark_count == 6, "three periodic events fired in two cycles"); + CHECK(mark_count == 6 + && !strcmp(marks[0].name, "early-first") + && !strcmp(marks[1].name, "early-second") + && !strcmp(marks[2].name, "late") + && !strcmp(marks[3].name, "early-first") + && !strcmp(marks[4].name, "early-second") + && !strcmp(marks[5].name, "late"), + "periodic tick order is chronological and same-tick order is stable"); +} + +static void test_mixed_periods_retain_generic_append_order(void) { + printf("mixed periodic schedules retain generic append order\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,15zPtwoZ"); + amy_add_message("H1,3,15zPthreeZ"); + amy_add_message("HC15,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(mark_count == 5, "both mixed periods keep repeating"); + CHECK(mark_count == 5 + && !strcmp(marks[0].name, "two") + && !strcmp(marks[1].name, "three") + && !strcmp(marks[2].name, "two") + && !strcmp(marks[3].name, "two") + && !strcmp(marks[4].name, "three"), + "coincident mixed-period events retain caller append order"); +} + +static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { + printf("empty tick-zero reset remains distinct from a tick-zero event\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,10zPstoredZ"); + amy_add_message("H,,10Z"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "the legacy empty H,,tag spelling resets that tag"); + amy_add_message("H0,0,10zPstoredZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start); + CHECK(mark_at("stored", start), + "H0,0,tag with a payload is a local tick-zero event"); +} + +static void test_active_definition_is_immutable(void) { + printf("active executions retain the definition they started with\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPold-headZ"); + amy_add_message("H4,0,11zPold-tailZ"); + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(old_start + 2); + + amy_add_message("HR11Z"); + amy_add_message("H0,0,11zPnew-headZ"); + clock_to(old_start + 4); + CHECK(mark_at("old-tail", old_start + 4), + "resetting future contents does not remove an old note release"); + + clear_marks(); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(new_start + 2); + CHECK(mark_at("new-head", new_start) && !marks_named("old-head") + && !marks_named("old-tail"), + "a later start uses only the replacement definition"); +} + +static void test_append_while_active_uses_copy_on_write(void) { + printf("appending while active publishes a future definition\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPbaseZ"); + amy_add_message("H6,0,11zPold-tailZ"); + amy_add_message("HC11,1,0Z"); + uint32_t old_start = sequencer_ticks() + 1; + clock_to(old_start + 1); + + amy_add_message("H2,0,11zPappendedZ"); + clock_to(old_start + 6); + CHECK(mark_at("base", old_start) && mark_at("old-tail", old_start + 6), + "the active execution retains its original events"); + CHECK(!mark_at("appended", old_start + 2), + "an append cannot enter an already-running snapshot"); + + clear_marks(); + amy_add_message("HC11,1,0Z"); + uint32_t new_start = sequencer_ticks() + 1; + clock_to(new_start + 6); + CHECK(mark_at("base", new_start) + && mark_at("appended", new_start + 2) + && mark_at("old-tail", new_start + 6), + "a later execution sees the cumulative appended definition"); +} + +static void test_three_definition_generations_overlap(void) { + printf("three immutable definition generations can overlap\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPbaseZ"); + amy_add_message("H12,0,12zPtailZ"); + + amy_add_message("HC12,1,0Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + + amy_add_message("H2,0,12zPsecondZ"); + amy_add_message("HC12,1,0Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(second_start); + + amy_add_message("H4,0,12zPthirdZ"); + amy_add_message("HC12,1,0Z"); + uint32_t third_start = sequencer_ticks() + 1; + clock_to(third_start + 12); + + CHECK(mark_at("base", first_start) + && !mark_at("second", first_start + 2) + && !mark_at("third", first_start + 4), + "the first execution keeps generation one"); + CHECK(mark_at("base", second_start) + && mark_at("second", second_start + 2) + && !mark_at("third", second_start + 4), + "the second execution keeps generation two"); + CHECK(mark_at("base", third_start) + && mark_at("second", third_start + 2) + && mark_at("third", third_start + 4), + "the third execution sees generation three"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("root events can launch stored sequences\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPchild-zeroZ"); + uint32_t start = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC12,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child-zero", start), + "a root launch includes the child's local tick zero"); +} + +static void test_root_can_reset_a_future_definition(void) { + printf("root events can reset future stored definitions\n"); + sequencer_reset(); + amy_add_message("H0,0,12zPfutureZ"); + uint32_t reset_tick = sequencer_ticks() + 2; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HR12Z", reset_tick); + amy_add_message(wire); + clock_to(reset_tick); + CHECK(!sequencer_sequence_control(12, SEQUENCE_CONTROL_START, 0, 0), + "a render-fired reset removes the future definition"); +} + +static void test_overlapping_executions_need_no_host_identity(void) { + printf("one sequence tag supports bounded overlapping executions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,13zPonZ"); + amy_add_message("H4,0,13zPoffZ"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first + 2); + amy_add_message(wire); + clock_to(first + 6); + CHECK(mark_at("on", first) && mark_at("on", first + 2), + "two starts of one tag can overlap"); + CHECK(mark_at("off", first + 4) && mark_at("off", first + 6), + "each overlap retains its own scheduled release"); +} + +static void test_parent_stop_leaves_started_child_to_finish(void) { + printf("stopping a parent prevents future children without truncating one\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,15zPnote-onZ"); + amy_add_message("H4,0,15zPnote-offZ"); + amy_add_message("H0,4,14HC15,1,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC14,1,4Z"); + clock_to(start + 2); + amy_add_message("HC14,0,0Z"); + clock_to(start + 8); + CHECK(mark_at("note-on", start), "parent starts its child"); + CHECK(mark_at("note-off", start + 4), + "the already-started child delivers its own note-off"); + CHECK(marks_named("note-on") == 1, + "the stopped parent launches no later child"); +} + +static void test_controller_sequence_bounds_repetition(void) { + printf("a finite controller sequence can bound a periodic child\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,8zPpulseZ"); + amy_add_message("H0,0,7HC8,1,0Z"); + amy_add_message("H12,0,7HC8,0,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC7,1,4Z"); + clock_to(start + 14); + CHECK(mark_at("pulse", start) && mark_at("pulse", start + 4) + && mark_at("pulse", start + 8), + "controller permits exactly three periods"); + CHECK(!mark_at("pulse", start + 12) && marks_named("pulse") == 3, + "same-tick stop precedes the child's ordinary event"); +} + +static void test_finite_gate_preserves_phase(void) { + printf("finite event gating preserves the target phase\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPbeatZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC6,1,4Z"); + clock_to(start); + CHECK(mark_at("beat", start), "loop begins on its aligned boundary"); + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_GATE, 6, 0), + "finite gate is accepted without a host timer"); + clock_to(start + 8); + CHECK(!mark_at("beat", start + 4), "event inside gate is suppressed"); + CHECK(mark_at("beat", start + 8), + "event resumes on the original phase after gate expiry"); +} + +static void test_gate_drops_state_restoration_without_replay(void) { + printf("gate suppression is event-agnostic and does not replay events\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPstate-onZ"); + amy_add_message("H2,0,5zPstate-offZ"); + amy_add_message("HC5,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("state-on", start), "event before gate is dispatched"); + CHECK(sequencer_sequence_control(5, SEQUENCE_CONTROL_GATE, 3, 1), + "gate covers the later state-restoring event"); + clock_to(start + 6); + CHECK(!marks_named("state-off"), + "suppressed state restoration is neither dispatched nor replayed"); +} + +static void test_quantized_stop_targets_current_executions(void) { + printf("quantized controls capture the current execution set\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPpulseZ"); + amy_add_message("HC6,1,1Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + CHECK(mark_at("pulse", first_start), "first execution begins"); + + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_STOP, 0, 8), + "first execution accepts a future aligned stop"); + uint32_t stop_boundary = next_boundary(sequencer_ticks(), 8); + amy_add_message("HC6,1,1Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(stop_boundary + 4); + CHECK(!mark_at("pulse", stop_boundary), + "the captured execution stops before its boundary event"); + CHECK(mark_at("pulse", second_start) + && mark_at("pulse", second_start + 4), + "a later start does not inherit an earlier pending stop"); +} + +static void test_cyclic_controls_are_bounded_and_recoverable(void) { + printf("cyclic sequence controls remain bounded and recoverable\n"); + sequencer_reset(); + amy_add_message("H0,1,1HC2,1,0Z"); + amy_add_message("H0,1,2HC1,1,0Z"); + amy_add_message("H0,0,3zPrecoveryZ"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "cycle root starts"); + clock_to(sequencer_ticks() + 1); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "the cycle fills but cannot exceed the execution pool"); + + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_STOP, 0, 0), + "all active A executions accept stop"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 0), + "all active B executions accept stop"); + clock_to(sequencer_ticks() + 1); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "stopping both cycle tags makes the pool reusable"); +} + +static void test_same_tick_control_is_slot_order_independent(void) { + printf("same-tick controls are independent of execution slot order\n"); + sequencer_reset(); + clear_marks(); + + // The filler occupies slot 0 for tick 1 only. The parent occupies slot 1 + // from tick 2. At tick 2 slot 0 is retired before slot 1 starts child 3, + // which therefore reuses the already-visited lower slot. Child 3 must still + // run its local-zero control and start leaf 4 on that same tick. + amy_add_message("H0,0,1zPfillerZ"); + amy_add_message("H0,0,2HC3,1,1Z"); + amy_add_message("H0,0,3HC4,1,1Z"); + amy_add_message("H0,0,4zPslot-leafZ"); + amy_add_message("HC1,1,1Z"); + amy_add_message("HC2,1,2Z"); + clock_to(sequencer_ticks() + 4); + + CHECK(marks_named("slot-leaf") == 1, + "a child in a recycled lower slot receives its tick-zero control"); +} + +static void test_per_tag_and_global_reset_semantics(void) { + printf("per-tag replacement and global reset have distinct scopes\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPsurvivorZ"); + amy_add_message("HC5,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + amy_add_message("HR5Z"); + clock_to(start); + CHECK(mark_at("survivor", start), + "per-tag reset leaves an already-started snapshot alive"); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "per-tag reset removed the future definition"); + + amy_add_message("H0,4,5zPclearedZ"); + amy_add_message("HC5,1,0Z"); + sequencer_reset(); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "global RESET_SEQUENCER clears stored definitions"); +} + +static void test_timebase_reset_keeps_definitions(void) { + printf("timebase reset drops runtime but keeps definitions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,4zPafter-rebaseZ"); + amy_add_message("HC4,1,0Z"); + sequencer_sequence_reset_timebase(); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("after-rebase"), "pending execution is discarded"); + CHECK(sequencer_sequence_control(4, SEQUENCE_CONTROL_START, 0, 0), + "definition remains available after timebase reset"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("after-rebase", start), "definition can be relaunched"); +} + +static void test_bounds_and_validation(void) { + printf("tag, event and execution bounds fail deterministically\n"); + sequencer_reset(); + CHECK(!sequencer_sequence_add_wire(16, 0, 0, strdup("zPbad-tagZ")), + "first tag beyond max_sequencer_tags is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 4, 4, strdup("zPbad-periodZ")), + "tick equal to period is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), + "empty payload is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), + "stored sequences cannot contain sequence authoring commands"); + + for (uint32_t i = 0; i < 8; ++i) { + char *payload = strdup("zPfullZ"); + CHECK(sequencer_sequence_add_wire(3, i, 0, payload), + "event slot %" PRIu32 " is available", i); + } + CHECK(!sequencer_sequence_add_wire(3, 9, 0, strdup("zPoverflowZ")), + "one event beyond configured capacity is rejected"); + + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "one execution beyond configured capacity is rejected"); + CHECK(!sequencer_sequence_control(3, 99, 0, 0), + "unknown control action is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, (uint32_t)INT32_MAX + 1U), + "alignment beyond the wrap-safe interval is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_GATE, (uint32_t)INT32_MAX + 1U, 0), + "gate duration beyond the wrap-safe interval is rejected"); +} + +static void test_wire_control_shape_is_strict(void) { + printf("sequence control and reset wire shapes are strict\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,3zPdefinedZ"); + + amy_add_message("HC3,1,0,99Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined"), + "a start with an extra field is rejected"); + + amy_add_message("HC3,2Z"); + amy_add_message("HC3,1,0zPignoredZ"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined") && !marks_named("ignored"), + "a missing gate duration and trailing payload are rejected"); + + amy_add_message("HR3,4Z"); + amy_add_message("HR4294967296Z"); + amy_add_message("HR3.0Z"); + amy_add_message("HR-1Z"); + amy_add_message("HA3Z"); + amy_add_message("H4294967296,0,3zPoverflow-tickZ"); + amy_add_message("H0,4294967296,3zPoverflow-periodZ"); + amy_add_message("H0,0,4294967296zPoverflow-tagZ"); + amy_add_message("H0.5,0,3zPfractional-tickZ"); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "malformed and overflowing resets leave the definition intact"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("defined", start) + && !marks_named("overflow-tick") + && !marks_named("overflow-period") + && !marks_named("overflow-tag") + && !marks_named("fractional-tick"), + "the intact definition starts without malformed additions"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,4zPaction-startZ"); + amy_add_message("HC4,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("action-start", start), "action start=1 starts a sequence"); + amy_add_message("HC4,0,1Z"); + clock_to(sequencer_ticks() + 1); + CHECK(!mark_at("action-start", sequencer_ticks()), + "action stop=0 stops a sequence"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,5zPmalformed-startZ"); + amy_add_message("HC5,-1,1Z"); + amy_add_message("HC5,0.5,1Z"); + amy_add_message("HC5,0.5,1.5Z"); + amy_add_message("HC5,1,Z"); + amy_add_message("HC4294967296,1Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("malformed-start"), + "invalid action, fractional, empty, and overflowing fields are rejected"); +} + +static void test_start_crosses_clock_rollover(void) { + printf("relative sequence phase crosses uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,2zPwrap-zeroZ"); + amy_add_message("H2,0,2zPwrap-twoZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("HC2,1,48Z"); + clock_to(2); + CHECK(mark_at("wrap-zero", 0), + "non-power-of-two alignment treats wrapped tick zero as a boundary"); + CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); +} + +static void test_gate_and_stop_cross_clock_rollover(void) { + printf("pending gate and stop controls cross uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,2zPwrap-pulseZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 4; + amy_add_message("HC2,1,2Z"); + uint32_t start = UINT32_MAX - 3; + clock_to(start); + CHECK(mark_at("wrap-pulse", start), "loop starts before rollover"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_GATE, 4, 1), + "gate spanning rollover is accepted"); + clock_to(2); + CHECK(!mark_at("wrap-pulse", UINT32_MAX - 1) + && !mark_at("wrap-pulse", 0), + "events remain gated on both sides of rollover"); + CHECK(mark_at("wrap-pulse", 2), "gate expires at its wrapped end tick"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 4), + "stop aligns to a post-rollover boundary"); + clock_to(4); + CHECK(mark_at("wrap-pulse", 2) && !mark_at("wrap-pulse", 4), + "stop suppresses the event on its aligned boundary"); +} + +static void test_execution_lifetime_beyond_half_clock_range(void) { + printf("started executions remain valid across the uint32 clock\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,2zPlong-periodicZ"); + amy_add_message("HC2,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start + (uint32_t)INT32_MAX; + sequencer_midi_clock_tick(); + CHECK(marks_named("long-periodic") == 2, + "a latched periodic execution keeps running past half-range"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H4294967295,0,3zPuint32-tailZ"); + amy_add_message("HC3,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start - 2; + sequencer_midi_clock_tick(); + CHECK(marks_named("uint32-tail") == 1, + "a finite event at UINT32_MAX fires exactly once"); + int starts = 0; + for (int i = 0; i < 8; ++i) + starts += sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, 1); + CHECK(starts == 8, + "the UINT32_MAX finite execution retires on its final event"); +} + +static void test_disabled_configuration(void) { + printf("invalid reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][3] = { + {256, 0, 8}, + {256, 8, 0}, + {256, UINT32_MAX, 1}, + {256, 1, UINT32_MAX}, + {UINT32_MAX, 1, 1}, + }; + for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequencer_tags = capacities[i][0]; + config.max_sequence_events = capacities[i][1]; + config.max_sequence_executions = capacities[i][2]; + amy_start(config); + CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), + "append is disabled for invalid capacity set %zu", i + 1); + CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "control is disabled for invalid capacity set %zu", i + 1); + amy_stop(); + } +} + +static void test_execution_bitsets_cross_machine_words(void) { + printf("execution activity indexes cross 32-bit word boundaries\n"); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 2; + config.max_sequence_events = 1; + config.max_sequence_executions = 70; + amy_start(config); + clear_marks(); + amy_add_message("H0,0,1zPwideZ"); + int starts = 0; + for (int i = 0; i < 70; ++i) + starts += sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0); + CHECK(starts == 70, "all 70 execution slots are addressable"); + clock_to(sequencer_ticks() + 1); + CHECK(marks_named("wide") == 70, + "activity traversal reaches executions beyond slot 63"); + amy_stop(); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 16; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_untagged_ticks_and_cumulative_tags(); + test_legacy_c_event_wire_is_unchanged(); + test_repeated_tag_and_one_shot_lifetime(); + test_out_of_order_one_shots_keep_musical_order(); + test_uniform_periodic_events_keep_musical_order(); + test_mixed_periods_retain_generic_append_order(); + test_empty_tick_zero_is_reset_but_payload_is_an_event(); + test_active_definition_is_immutable(); + test_append_while_active_uses_copy_on_write(); + test_three_definition_generations_overlap(); + test_root_launches_local_zero_on_same_tick(); + test_root_can_reset_a_future_definition(); + test_overlapping_executions_need_no_host_identity(); + test_parent_stop_leaves_started_child_to_finish(); + test_controller_sequence_bounds_repetition(); + test_finite_gate_preserves_phase(); + test_gate_drops_state_restoration_without_replay(); + test_quantized_stop_targets_current_executions(); + test_cyclic_controls_are_bounded_and_recoverable(); + test_same_tick_control_is_slot_order_independent(); + test_per_tag_and_global_reset_semantics(); + test_timebase_reset_keeps_definitions(); + test_start_crosses_clock_rollover(); + test_gate_and_stop_cross_clock_rollover(); + test_execution_lifetime_beyond_half_clock_range(); + test_bounds_and_validation(); + test_wire_control_shape_is_strict(); + + amy_stop(); + test_execution_bitsets_cross_machine_words(); + test_disabled_configuration(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall reusable sequencer sequence checks passed\n"); + return 0; +} diff --git a/tests/test_shared_reverb.c b/tests/test_shared_reverb.c new file mode 100644 index 00000000..5032fac5 --- /dev/null +++ b/tests/test_shared_reverb.c @@ -0,0 +1,230 @@ +// Shared aux-reverb routing, fixed arenas, and deferred diagnostics. + +#include +#include +#include +#include "amy.h" + +#define ROOM_BYTES (128u * 1024u) + +static int failures; +static uint8_t room_memory[2][ROOM_BYTES]; +static void *room_arenas[2] = { room_memory[0], room_memory[1] }; +static unsigned bus_hook_calls[4]; +static unsigned external_return_calls; +static bool external_return_received_audio; +static uint8_t external_return_selector[2] = { 0, 1 }; + +#define CHECK(c, fmt, ...) do { \ + if (c) printf(" ok " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); ++failures; } \ +} while (0) + +static bool inside_room(const void *pointer, int room) { + uintptr_t p = (uintptr_t)pointer; + uintptr_t first = (uintptr_t)room_memory[room]; + return p >= first && p < first + ROOM_BYTES; +} + +static void count_bus_hook(uint16_t bus, SAMPLE *buf, uint16_t len) { + (void)buf; + CHECK(bus < 4, "postprocess hook bus is in range (%u)", bus); + CHECK(len == AMY_BLOCK_SIZE, "postprocess hook receives one block (%u)", len); + if (bus < 4) ++bus_hook_calls[bus]; +} + +static void process_external_return(uint16_t return_index, SAMPLE *block, + uint16_t frames, void *user_data) { + unsigned *calls = (unsigned *)user_data; + CHECK(return_index == 1, "external callback receives return index"); + CHECK(frames == AMY_BLOCK_SIZE, "external callback receives one block"); + ++*calls; + for (int i = 0; i < frames * AMY_NCHANS; ++i) { + if (block[i] != 0) external_return_received_audio = true; + block[i] /= 2; + } +} + +static void start_shared_with_hook(bool hook) { + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_buses = 4; + config.max_reverb_rooms = 2; + config.reverb_room_memory = room_arenas; + config.reverb_room_memory_bytes = ROOM_BYTES; + config.reverb_diagnostics = 1; + config.amy_external_bus_postprocess_hook = hook ? count_bus_hook : NULL; + amy_start(config); +} + +static void start_shared(void) { start_shared_with_hook(false); } + +static void test_arena_and_wire_routing(void) { + puts("fixed rooms and hR/hS routing"); + start_shared(); + for (int room = 0; room < 2; ++room) { + shared_reverb_state_t *state = &amy_global.reverb_rooms[room]; + CHECK(state->arena == room_memory[room], "room %d uses its arena", room); + CHECK(state->arena_used > 108u * 1024u && state->arena_used < ROOM_BYTES, + "room %d fits (%zu/%u bytes)", room, state->arena_used, ROOM_BYTES); + CHECK(inside_room(state->effect.rev, room), "room %d state is contained", room); + CHECK(inside_room(state->block, room), "room %d workspace is contained", room); + CHECK(inside_room(state->effect.rev->delay_1->samples, room), + "room %d delay data is contained", room); + } + + amy_add_message("hR0,0.6,0.8,0.4,2800Z"); + amy_add_message("hR1,0.3,0.7,0.2,3500Z"); + amy_add_message("y2hS1,0.75Z"); + amy_execute_deltas(); + CHECK(S2F(amy_global.reverb_rooms[0].effect.level) > 0.59f, + "room 0 level configured"); + CHECK(amy_global.reverb_rooms[1].effect.xover_hz == 3500.0f, + "room 1 filter configured"); + CHECK(amy_global.bus[2]->reverb_send_room == 1, "bus 2 targets room 1"); + CHECK(S2F(amy_global.bus[2]->reverb_send_level) > 0.74f, + "bus 2 has a weighted send"); + amy_add_message("y2hS1,0Z"); + amy_execute_deltas(); + CHECK(amy_global.bus[2]->reverb_send_level == 0, + "zero send excludes a bus without changing its room"); +} + +static void test_audio_and_deferred_diagnostics(void) { + puts("audio path and stored diagnostics"); + start_shared(); + + // Configured storage is cheap while its return level is disabled: it must + // not walk the delay lines merely because a room exists. + for (int i = 0; i < 2; ++i) amy_simple_fill_buffer(); + amy_reverb_diagnostic_t room0, room1, stage; + CHECK(amy_reverb_diagnostics_get(0, &room0), "disabled-room snapshot succeeds"); + CHECK(room0.calls == 0, "disabled room performs no DSP work"); + + amy_add_message("hR0,0.8,0.85,0.5,3000Z" + "hR1,0.6,0.75,0.4,2600Z" + "y0hS0,1Zy1hS1,0.7Z" + "v0w0n60l1y0Zv1w0n67l1y1Z"); + for (int i = 0; i < 48; ++i) amy_simple_fill_buffer(); + + CHECK(amy_reverb_diagnostics_get(0, &room0), "room 0 snapshot succeeds"); + CHECK(amy_reverb_diagnostics_get(1, &room1), "room 1 snapshot succeeds"); + CHECK(amy_reverb_stage_diagnostics_get(&stage), "stage snapshot succeeds"); + CHECK(room0.calls == 48, "room 0 ran once per rendered block (%llu)", + (unsigned long long)room0.calls); + CHECK(room1.calls == 48, "room 1 ran once per rendered block (%llu)", + (unsigned long long)room1.calls); + CHECK(stage.calls == 50, "stage measured once per rendered block (%llu)", + (unsigned long long)stage.calls); + CHECK(room0.core_mask == 1 && room1.core_mask == 1, + "both host rooms ran on the host render core"); + + for (int room = 0; room < 2; ++room) { + bool wet_nonzero = false; + SAMPLE *wet = amy_global.reverb_rooms[room].block; + for (int i = 0; i < AMY_BLOCK_SIZE * AMY_NCHANS; ++i) + if (wet[i] != 0) wet_nonzero = true; + CHECK(wet_nonzero, "shared room %d produced a wet return", room); + } +} + +static void test_external_hook_serial_fallback(void) { + puts("external bus hooks retain one ordered callback per bus"); + for (int bus = 0; bus < 4; ++bus) bus_hook_calls[bus] = 0; + start_shared_with_hook(true); + amy_add_message("y3V1Z"); + amy_execute_deltas(); + amy_simple_fill_buffer(); + for (int bus = 0; bus < 4; ++bus) + CHECK(bus_hook_calls[bus] == 1, "bus %d hook ran once", bus); +} + +static void test_external_aux_return(void) { + puts("host-selected aux-return effect"); + amy_stop(); + external_return_calls = 0; + external_return_received_audio = false; + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_buses = 4; + config.max_reverb_rooms = 2; + config.reverb_room_memory = room_arenas; + config.reverb_room_memory_bytes = ROOM_BYTES; + config.aux_return_external = external_return_selector; + config.amy_external_aux_return_process_hook = process_external_return; + config.amy_external_aux_return_user_data = &external_return_calls; + amy_start(config); + + CHECK(amy_global.allocated_reverbs == 1, + "only the built-in return allocates a reverb"); + CHECK(amy_global.reverb_rooms[0].effect.rev != NULL, + "return 0 uses AMY's built-in effect"); + CHECK(amy_global.reverb_rooms[1].external_effect, + "return 1 is host processed"); + CHECK(amy_global.reverb_rooms[1].effect.rev == NULL, + "external return allocates no built-in reverb"); + CHECK(amy_global.reverb_rooms[1].arena_used + == sizeof(SAMPLE) * AMY_BLOCK_SIZE * AMY_NCHANS, + "external return arena contains only its block"); + + amy_add_message("y2hS1,1Zv0w0n60l1y2Z"); + amy_execute_deltas(); + for (int i = 0; i < 4; ++i) amy_simple_fill_buffer(); + CHECK(external_return_calls == 4, + "external effect ran once per block (%u)", external_return_calls); + CHECK(external_return_received_audio, + "external effect received the selected bus audio"); +} + +static void test_runtime_room_count_is_not_fixed_at_two(void) { + puts("runtime return count is not fixed at two"); + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.max_reverb_rooms = 3; + config.reverb_diagnostics = 1; + amy_start(config); + CHECK(amy_global.allocated_reverbs == 3, + "three configured built-in returns are allocated"); + amy_add_message("hR2,0.5,0.8,0.4,3000Zy0hS2,1Zv0w0n60l1y0Z"); + amy_execute_deltas(); + amy_simple_fill_buffer(); + amy_reverb_diagnostic_t room2; + CHECK(amy_reverb_diagnostics_get(2, &room2), + "third-return diagnostic snapshot succeeds"); + CHECK(room2.calls == 1, "third return is processed"); +} + +static void test_legacy_default(void) { + puts("legacy per-bus behavior remains the default"); + amy_stop(); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + CHECK(amy_global.config.max_reverb_rooms == 0, "shared rooms default off"); + amy_add_message("y0h0.5,0.8,0.4,3000Z"); + amy_execute_deltas(); + CHECK(amy_global.bus[0]->reverb.rev != NULL, + "historical h command still allocates a per-bus reverb"); + CHECK(S2F(amy_global.bus[0]->reverb.level) > 0.49f, + "historical h level is unchanged"); +} + +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + amy_start(config); + test_arena_and_wire_routing(); + test_audio_and_deferred_diagnostics(); + test_external_hook_serial_fallback(); + test_external_aux_return(); + test_runtime_room_count_is_not_fixed_at_two(); + test_legacy_default(); + amy_stop(); + if (failures) return 1; + puts("all shared reverb checks passed"); + return 0; +}