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..1e707fb1 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 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/Makefile b/Makefile index f849e7dc..2b9bbfe3 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,7 @@ 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_sequence_groups \ 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 \ @@ -145,6 +146,7 @@ amy-module: amy-example test: amy-module ${PYTHON} -m amy.test + ${PYTHON} tests/test_python_offline_live.py qtest: amy-module ${PYTHON} -m amy.test quiet diff --git a/README.md b/README.md index 77d9e83e..37b3478c 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ 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 Sequencer Groups**](docs/sequencer-groups.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) + * [**Porting AMY and local-service transports**](docs/porting.md) * [**Other AMY web demos**](https://shorepine.github.io/amy/) AMY supports @@ -111,6 +113,11 @@ 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. + In C: ```c @@ -171,10 +178,12 @@ 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 Sequencer Groups**](docs/sequencer-groups.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) * [**Other AMY web demos**](https://shorepine.github.io/amy/) diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..7cb08362 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -254,6 +254,7 @@ def str_of_int(arg): ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), + ('sequence_control', 'zQL'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), ('synth_level', 'iVF'), diff --git a/amy/constants.py b/amy/constants.py index ef33569a..4820ffbe 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,6 +124,12 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +TICKS_GROUP=3 +SEQUENCE_CONTROL_STOP=0 +SEQUENCE_CONTROL_START=1 +SEQUENCE_CONTROL_GATE=2 +SEQUENCE_CONTROL_PUBLISH=3 +SEQUENCE_CONTROL_CLEAR=4 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 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/android/README.md b/android/README.md new file mode 100644 index 00000000..fdd8a406 --- /dev/null +++ b/android/README.md @@ -0,0 +1,248 @@ +# 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, 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..649ae299 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -0,0 +1,367 @@ +#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 uint32_t kIntegrationMaxSequenceGroups = 1024; +constexpr uint32_t kIntegrationMaxSequenceGroupTags = 64; +constexpr uint32_t kIntegrationMaxSequenceGroupExecutions = 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_sequence_groups = kIntegrationMaxSequenceGroups; + config.max_sequence_group_tags = kIntegrationMaxSequenceGroupTags; + config.max_sequence_group_executions = kIntegrationMaxSequenceGroupExecutions; + /* 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/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..16f07b90 100644 --- a/docs/api.md +++ b/docs/api.md @@ -204,6 +204,9 @@ amy_start(amy_config); | `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_sequence_groups` | Int | 32 | Number of persistent sequencer groups; group tags are 1 through this value | +| `max_sequence_group_tags` | Int | 64 | Addressable local event tags in each allocated group definition | +| `max_sequence_group_executions` | Int | 32 | Maximum active or quantized-pending group 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 | @@ -503,8 +506,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[4]` | `ticks` | int[,int[,tag[,group]]] | Tick, period and tag for root sequencing. A nonzero fourth value instead addresses a persistent [sequencer group](sequencer-groups.md), with the third value as its local event tag. `tag` omitted at root: 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. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | +| `zQ` | — | `sequence_control` | group,action,value,quantize[,execution_tag] | Publish, start, stop, gate or clear a [sequencer group](sequencer-groups.md). | | `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. | | `N` | `latency_ms`| `latency_ms` | uint | Sets latency in ms. default 0 (see LATENCY) | diff --git a/docs/godot.md b/docs/godot.md index 3918c4f7..76949876 100644 --- a/docs/godot.md +++ b/docs/godot.md @@ -52,8 +52,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 +73,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 +148,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 +202,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..5dcc31d4 --- /dev/null +++ b/docs/lb_omnichord_release_contract.md @@ -0,0 +1,97 @@ +# 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 layers the tested platform and application profile on that clean work; +it is never itself offered upstream. + +## Current line + +`releases/amy_omnichord_R20260903T202802` starts with: + +- Shorepine main `0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b`; +- the generic sequencer-group work from `rework/sequencer`; +- the private Unix-socket service and Android Oboe integration; +- the Gamma9001 hosted drum bank profile; +- deterministic offline CPython startup for tests; and +- the larger bounded sequencer-group capacity required by the rhythm + catalogue. + +The Unix-socket receiver applies lossless backpressure when its bounded +realtime handoff queue is full. Large startup transactions therefore remain in +the kernel socket queue instead of being read and discarded. + +The abandoned bus-mixer experiment is not part of this line. AMY's generic +bus support remains whatever is present in Shorepine main; no private mixer +module or routing policy is restored. + +## Sequencer boundary + +The clean `rework/sequencer` branch contains only generic AMY behavior: + +- grouped events use `ticks=tick,period,event_tag,group_tag`; +- one `sequence_control` family publishes, starts, stops, gates and clears; +- active executions retain immutable published revisions; +- one, N and infinite repeats share the same repeat-count model; +- quantization uses AMY's own sequencer clock; and +- a root event may launch a group, while a group cannot launch another group. + +LB Omnichord owns all musical policy: which rhythm roles become groups, which +ones a fill gates, which arpeggios may overlap, group/tag allocation and root +arrangement schedules. The frontend remains a wire-protocol client and never +imports or calls AMY engine internals. + +The release profile uses 1,024 group slots, 64 local event tags per group and +40 active or pending executions. The high group count stores the complete fill +catalogue; it does not create 1,024 players. The execution pool includes room +for the characterized worst case of 34 concurrent role, fill and overlapping +arpeggio executions. Event tables are allocated lazily only for definitions +that are actually authored. + +## 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 may use its wrapper/named-pipe transport, but the AMY +message stream and frontend 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 consistently mean the Gamma808 ROM bank on these +targets; this profile changes no wire or sequencer semantics. + +The CPython `AMY_PCM_BANK` build selector is release/build policy rather than +generic sequencer behavior. `AMY_PCM_BANK=tiny` omits Gamma9001; the default +for this release line is Gamma9001. Both choices force a fresh extension build +because they share an output filename. + +`amy.live(audio=AMY_AUDIO_IS_NONE, ...)` is the deterministic 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. + +## Release procedure + +1. Verify fork main exactly matches the chosen Shorepine main. +2. Test generic work on the clean upstream-directed branch. +3. Create the release branch and add only required fork integrations. +4. Run native AMY, wire/socket, PCM-bank, offline and Android contract tests. +5. Pin the final release branch and SHA in LB Omnichord configuration and + packaging inputs. +6. Run LB Omnichord's generic and platform-specific suites against that same + SHA. +7. Record the exact AMY SHA in release notes and keep diagnostic commits. + +ESP32 validation is deliberately deferred for this rework; it must be +completed before claiming ESP32 support for the resulting release. diff --git a/docs/porting.md b/docs/porting.md new file mode 100644 index 00000000..f1a9a004 --- /dev/null +++ b/docs/porting.md @@ -0,0 +1,170 @@ +# 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. + +## 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-groups-abstractions.md b/docs/sequencer-groups-abstractions.md new file mode 100644 index 00000000..f3a3026a --- /dev/null +++ b/docs/sequencer-groups-abstractions.md @@ -0,0 +1,153 @@ +# Sequencer-group abstractions and implementation + +AMY's root sequencer stores ordinary events on one global musical timeline. +Sequencer groups add one reusable, bounded phrase level below that timeline: a +root event can start a finite or repeating group of ordinary AMY events. They +do not add a drum machine, arpeggiator, song model, or scheduler hierarchy. + +For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). +For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). +The concise argument reference is in [Sequencer groups](sequencer-groups.md). + +## The model + +The model separates stored content, scheduled starts, and active playback: + +| Object | Purpose | Lifetime | +| --- | --- | --- | +| Root sequencer event | Decides when a group starts | Existing `H` tick/period/tag semantics | +| Group tag | Selects one reusable definition slot | From 1 through the configured group capacity | +| Staging revision | Receives local event edits privately | Until published or cleared | +| Published revision | Supplies immutable content to future starts | Until replaced or cleared | +| Execution | Plays one captured revision | Until its repeat count completes or it is stopped | +| Execution tag | Optionally addresses live or pending executions | Supplied by the start operation | +| Local event tag | Replaces or clears one event in one group's staging revision | Scoped to that group only | + +Root tags, group tags, execution tags, and local event tags are separate +identities. For example, replacing a tagged root event changes which phrase +will start in the future. It does not edit the phrase definition or shorten an +execution that has already started. + +## Authoring and publication + +The existing `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +With a nonzero `group_tag`, the `H` message edits that group's private staging +revision instead of the root sequencer. The first edit after publication clones +the current published revision, so a host can replace only the local tags that +changed. A local tag is cleared with `tick=0,period=0`, exactly like a tagged +root event. + +Because that pair means clear, an event at local tick zero must use a nonzero +period. Using the group length as its period is usually the clearest choice; a +finite execution still fires it only once per repetition. + +Publication uses action 3 of the `sequence_control` family: + +```text +zQ,3,Z +``` + +The length is explicit. AMY validates every staged event against it, then +publishes the complete revision atomically. Playback therefore never observes +a partly rewritten phrase. AMY does not infer a potentially expensive least +common multiple from event periods. + +## Execution lifetime + +A start captures the currently published revision. Its repeat value is: + +- `1` for one performance; +- `N` for exactly N performances; +- `0` for indefinite repetition. + +Editing, publishing, or clearing the group afterward affects future starts +only. Every active execution retains a reference to the revision it captured +and can deliver the note-offs or other closing events already stored in that +revision. This is the key guarantee for glitch-free live phrase changes. + +Starts and stops can be quantized to the next multiple of a sequencer tick +interval. A zero quantization value means the next sequencer tick for a direct +command. When a root event starts a group, local tick zero is processed on that +same root tick. + +An optional execution tag gives live playback a stable control identity. A new +start with the same group and execution tag replaces the matching execution at +the requested boundary. Untagged starts may overlap. Stop and gate operations +can address one execution tag or, when the tag is omitted, all executions of a +group. + +## Finite event gates + +Gate action 2 suppresses event dispatch for a duration while the execution's +local clock continues advancing. It does not stop already-sounding audio. When +the gate ends, the next event occurs at its original phase rather than at a +restarted phase. A zero duration releases a current gate. + +A group may contain a gate control as a leaf event. This lets one finite phrase +temporarily suppress events from another tagged repeating layer. AMY assigns no +musical meaning to either layer; the controller owns that policy. + +## Bounded scheduling + +The root sequencer may start a group. A group may contain ordinary AMY events +and finite gate controls, but it cannot start, publish, or clear a group. This +provides the two useful musical levels—global arrangement and reusable +phrase—without cycles or variable scheduling depth. + +The configured limits independently bound: + +- persistent group slots; +- local event tags in each allocated definition; +- active or quantized-pending executions. + +The portable defaults are 32 groups, 64 local tags per group, and 32 active or +pending executions. Definition storage is allocated only when a group is +authored. The audio-time tick path scans only the fixed execution pool, not all +stored groups, so an application can choose a larger definition catalogue +without making every inactive definition part of per-tick work. + +## Implementation outline + +The implementation in [`src/sequencer.c`](../src/sequencer.c) deliberately +reuses the normal event path: + +- grouped `H` messages store the same wire payloads AMY already parses; +- staged and published definitions use fixed-capacity local-tag tables; +- published revisions are reference-counted and remain alive while captured by + an execution; +- an independently bounded execution pool owns start phase, repeat count, + execution identity, pending stop, and gate state; +- root events are processed before group events, which makes a root launch and + its local tick-zero payload sample-clock coherent; +- group-to-group lifecycle operations are rejected while a grouped payload is + firing. + +The public configuration fields and constants are declared in +[`src/amy.h`](../src/amy.h). The group engine entry points are in +[`src/sequencer.h`](../src/sequencer.h), and Python uses the existing +`amy.send(ticks=...)` and `amy.send(sequence_control=...)` interface. + +## Compatibility contract + +An absent or zero fourth `ticks` value follows the existing root-sequencer path. +Existing three-field `H` messages, anonymous root events, tag replacement and +clear behavior, modulo periods, and `amy_add_event()` scheduling are unchanged. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and pending executions +but preserve published group definitions. Full AMY shutdown releases the +definitions. + +The native group regression test exercises legacy root behavior and group +behavior in the same process. It covers the unchanged three-value C and wire +formats, root/group namespace isolation, one/N/infinite repetition, +quantization, tagged replacement, selective stop and gate, early ungate, +atomic publication, repair after rejected publication, immutable active +revisions, same-tick root launches, non-recursive lifecycle controls, allowed +leaf controls, resets, 32-bit clock rollover, disabled configuration, and +configured storage and execution bounds. The existing AMY C and audio suites +remain the broader backward-compatibility tests. diff --git a/docs/sequencer-groups-howto.md b/docs/sequencer-groups-howto.md new file mode 100644 index 00000000..9c747a52 --- /dev/null +++ b/docs/sequencer-groups-howto.md @@ -0,0 +1,258 @@ +# Sequencer-group how-to: switchable arpeggios and a percussion gate + +This example sends complete AMY wire messages, including the final `Z`. AMY's +sequencer uses 48 ticks per quarter note, so the arpeggios use 24 ticks per +eighth note and a 96-tick phrase length. + +The examples use `amy.send()` as the Python API. Each expandable section emits +the same wire message shown directly above it. + +## 1. Configure a simple sound + +Use oscillator 0 with a sine wave so the example does not depend on a stored +patch bank: + +```text +v0w0Z +``` + +
+Python API equivalent + +```python +import amy + +amy.send(osc=0, wave=amy.SINE) +``` + +
+ +## 2. Preload an ascending arpeggio + +Group 10 plays C4, E4, G4, and C5. Each note begins 24 ticks after the previous +one and has an 18-tick gate: + +```text +H0,96,0,10v0n60l1Z +H18,96,1,10v0l0Z +H24,96,2,10v0n64l1Z +H42,96,3,10v0l0Z +H48,96,4,10v0n67l1Z +H66,96,5,10v0l0Z +H72,96,6,10v0n72l1Z +H90,96,7,10v0l0Z +zQ10,3,96Z +``` + +The fourth `H` value selects group 10. The third value is a local event tag, +not a root tag. These messages update private staging storage; publish action 3 +makes the complete 96-tick revision visible atomically. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 10], osc=0, note=60, vel=1) +amy.send(ticks=[18, 96, 1, 10], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 10], osc=0, note=64, vel=1) +amy.send(ticks=[42, 96, 3, 10], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 10], osc=0, note=67, vel=1) +amy.send(ticks=[66, 96, 5, 10], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 10], osc=0, note=72, vel=1) +amy.send(ticks=[90, 96, 7, 10], osc=0, vel=0) +amy.send(sequence_control=[10, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 3. Preload a descending arpeggio + +Group 11 uses the same timing and reverses the pitches: + +```text +H0,96,0,11v0n72l1Z +H18,96,1,11v0l0Z +H24,96,2,11v0n67l1Z +H42,96,3,11v0l0Z +H48,96,4,11v0n64l1Z +H66,96,5,11v0l0Z +H72,96,6,11v0n60l1Z +H90,96,7,11v0l0Z +zQ11,3,96Z +``` + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 11], osc=0, note=72, vel=1) +amy.send(ticks=[18, 96, 1, 11], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 11], osc=0, note=67, vel=1) +amy.send(ticks=[42, 96, 3, 11], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 11], osc=0, note=64, vel=1) +amy.send(ticks=[66, 96, 5, 11], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 11], osc=0, note=60, vel=1) +amy.send(ticks=[90, 96, 7, 11], osc=0, vel=0) +amy.send(sequence_control=[11, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 4. Turn on the ascending arpeggio + +Install a normal repeating root event. Every 96 ticks it starts group 10 once. +Root tag 200 gives that future schedule a replaceable identity: + +```text +H0,96,200zQ10,1,1,0Z +zY1Z +``` + +The embedded control arguments are: + +```text +zQ group,action,repeats,quantize Z + 10 1 1 0 +``` + +Action 1 means start, and repeat value 1 makes each execution finite. The root +event supplies the repetition. Quantization is zero because the root event +already fires on the exact musical boundary; the group's local tick-zero event +is delivered on that same tick. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 0], +) +amy.send(sequencer_run=1) +``` + +
+ +## 5. Switch to the descending arpeggio + +Replace root tag 200 with a start for group 11: + +```text +H0,96,200zQ11,1,1,0Z +``` + +The next matching root boundary starts the descending revision. An ascending +execution that already began keeps its captured revision and reaches every +original note-off normally. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[11, amy.SEQUENCE_CONTROL_START, 1, 0], +) +``` + +
+ +## 6. Turn the arpeggio off and on + +Clear root tag 200 with the unchanged root-sequencer operation: + +```text +H0,0,200Z +``` + +This prevents future starts. It does not stop an execution that has already +begun, so the current phrase finishes with its normal note gates. Re-send the +root message from step 4 or 5 to turn the selected arpeggio on again. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 0, 200]) +``` + +
+ +To play group 10 only once instead of installing a root schedule, start one +execution at the next 96-tick boundary: + +```text +zQ10,1,1,96Z +``` + +
+Python API equivalent + +```python +amy.send( + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 96] +) +``` + +
+ +## 7. Gate one percussion instrument from a controller + +An independently controllable percussion role needs its own group execution. +Assume synth 10 is already configured as a percussion instrument and MIDI note +42 produces the desired closed hi-hat. Group 20 triggers that hit every 24 +ticks, and execution tag 300 is its live control address: + +```text +H0,24,0,20i10n42l1Z +zQ20,3,24Z +zQ20,1,0,24,300Z +``` + +The start repeat value is zero, so the execution repeats indefinitely. Other +percussion roles should use separate groups and execution tags when they need +independent control. + +Suppose a MIDI foot controller, switch, or other input has already been mapped +by the sending application. On press, it can apply a long finite event gate: + +```text +zQ20,2,2147483647,0,300Z +``` + +On release, duration zero removes the gate immediately: + +```text +zQ20,2,0,0,300Z +``` + +The gate suppresses future events from execution 300. It does not cut off a +sample that is already sounding, and the execution's clock continues. When the +gate is released, the hi-hat resumes on its original 24-tick phase. Reading the +controller and mapping it to these messages remain outside AMY. + +
+Python API equivalent + +```python +# Define and start the independently controllable hi-hat layer. +amy.send(ticks=[0, 24, 0, 20], synth=10, note=42, vel=1) +amy.send(sequence_control=[20, amy.SEQUENCE_CONTROL_PUBLISH, 24]) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_START, 0, 24, 300] +) + +# Controller press, then controller release. +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 2147483647, 0, 300] +) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 0, 0, 300] +) +``` + +
+ +When the silence has a known musical duration, send that duration directly. +For example, `zQ20,2,192,0,300Z` suppresses four quarter notes at 48 PPQ and +then releases automatically without another controller message. diff --git a/docs/sequencer-groups-musical-use-cases.md b/docs/sequencer-groups-musical-use-cases.md new file mode 100644 index 00000000..1db6b7a8 --- /dev/null +++ b/docs/sequencer-groups-musical-use-cases.md @@ -0,0 +1,100 @@ +# Musical use cases for sequencer groups + +Sequencer groups are useful when a musical phrase must remain a coherent unit +while a controller changes what will play next. Two representative applications +are an interactive rhythm engine with selectable drum fills and an arpeggiator +whose timing, direction, or notes can change during playback. Both are expressed +as ordinary AMY events on a local timeline; AMY contains no policy specific to +either application. + +## Dynamic drum fills + +Consider a rhythm engine that combines repeating percussion layers with a +selectable fill and a fill density. It may offer hundreds of short fills, let a +player change the active selection while transport continues, and temporarily +silence some background layers during a fill while allowing others to continue. + +A flat root sequence can represent one final arrangement. Live editing is more +complicated: the host must expand every chosen fill into root events, identify +which future events are safe to replace, coordinate the background boundaries, +avoid truncating a fill already in progress, and resend a large schedule whenever +selection or density changes. Combining fills, densities, and independently +controlled background layers multiplies that state even though every individual +phrase is small. + +Sequencer groups preserve the useful phrase boundary: + +1. The controller preloads each fill once as a finite group. +2. A small tagged root event starts the selected group at a musical boundary. +3. Independently controllable background roles run as tagged repeating group + executions. +4. A fill can contain finite gate events for background executions that should + not dispatch events during that fill. +5. Replacing or clearing the root event changes future fills only. A fill that + already started retains its immutable revision and finishes normally. + +The controller still owns every musical choice: fill selection, density, +instrument roles, and which roles continue. AMY only provides reusable phrase +storage, coherent execution, and generic event gating. Live control therefore +changes a small reference instead of rewriting the expanded leaf-event schedule. + +Stored definitions and active executions have independent limits. A rhythm +engine can configure enough group slots for a large fill catalogue without +creating hundreds of live players or scanning every stored fill on each tick. + +## Arpeggios with clean live changes + +An arpeggio can also be expanded into the root sequencer. The difficult part is +changing rate, direction, pitch, or voicing while notes are already in flight. +Deleting old root entries can remove a future note-off and leave a note hanging. +Sending an immediate all-off prevents the hang but shortens a valid note. A +host-side timer can defer the edit, but then the host must mirror AMY's musical +clock and track the lifetimes of overlapping phrases. + +Instead, one group revision stores the complete arpeggio phrase, including every +note-on and its matching note-off. Tagged root events determine when that phrase +starts. When a player changes the arpeggio: + +- the controller stages and atomically publishes the complete replacement; +- future starts capture the new published revision; +- an execution already sounding retains its previous immutable revision; +- every release in that execution therefore occurs at its original gate; +- quantized root starts preserve the musical boundary; +- untagged executions may overlap when a new phrase starts before an older one + has finished. + +The result avoids both abrupt releases and delayed hanging notes. AMY does not +know that the event collection is an arpeggio; the same lifetime guarantee +applies to any finite musical gesture. + +## Independently controlled repeating layers + +A drum voice, ostinato, control phrase, or other repeating part can run as an +independently tagged group execution. A controller can stop it at a quantized +boundary or gate future event dispatch without stopping the sequencer, changing +the phase, or affecting unrelated layers. + +For example, a foot controller can gate the event stream that triggers one +percussion instrument. Pedal-down suppresses future hits for that tagged +execution, while a sample already sounding ends naturally. Pedal-up releases the +gate and the next hit occurs on the layer's original phase. Reading the pedal and +choosing the execution tag remain responsibilities of the controller application. + +## The common abstraction + +All three applications share the same structure: + +```text +root timeline: decide when a stored phrase starts +group definition: store a coherent local event sequence +group execution: play one immutable revision with a bounded lifetime +execution control: start, stop, or temporarily gate that playback +``` + +A flat sequence can ultimately represent the same notes. The group boundary is +valuable because it makes live changes atomic, compact, and independent of host +timing. It moves phrase completion and release ownership into AMY without moving +application-specific musical policy into the synthesizer. + +See the [step-by-step arpeggio and percussion-gate example](sequencer-groups-howto.md) +for the corresponding wire commands and Python calls. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md new file mode 100644 index 00000000..8708659b --- /dev/null +++ b/docs/sequencer-groups.md @@ -0,0 +1,140 @@ +# Sequencer groups + +Sequencer groups are reusable collections of ordinary AMY sequencer events. +They add one bounded level below the existing root sequencer: a root event may +start a group, but a group cannot start another group. + +This is useful when a musical controller needs to trigger a complete phrase +as one operation. Examples include a drum fill, a short arpeggio with its own +note-on and note-off, or a repeating percussion layer. The controller can +preload these phrases and later send one small, quantized control message. It +does not need to reproduce AMY's clock or resend every event at performance +time. + +Related guides: + +- [Abstractions and implementation](sequencer-groups-abstractions.md) +- [Musical use cases](sequencer-groups-musical-use-cases.md) +- [Step-by-step wire and Python how-to](sequencer-groups-howto.md) + +## Defining and publishing a group + +The normal `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +`group_tag` values start at 1. An absent or zero group tag uses the existing +root sequencer without changing any of its semantics. + +This wire sequence stages a four-beat phrase in group 1 and then publishes it +atomically with a length of 192 ticks: + +```text +H0,192,0,1i2n60l1Z +H24,192,1,1i2n60l0Z +H48,192,2,1i2n64l1Z +H72,192,3,1i2n64l0Z +zQ1,3,192Z +``` + +The equivalent Python calls are: + +```python +amy.send(ticks="0,192,0,1", synth=2, note=60, vel=1) +amy.send(ticks="24,192,1,1", synth=2, note=60, vel=0) +amy.send(ticks="48,192,2,1", synth=2, note=64, vel=1) +amy.send(ticks="72,192,3,1", synth=2, note=64, vel=0) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_PUBLISH, 192]) +``` + +Grouped `ticks` commands update a private staging revision. Publishing is one +action in the generic control family rather than a separate begin/add/commit +API. It makes all staged local-tag replacements visible together, so a launch +can never observe a half-updated phrase. As at the root, `tick=0,period=0` +clears the specified event tag. Use a nonzero period for an event at local tick +zero. + +The published length is explicit and bounded; AMY does not derive it using an +LCM of event periods. Within each phrase, a nonzero event period repeats by +local modulo and a zero period fires once at its local tick. + +## Controlling executions + +The control layout is fixed: + +```text +group,action,value,quantize[,execution_tag] +``` + +| Action | Number | Meaning of `value` | +|---|---:|---| +| stop | 0 | reserved; use 0 | +| start | 1 | repeat count: 1 once, N exactly N times, 0 indefinitely | +| gate | 2 | suppress group-event firings for this many ticks; 0 releases a gate | +| publish | 3 | explicit group length in ticks | +| clear | 4 | reserved; use 0 | + +`quantize=0` means the next sequencer tick for a direct command. Otherwise the +control takes effect at the next multiple of that many ticks. When a root +sequencer event issues the control on the boundary itself, it takes effect on +that same tick, including the group's local tick-zero events. + +For example, start group 1 indefinitely at the next 192-tick boundary, assign +execution tag 100, and later stop that execution at a boundary: + +```text +zQ1,1,0,192,100Z +zQ1,0,0,192,100Z +``` + +```python +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_START, 0, 192, 100]) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_STOP, 0, 192, 100]) +``` + +Omit `execution_tag` to address every active execution of the group for stop +or gate operations. Supplying a tag to start makes a later start with the same +group and execution tag replace it on the requested boundary. Untagged starts +may overlap, which is useful for one-shot note phrases whose releases must be +allowed to finish independently. + +A finite gate advances the execution's local clock but suppresses its event +firings. Audio already sounding is not stopped, and the first event after the +gate occurs at its original phase. A gate can itself be placed in another +group as a leaf control; start, publish and clear are rejected while a group +payload is firing. A group therefore never launches or edits another group. + +## Scheduling a launch at the root + +Because `sequence_control` is an ordinary wire command, it can be the payload +of a normal root `ticks` event. This starts group 1 once at absolute tick 960: + +```text +H960,0,40zQ1,1,1,0Z +``` + +A repeating root entry can launch the same group sparsely without copying its +events. Clear that future launch with the unchanged root operation +`H0,0,40Z`; an execution already started from it keeps running. + +## Lifetime and memory guarantees + +An active execution retains the immutable published revision it started with. +Editing, publishing or clearing the group affects future starts only. This is +important for phrases containing releases: an old note-off cannot disappear +because a new definition was loaded while it was sounding. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and quantized-pending +executions but preserve published group definitions. Full AMY shutdown frees +them. + +Storage and work are bounded by `max_sequence_groups`, +`max_sequence_group_tags` and `max_sequence_group_executions` in +`amy_config_t`. Group event arrays and wire payloads are allocated only for +definitions that are authored. Setting any of the three capacities to zero +disables sequencer groups. The tick path scans only the fixed execution pool, +not all stored groups, so a larger definition catalogue does not make inactive +definitions part of per-tick work. Starting an execution does not allocate +memory. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..d3159403 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -239,7 +239,22 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon `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. -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 sequencer groups + +A fourth `ticks` value stores an event in a reusable group instead of the root +sequencer: `tick,period,event_tag,group_tag`. Group tag zero is reserved for +the root sequencer, so existing one-, two- and three-value `ticks` messages +retain their original behavior. Groups are controlled through the single +`sequence_control` parameter; they can run once, a fixed number of times, or +continuously, and start/stop can be quantized to AMY's tick clock. + +See [Sequencer groups](sequencer-groups.md) for the concise wire format and +lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-groups-abstractions.md), +[musical use cases](sequencer-groups-musical-use-cases.md), and a +[step-by-step wire and Python example](sequencer-groups-howto.md). ## Core oscillators @@ -477,5 +492,3 @@ amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - - diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..595e4184 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: @@ -343,6 +354,7 @@ var _KW_MAP: Dictionary = { "portamento": ["m", "I"], "tempo": ["j", "F"], "sequencer_run": ["zY", "I"], + "sequence_control": ["zQ", "L"], "external_midi_sync": ["zC", "I"], "synth": ["i", "I"], "pedal": ["ip", "I"], @@ -418,27 +430,28 @@ var _KW_PRIORITY: Dictionary = { "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, + "sequence_control": 51, + "external_midi_sync": 52, + "synth": 53, + "pedal": 54, + "synth_flags": 55, + "num_voices": 56, + "oscs_per_voice": 57, + "synth_level": 58, + "to_synth": 59, + "grab_midi_notes": 60, + "note_source_channel": 61, + "synth_delay": 62, + "preset": 63, + "num_partials": 64, + "start_sample": 65, + "stop_sample": 66, + "bus": 67, + "mode": 68, + "midi_cc": 69, + "midi_note_cmd": 70, + "cv_trigger": 71, + "patch_string": 72, } ## The control coefficient inputs, in wire order. Prefer naming these in a 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..d20187da 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1298,7 +1298,10 @@ 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_groups, + amy_global.config.max_sequence_group_tags, + amy_global.config.max_sequence_group_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. @@ -2476,6 +2479,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_group_reset_timebase(); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; diff --git a/src/amy.h b/src/amy.h index 3022cade..37a71d03 100644 --- a/src/amy.h +++ b/src/amy.h @@ -363,6 +363,13 @@ enum coefs{ #define TICKS_TICK 0 #define TICKS_PERIOD 1 #define TICKS_TAG 2 +#define TICKS_GROUP 3 + +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 +#define SEQUENCE_CONTROL_PUBLISH 3 +#define SEQUENCE_CONTROL_CLEAR 4 // Reset masks #define RESET_SEQUENCER 4096 @@ -667,7 +674,7 @@ typedef struct amy_event { uint16_t num_voices; uint8_t oscs_per_voice; // Used when initializing a synth without a patch. // - uint32_t ticks[3]; // tick, period, tag + uint32_t ticks[4]; // tick, period, tag, optional group tag // uint8_t note_source_channel; // .. to mark the channel of events that come from MIDI so we don't send them back out again. uint32_t reset_osc; @@ -887,6 +894,10 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; + // Group tag zero is reserved for the existing root sequencer. + uint32_t max_sequence_groups; + uint32_t max_sequence_group_tags; + uint32_t max_sequence_group_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..230f8876 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -55,6 +55,7 @@ var AMY_KW_MAP = { portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, sequencer_run: {wire: "zY", type: "I"}, + sequence_control: {wire: "zQ", type: "L"}, external_midi_sync: {wire: "zC", type: "I"}, synth: {wire: "i", type: "I"}, pedal: {wire: "ip", type: "I"}, @@ -130,27 +131,28 @@ var AMY_KW_PRIORITY = { 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 + sequence_control: 51, + external_midi_sync: 52, + synth: 53, + pedal: 54, + synth_flags: 55, + num_voices: 56, + oscs_per_voice: 57, + synth_level: 58, + to_synth: 59, + grab_midi_notes: 60, + note_source_channel: 61, + synth_delay: 62, + preset: 63, + num_partials: 64, + start_sample: 65, + stop_sample: 66, + bus: 67, + mode: 68, + midi_cc: 69, + midi_note_cmd: 70, + cv_trigger: 71, + patch_string: 72 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; @@ -406,6 +408,12 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + TICKS_GROUP: 3, + SEQUENCE_CONTROL_STOP: 0, + SEQUENCE_CONTROL_START: 1, + SEQUENCE_CONTROL_GATE: 2, + SEQUENCE_CONTROL_PUBLISH: 3, + SEQUENCE_CONTROL_CLEAR: 4, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, 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..faad006c 100644 --- a/src/api.c +++ b/src/api.c @@ -48,6 +48,9 @@ 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_groups = 32; + c.max_sequence_group_tags = 64; + c.max_sequence_group_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -187,6 +190,7 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->ticks[TICKS_TICK]); AMY_UNSET(e->ticks[TICKS_PERIOD]); AMY_UNSET(e->ticks[TICKS_TAG]); + AMY_UNSET(e->ticks[TICKS_GROUP]); AMY_UNSET(e->eq_l); AMY_UNSET(e->eq_m); AMY_UNSET(e->eq_h); @@ -320,7 +324,7 @@ void amy_send_wire_from_sysex(char *message) { void amy_add_event(amy_event *e) { peek_stack("add_event"); // was amy_process_event - if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG])) { + if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG]) || AMY_IS_SET(e->ticks[TICKS_GROUP])) { // C-API ticks event: serialize it to a wire message and hand it to // the sequencer, so scheduled events have a single storage format. char *buf = (char *)malloc_caps(MAX_MESSAGE_LEN, amy_global.config.ram_caps_events); diff --git a/src/parse.c b/src/parse.c index 436a4549..8c8b94f8 100644 --- a/src/parse.c +++ b/src/parse.c @@ -659,6 +659,20 @@ uint16_t amy_parse_transfer_layer_message(char *message) { return total; } } + else if (cmd == 'Q') { + // zQgroup,action,value,quantize[,execution_tag] + uint32_t values[5] = {0, 0, 0, 0, 0}; + int count = parse_list_uint32_t(message, values, 5, 0); + if (count < 2) { + fprintf(stderr, + "invalid sequence_control: expected " + "zQgroup,action[,value,quantize,execution_tag]\n"); + } else { + sequencer_group_control(values[0], values[1], values[2], values[3], + values[4], count >= 5); + } + return 1; + } else if (cmd == 'Y') { // zY: sequencer transport. zY1 starts the sequencer, zY0 stops it. Lets a // host drive playback without MIDI clock sync (see external_midi_sync). @@ -710,8 +724,8 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { // is only ever honored as the first command of a message. void handle_ticks_message(char *message) { assert(message[0] == 'H'); - uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); + uint32_t ticks[4] = {0, 0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, ticks, 4, 0); uint16_t schedule_len = 1 + _next_alpha(message + 1); char *payload = message + schedule_len; uint16_t payload_len = (uint16_t)strlen(payload); @@ -720,10 +734,17 @@ void handle_ticks_message(char *message) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - // A 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); + if (num_vals >= 4 && ticks[TICKS_GROUP] != 0) { + // The fourth ticks value selects persistent group-local storage. + // Group zero deliberately follows the legacy root path below. + sequencer_group_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], + ticks[TICKS_TAG], ticks[TICKS_GROUP], stripped); + } else { + // 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); + } } } @@ -906,4 +927,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..9aa7e50a 100644 --- a/src/patches.c +++ b/src/patches.c @@ -330,12 +330,12 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { snprintf(s, len - (size_t)(s - s_entry), "amy_event(time=%" PRIu32 ", osc=%u, addr_osc=%d adr_syn=%d adr_bus=%d): ", e->time, (unsigned)e->osc, event_addresses_oscs(e), event_addresses_synth(e), event_addresses_bus(e)); s += strlen(s); - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group } else { // e->time has no wire representation anymore (there's no 't' command); // it's only ever meaningful as this event's own near-term playback time. // ticks ("H") must always be the first entry in wire code if used. - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group _EPRINT_I(osc, "osc", "v"); } _EPRINT_I(wave, "wave", "w"); @@ -540,7 +540,7 @@ bool event_addresses_oscs(amy_event *e) { _RET_TRUE_IF_SET(eg_type[0]); _RET_TRUE_IF_SET(eg_type[1]); // We don't know - _RET_TRUE_IF_SET_SEQ(ticks, 3); // tick, period, tag + _RET_TRUE_IF_SET_SEQ(ticks, 4); // tick, period, tag, optional group // //_RET_TRUE_IF_SET(status, "status"); _RET_TRUE_IF_SET(reset_osc); diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..2594b960 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); @@ -97,6 +102,33 @@ 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_groups") == 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_groups must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_groups = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_tags") == 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_group_tags must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_tags = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_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_group_executions must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_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..96a3522e 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,7 +47,186 @@ 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) { +// A group definition is immutable once published. Edits are accumulated in a +// private copy and become visible together through SEQUENCE_CONTROL_PUBLISH. +// Active executions retain the published revision they started with. +typedef struct sequence_group_event_t { + char *wire; + uint32_t tick; + uint32_t period; +} sequence_group_event_t; + +typedef struct sequence_group_definition_t { + sequence_group_event_t *events; + uint32_t length_ticks; + uint32_t refs; +} sequence_group_definition_t; + +typedef struct sequence_group_slot_t { + sequence_group_definition_t *published; + sequence_group_definition_t *staging; +} sequence_group_slot_t; + +typedef struct sequence_group_execution_t { + sequence_group_definition_t *definition; + uint32_t group; + uint32_t start_tick; + uint32_t repeats; + uint32_t execution_tag; + uint32_t stop_tick; + uint32_t gate_change_tick; + uint32_t gate_duration; + uint32_t gate_end_tick; + bool occupied; + bool has_execution_tag; + bool stop_pending; + bool gate_change_pending; + bool gated; +} sequence_group_execution_t; + +static sequence_group_slot_t *sequence_groups = NULL; +static sequence_group_execution_t *group_executions = NULL; +static uint32_t max_sequence_groups = 0; +static uint32_t max_sequence_group_tags = 0; +static uint32_t max_sequence_group_executions = 0; +static size_t sequence_group_event_bytes = 0; +static volatile bool group_wire_firing = false; + +static bool checked_array_size(uint32_t count, size_t element_size, + size_t *bytes) { + if (count > SIZE_MAX / element_size) return false; + *bytes = (size_t)count * element_size; + return true; +} + +static void group_definition_release(sequence_group_definition_t *definition) { + if (definition == NULL || definition->refs == 0) return; + definition->refs--; + if (definition->refs != 0) return; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) + if (definition->events[i].wire != NULL) free(definition->events[i].wire); + free(definition->events); + free(definition); +} + +static sequence_group_definition_t *group_definition_new(void) { + sequence_group_definition_t *definition = + (sequence_group_definition_t *)malloc_caps(sizeof(sequence_group_definition_t), + amy_global.config.ram_caps_synth); + if (definition == NULL) return NULL; + definition->events = (sequence_group_event_t *)malloc_caps( + sequence_group_event_bytes, amy_global.config.ram_caps_synth); + if (definition->events == NULL) { + free(definition); + return NULL; + } + memset(definition->events, 0, sequence_group_event_bytes); + definition->length_ticks = 0; + definition->refs = 1; + return definition; +} + +static char *group_wire_copy(const char *wire) { + size_t len = strlen(wire); + char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + if (copy != NULL) memcpy(copy, wire, len + 1); + return copy; +} + +static sequence_group_definition_t *group_definition_clone( + const sequence_group_definition_t *source) { + sequence_group_definition_t *copy = group_definition_new(); + if (copy == NULL) return NULL; + if (source == NULL) return copy; + copy->length_ticks = source->length_ticks; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + const sequence_group_event_t *from = &source->events[i]; + if (from->wire == NULL) continue; + copy->events[i].wire = group_wire_copy(from->wire); + if (copy->events[i].wire == NULL) { + group_definition_release(copy); + return NULL; + } + copy->events[i].tick = from->tick; + copy->events[i].period = from->period; + } + return copy; +} + +static void group_execution_release(sequence_group_execution_t *execution) { + if (!execution->occupied) return; + sequence_group_definition_t *definition = execution->definition; + memset(execution, 0, sizeof(*execution)); + group_definition_release(definition); +} + +static void group_executions_reset(void) { + if (group_executions == NULL) return; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) + group_execution_release(&group_executions[i]); +} + +static void sequence_groups_deinit(void) { + group_executions_reset(); + if (sequence_groups != NULL) { + for (uint32_t i = 0; i < max_sequence_groups; ++i) { + group_definition_release(sequence_groups[i].published); + group_definition_release(sequence_groups[i].staging); + } + free(sequence_groups); + sequence_groups = NULL; + } + if (group_executions != NULL) { + free(group_executions); + group_executions = NULL; + } + max_sequence_groups = 0; + max_sequence_group_tags = 0; + max_sequence_group_executions = 0; + sequence_group_event_bytes = 0; +} + +static void sequence_groups_init(uint32_t groups, uint32_t tags, + uint32_t executions) { + max_sequence_groups = groups; + max_sequence_group_tags = tags; + max_sequence_group_executions = executions; + group_wire_firing = false; + if (groups == 0 || tags == 0 || executions == 0) return; + + size_t group_bytes = 0; + size_t execution_bytes = 0; + if (!checked_array_size(groups, sizeof(sequence_group_slot_t), &group_bytes) + || !checked_array_size(tags, sizeof(sequence_group_event_t), + &sequence_group_event_bytes) + || !checked_array_size(executions, + sizeof(sequence_group_execution_t), + &execution_bytes)) { + fprintf(stderr, + "sequencer group configuration exceeds addressable memory: " + "groups=%" PRIu32 ", event_tags=%" PRIu32 + ", executions=%" PRIu32 "\n", + groups, tags, executions); + sequence_groups_deinit(); + return; + } + sequence_groups = (sequence_group_slot_t *)malloc_caps( + group_bytes, amy_global.config.ram_caps_synth); + if (sequence_groups != NULL) + memset(sequence_groups, 0, group_bytes); + group_executions = (sequence_group_execution_t *)malloc_caps( + execution_bytes, amy_global.config.ram_caps_synth); + if (group_executions != NULL) + memset(group_executions, 0, execution_bytes); + if (sequence_groups == NULL || group_executions == NULL) { + amy_oom("sequencer groups"); + sequence_groups_deinit(); + return; + } +} + +void sequencer_init(int max_sequencer_tags, uint32_t groups, + uint32_t group_tags, uint32_t group_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; @@ -65,6 +244,7 @@ void sequencer_init(int max_sequencer_tags) { sequences[i].next_active = -1; } first_active = -1; + sequence_groups_init(groups, group_tags, group_execution_count); // We are read to go. sequencer_recompute(); } @@ -82,6 +262,9 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + // Definitions are preloadable state and deliberately survive a transport + // reset; only their active or quantized executions are discarded. + group_executions_reset(); } void sequencer_deinit() { @@ -91,6 +274,13 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + sequence_groups_deinit(); +} + +void sequencer_group_reset_timebase() { + // Absolute activation/control ticks cannot be meaningfully rebased across + // a timebase reset. Persistent definitions remain available for relaunch. + group_executions_reset(); } void sequencer_debug() { @@ -240,6 +430,310 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } +static sequence_group_slot_t *group_slot(uint32_t group) { + if (sequence_groups == NULL || group == 0 || group > max_sequence_groups) + return NULL; + return &sequence_groups[group - 1]; +} + +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL) { + if (sequence_groups == NULL) + fprintf(stderr, "cannot add event to sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot add event: sequencer group %" PRIu32 + " is outside the configured range [1, %" PRIu32 "]\n", + group, max_sequence_groups); + free(wire); + return 0; + } + if (tag >= max_sequence_group_tags) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": valid event tags are [0, %" PRIu32 "]\n", + tag, group, max_sequence_group_tags - 1); + free(wire); + return 0; + } + if (wire == NULL) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 ": wire is NULL\n", + tag, group); + return 0; + } + if (wire[0] == 'H') { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": a grouped event cannot contain another ticks command\n", + tag, group); + free(wire); + return 0; + } + + amy_grab_lock(); + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_release_lock(); + amy_oom("sequencer group edit"); + free(wire); + return 0; + } + } + sequence_group_event_t *event = &slot->staging->events[tag]; + if (event->wire != NULL) free(event->wire); + event->wire = NULL; + event->tick = 0; + event->period = 0; + if (tick != 0 || period != 0) { + event->wire = wire; + event->tick = tick; + event->period = period; + wire = NULL; + } + amy_release_lock(); + if (wire != NULL) free(wire); + return 1; +} + +static uint32_t group_control_tick(uint32_t quantize) { + // 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 = wire_firing ? amy_global.sequencer_tick_count + : amy_global.sequencer_tick_count + 1; + if (quantize != 0) { + uint32_t remainder = tick % quantize; + if (remainder != 0) tick += quantize - remainder; + } + return tick; +} + +static bool group_execution_matches(const sequence_group_execution_t *execution, + uint32_t group, uint32_t execution_tag, + bool has_execution_tag) { + if (!execution->occupied || execution->group != group) return false; + return !has_execution_tag + || (execution->has_execution_tag + && execution->execution_tag == execution_tag); +} + +static const char *group_action_name(uint32_t action) { + if (action == SEQUENCE_CONTROL_START) return "start"; + if (action == SEQUENCE_CONTROL_PUBLISH) return "publish"; + if (action == SEQUENCE_CONTROL_CLEAR) return "clear"; + return "unknown"; +} + +static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, + uint32_t length) { + if (length == 0) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": length must be greater than zero\n", group); + return 0; + } + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_oom("sequencer group publish"); + return 0; + } + } + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + sequence_group_event_t *event = &slot->staging->events[i]; + if (event->wire == NULL) continue; + if (event->tick >= length) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below group length %" PRIu32 "\n", + group, i, event->tick, length); + return 0; + } + if (event->period != 0 && event->tick >= event->period) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below its period %" PRIu32 "\n", + group, i, event->tick, event->period); + return 0; + } + } + slot->staging->length_ticks = length; + sequence_group_definition_t *previous = slot->published; + slot->published = slot->staging; + slot->staging = NULL; + group_definition_release(previous); + return 1; +} + +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL) { + if (sequence_groups == NULL) + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": valid groups are [1, %" PRIu32 "]\n", + group, max_sequence_groups); + return 0; + } + if (group_wire_firing + && (action == SEQUENCE_CONTROL_START + || action == SEQUENCE_CONTROL_PUBLISH + || action == SEQUENCE_CONTROL_CLEAR)) { + fprintf(stderr, "sequencer group %" PRIu32 + " cannot perform lifecycle action %s (%" PRIu32 ")" + ": grouped events may only stop or gate executions\n", + group, group_action_name(action), action); + return 0; + } + + uint8_t result = 0; + amy_grab_lock(); + if (action == SEQUENCE_CONTROL_PUBLISH) { + result = group_publish(slot, group, value); + } else if (action == SEQUENCE_CONTROL_CLEAR) { + group_definition_release(slot->published); + group_definition_release(slot->staging); + slot->published = NULL; + slot->staging = NULL; + result = 1; + } else if (action == SEQUENCE_CONTROL_START) { + if (slot->published == NULL || slot->published->length_ticks == 0) { + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": no definition has been published\n", group); + } else { + uint32_t start_tick = group_control_tick(quantize); + sequence_group_execution_t *available = NULL; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied && available == NULL) available = execution; + } + if (available == NULL) { + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": all %" PRIu32 " execution slots are occupied\n", + group, max_sequence_group_executions); + } else { + if (has_execution_tag) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (group_execution_matches(execution, group, execution_tag, true)) { + execution->stop_tick = start_tick; + execution->stop_pending = true; + } + } + } + memset(available, 0, sizeof(*available)); + available->definition = slot->published; + available->definition->refs++; + available->group = group; + available->start_tick = start_tick; + available->repeats = value; + available->execution_tag = execution_tag; + available->has_execution_tag = has_execution_tag; + available->occupied = true; + result = 1; + } + } + } else if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_GATE) { + uint32_t control_tick = group_control_tick(quantize); + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!group_execution_matches(execution, group, execution_tag, + has_execution_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 sequencer group %" PRIu32 + ": action %" PRIu32 " is unknown; valid actions are " + "stop=0, start=1, gate=2, publish=3, clear=4\n", + group, action); + } + amy_release_lock(); + return result; +} + +static bool group_event_hits(const sequence_group_event_t *event, + uint32_t local_tick) { + if (event->wire == NULL) return false; + return event->period != 0 ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool group_event_is_control(const sequence_group_event_t *event) { + return event->wire != NULL && strncmp(event->wire, "zQ", 2) == 0; +} + +static void group_play_wire(const char *wire) { + bool previous = group_wire_firing; + group_wire_firing = true; + amy_play_message((char *)wire); + group_wire_firing = previous; +} + +static void group_process_pass(uint32_t tick, bool controls) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + amy_grab_lock(); + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + continue; + } + uint32_t elapsed = tick - execution->start_tick; + sequence_group_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (execution->repeats != 0 + && elapsed / definition->length_ticks >= execution->repeats)) { + group_execution_release(execution); + amy_release_lock(); + continue; + } + if (!controls) { + 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; + definition->refs++; + uint32_t local_tick = elapsed % definition->length_ticks; + amy_release_lock(); + + if (!suppress) { + for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { + sequence_group_event_t *event = &definition->events[tag]; + if (group_event_is_control(event) == controls + && group_event_hits(event, local_tick)) + group_play_wire(event->wire); + } + } + + amy_grab_lock(); + group_definition_release(definition); + amy_release_lock(); + } +} + static void sequencer_process_tick(void) { amy_global.sequencer_tick_count++; midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode @@ -300,6 +794,10 @@ static void sequencer_process_tick(void) { } tag = next; } + // Controls embedded in a group are leaf operations (stop/gate only) and + // take effect before any ordinary group event on the same tick. + group_process_pass(amy_global.sequencer_tick_count, true); + group_process_pass(amy_global.sequencer_tick_count, false); 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); diff --git a/src/sequencer.h b/src/sequencer.h index d073e642..82eda5ba 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,7 +5,8 @@ #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(int max_num_sequences, uint32_t max_groups, + uint32_t max_group_tags, uint32_t max_group_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); @@ -22,6 +23,18 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) and can't be addressed // or cancelled by any tag. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +// Store one ordinary ticks event in a group's unpublished revision. Takes +// ownership of wire. Group zero is reserved for sequencer_add_wire(). +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire); + +// sequence_control actions. The wire/API representation is always +// [group, action, value, quantize, optional execution_tag]. +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag); +void sequencer_group_reset_timebase(); 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..2119a1da --- /dev/null +++ b/tests/test_android_service_contract.py @@ -0,0 +1,76 @@ +#!/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"kIntegrationMaxSequenceGroups\s*=\s*1024\s*;", engine, + "the complete hosted group catalogue capacity") + require(r"config\.max_sequence_groups\s*=\s*kIntegrationMaxSequenceGroups\s*;", + engine, "runtime sequencer-group configuration") + require(r"config\.max_sequence_group_tags\s*=\s*kIntegrationMaxSequenceGroupTags\s*;", + engine, "runtime local-tag configuration") + require(r"config\.max_sequence_group_executions\s*=\s*kIntegrationMaxSequenceGroupExecutions\s*;", + engine, "runtime group-execution configuration") + require(r"kIntegrationMaxSequenceGroupExecutions\s*=\s*40\s*;", engine, + "characterized Omnichord group-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, 1024 sequencer groups, " + "8-second test capture") + + +if __name__ == "__main__": + main() 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_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..043520cb --- /dev/null +++ b/tests/test_python_offline_live.py @@ -0,0 +1,48 @@ +#!/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_sequence_groups=1024, + max_sequence_group_tags=64, + max_sequence_group_executions=40, + ) + + 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) + 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 group tag proves that audio=False retained live()'s configurable + # engine sizing instead of falling back to the import-time defaults. + amy.send(ticks=(0, 4, 0, 1000), osc=0, vel=0) + amy.send(sequence_control=[1000, amy.SEQUENCE_CONTROL_PUBLISH, 4]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c new file mode 100644 index 00000000..0f078ba0 --- /dev/null +++ b/tests/test_sequence_groups.c @@ -0,0 +1,736 @@ +// Regression and behavior tests for reusable sequencer groups. + +#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[24]; + uint32_t tick; +} mark_t; + +static mark_t marks[128]; +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 int marks_named_at(const char *name, uint32_t tick) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) count++; + return count; +} + +static void clear_group(uint32_t group) { + char wire[32]; + snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); + amy_add_message(wire); +} + +static void test_legacy_ticks_are_unchanged(void) { + printf("legacy root ticks behavior remains unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H0,4,0zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), "root period event fires at global modulo"); + CHECK(mark_at("root", first + 4), "root period event keeps looping"); + amy_add_message("H0,0,0Z"); + + clear_marks(); + uint32_t target = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); + amy_add_message(wire); + clock_to(target); + CHECK(!marks_named("old") && mark_at("new", target), + "legacy root tags still replace by tag"); + + clear_marks(); + uint32_t group_zero = next_boundary(sequencer_ticks(), 4); + amy_add_message("H0,4,5,0zPgroup-zero-rootZ"); + clock_to(group_zero); + CHECK(mark_at("group-zero-root", group_zero), + "an explicit group tag zero follows the legacy root path"); + amy_add_message("H0,0,5Z"); +} + +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events keep their three-value ticks wire format\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 + && strncmp(wire, "H3,8,7,", 7) != 0, + "an unset group field adds no fourth ticks value: %s", wire); + + event.ticks[TICKS_GROUP] = 2; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7,2", 8) == 0, + "a grouped C event adds exactly one ticks value: %s", wire); +} + +static void test_group_local_tags_are_independent(void) { + printf("event tags are local to each sequencer group\n"); + sequencer_reset(); + clear_group(6); + clear_group(7); + clear_marks(); + amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); + amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); + amy_add_message("H0,4,0zProot-tag-zeroZ"); + amy_add_message("zQ6,3,4Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,1,4Z"); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start); + CHECK(mark_at("group-six-tag-zero", start), + "group 6 owns its event tag zero"); + CHECK(mark_at("group-seven-tag-zero", start), + "group 7 independently owns event tag zero"); + CHECK(mark_at("root-tag-zero", start), + "root tag zero remains independent of every group-local tag zero"); + amy_add_message("H0,0,0Z"); +} + +static void test_one_n_and_infinite_repeats(void) { + printf("groups support one, N and infinite repeats\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPzeroZ"); + amy_add_message("H2,4,1,1zPtwoZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t one = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,1,4Z"); + clock_to(one + 6); + CHECK(mark_at("zero", one) && mark_at("two", one + 2), + "one-shot uses local ticks from its activation"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "one-shot does not wrap"); + + clear_marks(); + uint32_t twice = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,2,4Z"); + clock_to(twice + 10); + CHECK(mark_at("zero", twice) && mark_at("zero", twice + 4), + "repeat count two runs exactly two phrases"); + CHECK(marks_named("zero") == 2, "N-shot finishes after N phrases"); + + clear_marks(); + uint32_t loop = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,0,4,77Z"); + clock_to(loop + 8); + CHECK(mark_at("zero", loop) && mark_at("zero", loop + 8), + "repeat count zero loops indefinitely"); + amy_add_message("zQ1,0,0,0,77Z"); + clock_to(loop + 12); + CHECK(!mark_at("zero", loop + 12), "tagged stop ends the loop"); +} + +static void test_atomic_revision_lifetime(void) { + printf("published revisions are atomic and immutable while active\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,8,0,2zPold-zeroZ"); + amy_add_message("H6,8,1,2zPold-tailZ"); + amy_add_message("zQ2,3,8Z"); + + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + amy_add_message("H0,8,0,2zPnew-zeroZ"); + amy_add_message("H0,0,1,2Z"); + + uint32_t still_old = old_start + 8; + char root[80]; + snprintf(root, sizeof(root), "H%" PRIu32 ",0,31zQ2,1,1,0Z", still_old); + amy_add_message(root); + clock_to(old_start + 6); + CHECK(mark_at("old-zero", old_start) && mark_at("old-tail", old_start + 6), + "an active execution finishes its original revision"); + + clock_to(still_old); + CHECK(mark_at("old-zero", still_old), + "staged edits are invisible before publication"); + amy_add_message("zQ2,3,8Z"); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + clock_to(new_start + 6); + CHECK(mark_at("new-zero", new_start), "future execution uses published edit"); + CHECK(!mark_at("old-tail", new_start + 6), "published local-tag clear took effect"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("a root event can launch group local tick zero on the same tick\n"); + sequencer_reset(); + clear_group(3); + clear_marks(); + amy_add_message("H0,4,0,3zPchildZ"); + amy_add_message("zQ3,3,4Z"); + + uint32_t start = sequencer_ticks() + 4; + char wire[80]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,22zQ3,1,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child", start), "root launch and group local zero coincide"); +} + +static void test_direct_start_begins_on_next_tick(void) { + printf("an unquantized direct start begins on the next tick\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPnext-tickZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "unquantized direct start is accepted"); + CHECK(!marks_named("next-tick"), "start does not fire synchronously"); + sequencer_midi_clock_tick(); + CHECK(mark_at("next-tick", start), "local tick zero fires on the next tick"); +} + +static void test_tagged_start_replaces_at_activation(void) { + printf("a tagged start replaces its predecessor at the activation boundary\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,2,0,2zPold-executionZ"); + amy_add_message("zQ2,3,2Z"); + uint32_t predecessor_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 41, true), + "the predecessor starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("old-execution", predecessor_start), + "the predecessor is running before replacement"); + + amy_add_message("H0,2,0,2zPnew-executionZ"); + amy_add_message("zQ2,3,2Z"); + clear_marks(); + uint32_t replacement = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 1, 4, 41, true), + "the tagged replacement is accepted"); + clock_to(replacement); + CHECK(!mark_at("old-execution", replacement), + "the predecessor does not fire at the replacement boundary"); + CHECK(marks_named_at("new-execution", replacement) == 1, + "exactly one replacement fires at the boundary"); +} + +static void test_c_event_uses_fourth_ticks_field(void) { + printf("the C event API defines grouped events through ticks[3]\n"); + sequencer_reset(); + clear_group(6); + amy_event event = amy_default_event(); + event.osc = 0; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 0; + event.ticks[TICKS_PERIOD] = 4; + event.ticks[TICKS_TAG] = 0; + event.ticks[TICKS_GROUP] = 6; + amy_add_event(&event); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "C-authored grouped event publishes"); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "C-authored group starts"); + clock_to(sequencer_ticks() + 2); + amy_execute_deltas(); + CHECK(synth[0] != NULL && synth[0]->wave == TRIANGLE, + "C-authored grouped event reaches normal playback"); +} + +static void test_quantized_gate_preserves_phase(void) { + printf("finite event gating preserves local phase\n"); + sequencer_reset(); + clear_group(4); + clear_group(5); + clear_marks(); + amy_add_message("H0,2,0,4zPbackgroundZ"); + amy_add_message("zQ4,3,4Z"); + amy_add_message("H0,4,0,5zQ4,2,4,0,81Z"); + amy_add_message("H0,4,1,5zPforegroundZ"); + amy_add_message("zQ5,3,4Z"); + + uint32_t background = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ4,1,0,4,81Z"); + clock_to(background + 2); + CHECK(mark_at("background", background) + && mark_at("background", background + 2), + "background loop initially emits on phase"); + + uint32_t foreground = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ5,1,1,4Z"); + clock_to(foreground + 4); + CHECK(mark_at("foreground", foreground), "foreground group starts normally"); + CHECK(!mark_at("background", foreground) + && !mark_at("background", foreground + 2), + "gate suppresses events for its exact duration"); + CHECK(mark_at("background", foreground + 4), + "background resumes on its unchanged phase"); + amy_add_message("zQ4,0,0,0,81Z"); + clock_to(foreground + 6); +} + +static void test_quantized_stop_precedes_boundary_event(void) { + printf("quantized stop takes effect before an event at its boundary\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPstoppedZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,0,4,91Z"); + clock_to(start); + CHECK(mark_at("stopped", start), "loop starts on its boundary"); + + uint32_t stop = next_boundary(sequencer_ticks(), 8); + amy_add_message("zQ6,0,0,8,91Z"); + clock_to(stop); + CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); +} + +static void test_tagged_gate_and_stop_are_selective(void) { + printf("execution tags make gate and stop selective\n"); + sequencer_reset(); + clear_group(3); + clear_group(4); + clear_marks(); + amy_add_message("H0,1,0,3zPsharedZ"); + amy_add_message("zQ3,3,8Z"); + amy_add_message("H0,1,0,4zPother-groupZ"); + amy_add_message("zQ4,3,8Z"); + amy_add_message("zQ3,1,0,0,101Z"); + amy_add_message("zQ3,1,0,0,102Z"); + amy_add_message("zQ4,1,0,0,101Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named_at("shared", sequencer_ticks()) == 2, + "two tagged executions of one group can overlap"); + CHECK(marks_named_at("other-group", sequencer_ticks()) == 1, + "the same execution tag is independent in another group"); + + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 2, 0, 101, true), + "a matching tagged gate is accepted"); + uint32_t gate_tick = sequencer_ticks() + 1; + clock_to(gate_tick + 2); + CHECK(marks_named_at("shared", gate_tick) == 1 + && marks_named_at("shared", gate_tick + 1) == 1, + "only the selected execution is gated"); + CHECK(marks_named_at("shared", gate_tick + 2) == 2, + "the selected execution resumes after the exact duration"); + CHECK(marks_named_at("other-group", gate_tick) == 1, + "a tagged gate does not cross group boundaries"); + + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 100, 0, 101, true), + "a longer tagged gate is accepted"); + uint32_t long_gate_tick = sequencer_ticks() + 1; + clock_to(long_gate_tick); + CHECK(marks_named_at("shared", long_gate_tick) == 1, + "a positive gate duration suppresses the selected execution"); + uint32_t ungate_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 0, 0, 101, true), + "gate duration zero requests an early ungate"); + clock_to(ungate_tick); + CHECK(marks_named_at("shared", ungate_tick) == 2, + "gate duration zero resumes the selected execution on its phase"); + + clear_marks(); + uint32_t tagged_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), + "a matching tagged stop is accepted"); + clock_to(tagged_stop_tick); + CHECK(marks_named_at("shared", tagged_stop_tick) == 1, + "only the selected execution stops"); + CHECK(!sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 999, true), + "a nonmatching execution tag reports no affected execution"); + uint32_t all_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop selects every remaining execution in the group"); + clock_to(all_stop_tick); + int remaining = marks_named_at("shared", all_stop_tick); + CHECK(remaining == 0, + "the untagged stop removed the remaining execution (got %d events)", + remaining); + CHECK(mark_at("other-group", all_stop_tick), + "the untagged stop remains scoped to its group"); + amy_add_message("zQ4,0Z"); + sequencer_midi_clock_tick(); +} + +static void test_tagged_control_does_not_select_untagged_execution(void) { + printf("tagged controls do not select untagged executions\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,1,0,2zPuntaggedZ"); + amy_add_message("zQ2,3,1Z"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 0, false), + "an untagged execution starts"); + sequencer_midi_clock_tick(); + + clear_marks(); + CHECK(!sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 77, true), + "a tagged stop reports no match for an untagged execution"); + sequencer_midi_clock_tick(); + CHECK(marks_named("untagged") == 2, + "the unmatched tagged stop leaves the untagged execution running"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop still selects the execution"); + sequencer_midi_clock_tick(); +} + +static void test_group_lifecycle_control_is_not_recursive(void) { + printf("a group payload cannot start, publish or clear a group\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPpublished-revisionZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,8zPstaged-revisionZ"); + amy_add_message("H0,4,0,7zQ8,1,1,0Z"); + amy_add_message("H0,4,1,7zQ8,3,4Z"); + amy_add_message("H0,4,2,7zQ8,4Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start); + CHECK(!marks_named("published-revision") && !marks_named("staged-revision"), + "group-to-group start is rejected"); + + uint32_t old_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the target group can still be started directly"); + sequencer_midi_clock_tick(); + CHECK(mark_at("published-revision", old_revision_start), + "nested clear was rejected and the published revision remains"); + CHECK(!mark_at("staged-revision", old_revision_start), + "nested publish was rejected and staged edits remain private"); + + amy_add_message("zQ8,3,4Z"); + uint32_t new_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the newly published target group starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("staged-revision", new_revision_start), + "the rejected nested publish did not discard staged edits"); +} + +static void test_group_stop_control_is_a_supported_leaf(void) { + printf("a group payload may stop an existing group execution\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,1,0,8zPmust-be-stoppedZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,7zQ8,0,0,0,55Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t boundary = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 0, 4, 55, true), + "the target execution is queued"); + CHECK(sequencer_group_control(7, SEQUENCE_CONTROL_START, 1, 4, 0, false), + "the stopping group is queued on the same boundary"); + clock_to(boundary); + CHECK(!mark_at("must-be-stopped", boundary), + "the leaf stop takes effect before ordinary events on that tick"); +} + +static void test_invalid_edits_are_repairable(void) { + printf("invalid definitions fail without losing staged edits\n"); + sequencer_reset(); + clear_group(5); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 0, 0, 0, false), + "zero-length publication is rejected"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, NULL), + "a NULL wire is rejected safely"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, strdup("H0zPnestedZ")), + "a second ticks command is rejected"); + + CHECK(sequencer_group_add_wire(3, 2, 0, 5, strdup("zPbad-periodZ")), + "an invalid-period edit can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= period"); + CHECK(sequencer_group_add_wire(1, 2, 0, 5, strdup("zPrepairedZ")), + "the invalid staged event can be replaced"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "the repaired definition publishes"); + + CHECK(sequencer_group_add_wire(4, 0, 1, 5, strdup("zPtoo-lateZ")), + "an out-of-length event can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= group length"); + CHECK(sequencer_group_add_wire(0, 0, 1, 5, strdup("")), + "the invalid local tag can be cleared"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication succeeds after clearing the invalid tag"); + + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publishing without new edits clones the published definition"); + clear_marks(); + uint32_t cloned_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the cloned definition can be started"); + sequencer_midi_clock_tick(); + CHECK(mark_at("repaired", cloned_start + 1), + "the cloned definition retains its event wire"); + sequencer_reset(); + + CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), + "an unknown lifecycle action is rejected"); + CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "reserved group zero is rejected by group control"); + CHECK(!sequencer_group_control(9, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "a group beyond the configured range is rejected"); + clear_group(6); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "start without a published definition is rejected"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_GATE, 1, 0, 0, false), + "gate with no active execution reports no affected execution"); +} + +static void test_clear_preserves_active_revision(void) { + printf("clearing storage does not invalidate an active revision\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPactive-after-clearZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the execution starts before storage is cleared"); + clear_group(6); + sequencer_midi_clock_tick(); + CHECK(mark_at("active-after-clear", start), + "an active execution retains its published revision"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "clear prevents future starts until another publication"); +} + +static void test_resets_keep_definitions_only(void) { + printf("sequencer and timebase resets stop executions but keep definitions\n"); + sequencer_reset(); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPsurvivorZ"); + amy_add_message("zQ8,3,4Z"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,0,4Z"); + clock_to(first); + CHECK(mark_at("survivor", first), "definition runs before reset"); + + clear_marks(); + sequencer_reset(); + clock_to(first + 4); + CHECK(!marks_named("survivor"), "RESET_SEQUENCER stops active executions"); + uint32_t second = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,1,4Z"); + clock_to(second); + CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); + + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + sequencer_midi_clock_tick(); + amy_add_message("S4096Z"); + amy_execute_deltas(); + clear_marks(); + clock_to(sequencer_ticks() + 4); + CHECK(!marks_named("survivor"), + "the public RESET_SEQUENCER wire stops group executions"); + amy_add_message("zQ8,1,1,0Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named("survivor") == 1, + "the public RESET_SEQUENCER wire preserves definitions"); + + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + clock_to(sequencer_ticks() + 2); + sequencer_group_reset_timebase(); + clear_marks(); + uint32_t after_reset = sequencer_ticks() + 4; + clock_to(after_reset); + CHECK(!marks_named("survivor"), "RESET_TIMEBASE stops active executions"); + amy_add_message("zQ8,1,1,0Z"); + clock_to(sequencer_ticks() + 2); + CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); +} + +static void test_group_start_crosses_clock_rollover(void) { + printf("group phase remains correct across the 32-bit tick rollover\n"); + sequencer_reset(); + clear_group(5); + clear_marks(); + amy_add_message("H0,4,0,5zPwrap-zeroZ"); + amy_add_message("H1,0,1,5zPwrap-oneZ"); + amy_add_message("zQ5,3,4Z"); + + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("zQ5,1,1,4Z"); + clock_to(1); + CHECK(mark_at("wrap-zero", 0), + "quantized local tick zero fired after rollover"); + CHECK(mark_at("wrap-one", 1), + "local elapsed time advanced across rollover"); +} + +static void test_configured_bounds(void) { + printf("configured group, local-tag and execution bounds are enforced\n"); + sequencer_reset(); + clear_group(8); + char *valid = strdup("zPlastZ"); + char *bad_group = strdup("zPbad-groupZ"); + char *bad_tag = strdup("zPbad-tagZ"); + CHECK(sequencer_group_add_wire(0, 4, 7, 8, valid), + "last configured group and local tag are valid"); + CHECK(!sequencer_group_add_wire(0, 4, 0, 9, bad_group), + "first group past the configured range is rejected"); + CHECK(!sequencer_group_add_wire(0, 4, 8, 8, bad_tag), + "first local tag past the configured range is rejected"); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "last group publishes"); + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + i, true), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + 8, true), + "one execution beyond the configured pool is rejected"); + clear_marks(); + uint32_t start = next_boundary(sequencer_ticks(), 64); + clock_to(start); + CHECK(marks_named_at("last", start) == 8, + "a rejected ninth start does not disturb the eight queued executions"); + clock_to(start + 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "completed one-shots return their execution slots to the pool"); + sequencer_reset(); +} + +static void test_disabled_configuration(void) { + printf("zero capacities disable sequencer groups safely\n"); + const uint32_t capacities[][3] = { + {0, 8, 8}, + {8, 0, 8}, + {8, 8, 0}, + }; + 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_sequence_groups = capacities[i][0]; + config.max_sequence_group_tags = capacities[i][1]; + config.max_sequence_group_executions = capacities[i][2]; + amy_start(config); + CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), + "group storage is disabled when capacity set %zu contains zero", + i + 1); + CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "group control is disabled when capacity set %zu contains zero", + i + 1); + 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_sequence_groups = 8; + config.max_sequence_group_tags = 8; + config.max_sequence_group_executions = 8; + amy_start(config); + + test_legacy_ticks_are_unchanged(); + test_legacy_c_event_wire_is_unchanged(); + test_group_local_tags_are_independent(); + test_one_n_and_infinite_repeats(); + test_atomic_revision_lifetime(); + test_root_launches_local_zero_on_same_tick(); + test_direct_start_begins_on_next_tick(); + test_tagged_start_replaces_at_activation(); + test_c_event_uses_fourth_ticks_field(); + test_quantized_gate_preserves_phase(); + test_quantized_stop_precedes_boundary_event(); + test_tagged_gate_and_stop_are_selective(); + test_tagged_control_does_not_select_untagged_execution(); + test_group_lifecycle_control_is_not_recursive(); + test_group_stop_control_is_a_supported_leaf(); + test_invalid_edits_are_repairable(); + test_clear_preserves_active_revision(); + test_resets_keep_definitions_only(); + test_group_start_crosses_clock_rollover(); + test_configured_bounds(); + + amy_stop(); + test_disabled_configuration(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequencer group checks passed\n"); + return 0; +}