dsp

    Numeric kernel of atools: FFT, STFT codec and phase reconstruction, compiled to WASM for direct browser use

    wasm
    fft
    stft
    phase-retrieval
    simd
    Download zip
    Author
    Version
    0.1.0
    License
    GPL-3.0
    Last updated
    15 hours ago
    Downloads
    1

    #dsp — the numeric kernel of atools

    MoonBit source for the single numeric implementation behind the audio ↔ spectrogram tool. It is compiled to moon/_build/wasm/release/build/dsp.wasm; app/lib/dsp.ts imports that file as an asset (Bun's .wasm loader hands back a path string, not a URL) and both threads of the web app load it (worker + main). There is no JavaScript fallback: if a host cannot load this module, it must fail loudly rather than silently switch to a second implementation nobody maintains.

    moon/ source (this directory) → scripts/moon.ts → moon/_build/…/dsp.wasm app/lib/dsp.ts host loader: kernelUrl() folds the asset path to an absolute URL, then fetch, handshake, typed-view slicing

    #Why a kernel at all

    1. One implementation. The host keeps only the glue: it moves bytes across the boundary. Every numeric loop that used to exist twice (once in TS, once here) is gone.
    2. Hot loops are written with unsafe_get / unsafe_set. arr[i] compiles to two non-inlined calls (check_range + array_length); in a butterfly with 8 accesses that made the same algorithm 7× slower than the JS version. After switching to the unsafe accessors it is 1.5× faster (see docs/algorithms.md).
    3. No imports, no extern. moon.pkg allows only the standard library, so the same source type-checks for wasm, js and native (bun run moon:ports).

    #Standard library first

    A hand-rolled helper looks harmless at this size, which is exactly why the rule is written down: if the standard library has it, call it. log2 replaced doubling loops in rtisi_open and plan_of; clamp / min / max come from Double / Int through the prelude. rtisi_wbtest.mbt pins @math.log2 as the exact step count on every reachable window length — that equality is what makes the swap sound, and it deliberately does not hold off the ladder (3000 → 11.55 against 12).

    Two helpers stay hand-written, each with its measurement in docs/algorithms.md: the counting sort in pghi.mbt (Array::sort is documented unstable, and the visit order is the result) and sqrt(re² + im²) in rtisi.mbt (@math.hypot differs by 1 ulp on about a third of the plane, and this feeds the phase estimate).

    using @math {…} imports functions only. A pub const cannot come along — using @math{log2, PI} is a hard Error 0002 under --deny-warn — so @math.PI stays qualified everywhere; that is a compiler fact, not a style choice. An imported name also loses to a local of the same name, so plan.mbt (which declares let cos) could not import cos even if it wanted to. Today only rtisi.mbt qualifies: its whole @math surface is importable and collision-free.

    #Three kinds of memory

    LayerLifetimeWhereNotes
    Plan tablesprocess, per window sizeplan.mbtread-only: bit-reversal, twiddles, Hann window. Cached by win, never moved.
    Session slotsone long computationsession.mbtfixed-size workspace + the plan tables it binds to. Pool of max_slots (6) — you must return it.
    Job arenasone jobarena.mbta job owns its arrays: one Double buffer + one Byte segment, addressed by a handle.

    A job arena exists because memory.grow detaches every typed view the host previously sliced. Views therefore must never be held: dsp_job_d / dsp_job_b / dsp_slot_mem / dsp_plan_* hand out the array now, and the host re-slices after every await.

    #Layout

    FileRole
    engine.mbtABI version + the boot-time probe that proves the host/guest addressing convention
    plan.mbtper-window tables: bit reversal, twiddles, Hann window
    session.mbtsession slot pool
    pair.mbttwo real transforms packed into one complex transform (halves the FFT count of RTISI)
    fft.mbtcomplex FFT (dsp_fft, plus inverse for tests) and real_ifft — the one-sided → conjugate-symmetric inverse that the host's exact path and RTISI's inner loop both call
    arena.mbtjob arenas (variable-size, one per job)
    rtisi.mbtRTISI-LA phase reconstruction (the whole iterative loop)
    pghi.mbtPGHI phase initialisation: the magnitude spectrum in, a phase guess out
    stub.mbtthe barcode "stub" at the bottom of a spectrogram: pure integer encode/decode

    Tests live next to the sources: *_wbtest.mbt are white-box tests (bun run test:kernel), *_bench_wbtest.mbt are kernels-side benchmarks (bun run bench:kernel).

    #Host boundary

    Exactly one convention crosses the language boundary, and it is not documented behaviour of the toolchain:

    When an exported function returns a FixedArray, the integer the host receives is the address of its data area. The reverse does not hold — a Float64Array passed in from the host arrives as a bare i32 and will read out of bounds.

    loadDsp therefore performs a two-way handshake at boot (dsp_probe_*): the kernel writes a pattern and the host reads it back through two views, then the host writes and the kernel reads back. If the toolchain ever changes the layout, this fails at startup instead of producing plausible-but-wrong numbers later. The guest reports element offsets, never addresses; the host adds the base in exactly one place (app/lib/dsp.ts's jobSlice / jobBytes).

    Failure is expressed in return values, never by throwing across the boundary:

    • 0 from an *_open / *_fits / *_paint / *_decode call = the request was rejected (pool full, size illegal, table too short). The host must not proceed.
    • -1 from an offset accessor = invalid slot / handle.
    • dsp_real_ifft returns 1 when it ran, 0 when it refused.

    Exports are grouped by layer: dsp_abi · dsp_probe_* · dsp_plan_* · dsp_slot_* · dsp_pair_* · dsp_fft · dsp_real_ifft · dsp_job_* · dsp_pghi_* · dsp_rtisi_* · dsp_stub_*. That list is the whole surface, and it is checkable rather than remembered: WebAssembly.Module.exports on the built module and the Kernel interface in app/lib/dsp.ts agree one for one. dsp_* names are the ABI (fixed) while the MoonBit identifier may be named differently (e.g. dsp_job_wordsjob_words_of).

    The surface is the ABI: dsp_abi is 7. Anything the host never calls stays unexported — inverse, pair_forward and pair_inverse are pub only within the package and carry no #export_name, so that attribute is what separates "ABI" from "entry point for our own tests": inverse is the oracle real_ifft is compared against, and RTISI calls the pair_* pair. A grep for dsp_ in app/ and bench/ is therefore the list.

    #Build and verify

    bun run build:wasm # compile this directory → moon/_build/…/dsp.wasm (--force = full rebuild) bun run test:kernel # white-box tests: moon test --release --deny-warn --target wasm bun run bench:kernel # kernel benchmarks: moon bench --release --deny-warn --target wasm bun run moon:ports # moon check --target js / native (proves "standard library only")

    --deny-warn is part of the contract: a warning is a piece of code nobody understood. Nothing is tolerated today, so moon.pkg carries no warn_list; if something ever has to be, it goes there as a decision somebody took rather than a silent debt.

    moon fmt is the formatter of record, the same way it is in ~/.moon/lib/core: blocks are separated by ///|, the order of blocks does not matter, and a formatting pass is therefore safe to run at any point.

    It does strip redundant parentheses, so "this association order matches the reference" cannot be said with brackets — and this directory carries no comments at all (///| is moon fmt's block separator, not prose), so it cannot be said that way either. A constraint that has to survive a reformat has to be expressed as code or pinned by a test. Note that the tables are not bit-identical to a JS recomputation anyway: @math.cos and V8's Math.cos disagree in the last bit on roughly 4% of the points, which is measured in docs/algorithms.md and is far below the precision the encoders store.

    #Adding a module

    The migration recipe, in this order:

    1. Measure the case, then A/B it in the same round. Keep the retired implementation in a scratch directory (never in the repo), drive both with the same input in the same process, and compare bits before times. A number rescaled from another measurement is not an A/B: two entries in docs/algorithms.md were first written that way and both were wrong (PGHI recorded as "twice as slow as the JS"; resample's blocker recorded as its phase table).
    2. Move the implementation here; the host keeps a single "move the data" call (one set).
    3. Move the behaviour tests into <module>_wbtest.mbt, and keep the property they assert rather than the numbers of the day (a memo table is bit-identical to computing inline; a table is not bit-identical to a JS recomputation — that one belongs in a measurement, not in an assertion).
    4. Leave the host with nothing numeric — no reference implementation, no fallback branch.
    5. Re-export through dsp_*, and bump dsp_abi if the export surface or its semantics changed.
    6. Before moving anything, check its domain against the segment caps (max_job_words, max_job_bytes). Those are sized for pixels; a module that works in the sample domain (resample wants src + dst in one segment, and a 96 kHz source reaches that cap at ~650 s) turns a large input into a hard failure. An arena is only a good home when the data already has to be there.

    abi

    fn abi() -> Int

    forward

    fn forward(s : Int) -> Unit

    inverse

    fn inverse(s : Int) -> Unit

    job_b

    fn job_b(h : Int) -> FixedArray[Byte]

    job_bytes_of

    fn job_bytes_of(h : Int) -> Int

    job_close

    fn job_close(h : Int) -> Unit

    job_d

    fn job_d(h : Int) -> FixedArray[Double]

    job_live_count

    fn job_live_count() -> Int

    job_open

    fn job_open(dwords : Int, bytes : Int) -> Int

    job_words_of

    fn job_words_of(h : Int) -> Int

    pair_forward

    fn pair_forward(s : Int) -> Unit

    pair_half_of

    fn pair_half_of(s : Int) -> Int

    pair_inverse

    fn pair_inverse(s : Int) -> Unit

    pair_off_of

    fn pair_off_of(s : Int, which : Int) -> Int

    pair_size_of

    fn pair_size_of(s : Int) -> Int

    pghi_close

    fn pghi_close(h : Int) -> Unit

    pghi_off

    fn pghi_off(h : Int, which : Int) -> Int

    pghi_open

    fn pghi_open(frames : Int, bins : Int, win : Int, hop : Int, gamma : Double, tol_hi : Double, tol_lo : Double) -> Int

    pghi_run

    fn pghi_run(h : Int) -> Int

    plan

    fn plan(win : Int) -> Int

    plan_cos_of

    fn plan_cos_of(i : Int) -> FixedArray[Double]

    plan_hann_of

    fn plan_hann_of(i : Int) -> FixedArray[Double]

    plan_rev_of

    fn plan_rev_of(i : Int) -> FixedArray[Int]

    plan_sin_of

    fn plan_sin_of(i : Int) -> FixedArray[Double]

    probe_check

    fn probe_check(h : Int, want_d : Int, want_b : Int) -> Int

    probe_stamp

    fn probe_stamp(h : Int) -> Int

    probe_want

    fn probe_want(which : Int) -> Int

    real_ifft

    fn real_ifft(s : Int, bins : Int) -> Int

    rtisi_close

    fn rtisi_close(h : Int) -> Unit

    rtisi_finish

    fn rtisi_finish(h : Int) -> Unit

    rtisi_levels

    fn rtisi_levels(h : Int, which : Int) -> Int

    rtisi_off

    fn rtisi_off(h : Int, which : Int) -> Int

    rtisi_open

    fn rtisi_open(frames : Int, bins : Int, win : Int, hop : Int, samples : Int, iters : Int, has_warm : Int, has_band : Int, budget : Double) -> Int

    rtisi_run

    fn rtisi_run(h : Int, from : Int, to : Int) -> Int

    slot_close

    fn slot_close(s : Int) -> Unit

    slot_mem_of

    fn slot_mem_of(s : Int) -> FixedArray[Double]

    slot_open

    fn slot_open(win : Int) -> Int

    slot_words_of

    fn slot_words_of(s : Int) -> Int

    stub_decode

    fn stub_decode(h : Int, n : Int) -> Int

    stub_decode_words

    fn stub_decode_words(n : Int) -> Int

    stub_fits

    fn stub_fits(w : Int) -> Int

    stub_luma

    fn stub_luma(h : Int, w : Int, sr : Int, win : Int, exact : Int) -> Int

    stub_luma_bytes

    fn stub_luma_bytes(w : Int) -> Int

    stub_paint

    fn stub_paint(h : Int, w : Int, sr : Int, win : Int, exact : Int) -> Int

    stub_paint_bytes

    fn stub_paint_bytes(w : Int) -> Int

    stub_rows_of

    fn stub_rows_of() -> Int

    stub_sr_at

    fn stub_sr_at(i : Int) -> Int

    stub_win_at

    fn stub_win_at(i : Int) -> Int