From 4fdb45e2b1d9b0cc27e7bed7a21827f69332d4cf Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 13:57:13 +0100 Subject: [PATCH] feat(stdlib): @pixi/sound binding (bindings #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds stdlib/PixiSound.affine — an opaque `Sound` extern type plus `pixiSoundFrom` / `play` / `stop` / `pause` / `resume` / `setVolume` / `setLoop`, the surface idaptik's `src/bindings/PixiSound.res` consumes for BGM + SFX. Lowers on the Deno-ESM backend to `globalThis.__as_pixi_sound`, which the consumer (or its host wrapper) populates with `@pixi/sound`'s `Sound` named export once at module-init time. Pattern matches the Motion binding (#4, PR #422) and the WASM-exports binding (#5, also in #422). Test fixture: tests/codegen-deno/pixisound_smoke.{affine,harness.mjs}. The harness installs a MockSound class at globalThis.__as_pixi_sound, drives each smoke wrapper, and asserts URL pass-through, transport call counts, and volume/loop field updates. Roadmap row #2 in docs/bindings-roadmap.adoc moves from `○` to `◐` scaffold with the initial surface + follow-up list (`Sound.add` for multi-source registry, sprite atlases, async load, `pauseAll` / `stopAll` / `resumeAll`, `sound` singleton filter pipeline). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/bindings-roadmap.adoc | 6 +- lib/codegen_deno.ml | 23 ++++++ stdlib/PixiSound.affine | 73 +++++++++++++++++++ tests/codegen-deno/pixisound_smoke.affine | 29 ++++++++ .../codegen-deno/pixisound_smoke.harness.mjs | 67 +++++++++++++++++ 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 stdlib/PixiSound.affine create mode 100644 tests/codegen-deno/pixisound_smoke.affine create mode 100644 tests/codegen-deno/pixisound_smoke.harness.mjs diff --git a/docs/bindings-roadmap.adoc b/docs/bindings-roadmap.adoc index 6b80460..e9a2e83 100644 --- a/docs/bindings-roadmap.adoc +++ b/docs/bindings-roadmap.adoc @@ -55,9 +55,9 @@ no further significant ReScript → AffineScript work is tractable. |2 |*@pixi/sound* (`Sound.from`, play, stop, volume, setVolume, loop) -|`○` -|`affinescript-pixijs` sub-module, or `affinescript-pixi-sound` -|idaptik `src/bindings/PixiSound.res`; BGM + SFX. +|`◐` scaffold (from / play / stop / pause / resume / setVolume / setLoop landed) +|`stdlib/PixiSound.affine` (eventual home: `affinescript-pixijs` sub-module, or `affinescript-pixi-sound`) +|idaptik `src/bindings/PixiSound.res`; BGM + SFX. Initial surface (`pixiSoundFrom` / `pixiSoundPlay` / `pixiSoundStop` / `pixiSoundPause` / `pixiSoundResume` / `pixiSoundSetVolume` / `pixiSoundSetLoop`) lands in `stdlib/` parallel to Motion / Http / Sqlite / Crypto; consumer provides `globalThis.__as_pixi_sound` (the `Sound` named export from `@pixi/sound`) at module-init time. Test fixture: `tests/codegen-deno/pixisound_smoke.{affine,harness.mjs}`. Follow-ups: `Sound.add` (multi-source registry), sprite atlases, async load (await + onload), `pauseAll` / `stopAll` / `resumeAll`, the `sound` singleton's filter pipeline. |3 |*@pixi/ui* (Button, FancyButton, Switch, Slider, Input, ScrollBox) diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index dbbb604..55deb8d 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -242,6 +242,21 @@ const __as_pixiUiSliderAsContainer = (s) => s; const __as_pixiUiSwitchNew = (options) => new globalThis.__as_pixi_ui.Switch(options); const __as_pixiUiSwitchOnChange = (sw, cb) => { sw.onChange.connect(cb); return 0; }; const __as_pixiUiSwitchAsContainer = (sw) => sw; +// ---- @pixi/sound (bindings #2): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi_sound (the `Sound` +// named export from `@pixi/sound`). Tests set it in the harness before +// importing the generated module; production consumers typically do +// `import { Sound } from "@pixi/sound"; globalThis.__as_pixi_sound = Sound;` +// once at module-init time. The AffineScript-side externs +// (stdlib/PixiSound.affine) don't see this indirection — they call +// __as_pixiSound* helpers directly. +const __as_pixiSoundFrom = (url) => globalThis.__as_pixi_sound.from(url); +const __as_pixiSoundPlay = (s) => { s.play(); return 0; }; +const __as_pixiSoundStop = (s) => { s.stop(); return 0; }; +const __as_pixiSoundPause = (s) => { s.pause(); return 0; }; +const __as_pixiSoundResume = (s) => { s.resume(); return 0; }; +const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; +const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; // `++` is overloaded (string concat / array concat); `a + b` would // stringify arrays. Dispatch on shape so stdlib/string.affine's // `result ++ [x]` and `a ++ b` are both correct. @@ -434,6 +449,14 @@ let () = b "motionTween" (fun a -> Printf.sprintf "__as_motionTween(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)); b "motionSpring" (fun a -> Printf.sprintf "__as_motionSpring(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); b "motionEase" (fun a -> Printf.sprintf "__as_motionEase(%s)" (arg 0 a)); + (* ---- @pixi/sound (bindings #2) ---- *) + b "pixiSoundFrom" (fun a -> Printf.sprintf "__as_pixiSoundFrom(%s)" (arg 0 a)); + b "pixiSoundPlay" (fun a -> Printf.sprintf "__as_pixiSoundPlay(%s)" (arg 0 a)); + b "pixiSoundStop" (fun a -> Printf.sprintf "__as_pixiSoundStop(%s)" (arg 0 a)); + b "pixiSoundPause" (fun a -> Printf.sprintf "__as_pixiSoundPause(%s)" (arg 0 a)); + b "pixiSoundResume" (fun a -> Printf.sprintf "__as_pixiSoundResume(%s)" (arg 0 a)); + b "pixiSoundSetVolume" (fun a -> Printf.sprintf "__as_pixiSoundSetVolume(%s, %s)" (arg 0 a) (arg 1 a)); + b "pixiSoundSetLoop" (fun a -> Printf.sprintf "__as_pixiSoundSetLoop(%s, %s)" (arg 0 a) (arg 1 a)); (* Generic JS array push helper (returns the array, fluent). *) b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a)); (* ---- honest string/number primitives underpinning the diff --git a/stdlib/PixiSound.affine b/stdlib/PixiSound.affine new file mode 100644 index 0000000..08c5f6d --- /dev/null +++ b/stdlib/PixiSound.affine @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// PixiSound.affine — bindings for the `@pixi/sound` npm library +// (bindings #2 in docs/bindings-roadmap.adoc). +// +// Provides a typed surface over `@pixi/sound`'s `Sound.from()` factory +// plus the basic transport operations (play / stop / pause / resume, +// volume + loop control). Targets the Deno-ESM backend; the consumer +// (or its host wrapper) is responsible for putting the `Sound` class +// from `@pixi/sound` at `globalThis.__as_pixi_sound` before any +// generated module that uses these externs runs. The test harness +// pattern is in `tests/codegen-deno/pixisound_smoke.harness.mjs`. +// +// This file lives in `stdlib/` for parity with Motion / Http / Sqlite +// / Crypto. The dedicated `affinescript-pixijs` sub-module / standalone +// `affinescript-pixi-sound` package home flagged in the bindings +// roadmap is the long-term destination; the migration from here to +// there is additive and source-compatible. +// +// Surface coverage in this version: `Sound.from`, play, stop, pause, +// resume, setVolume, setLoop — the same surface idaptik's +// `src/bindings/PixiSound.res` consumes. +// Follow-ups (deferred): `Sound.add` (multi-source registry), sprite +// atlases (multi-segment audio with named ranges), async load +// (`await Sound.from(...)` with onload Promise), `pauseAll` / +// `stopAll` / `resumeAll`, the `sound` singleton's filter pipeline. +// Status row in `docs/bindings-roadmap.adoc` (#2) updates with each +// coverage tranche. + +module PixiSound; + +// Opaque handle to a `@pixi/sound` `Sound` instance. The underlying +// value is the `Sound` object produced by `Sound.from(url)`; treated +// opaquely at the AS boundary. +pub extern type Sound; + +/// `Sound.from(url) -> Sound`. The argument is a URL or path; the +/// host library is responsible for resolving + decoding it. Returns +/// the constructed `Sound` handle synchronously; the underlying load +/// may be in-flight (a `.play()` queued before the asset is ready is +/// a documented `@pixi/sound` use-case — it autoplays on completion). +pub extern fn pixiSoundFrom(url: String) -> Sound; + +/// `sound.play()` — start (or resume) playback from the beginning. +/// Returns 0. `@pixi/sound`'s real `play` accepts an options object +/// (volume / loop / sprite / completion callback); this minimal +/// surface ignores it. Use `pixiSoundSetVolume` / `pixiSoundSetLoop` +/// beforehand for the common cases. +pub extern fn pixiSoundPlay(s: Sound) -> Int; + +/// `sound.stop()` — stop playback and reset the play head. +/// Returns 0. +pub extern fn pixiSoundStop(s: Sound) -> Int; + +/// `sound.pause()` — pause playback at the current position. +/// Returns 0. Use `pixiSoundResume` to continue from the same point. +pub extern fn pixiSoundPause(s: Sound) -> Int; + +/// `sound.resume()` — resume playback from a paused position. +/// Returns 0. A `resume` on a non-paused sound is a no-op. +pub extern fn pixiSoundResume(s: Sound) -> Int; + +/// `sound.volume = vol` — set playback volume in the conventional +/// `[0.0, 1.0]` range (1.0 = full, 0.0 = silent). Values outside the +/// range are accepted by `@pixi/sound` (the underlying Web Audio API +/// gain node will clip), but consumers should treat that as out-of- +/// contract. Returns 0. +pub extern fn pixiSoundSetVolume(s: Sound, vol: Float) -> Int; + +/// `sound.loop = loop` — enable or disable looping. When `true`, +/// playback restarts from the beginning on completion. Returns 0. +pub extern fn pixiSoundSetLoop(s: Sound, loop: Bool) -> Int; diff --git a/tests/codegen-deno/pixisound_smoke.affine b/tests/codegen-deno/pixisound_smoke.affine new file mode 100644 index 0000000..20e9b9f --- /dev/null +++ b/tests/codegen-deno/pixisound_smoke.affine @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// bindings #2 — @pixi/sound smoke test. +// +// The harness installs a mock at globalThis.__as_pixi_sound before +// importing the generated module; we re-export thin wrappers so the +// harness can drive each extern via stable names. + +use PixiSound::{Sound, pixiSoundFrom, pixiSoundPlay, pixiSoundStop, pixiSoundPause, pixiSoundResume, pixiSoundSetVolume, pixiSoundSetLoop}; + +pub fn smokeFrom(url: String) -> Sound = + pixiSoundFrom(url); + +pub fn smokePlay(s: Sound) -> Int = + pixiSoundPlay(s); + +pub fn smokeStop(s: Sound) -> Int = + pixiSoundStop(s); + +pub fn smokePause(s: Sound) -> Int = + pixiSoundPause(s); + +pub fn smokeResume(s: Sound) -> Int = + pixiSoundResume(s); + +pub fn smokeSetVolume(s: Sound, vol: Float) -> Int = + pixiSoundSetVolume(s, vol); + +pub fn smokeSetLoop(s: Sound, loop: Bool) -> Int = + pixiSoundSetLoop(s, loop); diff --git a/tests/codegen-deno/pixisound_smoke.harness.mjs b/tests/codegen-deno/pixisound_smoke.harness.mjs new file mode 100644 index 0000000..402ecf4 --- /dev/null +++ b/tests/codegen-deno/pixisound_smoke.harness.mjs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// bindings #2 — Node ESM harness for the @pixi/sound binding. +// +// Installs a globalThis.__as_pixi_sound mock before importing the +// generated module, drives each smoke* wrapper, and asserts the side +// effects (URL forwarded, transport calls counted, volume/loop set) +// were observed on the underlying mock sound instance. + +import assert from "node:assert/strict"; + +class MockSound { + constructor(url) { + this.url = url; + this.playCount = 0; + this.stopCount = 0; + this.pauseCount = 0; + this.resumeCount = 0; + this.volume = 1.0; + this.loop = false; + } + play() { this.playCount += 1; } + stop() { this.stopCount += 1; } + pause() { this.pauseCount += 1; } + resume() { this.resumeCount += 1; } +} + +let lastFromUrl = null; + +globalThis.__as_pixi_sound = { + from(url) { + lastFromUrl = url; + return new MockSound(url); + }, +}; + +const { + smokeFrom, smokePlay, smokeStop, smokePause, smokeResume, + smokeSetVolume, smokeSetLoop, +} = await import("./pixisound_smoke.deno.js"); + +const s = smokeFrom("assets/bgm.mp3"); +assert.equal(lastFromUrl, "assets/bgm.mp3", "from URL reaches host"); +assert.equal(s.url, "assets/bgm.mp3", "constructed sound carries url"); + +assert.equal(smokePlay(s), 0, "play returns 0"); +assert.equal(smokePlay(s), 0, "play returns 0 (twice)"); +assert.equal(s.playCount, 2, "play invoked twice"); + +assert.equal(smokeStop(s), 0, "stop returns 0"); +assert.equal(s.stopCount, 1, "stop invoked once"); + +assert.equal(smokePause(s), 0, "pause returns 0"); +assert.equal(s.pauseCount, 1, "pause invoked once"); + +assert.equal(smokeResume(s), 0, "resume returns 0"); +assert.equal(s.resumeCount, 1, "resume invoked once"); + +assert.equal(smokeSetVolume(s, 0.5), 0, "setVolume returns 0"); +assert.equal(s.volume, 0.5, "volume field updated"); + +assert.equal(smokeSetLoop(s, true), 0, "setLoop returns 0"); +assert.equal(s.loop, true, "loop field updated"); + +assert.equal(smokeSetLoop(s, false), 0, "setLoop(false) returns 0"); +assert.equal(s.loop, false, "loop field cleared"); + +console.log("pixisound_smoke.harness.mjs OK");