diff --git a/.dockerignore b/.dockerignore index c9e2b99..6fd6e9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,7 @@ coverage .env .env.* workproduct +.local +output +e2e/output +packages/*/dist diff --git a/.github/workflows/release-sdks.yml b/.github/workflows/release-sdks.yml new file mode 100644 index 0000000..446dfaf --- /dev/null +++ b/.github/workflows/release-sdks.yml @@ -0,0 +1,46 @@ +name: release SDKs + +on: + push: + tags: ['sdk-v*'] + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-sdk-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + NODE_OPTIONS: --max-old-space-size=6144 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test -- --maxWorkers=2 + - run: npm run build:sdks + - run: node scripts/generate-sdk-examples.mjs + - run: npm run test:sdks + - run: npm run build:modules -- --matrix + - uses: actions/upload-artifact@v4 + with: + name: sdk-${{ github.sha }} + path: .local/modularity/tarballs/*.tgz + include-hidden-files: true + if-no-files-found: error + - run: npm install -g npm@11 + - name: Publish validated artifacts in dependency order + run: node scripts/publish-sdks.mjs + - name: Install and verify the exact artifacts from npm + run: npm run test:sdks -- --registry diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 58992f1..0e5cf84 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -1,4 +1,4 @@ -# The verification chain agents run in Docker, run again on every push. +# The Docker verification chain runs for every pull request and main-branch push. # # It is deliberately the same list of commands, in the same order, minus `npm run e2e`: those # scripts drive a headless GPU Chrome on the developer's own machine and need the running services @@ -8,6 +8,7 @@ name: verify on: push: + branches: [main] pull_request: # The chain only reads the repository. The account's default for workflow tokens is read-only @@ -16,11 +17,22 @@ on: permissions: contents: read +# Feature revisions are checked by their pull request. Running their push as well +# leaves a cancelled required check when the two events share a concurrency group. +# Main keeps its own check after integration; only obsolete branch runs are replaced. +concurrency: + group: verify-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true + jobs: verify: runs-on: ubuntu-latest + env: + SOURCE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.SOURCE_REVISION }} - uses: actions/setup-node@v7 with: node-version: 22 @@ -36,3 +48,16 @@ jobs: - run: npm run build:web-sdk - run: npm pack --dry-run working-directory: packages/web-sdk + - run: npm run build:sdks + - run: node scripts/generate-sdk-examples.mjs + - run: npm run test:sdks + - run: npm run build:modules -- --matrix + - name: Keep the validated npm artifacts and distributions + uses: actions/upload-artifact@v4 + with: + name: paramrig-modular-${{ env.SOURCE_REVISION }} + path: | + .local/modularity/tarballs/*.tgz + .local/modularity/archives/*.tar.gz + include-hidden-files: true + if-no-files-found: error diff --git a/.gitignore b/.gitignore index ad8b0fc..6d0cd91 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ workproduct/lean-mvp/ .local/ output/ e2e/output/ + +# Scratch output of the controller catalogue check +.tmp-controllers.json diff --git a/CLAUDE.md b/CLAUDE.md index 06f1d71..b8f4369 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,14 @@ `docker compose`. Never run `npm run dev` or Vite directly on macOS. - The assigned port is 5174. If it is busy, identify the occupant instead of choosing another port. -- Before any `docker compose up`, list active Compose projects. If - `site-anym`, `stellary`, `helios`, or another development stack is already - running, warn the user and wait; never start both stacks or stop the other - project silently. `stellary-ci` may remain active. +- Another development stack may run alongside this one. The published ports do + not collide and Docker Desktop has the headroom; do not warn or wait over it. + Never stop another project silently. +- Before a 3D end-to-end campaign (`node e2e/campaign.mjs scene-`), list active + Compose projects and ask for any other development stack to be stopped: + `site-anym`, `stellary`, `helios`. GPU contention is what makes `scene-cut` + crash the renderer, and that campaign is the only thing here that needs an + idle machine. `stellary-ci` may remain active. - Run tests, typechecks, and one-off commands with `docker compose run --rm`, for example `docker compose run --rm app npm test`. Do not leave watch mode running unless asked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aef2ab1..c074291 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,9 @@ docker compose run --rm app npm run lint docker compose run --rm app npm run test:web-service # the connected-project service docker compose run --rm app npm run build docker compose run --rm app npm run build:web-sdk # the @paramrig/web package, and its check +docker compose run --rm app npm run build:sdks +docker compose run --rm app npm run test:sdks +docker compose run --rm app npm run build:modules -- --matrix ``` That list, in that order, is what `.github/workflows/verify.yml` runs on every push and pull @@ -44,8 +47,9 @@ what is now true, not labels. - `src/` — the workbench: library, editors, controls, the connected web workspace. - `services/web/` — the local service that reads and writes a connected project's `.paramrig`. -- `packages/web-sdk/` — the `@paramrig/web` package, built from `src/web`; its README is the - integration guide and is written for the agent maintaining the project being tuned. +- `packages/` — the public Core, Audio, Sound Labs, browser audio, Vector, Scene, Controls and Web + workspaces. Each README documents its standalone integration contract. +- `examples/sdk/` — executable consumers of the published packages, independent of the workbench. - `docs/` — how the parts work, and the record of each roadmap prompt and what it changed. - `e2e/` — browser checks that drive a real GPU Chrome on the developer's machine; they are not part of the public workflow and stay a local gate. @@ -55,8 +59,10 @@ what is now true, not labels. `@paramrig/web` is published by `.github/workflows/release-web.yml` when the maintainer pushes a `web-v` tag that matches `packages/web-sdk/package.json`. The same workflow writes the GitHub release afterwards, from the commits that touched the package since the previous tag. -Nothing else in the repository is published as a package; the workbench is deployed as a static -site from `npm run build:app`. +The other SDKs use the coordinated `sdk-v` workflow. Both release paths validate their +artifacts before publication. [Modular releases](docs/modular-releases.md) covers initial package +bootstrap, trusted publishers and registry verification. The workbench is deployed as a static +site from `npm run build:app`; selected-domain archives use `npm run build:modules`. ## Security diff --git a/README.md b/README.md index 82757e6..718cf69 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ docker compose run --rm app npm run build | Route | What is there | | --- | --- | | `/` | The library | +| `/audio`, `/vector`, `/3d` | Sound, drawing/layout and scene libraries | | `/r/contour-bloom`, `/r/tidal-planet` | Example rigs | | `/r/controller-lab` | Every controller family in one rig | | `/docs` | Documentation inside the app | @@ -71,6 +72,32 @@ docker compose run --rm app npm run build | `/web` | Connected web projects | | `/?fixture=empty`, `/?fixture=error`, `/?fixture=loading`, `/?fixture=long` | Library states for QA | +## Use one engine + +The repository is an npm workspace with independent public packages. Engines do not require the +ParamRig editor. React controls are optional; the 3D host supplies its own Three.js dependency. + +| Package | Integration guide | +| --- | --- | +| `@paramrig/core` | [Rig contracts, values and validation](packages/core/README.md) | +| `@paramrig/audio` | [Synthesis, blocks, WAV and wavetables](packages/audio/README.md) | +| `@paramrig/audio-labs` | [Generation, variation and fusion](packages/audio-labs/README.md) | +| `@paramrig/audio-browser` | [Independent Web Audio players](packages/audio-browser/README.md) | +| `@paramrig/vector` | [Drawing, layout, browser rendering and exports](packages/vector/README.md) | +| `@paramrig/scene` | [Scene engine, host renderer and optional viewer](packages/scene/README.md) | +| `@paramrig/controls` | [Controlled React inputs and explicit styles](packages/controls/README.md) | +| `@paramrig/web` | [Connected web projects](packages/web-sdk/README.md) | + +[Standalone examples](examples/sdk) install only the packages they use. The +[Vector + React example](examples/sdk/vector-controls) demonstrates optional controls alongside +an engine. Separate packages reduce installation; sub-entries select what a bundler loads. + +Build only the application domains you need with +`docker compose run --rm app npm run build:modules -- --modules=audio,vector`. +The normal build still produces the complete suite in `dist`. See +[module boundaries and build budgets](docs/modular-packages.md) and the +[release process](docs/modular-releases.md). + ## Tune a project you are building The Web workspace opens a page from your own development server beside the controls its agent @@ -120,16 +147,25 @@ Numbers, position and dimensions, color and appearance, choices, text and typogr profiles, resources, scene instruments, actions, collections, value sources and animation. [`docs/CONTROLLERS.md`](docs/CONTROLLERS.md) has the contracts and the limits. -### Two editors that carry their own controls +### Three editors that carry their own controls A document can be a drawing, or a drawing that carries a rig. Bind an element's property to a -parameter and the document becomes tunable without leaving it. +parameter and the document becomes tunable without leaving it. The same is true of a 3D scene and +of a sound. | Vector documents | 3D scenes | | --- | --- | | The vector editor on a poster document, one shape selected, and the poster's own controllers on the right | The 3D scene editor on the desk study, with its bindings and the scene's own controllers on the right | | Paths, networks and planar regions, text, frames, guides, boolean operations, export presets. Fills, strokes, effects and node positions can all be bound to a controller. | Objects, modifiers, lights, cameras, materials and world settings, with the same binding vocabulary. Edit and Tune share one WebGL context. | +**Sound effects.** Four layers — two oscillators, two noise generators — each with wavetables or +plain shapes, a filter of eight models, three insert slots and an amplifier envelope; eight +modulation slots that are each an envelope or an oscillator; three performers with a drawn row of +sixteen steps; three master effects. The engine is a pure function of a patch and a sample rate, +with no Web Audio in it, so what you hear while tuning and what lands in the exported file cannot +drift apart. [`/docs/audio-rigs`](https://app.paramrig.com/docs/audio-rigs) has the paths a control +can be bound to. + ### Timeline, snapshots, history Grouped tracks with zoom, keyframe selection and dragging, copy, paste and delete, precise time, diff --git a/compose.yml b/compose.yml index 6e87383..fc6eadd 100644 --- a/compose.yml +++ b/compose.yml @@ -11,7 +11,7 @@ services: sh -lc "npm install --package-lock=false --prefer-offline --no-audit --no-fund && exec ./node_modules/.bin/vite --host 0.0.0.0 --port 5174" init: true - restart: "no" + restart: unless-stopped ports: - "5174:5174" volumes: @@ -39,7 +39,7 @@ services: dockerfile: docker/dev.Dockerfile command: node --experimental-strip-types services/web/server.mjs init: true - restart: "no" + restart: unless-stopped # The feedback files belong to whoever runs the project, not to the image's own user. Docker # Desktop maps ownership for you; on Linux a bind mount keeps the container's numeric ids, so # export PARAMRIG_UID=$(id -u) and PARAMRIG_GID=$(id -g) there or the project's agent cannot diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile index 907747b..f0eb124 100644 --- a/docker/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -5,4 +5,12 @@ RUN chown -R node:node /workspace USER node COPY --chown=node:node package.json package-lock.json ./ +COPY --chown=node:node packages/core/package.json ./packages/core/package.json +COPY --chown=node:node packages/audio/package.json ./packages/audio/package.json +COPY --chown=node:node packages/audio-labs/package.json ./packages/audio-labs/package.json +COPY --chown=node:node packages/audio-browser/package.json ./packages/audio-browser/package.json +COPY --chown=node:node packages/vector/package.json ./packages/vector/package.json +COPY --chown=node:node packages/scene/package.json ./packages/scene/package.json +COPY --chown=node:node packages/controls/package.json ./packages/controls/package.json +COPY --chown=node:node packages/web-sdk/package.json ./packages/web-sdk/package.json RUN npm ci --no-audit --no-fund diff --git a/docs/ADDING-A-RIG.md b/docs/ADDING-A-RIG.md index f4cc9ab..9149d1b 100644 --- a/docs/ADDING-A-RIG.md +++ b/docs/ADDING-A-RIG.md @@ -81,3 +81,30 @@ library and in the navigation beside the example rigs with no registry entry to - The mesh a person edits is not a soup of triangles: `MeshData` keeps polygons with stable vertex and face ids, so a selection survives an edit that renumbers everything. `src/scene/types.ts` is the contract, `sanitizeSceneDocument` is what enforces it on the way in. + +## Audio documents + +A sound is the third kind of document the workbench stores itself. `createAudioDocument()` writes +one under `paramrig.audio-documents.v1` and `audioManifest(document)` presents it as a +`RigManifest` with `renderer: 'audio'`, so it appears in the library beside the others with no +registry entry to write. + +- **New sound** in the library titlebar makes one — a short blip, audible immediately — and + navigates to `/r/`. +- The engine is a pure function: `renderPatch(patch, sampleRate)` takes a patch and a rate and + returns two channels. There is no Web Audio in it and no clock, so it runs under Node — which is + what `scripts/audio-preview.mjs` and `scripts/audio-bench.mjs` use, and why the tests need no + browser. Playback, the waveform view and the exported file are all consumers of that one buffer, + so what you hear while tuning and what lands in the file cannot drift apart. +- `src/audio/fields.ts` is the single table four things agree on: the property parser, the + generated documentation at `/docs/audio-rigs`, the control built when a field is exposed, and the + clamp applied to a patch read back off disk. A field added there is a documented, bindable, + bounded field on the same commit. It imports nothing but types and relative `.ts` paths, which is + what keeps the Node scripts working without a build step. +- A patch carries the `version` it was written in, and `sanitizeAudioPatch` walks it forward + through `MIGRATIONS`. A rename that moved a field also names its old path in + `carryAudioProperty`, or the rig of every sound anybody built loses that binding in silence. +- The slots — a modulation slot, an insert slot, a master effect slot — are flat records carrying + `kind` plus the fields of every kind they could be. The engine reads only the active kind's, + which is what makes a slot changed to another kind and back the slot it was. +- A sound saved to disk is a `.paramrig.json` file marked `"format": "paramrig.audio"`. diff --git a/docs/audio-labs-sdk.md b/docs/audio-labs-sdk.md new file mode 100644 index 0000000..cd3bd63 --- /dev/null +++ b/docs/audio-labs-sdk.md @@ -0,0 +1,447 @@ +# Sound Labs discovery SDK + +The public headless entry point is `@paramrig/audio-labs`. It extends the audio +engine in `@paramrig/audio`. The application consumes the same implementation; +its palette, spectral visualization and playback interface remain application adapters. +See the [package guide](../packages/audio-labs/README.md) for installation. + +## General discovery + +Use `DEFAULT_DISCOVERY_CRITERIA` to opt into the general generator. The previous +`DEFAULT_CRITERIA` remains available for callers that need their existing recipes. + +```ts +import { + DEFAULT_DISCOVERY_CRITERIA, createLabBatch, describeLabRecipe, + labFamilyCatalog, validateLabRequest, type LabCriteria, +} from '@paramrig/audio-labs' + +const criteria: LabCriteria = { + ...DEFAULT_DISCOVERY_CRITERIA, // Any family, material and character + pool: { + families: ['water', 'percussion', 'notification', 'pad'], + materials: ['wood', 'glass', 'liquid'], + characters: ['acoustic', 'digital', 'ethereal'], + }, + diversity: 'wild', minMs: 300, maxMs: 1200, +} +const request = { mode: 'create' as const, criteria, seed: 171, count: 1 } +validateLabRequest(request, 48000) +const result = createLabBatch(request, 48000) +if (result.results[0]) { + const { sound, samples } = result.results[0] + const resolved = describeLabRecipe(sound.origin.recipe) +} +``` + +Pools are optional nonempty subsets of concrete IDs. The corresponding scalar +selection must be `any`. Ordering does not affect generation and duplicates do +not add weight. Import preserves display order while intent keys compare sets. +Any-family generation only selects families compatible with the chosen gesture. +Invalid combinations are rejected; stale imported gestures reset to Auto. + +### Catalog + +The SDK facade's `LAB_TYPES`, `LAB_MATERIALS`, `LAB_CHARACTERS` and `LAB_MOTIONS` +contain the full catalog. The similarly named constants in `model.ts` retain the +compact compatibility lists. Import the SDK facade for the full discovery catalog. + +There are 49 concrete families, plus Any: + +| Domain | Families | +| --- | --- | +| Effects | Growl, Impact, Transformation, Servo, Glitch, Pulse, Drone, Rise, Fall, Burst, Texture, Explosion | +| Foley | Whoosh, Scrape, Roll, Shake, Crush, Footstep, Engine, Spring, Creak, Tear, Pressure | +| Nature | Wind, Water, Fire, Rain, Ambience | +| Creatures | Creature, Chirp, Breath | +| Interface | Scan, Notification, Confirmation, Error, Alarm | +| Percussion | Kick, Snare, Hi-hat (`hat`), Percussion | +| Music | Bass, Lead, Pad, Pluck, Bell, Keys, Bowed, Wind instrument (`wind-instrument`), Choir | + +`labFamilyCatalog()` returns fresh records with IDs, labels, domains, descriptions, +supported constructions and whether a family is pitched. Returned records can be +edited without modifying generation definitions. + +Material has 14 concrete choices plus Any: Metal, Glass, Liquid, Air, Electrical, +Wood, Stone, Sand, Ice, Ceramic, Rubber, Fabric, Membrane and Fire. These describe +sonic matter, not a strict physical-material taxonomy. + +Character has 12 concrete choices plus Any: Mechanical, Futuristic, Organic, +Alien, Industrial, Acoustic, Analog, Digital, Retro, Ethereal, Corrupted and Clean. +`labMaterialCatalog()` and `labCharacterCatalog()` return their IDs and labels. + +Families do not prohibit materials or characters. A digital wood percussion or +organic notification is valid. Material colours a featured voice; character +changes stability, harmonicity, drive, quantisation, unison and spatial treatment. +In discovery, explicit texture uses a secondary voice so an airy bass keeps its +primary body. Every option is connected to synthesis behaviour. + +### Subtypes + +`subtype?: LabSubtype | 'auto'` targets a construction within a family. It is +independent of material and character: an electric motor can have a wooden body, +and a choir can be digital. `labSubtypeCatalog(family)` returns fresh metadata +with `id`, `family`, `label` and `description`, plus an optional `group` for the +detail collections below. Omit the family or use `any` to list all 78 subtypes. +`LAB_SUBTYPES` exposes their readonly IDs. + +| Family | Subtype IDs | +| --- | --- | +| Explosion | `blast`, `detonation`, `muffled-blast` | +| Engine | `combustion`, `turbine`, `electric-motor`, `ignition` | +| Spring | `boing`, `twang`, `coil` | +| Creak | `hinge`, `wood-stress`, `hull-stress` | +| Tear | `paper-rip`, `fabric-rip`, `metal-rip` | +| Pressure | `valve`, `steam`, `suction` | +| Bowed | `bowed-string`, `rubbed-glass`, `abrasive-bow` | +| Wind instrument | `flute`, `reed`, `brass` | +| Choir | `vowel-choir`, `robot-choir`, `whisper-choir` | +| Ambience | `thunder`, `insects` | +| Water | `surf`, `stream` | +| Percussion | `clap`, `cymbal`, `gong`, `tom`, `shaker` | +| Pad / Keys / Pluck | `organ` / `electric-piano` / `harp` | +| Transformation / Burst / Texture / Rise | `portal` / `spell` / `enchantment` / `apparition` | + +The following 34 additional subtypes extend those families. Groups are discovery +metadata, not extra top-level families or mutually exclusive synthesis engines. + +| Group | Subtypes and parent families | +| --- | --- | +| `electricity` | `electrical-arc`, `electric-discharge` (Burst); `short-circuit` (Glitch); `transformer-hum` (Drone) | +| `transmissions` | `radio-tuning`, `radio-squelch`, `coded-transmission` (Scan); `radio-static` (Texture) | +| `mechanisms` | `ticking` (Pulse); `small-ratchet`, `lock-mechanism` (Servo); `zipper` (Scrape); `switch-click` (Impact) | +| `soft-matter` | `paper-crumple` (Crush); `plastic-rustle` (Shake); `fabric-rustle` (Scrape); `leather-flex` (Creak) | +| `viscous` | `slime`, `mud`, `gurgle`, `bubble-pop` (Water); `suction-pop` (Pressure) | +| `animals` | `purr`, `croak`, `bark` (Creature); `wing-flap` (Whoosh) | +| `tuned-percussion` | `marimba`, `vibraphone`, `handpan` (Percussion); `kalimba` (Pluck) | +| `recording-media` | `vinyl`, `tape-hiss`, `cassette-wobble` (Texture); `digital-dropout` (Glitch) | + +Each detail has its own excitation and articulation: discharge decays, radio +opening/closing bursts, contact sequences, friction grains, wet cavity motion, +animal pulses, pitched strikes or recording artefacts. These remain seeded, +editable procedural designs. Radio does not generate intelligible messages; +cassette wobble creates an original modulated tone bed, not an effect applied +to an imported recording. Animal and material names denote stylized synthesis. + +```ts +const criteria: LabCriteria = { + ...DEFAULT_DISCOVERY_CRITERIA, + type: 'engine', subtype: 'turbine', + material: 'metal', character: 'acoustic', + motion: 'accelerating', minMs: 900, maxMs: 2200, +} +``` + +- An explicit subtype with `type: 'any'` resolves to its own family. A family + pool must contain that family; incompatible gestures are rejected. +- `subtype: 'auto'` explores the available subtypes and avoids recent ones when + alternatives exist. A family without subtypes keeps its ordinary construction. +- For compatibility, omitting `subtype` preserves existing discovery-v1 draws + on the original 40 families. The nine new families always draw a subtype. + Use explicit Auto to explore the new subtypes of Water, Percussion, etc. +- A family change should clear an incompatible subtype. `sanitizeCriteria` + performs this recovery for imports; direct generation rejects contradictions. +- Subtypes retain multiple primary constructions, random excitation, resonances + and accompaniment. They are not fixed presets with a pitch randomizer. + +Dedicated identity design precedes material and character treatment. Layer 1 +remains available for material/texture; another voice supplies defining details +such as exhaust, breath or fibres. Temporal contours shape pressure tails, +starting engines, settling springs and friction events. An explicit gesture +retains its event timing; intensity, mass, endings and exclusions still apply. +The fantasy subtypes combine existing synthesis techniques rather than adding +an undocumented engine. These are designed acoustic interpretations, not +recordings or calibrated physical simulations. + +### Diversity + +- `focused`: three core constructions per family, fewer layouts, and typical + character choices when character is Any. +- `balanced`: all constructions of the selected family and complementary layers. +- `wild`: retains the requested primary family, but permits secondary voices from + the full construction palette and uses up to four layers. + +The ten construction types are subtractive, FM, phase-modulation cascade, +wavetable, vocal/formant, tuned comb, modal resonance, noise, additive partials +and membrane excitation. These use existing DSP primitives; they are not ten +new DSP engines. Layouts include solo with detail, layering, responses, particles, +harmonic stacks, cascades and sustained beds. Additive designs use independent +partial layers. Cascades have real acyclic modulation dependencies. + +Structural choices precede continuous parameter variation. Free family searches +draw a domain before a family, so a large domain does not crowd out smaller ones. +Recent recipes avoid the last two primary constructions of a family when possible, +and recent layouts/domains where alternatives exist. There is no global random +state. Identical criteria, seed and ordered recent-recipe context reproduce the +same patch in the same JavaScript runtime. Pool order has no effect; history order does. +Transcendental math can differ in the last floating-point digits across CPU +architectures; regenerated hashes are not portable identifiers. Persist the +returned patch and identity metadata to replay the exact saved values. + +`describeLabRecipe()` decodes `discovery-v1` metadata into resolved family, +construction, layout, material, character and gesture. `discovery-v2` adds the +resolved subtype. `discovery-v3` adds an actual chord and voicing, uses the +`chord` layout, and stores `-` when no subtype was resolved. Invalid combinations +or unknown versions return `null`. +The sound's criteria retain the requested Auto/Any/pools; its recipe records +what was actually drawn. Imported patches are never regenerated. + +### Musical tuning + +`rootNote` is an integer MIDI note from 24 to 96. `scale` accepts `chromatic`, +`major`, `minor` or `pentatonic` (default). For example, use `type: 'bass', +rootNote: 45, scale: 'minor', gesture: 'pluck'` with discovery defaults. +An exact note and a non-Auto register range cannot both be requested. The primary +root is exact without random jitter; intentional sweeps can still move pitch. +Pitched accompaniment and arpeggiated phrases use scale degrees; additive partials +remain harmonics of their root. Chosen materials or characters can contribute +inharmonic partials. Without a root, pitched families +choose a note within their natural range. + +### Chords and voicings + +```ts +const criteria: LabCriteria = { + ...DEFAULT_DISCOVERY_CRITERIA, + type: 'percussion', subtype: 'vibraphone', + chord: 'minor', voicing: 'open', rootNote: 60, + material: 'glass', character: 'clean', + minMs: 1200, maxMs: 1800, +} +``` + +- `chord`: `none`, `auto`, `major`, `minor`, `sus2`, `sus4`, `dissonant`. + Omitted or `none` preserves existing single-sound generation. +- `voicing`: `auto`, `close`, `open`. Requires an enabled chord. Omitted means + Auto when a chord is enabled. +- `labChordCatalog()` returns fresh quality metadata and semitone intervals. + `chordIntervals(quality, voicing)` returns the three actual note intervals. + `LAB_CHORDS`, `LAB_VOICINGS` and `supportsChord(family, subtype?)` are public. +- Close triads are major `[0,4,7]`, minor `[0,3,7]`, sus2 `[0,2,7]`, sus4 + `[0,5,7]`, dissonant `[0,1,6]`. Open voicing keeps the root and fifth in place + and raises the middle note an octave, e.g. C minor becomes C–G–E-flat above. +- All nine Music families support chords, including their subtypes. Percussion + supports Marimba, Vibraphone and Handpan; omitted/Auto subtype chooses among + those three. Kalimba belongs to Pluck and also supports chords. +- Any family/pools are filtered to compatible musical candidates. Unsupported + explicit families/subtypes are rejected. Stale imports drop incompatible + harmony selections and reset incompatible gestures. +- Auto chooses from all five chord qualities when no scale is specified or the + scale is Chromatic. Explicit Major/Minor restrict Auto to that quality and + suspended chords; Pentatonic restricts it to Major/Sus2. An explicit quality + takes precedence over scale. Recent resolved qualities discourage repetition. +- Three independent pitched voices occupy layers 0, 2 and 3 and retain the + `body` role. Layer 1 carries material/texture. Density cannot remove a chord + tone; harmonic FM ratios and bounded detuning protect pitch clarity while the + texture voice can retain inharmonic colour. The root and intervals remain + stationary through scenario shaping; supported Strum/Arpeggiate gestures + sequence the requested notes rather than substituting unrelated scale tones. +- `register: 'full'` keeps its low root, but chord voicing takes precedence over + the ordinary spread of independent layers across registers. +- Variations preserve all three pitches. Fusion protects their body layers; + a simultaneous chord shares one contour and leaves slots for a contributor. + A contribution that needs more layers or modulation slots than remain is + rejected. It never silently replaces a chord note to make room. +- Chords whose upper voices exceed the render rate's oscillator limit are + rejected, rather than clamped into incorrect intervals. Use 48 kHz for high + roots and open voicings. Twenty milliseconds is supported as a short fragment, + not a guarantee that the whole chord quality is perceptible at that duration. + +### Runtime scope + +This is procedural synthesis. Foley, nature and creatures are synthesized +interpretations. Intelligible speech, exact recorded instruments, sample import +and sample-granular playback are not provided by this extension. + +The current patch engine supports four layers and 20–4000 ms per sound. Ambience +and Pad produce segments within this range. Long-form environments, guaranteed +seamless loops and MIDI note-on/note-off playback are separate capabilities. + +Seeds must be unsigned 32-bit integers. Sample rates must be integers from 8000 to +192000 Hz. Batches accept one to four candidates. Invalid requests are rejected +before DSP work: `createLabBatch` returns `{ results: [], issue }`; other strict +functions throw. Rendering is synchronous CPU work and belongs in the existing +worker/runner, which handles cancellation. + +Each rendered candidate is checked for finite samples, peak limits, minimum +audible level, mono retention and duration. Rejections have bounded retries and +may produce fewer results with an issue. These checks do not certify realism, +artistic quality or perceptual novelty; those need auditioning. +Quiet discovery designs may receive a common layer-level boost when master gain +alone cannot reach the preview level. This preserves layer balance and PM capture, +stays within editable gain limits, and is stored in the returned patch. Variations +do not receive this boost. Returned samples always correspond to the returned patch. + +## Original selection API + +```ts +import { + DEFAULT_CRITERIA, createLabBatch, labGestureOptions, labCriteriaKey, + sanitizeCriteria, validateLabCriteria, type LabCriteria, +} from '@paramrig/audio-labs' + +const criteria: LabCriteria = { + ...DEFAULT_CRITERIA, + type: 'transformation', material: 'metal', character: 'mechanical', + gesture: 'assemble-lock', register: 'low', texture: 'friction', + intensity: 0.65, density: 0.75, mass: 'heavy', ending: 'cut', + minMs: 700, maxMs: 1200, +} +validateLabCriteria(criteria) +const gestures = labGestureOptions(criteria.type) +const intent = labCriteriaKey(criteria) +// CPU work: call in the existing Labs worker, not on the browser's main thread. +const batch = createLabBatch({ mode: 'create', criteria, seed: 171, count: 1 }, 48000) +// batch.results contains the actual stereo samples and the measured visualisation. +// A nonempty issue describes an unsupported request or an unsuccessful audio check. +``` + +## Selections + +| Field | Values | Meaning | +| --- | --- | --- | +| `gesture` | `auto` or an ID from `labGestureOptions(type)` | Macro event structure: assembling and locking, charging and hitting, rebounds, etc. | +| `register` | `auto`, `low`, `mid`, `high`, `full` | Pitch and spectral anchors; `full` distributes voices across registers. | +| `texture` | `auto`, `vocal`, `buzz`, `friction`, `crackle`, `resonant`, `pure`, `airy`, `gritty`, `hollow`, `shimmer`, `rasp` | A featured texture voice, retaining the rest of the recipe's source graph. | +| `intensity` | `null` for Auto, or 0–1 | Gentle to aggressive attack, harmonic drive and timbral motion. Does not increase master gain. | +| `density` | `null` for Auto, or 0–1 | Sparse to dense events, detail balance and modulation activity. | +| `ending` | `auto`, `cut`, `fade`, `ring` | Short clean cutoff, progressive fade, or resonant decay. The entire tail is inside the requested duration. | +| `mass` | `light`, `balanced`, `heavy`, or omitted | Body/detail balance and persistence, independent of musical pitch. | +| `chord` | `none`, `auto`, `major`, `minor`, `sus2`, `sus4`, `dissonant` | Three musical notes, with a separate voice for material and texture. | +| `voicing` | `auto`, `close`, `open` | Compact or spread chord intervals, preserving the root. Requires a chord. | + +These gesture, register, texture, ending and amount fields are optional. Missing +categorical fields equal `auto`; missing intensity/density equal `null`. Zero is +an explicit choice, never Auto. For legacy inputs without discovery selections +(diversity, subtype, pools or tuning), automatic gesture controls and no explicit +mass preserve the previous v3 recipes, including their random draw sequence. +Curated signature demos continue using their frozen generation path. + +`weight` remains required for old callers and saved documents. When neither mass +nor register is selected, it retains its old pitch-weighting behaviour. New UI +controls should set `mass` explicitly (including `balanced`) and keep `weight` +at `balanced`. Explicit register also separates weight from transposition. + +## Family and duration compatibility + +`labGestureOptions(type)` returns fresh `{ id, label, minMs }` records. It covers +all 49 families, with 34 concrete gestures and Auto. New gestures include Rub, +Roll, Shake, Breathe, Flutter, Drip, Rattle, Strum, Pluck, Arpeggiate and Crumble. +Movement also includes Decelerating, Irregular and Alternating. +Disable choices whose minimum duration exceeds `maxMs` and +show the reason. On a family change, `sanitizeCriteria` can reset an incompatible +old gesture to Auto. Do not silently replace an explicitly requested gesture at +the generation boundary: `validateLabCriteria` and generation reject it. + +Durations use whole milliseconds, 20–4000. When only the lower bound is below a +gesture's useful minimum, generation samples between that minimum and `maxMs`; +the user's stored range remains unchanged. An unsupported entire range is an +error. Auto retains the existing short-click path. + +An explicit Gesture owns the large event structure, including layer timing. +Movement changes event spacing inside repetitive gestures without multiplying +them by a second pulse train. Single hits and charge/impact landmarks retain +their timing; subtype articulation cannot overwrite their amplitude contour. +With Gesture on Auto, a selected Movement owns the temporal contour: Accelerating +shortens event intervals, Decelerating lengthens them, Pulsed keeps regular +spacing, and Collapsing decays and darkens continuously. Continuous retains a +sustained body; Stuttering clusters events, Irregular varies their spacing, and +Alternating exchanges layer activity across the stereo field. Chords retain +their simultaneous notes and move their filters together. + +Density adjusts event count/activity within that structure rather than inventing +a new phrase on Auto/Natural. Layer offsets and slow gain modulation cannot +reintroduce an unrelated rhythm after movement selection. Resonator excitation +is compensated after duration fitting when movement sustains an originally +percussive source. Saved patches are replayed as stored, without regeneration. +Intensity changes timbre +and attack, not output volume. Existing exclusions have final precedence over +texture and ending; for example, resonant decay uses a short feedback delay +when reverb is excluded. Exclusions can constrain the strength of a requested +texture. These controls are synthesis directions, not guarantees that every +listener will assign the same perceptual label to every result. + +## Persistence, variation and history + +`sanitizeCriteria`, `sanitizeLabSound` and `sanitizeLabSession` preserve the new +fields through project, history, reserve, reference and snapshot round trips. +Storage remains additive version 1; imported patches are not regenerated. +Malformed stored amounts are clamped or reset to Auto. The strict request API +instead reports invalid values. + +Variations and fusion use the reference/principal's new search selections. +Changing them in those modes is rejected instead of relabelling an unchanged +body. Generate a new reference to change the intent. Existing macro locks, +variation strength and duration-variation semantics remain in effect. + +The Labs UI builds variation/fusion requests from the selected reference's intent, +with the visible duration range and allowed exclusions. The last Explore search +does not leak into these requests. `fusionCompatibility` runs the same layer and +modulation allocation as fusion, so missing contributions and capacity limits can +be shown before rendering. The planner tries alternative compatible groups, +retains PM dependencies, and shares identical modulation drivers when possible. +Attack can extract a short onset from a body-only contributor. + +On reload, role validation checks the stored patch before normalization adds +defaults or changes property order. Known legacy Labs sounds with lost roles are +classified from their saved layers without regenerating audio. Actual snapshot +edits carry `rolesInvalidated` through subsequent reloads; imported instruments +remain unclassified. + +When wiring the UI, filter recent generation metadata using `labCriteriaKey`. +It includes all new selections, duration and exclusions, unlike the earlier +six-field comparison. Pass the matched `recentRecipes` to the existing worker; +engine avoidance remains active under selected intent. Identical criteria, +seed and recent-recipe context reproduce the same patch within the same runtime. + +## UI integration still to do + +- Bind new controls to these fields and use the catalog's IDs, not display labels. +- Use the complete intent key when collecting recent recipes. +- Keep the criteria for the next generation separate from current-sound edits. +- Verify real interaction and auditioning once the UI is ready. + +The proposed Shape view's length/stereo/level manipulation is a separate +current-sound editing contract. This SDK addition does not implement those +Three.js gestures or their playback behaviour. + +## Verification + +- 122 SDK/audio/project/UI tests pass across seventeen suites, covering legacy + fingerprints, rendering, gesture compatibility, macros, protected references, + persistence, project storage, subtype diversity and musical harmony. +- All 735 family/material pairs render without first-pass rejection at 16 kHz, + with crossed character, gesture, texture, movement and ending settings. +- All 49 families cross all nine movements (441 renders), and all 78 subtypes + cross Accelerating and Collapsing (156 renders), without first-pass rejection. + Known synthetic pulses calibrate the audio onset probe before it checks + accelerating/decelerating intervals, regular pulses and continuous decays at + minimum, medium, maximum and automatic density. Explicit event landmarks, + subtype contour precedence and saved/varied timing are checked separately. +- All five chord qualities cross all nine movements (45 renders), including + preservation of continuous filter movement and collapse darkening on every note. +- All 49 families pass at 20 ms and 4000 ms with all exclusions enabled. +- All 78 subtypes pass four crossed seed/material/character/control scenarios + (312 renders), plus one duration endpoint each with all exclusions enabled. +- Every family produces at least three primary constructions and ten distinct + source graphs in 24 history-aware draws. Subtype audio differences are checked + with a fixed root and pitch travel removed, using gain-invariant spectral and + temporal probes calibrated on known tones. +- The eight new detail groups remain distinct under that fixed-pitch probe; + each of their 34 subtypes produces at least three distinct source graphs in + sixteen draws with pitch and material fixed. +- All five chord qualities and both voicings render across nine Music families + and three tuned percussion subtypes (120 crossed cases), plus both duration + endpoints with all exclusions (24 cases). A calibrated spectral test measures + the contribution of all three notes in generated chord audio. MIDI roots + 24, 60 and 96 retain exact intervals under the requested controls. +- Nine new-family representatives replay saved patches sample-for-sample at + 48 kHz. A separate Vite SSR smoke run confirms operation without browser globals + and writes optional local WAV/patch examples to `.local/sdk-subtypes-audio/`. +- Thirteen further SSR examples (one per new detail group and five chords) + also replay sample-for-sample at 48 kHz. Their local WAVs, saved sounds and + report are in `.local/sdk-details-harmony-audio/`. +- Fusion keeps the principal subtype and pitch; incompatible search changes are + rejected. Variations keep their reference's subtype and exact musical root. +- TypeScript and targeted ESLint pass. UI interaction and listening acceptance + remain separate: these tests do not certify physical realism or artistic quality. diff --git a/docs/audio-labs.md b/docs/audio-labs.md new file mode 100644 index 0000000..3879cb4 --- /dev/null +++ b/docs/audio-labs.md @@ -0,0 +1,180 @@ +# Sound Labs + +Labs is the research bench beside Instrument and Sounds. Generating, auditioning, keeping and +combining sounds leave the current Instrument patch in place. **Open in Instrument** transfers a +complete editable patch and its macro rig; Instrument's Undo restores the preceding sound. + +## The loop + +1. In the **Palette**, choose a search section. Search reaches family names, details, materials + and characters across the catalog; it does not change any setting until a result is selected. + - **Source** browses 49 families in seven domains and 78 contextual details. Domain tabs only + change what is being browsed. **Any** explores the whole catalog. **Multiple** builds a pool + of alternatives, including families in different domains; each generation draws one family. + Selected family chips stay visible across domains and can be removed directly. + - **Timbre** offers all 15 material choices, 13 character choices and 12 textures (including + their Any/Auto choices). Materials and characters also support multiple alternatives. + **Avoid** steers away from piercing highs, sub bass, reverb or a sharp attack. + - **Behaviour** offers the source's gestures, all nine movements, and Auto/Cut/Fade/Ring endings. + Gestures are drawn as sound contours. An unavailable gesture explains its minimum duration. + - **Pitch** offers register ranges, exact notes C1–C7, scale, chord and voicing. An exact note + replaces the register range. Chords require a musical family or tuned percussion. If a source + change invalidates a detail, gesture or chord, the dependent choice resets with an Undo notice. + A gesture incompatible with a shortened duration stays selected, with an explanation beside + the disabled generation action. +2. Above the relief, the four **Next sound** summaries open the corresponding palette section, + including from a collapsed rail or a narrow screen. **Intensity** and **Density** start on Auto; + drag their dials, use the arrows, or press Delete/double-click to return to Auto. **Mass** has + three positions and never transposes the sound. **Duration** accepts two handles or millisecond + fields, from 20 to 4000 ms; equal endpoints request an exact length. + **Diversity** chooses Focused, Balanced or Wild. These retain explicit filters. New Explore + generations use discovery recipes even when the source is a legacy family. Recent recipe + avoidance compares the complete search intent, including pools, harmony and detail. +3. Press **Generate & play** (or `Enter`, or `G`). One sound is made and heard at once. Pressing again while a + render is in flight replaces the request rather than queueing it, and there is only ever one + voice: the new sound stops the old one. +4. Changing a criterion prepares the next sound. The sound on the bench stays there, playable, + until the next one has arrived. Auto settings leave the corresponding choices to the generator. Presets remain accessible in the rail. + +The bench shows the current sound: its name, its length, the relief, and — behind the sliders +button beside the bookmark — its four timbre controls. `Space` plays or stops it, `K` keeps it, `R` +makes it the reference, `Escape` stops the voice and cancels a render. ⌘Z / ⌘⇧Z undo and redo Labs +changes while the workspace is open. + +## The relief + +The relief comes from the rendered stereo samples: 384 time columns and 128 frequency bands, +from 40 Hz up to 20 kHz (or Nyquist). A centred, elevated perspective gives roughly three quarters +of the visible frequency depth to 50 Hz–1 kHz. The surface, axes and control anchors share the +same projection. The upper and right edges have no enclosing frame. + +- Analysis runs in the worker. A short FFT window preserves attacks; a second, longer window + resolves bass harmonics. Their amplitude-calibrated powers blend gradually between 250 Hz and + 1 kHz. Short-window activity prevents the longer window from filling silent gaps. Both stereo + channels contribute even when their phases oppose. +- Heights cover 80 dB below the strongest spectral bin, on a squared scale. The axis says + **dB rel.** because this is spectral balance, not output loudness. **Peak … dBFS** in the header + reports the rendered sample peak; its tooltip also gives RMS and explains the distinction. +- Fine contours and a translucent skin share the measured surface. Highlights follow real + curvature; subdued cross-filaments and depth occlusion separate crests from valleys. Silent + bands remain unlit. A narrow display reduces the number of visible lines, not the measured data. +- The camera stays fixed. Playback lights the part being heard; transitions interpolate between + measured spectra. Reduced-motion preference removes the shape transition. + +### Wave controls + +Four separate handles sit inside the front edge of the relief, with names and percentages always +visible. Their hit areas are at least 44 px high. Hover or keyboard focus connects a handle to the +surface and lights its region; the rest of the picture stays clear. + +| Control | Highlight on the surface | +| --- | --- | +| **Bite** | The filter's frequency band | +| **Grain** | A band of spectral energy | +| **Space** | A slice towards the tail | +| **Motion** | The moment with the most spectral change | + +All four drag vertically with the same sensitivity: 200 CSS pixels for the full range, +independent of perspective or viewport size. Hold `Shift` for quarter-speed fine adjustment. +Double-click a handle, or focus it and press `Enter`, to type a percentage. `Enter` applies it; +`Escape` closes it without changing the sound. Blank and out-of-range entries cannot become zero +silently. Arrow keys adjust by one percentage point, `Shift`/Page keys by ten; Home/End select +zero or one hundred. + +A click without a drag leaves the sound untouched. A drag is one undo step, replays when released, +and can be cancelled with `Escape` or pointer cancellation. Pausing the hand auditions the current +value. A kept sound is copied only when editing actually begins. The previous relief and captured +handle stay mounted while the worker measures the updated sound, so editing a reserve entry +cannot interrupt the gesture. The reserve itself stays unchanged. + +**Spectrum**, **Waveform** and **Both** choose what the block shows; the choice is remembered per +browser. The front-edge handles appear in Spectrum and Both. The same macros are always available +in the bench's timbre popover. Where WebGL is unavailable or lost, a 2D canvas draws the spectrum. + +## History and reserve + +The **history** is automatic: the last twenty sounds generated or brought to the bench, newest +first. Click a row, or use the arrow keys, to bring one back; the one you just passed is one +step back. The **reserve** is deliberate: what you chose to keep, up to 24 immutable snapshots +that stay with the project. Rename them there, use one as the reference, fuse two, save them to +Sounds or open them in Instrument. A full reserve asks for a removal; nothing is evicted +silently, and a removal can be undone. + +The four timbre controls — Grain, Bite, Motion and Space — have handles on the relief, and the +same four open as sliders from the button on the bench, where their locks hold a control still for +variations. Adjusting a kept sound puts an editable copy on the bench; the reserve entry does not +change. + +## Variations + +**Use as reference** (or entering Variations with a sound on the bench) establishes a stable +anchor. Adjust its controls, lock the ones that should stay fixed, choose Subtle, Medium or +Strong, then **Vary & play**. Every variation starts from the reference plus those adjustments; +auditioning other sounds does not move the reference, and the previous references stay +reachable from the strip. The duration stays fixed unless **Vary duration** is on. + +Imported Instrument sounds retain their macro rig. Up to four compatible numeric timbre macros +are exposed. Pitch, layer amplitude, envelope timing and master gain mappings are excluded from +automatic timbre variation. A stale mapping is anchored to the audible value on its first move in +the child; the original rig remains in the parent snapshot. + +## Guided fusion + +Choose a principal and a contributor from the reserve, then choose Texture, Attack, Motion or +Resonance and an influence amount, and **Fuse & play**. + +- The principal supplies the body and global effects. Its core layers and their + phase-modulation dependencies are protected. +- A layer contribution is copied with its dependency group. Its modulation paths are remapped + to the new layer indices. The donor's active performer scene is retained. Optional principal + layers are retired together so retained layers cannot point at a replaced source. +- Motion contributes compatible timbre modulation into free slots. It does not replace the + principal's pitch or amplitude modulation. +- The result fits the standard four-layer engine. A dependency group that cannot fit, + exhausted modulation slots, unknown layer roles or unresolved recorded gestures produce an + explanation instead of silently dropping required routes. + +The original sounds remain in the reserve and the child records both parent snapshots. +Generated recipes declare layer roles during construction. Imported patches with unknown roles +can be explored with macros but cannot be used for guided fusion until their roles are known +through a Labs-generated design. + +## Rendering and persistence + +The generator uses the existing DSP engine and twelve recipe entry points. Materials and motion +shape those recipes under explicit parameter constraints. Duration fitting scales offsets, +envelopes, modulation timing, recorded gesture timing and effect tails before the final output +fade. Names are drawn from the seed, so the same recipe carries the same name. + +Each operation runs in a Web Worker that is kept warm between presses and terminated when a +press replaces a render in flight. Non-finite, silent, excessive-level and severely +phase-cancelled candidates are rejected and explained. Sounds play at their own level, with no +monitor compensation, so the level heard is the level exported. The voice ends in a guard that is +exactly transparent below −1 dBFS and only rounds a peak that would otherwise clip. + +The preview cache is limited to eight sounds and 16 MiB. Persisted research state contains +patches, rigs, seeds, criteria, recipe versions, variation/fusion settings, waveform previews and +bounded parent snapshots; the relief is rendered again on demand. Project export includes custom +wavetables referenced only by Labs or by saved parent sounds. Missing assets are reported +explicitly. + +Exclusions are sound-design constraints, not a guarantee of a perfectly empty frequency band. +Automated validation covers signal integrity and state consistency. Sound character, useful +variation and perceived quality still need auditioning at a consistent listening level. + +## Starting points + +Five reproducible sounds are available under **Presets** in the palette and under **Labs** in +Sounds: + +| Preset | Design | Duration | +| --- | --- | --- | +| Iron Colossus | Heavy metallic growl with an industrial edge | 1560 ms | +| Servo Cathedral | Accelerating mechanical transformation | 2180 ms | +| Prism Fracture | Glass impact with a pitched core | 780 ms | +| Neural Stutter | Electrical, stuttering digital texture | 1060 ms | +| Titan Splice | Guided contribution from Servo Cathedral into Iron Colossus | 1560 ms | + +Their seeds are retained when loaded into Instrument. All five carry editable macro rigs; Titan +Splice also retains its two source snapshots. The same tab lists the project's saved sounds and +brings the Instrument's current patch to the bench. diff --git a/docs/local-docker-development.md b/docs/local-docker-development.md index cbb220a..2b34c02 100644 --- a/docs/local-docker-development.md +++ b/docs/local-docker-development.md @@ -41,10 +41,12 @@ Agents and developers must use disposable containers: ```bash docker compose run --rm app npm test docker compose run --rm app npm run lint -docker compose run --rm app npm run typecheck -docker compose run --rm app npm run build +docker compose run --rm -e NODE_OPTIONS=--max-old-space-size=1280 app npm run typecheck +docker compose run --rm -e NODE_OPTIONS=--max-old-space-size=1280 app npm run build ``` +TypeScript needs the explicit heap budget above; Node's default under the 1536 MB container limit can run out of heap during compilation. This does not change the persistent development server. + Do not add `--service-ports` to one-off tasks: that would try to bind `5174` a second time. @@ -88,7 +90,21 @@ loop cannot take the whole machine. They are not reservations while idle. ## Other Compose projects -Before `docker compose up`, list running Compose projects. If `site-anym`, -`stellary`, `helios`, or another development stack is already up, warn and -wait; never start both stacks or stop the other one silently. `stellary-ci` -may remain active. +Another development stack may run alongside this one. The published ports do not +collide, and Docker Desktop has the headroom for both. Never stop another +project silently. + +The exception is the 3D end-to-end campaign: `scene-cut` crashes the renderer on +a machine that carries several Docker stacks at once. Before +`node e2e/campaign.mjs scene-`, list running Compose projects and ask for +`site-anym`, `stellary` or `helios` to be stopped. `stellary-ci` may remain +active. + +## Restart policy + +Services use `restart: unless-stopped`, not `no`. Docker Desktop restarts its +engine on its own — a settings change such as the macOS virtualisation backend, +an update, or a resume from Resource Saver — and every container stops with it. +Under `no` nothing came back, and a stack vanishing at the moment another one +started looked exactly like one project killing the other. `unless-stopped` +still honours an explicit `docker compose stop`. diff --git a/docs/modular-packages.md b/docs/modular-packages.md new file mode 100644 index 0000000..69b49fc --- /dev/null +++ b/docs/modular-packages.md @@ -0,0 +1,63 @@ +# Modular packages and application domains + +ParamRig uses npm workspaces and one dependency lockfile. The application aliases public package entries to their canonical sources during development and production builds. Package builds compile those same sources to ESM and standalone TypeScript declarations; they do not copy an alternative implementation of an engine. + +## Choose a package + +| Package | Responsibility | Host requirements | +| --- | --- | --- | +| `@paramrig/core` | Rig contracts, value validation, bindings and animated values | Node or browser; no engine, React or storage | +| `@paramrig/audio` | Patches, synthesis and block rendering; `/wav` and `/wavetables` | Node or browser; Core only | +| `@paramrig/audio-labs` | Existing seeded generation, variation, fusion and catalogs | Audio and Core; no visual interface | +| `@paramrig/audio-browser` | Independent AudioWorklet players | Host AudioContext and destination; secure browser context | +| `@paramrig/vector` | Drawing and layout documents, bindings, geometry, SVG, PNG and PDF | Explicit browser/export entries and font/image resolvers | +| `@paramrig/scene` | Documents, evaluated geometry, materials, shaders and scene instances | One host-owned Three.js copy; optional viewer | +| `@paramrig/controls` | Controlled React inputs and extensible control registries | Host React/React DOM; explicit scoped stylesheet | +| `@paramrig/web` | Existing web connector, contracts and schemas | Remains independent of Core and the other engines | + +Package READMEs describe APIs and their errors. Runnable examples live in `examples/sdk/`. Copy an example outside the repository, install its dependencies and follow its README. Web's existing reference consumer and connected example remain available under `scripts/web-sdk-consumer` and `examples/web`. + +Separate packages reduce installation dependencies. Sub-entries select what a bundler loads from an installed package. These are different guarantees. Installing an engine never installs the complete editor. + +## Resources and ownership + +Audio players receive an AudioContext and destination. Each maintains its own user wavetable registry. Stop ends playback; destroy disconnects owned nodes and ports and cancels pending work. Neither closes the host's context. The compiled worklet URL is resolved relative to the package and can be overridden for a host bundler or CSP. + +Vector receives a container, document, parameter values and resource callbacks. Font callbacks receive display/outline purpose and a cancellation signal. Images resolve to self-contained data URLs. Await `ready` or `update()` before exporting. Node supports documents, serialization and bindings. Browser typography and canvas-dependent rendering are not presented as equivalent server rendering. PDF exposes skipped/rasterized output notes. + +Scene instances expose their Three.js scene and cameras. A host can update document, values, time and view, then render in its own loop. The optional browser viewer owns its canvas and renderer; destroying it does not destroy a renderer owned by another integration. Texture and font resources are injected. Explicit, idempotent modifier initialization is shared with exports and previews. The visual engine reuses viewport geometry, materials, lighting and shaders; the glTF interchange builder is not its renderer. + +Controls accept values and gesture callbacks. They have no document, history or engine dependency. Import `@paramrig/controls/styles.css` deliberately; styles are scoped, including portaled controls, and do not fetch fonts. Specialized controls use an explicit registry and host resource/catalog callbacks. + +## Application loading + +`src/modules` supplies Audio, Vector, Scene and Web descriptors. Each descriptor owns its editor, preview, import/export, examples, specialized controls and cleanup. `/audio`, `/vector`, `/3d` and `/web` open the relevant domain; `/r/:rigId` remains the document address. + +The library reads a generated example catalog and a reconstructible local metadata index. It uses cached thumbnails without evaluating a scene or synthesizing audio. Domain CSS loads with its module. Full document validation happens when that domain opens. + +Sound Labs keeps its existing Three.js spectral relief, loaded when the Labs view opens. That is a visualization owned by the Audio application, not a dependency of the Audio or Labs SDK, and it does not include the Scene editor or SDK. + +## Storage compatibility + +Existing document IDs, project formats, local storage keys and IndexedDB resource stores are retained. The metadata index is secondary to the documents. Rebuilding it reads metadata without regenerating patches or rewriting stored documents. A successful save remains successful when index persistence fails. Writes update only their target document, preserving unreadable or future-version neighbors in the same store. Other-tab changes are reconciled both while subscribed and when returning to the library. + +The thumbnail cache is separate from document storage. A missing module or a failed lazy load keeps the stored project intact and offers an appropriate recovery route. + +## Builds and gates + +Run local tasks through Docker Compose from the repository root. Do not start a second persistent development stack to run these tasks. + +```sh +docker compose run --rm app npm run build:sdks +docker compose run --rm app npm run test:sdks +docker compose run --rm app npm run build:app +docker compose run --rm app npm run build:modules -- --modules=audio +docker compose run --rm app npm run build:modules -- --modules=vector,scene +docker compose run --rm app npm run build:modules -- --matrix +``` + +The existing full build still writes `dist`. Selective builds and archives are generated under `.local/modularity/distributions` and `.local/modularity/archives`. Selection generates literal entry points at compilation. Public resources are copied from explicit domain allowlists; omitted domains are checked in actual bundle graphs and archive contents. + +The bundle measurement code calibrates its gzip and graph collectors against known fixtures. CI checks clean tarball installations, strict NodeNext declarations, Node APIs, browser builds and runnable examples. It checks forbidden dependencies, a single Three.js copy, Audio plus WAV below 30,000 gzip bytes, Labs below 90,000 bytes, and the initial library JS/CSS below 250,000 bytes. Vector, Scene and Controls have recorded consumer baselines in `tests/consumers/budgets.json`; a growth above 5% requires an explicit, documented baseline change. + +Consumer bundles include the host libraries and export paths their fixtures exercise. Their gzip sizes are not npm installation sizes. Generated reports list installed dependencies and the actual modules and assets measured. diff --git a/docs/modular-releases.md b/docs/modular-releases.md new file mode 100644 index 0000000..c14fe01 --- /dev/null +++ b/docs/modular-releases.md @@ -0,0 +1,43 @@ +# Releasing modular SDKs + +New engine and control packages start at `0.1.0`. Web keeps its independent version and `web-v*` workflow. Its `0.1.2` declaration update retains the public API and standalone installation while supporting strict NodeNext consumers after the shared types moved into Core. + +## Validate before publishing + +The `verify` workflow builds the application and packages, installs consumers from npm tarballs, checks TypeScript and Node/browser use, and verifies selective distributions. It retains the validated tarballs and application archives as an artifact named with the source commit. Local equivalents run through Docker Compose; see [Modular packages](./modular-packages.md). + +Do not publish an artifact from a different revision than the validated one. Inspect the tarball file list and integrity. An npm release is immutable: fix a defective release with a new version. + +## Initial package bootstrap + +A trusted publisher can only be configured for a package that already exists. The first publication therefore requires an authenticated npm maintainer and any account-level two-factor authentication. Publish the **validated artifact** for each new package in this order: + +1. Core. +2. Audio. +3. Sound Labs and browser audio. +4. Vector and Scene. +5. Controls. + +Use the tarballs retained by successful CI for the exact source commit. Do not create placeholder packages or publish different local builds merely to reserve names. The initial maintainer publication does not have CI-generated provenance; later OIDC publications do. + +After each package exists, configure the trusted publisher for `Anymfah/paramrig`, workflow file `release-sdks.yml`, with direct publication allowed. This workflow does not use a GitHub environment. Keep Web's existing `release-web.yml` publisher separate. Inspect existing publisher configuration before adding a relationship; do not replace unrelated publisher access. + +npm documents the existing-package and authentication prerequisites in [npm trust](https://docs.npmjs.com/cli/v11/commands/npm-trust/) and workflow matching in [trusted publishing](https://docs.npmjs.com/trusted-publishers/). The workflow installs npm 11 for OIDC support. + +## Coordinated releases + +Push `sdk-v` only for a reviewed commit whose new package manifests all declare that version. The release workflow rebuilds and tests the packages and consumers before publication. It checks every intended version against the registry before uploading any package, publishes in dependency order, and permits an identical already-published artifact when resuming a partial release. A different artifact under the same version is an error. + +After publication it installs from npm, compares registry integrity with the validated artifact and repeats the consumer checks. That check is also available as: + +```sh +docker compose run --rm app npm run test:sdks -- --registry +``` + +Publish a separate `web-v` tag only when Web's artifact changes. + +## Application and product pages + +After the SDK installations pass, deploy the full application from an exact Git commit through the existing private website workflow. Run its dry-run mode first and inspect the target and changes. App and site transfers share one concurrency group. Real transfers repeat the dry run, retain previous hashed assets and transfer the entry point after its resources. The application records its source revision in `release.json`. + +Verify cold navigation to each domain, existing document recovery, save/reload, playback and exports, module transitions and the network resources actually loaded. Then publish the pages that announce those capabilities. A rollback restores the prior server entry points and backed-up files; it does not modify browser storage. An npm rollback uses a new corrective version, never an overwritten artifact. diff --git a/e2e/audio-labs.e2e.mjs b/e2e/audio-labs.e2e.mjs new file mode 100644 index 0000000..41b7a20 --- /dev/null +++ b/e2e/audio-labs.e2e.mjs @@ -0,0 +1,460 @@ +import { run, BASE } from './lib.mjs' + +/** + * Sound Labs in a real browser. + * + * The unit tests run the generator inline and mock the speakers; this is the other half — the + * worker, Web Audio, the WebGL relief, and the layout — checked the way somebody uses the bench: + * press, listen, press again, go back, reload. + */ +export default run('audio-labs', async ({ page, check, log, shot, errors }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => { localStorage.removeItem('paramrig.audio-documents.v1'); localStorage.removeItem('paramrig.labs-view.v1') }) + await page.goto(`${BASE}/r/audio-example-arcade-coin`, { waitUntil: 'networkidle' }) + await page.waitForSelector('.fp') + await page.click('#audio-view-labs') + await page.waitForSelector('.audio-labs') + await page.waitForTimeout(300) + + const state = () => page.evaluate(() => { + const doc = JSON.parse(localStorage.getItem('paramrig.audio-documents.v1') ?? '{}') + const labs = Object.values(doc).find((entry) => entry?.id === 'audio-example-arcade-coin')?.labs + return { + history: document.querySelectorAll('.labs-row').length, + bench: document.querySelector('.labs-bench__title h2')?.textContent ?? '', + slot: document.querySelector('.labs-bench__meta')?.textContent?.match(/·\s*(\d+)/)?.[1] ?? '', + playing: [...document.querySelectorAll('.labs-play[aria-pressed="true"]')].length, + stops: [...document.querySelectorAll('button[aria-label^="Stop "]')].length, + busy: !!document.querySelector('.labs-generate[aria-busy="true"]'), + canvas: !!document.querySelector('.labs-relief__gl canvas'), + saved: labs ? { history: labs.history?.length ?? 0, current: labs.current ?? null, reserve: labs.reserve?.length ?? 0 } : null, + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + mainOverflow: (() => { const main = document.querySelector('.labs-main'); return main ? main.scrollWidth > main.clientWidth : false })(), + } + }) + + // 1. The empty bench, and the palette in the left column. + const empty = await state() + check('the bench starts empty', empty.bench === 'Nothing on the bench yet', empty.bench) + check('the palette holds the twelve families', await page.locator('.labs-palette .labs-tiles--family .labs-tile').count() === 12) + check('nothing overflows sideways', !empty.overflow && !empty.mainOverflow) + await shot('labs-empty.png') + + // 2. One press, one sound, heard at once. + await page.click('.labs-generate') + await page.waitForFunction(() => !document.querySelector('.labs-generate[aria-busy="true"]') && document.querySelectorAll('.labs-row').length >= 1, null, { timeout: 15000 }) + await page.waitForTimeout(250) + const first = await state() + log(`MEASURE first: ${JSON.stringify(first)}`) + check('one press puts exactly one sound in the history', first.history === 1) + check('and it is on the bench', first.bench !== 'Nothing on the bench yet' && first.bench.length > 0, first.bench) + check('and it is playing', first.stops >= 1, String(first.stops)) + check('the relief is drawn in WebGL', first.canvas) + await page.waitForTimeout(400) + await shot('labs-first.png') + + // 3. Pressing again while a render is in flight replaces the request rather than queueing it. + const before = (await state()).history + for (let i = 0; i < 5; i++) { await page.click('.labs-generate'); await page.waitForTimeout(40) } + await page.waitForFunction(() => !document.querySelector('.labs-generate[aria-busy="true"]'), null, { timeout: 20000 }) + await page.waitForTimeout(300) + const burst = await state() + log(`MEASURE burst: ${JSON.stringify(burst)}`) + check('five rapid presses add at most five sounds', burst.history - before <= 5 && burst.history > before, `${burst.history - before}`) + check('and never more than one voice is playing', burst.stops <= 2 && burst.playing <= 2, `${burst.stops} stop buttons`) + const voices = await page.evaluate(() => new Promise((resolve) => setTimeout(() => resolve(document.querySelectorAll('.labs-row .labs-play[aria-pressed="true"]').length), 50))) + check('exactly one history row is the voice', voices <= 1, String(voices)) + + // 4. The keyboard: G makes one, the arrows walk the history, Space toggles the voice. + await page.locator('.labs-head h1').click() + const beforeKey = (await state()).history + await page.keyboard.press('g') + await page.waitForFunction((count) => document.querySelectorAll('.labs-row').length === count + 1, beforeKey, { timeout: 15000 }) + await page.waitForTimeout(200) + const afterKey = await state() + check('G generates one sound', afterKey.history === beforeKey + 1) + await page.keyboard.press('ArrowLeft') + await page.waitForTimeout(400) + const stepped = await state() + check('ArrowLeft brings the previous sound back to the bench', stepped.slot !== afterKey.slot && stepped.slot !== '', `${afterKey.slot} → ${stepped.slot}`) + const wasPlaying = stepped.stops > 0 + await page.keyboard.press('Space') + await page.waitForTimeout(150) + const toggled = await state() + check('Space toggles the voice', (toggled.stops > 0) !== wasPlaying, `${wasPlaying} → ${toggled.stops > 0}`) + await page.keyboard.press('Escape') + + // 5. Clicking a row in the history brings it to the bench. + const rows = page.locator('.labs-row .labs-row__main') + const last = await rows.count() + const wanted = await rows.nth(last - 1).locator('.labs-row__name').textContent() + await rows.nth(last - 1).click() + await page.waitForTimeout(400) + const chosen = await state() + check('the oldest row goes back on the bench', chosen.bench === wanted, `${chosen.bench} vs ${wanted}`) + await page.keyboard.press('Escape') + + // 6. Keep, then Variations from the bench: the reference stays put while variations are made. + await page.click('.labs-bench__actions button[aria-label^="Keep in the reserve"]') + await page.waitForTimeout(200) + check('Keep puts the sound in the reserve', (await state()).saved?.reserve === 1 || await page.locator('.labs-card').count() === 1) + await page.click('[role="tab"]:has-text("Variations")') + await page.waitForTimeout(200) + const referenceName = await page.locator('.labs-reference__who strong').textContent() + check('entering Variations adopts the bench sound as the reference', referenceName === chosen.bench, `${referenceName} vs ${chosen.bench}`) + const beforeVary = (await state()).history + await page.click('.labs-generate') + await page.waitForFunction(() => !document.querySelector('.labs-generate[aria-busy="true"]'), null, { timeout: 20000 }) + await page.waitForTimeout(300) + const varied = await state() + const meta = await page.locator('.labs-bench__meta').textContent() + check('Vary & play makes one variation', varied.history === beforeVary + 1 && /Variation/.test(meta ?? ''), meta ?? '') + check('the reference is unchanged by it', await page.locator('.labs-reference__who strong').textContent() === referenceName) + await shot('labs-variations.png') + + // 7. Fusion needs two kept sounds and says so. + await page.click('[role="tab"]:has-text("Fusion")') + await page.waitForTimeout(200) + check('Fusion explains what it still needs', (await page.locator('.labs-generate').getAttribute('aria-disabled')) === 'true') + await page.click('[role="tab"]:has-text("Explore")') + await page.waitForTimeout(200) + + // 8. Reload: the history, the bench and the relief come back. + const kept = await state() + await page.reload({ waitUntil: 'networkidle' }) + await page.waitForSelector('.fp') + await page.click('#audio-view-labs') + await page.waitForSelector('.audio-labs') + await page.waitForFunction(() => !!document.querySelector('.labs-relief__gl canvas'), null, { timeout: 15000 }) + await page.waitForTimeout(800) + const back = await state() + log(`MEASURE reload: ${JSON.stringify(back)}`) + check('the history survives a reload', back.history === kept.history, `${back.history} vs ${kept.history}`) + check('the same sound is on the bench', back.bench === kept.bench, `${back.bench} vs ${kept.bench}`) + check('and its relief is drawn again', back.canvas) + check('the reserve survives too', await page.locator('.labs-card').count() === 1) + await shot('labs-reloaded.png') + + // 9. The voice plays exactly what is exported: the bench's audio graph, rendered offline here, is + // the engine's render sample for sample — at unity, and with the width and gain changes the next + // shaping step will use. + const heard = await page.evaluate(async () => { + const { renderPatch } = await import('/src/audio/dsp/render.ts') + const { wireVoice } = await import('/src/audio/labs/labVoice.ts') + const { generateSound } = await import('/src/audio/labs/generate.ts') + const { DEFAULT_CRITERIA } = await import('/src/audio/labs/model.ts') + const { sculptSound } = await import('/src/audio/labs/sculpt.ts') + const rate = 48000 + const sound = generateSound({ ...DEFAULT_CRITERIA, minMs: 400, maxMs: 400 }, 23) + // Under the limiter, so the comparison is of the two paths and not of saturation. + sound.patch.master.limiter = 0 + sound.patch.master.gain *= 0.25 + const base = renderPatch(sound.patch, rate) + let peak = 0 + for (let i = 0; i < base.left.length; i++) peak = Math.max(peak, Math.abs(base.left[i]), Math.abs(base.right[i])) + const peakDb = 20 * Math.log10(peak) + const cases = [['unity', 0, {}], ['width', 1.7, { side: 1.7 }], ['width', 0.3, { side: 0.3 }], ['level', peakDb - 7, { gain: Math.pow(10, -7 / 20) }]] + const out = [] + for (const [kind, value, live] of cases) { + const baked = kind === 'unity' ? base : renderPatch(sculptSound(sound, kind, value, peakDb).patch, rate) + const context = new OfflineAudioContext(2, base.left.length, rate) + const buffer = context.createBuffer(2, base.left.length, rate) + buffer.copyToChannel(base.left, 0); buffer.copyToChannel(base.right, 1) + wireVoice(context, buffer, context.destination, live).source.start() + const rendered = await context.startRendering() + const l = rendered.getChannelData(0), r = rendered.getChannelData(1) + let error = 0, level = 0 + for (let i = 0; i < l.length; i++) { error = Math.max(error, Math.abs(l[i] - baked.left[i]), Math.abs(r[i] - baked.right[i])); level = Math.max(level, Math.abs(baked.left[i])) } + out.push({ kind, value: Math.round(value * 100) / 100, error, level }) + } + return out + }) + log(`MEASURE heard: ${JSON.stringify(heard)}`) + check('the voice plays sample for sample the sound that is exported', heard.every((entry) => entry.error < 1e-4 && entry.level > 0.01), heard.map((entry) => `${entry.kind} ${entry.value}: ${entry.error.toExponential(1)}`).join(', ')) + + // 9b. The Bite mark: a line of the relief, at the frequency where the sound cuts, dragged up the + // scale for more bite while the waves are rendered again under the hand. + const bitePresent = await page.locator('.labs-mark[data-kind="bite"] .labs-mark__hit').count() + if (bitePresent) { + const grip = () => page.evaluate(() => { + const el = document.querySelector('.labs-mark[data-kind="bite"] .labs-mark__hit') + const points = el.getAttribute('points').split(' ').map((pair) => pair.split(',').map(Number)) + const box = el.ownerSVGElement.getBoundingClientRect() + const middle = points[Math.floor(points.length / 2)] + const hz = /([\d.]+) ?(k?Hz)/.exec(el.getAttribute('aria-valuetext') ?? '') + return { + now: Number(el.getAttribute('aria-valuenow')), + hz: hz ? Number(hz[1]) * (hz[2] === 'kHz' ? 1000 : 1) : 0, + x: box.left + middle[0], y: box.top + middle[1], + label: document.querySelector('.labs-mark__label[data-kind="bite"]')?.textContent ?? '', + } + }) + // The sound on the bench, rendered here: its spectral centre, the brightness the ear follows. + const centre = () => page.evaluate(async () => { + const { renderPatch } = await import('/src/audio/dsp/render.ts') + const { fft } = await import('/src/audio/labs/analysis.ts') + const doc = JSON.parse(localStorage.getItem('paramrig.audio-documents.v1') ?? '{}') + const labs = Object.values(doc).find((entry) => entry?.id === 'audio-example-arcade-coin')?.labs + const sound = [...labs.history, ...labs.reserve].find((entry) => entry.id === labs.current) + const rate = 48000, size = 2048, samples = renderPatch(sound.patch, rate) + let weighted = 0, all = 0 + for (let from = 0; from + size <= samples.left.length; from += size) { + const re = new Float32Array(size), im = new Float32Array(size) + for (let i = 0; i < size; i++) re[i] = (samples.left[from + i] + samples.right[from + i]) * 0.5 * (0.5 - 0.5 * Math.cos(2 * Math.PI * i / (size - 1))) + fft(re, im) + for (let k = 1; k < size / 2; k++) { const p = re[k] ** 2 + im[k] ** 2; all += p; weighted += p * k * rate / size } + } + return weighted / all + }) + const stage = await page.evaluate(() => { const r = document.querySelector('.labs-stage').getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height } }) + const picture = async () => (await page.screenshot({ clip: stage })).toString('base64') + const rest = await grip() + const brightBefore = await centre() + await page.mouse.move(rest.x, rest.y) + await page.waitForTimeout(250) + const hover = (await grip()).label + check('the Bite line shows its value, its frequency and a hint under the pointer', /Bite\s*\d+%/.test(hover) && /k?Hz/.test(hover) && /Drag up/.test(hover), hover) + // Up the scale, the way the relief's depth runs, in slow steps: the waves follow the hand. + await page.mouse.down() + const frames = [] + let heardWhileHeld = 0 + for (let i = 1; i <= 12; i++) { + await page.mouse.move(rest.x + i * 3, rest.y - i * 4) + // Halfway, the hand rests: that is where the sound is heard as it now is. The voice is + // watched for through the rest, since the render it waits on can take a moment and a short + // sound is over before the drag is. + if (i === 6) { + for (let tick = 0; tick < 14 && !heardWhileHeld; tick++) { + await page.waitForTimeout(100) + heardWhileHeld = await page.evaluate(() => document.querySelectorAll('button[aria-label^="Stop "]').length) + } + } else await page.waitForTimeout(70) + if (i === 3 || i === 8 || i === 12) frames.push(await picture()) + } + const pulling = await page.evaluate(() => !!document.querySelector('.labs-marks__pull line') && !document.querySelector('.labs-relief__loading')) + await shot('labs-bite-pull.png') + await page.mouse.up() + await page.waitForTimeout(1300) + const pulled = await grip() + const brightAfter = await centre() + log(`MEASURE bite: ${rest.now}% at ${rest.hz} Hz → ${pulled.now}% at ${pulled.hz} Hz, spectral centre ${Math.round(brightBefore)} → ${Math.round(brightAfter)} Hz`) + check('dragging the line up the scale adds bite and moves where the sound cuts', pulled.now > rest.now + 10 && pulled.hz > rest.hz, `${rest.now}% at ${rest.hz} Hz → ${pulled.now}% at ${pulled.hz} Hz`) + /* + * The brightness moves, and which way is the sound's business rather than the grip's: over the + * twelve families a wider filter raises the centre twenty-two times in twenty-four, and on a + * resonant one it can collapse it — the peak that was sitting in the middle of the spectrum + * walks off the top of it, leaving the body behind. So what is checked is that the render + * really answers the hand; the number is reported either way. + */ + const shift = Math.abs(brightAfter - brightBefore) / Math.max(1, brightBefore) + check('and the sound it renders really changes', shift > 0.05, `${Math.round(brightBefore)} → ${Math.round(brightAfter)} Hz, ${(shift * 100).toFixed(0)}%`) + check('the waves change while the hand is still moving', frames[0] !== frames[1] && frames[1] !== frames[2]) + check('a hand at rest hears the sound while still holding it, with a filament to the line', heardWhileHeld >= 1 && pulling) + await page.locator('.labs-head h1').click() + await page.keyboard.press(`${process.platform === 'darwin' ? 'Meta' : 'Control'}+z`) + await page.waitForTimeout(800) + check('one undo takes the whole move back', (await grip()).now === rest.now) + const again = await grip() + await page.mouse.move(again.x, again.y); await page.mouse.down() + for (let i = 1; i <= 6; i++) { await page.mouse.move(again.x - i * 3, again.y + i * 4); await page.waitForTimeout(16) } + await page.keyboard.press('Escape') + await page.mouse.up() + await page.waitForTimeout(600) + check('Escape takes a move back before it lands', (await grip()).now === rest.now) + await page.focus('.labs-mark[data-kind="bite"] .labs-mark__hit') + for (let i = 0; i < 3; i++) await page.keyboard.press('ArrowUp') + await page.waitForTimeout(800) + check('the arrows step Bite from the keyboard', (await grip()).now === Math.min(100, rest.now + 3)) + // Each step is heard: the voice is stopped, and the relief left to settle, before stillness is measured. + await page.locator('.labs-head h1').click() + await page.waitForTimeout(400) + await page.keyboard.press('Escape') + await page.waitForTimeout(1200) + } else log('the bench sound has no Bite control: the line is not shown') + + /* + * 9c. The three other marks. Each stands on the direction its control works in — Grain on the + * level, Space on time, Motion on the height of a stem — and each is dragged along that + * direction. What is checked here is the thing that makes them grips rather than readouts: the + * mark goes exactly as far as the hand, the relief lights it under the pointer, and the sound + * really changes. + */ + const marked = await page.evaluate(() => [...document.querySelectorAll('.labs-mark')].map((g) => g.dataset.kind)) + check('the sound on the bench wears its four controls', marked.join() === 'bite,grain,space,motion', marked.join(' ')) + const shapeOf = async (kind) => { + /* + * The marks come and go with the picture: while a render is in flight the relief keeps the last + * one, but between two sounds a mark can be a frame late. Waited for as ATTACHED, not visible — + * Motion's stem is a vertical segment, so its box is zero pixels wide and the default visible + * state is never reached however long it is given. + */ + const there = await page.waitForSelector(`.labs-mark[data-kind="${kind}"] .labs-mark__hit`, { state: 'attached', timeout: 6000 }).catch(() => null) + if (!there) log(`the ${kind} mark is not on the relief: ${JSON.stringify(await page.evaluate(() => ({ + marks: [...document.querySelectorAll('.labs-mark')].map((g) => g.dataset.kind).join() || 'none', + empty: !!document.querySelector('.labs-relief__empty'), loading: !!document.querySelector('.labs-relief__loading'), + canvas: !!document.querySelector('.labs-relief__gl canvas'), bench: document.querySelector('.labs-bench__title h2')?.textContent, + sliders: [...document.querySelectorAll('.labs-timbre [role="slider"], .labs-mark__hit')].map((el) => el.getAttribute('aria-label')).join(), + })))}`) + return readShape(kind) + } + const readShape = (kind) => page.evaluate((k) => { + const hit = document.querySelector(`.labs-mark[data-kind="${k}"] .labs-mark__hit`) + if (!hit) return null + const box = hit.ownerSVGElement.getBoundingClientRect() + const points = hit.getAttribute('points').split(' ').filter(Boolean).map((p) => p.split(',').map(Number)) + const mid = points[Math.floor(points.length / 2)] + return { + now: Number(hit.getAttribute('aria-valuenow')), text: hit.getAttribute('aria-valuetext'), + label: document.querySelector(`.labs-mark__label[data-kind="${k}"]`)?.textContent ?? '', + grab: { x: box.left + mid[0], y: box.top + mid[1] }, + // The point the drag is read on: the far end for the lines, the head for the stem. + far: { x: box.left + points[k === 'motion' ? 1 : Math.floor(points.length * 0.2)][0], y: box.top + points[k === 'motion' ? 1 : Math.floor(points.length * 0.2)][1] }, + lit: false, + } + }, kind) + /* + * How much one picture differs from another, pixel by pixel. + * + * Not the difference of their averages: a mark lit where the relief is already white has no + * headroom to raise an average, and a bright sound reads as no light at all. + */ + const apart = (before, after) => page.evaluate(async ([a, b]) => { + const load = async (b64) => { + const img = new Image(); img.src = `data:image/png;base64,${b64}`; await img.decode() + const c = document.createElement('canvas'); c.width = img.width; c.height = img.height + c.getContext('2d').drawImage(img, 0, 0) + return c.getContext('2d').getImageData(0, 0, c.width, c.height).data + } + const one = await load(a), two = await load(b) + let sum = 0 + for (let k = 0; k < one.length; k += 4) sum += Math.abs((0.2126 * one[k] + 0.7152 * one[k + 1] + 0.0722 * one[k + 2]) - (0.2126 * two[k] + 0.7152 * two[k + 1] + 0.0722 * two[k + 2])) + return sum / (one.length / 4) + }, [before, after]) + const frame = async (clip) => (await page.screenshot({ clip })).toString('base64') + for (const [kind, name, dx, dy, unit] of [['grain', 'Grain', 0, -52, 'dB'], ['space', 'Space', 96, 0, 'ms'], ['motion', 'Motion', 0, -44, 'ms']]) { + const before = await shapeOf(kind) + if (!before) { log(`the bench sound has no ${name} control`); continue } + // The mark lights where the pointer is on it — in the relief for a slice of it, in its own + // line for the bed and the stem. Measured away from the pointer, and with the names taken out + // of the picture, so a label's own backing cannot be taken for the light. + const hidden = await page.addStyleTag({ content: '.labs-marks__names { display: none !important }' }) + const clip = { x: Math.round(before.far.x - 60), y: Math.round(before.far.y - 40), width: 120, height: 80 } + await page.mouse.move(20, 20) + await page.waitForTimeout(420) + const cold = await frame(clip) + await page.mouse.move(before.grab.x, before.grab.y) + await page.waitForTimeout(420) + const warm = await frame(clip) + await page.mouse.move(20, 20) + await page.waitForTimeout(420) + const away = await frame(clip) + // The hand off the mark again gives the noise floor: what the picture does on its own. + const [change, still] = [await apart(cold, warm), await apart(cold, away)] + check(`${name} lights under the pointer`, change > 1 && change > still * 3, `${change.toFixed(2)} lit vs ${still.toFixed(2)} still`) + await page.mouse.move(before.grab.x, before.grab.y) + await page.waitForTimeout(300) + // Only the tag this check added: the page's own styles arrive in +

Audio SDK consumer

+

Two independent players sharing a host-owned AudioContext.

+ +Ready

+
diff --git a/examples/sdk/audio-browser/package.json b/examples/sdk/audio-browser/package.json
new file mode 100644
index 0000000..8a60199
--- /dev/null
+++ b/examples/sdk/audio-browser/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "paramrig-example-audio-browser",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite --host 0.0.0.0",
+    "build": "vite build"
+  },
+  "dependencies": {
+    "@paramrig/audio": "0.1.0",
+    "@paramrig/audio-browser": "0.1.0"
+  },
+  "devDependencies": {
+    "vite": "8.2.2"
+  }
+}
diff --git a/examples/sdk/audio-browser/sdk.js b/examples/sdk/audio-browser/sdk.js
new file mode 100644
index 0000000..683b384
--- /dev/null
+++ b/examples/sdk/audio-browser/sdk.js
@@ -0,0 +1 @@
+export { createAudioPlayer, audioWorkletUrl } from '@paramrig/audio-browser'; export { defaultPatch, renderPatch } from '@paramrig/audio'; export { encodeWav } from '@paramrig/audio/wav';
diff --git a/examples/sdk/audio-labs/README.md b/examples/sdk/audio-labs/README.md
new file mode 100644
index 0000000..badafe2
--- /dev/null
+++ b/examples/sdk/audio-labs/README.md
@@ -0,0 +1,12 @@
+# audio-labs example
+
+A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository.
+
+```sh
+npm install
+npm start
+```
+
+Requires Node.js 22 or newer. The checks fail if rendering, serialization or the public contract changes.
+
+No browser storage or editor is required.
diff --git a/examples/sdk/audio-labs/index.mjs b/examples/sdk/audio-labs/index.mjs
new file mode 100644
index 0000000..19032af
--- /dev/null
+++ b/examples/sdk/audio-labs/index.mjs
@@ -0,0 +1,7 @@
+import * as labs from '@paramrig/audio-labs'; import { renderPatch } from '@paramrig/audio';
+const sound = labs.generateSound({ ...labs.DEFAULT_CRITERIA, type: 'notification', minMs: 100, maxMs: 300 }, 171);
+const saved = JSON.parse(JSON.stringify(sound));
+const a = renderPatch(sound.patch, 16000, 64), b = renderPatch(saved.patch, 16000, 257);
+if (a.left.some((value, i) => value !== b.left[i] || !Number.isFinite(value))) throw Error('Saved patch replay changed');
+export const result = labs;
+console.log('audio-labs: public API completed successfully')
diff --git a/examples/sdk/audio-labs/package.json b/examples/sdk/audio-labs/package.json
new file mode 100644
index 0000000..2400962
--- /dev/null
+++ b/examples/sdk/audio-labs/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "paramrig-example-audio-labs",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "start": "node index.mjs"
+  },
+  "dependencies": {
+    "@paramrig/audio": "0.1.0",
+    "@paramrig/audio-labs": "0.1.0"
+  }
+}
diff --git a/examples/sdk/audio/README.md b/examples/sdk/audio/README.md
new file mode 100644
index 0000000..a7f6d3d
--- /dev/null
+++ b/examples/sdk/audio/README.md
@@ -0,0 +1,12 @@
+# audio example
+
+A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository.
+
+```sh
+npm install
+npm start
+```
+
+Requires Node.js 22 or newer. The checks fail if rendering, serialization or the public contract changes.
+
+No browser storage or editor is required.
diff --git a/examples/sdk/audio/index.mjs b/examples/sdk/audio/index.mjs
new file mode 100644
index 0000000..e11d9bc
--- /dev/null
+++ b/examples/sdk/audio/index.mjs
@@ -0,0 +1,8 @@
+import { defaultPatch, renderPatch } from '@paramrig/audio'; import { encodeWav } from '@paramrig/audio/wav';
+const patch = defaultPatch(); patch.duration = 0.1;
+const a = renderPatch(patch, 16000, 64), b = renderPatch(patch, 16000, 257);
+if (a.left.some((value, i) => !Number.isFinite(value) || value !== b.left[i])) throw Error('Block rendering changed');
+if (!a.left.some(value => value !== 0)) throw Error('Silent default patch');
+if (encodeWav(a, 16000).length !== 44 + 4 * a.left.length) throw Error('Invalid WAV length');
+export const result = a;
+console.log('audio: public API completed successfully')
diff --git a/examples/sdk/audio/package.json b/examples/sdk/audio/package.json
new file mode 100644
index 0000000..465fba9
--- /dev/null
+++ b/examples/sdk/audio/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "paramrig-example-audio",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "start": "node index.mjs"
+  },
+  "dependencies": {
+    "@paramrig/audio": "0.1.0"
+  }
+}
diff --git a/examples/sdk/controls/README.md b/examples/sdk/controls/README.md
new file mode 100644
index 0000000..83a4782
--- /dev/null
+++ b/examples/sdk/controls/README.md
@@ -0,0 +1,12 @@
+# controls example
+
+A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository.
+
+```sh
+npm install
+npm run dev
+```
+
+Vite prints the local URL. The example uses no repository aliases or application sources. Run `npm run build` to produce a standalone browser distribution.
+
+React and React DOM belong to the host. The explicit stylesheet import is in `sdk.js`. Values, gesture history and resources belong to this application.
diff --git a/examples/sdk/controls/index.html b/examples/sdk/controls/index.html
new file mode 100644
index 0000000..e944445
--- /dev/null
+++ b/examples/sdk/controls/index.html
@@ -0,0 +1,11 @@
+Controls SDK consumer
+
+

Controls SDK consumer

The host keeps its own typography, colours and button styles.

Starting + diff --git a/examples/sdk/controls/package.json b/examples/sdk/controls/package.json new file mode 100644 index 0000000..1c5cbf9 --- /dev/null +++ b/examples/sdk/controls/package.json @@ -0,0 +1,17 @@ +{ + "name": "paramrig-example-controls", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build" + }, + "dependencies": { + "@paramrig/controls": "0.1.0", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "vite": "8.2.2" + } +} diff --git a/examples/sdk/controls/sdk.js b/examples/sdk/controls/sdk.js new file mode 100644 index 0000000..70a985d --- /dev/null +++ b/examples/sdk/controls/sdk.js @@ -0,0 +1 @@ +export { RigControls, ParameterControl, createControlRegistry } from '@paramrig/controls'; export { createElement, useState } from 'react'; export { createRoot } from 'react-dom/client'; import '@paramrig/controls/styles.css'; diff --git a/examples/sdk/core/README.md b/examples/sdk/core/README.md new file mode 100644 index 0000000..ed3c80b --- /dev/null +++ b/examples/sdk/core/README.md @@ -0,0 +1,12 @@ +# core example + +A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository. + +```sh +npm install +npm start +``` + +Requires Node.js 22 or newer. The checks fail if rendering, serialization or the public contract changes. + +No browser storage or editor is required. diff --git a/examples/sdk/core/index.mjs b/examples/sdk/core/index.mjs new file mode 100644 index 0000000..c607c54 --- /dev/null +++ b/examples/sdk/core/index.mjs @@ -0,0 +1,2 @@ +import { evaluateExpression, normalizeValue } from '@paramrig/core'; if (evaluateExpression('2 + 3', () => 0) !== 5) throw Error('Expression failed'); export const result = normalizeValue; +console.log('core: public API completed successfully') diff --git a/examples/sdk/core/package.json b/examples/sdk/core/package.json new file mode 100644 index 0000000..3026d1c --- /dev/null +++ b/examples/sdk/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "paramrig-example-core", + "private": true, + "type": "module", + "scripts": { + "start": "node index.mjs" + }, + "dependencies": { + "@paramrig/core": "0.1.0" + } +} diff --git a/examples/sdk/scene/README.md b/examples/sdk/scene/README.md new file mode 100644 index 0000000..71461dc --- /dev/null +++ b/examples/sdk/scene/README.md @@ -0,0 +1,12 @@ +# scene example + +A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository. + +```sh +npm install +npm run dev +``` + +Vite prints the local URL. The example uses no repository aliases or application sources. Run `npm run build` to produce a standalone browser distribution. + +Three.js is an explicit host dependency. Both integrations share that single copy. Destroying the optional viewer leaves the host renderer running. diff --git a/examples/sdk/scene/index.html b/examples/sdk/scene/index.html new file mode 100644 index 0000000..0786bf5 --- /dev/null +++ b/examples/sdk/scene/index.html @@ -0,0 +1,24 @@ +Scene SDK consumer + +

Scene SDK consumer

The optional viewer and an independent host renderer use the same scene engine. No React.

+ + +Starting

Optional viewer

Host renderer

+ diff --git a/examples/sdk/scene/package.json b/examples/sdk/scene/package.json new file mode 100644 index 0000000..3cec3e1 --- /dev/null +++ b/examples/sdk/scene/package.json @@ -0,0 +1,16 @@ +{ + "name": "paramrig-example-scene", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build" + }, + "dependencies": { + "@paramrig/scene": "0.1.0", + "three": "0.185.1" + }, + "devDependencies": { + "vite": "8.2.2" + } +} diff --git a/examples/sdk/scene/sdk.js b/examples/sdk/scene/sdk.js new file mode 100644 index 0000000..c6595e8 --- /dev/null +++ b/examples/sdk/scene/sdk.js @@ -0,0 +1 @@ +export { createSceneDocument, importProject } from '@paramrig/scene'; export { createSceneInstance } from '@paramrig/scene/engine'; export { createSceneViewer } from '@paramrig/scene/browser'; import { Scene, WebGLRenderer } from 'three'; export const THREE = { Scene, WebGLRenderer }; diff --git a/examples/sdk/vector-controls/README.md b/examples/sdk/vector-controls/README.md new file mode 100644 index 0000000..65b7f11 --- /dev/null +++ b/examples/sdk/vector-controls/README.md @@ -0,0 +1,12 @@ +# vector-controls example + +A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository. + +```sh +npm install +npm run dev +``` + +Vite prints the local URL. The example uses no repository aliases or application sources. Run `npm run build` to produce a standalone browser distribution. + +No browser storage or editor is required. diff --git a/examples/sdk/vector-controls/index.html b/examples/sdk/vector-controls/index.html new file mode 100644 index 0000000..b51205b --- /dev/null +++ b/examples/sdk/vector-controls/index.html @@ -0,0 +1,18 @@ +Vector with React controls + + +

Vector with React controls

The host owns the values, document, export and lifecycle.

+ diff --git a/examples/sdk/vector-controls/package.json b/examples/sdk/vector-controls/package.json new file mode 100644 index 0000000..84aa9c6 --- /dev/null +++ b/examples/sdk/vector-controls/package.json @@ -0,0 +1,18 @@ +{ + "name": "paramrig-example-vector-controls", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build" + }, + "dependencies": { + "@paramrig/vector": "0.1.0", + "@paramrig/controls": "0.1.0", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "vite": "8.2.2" + } +} diff --git a/examples/sdk/vector-controls/sdk.js b/examples/sdk/vector-controls/sdk.js new file mode 100644 index 0000000..f7186d4 --- /dev/null +++ b/examples/sdk/vector-controls/sdk.js @@ -0,0 +1 @@ +export { createVectorDocument, createVectorElement } from '@paramrig/vector'; export { createVectorRenderer } from '@paramrig/vector/browser'; export { RigControls } from '@paramrig/controls'; export { createElement, useState, useEffect, useRef } from 'react'; export { createRoot } from 'react-dom/client'; import '@paramrig/controls/styles.css'; diff --git a/examples/sdk/vector/README.md b/examples/sdk/vector/README.md new file mode 100644 index 0000000..9e272ae --- /dev/null +++ b/examples/sdk/vector/README.md @@ -0,0 +1,12 @@ +# vector example + +A standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository. + +```sh +npm install +npm run dev +``` + +Vite prints the local URL. The example uses no repository aliases or application sources. Run `npm run build` to produce a standalone browser distribution. + +Fonts are supplied by this host from `public/fonts`, with the included SIL Open Font License. Replace the resolver to use your own resources. PDF outlines use the supplied TTF; browser text uses WOFF2. Server rendering is not claimed to match browser typography. diff --git a/examples/sdk/vector/index.html b/examples/sdk/vector/index.html new file mode 100644 index 0000000..8e8f01b --- /dev/null +++ b/examples/sdk/vector/index.html @@ -0,0 +1,44 @@ + +Vector SDK consumer + +

Vector SDK consumer

Host-provided fonts, SVG rendering and exports without React.

+ +Loading
+ diff --git a/examples/sdk/vector/package.json b/examples/sdk/vector/package.json new file mode 100644 index 0000000..5b9d9ba --- /dev/null +++ b/examples/sdk/vector/package.json @@ -0,0 +1,15 @@ +{ + "name": "paramrig-example-vector", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build" + }, + "dependencies": { + "@paramrig/vector": "0.1.0" + }, + "devDependencies": { + "vite": "8.2.2" + } +} diff --git a/examples/sdk/vector/public/fonts/SourceSerif4-OFL.txt b/examples/sdk/vector/public/fonts/SourceSerif4-OFL.txt new file mode 100644 index 0000000..98507d3 --- /dev/null +++ b/examples/sdk/vector/public/fonts/SourceSerif4-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/sdk/vector/public/fonts/SourceSerif4.ttf b/examples/sdk/vector/public/fonts/SourceSerif4.ttf new file mode 100644 index 0000000..813691b Binary files /dev/null and b/examples/sdk/vector/public/fonts/SourceSerif4.ttf differ diff --git a/examples/sdk/vector/public/fonts/SourceSerif4.woff2 b/examples/sdk/vector/public/fonts/SourceSerif4.woff2 new file mode 100644 index 0000000..53a05f3 Binary files /dev/null and b/examples/sdk/vector/public/fonts/SourceSerif4.woff2 differ diff --git a/examples/sdk/vector/sdk.js b/examples/sdk/vector/sdk.js new file mode 100644 index 0000000..d1c8efd --- /dev/null +++ b/examples/sdk/vector/sdk.js @@ -0,0 +1 @@ +export { createVectorRenderer } from '@paramrig/vector/browser'; export { createVectorDocument, createVectorElement, resolveRigValues } from '@paramrig/vector'; export { exportPdf, pdfPages } from '@paramrig/vector/pdf'; diff --git a/package-lock.json b/package-lock.json index faa1de4..3ddffe5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "paramrig", "version": "0.1.0", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@react-three/fiber": "^9.4.0", + "fontkit": "^2.0.4", "html-to-image": "^1.11.13", "lucide-react": "^1.41.0", "opentype.js": "^2.0.0", @@ -41,6 +45,7 @@ "globals": "^17.12.0", "jsdom": "^30.0.1", "playwright-core": "^1.63.0", + "postcss": "^8.5.26", "typescript": "~6.0.3", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", @@ -303,7 +308,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@eslint-community/eslint-utils": { @@ -685,6 +689,38 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@paramrig/audio": { + "resolved": "packages/audio", + "link": true + }, + "node_modules/@paramrig/audio-browser": { + "resolved": "packages/audio-browser", + "link": true + }, + "node_modules/@paramrig/audio-labs": { + "resolved": "packages/audio-labs", + "link": true + }, + "node_modules/@paramrig/controls": { + "resolved": "packages/controls", + "link": true + }, + "node_modules/@paramrig/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@paramrig/scene": { + "resolved": "packages/scene", + "link": true + }, + "node_modules/@paramrig/vector": { + "resolved": "packages/vector", + "link": true + }, + "node_modules/@paramrig/web": { + "resolved": "packages/web-sdk", + "link": true + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -1652,6 +1688,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1755,7 +1800,6 @@ "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "dev": true, "license": "MIT" }, "node_modules/@types/aria-query": { @@ -1847,14 +1891,12 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "dev": true, "license": "MIT" }, "node_modules/@types/three": { "version": "0.185.4", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", - "dev": true, "license": "MIT", "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", @@ -2148,6 +2190,15 @@ "node": "20 || >=22" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -2209,6 +2260,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2378,6 +2438,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2678,7 +2744,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2717,7 +2782,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -2771,6 +2835,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3418,7 +3499,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", - "dev": true, "license": "MIT" }, "node_modules/min-indent": { @@ -3553,6 +3633,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/paper": { "version": "0.12.18", "resolved": "https://registry.npmjs.org/paper/-/paper-0.12.18.tgz", @@ -3908,6 +3994,12 @@ "node": ">=4" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -4101,6 +4193,12 @@ "three": ">= 0.159.0" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -4464,6 +4562,26 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4836,6 +4954,83 @@ "optional": true } } + }, + "packages/audio": { + "name": "@paramrig/audio", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/core": "0.1.0" + } + }, + "packages/audio-browser": { + "name": "@paramrig/audio-browser", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/audio": "0.1.0" + } + }, + "packages/audio-labs": { + "name": "@paramrig/audio-labs", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/audio": "0.1.0", + "@paramrig/core": "0.1.0" + } + }, + "packages/controls": { + "name": "@paramrig/controls", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/core": "0.1.0", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@types/react": "^19.1.13", + "lucide-react": "^1.41.0" + }, + "peerDependencies": { + "react": ">=19.2.0 <20", + "react-dom": ">=19.2.0 <20" + } + }, + "packages/core": { + "name": "@paramrig/core", + "version": "0.1.0", + "license": "MIT" + }, + "packages/scene": { + "name": "@paramrig/scene", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/core": "0.1.0", + "@types/three": "^0.185.4", + "opentype.js": "^2.0.0", + "three-bvh-csg": "^0.0.18", + "three-mesh-bvh": "^0.9.14" + }, + "peerDependencies": { + "three": ">=0.185.1 <0.186.0" + } + }, + "packages/vector": { + "name": "@paramrig/vector", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@paramrig/core": "0.1.0", + "fontkit": "^2.0.4", + "opentype.js": "^2.0.0", + "paper": "^0.12.18" + } + }, + "packages/web-sdk": { + "name": "@paramrig/web", + "version": "0.1.2", + "license": "MIT" } } } diff --git a/package.json b/package.json index 0562ed5..9084c18 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,19 @@ "lint": "eslint src --max-warnings 0", "typecheck": "tsc -b --noEmit", "e2e": "node e2e/run.mjs", - "build:web-sdk": "vite build --config vite.web-sdk.config.ts && tsc -p tsconfig.web-sdk.json && node scripts/check-web-sdk.mjs", - "test:web-service": "node --experimental-strip-types --test services/web/server.test.mjs" + "build:web-sdk": "vite build --config vite.web-sdk.config.ts && node scripts/build-web-declarations.mjs && node scripts/check-web-sdk.mjs", + "test:web-service": "node --experimental-strip-types --test services/web/server.test.mjs", + "build:sdks": "node scripts/build-sdks.mjs", + "test:sdks": "node scripts/test-sdk-consumers.mjs", + "build:modules": "tsc -b && node scripts/build-modules.mjs", + "build:catalog": "node scripts/generate-module-catalog.mjs && node scripts/generate-module-thumbnails.mjs" }, "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@react-three/fiber": "^9.4.0", + "fontkit": "^2.0.4", "html-to-image": "^1.11.13", "lucide-react": "^1.41.0", "opentype.js": "^2.0.0", @@ -56,6 +61,10 @@ "typescript": "~6.0.3", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", - "vitest": "^5.0.0" - } + "vitest": "^5.0.0", + "postcss": "^8.5.26" + }, + "workspaces": [ + "packages/*" + ] } diff --git a/packages/audio-browser/LICENSE b/packages/audio-browser/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/audio-browser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audio-browser/README.md b/packages/audio-browser/README.md new file mode 100644 index 0000000..47e5b3d --- /dev/null +++ b/packages/audio-browser/README.md @@ -0,0 +1,28 @@ +# @paramrig/audio-browser + +Independent AudioWorklet players for `@paramrig/audio`, without React or editor UI. + +```sh +npm install @paramrig/audio @paramrig/audio-browser +``` + +```js +import { defaultPatch } from '@paramrig/audio' +import { createAudioPlayer } from '@paramrig/audio-browser' + +const context = new AudioContext() +const player = createAudioPlayer({ context, destination: context.destination }) +// Start from a user gesture in a secure browser context. +await player.start(defaultPatch()) +player.setPatch(defaultPatch()) +player.trigger() +player.stop() +player.destroy() +// Only the host may decide to close its context. +``` + +The player exposes `start`, `setPatch`, `setGate`, `trigger`, `stop`, `getMeter`, `getError`, `isPlaying`, `subscribe` and `destroy`. A subscription returns an unsubscribe function. `start` returns false on unavailable playback and records the error; `onError` can receive it. A destroyed player cannot restart. + +The compiled processor is distributed with the package and referenced relative to its module. Bundlers must copy URL assets. `audioWorkletUrl` exposes the default location; supply `workletUrl` when your bundler or CSP requires another location. Serve the processor as JavaScript from an allowed origin. + +Supply `resolveWavetable` for host resources. Each player and worklet processor maintains its own user table registry. Two players may use the same resource ID with different frames. The host owns its AudioContext and destination; destroying a player disconnects only that player's nodes, closes its ports and releases its subscriptions. diff --git a/packages/audio-browser/package.json b/packages/audio-browser/package.json new file mode 100644 index 0000000..60a2188 --- /dev/null +++ b/packages/audio-browser/package.json @@ -0,0 +1,31 @@ +{ + "name": "@paramrig/audio-browser", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Independent Web Audio players for the ParamRig audio engine", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/src/audio/player.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "dependencies": { + "@paramrig/audio": "0.1.0" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/audio-browser" + } +} diff --git a/packages/audio-labs/LICENSE b/packages/audio-labs/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/audio-labs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audio-labs/README.md b/packages/audio-labs/README.md new file mode 100644 index 0000000..7d31a3b --- /dev/null +++ b/packages/audio-labs/README.md @@ -0,0 +1,21 @@ +# @paramrig/audio-labs + +ParamRig's headless sound generation, variation, fusion, criteria and catalogs. Depends on Audio and Core; it does not install an editor or React. + +```sh +npm install @paramrig/audio-labs +``` + +```js +import { DEFAULT_CRITERIA, generateSound } from '@paramrig/audio-labs' +import { renderPatch } from '@paramrig/audio' + +const sound = generateSound({ ...DEFAULT_CRITERIA, type: 'notification' }, 171) +const samples = renderPatch(sound.patch, 48000) +``` + +Persist the returned sound, including its patch and identity metadata. Replay the saved patch directly; do not regenerate it from a recipe during migration. Use `fusionCompatibility` before fusion and surface its explanation. Criteria and request validators expose the existing generation limits. + +Generation is reproducible for the same inputs and JavaScript runtime. Transcendental math can differ in the last floating-point digits across architectures, so regenerated patch hashes are not a portable identifier. A saved patch retains its exact values and identity across machines. + +This package is synchronous and headless. Hosts choose their own worker, cancellation and playback integration. Node 22 and modern ESM browsers are supported. React controls and the Sound Labs editor are separate concerns. diff --git a/packages/audio-labs/package.json b/packages/audio-labs/package.json new file mode 100644 index 0000000..9ec50f8 --- /dev/null +++ b/packages/audio-labs/package.json @@ -0,0 +1,32 @@ +{ + "name": "@paramrig/audio-labs", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "ParamRig sound generation, variation and fusion", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/src/audio/labs/sdk.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/audio-labs" + }, + "dependencies": { + "@paramrig/core": "0.1.0", + "@paramrig/audio": "0.1.0" + } +} diff --git a/packages/audio/LICENSE b/packages/audio/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/audio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audio/README.md b/packages/audio/README.md new file mode 100644 index 0000000..b477581 --- /dev/null +++ b/packages/audio/README.md @@ -0,0 +1,22 @@ +# @paramrig/audio + +The headless ParamRig sound engine: patches, deterministic synthesis, independent voices and block processing. No React, Web Audio, Three.js or Vector engine is installed. + +```sh +npm install @paramrig/audio +``` + +```js +import { defaultPatch, renderPatch } from '@paramrig/audio' +import { encodeWav } from '@paramrig/audio/wav' + +const patch = defaultPatch() +const samples = renderPatch(patch, 48000, 128) +const wav = encodeWav(samples, 48000) +``` + +`createVoice`, `processVoice`, `updateVoice`, `triggerVoice` and `setVoiceGate` provide streaming synthesis without a browser. Pass `resolveWavetable` to `createVoice` to keep user resources independent between voices. A resolver returns a `Wavetable` or `null` when the resource is unavailable. Treat returned frames as immutable. + +Additional entries: `wav`, `wavetables`, `bindings`, `fields`, `curves` and `random`. Sub-entries reduce imported code; install the separate `@paramrig/audio-labs` package only when generation, variation or fusion is needed. Browser playback is available separately in `@paramrig/audio-browser`. + +ESM and TypeScript declarations support Node 22 and modern browsers. Use the existing patch validator for untrusted inputs and valid sample rates. Missing user tables retain their existing silent-layer behavior in the headless engine; the browser player reports missing resources before starting. diff --git a/packages/audio/package.json b/packages/audio/package.json new file mode 100644 index 0000000..620cf2e --- /dev/null +++ b/packages/audio/package.json @@ -0,0 +1,55 @@ +{ + "name": "@paramrig/audio", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Headless deterministic ParamRig sound engine", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/packages/audio/src/index.d.ts", + "import": "./dist/index.js" + }, + "./wav": { + "types": "./dist/types/src/audio/dsp/wav.d.ts", + "import": "./dist/wav.js" + }, + "./wavetables": { + "types": "./dist/types/src/audio/dsp/wavetable.d.ts", + "import": "./dist/wavetables.js" + }, + "./bindings": { + "types": "./dist/types/src/audio/rig.d.ts", + "import": "./dist/bindings.js" + }, + "./package.json": "./package.json", + "./fields": { + "types": "./dist/types/src/audio/fields.d.ts", + "import": "./dist/fields.js" + }, + "./curves": { + "types": "./dist/types/src/audio/dsp/curve.d.ts", + "import": "./dist/curves.js" + }, + "./random": { + "types": "./dist/types/src/audio/dsp/rng.d.ts", + "import": "./dist/random.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/audio" + }, + "dependencies": { + "@paramrig/core": "0.1.0" + } +} diff --git a/packages/audio/src/index.ts b/packages/audio/src/index.ts new file mode 100644 index 0000000..9522210 --- /dev/null +++ b/packages/audio/src/index.ts @@ -0,0 +1,3 @@ +export * from '../../../src/audio/types' +export * from '../../../src/audio/patch' +export * from '../../../src/audio/dsp/render' diff --git a/packages/controls/LICENSE b/packages/controls/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/controls/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/controls/README.md b/packages/controls/README.md new file mode 100644 index 0000000..baf0370 --- /dev/null +++ b/packages/controls/README.md @@ -0,0 +1,15 @@ +# @paramrig/controls + +Controlled React parameter inputs, with no audio, vector or 3D engine. + +```jsx +import { RigControls } from '@paramrig/controls'; +import '@paramrig/controls/styles.css'; + setValues(old => ({ ...old, [id]: value }))} + onGestureStart={beginGesture} onGestureEnd={endGesture} onGestureCancel={cancelGesture} /> +``` + +React and React DOM are peer dependencies, tested with 19.2. Styles are optional, scoped to the components and their portals, and use system fonts. `ParameterControl` renders one input; `ControlsScope` scopes a host's custom inputs. No document, history or browser storage is accessed. + +Use `createControlRegistry()` and pass `registry` to override a kind (`number`) or a specific view (`select:font-library`). Resources are explicit: `resources.load(id, signal)` returns a Blob, and `resources.save(file, accept, maxMB)` returns a resource value. A file input is disabled until its host provides these callbacks. Specialised catalogues belong to the host's registered components. diff --git a/packages/controls/package.json b/packages/controls/package.json new file mode 100644 index 0000000..5a37fd9 --- /dev/null +++ b/packages/controls/package.json @@ -0,0 +1,48 @@ +{ + "name": "@paramrig/controls", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Optional controlled React inputs for ParamRig parameters", + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./dist/types/src/controls/index.d.ts", + "import": "./dist/index.js" + }, + "./registry": { + "types": "./dist/types/src/controls/registry.d.ts", + "import": "./dist/registry.js" + }, + "./styles.css": { + "types": "./dist/styles.d.ts", + "default": "./dist/styles.css" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "dependencies": { + "@paramrig/core": "0.1.0", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-popover": "^1.1.15", + "lucide-react": "^1.41.0", + "@types/react": "^19.1.13" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/controls" + }, + "peerDependencies": { + "react": ">=19.2.0 <20", + "react-dom": ">=19.2.0 <20" + } +} diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..2f24a44 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,17 @@ +# @paramrig/core + +Shared ParamRig parameter and rig contracts, validation, binding transforms, expressions and animated values. ESM with TypeScript declarations. No React, browser storage or rendering engine. + +```sh +npm install @paramrig/core +``` + +```js +import { evaluateExpression, normalizeValue } from '@paramrig/core' + +const value = evaluateExpression('width * 2', id => id === 'width' ? 12 : 0) +``` + +The individual `types`, `extended-types`, `binding`, `sanitize`, `expression`, `values`, `parameter-values`, `drivers`, `fonts` and `font-value` entries expose the same implementation used by the application. Importing a contract does not initialize a renderer or read storage. Node 22 and modern ESM browsers are supported. + +Validation preserves the application's existing limits and fallback rules. Callers retain ownership of documents, history and persistence. Font contracts describe carried data; this package does not fetch or register fonts. diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..fe35d30 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,68 @@ +{ + "name": "@paramrig/core", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Shared ParamRig contracts, validation and animated values", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/packages/core/src/index.d.ts", + "import": "./dist/index.js" + }, + "./types": { + "types": "./dist/types/packages/core/src/types.d.ts", + "import": "./dist/types.js" + }, + "./extended-types": { + "types": "./dist/types/packages/core/src/extended-types.d.ts", + "import": "./dist/extended-types.js" + }, + "./binding": { + "types": "./dist/types/packages/core/src/binding.d.ts", + "import": "./dist/binding.js" + }, + "./sanitize": { + "types": "./dist/types/packages/core/src/sanitize.d.ts", + "import": "./dist/sanitize.js" + }, + "./expression": { + "types": "./dist/types/packages/core/src/expression.d.ts", + "import": "./dist/expression.js" + }, + "./values": { + "types": "./dist/types/packages/core/src/values.d.ts", + "import": "./dist/values.js" + }, + "./parameter-values": { + "types": "./dist/types/packages/core/src/parameter-values.d.ts", + "import": "./dist/parameter-values.js" + }, + "./drivers": { + "types": "./dist/types/packages/core/src/drivers.d.ts", + "import": "./dist/drivers.js" + }, + "./fonts": { + "types": "./dist/types/packages/core/src/fonts.d.ts", + "import": "./dist/fonts.js" + }, + "./font-value": { + "types": "./dist/types/packages/core/src/font-value.d.ts", + "import": "./dist/font-value.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/core" + } +} diff --git a/packages/core/src/binding.ts b/packages/core/src/binding.ts new file mode 100644 index 0000000..a0b1ec1 --- /dev/null +++ b/packages/core/src/binding.ts @@ -0,0 +1,94 @@ +import { evaluateExpression } from './expression' +import type { BezierCurve } from './types' + +/** + * What every rig shares, whatever it drives. + * + * A binding is a control writing to a property, and the arithmetic between the two is the same + * whether the property belongs to a drawing or to a scene: clamp it, scale it, offset it, or + * replace all of that with an expression over the control's value. It lives here rather than in + * either editor so that the two cannot drift — a rig file written for one has to mean the same + * thing to the other. + */ + +/** + * How a control reaches a property. A number can be rescaled on the way — `min`/`max` clamp it, + * `scale` and `offset` map it, and `expression` replaces all of that with arithmetic over the + * control's value, written as `value`. + */ +export type BindingTransform = { + min?: number + max?: number + scale?: number + offset?: number + expression?: string + /** + * When both are present, the control is read as 0..1 and mapped onto this range instead of + * being written through. That is how one macro drives several fields, each with its own span. + */ + from?: number + to?: number + /** Reverse the 0..1 amount before it is mapped. */ + invert?: boolean + /** How the amount travels from from to to. Linear when omitted. */ + curve?: BezierCurve +} + +/** + * A number put through a binding's transform. Anything that is not finite is left alone: a control + * that produces a NaN should leave the property where it was rather than erase it. + */ +export function applyTransform(value: number, transform: BindingTransform | undefined, resolve: (id: string) => number): number { + if (!transform) return value + let next = value + if (transform.expression) { + try { + next = evaluateExpression(transform.expression, (id) => (id === 'value' ? value : resolve(id))) + } catch { + return value + } + } else { + if (typeof transform.scale === 'number') next *= transform.scale + if (typeof transform.offset === 'number') next += transform.offset + } + if (typeof transform.min === 'number') next = Math.max(transform.min, next) + if (typeof transform.max === 'number') next = Math.min(transform.max, next) + return Number.isFinite(next) ? next : value +} + +/** A control id from what it was called, kept unique against the ones already there. */ +export function controlId(label: string, taken: Set): string { + const base = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'control' + if (!taken.has(base)) return base + for (let index = 2; index < 500; index += 1) { + const candidate = `${base}-${index}` + if (!taken.has(candidate)) return candidate + } + return `${base}-${crypto.randomUUID().slice(0, 8)}` +} + +/** The transform of a binding, read from a file that could say anything. */ +export function sanitizeTransform(value: unknown): BindingTransform | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const source = value as Record + const transform: BindingTransform = {} + for (const key of ['min', 'max', 'scale', 'offset'] as const) { + const number = source[key] + if (typeof number === 'number' && Number.isFinite(number)) transform[key] = number + } + const expression = source.expression + if (typeof expression === 'string' && expression.trim()) transform.expression = expression.trim().slice(0, 1000) + for (const key of ['from', 'to'] as const) { + const number = source[key] + if (typeof number === 'number' && Number.isFinite(number)) transform[key] = number + } + if (source.invert === true) transform.invert = true + if (source.curve && typeof source.curve === 'object' && !Array.isArray(source.curve)) { + const curve = source.curve as Record + const ok = (point: unknown) => Array.isArray(point) && point.length >= 2 && point.every((n) => typeof n === 'number' && Number.isFinite(n)) + if (curve.type === 'cubic-bezier' && ok(curve.p0) && ok(curve.p1) && ok(curve.p2) && ok(curve.p3)) { + transform.curve = curve as unknown as BezierCurve + } + } + return Object.keys(transform).length > 0 ? transform : undefined +} diff --git a/packages/core/src/drivers.ts b/packages/core/src/drivers.ts new file mode 100644 index 0000000..95acdc2 --- /dev/null +++ b/packages/core/src/drivers.ts @@ -0,0 +1,87 @@ +import type { ParameterDef, ParamValue } from './types' +import type { ModulationValue, ValueSource } from './extended-types' +import { evaluateExpression } from './expression' +import { normalizeValue } from './parameter-values' + +export function sampleModulation(v: ModulationValue, time: number): number { + const p = ((time * v.rate + v.phase) % 1 + 1) % 1 + let wave = Math.sin(p * Math.PI * 2) + if (v.mode === 'triangle') wave = 1 - 4 * Math.abs(p - 0.5) + if (v.mode === 'square') wave = p < 0.5 ? 1 : -1 + if (v.mode === 'noise') { + const hash = (i: number) => { const n = Math.sin(i * 127.1 + v.seed * 311.7) * 43758.5453; return (n - Math.floor(n)) * 2 - 1 } + const x = time * v.rate + v.phase, i = Math.floor(x), t = x-i, u = t*t*(3-2*t) + wave = hash(i) + (hash(i+1)-hash(i))*u + } + if (v.mode === 'envelope') { + const t = Math.max(0,time + v.phase) + wave = t < v.attack ? t/v.attack : t < v.attack+v.hold ? 1 : Math.max(0,1-(t-v.attack-v.hold)/v.release) + } + return v.offset + wave * v.amplitude +} + +export function mixValues(a: ParamValue, b: ParamValue, amount: number): ParamValue { + if (typeof a === 'number' && typeof b === 'number') return a+(b-a)*amount + if (typeof a === 'string' && typeof b === 'string' && /^#[\da-f]{6}([\da-f]{2})?$/i.test(a) && /^#[\da-f]{6}([\da-f]{2})?$/i.test(b)) { + const aa=a.slice(1).padEnd(8,'f'), bb=b.slice(1).padEnd(8,'f') + return '#'+[0,2,4,6].map(i=>Math.round(parseInt(aa.slice(i,i+2),16)*(1-amount)+parseInt(bb.slice(i,i+2),16)*amount).toString(16).padStart(2,'0')).join('') + } + if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((v,i)=>mixValues(v,b[i]!,amount)) + if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) { + const aa=a as Record, bb=b as Record + return Object.fromEntries(Object.keys(aa).map(k=>[k, Object.hasOwn(bb,k) ? mixValues(aa[k]!,bb[k]!,amount) : aa[k]!])) + } + return structuredClone(amount < 0.5 ? a : b) +} + +export function drivenValues(parameters: ParameterDef[], input: Record, sources: Record, time: number) { + const output = {...input} + const errors: Record = {} + const drivers = new Mapnumber)=>ParamValue}>() + const add = (target:string, owner:string, read:(resolve:(id:string)=>number)=>ParamValue) => { + if (!parameters.some(p=>p.id===target)) { errors[owner]=`Unknown target: ${target}`; return } + if (drivers.has(target)) { errors[owner]=`Multiple drivers target ${target}`; return } + drivers.set(target,{owner,read}) + } + for (const p of parameters) { + if (p.kind !== 'number') continue + const source = sources[p.id] ?? p.defaultSource ?? { mode: 'local' } + const validNumericSource = (id: string | undefined) => id && parameters.some(item => item.id === id && item.kind === 'number' && !item.readOnly) + if (source.mode === 'parameter') { + if (!validNumericSource(source.source)) errors[p.id] = 'Choose a numeric source parameter' + else add(p.id, p.id, resolve => resolve(source.source!)) + } + if (source.mode === 'expression') { + const expression = source.expression?.trim() + if (!expression) errors[p.id] = 'Enter an expression' + else add(p.id, p.id, resolve => evaluateExpression(expression, id => id === 't' ? time : resolve(id))) + } + if (source.mode === 'macro') { + if (!validNumericSource(source.source)) errors[p.id] = 'Choose a macro source parameter' + else add(p.id, p.id, resolve => source.min + (source.max - source.min) * (resolve(source.source!) - (parameters.find(item => item.id === source.source) as Extract).min) / ((parameters.find(item => item.id === source.source) as Extract).max - (parameters.find(item => item.id === source.source) as Extract).min || 1)) + } + if (source.mode === 'modulation') add(p.id, p.id, () => sampleModulation({ ...source, enabled: true, mode: source.shape }, time)) + if (source.mode === 'blend') { + if (source.source && !validNumericSource(source.source)) errors[p.id] = 'Choose a blend source parameter' + else add(p.id, p.id, resolve => source.from + (source.to - source.from) * (source.source ? resolve(source.source) : source.amount ?? 0)) + } + } + const resolved = new Set(), visiting = new Set() + const resolve = (id:string): number => { + if (visiting.has(id)) throw new Error('Circular parameter link') + const driver = drivers.get(id) + if (driver && !resolved.has(id)) { + visiting.add(id) + try { output[id] = normalizeValue(parameters.find(p=>p.id===id)!,driver.read(resolve)); resolved.add(id) } + catch (error) { errors[driver.owner] = error instanceof Error ? error.message : 'Invalid driver'; throw error } + finally { visiting.delete(id) } + } + const value=output[id] + if (typeof value!=='number') throw new Error(`Not a numeric parameter: ${id}`) + return value + } + for (const id of drivers.keys()) { + try { resolve(id) } catch { /* Keep the stored value and expose the error alongside the controller. */ } + } + return {values:output,errors} +} diff --git a/packages/core/src/expression.ts b/packages/core/src/expression.ts new file mode 100644 index 0000000..90a4ad1 --- /dev/null +++ b/packages/core/src/expression.ts @@ -0,0 +1,48 @@ +const functions: Record number> = { + sin: Math.sin, cos: Math.cos, abs: Math.abs, min: Math.min, max: Math.max, + sqrt: Math.sqrt, floor: Math.floor, ceil: Math.ceil, round: Math.round, + clamp: (v,lo,hi) => Math.max(lo,Math.min(hi,v)), pow: Math.pow, +} + +/** Arithmetic only. No eval, property access, assignment, or executable source. */ +export function evaluateExpression(source: string, resolve: (id: string) => number): number { + if (source.length > 1000) throw new Error('Expression is too long') + const tokens = source.match(/(?:\d*\.\d+|\d+\.?\d*)(?:e[+-]?\d+)?|[A-Za-z_][\w]*|[()+\-*/%,]/gi) ?? [] + if (tokens.join('') !== source.replace(/\s/g,'')) throw new Error('Use numbers, parameter names and arithmetic operators') + let i = 0 + let depth = 0 + const atom = (): number => { + if (++depth > 64) throw new Error('Expression is too deeply nested') + try { + const token = tokens[i++] + if (!token) throw new Error('Expected a value') + if (token === '+' || token === '-') return (token === '-' ? -1 : 1) * atom() + if (token === '(') { const v = sum(); if (tokens[i++] !== ')') throw new Error('Missing closing parenthesis'); return v } + if (/^[\d.]/.test(token)) return Number(token) + if (!/^[A-Za-z_]/.test(token)) throw new Error('Expected a number or parameter') + if (tokens[i] === '(') { + const fn = Object.hasOwn(functions, token) ? functions[token] : undefined + if (!fn) throw new Error(`Unknown function: ${token}`) + i++ + const args = [sum()] + while (tokens[i] === ',') { i++; args.push(sum()) } + if (tokens[i++] !== ')') throw new Error('Missing closing parenthesis') + return fn(...args) + } + return token === 'pi' ? Math.PI : resolve(token) + } finally { depth-- } + } + const product = (): number => { + let v = atom() + while (['*','/','%'].includes(tokens[i] ?? '')) { const op = tokens[i++]; const rhs = atom(); v = op === '*' ? v * rhs : op === '/' ? v / rhs : v % rhs } + return v + } + const sum = (): number => { + let v = product() + while (tokens[i] === '+' || tokens[i] === '-') { const op = tokens[i++]; const rhs = product(); v = op === '+' ? v + rhs : v-rhs } + return v + } + const result = sum() + if (i !== tokens.length || !Number.isFinite(result)) throw new Error('Expression must produce a finite number') + return result +} diff --git a/packages/core/src/extended-types.ts b/packages/core/src/extended-types.ts new file mode 100644 index 0000000..ea7541e --- /dev/null +++ b/packages/core/src/extended-types.ts @@ -0,0 +1,42 @@ +import type { ParameterDef, ParamValue } from './types' + +export type ParameterBase = { id: string; label: string; group: string; description?: string; hidden?: boolean } +export type Option = { value: string; label: string; preview?: string } +export type Point = { x: number; y: number } +export type RadialZone = { label: string; start: number; end: number } +export type RadialLayer = { name: string; color: string; enabled: boolean; points: Point[] } +export type ResourceValue = { id: string; name: string; mime: string; size: number } +export type Gizmo2DValue = { position: [number, number]; size: [number, number]; rotation: number } +export type Gizmo3DValue = { position: [number, number, number]; rotation: [number, number, number]; scale: [number, number, number]; mode: 'translate' | 'rotate' | 'scale' } +export type TextureFrameValue = { rect: [number, number, number, number]; rotation: number } +export type CameraValue = { azimuth: number; elevation: number; distance: number; fov: number } +export type ModulationValue = { + enabled: boolean; mode: string; rate: number; phase: number; amplitude: number; offset: number + attack: number; hold: number; release: number; seed: number +} +export type ValueSource = + | { mode: 'local' } + | { mode: 'parameter'; source?: string } + | { mode: 'expression'; expression?: string } + | { mode: 'animation' } + | { mode: 'macro'; source?: string; min: number; max: number } + | { mode: 'modulation'; shape: ModulationValue['mode']; rate: number; phase: number; amplitude: number; offset: number; attack: number; hold: number; release: number; seed: number } + | { mode: 'blend'; source?: string; amount?: number; from: number; to: number } +export type ExtendedParameter = ParameterBase & ( + | { kind: 'vector'; defaultValue: number[]; axes: string[]; min: number; max: number; step: number; unit?: string; view?: 'fields' | 'xy' | 'dimensions' | 'direction' | 'rotation' | 'anchor'; proportional?: boolean; linkLabel?: string } + | { kind: 'range'; defaultValue: number[]; min: number; max: number; step: number; unit?: string } + | { kind: 'text'; defaultValue: string; multiline?: boolean; maxLength?: number } + | { kind: 'multiselect'; defaultValue: string[]; options: Option[] } + | { kind: 'palette'; defaultValue: string[]; maxItems?: number } + | { kind: 'points'; defaultValue: Point[]; view?: 'curve' | 'ramp' | 'path'; min?: number; max?: number } + | { kind: 'radial'; defaultValue: RadialLayer[]; zones?: RadialZone[]; maxLayers?: number } + | { kind: 'resource'; defaultValue: ResourceValue | null; accept: string; view?: 'image' | 'texture' | 'svg' | 'font' | 'model' | 'environment'; maxMB?: number } + | { kind: 'gizmo2d'; defaultValue: Gizmo2DValue } + | { kind: 'gizmo3d'; defaultValue: Gizmo3DValue } + | { kind: 'textureFrame'; defaultValue: TextureFrameValue } + | { kind: 'camera'; defaultValue: CameraValue } + | { kind: 'group'; defaultValue: Record; fields: ParameterDef[] } + | { kind: 'list'; defaultValue: ParamValue[]; item: ParameterDef; maxItems?: number } + | { kind: 'action'; defaultValue: number; action: 'set' | 'randomize' | 'reset' | 'trigger'; targets?: string[]; values?: Record } + | { kind: 'preset'; defaultValue: string; options: { value: string; label: string; values: Record }[] } +) diff --git a/packages/core/src/font-value.ts b/packages/core/src/font-value.ts new file mode 100644 index 0000000..b008a93 --- /dev/null +++ b/packages/core/src/font-value.ts @@ -0,0 +1,18 @@ +import type { ParamValue } from './types' +import { sanitizeFonts } from './fonts' +const FONT_FAMILIES = ['Public Sans', 'Space Grotesk', 'Source Serif 4', 'Helvetica', 'Verdana', 'Trebuchet MS', 'Georgia', 'Times New Roman', 'Courier New', 'Menlo'] +import type { Font as VectorFont } from './fonts' + +/** Font bytes travel with the parameter, including snapshots, undo and exported values. */ +export function normalizeFontValue(value: unknown): ParamValue | null { + if (typeof value === 'string') return FONT_FAMILIES.includes(value) ? value : null + const font = sanitizeFonts([value])?.[0] + return font ? { ...font } : null +} +export function fontValueFamily(value: unknown): string | null { + if (typeof value === 'string') return value + return sanitizeFonts([value])?.[0]?.family ?? null +} +export function fontValueFont(value: unknown): VectorFont | null { + return typeof value === 'object' && value !== null ? sanitizeFonts([value])?.[0] ?? null : null +} diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts new file mode 100644 index 0000000..4d7f8fb --- /dev/null +++ b/packages/core/src/fonts.ts @@ -0,0 +1,65 @@ +export type FontSource = 'system' | 'google' | 'file' +export type FontAxis = { tag: string; name: string; min: number; max: number; default: number } + +/** + * A font the document knows about. A file carries its own bytes so it travels with the document; + * a Google family is fetched on demand and embedded only when the file is exported. + */ +export type Font = { + family: string + source: FontSource + weights: number[] + axes?: FontAxis[] + variations?: Record + license?: string + /** Base64 of the font file, for an imported one. */ + data?: string + format?: 'woff2' | 'ttf' | 'otf' +} + + +export const MAX_FONT_BYTES = 2 * 1024 * 1024 + +export function sanitizeVariations(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const entries = Object.entries(value).filter(([tag, number]) => /^[a-zA-Z0-9]{4}$/.test(tag) && typeof number === 'number' && Number.isFinite(number) && Math.abs(number) <= 10000).slice(0, 16) + return entries.length ? Object.fromEntries(entries) as Record : undefined +} + +export function sanitizeFonts(value: unknown): Font[] | undefined { + if (!Array.isArray(value)) return undefined + const seen = new Set() + const fonts = value.slice(0, 24).flatMap((candidate): Font[] => { + if (!candidate || typeof candidate !== 'object') return [] + const source = candidate as Partial + const family = typeof source.family === 'string' ? source.family.trim().slice(0, 80) : '' + if (!family || seen.has(family)) return [] + const kind: FontSource = source.source === 'google' || source.source === 'file' ? source.source : 'system' + const weights = Array.isArray(source.weights) + ? [...new Set(source.weights.filter((weight): weight is number => typeof weight === 'number' && Number.isFinite(weight) && weight >= 1 && weight <= 1000))].sort((a, b) => a - b) + : [] + const data = kind !== 'system' && typeof source.data === 'string' && source.data.length > 0 && base64Bytes(source.data) <= MAX_FONT_BYTES + ? source.data + : undefined + // An imported font without its file is nothing at all: it cannot be drawn or embedded. + if (kind === 'file' && !data) return [] + seen.add(family) + const axes = Array.isArray(source.axes) ? source.axes.filter(axis => axis && /^[a-zA-Z0-9]{4}$/.test(axis.tag) && [axis.min, axis.max, axis.default].every(number => Number.isFinite(number) && Math.abs(number) <= 10000) && axis.min < axis.max && axis.default >= axis.min && axis.default <= axis.max).slice(0, 16).map(axis => ({ tag: axis.tag, name: typeof axis.name === 'string' ? axis.name.slice(0, 60) : axis.tag, min: axis.min, max: axis.max, default: axis.default })) : [] + const variations = sanitizeVariations(source.variations) + return [{ + family, + source: kind, + weights: weights.length ? weights : [400], + ...(axes.length ? { axes } : {}), + ...(variations ? { variations: Object.fromEntries(Object.entries(variations).flatMap(([tag, number]) => { const axis = axes.find(axis => axis.tag === tag); return axis ? [[tag, Math.max(axis.min, Math.min(axis.max, number))]] : [] })) } : {}), + ...(typeof source.license === 'string' ? { license: source.license.slice(0, 20000) } : {}), + ...(data ? { data, format: source.format === 'ttf' || source.format === 'otf' ? source.format : 'woff2' } : {}), + }] + }) + return fonts.length ? fonts : undefined +} + +export function base64Bytes(data: string): number { + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0 + return Math.max(0, Math.floor((data.length * 3) / 4) - padding) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..f1a5a58 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,10 @@ +export * from './types' +export * from './extended-types' +export * from './binding' +export * from './sanitize' +export * from './expression' +export * from './values' +export * from './parameter-values' +export * from './drivers' +export * from './fonts' +export * from './font-value' diff --git a/packages/core/src/parameter-values.ts b/packages/core/src/parameter-values.ts new file mode 100644 index 0000000..4a400e7 --- /dev/null +++ b/packages/core/src/parameter-values.ts @@ -0,0 +1,117 @@ +import type { ParameterDef, ParamValue } from './types' +import type { ModulationValue, ValueSource } from './extended-types' +import { normalizeFontValue } from './font-value' + +export function objectValue(value: ParamValue | undefined): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} +} +const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) + +export function normalizeValueSource(param: Extract, value: unknown): ValueSource { + const fallback = param.defaultSource ?? { mode: 'local' as const } + const v = objectValue(value as ParamValue) + const mode = String(v.mode) + if (mode === 'local' || mode === 'animation') return { mode } + if (mode === 'parameter') return { mode, ...(typeof v.source === 'string' ? { source: v.source.slice(0, 128) } : {}) } + if (mode === 'expression') return { mode, ...(typeof v.expression === 'string' ? { expression: v.expression.slice(0, 1000) } : {}) } + if (mode === 'macro') { + const min = finite(v.min) ? clamp(v.min, param.min, param.max) : param.min + const max = finite(v.max) ? clamp(v.max, param.min, param.max) : param.max + return { mode, min: Math.min(min, max), max: Math.max(min, max), ...(typeof v.source === 'string' ? { source: v.source.slice(0, 128) } : {}) } + } + if (mode === 'blend') { + const from = finite(v.from) ? clamp(v.from, param.min, param.max) : param.min + const to = finite(v.to) ? clamp(v.to, param.min, param.max) : param.max + const amount = finite(v.amount) ? clamp(v.amount, 0, 1) : undefined + return { mode, from, to, ...(amount === undefined ? {} : { amount }), ...(typeof v.source === 'string' ? { source: v.source.slice(0, 128) } : {}) } + } + if (mode === 'modulation') { + const defaults: ModulationValue = { enabled: true, mode: 'sine', rate: 1, phase: 0, amplitude: 1, offset: 0, attack: 0.5, hold: 1, release: 0.5, seed: 0 } + const number = (key: keyof ModulationValue, lo: number, hi: number) => finite(v[key]) ? clamp(v[key] as number, lo, hi) : defaults[key] as number + return { mode, shape: ['sine', 'triangle', 'square', 'noise', 'envelope'].includes(String(v.shape)) ? String(v.shape) : defaults.mode, + rate: number('rate', 0.01, 60), phase: number('phase', 0, 1), amplitude: number('amplitude', 0, 10000), offset: number('offset', -10000, 10000), + attack: number('attack', 0.01, 60), hold: number('hold', 0, 60), release: number('release', 0.01, 60), seed: number('seed', 0, 1000000) } + } + return fallback +} + +/** All entry points (edits, restored drafts, composites) share this value contract. */ +export function normalizeValue(param: ParameterDef, value: unknown): ParamValue { + const fallback = () => structuredClone(param.defaultValue) + switch (param.kind) { + case 'number': return finite(value) ? clamp(value, param.min, param.max) : fallback() + case 'text': return typeof value === 'string' ? value.slice(0, param.maxLength ?? 10000) : fallback() + case 'color': return typeof value === 'string' && ((param.allowNone && value === 'none') || /^#[\da-f]{6}([\da-f]{2})?$/i.test(value)) ? value : fallback() + case 'switch': return typeof value === 'boolean' ? value : fallback() + case 'select': + if (param.view === 'font-library') return normalizeFontValue(value) ?? fallback() + return typeof value === 'string' && param.options.some(o => o.value === value) ? value : fallback() + case 'preset': return typeof value === 'string' && param.options.some(o => o.value === value) ? value : fallback() + case 'multiselect': return Array.isArray(value) ? [...new Set(value.filter((v): v is string => typeof v === 'string' && param.options.some(o => o.value === v)))] : fallback() + case 'vector': return Array.isArray(value) && value.length === param.axes.length && value.every(finite) ? value.map(v => clamp(v, param.min, param.max)) : fallback() + case 'range': return Array.isArray(value) && value.length === 2 && value.every(finite) ? value.map(v => clamp(v, param.min, param.max)).sort((a,b) => a-b) : fallback() + case 'palette': return Array.isArray(value) && value.every(v => typeof v === 'string' && /^#[\da-f]{6}([\da-f]{2})?$/i.test(v)) ? value.slice(0, param.maxItems ?? 16) : fallback() + case 'points': { + if (!Array.isArray(value) || value.length < 2 || value.length > 128) return fallback() + if (!value.every(v => v && finite(v.x) && finite(v.y))) return fallback() + const points = value.map(v => ({x: clamp(v.x, 0, 1), y: clamp(v.y, param.min ?? 0, param.max ?? 1)})) + return param.view === 'path' ? points : points.sort((a,b) => a.x-b.x) + } + case 'radial': { + if (!Array.isArray(value) || value.length < 1) return fallback() + const hex = (v: unknown) => typeof v === 'string' && /^#[\da-f]{6}([\da-f]{2})?$/i.test(v) + const layers = value.slice(0, param.maxLayers ?? 8).flatMap(entry => { + const v = objectValue(entry as ParamValue) + if (typeof v.name !== 'string' || !hex(v.color) || !Array.isArray(v.points) || v.points.length < 2) return [] + const points = v.points.flatMap(point => { + const item = objectValue(point) + return finite(item.x) && finite(item.y) ? [{ x: clamp(item.x, 0, 1), y: clamp(item.y, 0, 1) }] : [] + }).slice(0, 32).sort((a,b) => a.x-b.x) + if (points.length < 2) return [] + return [{ name: v.name.slice(0, 32), color: String(v.color), enabled: v.enabled !== false, points }] + }) + return layers.length ? layers : fallback() + } + case 'group': { + const source = objectValue(value as ParamValue) + return Object.fromEntries(param.fields.map(field => [field.id, normalizeValue(field, source[field.id])])) + } + case 'list': return Array.isArray(value) ? value.slice(0, param.maxItems ?? 32).map(v => normalizeValue(param.item, v)) : fallback() + case 'resource': { + if (value === null) return null + const v = objectValue(value as ParamValue) + return typeof v.id === 'string' && typeof v.name === 'string' && typeof v.mime === 'string' && finite(v.size) + ? {id:v.id, name:v.name, mime:v.mime, size:v.size} : fallback() + } + case 'gizmo2d': { + const v=objectValue(value as ParamValue) + return Array.isArray(v.position)&&v.position.length===2&&v.position.every(finite)&&Array.isArray(v.size)&&v.size.length===2&&v.size.every(finite)&&finite(v.rotation) + ? {position:[clamp(v.position[0] as number,-1,1),clamp(v.position[1] as number,-1,1)],size:[clamp(v.size[0] as number,0.1,2),clamp(v.size[1] as number,0.1,2)],rotation:clamp(v.rotation,-180,180)} : fallback() + } + case 'gizmo3d': { + const v=objectValue(value as ParamValue) + const vector=(value:unknown,lo:number,hi:number):[number,number,number]|null=>Array.isArray(value)&&value.length===3&&value.every(finite)?[clamp(value[0] as number,lo,hi),clamp(value[1] as number,lo,hi),clamp(value[2] as number,lo,hi)]:null + const position=vector(v.position,-1,1),rotation=vector(v.rotation,-180,180),scale=vector(v.scale,0.1,2) + return position&&rotation&&scale&&['translate','rotate','scale'].includes(String(v.mode)) ? {position,rotation,scale,mode:String(v.mode) as 'translate'|'rotate'|'scale'} : fallback() + } + case 'textureFrame': { + const v=objectValue(value as ParamValue) + if(!Array.isArray(v.rect)||v.rect.length!==4||!v.rect.every(finite)||!finite(v.rotation))return fallback() + const [left,top,right,bottom]=v.rect.map(n=>clamp(n as number,0,1)) as [number,number,number,number] + return {rect:[Math.min(left,right-.05),Math.min(top,bottom-.05),Math.max(right,left+.05),Math.max(bottom,top+.05)],rotation:clamp(v.rotation,-180,180)} + } + case 'camera': { + const v=objectValue(value as ParamValue) + return finite(v.azimuth)&&finite(v.elevation)&&finite(v.distance)&&finite(v.fov) + ? {azimuth:clamp(v.azimuth,-180,180),elevation:clamp(v.elevation,-80,80),distance:clamp(v.distance,1,20),fov:clamp(v.fov,20,100)} : fallback() + } + case 'action': return finite(value) ? Math.max(0, Math.floor(value)) : fallback() + case 'curve': { + const v = objectValue(value as ParamValue) + return v.type === 'cubic-bezier' && ['p0','p1','p2','p3'].every(k => Array.isArray(v[k]) && (v[k] as unknown[]).length === 2 && (v[k] as unknown[]).every(finite)) ? structuredClone(value as ParamValue) : fallback() + } + case 'gradient': return Array.isArray(value) && value.length >= 2 && value.every(v => finite(v?.t) && typeof v?.color === 'string' && /^#[\da-f]{6}([\da-f]{2})?$/i.test(v.color)) + ? value.slice(0,64).map(v => ({t:clamp(v.t,0,1),color:v.color})).sort((a,b)=>a.t-b.t) : fallback() + } +} diff --git a/packages/core/src/sanitize.ts b/packages/core/src/sanitize.ts new file mode 100644 index 0000000..d62a908 --- /dev/null +++ b/packages/core/src/sanitize.ts @@ -0,0 +1,206 @@ +import type { BezierCurve, InspectorCategory, ParameterDef, ParamGroup, Vec2 } from './types' + +/** + * Reading a rig back from a file that could say anything. + * + * The parameters, groups and categories of a rig are the workbench's own types, and a rig written + * for the drawing editor has to mean the same thing to the scene editor — so the reading of them + * lives here, once, rather than in each editor. What differs between the two is which *properties* + * exist to bind to, and that stays in each editor's own `rig.ts`. + * + * The rule throughout: a control whose kind, id or group is missing is dropped rather than + * repaired. A rig with a control that writes nowhere is worse than a rig with one control fewer. + */ + +const NUMBER_VIEWS = ['field', 'stepper', 'bar', 'knob', 'angle', 'seed'] +const MAX_PARAMETERS = 200 +const MAX_BINDINGS = 500 + +function text(value: unknown, max: number): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, max) : null +} + +function finite(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function hex(value: unknown, fallback: string): string { + return typeof value === 'string' && /^#[0-9a-f]{3,8}$/i.test(value) ? value : fallback +} + +/** A display unit a number can be read in. The stored value stays in the base unit regardless. */ +function unitList(value: unknown): Array<{ value: string; label: string; factor: number; step?: number }> { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!item || typeof item !== 'object') return [] + const entry = item as { value?: unknown; label?: unknown; factor?: unknown; step?: unknown } + const id = text(entry.value, 12) + const factor = entry.factor + if (!id || typeof factor !== 'number' || !Number.isFinite(factor) || factor <= 0) return [] + const step = entry.step + return [{ + value: id, + label: text(entry.label, 40) ?? id, + factor, + ...(typeof step === 'number' && Number.isFinite(step) && step > 0 ? { step } : {}), + }] + }) +} + +function point(value: unknown): Vec2 | null { + if (!Array.isArray(value) || value.length < 2) return null + const [x, y] = value + const ok = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) + return ok(x) && ok(y) ? [x, y] : null +} + +function bezier(value: unknown): BezierCurve | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as Record + if (source.type !== 'cubic-bezier') return null + const p0 = point(source.p0) + const p1 = point(source.p1) + const p2 = point(source.p2) + const p3 = point(source.p3) + return p0 && p1 && p2 && p3 ? { type: 'cubic-bezier', p0, p1, p2, p3 } : null +} + +const LINEAR_CURVE: BezierCurve = { type: 'cubic-bezier', p0: [0, 0], p1: [0.33, 0.33], p2: [0.67, 0.67], p3: [1, 1] } + +function options(value: unknown): Array<{ value: string; label: string }> { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!item || typeof item !== 'object') return [] + const entry = item as { value?: unknown; label?: unknown } + const id = text(entry.value, 60) + return id ? [{ value: id, label: text(entry.label, 60) ?? id }] : [] + }) +} + +/** + * One control. `extended` lets the richer kinds of the catalogue through — a scene binds a whole + * transform to a `gizmo3d` and a camera to a `camera`, where a drawing has nothing to put in one. + */ +export function sanitizeParameter(value: unknown, groupIds: Set, allow: { extended?: boolean } = {}): ParameterDef | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as Record + const id = text(source.id, 60) + const kind = text(source.kind, 20) + if (!id || !kind) return null + const label = text(source.label, 80) ?? id + const group = text(source.group, 60) ?? '' + if (!groupIds.has(group)) return null + const base = { id, label, group, ...(source.hidden === true ? { hidden: true as const } : {}) } + if (kind === 'preset' && Array.isArray(source.options)) { + const choices = source.options.slice(0, 100).flatMap(item => { + if (!item || typeof item !== 'object') return [] + const option = item as Record, value = text(option.value, 60) + if (!value || !option.values || typeof option.values !== 'object' || Array.isArray(option.values)) return [] + const values = Object.fromEntries(Object.entries(option.values).filter(([key, next]) => key.length <= 60 && (typeof next === 'string' || typeof next === 'boolean' || typeof next === 'number' && Number.isFinite(next))).slice(0, 200)) as Record + return [{ value, label: text(option.label, 60) ?? value, values }] + }) + return choices.length ? { ...base, kind: 'preset', defaultValue: choices.find(option => option.value === source.defaultValue)?.value ?? choices[0]!.value, options: choices } : null + } + if (kind === 'number') { + const min = finite(source.min, 0) + const max = finite(source.max, min + 1) + const view = text(source.view, 20) + const scale = text(source.scale, 8) + const units = unitList(source.units) + return { + ...base, + kind: 'number', + min, + max: max > min ? max : min + 1, + step: Math.max(0, finite(source.step, 1)), + ...(text(source.fontParameter, 60) ? { fontParameter: text(source.fontParameter, 60)! } : {}), + defaultValue: finite(source.defaultValue, min), + ...(text(source.unit, 12) ? { unit: text(source.unit, 12)! } : {}), + ...(view && NUMBER_VIEWS.includes(view) ? { view: view as 'bar' } : {}), + // A frequency read on a linear slider is not the same control. The scale and the display + // units are part of what the field is, not decoration, so they survive the round trip. + ...(scale === 'log' || scale === 'linear' ? { scale } : {}), + ...(units.length ? { units } : {}), + } + } + if (kind === 'color') return { ...base, kind: 'color', defaultValue: source.allowNone === true && source.defaultValue === 'none' ? 'none' : hex(source.defaultValue, '#D4E7E1'), ...(source.alpha === true ? { alpha: true } : {}), ...(source.allowNone === true ? { allowNone: true } : {}) } + if (kind === 'switch') return { ...base, kind: 'switch', defaultValue: source.defaultValue === true } + if (kind === 'select') { + const list = options(source.options) + if (list.length === 0) return null + const fallback = list[0]!.value + const chosen = text(source.defaultValue, 60) + return { ...base, kind: 'select', options: list, defaultValue: chosen && list.some((item) => item.value === chosen) ? chosen : fallback, + ...(['search', 'visual', 'font', 'font-library'].includes(String(source.view)) ? { view: source.view as 'search' | 'visual' | 'font' | 'font-library' } : {}) } + } + if (kind === 'text') { + return { ...base, kind: 'text', defaultValue: typeof source.defaultValue === 'string' ? source.defaultValue.slice(0, 2000) : '', ...(source.multiline === true ? { multiline: true } : {}) } + } + if (kind === 'gradient') { + const stops = Array.isArray(source.defaultValue) + ? source.defaultValue.flatMap((stop) => { + if (!stop || typeof stop !== 'object') return [] + const entry = stop as { t?: unknown; color?: unknown } + return typeof entry.t === 'number' ? [{ t: Math.min(1, Math.max(0, entry.t)), color: hex(entry.color, '#D4E7E1') }] : [] + }) + : [] + return { ...base, kind: 'gradient', defaultValue: stops.length > 1 ? stops : [{ t: 0, color: '#1C1D1E' }, { t: 1, color: '#D4E7E1' }] } + } + if (kind === 'curve') { + // Repaired rather than dropped, on the gradient's precedent above: a curve control that opens + // on a straight line is still the control the rig asked for. + return { ...base, kind: 'curve', defaultValue: bezier(source.defaultValue) ?? LINEAR_CURVE } + } + if (allow.extended === true && (kind === 'gizmo3d' || kind === 'camera')) { + // Both carry an object rather than a number, and their shape is the controller's business; + // what matters here is that the default is an object at all. + const defaultValue = source.defaultValue && typeof source.defaultValue === 'object' && !Array.isArray(source.defaultValue) + ? source.defaultValue + : null + if (!defaultValue) return null + return { ...base, kind, defaultValue } as ParameterDef + } + if (kind === 'vector') { + const values = Array.isArray(source.defaultValue) ? source.defaultValue.map((item) => finite(item, 0)) : [0, 0] + const axes = Array.isArray(source.axes) ? source.axes.flatMap((axis) => (text(axis, 8) ? [text(axis, 8)!] : [])) : ['X', 'Y'] + if (values.length < 2 || axes.length !== values.length) return null + const min = finite(source.min, 0) + const max = finite(source.max, min + 1) + return { ...base, kind: 'vector', defaultValue: values, axes, min, max: max > min ? max : min + 1, step: Math.max(0, finite(source.step, 1)) } + } + return null +} + +/** The groups a rig declares. A rig with no group has nowhere to put a control, so it has no rig. */ +export function sanitizeGroups(value: unknown): ParamGroup[] { + if (!Array.isArray(value)) return [] + return value.flatMap((group) => { + if (!group || typeof group !== 'object') return [] + const entry = group as { id?: unknown; label?: unknown; defaultOpen?: unknown; tab?: unknown } + const id = text(entry.id, 60) + if (!id) return [] + return [{ + id, + label: text(entry.label, 80) ?? id, + ...(entry.tab ? { tab: text(entry.tab, 60) ?? undefined } : {}), + ...(entry.defaultOpen === false ? { defaultOpen: false } : {}), + }] + }) +} + +/** The inspector's own tabs, when a rig asks for more than one. */ +export function sanitizeCategories(value: unknown): InspectorCategory[] { + if (!Array.isArray(value)) return [] + return value.flatMap((category) => { + if (!category || typeof category !== 'object') return [] + const entry = category as { id?: unknown; label?: unknown } + const id = text(entry.id, 60) + return id ? [{ id, label: text(entry.label, 80) ?? id }] : [] + }) +} + +/** The two ceilings a file is held to, shared so that both editors refuse the same sizes. */ +export { MAX_PARAMETERS, MAX_BINDINGS } + +/** The small readers the sanitisers are built from, exported for the editors' own binding readers. */ +export { text as rigText, finite as rigFinite } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..6c57760 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,214 @@ +export type Vec2 = [number, number] + +export type BezierCurve = { + type: 'cubic-bezier' + p0: Vec2 + p1: Vec2 + p2: Vec2 + p3: Vec2 +} + +export type GradientStop = { + t: number + color: string +} + +export type ParamValue = number | string | boolean | null | BezierCurve | GradientStop[] | ParamValue[] | { [key: string]: ParamValue } + +export type NumberParam = { + kind: 'number' + id: string + label: string + group: string + min: number + max: number + step: number + unit?: string + /** Display-only unit conversions. Stored values remain in the base unit. */ + units?: { value: string; label: string; factor: number; step?: number }[] + defaultValue: number + sliderMin?: number + sliderMax?: number + /** 'bar' is the default measured view: the field itself is the track, filled to the value. */ + view?: 'field' | 'stepper' | 'bar' | 'knob' | 'angle' | 'seed' + scale?: 'linear' | 'log' + stops?: number[] + /** Constrain a weight control to the face selected by this font-library parameter. */ + fontParameter?: string + readOnly?: boolean + role?: 'duration' | 'playhead' + defaultSource?: import('./extended-types').ValueSource + hidden?: boolean +} + +export type ColorParam = { + kind: 'color' + id: string + label: string + group: string + defaultValue: string + alpha?: boolean + allowNone?: boolean + channels?: boolean + hidden?: boolean +} + +export type SelectParam = { + kind: 'select' + id: string + label: string + group: string + options: { value: string; label: string; preview?: string }[] + defaultValue: string + appliesToTracks?: boolean + view?: 'search' | 'visual' | 'font' | 'font-library' + hidden?: boolean +} + +export type CurveParam = { + kind: 'curve' + id: string + label: string + group: string + defaultValue: BezierCurve + hidden?: boolean +} + +export type SwitchParam = { + kind: 'switch' + id: string + label: string + group: string + defaultValue: boolean + hidden?: boolean +} + +export type GradientParam = { + kind: 'gradient' + id: string + label: string + group: string + defaultValue: GradientStop[] + hidden?: boolean +} + +export type ParameterDef = + | NumberParam + | ColorParam + | SelectParam + | CurveParam + | SwitchParam + | GradientParam + | import('./extended-types').ExtendedParameter + +export type Keyframe = { + id?: string + time: number + value: number + easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'step' +} + +export type KeyframeRef = { paramId: string; id: string } +export type LoopRange = { start: number; end: number } + +export type AnimTrack = { + paramId: string + interpolation: 'linear' | 'step' + keyframes: Keyframe[] +} + +export type AnimationDef = { + duration: number + fps: number + loop: boolean + tracks: AnimTrack[] +} + +export type ParamGroup = { + id: string + label: string + /** Optional secondary inspector tab. Groups without one remain visible in every tab. */ + tab?: string + defaultOpen?: boolean +} + +export type InspectorCategory = { + id: string + label: string +} + +export type RendererKind = 'svg' | 'three' | 'html' | 'vector' | 'scene' | 'web' | 'audio' + +export type RigManifest = { + id: string + name: string + summary: string + description: string + renderer: RendererKind + rendererLabel: string + collection: 'examples' | 'project' + /** Storybook-style path. Folders are `/` segments; the leaf is `name`. */ + title: string + sourceFile: string + tags: string[] + /** Optional second-level navigation for rigs with several families of controls. */ + inspectorCategories?: InspectorCategory[] + groups: ParamGroup[] + parameters: ParameterDef[] + animation?: AnimationDef +} + +export type Snapshot = { + id: string + name: string + createdAt: string + values: Record + valueSources?: Record + tracks?: AnimTrack[] + loop?: boolean + loopRange?: LoopRange | null + duration?: number +} + +export type ExportDocument = { + version: 1 + rigId: string + name: string + exportedAt: string + values: Record + valueSources: Record + animation?: { + duration: number + fps: number + loop: boolean + playhead: number + tracks: AnimTrack[] + loopRange?: LoopRange | null + } +} + +export type StoredDraft = { + version: 1 + rigId: string + values: Record + valueSources?: Record + snapshots: Snapshot[] + compare: 'original' | 'current' + activeSnapshotId: string | null + playhead: number + loop: boolean + tracks?: AnimTrack[] + loopRange?: LoopRange | null + duration?: number +} + +export type PanelPrefs = { + version: 1 + navWidth: number + inspectorWidth: number + timelineHeight: number + navCollapsed: boolean + navCompact: boolean + inspectorCollapsed: boolean + timelineCollapsed: boolean +} diff --git a/packages/core/src/values.ts b/packages/core/src/values.ts new file mode 100644 index 0000000..a45eb42 --- /dev/null +++ b/packages/core/src/values.ts @@ -0,0 +1,112 @@ +import type { AnimTrack, BezierCurve, GradientStop, ParamValue } from './types' + +export function cloneValue(value: ParamValue): ParamValue { + return structuredClone(value) +} + +export function cloneValues(values: Record): Record { + return structuredClone(values) +} + +export function valuesEqual(a: ParamValue, b: ParamValue): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +export function recordsEqual( + a: Record, + b: Record, +): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (!valuesEqual(a[key] as ParamValue, b[key] as ParamValue)) return false + } + return true +} + +export function changedKeys( + current: Record, + baseline: Record, +): string[] { + return Object.keys(current).filter( + (key) => !valuesEqual(current[key] as ParamValue, baseline[key] as ParamValue), + ) +} + +export function isBezier(value: ParamValue): value is BezierCurve { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'cubic-bezier' +} + +export function isGradient(value: ParamValue): value is GradientStop[] { + return Array.isArray(value) && value.length > 0 && value.every(stop => + typeof stop === 'object' && stop !== null && 'color' in stop && typeof stop.color === 'string' && + 't' in stop && typeof stop.t === 'number') +} + +export function sampleBezier(curve: BezierCurve, t: number): number { + const clamped = Math.min(1, Math.max(0, t)) + const x = cubic(curve.p0[0], curve.p1[0], curve.p2[0], curve.p3[0], clamped) + const y = cubic(curve.p0[1], curve.p1[1], curve.p2[1], curve.p3[1], clamped) + return { x, y }.y +} + +function cubic(a: number, b: number, c: number, d: number, t: number): number { + const mt = 1 - t + return mt * mt * mt * a + 3 * mt * mt * t * b + 3 * mt * t * t * c + t * t * t * d +} + +export function interpolateNumber(track: AnimTrack, time: number, duration: number, loop: boolean): number { + const frames = [...track.keyframes].sort((a, b) => a.time - b.time) + if (frames.length === 0) return 0 + const first = frames[0] + const last = frames[frames.length - 1] + if (!first || !last) return 0 + + let t = time + if (loop && duration > 0) { + t = ((time % duration) + duration) % duration + } else { + t = Math.min(Math.max(time, 0), duration) + } + + if (t <= first.time) return first.value + if (t >= last.time) return last.value + + for (let i = 0; i < frames.length - 1; i += 1) { + const a = frames[i] + const b = frames[i + 1] + if (!a || !b) continue + if (t >= a.time && t <= b.time) { + if (t === b.time) return b.value + const easing = a.easing ?? track.interpolation + if (easing === 'step' || b.time === a.time) return a.value + const linear = (t - a.time) / (b.time - a.time) + const u = easing === 'ease-in' ? linear * linear + : easing === 'ease-out' ? 1 - (1 - linear) ** 2 + : easing === 'ease-in-out' ? linear * linear * (3 - 2 * linear) : linear + return a.value + (b.value - a.value) * u + } + } + return last.value +} + +export function upsertKeyframe(track: AnimTrack, time: number, value: number, tolerance = 0.04): AnimTrack { + const frames = [...track.keyframes] + const existing = frames.findIndex((frame) => Math.abs(frame.time - time) <= tolerance) + if (existing >= 0) { + frames[existing] = { ...frames[existing], time: frames[existing]?.time ?? time, value } + } else { + frames.push({ time, value }) + } + return { + ...track, + keyframes: frames.sort((a, b) => a.time - b.time), + } +} + +export const defaultCurve = (): BezierCurve => ({ + type: 'cubic-bezier', + p0: [0, 0], + p1: [0.32, 0], + p2: [0.36, 1], + p3: [1, 1], +}) diff --git a/packages/entries.json b/packages/entries.json new file mode 100644 index 0000000..1ddfcbf --- /dev/null +++ b/packages/entries.json @@ -0,0 +1,59 @@ +{ + "core": { + ".": "packages/core/src/index.ts", + "./types": "packages/core/src/types.ts", + "./extended-types": "packages/core/src/extended-types.ts", + "./binding": "packages/core/src/binding.ts", + "./sanitize": "packages/core/src/sanitize.ts", + "./expression": "packages/core/src/expression.ts", + "./values": "packages/core/src/values.ts", + "./parameter-values": "packages/core/src/parameter-values.ts", + "./drivers": "packages/core/src/drivers.ts", + "./fonts": "packages/core/src/fonts.ts", + "./font-value": "packages/core/src/font-value.ts" + }, + "audio": { + ".": "packages/audio/src/index.ts", + "./wav": "src/audio/dsp/wav.ts", + "./wavetables": "src/audio/dsp/wavetable.ts", + "./bindings": "src/audio/rig.ts", + "./fields": "src/audio/fields.ts", + "./curves": "src/audio/dsp/curve.ts", + "./random": "src/audio/dsp/rng.ts" + }, + "audio-labs": { + ".": "src/audio/labs/sdk.ts" + }, + "audio-browser": { + ".": "src/audio/player.ts" + }, + "vector": { + ".": "packages/vector/src/index.ts", + "./model": "src/vector/model.ts", + "./bindings": "src/vector/rig.ts", + "./geometry": "src/vector/geometry.ts", + "./svg": "src/vector/svg.ts", + "./project": "src/vector/project.ts", + "./export": "src/vector/exportRuntime.ts", + "./pdf": "src/vector/pdfRuntime.ts", + "./resources": "src/vector/resources.ts", + "./browser": "src/vector/browser.ts" + }, + "scene": { + ".": "packages/scene/src/index.ts", + "./model": "src/scene/model.ts", + "./bindings": "src/scene/rig.ts", + "./modifiers": "src/scene/modifiers/index.ts", + "./engine": "src/scene/engine.ts", + "./browser": "src/scene/browser.ts", + "./resources": "src/scene/resources.ts", + "./project": "src/scene/project.ts", + "./gltf": "src/scene/io/gltfRuntime.ts", + "./obj": "src/scene/io/obj.ts", + "./stl": "src/scene/io/stl.ts" + }, + "controls": { + ".": "src/controls/index.ts", + "./registry": "src/controls/registry.ts" + } +} diff --git a/packages/scene/LICENSE b/packages/scene/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/scene/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/scene/README.md b/packages/scene/README.md new file mode 100644 index 0000000..1876f9c --- /dev/null +++ b/packages/scene/README.md @@ -0,0 +1,18 @@ +# @paramrig/scene + +ESM scene documents, bindings, modifiers, geometry, materials and shaders shared with ParamRig. + +Install `@paramrig/scene` and `three@0.185.1`. Use `/engine` to create content for your own Three.js renderer and animation loop, or `/browser` for an optional canvas owner. React is not required. + +```js +import { createSceneInstance } from '@paramrig/scene/engine'; +const instance = createSceneInstance({ document, renderer, resources }); +await instance.ready; +instance.configureRenderer(renderer); +renderer.render(instance.scene, instance.camera); +instance.destroy(); // Your renderer remains yours. +``` + +`resources.resource(id, signal)` supplies textures and environments as blobs. `resources.font(request)` supplies outline font bytes. No browser storage or application asset paths are used. The host owns its renderer, render loop and resource source. Errors are available through `onError` and `instance.errors`. + +The root entry supports documents and bindings under Node. `/gltf`, `/obj` and `/stl` expose the existing format readers and writers with their existing limits. Graph materials and image environments require the browser; glTF is an interchange export and does not reproduce every procedural shader. diff --git a/packages/scene/package.json b/packages/scene/package.json new file mode 100644 index 0000000..d69c113 --- /dev/null +++ b/packages/scene/package.json @@ -0,0 +1,77 @@ +{ + "name": "@paramrig/scene", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "ParamRig 3D documents, scene runtime and optional browser viewer", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/packages/scene/src/index.d.ts", + "import": "./dist/index.js" + }, + "./model": { + "types": "./dist/types/src/scene/model.d.ts", + "import": "./dist/model.js" + }, + "./bindings": { + "types": "./dist/types/src/scene/rig.d.ts", + "import": "./dist/bindings.js" + }, + "./modifiers": { + "types": "./dist/types/src/scene/modifiers/index.d.ts", + "import": "./dist/modifiers.js" + }, + "./engine": { + "types": "./dist/types/src/scene/engine.d.ts", + "import": "./dist/engine.js" + }, + "./browser": { + "types": "./dist/types/src/scene/browser.d.ts", + "import": "./dist/browser.js" + }, + "./resources": { + "types": "./dist/types/src/scene/resources.d.ts", + "import": "./dist/resources.js" + }, + "./project": { + "types": "./dist/types/src/scene/project.d.ts", + "import": "./dist/project.js" + }, + "./gltf": { + "types": "./dist/types/src/scene/io/gltfRuntime.d.ts", + "import": "./dist/gltf.js" + }, + "./obj": { + "types": "./dist/types/src/scene/io/obj.d.ts", + "import": "./dist/obj.js" + }, + "./stl": { + "types": "./dist/types/src/scene/io/stl.d.ts", + "import": "./dist/stl.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "dependencies": { + "@paramrig/core": "0.1.0", + "three-mesh-bvh": "^0.9.14", + "opentype.js": "^2.0.0", + "@types/three": "^0.185.4", + "three-bvh-csg": "^0.0.18" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/scene" + }, + "peerDependencies": { + "three": ">=0.185.1 <0.186.0" + } +} diff --git a/packages/scene/src/index.ts b/packages/scene/src/index.ts new file mode 100644 index 0000000..a8ce5f2 --- /dev/null +++ b/packages/scene/src/index.ts @@ -0,0 +1,4 @@ +export type * from '../../../src/scene/types' +export { createSceneDocument, sanitizeSceneDocument, serializeSceneDocument, DEFAULT_MATERIAL, DEFAULT_WORLD } from '../../../src/scene/model' +export { resolveSceneValues } from '../../../src/scene/rig' +export { exportProject, serializeProject, importProject } from '../../../src/scene/project' diff --git a/packages/vector/LICENSE b/packages/vector/LICENSE new file mode 100644 index 0000000..2cc4b64 --- /dev/null +++ b/packages/vector/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soheil Saheb-Jamii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vector/README.md b/packages/vector/README.md new file mode 100644 index 0000000..add6337 --- /dev/null +++ b/packages/vector/README.md @@ -0,0 +1,29 @@ +# @paramrig/vector + +Vector documents, bindings, geometry and SVG rendering, with explicit browser and export adapters. Drawing and page layout use this same engine. + +```sh +npm install @paramrig/vector +``` + +```js +import { createVectorDocument, createVectorElement } from '@paramrig/vector' +import { createVectorRenderer } from '@paramrig/vector/browser' + +const document = createVectorDocument() +document.elements.push(createVectorElement('rectangle', { x: 20, y: 20, width: 100, height: 60 })) +const view = createVectorRenderer({ container: documentHost, document }) +await view.ready +const svg = await view.exportSvg() +view.destroy() +``` + +The SDK factory does not save anything. The application adds persistence around that same factory. Import/export project helpers retain the existing project format and return import errors and loss notes. + +`browser` accepts a document, controlled parameter values and optional resource resolvers. `update` returns a promise; wait for it before exporting the updated result. `destroy` removes only the owned view and fonts and cancels pending image work. + +Resource resolvers are supplied by the host. Font requests include the family, weight, purpose (`display` or `outline`) and cancellation signal. Return font bytes or null. Image resolvers return self-contained data URLs. No `/fonts` path or browser storage is assumed by the package. Carried font bytes are usable without a resolver. + +Dedicated entries include `model`, `bindings`, `geometry`, `svg`, `project`, `resources`, `export` and `pdf`. PNG uses browser canvas. PDF preserves the existing path writer and reports rasterized or skipped elements. Supply its rasterization callback where needed for effects and unsupported PDF primitives. + +Node supports contracts, project serialization and bindings. Typography, canvas-dependent geometry and raster exports require a browser; server rendering is not claimed to match browser text layout. React controls and complete editors are not included. diff --git a/packages/vector/package.json b/packages/vector/package.json new file mode 100644 index 0000000..577b95e --- /dev/null +++ b/packages/vector/package.json @@ -0,0 +1,69 @@ +{ + "name": "@paramrig/vector", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "ParamRig vector documents, bindings and rendering", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/packages/vector/src/index.d.ts", + "import": "./dist/index.js" + }, + "./model": { + "types": "./dist/types/src/vector/model.d.ts", + "import": "./dist/model.js" + }, + "./bindings": { + "types": "./dist/types/src/vector/rig.d.ts", + "import": "./dist/bindings.js" + }, + "./geometry": { + "types": "./dist/types/src/vector/geometry.d.ts", + "import": "./dist/geometry.js" + }, + "./svg": { + "types": "./dist/types/src/vector/svg.d.ts", + "import": "./dist/svg.js" + }, + "./project": { + "types": "./dist/types/src/vector/project.d.ts", + "import": "./dist/project.js" + }, + "./export": { + "types": "./dist/types/src/vector/exportRuntime.d.ts", + "import": "./dist/export.js" + }, + "./pdf": { + "types": "./dist/types/src/vector/pdfRuntime.d.ts", + "import": "./dist/pdf.js" + }, + "./resources": { + "types": "./dist/types/src/vector/resources.d.ts", + "import": "./dist/resources.js" + }, + "./browser": { + "types": "./dist/types/src/vector/browser.d.ts", + "import": "./dist/browser.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "dependencies": { + "@paramrig/core": "0.1.0", + "paper": "^0.12.18", + "opentype.js": "^2.0.0", + "fontkit": "^2.0.4" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Anymfah/paramrig.git", + "directory": "packages/vector" + } +} diff --git a/packages/vector/src/index.ts b/packages/vector/src/index.ts new file mode 100644 index 0000000..9552e50 --- /dev/null +++ b/packages/vector/src/index.ts @@ -0,0 +1,4 @@ +export * from '../../../src/vector/types' +export * from '../../../src/vector/model' +export * from '../../../src/vector/rig' +export { exportProject, serializeProject, importProject, type VectorProject, type ProjectImport } from '../../../src/vector/project' diff --git a/packages/web-sdk/package.json b/packages/web-sdk/package.json index 54d55be..7e99c8d 100644 --- a/packages/web-sdk/package.json +++ b/packages/web-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@paramrig/web", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "license": "MIT", "publishConfig": { diff --git a/public/.htaccess b/public/.htaccess index 198f65a..1ae5996 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -6,7 +6,7 @@ Options -Indexes RewriteCond %{REQUEST_FILENAME} !-d # Every route src/App.tsx declares, so a reload or a shared link answers with the application # rather than with Apache's 404. src/App.routes.test.ts fails when a route is added without one. - RewriteRule ^(?:r/[^/]+/?|web/?|docs(?:/controls|/vector-rigs|/scene-rigs)?/?)$ index.html [L] + RewriteRule ^(?:r/[^/]+/?|audio/?|vector/?|3d/?|web/?|docs(?:/controls|/vector-rigs|/scene-rigs|/audio-rigs)?/?)$ index.html [L] @@ -15,3 +15,9 @@ Options -Indexes Header always set X-Robots-Tag "noindex, nofollow" Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + + + + Header set Cache-Control "no-cache" + + diff --git a/public/fonts/ARTWORK-FONTS.md b/public/fonts/ARTWORK-FONTS.md new file mode 100644 index 0000000..3754d20 --- /dev/null +++ b/public/fonts/ARTWORK-FONTS.md @@ -0,0 +1,15 @@ +# Artwork font sources + +The graphic-charter rig uses Public Sans (the existing default), Space Grotesk and Source Serif 4. +These are artwork choices; application chrome remains unchanged. + +- Space Grotesk: https://github.com/google/fonts/tree/main/ofl/spacegrotesk +- Source Serif 4: https://github.com/google/fonts/tree/main/ofl/sourceserif4 + +Retrieved 2026-09-12. Original SIL Open Font License files are included alongside each family. +The distributed TTF and WOFF2 files are subsets of the upright variable masters, keeping +U+0000–024F, U+2000–206F, U+20AC and U+2190–21FF, with all layout features. +Source Serif 4's optical-size axis is fixed at 20 to reduce transfer size and keep +the same optical design in browser rendering and PDF outlines; its weight axis remains variable. +WOFF2 serves the canvas and SVG/PNG exports; TTF supplies PDF glyph outlines. +They are served from this application's origin, without a third-party font request. diff --git a/public/fonts/Roboto-OFL.txt b/public/fonts/Roboto-OFL.txt new file mode 100644 index 0000000..9c48e05 --- /dev/null +++ b/public/fonts/Roboto-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Roboto.woff2 b/public/fonts/Roboto.woff2 new file mode 100644 index 0000000..8dc272e Binary files /dev/null and b/public/fonts/Roboto.woff2 differ diff --git a/public/fonts/SourceSerif4-OFL.txt b/public/fonts/SourceSerif4-OFL.txt new file mode 100644 index 0000000..98507d3 --- /dev/null +++ b/public/fonts/SourceSerif4-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/SourceSerif4.ttf b/public/fonts/SourceSerif4.ttf new file mode 100644 index 0000000..813691b Binary files /dev/null and b/public/fonts/SourceSerif4.ttf differ diff --git a/public/fonts/SourceSerif4.woff2 b/public/fonts/SourceSerif4.woff2 new file mode 100644 index 0000000..53a05f3 Binary files /dev/null and b/public/fonts/SourceSerif4.woff2 differ diff --git a/public/fonts/SpaceGrotesk-OFL.txt b/public/fonts/SpaceGrotesk-OFL.txt new file mode 100644 index 0000000..d5666d7 --- /dev/null +++ b/public/fonts/SpaceGrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/SpaceGrotesk.ttf b/public/fonts/SpaceGrotesk.ttf new file mode 100644 index 0000000..7970c6c Binary files /dev/null and b/public/fonts/SpaceGrotesk.ttf differ diff --git a/public/fonts/SpaceGrotesk.woff2 b/public/fonts/SpaceGrotesk.woff2 new file mode 100644 index 0000000..4062ae9 Binary files /dev/null and b/public/fonts/SpaceGrotesk.woff2 differ diff --git a/public/thumbnails/audio/audio-example-arcade-coin.svg b/public/thumbnails/audio/audio-example-arcade-coin.svg new file mode 100644 index 0000000..3ce949e --- /dev/null +++ b/public/thumbnails/audio/audio-example-arcade-coin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/scene/example-desk-study.svg b/public/thumbnails/scene/example-desk-study.svg new file mode 100644 index 0000000..5b2dcf5 --- /dev/null +++ b/public/thumbnails/scene/example-desk-study.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/scene/example-paper-lantern.svg b/public/thumbnails/scene/example-paper-lantern.svg new file mode 100644 index 0000000..bfb853b --- /dev/null +++ b/public/thumbnails/scene/example-paper-lantern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/scene/tidal-planet.svg b/public/thumbnails/scene/tidal-planet.svg new file mode 100644 index 0000000..281aa9a --- /dev/null +++ b/public/thumbnails/scene/tidal-planet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/vector/contour-bloom.svg b/public/thumbnails/vector/contour-bloom.svg new file mode 100644 index 0000000..bdfcc93 --- /dev/null +++ b/public/thumbnails/vector/contour-bloom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/vector/vector-example-aperture-mark.svg b/public/thumbnails/vector/vector-example-aperture-mark.svg new file mode 100644 index 0000000..dd9eca0 --- /dev/null +++ b/public/thumbnails/vector/vector-example-aperture-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/thumbnails/vector/vector-example-aperture-poster.svg b/public/thumbnails/vector/vector-example-aperture-poster.svg new file mode 100644 index 0000000..728571d --- /dev/null +++ b/public/thumbnails/vector/vector-example-aperture-poster.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + APERTURE + Field study 04 · shape, light,aperture + 04 / 12 + Paper, ink and oneopening of light + + + diff --git a/public/thumbnails/vector/vector-example-field-form-brand.svg b/public/thumbnails/vector/vector-example-field-form-brand.svg new file mode 100644 index 0000000..7c46d5d --- /dev/null +++ b/public/thumbnails/vector/vector-example-field-form-brand.svg @@ -0,0 +1,283 @@ + + + + + + FIELD NOTES / A SHARED GROUND + IDENTITY MANUAL / 2026 + + + Field / Form + 01 / 08 + Field / Form + + ARCHITECTURE& THE COMMON GOOD + Good placesbegin betweenus. + Places made for shared life. + An independent practice shaping everydayspaces with the people who use them. + A FICTIONAL PRACTICE. A WORKING IDENTITY SYSTEM. + + + + + + THE MARK / TWO FORMS. ONE SHARED SPACE. + IDENTITY MANUAL / 2026 + + + Field / Form + 02 / 08 + Built from the same ground. + + + + + + + + + + + + + + + + + + + + + + + + + 10 × 10 GRID / STEM = 2 UNITS + + + x + Leave one stem width (x) clearon every side. Nothing entersthis space. + + + Field / Form + HORIZONTAL LOCKUP / GAP ≈ 2x + + + Field / Form + REVERSED + Two F forms face one another, one rotatedthrough 180 degrees. Their shared grid joinsField and Form in a single, balanced symbol. + SCREEN MINIMUM + + + + 16 / 24 / 32 pxUse the symbol alone below a 120 px lockup. + + + + + + COLOUR / THE FIELD, NOT THE DECORATION + IDENTITY MANUAL / 2026 + + + Field / Form + 03 / 08 + Quiet ground.A living accent. + Paper carries the content. Inkcarries the words. Field anchors theidentity. Lime marks an invitation,never a paragraph. + + 01 / PAPER + 55% + #F2EFE7 + Space & long-form reading + + 02 / FIELD + 30% + #254E3D + Identity & large surfaces + + 03 / INK + 10% + #202922 + Type & monochrome + + 04 / LIME + 5% + #D7F06F + Invitation & emphasis + + + + + + + Starting proportions, not a quota. Use one dominantfield per composition. Never give all four coloursequal weight. + Text pairs: Ink / Paper, Paper / Field, Ink / Lime.Recheck contrast after changing colours. Keep Lime offPaper for small text. + MASTER: sRGB HEX. CONVERT WITH THE PRINTER’S ICC PROFILE; APPROVE A PHYSICAL PROOF. + + + + + + TYPOGRAPHY / PUBLIC, PRECISE, HUMAN + IDENTITY MANUAL / 2026 + + + Field / Form + 04 / 08 + Public Sans + HEADLINES ABOVE / BODY FAMILY BELOW + Public Sans + Aa + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 & / — ( ) + + DISPLAY + 80 + 600 + 1.08 + Shared life. + + HEADING + 40 + 600 + 1.08 + A place to belong. + + BODY + 20 + 400 + 1.5 + Start with the people who live here.Listen first. Draw what comes next. + + CAPTION + 12 + 600 + 1.3 + PROJECT NOTES / 2026 + + + + + + GRAPHIC LANGUAGE / LEAVE ROOM + IDENTITY MANUAL / 2026 + + + Field / Form + 05 / 08 + Structure makesspace for life. + A six-column grid. A generous edge.One decisive crop. The identityshould feel composed, never filledin. + + + + + + + + NEIGHBOURHOOD NOTES + Commonground. + + 6 COLUMNS / 12 GUTTER / 32 MARGIN + + + + + + REPEAT THE SYMBOL. KEEP ITS COUNTERFORMS OPEN. + Photograph use, not perfection. + Eye-level spaces, people in context, natural light, honestmaterials. Keep verticals straight and colour restrained.No empty luxury interiors or staged handshakes. + Align left. Work in multiples of 8. Keep text off busy images. Crop the graphic motif, never the identifying logo. + + + + + + STATIONERY / USEFUL THINGS, WELL MADE + IDENTITY MANUAL / 2026 + + + Field / Form + 06 / 08 + The everyday carries the brand. + + + + Field / Form + + PROJECT / 026 — 14 MAY 2026 + A more usefulshared courtyard. + Dear neighbours,Thank you for walking the site with us. Yournotes about shade, access and places to sit arenow part of the next drawing.We will bring the revised plan to the openstudio on Saturday. + + hello@fieldform.examplefieldform.example + + + Field / Form + + PROJECT ENQUIRIES + Places made for sharedlife. + hello@fieldform.examplefieldform.example + ARTWORK STUDIES / A4 LETTERHEAD / 85 × 55 mm CARD / UNCOATED STOCK, NO GLOSS + + + + + + CAMPAIGN / AN INVITATION TO TAKE PART + IDENTITY MANUAL / 2026 + + + Field / Form + 07 / 08 + + Field / Form + Room foreveryone. + + OPEN STUDIOSATURDAY / 10:00—16:00 + + Bring your ideas for the neighbourhood. + + + Field / Form + Room foreveryone. + OPEN STUDIOSATURDAY / 10:00—16:00 + An invitation,not an announcement. + Lead with the public benefit. Keep the time and placevisible. One message, one action; no logo wallpaper behindthe headline. + + + + + + USE & HANDOFF / KEEP IT RECOGNISABLE + IDENTITY MANUAL / 2026 + + + Field / Form + 08 / 08 + The same voice. Every scale. + + + Field / Form + Work Practice Contact + Good placesbegin betweenus. + + Explore the projects ↗ + + + + Field / Form + Room foreveryone. + Places made for shared life. + DESKTOP & COMPACT LAYOUT STUDIES / PRESERVE MARGINS BEFORE SHRINKING TYPE + KEEP THE MARK INTACT + + + No stretching + + + No rotation + + No outlining + BEFORE RELEASE + Use the vector master. Check 16 px and monochrome.Recheck contrast and long names after tuning.Embed fonts for export. Proof print colour and bleed.Keep the legal name, URL and contact details verified. + + + diff --git a/public/thumbnails/web/controller-lab.svg b/public/thumbnails/web/controller-lab.svg new file mode 100644 index 0000000..c6670de --- /dev/null +++ b/public/thumbnails/web/controller-lab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/web/surface-studies.svg b/public/thumbnails/web/surface-studies.svg new file mode 100644 index 0000000..b94c332 --- /dev/null +++ b/public/thumbnails/web/surface-studies.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/thumbnails/web/type-specimen.svg b/public/thumbnails/web/type-specimen.svg new file mode 100644 index 0000000..d20576c --- /dev/null +++ b/public/thumbnails/web/type-specimen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/app-assets.d.mts b/scripts/app-assets.d.mts new file mode 100644 index 0000000..f4a7259 --- /dev/null +++ b/scripts/app-assets.d.mts @@ -0,0 +1,2 @@ +import type { Plugin } from 'vite' +export function appAssetsPlugin(): Plugin diff --git a/scripts/app-assets.mjs b/scripts/app-assets.mjs new file mode 100644 index 0000000..7435b27 --- /dev/null +++ b/scripts/app-assets.mjs @@ -0,0 +1,25 @@ +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' +import { resolve } from 'node:path' +import { selectModules } from './app-modules.mjs' +import { root } from './sdk-entries.mjs' +export function appAssetsPlugin() { + let outDir + return { name: 'paramrig-public-assets', apply: 'build', + config() { return { build: { copyPublicDir: false } } }, + configResolved(config) { outDir = resolve(config.root, config.build.outDir) }, + writeBundle() { + const modules = selectModules() + const files = ['.htaccess', 'favicon.svg', 'fonts/PublicSans.woff2', 'fonts/PublicSans-OFL.txt'] + if (modules.includes('vector') || modules.includes('scene')) files.push('fonts/PublicSans.ttf', 'fonts/SpaceGrotesk.ttf', 'fonts/SpaceGrotesk.woff2', 'fonts/SpaceGrotesk-OFL.txt', 'fonts/SourceSerif4.ttf', 'fonts/SourceSerif4.woff2', 'fonts/SourceSerif4-OFL.txt', 'fonts/ARTWORK-FONTS.md') + for (const module of modules) if (existsSync(resolve(root, 'public/thumbnails', module))) files.push(`thumbnails/${module}`) + const copy = file => { + const source = resolve(root, 'public', file) + if (statSync(source).isDirectory()) { for (const child of readdirSync(source)) copy(`${file}/${child}`); return } + const target = resolve(outDir, file) + mkdirSync(resolve(target, '..'), { recursive: true }) + copyFileSync(source, target) + } + for (const file of files) copy(file) + }, + } +} diff --git a/scripts/app-bundle-report.d.mts b/scripts/app-bundle-report.d.mts new file mode 100644 index 0000000..d0a9884 --- /dev/null +++ b/scripts/app-bundle-report.d.mts @@ -0,0 +1,2 @@ +import type { Plugin } from 'vite' +export function appBundleReport(): Plugin diff --git a/scripts/app-bundle-report.mjs b/scripts/app-bundle-report.mjs new file mode 100644 index 0000000..2997f4d --- /dev/null +++ b/scripts/app-bundle-report.mjs @@ -0,0 +1,26 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { measureBundle } from './bundle-metrics.mjs' +import { root } from './sdk-entries.mjs' +import { selectModules } from './app-modules.mjs' +export function appBundleReport() { + return { name: 'paramrig-bundle-report', apply: 'build', generateBundle(_options, bundle) { + const files = Object.values(bundle) + const initial = new Set() + const visit = name => { + if (initial.has(name)) return + initial.add(name) + const file = bundle[name] + if (file?.type !== 'chunk') return + for (const dependency of file.imports) visit(dependency) + for (const css of file.viteMetadata?.importedCss ?? []) initial.add(css) + } + for (const file of files) if (file.type === 'chunk' && file.isEntry && !file.isDynamicEntry) visit(file.fileName) + const measurement = measureBundle(files.filter(file => initial.has(file.fileName))) + const selected = selectModules() + const report = { selected, initial: { ...measurement, modules: [...measurement.modules] }, total: { ...measureBundle(files), modules: [...measureBundle(files).modules] }, chunks: files.filter(file => file.type === 'chunk').map(file => ({ name: file.fileName, imports: file.imports, dynamicImports: file.dynamicImports, modules: Object.keys(file.modules) })) } + mkdirSync(resolve(root, '.local/modularity'), { recursive: true }) + writeFileSync(resolve(root, '.local/modularity', `${selected.join('-')}-app-build.json`), JSON.stringify(report, null, 2)) + console.log(`Library initial JS/CSS: ${measurement.gzipBytes} gzip bytes; ${measurement.modules.size} modules`) + } } +} diff --git a/scripts/app-modules.d.mts b/scripts/app-modules.d.mts new file mode 100644 index 0000000..4de02f7 --- /dev/null +++ b/scripts/app-modules.d.mts @@ -0,0 +1,4 @@ +import type { Plugin } from 'vite' +export const allModules: string[] +export function selectModules(value?: string): string[] +export function appModulesPlugin(): Plugin diff --git a/scripts/app-modules.mjs b/scripts/app-modules.mjs new file mode 100644 index 0000000..6646cd4 --- /dev/null +++ b/scripts/app-modules.mjs @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { root } from './sdk-entries.mjs' +export const allModules = ['audio', 'vector', 'scene', 'web'] +export function selectModules(value = process.env.PARAMRIG_MODULES) { + if (!value) return allModules + const selected = [...new Set(value.split(',').map(item => item.trim()).filter(Boolean))] + if (!selected.length || selected.some(item => !allModules.includes(item))) throw Error('Select one or more modules: audio, vector, scene, web') + return allModules.filter(item => selected.includes(item)) +} +/** Literal imports are generated before bundling. Omitted domains never enter the app graph. */ +export function appModulesPlugin() { + const selected = selectModules() + return { name: 'paramrig-modules', + resolveId(id) { if (id === 'virtual:paramrig-modules') return '\0paramrig-modules' }, + load(id) { + if (id !== '\0paramrig-modules') return + const path = resolve(root, 'src/modules/catalog.generated.json') + this.addWatchFile(path) + const all = JSON.parse(readFileSync(path, 'utf8')) + const catalog = all.filter(item => selected.includes(item.module)) + const requiredModules = Object.fromEntries(all.map(item => [item.id, item.module])) + return `export const requiredModules = ${JSON.stringify(requiredModules)}; export const catalog = ${JSON.stringify(catalog)}; export const loaders = {${selected.map(module => `${module}: () => import('/src/modules/${module}.tsx').then(module => module.default)`).join(',')}};` + }, + } +} diff --git a/scripts/app-preview.d.mts b/scripts/app-preview.d.mts new file mode 100644 index 0000000..eb665d5 --- /dev/null +++ b/scripts/app-preview.d.mts @@ -0,0 +1,2 @@ +import type { Plugin } from 'vite' +export function appPreviewPlugin(): Plugin diff --git a/scripts/app-preview.mjs b/scripts/app-preview.mjs new file mode 100644 index 0000000..bbc4e8c --- /dev/null +++ b/scripts/app-preview.mjs @@ -0,0 +1,25 @@ +import { createReadStream, existsSync, statSync } from 'node:fs' +import { resolve, extname, sep } from 'node:path' +import { root } from './sdk-entries.mjs' +/** Serve exact built artifacts on loopback hostnames through the existing development service. */ +export function appPreviewPlugin() { + return { name: 'paramrig-distribution-preview', apply: 'serve', configureServer(server) { + server.middlewares.use((req, res, next) => { + const match = /^paramrig-(suite|audio|vector|scene|web|website)\.localhost(?::\d+)?$/.exec(req.headers.host ?? '') + if (!match) return next() + const base = resolve(root, '.local/modularity/distributions', match[1]) + let path + try { path = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname) } catch { res.statusCode = 400; res.end(); return } + const candidate = resolve(base, `.${path}`) + if (!candidate.startsWith(base + sep) && candidate !== base) { res.statusCode = 403; res.end(); return } + const index = resolve(candidate, 'index.html') + const file = existsSync(candidate) && statSync(candidate).isFile() ? candidate : existsSync(index) ? index : match[1] !== 'website' && !extname(path) ? resolve(base, 'index.html') : candidate + if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end('Build this distribution first.'); return } + const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.webp': 'image/webp', '.png': 'image/png', '.txt': 'text/plain', '.xml': 'application/xml' } + res.setHeader('Content-Type', mime[extname(file)] ?? 'application/octet-stream') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('X-Content-Type-Options', 'nosniff') + createReadStream(file).pipe(res) + }) + } } +} diff --git a/scripts/audio-bench.mjs b/scripts/audio-bench.mjs new file mode 100644 index 0000000..0e5e48e --- /dev/null +++ b/scripts/audio-bench.mjs @@ -0,0 +1,49 @@ +/** + * How long the synthesiser takes, per preset and in total. + * + * The renderer is a pure function with no clock, so its cost is a number that can be watched + * rather than guessed at. Every change to the engine is supposed to leave this alone; the ones + * that are meant to make it faster have to show it. Run it before and after, and paste both. + * + * node --experimental-strip-types scripts/audio-bench.mjs [rounds] + */ +import { PRESETS } from '../src/audio/presets.ts' +import { renderPatch } from '../src/audio/dsp/render.ts' + +const SAMPLE_RATE = 44100 +const rounds = Math.max(1, Number(process.argv[2] ?? 3)) + +/** One pass over the whole library, timed per preset. */ +function pass() { + const rows = [] + for (const preset of PRESETS) { + const patch = preset.build() + const started = performance.now() + const out = renderPatch(patch, SAMPLE_RATE) + const took = performance.now() - started + rows.push({ id: preset.id, ms: took, seconds: out.left.length / SAMPLE_RATE }) + } + return rows +} + +// One pass thrown away: the first render pays for the compiler warming up on this code. +pass() + +const best = new Map() +let total = 0 +for (let round = 0; round < rounds; round += 1) { + for (const row of pass()) { + const kept = best.get(row.id) + if (!kept || row.ms < kept.ms) best.set(row.id, row) + } +} +for (const row of best.values()) total += row.ms + +const rows = [...best.values()].sort((a, b) => b.ms - a.ms) +const audio = rows.reduce((sum, row) => sum + row.seconds, 0) +console.log(`${rows.length} presets, ${audio.toFixed(1)} s of audio, best of ${rounds}\n`) +console.log('slowest ten') +for (const row of rows.slice(0, 10)) { + console.log(` ${row.ms.toFixed(1).padStart(7)} ms ${(row.ms / row.seconds).toFixed(1).padStart(6)}× faster than real time is 1000/this ${row.id}`) +} +console.log(`\ntotal ${total.toFixed(0)} ms · ${(total / rows.length).toFixed(1)} ms per preset · ${(audio * 1000 / total).toFixed(0)}× real time`) diff --git a/scripts/audio-preview.mjs b/scripts/audio-preview.mjs new file mode 100644 index 0000000..559537f --- /dev/null +++ b/scripts/audio-preview.mjs @@ -0,0 +1,55 @@ +/** + * Renders every preset to a .wav so they can be listened to before any interface exists. + * + * This is the whole point of keeping the DSP kernel free of Web Audio: the synthesiser runs here, + * in Node, with no browser and no sound card, and the files it writes are byte-identical to what + * the workbench will play. If a sound is wrong, it is wrong before a single control is drawn. + * + * node --experimental-strip-types scripts/audio-preview.mjs [outputDir] + */ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { PRESETS } from '../src/audio/presets.ts' +import { renderPatch } from '../src/audio/dsp/render.ts' +import { stereoLevels } from '../src/audio/waveform.ts' +import { encodeWav } from '../src/audio/dsp/wav.ts' + +const SAMPLE_RATE = 44100 + +const dB = (value) => (value <= 0 ? '-inf' : `${(20 * Math.log10(value)).toFixed(1)} dB`) + +async function main() { + const out = path.resolve(process.argv[2] ?? 'output/audio') + await fs.mkdir(out, { recursive: true }) + + const rows = [] + for (const preset of PRESETS) { + const patch = preset.build() + const stereo = renderPatch(patch, SAMPLE_RATE) + const file = path.join(out, `${preset.id}.wav`) + await fs.writeFile(file, encodeWav(stereo, SAMPLE_RATE)) + const { peak, rms } = stereoLevels(stereo) + // Counted in place. Spreading two Float32Arrays into one boxed JS array built and threw away + // a third of a million elements per preset to count a handful of them. + let clipped = 0 + for (const channel of [stereo.left, stereo.right]) { + for (let i = 0; i < channel.length; i += 1) if (Math.abs(channel[i]) >= 0.999) clipped += 1 + } + rows.push({ id: preset.id, seconds: patch.duration, peak, rms, clipped, file }) + } + + const width = Math.max(...rows.map((r) => r.id.length)) + console.log(`\n${rows.length} presets → ${out}\n`) + console.log(`${'preset'.padEnd(width)} dur peak rms clipped`) + for (const r of rows) { + console.log( + `${r.id.padEnd(width)} ${r.seconds.toFixed(2)}s ${dB(r.peak).padEnd(10)} ${dB(r.rms).padEnd(10)} ${r.clipped}`, + ) + } + console.log('') +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/build-control-styles.mjs b/scripts/build-control-styles.mjs new file mode 100644 index 0000000..072d6ed --- /dev/null +++ b/scripts/build-control-styles.mjs @@ -0,0 +1,33 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import postcss from 'postcss' +import { root } from './sdk-entries.mjs' + +/** Prefix the actual shared UI styles, including nested CSS. No copied styling implementation. */ +export async function buildControlStyles(outDir) { + const scope = ':where(.paramrig-controls, .paramrig-control-portal)' + const source = await Promise.all(['tokens.css', 'components.css'].map(file => readFile(resolve(root, 'src/styles', file), 'utf8'))) + const sheet = postcss.parse(source.join('\n')) + sheet.walkAtRules('font-face', rule => rule.remove()) + const animations = new Map() + sheet.walkAtRules(/keyframes$/, rule => { const name = rule.params; const next = `paramrig-control-${name}`; animations.set(name, next); rule.params = next }) + sheet.walkDecls(decl => { + if (decl.prop === '--font-sans' || decl.prop === '--font-mono') decl.value = decl.prop === '--font-sans' ? 'system-ui, sans-serif' : 'ui-monospace, monospace' + if (/^animation(?:-name)?$/.test(decl.prop)) for (const [name, next] of animations) decl.value = decl.value.replace(new RegExp(`\\b${name}\\b`, 'g'), next) + }) + sheet.walkRules(rule => { + let parent = rule.parent + while (parent) { if (parent.type === 'rule' || parent.type === 'atrule' && /keyframes$/.test(parent.name)) return; parent = parent.parent } + // Page layout belongs to the host. Keep reset rules only within actual components. + if (rule.selectors.some(selector => /(^|[\s,])(?:html|body|#root)(?=[\s.:#\[]|$)/.test(selector))) { rule.remove(); return } + rule.selectors = rule.selectors.flatMap(selector => { + if (/(^|[\s,])(?:html|body|#root)(?=[\s.:#\[]|$)|:root/.test(selector)) return [selector.replace(/:root|\bhtml\b|\bbody\b|#root/g, scope)] + return [`${scope} ${selector}`, `${scope}:is(${selector})`] + }) + }) + sheet.append(`${scope} { font-family: var(--font-sans); font-size: 14px; line-height: var(--leading-snug); color: var(--text-primary); }\n.paramrig-rig-controls { display: grid; gap: var(--space-4); }`) + const css = sheet.toString() + if (/@font-face|url\(/.test(css)) throw new Error('Controls CSS includes an implicit font or asset') + await writeFile(resolve(outDir, 'styles.css'), css) + await writeFile(resolve(outDir, 'styles.d.ts'), 'export {}\n') +} diff --git a/scripts/build-modules.mjs b/scripts/build-modules.mjs new file mode 100644 index 0000000..d84e0e9 --- /dev/null +++ b/scripts/build-modules.mjs @@ -0,0 +1,45 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { root } from './sdk-entries.mjs' +import { calibrateBundleMetrics } from './bundle-metrics.mjs' +import { allModules, selectModules } from './app-modules.mjs' +await calibrateBundleMetrics() +const matrix = process.argv.includes('--matrix') +const args = process.argv.slice(2) +if (args.some(arg => arg.startsWith('--') && arg !== '--matrix' && !arg.startsWith('--modules='))) throw Error('Usage: build:modules -- --modules=audio,vector or --matrix') +const selections = args.filter(arg => arg !== '--matrix').map(arg => arg.replace(/^--modules=/, '')) +if (selections.length > 1 || (matrix && selections.length)) throw Error('Choose one module list or --matrix') +const requested = selections[0] +if (requested === '') throw Error('The module list cannot be empty') +const groups = matrix ? [allModules, ...allModules.map(module => [module])] : [selectModules(requested)] +for (const group of groups) { + const name = group.length === 4 ? 'suite' : group.join('-') + const output = resolve(root, '.local/modularity/distributions', name) + execFileSync(process.execPath, [resolve(root, 'node_modules/vite/bin/vite.js'), 'build', '--mode', 'app', '--outDir', output], { cwd: root, env: { ...process.env, PARAMRIG_MODULES: group.join(',') }, stdio: 'inherit' }) + const report = JSON.parse(readFileSync(resolve(root, '.local/modularity', `${group.join('-')}-app-build.json`), 'utf8')) + const foreign = allModules.filter(module => !group.includes(module)) + // Sound Labs owns its 3D relief. It shares Three.js, never the Scene SDK or editor. + const presentationDependencies = group.includes('audio') ? { three: 'Sound Labs spectral relief; loaded when Labs opens' } : {} + for (const module of report.total.modules) { + if (foreign.some(domain => module.includes(`/src/${domain}/`))) throw Error(`${name} includes an omitted domain: ${module}`) + if (!group.includes('scene') && !group.includes('audio') && /node_modules\/(three|three-mesh-bvh|three-bvh-csg|@react-three)\//.test(module)) throw Error(`${name} includes 3D dependencies: ${module}`) + if (!group.includes('vector') && /node_modules\/paper\//.test(module)) throw Error(`${name} includes Paper: ${module}`) + } + if (report.initial.modules.some(module => /\/src\/(audio|vector|scene)\//.test(module) || /node_modules\/(three|paper)\//.test(module))) throw Error(`${name} starts an engine in the library`) + if (report.initial.gzipBytes > 250000) throw Error(`${name} library exceeds 250000 gzip bytes`) + writeFileSync(resolve(output, 'distribution.json'), JSON.stringify({ modules: group, presentationDependencies, initialGzipBytes: report.initial.gzipBytes, schemaVersion: 1 }, null, 2)) + const files = readdirSync(output, { recursive: true, withFileTypes: true }).filter(file => file.isFile()).map(file => resolve(file.parentPath, file.name).slice(output.length + 1)) + for (const file of files) { + if (file.startsWith('thumbnails/') && !group.includes(file.split('/')[1])) throw Error(`Foreign thumbnail in ${name}: ${file}`) + if (!group.includes('vector') && !group.includes('scene') && file.startsWith('fonts/') && !['fonts/PublicSans.woff2', 'fonts/PublicSans-OFL.txt'].includes(file)) throw Error(`Foreign artwork font in ${name}: ${file}`) + if (!/^(assets\/|fonts\/|thumbnails\/|index\.html$|favicon\.svg$|\.htaccess$|distribution\.json$)/.test(file)) throw Error(`Unexpected archive file in ${name}: ${file}`) + if (/\.(map|tsx?)$/.test(file) || readFileSync(resolve(output, file)).length === 0) throw Error(`Source or empty file in ${name}: ${file}`) + } + mkdirSync(resolve(root, '.local/modularity/archives'), { recursive: true }) + const archive = resolve(root, '.local/modularity/archives', `paramrig-${name}.tar.gz`) + execFileSync('tar', ['-czf', archive, '-C', output, '.']) + const archived = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).split('\n').filter(file => file && !file.endsWith('/')).map(file => file.replace(/^\.\//, '')).sort() + if (JSON.stringify(archived) !== JSON.stringify(files.sort())) throw Error(`Archive content does not match verified distribution: ${name}`) + console.log(`Verified and archived ${name}: ${report.initial.gzipBytes} initial gzip bytes`) +} diff --git a/scripts/build-sdks.mjs b/scripts/build-sdks.mjs new file mode 100644 index 0000000..0a8aef7 --- /dev/null +++ b/scripts/build-sdks.mjs @@ -0,0 +1,96 @@ +import { buildControlStyles } from './build-control-styles.mjs' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { calibrateBundleMetrics, measureBundle } from './bundle-metrics.mjs' +import ts from 'typescript' +import { build } from 'vite' +import { entries, root, sourceAliases, specifier } from './sdk-entries.mjs' + +await calibrateBundleMetrics() +const requested = process.argv.slice(2) +const selected = requested.length ? requested : Object.keys(entries) +for (const name of selected) if (!entries[name]) throw new Error(`Unknown SDK: ${name}`) +const posix = path => path.replaceAll('\\', '/') +const config = ts.readConfigFile(resolve(root, 'tsconfig.app.json'), ts.sys.readFile).config +const compiler = ts.parseJsonConfigFileContent(config, ts.sys, root).options + +async function declarations(name, points, outDir) { + const options = { ...compiler, noEmit: false, declaration: true, emitDeclarationOnly: true, + incremental: false, composite: false, noEmitOnError: true, rootDir: root, + outDir: resolve(outDir, 'types'), tsBuildInfoFile: undefined } + const ambient = ts.parseJsonConfigFileContent(config, ts.sys, root).fileNames.filter(file => file.endsWith('.d.ts') && !file.includes('/src/modules/')) + const program = ts.createProgram({ rootNames: [...Object.values(points).map(path => resolve(root, path)), ...ambient], options }) + const diagnostics = ts.getPreEmitDiagnostics(program) + if (diagnostics.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: f => f, getCurrentDirectory: () => root, getNewLine: () => '\n', + })) + const writes = [] + const result = program.emit(undefined, (file, content, _bom, _error, sources) => { + const source = sources?.[0]?.fileName + if (!source) return + // Dependency declarations belong to their own package, never to this tarball. + if (source.includes('/packages/') && !source.includes(`/packages/${name}/`)) return + const ast = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true) + const edits = [] + const visit = node => { + if (ts.isStringLiteral(node) && (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent) + || (ts.isLiteralTypeNode(node.parent) && ts.isImportTypeNode(node.parent.parent)))) { + const id = node.text + if (id.startsWith('@/') || id.startsWith('.')) { + const resolved = ts.resolveModuleName(id, source, options, ts.sys).resolvedModule?.resolvedFileName + if (resolved && !resolved.includes('/node_modules/')) { + const target = resolve(outDir, 'types', relative(root, resolved)).replace(/\.(tsx?|mts)$/, '.js') + const path = posix(relative(dirname(file), target)) + edits.push([node.getStart(ast), node.getEnd(), JSON.stringify(path.startsWith('.') ? path : `./${path}`)]) + } + } + } + ts.forEachChild(node, visit) + } + visit(ast) + for (const [start, end, value] of edits.sort((a, b) => b[0] - a[0])) content = content.slice(0, start) + value + content.slice(end) + writes.push(mkdir(dirname(file), { recursive: true }).then(() => writeFile(file, content))) + }) + if (result.emitSkipped) throw new Error(`Declaration emission failed for ${name}`) + await Promise.all(writes) +} + +for (const name of selected) { + const points = entries[name] + const destination = resolve(root, 'packages', name, 'dist') + const outDir = resolve(root, '.local/modularity/sdk-builds', name) + const manifest = JSON.parse(await readFile(resolve(root, 'packages', name, 'package.json'), 'utf8')) + const allowed = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]) + const external = id => allowed.has(id) || [...allowed].some(dep => id.startsWith(`${dep}/`)) + await rm(outDir, { recursive: true, force: true }) + const modules = new Set() + const result = await build({ configFile: false, publicDir: false, base: './', logLevel: 'warn', + resolve: { alias: [...sourceAliases.filter(alias => !external(alias.find)), { find: '@', replacement: resolve(root, 'src') }], dedupe: ['three', 'react', 'react-dom'] }, + plugins: [{ name: 'sdk-boundaries', generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) if (output.type === 'chunk') { + for (const id of Object.keys(output.modules)) modules.add(posix(relative(root, id))) + } + } }], + build: { outDir, emptyOutDir: false, minify: true, target: 'es2022', + lib: { entry: Object.fromEntries(Object.entries(points).map(([entry, source]) => [entry === '.' ? 'index' : entry.slice(2), resolve(root, source)])), formats: ['es'] }, + rollupOptions: { external, output: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' } }, + }, + }) + const forbidden = name === 'core' ? /^(src\/(audio|vector|scene|web|ui|resources)\/|node_modules\/)/ + : name.startsWith('audio') ? /^(src\/(vector|scene|web|ui|resources)\/|node_modules\/(react|three|paper)(\/|$))/ + : name === 'vector' ? /^(src\/(audio|scene|web|ui|editor|resources|rigs\/examples)\/|node_modules\/(react|three)(\/|$))/ : name === 'scene' ? /^(src\/(audio|vector|web|ui|editor|resources|rigs\/examples)\/|node_modules\/(react|paper)(\/|$))/ : name === 'controls' ? /^(src\/(audio|vector|scene|web|state\/resources|renderers)|node_modules\/(three|paper)(\/|$))/ : null + for (const id of modules) if (/^src\/(modules|library|state\/(resources|persistence|workspace))/.test(id) || forbidden?.test(id)) throw new Error(`${specifier(name, '.')} includes forbidden module ${id}`) + const outputs = (Array.isArray(result) ? result : [result]).flatMap(result => result.output ?? []) + const sizes = measureBundle(outputs).files + await declarations(name, points, outDir) + if (name === 'controls') await buildControlStyles(outDir) + const previous = `${destination}.previous` + await rm(previous, { recursive: true, force: true }) + let retained = false + try { await rename(destination, previous); retained = true } catch (error) { if (error.code !== 'ENOENT') throw error } + try { await rename(outDir, destination) } catch (error) { if (retained) await rename(previous, destination); throw error } + await rm(previous, { recursive: true, force: true }) + await mkdir(resolve(root, '.local/modularity'), { recursive: true }) + await writeFile(resolve(root, '.local/modularity', `${name}-build.json`), JSON.stringify({ modules: [...modules].sort(), sizes }, null, 2)) + console.log(`${manifest.name}: built ${sizes.length} JavaScript artifacts and checked declarations`) +} diff --git a/scripts/build-web-declarations.mjs b/scripts/build-web-declarations.mjs new file mode 100644 index 0000000..960f794 --- /dev/null +++ b/scripts/build-web-declarations.mjs @@ -0,0 +1,24 @@ +import ts from 'typescript' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { root } from './sdk-entries.mjs' + +const configPath = resolve(root, 'tsconfig.web-sdk.json') +const config = ts.readConfigFile(configPath, ts.sys.readFile) +if (config.error) throw Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root) +const host = ts.createCompilerHost(parsed.options) +const originalRead = host.readFile.bind(host) +// Web carries its existing structural types, compiled from the canonical Core source. +// This keeps the public Web paths stable without adding a Core installation dependency. +const source = new Map(['types', 'extended-types'].map(name => [resolve(root, `src/rigs/${name}.ts`), resolve(root, `packages/core/src/${name}.ts`)])) +host.readFile = file => source.has(resolve(file)) ? readFileSync(source.get(resolve(file)), 'utf8') : originalRead(file) +const write = host.writeFile.bind(host) +host.writeFile = (file, content, ...rest) => write(file, content.replace(/(['"])(\.{1,2}\/[^'"\n]+)\1/g, (_match, quote, path) => `${quote}${path.replace(/\.ts$/, '').replace(/(?:\.js)?$/, '.js')}${quote}`), ...rest) +const program = ts.createProgram(parsed.fileNames, parsed.options, host) +const result = program.emit() +const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program), ...result.diagnostics] +if (diagnostics.length || result.emitSkipped) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { getCurrentDirectory: () => root, getCanonicalFileName: file => file, getNewLine: () => '\n' })) + process.exit(1) +} diff --git a/scripts/bundle-metrics.mjs b/scripts/bundle-metrics.mjs new file mode 100644 index 0000000..8098e93 --- /dev/null +++ b/scripts/bundle-metrics.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { gzipSync } from 'node:zlib' +import { build } from 'vite' + +export function measureBundle(outputs) { + const modules = new Set() + const files = outputs.map(output => { + if (output.type === 'chunk') for (const id of Object.keys(output.modules)) modules.add(id) + const bytes = output.type === 'chunk' ? output.code : output.source + return { file: output.fileName, bytes: Buffer.byteLength(bytes), gzip: gzipSync(bytes).length } + }) + return { modules, files, gzipBytes: files.reduce((total, file) => total + file.gzip, 0) } +} +export const containsDependency = (modules, name) => [...modules].some(id => id.replaceAll('\\', '/').includes(`/node_modules/${name}/`)) + +/** Exercise the same instrument on two known modules, one deferred, and a known asset. */ +export async function calibrateBundleMetrics() { + assert.equal(gzipSync('hello').length, 25, 'Gzip calibration changed') + const entry = '/probe/entry.js' + const foreign = '/probe/node_modules/three/index.js' + const result = await build({ configFile: false, publicDir: false, logLevel: 'silent', + plugins: [{ name: 'metric-calibration', + resolveId(id) { if (id === entry || id === foreign) return id }, + load(id) { + if (id === entry) return `export const value = 7; export const deferred = () => import(${JSON.stringify(foreign)});` + if (id === foreign) return 'export const cube = 3;' + }, + generateBundle() { this.emitFile({ type: 'asset', fileName: 'known.txt', source: 'hello' }) }, + }], + build: { write: false, minify: false, lib: { entry, formats: ['es'] } }, + }) + const outputs = (Array.isArray(result) ? result : [result]).flatMap(result => result.output) + const measured = measureBundle(outputs) + assert.equal(measured.files.length, 3, 'The probe missed a deferred chunk or asset') + assert(measured.modules.has(entry) && measured.modules.has(foreign), 'The probe missed known source modules') + assert(containsDependency(measured.modules, 'three'), 'The probe missed the forbidden dependency control') + assert(!containsDependency(measured.modules, 'react'), 'The probe invented an absent dependency') + assert.deepEqual(measured.files.find(file => file.file === 'known.txt'), { file: 'known.txt', bytes: 5, gzip: 25 }) + assert.equal(measured.gzipBytes, outputs.filter(output => output.type === 'chunk').reduce((total, output) => total + gzipSync(output.code).length, 25)) +} diff --git a/scripts/check-web-sdk.mjs b/scripts/check-web-sdk.mjs index fba327d..ac43d28 100644 --- a/scripts/check-web-sdk.mjs +++ b/scripts/check-web-sdk.mjs @@ -12,8 +12,9 @@ * A declaration that imports a `.ts` path, or an `exports` map that names a file that is not there, * fails here rather than in someone else's repository. */ -import { spawnSync } from 'node:child_process' -import { readdirSync, statSync, existsSync, mkdirSync, rmSync, symlinkSync, readFileSync } from 'node:fs' +import { spawnSync, execFileSync } from 'node:child_process' +import { readdirSync, statSync, existsSync, mkdirSync, rmSync, symlinkSync, readFileSync, mkdtempSync, copyFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -38,6 +39,7 @@ if (!existsSync(dist)) { const files = walk(dist).sort() const allowed = (file) => file === 'paramrig-web.js' || /^[\w.-]+\.js$/.test(file) || /\.d\.ts$/.test(file) for (const file of files.filter((f) => !allowed(f))) problems.push(`Unexpected file in the package: dist/${file}`) + for (const file of files.filter(file => file.endsWith('.d.ts'))) if (/['"](?:@\/|@paramrig\/core)/.test(readFileSync(join(dist, file), 'utf8'))) problems.push(`A private alias or external Core dependency leaked into ${file}`) if (!files.includes('paramrig-web.js')) problems.push('dist/paramrig-web.js is missing.') if (!files.some((f) => f.endsWith('.d.ts'))) problems.push('No declarations were emitted into dist.') @@ -83,4 +85,16 @@ if (problems.length) { for (const problem of problems) console.error(` ${problem}`) process.exit(1) } +const isolated = mkdtempSync(join(tmpdir(), 'paramrig-web-consumer-')) +const [artifact] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', isolated], { cwd: pkgDir, encoding: 'utf8' })) +writeFileSync(join(isolated, 'package.json'), JSON.stringify({ name: 'paramrig-web-isolated-consumer', private: true, type: 'module' })) +execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(isolated, artifact.filename)], { cwd: isolated, stdio: 'pipe' }) +for (const file of ['index.ts', 'tsconfig.json', 'manifest.json']) copyFileSync(join(consumer, file), join(isolated, file)) +execFileSync(process.execPath, [tsc, '-p', isolated], { stdio: 'pipe', encoding: 'utf8' }) +execFileSync(process.execPath, ['--input-type=module', '-e', "import { parseManifest, connectWeb } from '@paramrig/web'; if(typeof parseManifest !== 'function' || typeof connectWeb !== 'function') throw Error('Public exports unavailable')"], { cwd: isolated, stdio: 'pipe' }) +const installed = Object.keys(JSON.parse(readFileSync(join(isolated, 'package-lock.json'), 'utf8')).packages).filter(Boolean) +if (JSON.stringify(installed) !== JSON.stringify(['node_modules/@paramrig/web'])) throw Error('Web installed an unrelated dependency') +const artifacts = join(root, '.local/modularity/tarballs') +mkdirSync(artifacts, { recursive: true }); copyFileSync(join(isolated, artifact.filename), join(artifacts, artifact.filename)) +console.log('A clean Web tarball consumer passes strict NodeNext TypeScript and Node imports without Core or repository dependencies.') console.log('The SDK package holds exactly what it should.') diff --git a/scripts/consumer-fixtures.mjs b/scripts/consumer-fixtures.mjs new file mode 100644 index 0000000..36c1047 --- /dev/null +++ b/scripts/consumer-fixtures.mjs @@ -0,0 +1,35 @@ +const audioSource = `import { defaultPatch, renderPatch } from '@paramrig/audio'; import { encodeWav } from '@paramrig/audio/wav'; +const patch = defaultPatch(); patch.duration = 0.1; +const a = renderPatch(patch, 16000, 64), b = renderPatch(patch, 16000, 257); +if (a.left.some((value, i) => !Number.isFinite(value) || value !== b.left[i])) throw Error('Block rendering changed'); +if (!a.left.some(value => value !== 0)) throw Error('Silent default patch'); +if (encodeWav(a, 16000).length !== 44 + 4 * a.left.length) throw Error('Invalid WAV length'); +export const result = a;` +export const fixtures = { + core: { packages: ['core'], node: `import { evaluateExpression, normalizeValue } from '@paramrig/core'; if (evaluateExpression('2 + 3', () => 0) !== 5) throw Error('Expression failed'); export const result = normalizeValue;` }, + audio: { packages: ['core', 'audio'], node: audioSource, budget: 30000 }, + 'audio-labs': { packages: ['core', 'audio', 'audio-labs'], node: `import * as labs from '@paramrig/audio-labs'; import { renderPatch } from '@paramrig/audio'; +const sound = labs.generateSound({ ...labs.DEFAULT_CRITERIA, type: 'notification', minMs: 100, maxMs: 300 }, 171); +const saved = JSON.parse(JSON.stringify(sound)); +const a = renderPatch(sound.patch, 16000, 64), b = renderPatch(saved.patch, 16000, 257); +if (a.left.some((value, i) => value !== b.left[i] || !Number.isFinite(value))) throw Error('Saved patch replay changed'); +export const result = labs;`, budget: 90000 }, + 'audio-browser': { packages: ['core', 'audio', 'audio-browser'], browser: `export { createAudioPlayer, audioWorkletUrl } from '@paramrig/audio-browser'; export { defaultPatch, renderPatch } from '@paramrig/audio'; export { encodeWav } from '@paramrig/audio/wav';` }, + vector: { packages: ['core', 'vector'], node: `import { createVectorDocument, createVectorElement, serializeProject, importProject, resolveRigValues } from '@paramrig/vector'; +const doc = createVectorDocument(); doc.elements.push(createVectorElement('rectangle', { x: 10, y: 20, width: 40, height: 60 })); +const saved = importProject(serializeProject(doc)); if (!saved.ok || saved.project.document.id !== doc.id) throw Error('Vector serialization failed'); + export const result = resolveRigValues(saved.project.document, {});`, browser: `export { createVectorRenderer } from '@paramrig/vector/browser'; export { createVectorDocument, createVectorElement, resolveRigValues } from '@paramrig/vector'; export { exportPdf, pdfPages } from '@paramrig/vector/pdf';` }, + scene: { packages: ['core', 'scene'], node: `import { createSceneDocument, serializeProject, importProject, resolveSceneValues } from '@paramrig/scene'; +const doc = createSceneDocument(); const saved = importProject(serializeProject(doc)); +if (!saved.ok || saved.project.document.id !== doc.id) throw Error('Scene serialization failed'); export const result = resolveSceneValues(doc, {});`, + browser: `export { createSceneDocument, importProject } from '@paramrig/scene'; export { createSceneInstance } from '@paramrig/scene/engine'; export { createSceneViewer } from '@paramrig/scene/browser'; import { Scene, WebGLRenderer } from 'three'; export const THREE = { Scene, WebGLRenderer };` }, + + controls: { packages: ['core', 'controls'], extra: ['react@19.2.0', 'react-dom@19.2.0', '@types/react-dom@19.2.7'], + node: `import { createElement } from 'react'; import { renderToString } from 'react-dom/server'; import { ParameterControl } from '@paramrig/controls'; +const html = renderToString(createElement(ParameterControl, {param:{kind:'number',id:'gain',group:'main',label:'Gain',min:0,max:1,step:0.01,defaultValue:0.5},value:0.5,onChange:()=>{}})); if(!html.includes('Gain')) throw Error('SSR control failed'); export const result = html;`, + browser: `export { RigControls, ParameterControl, createControlRegistry } from '@paramrig/controls'; export { createElement, useState } from 'react'; export { createRoot } from 'react-dom/client'; import '@paramrig/controls/styles.css';` }, + + 'vector-controls': { packages: ['core', 'vector', 'controls'], extra: ['react@19.2.0', 'react-dom@19.2.0', '@types/react-dom@19.2.7'], + browser: `export { createVectorDocument, createVectorElement } from '@paramrig/vector'; export { createVectorRenderer } from '@paramrig/vector/browser'; export { RigControls } from '@paramrig/controls'; export { createElement, useState, useEffect, useRef } from 'react'; export { createRoot } from 'react-dom/client'; import '@paramrig/controls/styles.css';` }, + +} diff --git a/scripts/generate-module-catalog.mjs b/scripts/generate-module-catalog.mjs new file mode 100644 index 0000000..cf2296b --- /dev/null +++ b/scripts/generate-module-catalog.mjs @@ -0,0 +1,23 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'vite' +import { root, sourceAliases } from './sdk-entries.mjs' + +// This entry runs during development/build tooling only. Browsers read its static metadata. +const directory = resolve(root, '.local/modularity/catalog') +await mkdir(directory, { recursive: true }) +const entry = resolve(directory, 'source.ts') +await writeFile(entry, `import { listRigs } from '@/modules/catalog-source'; export const catalog = listRigs();`) +await build({ configFile: false, publicDir: false, logLevel: 'warn', + resolve: { alias: [...sourceAliases, { find: '@', replacement: resolve(root, 'src') }], dedupe: ['three'] }, + build: { rollupOptions: { external: id => ['paper', 'opentype.js', 'fontkit', 'three', 'three-mesh-bvh', 'three-bvh-csg'].some(name => id === name || id.startsWith(`${name}/`)) }, target: 'es2022', outDir: resolve(directory, 'dist'), minify: false, lib: { entry, formats: ['es'], fileName: 'catalog' } }, +}) +const { catalog } = await import(pathToFileURL(resolve(directory, 'dist/catalog.js')).href) +const ids = new Set() +for (const item of catalog) { + if (ids.has(item.id)) throw new Error(`Duplicate catalogue id ${item.id}`) + ids.add(item.id) +} +await writeFile(resolve(root, 'src/modules/catalog.generated.json'), JSON.stringify(catalog, null, 2) + '\n') +console.log(`Recorded ${catalog.length} bundled metadata entries without parameters or document data`) diff --git a/scripts/generate-module-thumbnails.mjs b/scripts/generate-module-thumbnails.mjs new file mode 100644 index 0000000..6f8bb47 --- /dev/null +++ b/scripts/generate-module-thumbnails.mjs @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'vite' +import { root, sourceAliases } from './sdk-entries.mjs' +// Paper's optional Node integration must not pick up the repository's jsdom test environment. +const directory = await mkdtemp(resolve(tmpdir(), 'paramrig-thumbnails-')) +await writeFile(resolve(directory, 'package.json'), JSON.stringify({ private: true, type: 'module' })) +execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', 'paper@0.12.18', 'react@19.2.0', 'react-dom@19.2.0', 'opentype.js@2.0.0', 'fontkit@2.0.4', 'three@0.185.1', 'three-mesh-bvh@0.9.14', 'three-bvh-csg@0.0.18'], { cwd: directory, stdio: 'pipe' }) +await build({ configFile: false, publicDir: false, logLevel: 'warn', + resolve: { alias: [...sourceAliases, { find: '@', replacement: resolve(root, 'src') }] }, + build: { outDir: resolve(directory, 'dist'), minify: false, target: 'es2022', + lib: { entry: resolve(root, 'src/modules/thumbnail-source.tsx'), formats: ['es'], fileName: 'thumbnails' }, + rollupOptions: { external: id => ['react', 'react-dom', 'paper', 'opentype.js', 'fontkit', 'three', 'three-mesh-bvh', 'three-bvh-csg'].some(name => id === name || id.startsWith(`${name}/`)) }, + }, +}) +const { thumbnails } = await import(pathToFileURL(resolve(directory, 'dist/thumbnails.js')).href) +const images = thumbnails() +const path = resolve(root, 'src/modules/catalog.generated.json') +const catalog = JSON.parse(await readFile(path, 'utf8')) +for (const item of catalog) { + if (!images[item.id]?.startsWith(' [`@paramrig/${name}`, '0.1.0'])) + if (name === 'scene') dependencies.three = '0.185.1' + if (fixture.packages.includes('controls')) Object.assign(dependencies, { react: '19.2.0', 'react-dom': '19.2.0' }) + // Only the public package requested by the host needs to be installed explicitly. + if (name !== 'core') delete dependencies['@paramrig/core'] + const browser = Boolean(fixture.browser) + const manifest = { name: `paramrig-example-${name}`, private: true, type: 'module', + scripts: browser ? { dev: 'vite --host 0.0.0.0', build: 'vite build' } : { start: 'node index.mjs' }, + dependencies, ...(browser ? { devDependencies: { vite: '8.2.2' } } : {}) } + await writeFile(resolve(directory, 'package.json'), JSON.stringify(manifest, null, 2) + '\n') + if (browser) { + await writeFile(resolve(directory, 'sdk.js'), fixture.browser + '\n') + const template = await readFile(resolve(root, 'tests/consumers', `${name}.html`), 'utf8') + await writeFile(resolve(directory, 'index.html'), template.replaceAll('__SDK_ENTRY__', './sdk.js').replace(/]+__SDK_STYLES__[^>]*>/g, '')) + } else { + await writeFile(resolve(directory, 'index.mjs'), fixture.node + `\nconsole.log('${name}: public API completed successfully')\n`) + } + if (name === 'vector') { + await mkdir(resolve(directory, 'public/fonts'), { recursive: true }) + for (const file of ['SourceSerif4.ttf', 'SourceSerif4.woff2', 'SourceSerif4-OFL.txt']) await cp(resolve(root, 'public/fonts', file), resolve(directory, 'public/fonts', file)) + } + await writeFile(resolve(directory, 'README.md'), `# ${name} example\n\nA standalone consumer of the public npm packages. Copy this directory outside the ParamRig repository.\n\n\`\`\`sh\nnpm install\nnpm ${browser ? 'run dev' : 'start'}\n\`\`\`\n\n${browser ? 'Vite prints the local URL. The example uses no repository aliases or application sources. Run `npm run build` to produce a standalone browser distribution.' : 'Requires Node.js 22 or newer. The checks fail if rendering, serialization or the public contract changes.'}\n\n${name === 'vector' ? 'Fonts are supplied by this host from `public/fonts`, with the included SIL Open Font License. Replace the resolver to use your own resources. PDF outlines use the supplied TTF; browser text uses WOFF2. Server rendering is not claimed to match browser typography.' : name === 'scene' ? 'Three.js is an explicit host dependency. Both integrations share that single copy. Destroying the optional viewer leaves the host renderer running.' : name === 'audio-browser' ? 'Playback starts from a user gesture. Both players share the host context; stopping or destroying one never closes it. This example owns and closes the context on page exit.' : name === 'controls' ? 'React and React DOM belong to the host. The explicit stylesheet import is in `sdk.js`. Values, gesture history and resources belong to this application.' : 'No browser storage or editor is required.'}\n`) +} diff --git a/scripts/publish-sdks.mjs b/scripts/publish-sdks.mjs new file mode 100644 index 0000000..202c0d6 --- /dev/null +++ b/scripts/publish-sdks.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { root } from './sdk-entries.mjs' + +const order = ['core', 'audio', 'audio-labs', 'audio-browser', 'vector', 'scene', 'controls'] +const temporary = await mkdtemp(resolve(tmpdir(), 'paramrig-release-')) +const planned = [] +for (const name of order) { + const directory = resolve(root, 'packages', name) + const manifest = JSON.parse(await readFile(resolve(directory, 'package.json'), 'utf8')) + assert.equal(process.env.GITHUB_REF_NAME, `sdk-v${manifest.version}`, `Release tag does not match ${manifest.name}`) + const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], { cwd: directory, encoding: 'utf8' })) + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(manifest.name)}/${manifest.version}`) + if (!response.ok && response.status !== 404) throw Error(`Registry check failed for ${manifest.name}: HTTP ${response.status}`) + const published = response.ok ? await response.json() : null + if (published) assert.equal(published.dist.integrity, packed.integrity, `${manifest.name}@${manifest.version} already exists with different bytes. Release a new version.`) + planned.push({ manifest, packed, exists: Boolean(published) }) +} +// Check every package first; a later conflict must not cause an avoidable partial release. +for (const { manifest, packed, exists } of planned) { + if (exists) { console.log(`${manifest.name}@${manifest.version}: identical artifact already published`); continue } + execFileSync('npm', ['publish', resolve(temporary, packed.filename), '--access', 'public', '--provenance'], { cwd: root, stdio: 'inherit' }) +} diff --git a/scripts/sdk-entries.d.mts b/scripts/sdk-entries.d.mts new file mode 100644 index 0000000..64bbc56 --- /dev/null +++ b/scripts/sdk-entries.d.mts @@ -0,0 +1,4 @@ +export const root: string +export const entries: Record> +export const specifier: (name: string, entry: string) => string +export const sourceAliases: { find: string; replacement: string }[] diff --git a/scripts/sdk-entries.mjs b/scripts/sdk-entries.mjs new file mode 100644 index 0000000..dddf9d8 --- /dev/null +++ b/scripts/sdk-entries.mjs @@ -0,0 +1,11 @@ +import packageEntries from '../packages/entries.json' with { type: 'json' } +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const root = fileURLToPath(new URL('../', import.meta.url)) +export const entries = packageEntries +export const specifier = (name, entry) => `@paramrig/${name}${entry === '.' ? '' : entry.slice(1)}` +export const sourceAliases = Object.entries(entries).flatMap(([name, points]) => + Object.entries(points).map(([entry, source]) => ({ + find: specifier(name, entry), replacement: resolve(root, source), + }))).sort((a, b) => b.find.length - a.find.length) diff --git a/scripts/test-sdk-consumers.mjs b/scripts/test-sdk-consumers.mjs new file mode 100644 index 0000000..087c288 --- /dev/null +++ b/scripts/test-sdk-consumers.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { cp, mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { calibrateBundleMetrics, measureBundle } from './bundle-metrics.mjs' +import { build } from 'vite' +import { entries, root } from './sdk-entries.mjs' +import { fixtures } from './consumer-fixtures.mjs' + +await calibrateBundleMetrics() +const temp = await mkdtemp(resolve(tmpdir(), 'paramrig-consumers-')) +const run = (command, args, cwd = temp) => execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +const tarballs = new Map() +const artifactDirectory = resolve(root, '.local/modularity/tarballs') +await mkdir(artifactDirectory, { recursive: true }) +const registry = process.argv.includes('--registry') +const selected = process.argv.slice(2).filter(arg => arg !== '--registry') +for (const name of Object.keys(entries)) { + if (selected.length && !selected.includes(name)) continue + const [packed] = JSON.parse(run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temp], resolve(root, 'packages', name))) + assert(packed.files.every(file => /^(dist\/|README\.md$|LICENSE$|package\.json$)/.test(file.path)), `Unexpected published file in ${name}`) + assert(!packed.files.some(file => /\.(tsx?|map)$/.test(file.path) && !file.path.endsWith('.d.ts')), `Sources leaked into ${name}`) + for (const file of packed.files.filter(file => file.path.endsWith('.d.ts'))) { + const text = await readFile(resolve(root, 'packages', name, file.path), 'utf8') + assert(!/["']@\//.test(text), `Repository alias leaked into ${name}/${file.path}`) + } + if (registry) { + const integrity = JSON.parse(run('npm', ['view', `${packed.name}@${packed.version}`, 'dist.integrity', '--json'])) + assert.equal(integrity, packed.integrity, `${name}: npm does not contain the validated artifact`) + } + tarballs.set(name, registry ? `${packed.name}@${packed.version}` : resolve(temp, packed.filename)) + await cp(resolve(temp, packed.filename), resolve(artifactDirectory, packed.filename)) +} + +const report = [] +for (const [name, fixture] of Object.entries(fixtures)) { + if (!fixture.packages.every(name => tarballs.has(name))) continue + const cwd = resolve(temp, name) + await mkdir(cwd) + await writeFile(resolve(cwd, 'package.json'), JSON.stringify({ name: `consumer-${name}`, private: true, type: 'module' })) + run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...fixture.packages.map(name => tarballs.get(name)), ...(fixture.extra ?? [])], cwd) + const lock = JSON.parse(await readFile(resolve(cwd, 'package-lock.json'), 'utf8')) + const installed = Object.keys(lock.packages).filter(Boolean) + assert.deepEqual(installed.filter(id => id.startsWith('node_modules/@paramrig/')).sort(), fixture.packages.map(name => `node_modules/@paramrig/${name}`).sort(), `${name} installed unrelated ParamRig engines`) + const hasControls = fixture.packages.includes('controls') + const hasVector = fixture.packages.includes('vector') + const hasScene = fixture.packages.includes('scene') + if (!hasVector && !hasScene && !hasControls) assert.deepEqual(installed.sort(), fixture.packages.map(name => `node_modules/@paramrig/${name}`).sort(), `${name} installed unrelated dependencies`) + const forbidden = [!hasControls && 'react', !hasVector && 'paper', !hasScene && 'three'].filter(Boolean) + const foreign = new RegExp(`node_modules/(${forbidden.join('|')})(/|$)`) + assert(!installed.some(id => foreign.test(id)), `${name} installed a forbidden engine or React`) + if (name === 'scene') assert.equal(installed.filter(id => /(?:^|\/)node_modules\/three$/.test(id)).length, 1, 'More than one Three.js copy installed') + if (fixture.node) { + await writeFile(resolve(cwd, 'node.mjs'), fixture.node) + run(process.execPath, ['node.mjs'], cwd) + } + // The consumer has no repository aliases, sources or devDependencies. The compiler is a tool. + await writeFile(resolve(cwd, 'consumer.ts'), fixture.browser ?? fixture.node) + await writeFile(resolve(cwd, 'tsconfig.json'), JSON.stringify({ compilerOptions: { noEmit: true, strict: true, target: 'ES2023', lib: ['ES2023', 'DOM'], module: 'NodeNext', moduleResolution: 'NodeNext', types: [], skipLibCheck: false }, include: ['consumer.ts'] })) + run(process.execPath, [resolve(root, 'node_modules/typescript/bin/tsc'), '-p', cwd], cwd) + const modules = new Set() + const built = await build({ root: cwd, configFile: false, publicDir: false, logLevel: 'warn', define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + plugins: [{ name: 'consumer-graph', generateBundle(_opts, bundle) { for (const out of Object.values(bundle)) if (out.type === 'chunk') Object.keys(out.modules).forEach(id => modules.add(id)) } }], + build: { minify: true, lib: { entry: resolve(cwd, 'consumer.ts'), formats: ['es'], fileName: 'consumer' } }, + }) + const outputs = (Array.isArray(built) ? built : [built]).flatMap(result => result.output) + if (name === 'audio-browser') { + const code = outputs.filter(out => out.type === 'chunk').map(out => out.code).join('\n') + assert(!code.includes('"/assets/worklet-'), 'The player contains an application-relative processor URL') + assert(outputs.some(out => /worklet.*\.js$/.test(out.fileName)) || code.includes('data:'), 'The consuming bundle did not carry its processor') + } + const measurement = measureBundle(outputs) + const bytes = measurement.gzipBytes + assert.deepEqual([...modules].sort(), [...measurement.modules].sort(), 'Module collector disagrees with calibrated measurement') + assert([...modules].every(id => !foreign.test(id)), `${name} bundled an unrelated engine`) + if (fixture.budget) assert(bytes <= fixture.budget, `${name}: ${bytes} gzip bytes exceeds ${fixture.budget}`) + const baselines = JSON.parse(await readFile(resolve(root, 'tests/consumers/budgets.json'), 'utf8')) + if (baselines[name]) assert(bytes <= Math.ceil(baselines[name].gzipBytes * 1.05), `${name}: ${bytes} gzip bytes exceeds its validated baseline by more than 5%; investigate and document any deliberate baseline change`) + report.push({ name, gzipBytes: bytes, files: measurement.files, installed, modules: [...modules].map(id => id.replace(temp, '')) }) + if (fixture.browser) { + const demo = resolve(root, '.local/modularity/consumers', name) + await cp(resolve(cwd, 'dist'), demo, { recursive: true }) + const entry = outputs.find(out => out.type === 'chunk' && out.isEntry).fileName + const template = await readFile(resolve(root, 'tests/consumers', `${name}.html`), 'utf8') + await writeFile(resolve(demo, 'index.html'), template.replaceAll('__SDK_ENTRY__', `./${entry}`).replaceAll('__SDK_STYLES__', `./${outputs.find(out => out.type === 'asset' && out.fileName.endsWith('.css'))?.fileName ?? ''}`)) + } + if (name === 'vector') { + // A host's test environment must not make a data-only import initialise Paper's canvas. + run('npm', ['install', '--no-save', '--ignore-scripts', '--no-audit', '--no-fund', 'jsdom@30.0.1'], cwd) + run(process.execPath, ['node.mjs'], cwd) + } + // Build the published example as an application, not only as an SDK library bundle. + const example = resolve(root, 'examples/sdk', name) + if (fixture.browser) { + await cp(resolve(example, 'index.html'), resolve(cwd, 'index.html')) + await cp(resolve(example, 'sdk.js'), resolve(cwd, 'sdk.js')) + if (name === 'vector') await cp(resolve(example, 'public'), resolve(cwd, 'public'), { recursive: true }) + await build({ root: cwd, configFile: false, base: './', logLevel: 'warn', build: { outDir: 'example-dist' } }) + await cp(resolve(cwd, 'example-dist'), resolve(root, '.local/modularity/examples', name), { recursive: true }) + } else { + await cp(resolve(example, 'index.mjs'), resolve(cwd, 'example.mjs')) + run(process.execPath, ['example.mjs'], cwd) + } + console.log(`${name}: clean tarball install, ${fixture.node ? 'Node, ' : ''}TypeScript and browser bundle passed (${bytes} gzip bytes)`) +} +await mkdir(resolve(root, '.local/modularity'), { recursive: true }) +await writeFile(resolve(root, `.local/modularity/consumers${registry ? '-registry' : ''}.json`), JSON.stringify({ temp, source: registry ? 'npm' : 'tarballs', report }, null, 2)) diff --git a/scripts/web-sdk-consumer/tsconfig.json b/scripts/web-sdk-consumer/tsconfig.json index 0a71681..913116d 100644 --- a/scripts/web-sdk-consumer/tsconfig.json +++ b/scripts/web-sdk-consumer/tsconfig.json @@ -1,14 +1,20 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", "types": [], "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": false, "noEmit": true }, - "include": ["index.ts"] + "include": [ + "index.ts" + ] } diff --git a/src/App.tsx b/src/App.tsx index 7e859e2..839b611 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,50 +1,27 @@ import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { LibraryPage } from '@/library/LibraryPage' -import { WorkspacePage } from '@/workspace/WorkspacePage' - -/* - * The documentation reads a table of strings, and it was costing a browser the whole editor to get - * there: every route reaches the rig registry, which reads both document stores at module scope, so - * a hard load of `/docs` parsed three.js and paper.js before it could print a heading. Deferring - * these four takes that load from 260 modules to 93. - * - * The library and the workspace stay eager on purpose. They are the two ends of the one journey the - * QA campaign measures — open the library, click New scene — so deferring them would not remove a - * module from that path, only split it into another round of requests: Vite serves each module of a - * development build as its own request, and a chunk cannot begin until the chunk that imports it has - * arrived. Measured, that turns eighteen serial rounds into nineteen. - */ +import { ModuleRoute } from '@/modules/ModuleRoute' const DocsPage = lazy(async () => ({ default: (await import('@/docs/DocsPage')).DocsPage })) const ControlsPage = lazy(async () => ({ default: (await import('@/docs/ControlsPage')).ControlsPage })) -const SceneRigsPage = lazy(async () => ({ default: (await import('@/docs/SceneRigsPage')).SceneRigsPage })) -const VectorRigsPage = lazy(async () => ({ default: (await import('@/docs/VectorRigsPage')).VectorRigsPage })) -const WebConnectPage = lazy(async () => ({ default: (await import('@/web/WebConnectPage')).WebConnectPage })) - export function App() { - return ( - - - Skip to preview - - {/* - * One boundary, outside `Routes` and always mounted. A boundary mounted by the route change - * itself would show its fallback on every navigation; this one lets React hold the page that - * is on screen until the next one has arrived, so the message below is only ever seen on a - * hard load of a lazy route. - */} - Opening ParamRig

}> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
- ) + return + Skip to preview + Opening ParamRig

}> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + +
+
} diff --git a/src/audio/AudioEditorPage.test.tsx b/src/audio/AudioEditorPage.test.tsx new file mode 100644 index 0000000..c48bb92 --- /dev/null +++ b/src/audio/AudioEditorPage.test.tsx @@ -0,0 +1,870 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it } from 'vitest' +import { AudioEditorPage } from '@/audio/AudioEditorPage' +import { createAudioDocument, getAudioDocument, isBundledAudioDocument, saveAudioDocument } from '@/audio/document' +import { AudioRigPreview } from '@/renderers/audio/AudioRigPreview' +import { arcadeCoin } from '@/rigs/examples/arcade-coin' +import { PRESET_ORDER } from '@/audio/presets' + +/** + * The page in a browser with no audio device — which jsdom is, and which some real browsers are. + * Everything except the sound itself has to keep working: the board, the transport, the rail, the + * history, the writing back to storage. + */ + +beforeEach(() => { + localStorage.clear() +}) + +/** A source's cell pressed and released where it was: the gesture that shows its panel below. */ +const showPanel = (id: string) => { + const cell = screen.getByRole('button', { name: new RegExp(`^${id},`) }) + fireEvent.pointerDown(cell, { clientX: 10, clientY: 10 }) + fireEvent.pointerUp(cell, { clientX: 10, clientY: 10 }) +} + +/** + * A source dragged from the routing bar onto a control. + * + * Down, moved, up — the move matters. The whole cell is one target now and a press that does not + * travel is a click that shows the modulator's panel, so a test that only pressed and released + * would be testing the other gesture. + */ +const dropOn = (handle: HTMLElement, onto: HTMLElement) => { + const under = document.elementFromPoint + document.elementFromPoint = () => onto + try { + fireEvent.pointerDown(handle, { clientX: 10, clientY: 10 }) + fireEvent.pointerMove(handle, { clientX: 40, clientY: 40 }) + fireEvent.pointerUp(handle, { clientX: 40, clientY: 40 }) + } finally { + document.elementFromPoint = under + } +} + +const open = (id: string) => render( + + undefined} /> + , +) + +describe('AudioEditorPage', () => { + /** The whole argument of the layout: nothing is behind a tab, so nothing has to be found. */ + it('lays the panels across the window in the reference order', () => { + open(arcadeCoin().id) + const plate = screen.getByRole('group', { name: 'Face-plate' }) + for (const panel of ['Pitch', 'Oscillators', 'Noise', 'Insert', 'Filter', 'Amp', 'FX']) { + expect(within(plate).getByRole('region', { name: panel })).toBeInTheDocument() + } + expect(within(plate).getByRole('group', { name: 'Macros' })).toBeInTheDocument() + expect(within(plate).getByRole('list', { name: 'Routing' })).toBeInTheDocument() + }) + + it('filters the layer chosen at the panel head, since this engine filters per layer', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + // The layer badges in the oscillator and noise heads choose which layer Comb, Filter and the + // amp envelope show; the reference's own badges, given a job. + const oscillators = screen.getByRole('tablist', { name: 'Oscillator layer' }) + expect(within(oscillators).getByRole('tab', { name: 'Oscillator 1', selected: true })).toBeInTheDocument() + const filter = screen.getByRole('region', { name: 'Filter' }) + const cutoff = () => within(filter).getByRole('slider', { name: 'Cutoff' }) + const before = cutoff().getAttribute('aria-valuenow') + await user.click(within(oscillators).getByRole('tab', { name: 'Oscillator 2' })) + expect(within(oscillators).getByRole('tab', { name: 'Oscillator 2', selected: true })).toBeInTheDocument() + // An edit made now lands on the second layer, and the first still reads as it did. + cutoff().focus() + await user.keyboard('{ArrowDown}{ArrowDown}') + expect(cutoff().getAttribute('aria-valuenow')).not.toBe(before) + await user.click(within(oscillators).getByRole('tab', { name: 'Oscillator 1' })) + expect(cutoff().getAttribute('aria-valuenow')).toBe(before) + // One selector moves every per-layer panel together, so the noise badges take over the same way. + const noise = screen.getByRole('tablist', { name: 'Noise layer' }) + await user.click(within(noise).getByRole('tab', { name: 'Noise 1' })) + expect(within(oscillators).queryByRole('tab', { selected: true })).toBeNull() + expect(within(noise).getByRole('tab', { name: 'Noise 1', selected: true })).toBeInTheDocument() + }) + + /** + * The tabs separate what you are doing, not what you can see: nothing is hidden inside a view. + * The layers show every field they have at once, and so do the modulators. + */ + it('keeps the routing beside the instrument even though the modulators moved out', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const tabs = screen.getByRole('tablist', { name: 'Views' }) + expect(within(tabs).getByRole('tab', { name: 'Instrument', selected: true })).toBeInTheDocument() + + // The panels have their own view; the one line saying what is moving does not go with them. + expect(screen.getByRole('region', { name: 'Filter' })).toBeInTheDocument() + expect(screen.getByRole('list', { name: 'Routing' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Modulation' })).toBeNull() + + await user.click(within(tabs).getByRole('tab', { name: 'Sounds' })) + const browser = screen.getByRole('tabpanel') + expect(within(browser).getByRole('region', { name: 'Impact' })).toBeInTheDocument() + expect(within(browser).getByRole('button', { name: /Sub drop/ })).toBeInTheDocument() + + await user.click(within(tabs).getByRole('tab', { name: 'Instrument' })) + expect(screen.getByRole('region', { name: 'Filter' })).toBeInTheDocument() + }, 30_000) + + /** jsdom measures nothing; a stand-in observer reports a room of the given size the moment it is asked to watch. */ + const roomOf = (width: number, height: number) => class { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback([{ target, contentRect: { width, height } } as ResizeObserverEntry], this as unknown as ResizeObserver) + } + unobserve() {} + disconnect() {} + } + + it('folds the plate to its medium face in a laptop room that would shrink it past reading', () => { + const Real = globalThis.ResizeObserver + globalThis.ResizeObserver = roomOf(1046, 835) as unknown as typeof ResizeObserver + try { + open(arcadeCoin().id) + expect(window.document.querySelector('.fp-stage')).toHaveAttribute('data-layout', 'medium') + // Every panel is still there, and the amp envelope is the one modulator on show. + for (const name of ['Pitch', 'Oscillators', 'Noise', 'Insert', 'Filter', 'Amp', 'FX', 'Amp envelope']) { + expect(screen.getByRole('region', { name })).toBeInTheDocument() + } + expect(screen.queryByRole('region', { name: 'Modulator 2' })).toBeNull() + // A source's name brings its modulator alone; the trio is the wide face's. + showPanel('L5') + expect(screen.getByRole('region', { name: 'Modulator 5' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Modulator 4' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Amp envelope' })).toBeNull() + } finally { + globalThis.ResizeObserver = Real + } + }) + + it('folds the plate to its narrow face in a room taller than it is wide, at the reference\'s own size', () => { + const Real = globalThis.ResizeObserver + globalThis.ResizeObserver = roomOf(695, 1100) as unknown as typeof ResizeObserver + try { + open(arcadeCoin().id) + const stage = window.document.querySelector('.fp-stage') + expect(stage).toHaveAttribute('data-layout', 'narrow') + expect(stage).toHaveStyle({ '--fp-scale': '1' }) + expect(screen.getByRole('region', { name: 'Amp envelope' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Modulator 3' })).toBeNull() + expect(screen.getByRole('list', { name: 'Routing' })).toBeInTheDocument() + } finally { + globalThis.ResizeObserver = Real + } + }) + + it('has the three performers in front of the envelopes, each with a row to draw and a bar to pick the row', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + // The plate opens on the envelopes; the performers are a click away, first in the bar. + expect(screen.queryByRole('region', { name: 'Performer 1' })).toBeNull() + showPanel('P1') + for (const name of ['Performer 1', 'Performer 2', 'Performer 3']) expect(screen.getByRole('region', { name })).toBeInTheDocument() + const panel = screen.getByRole('region', { name: 'Performer 1' }) + expect(within(panel).getByRole('slider', { name: 'Performer 1 level' })).toBeInTheDocument() + // A step drawn by the keyboard lands in the patch's row for the scene that plays. + const steps = within(within(panel).getByRole('group', { name: 'Performer 1 row 1' })).getAllByRole('slider', { name: /^Step \d+$/ }) + expect(steps).toHaveLength(16) + fireEvent.keyDown(steps[2]!, { key: 'PageUp' }) + expect(steps[2]).toHaveAttribute('aria-valuenow', '0.25') + // The bar picks the row: row four, empty, becomes the one the performers play. + expect(screen.getByRole('button', { name: 'Pattern 1' })).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: 'Pattern 4' })) + expect(screen.getByRole('button', { name: 'Pattern 4' })).toHaveAttribute('aria-pressed', 'true') + expect(within(panel).getByRole('group', { name: 'Performer 1 row 4' })).toBeInTheDocument() + // Init starts the row from a shape rather than from nothing, and Flat is the old Init. + fireEvent.keyDown(within(within(panel).getByRole('group', { name: 'Performer 1 row 4' })).getAllByRole('slider', { name: /^Step \d+$/ })[0]!, { key: 'End' }) + await user.click(within(panel).getByRole('button', { name: 'Start performer 1 row 4 from a shape' })) + await user.click(screen.getByRole('menuitem', { name: 'Ramp up' })) + const drawn = () => within(within(screen.getByRole('region', { name: 'Performer 1' })).getByRole('group', { name: 'Performer 1 row 4' })).getAllByRole('slider', { name: /^Step \d+$/ }) + expect(drawn()[0]).toHaveAttribute('aria-valuenow', '0') + expect(drawn()[15]).toHaveAttribute('aria-valuenow', '1') + await user.click(within(screen.getByRole('region', { name: 'Performer 1' })).getByRole('button', { name: 'Start performer 1 row 4 from a shape' })) + await user.click(screen.getByRole('menuitem', { name: 'Flat' })) + expect(drawn()[15]).toHaveAttribute('aria-valuenow', '0') + }) + + it('turns an oscillator into a wavetable, names the table, and gives the big dial to the position', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const sources = screen.getByRole('radiogroup', { name: 'Oscillator 1 source' }) + // Four positions where there were three, and the fourth is the one the engine gained. + expect(within(sources).getAllByRole('radio').map((choice) => choice.textContent)).toEqual(['Tone', 'Table', 'Noise', 'Off']) + // The big dial sets the pitch while the source is a plain shape. + expect(screen.getByRole('slider', { name: 'Pos1' })).toHaveAttribute('aria-valuemax', '12000') + await user.click(within(sources).getByRole('radio', { name: 'Table' })) + // The four wave names give way to the table's, and the dial now walks along it. + expect(screen.getByRole('button', { name: 'Oscillator 1 wavetable' })).toHaveTextContent('Sweep') + expect(screen.queryByRole('radiogroup', { name: 'Oscillator 1 wave' })).toBeNull() + expect(screen.getByRole('slider', { name: 'Pos1' })).toHaveAttribute('aria-valuemax', '1') + }) + + it('lets one oscillator bend another one\'s phase, and drops the ratio that stops meaning anything', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const panel = () => screen.getByRole('region', { name: 'Oscillators' }) + // Its own modulator to begin with, tuned by a ratio beside it. + const word = () => within(panel()).getByRole('button', { name: /^Oscillator 1 phase modulator/ }) + expect(word()).toHaveTextContent('Self') + expect(within(panel()).getByRole('slider', { name: 'Oscillator 1 modulator ratio' })).toBeInTheDocument() + // Stepping past itself: a layer cannot name itself, so Oscillator 1 is not on its own list. + await user.click(word()) + expect(word()).toHaveTextContent('Osc 2') + expect(within(panel()).queryByRole('slider', { name: 'Oscillator 1 modulator ratio' })).toBeNull() + await user.click(word()) + expect(word()).toHaveTextContent('Noise 1') + }) + + it('gives a layer three insert slots, each saying what it is and where it stands', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const panel = () => screen.getByRole('region', { name: 'Insert' }) + // Nothing is in the first slot of this sound, and the panel says so rather than showing a hole. + const slots = within(panel()).getByRole('tablist', { name: 'Insert slot' }) + expect(within(slots).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['A', 'B', 'C']) + await user.click(within(panel()).getByRole('button', { name: 'Insert A kind' })) + expect(screen.getAllByRole('menuitem').map((cell) => cell.getAttribute('aria-label'))).toEqual( + ['Off', 'Drive', 'Crusher', 'Ring', 'Fold', 'Body', 'Comb'], + ) + await user.click(screen.getByRole('menuitem', { name: 'Comb' })) + // The kind brings its own controls, and the side of the amp it stands on is one click away. + expect(within(panel()).getByRole('slider', { name: 'Time' })).toBeInTheDocument() + expect(within(panel()).getByRole('slider', { name: 'Feedback' })).toBeInTheDocument() + const place = within(panel()).getByRole('button', { name: /^Insert A stands/ }) + expect(place).toHaveTextContent('Before the amp') + await user.click(place) + expect(within(panel()).getByRole('button', { name: /^Insert A stands/ })).toHaveTextContent('After the amp') + // The third slot is its own slot, and holds what it is given rather than what A was given. + await user.click(within(slots).getByRole('tab', { name: 'Insert C' })) + expect(within(panel()).getByRole('button', { name: 'Insert C kind' })).toHaveTextContent('Off') + await user.click(within(panel()).getByRole('button', { name: 'Insert C kind' })) + await user.click(screen.getByRole('menuitem', { name: 'Body' })) + expect(within(panel()).getByRole('slider', { name: 'Size' })).toBeInTheDocument() + expect(within(panel()).getByRole('button', { name: 'Material' })).toBeInTheDocument() + expect(within(panel()).queryByRole('slider', { name: 'Time' })).toBeNull() + await user.click(within(slots).getByRole('tab', { name: 'Insert A' })) + expect(within(panel()).getByRole('button', { name: 'Insert A kind' })).toHaveTextContent('Comb') + }) + + it('offers every filter the engine can run, and gives the choice to the one slot of the one layer', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const model = () => within(screen.getByRole('region', { name: 'Filter' })).getByRole('button', { name: 'Filter model' }) + await user.click(model()) + // The engine has answered to nine models for a while; the plate offered three of them. + expect(screen.getAllByRole('menuitem').map((cell) => cell.getAttribute('aria-label'))).toEqual( + ['Off', 'Low', 'High', 'Band', 'Notch', 'Peak', 'Ladder', 'Comb', 'Vowel'], + ) + await user.click(screen.getByRole('menuitem', { name: 'Ladder' })) + expect(model()).toHaveTextContent('Ladder') + // And the choice went to that layer's filter, not to something the whole patch shares. + const layers = screen.getByRole('tablist', { name: 'Oscillator layer' }) + await user.click(within(layers).getByRole('tab', { name: 'Oscillator 2' })) + expect(model()).not.toHaveTextContent('Ladder') + await user.click(within(layers).getByRole('tab', { name: 'Oscillator 1' })) + expect(model()).toHaveTextContent('Ladder') + }) + + it('lets a slot be an envelope or an oscillator, and the bar says which', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + // The bar numbers its slots once and the letter says what each holds, as the reference does. + const routing = screen.getByRole('list', { name: 'Routing' }) + expect(within(routing).getAllByRole('listitem').map((entry) => entry.textContent)).toEqual( + ['P1', 'P2', 'P3', 'E1', 'E2', 'E3', 'L4', 'L5', 'L6', 'L7', 'L8', 'L9'], + ) + showPanel('L4') + const panel = screen.getByRole('region', { name: 'Modulator 4' }) + // An oscillator has a rate; the envelope it can become has stages instead. + expect(within(panel).getByRole('slider', { name: 'Modulator 4 rate' })).toBeInTheDocument() + expect(within(panel).queryByRole('slider', { name: 'Modulator 4 hold' })).toBeNull() + await user.click(within(panel).getByRole('button', { name: 'Modulator 4 kind' })) + await user.click(screen.getByRole('menuitem', { name: 'Envelope' })) + expect(within(screen.getByRole('region', { name: 'Modulator 4' })).getByRole('slider', { name: 'Modulator 4 hold' })).toBeInTheDocument() + expect(within(routing).getAllByRole('listitem')[6]).toHaveTextContent('E4') + }) + + it('says what a modulator is doing, and that it is doing it to nothing yet', () => { + open(arcadeCoin().id) + // The plate opens on the envelopes; the LFOs are a page away, reached by their names. + expect(screen.getByRole('region', { name: 'Modulator 2' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Modulator 4' })).toBeNull() + showPanel('L4') + for (const name of ['Modulator 4', 'Modulator 5', 'Modulator 6']) { + const panel = screen.getByRole('region', { name }) + expect(within(panel).getByRole('slider', { name: `${name} level` })).toBeInTheDocument() + } + // The routing bar lists the reference's nine sources, all of them this engine's: the amp + // envelope, two free envelopes, six LFOs. A name shows its trio of panels. + const routing = screen.getByRole('list', { name: 'Routing' }) + expect(within(routing).getAllByRole('listitem')).toHaveLength(12) + for (const live of ['E1', 'E2', 'E3', 'L4', 'L5', 'L6', 'L7', 'L8', 'L9']) expect(within(routing).getByText(live)).toBeInTheDocument() + }) + + /** + * The reference's way of routing: pick a modulator's handle up in the routing bar and drop it + * on a control. jsdom cannot say what is under a pointer, so the drop is answered for it. + */ + it('assigns a modulator by dropping its handle on a control', () => { + open(arcadeCoin().id) + const filter = screen.getByRole('region', { name: 'Filter' }) + const cutoff = within(filter).getByRole('slider', { name: 'Cutoff' }) + expect(cutoff).toHaveAttribute('data-target', 'layers[0].cutoff') + expect(cutoff.querySelector('.fp-knob__mod')).toBeNull() + dropOn(screen.getByRole('button', { name: /^L4,/ }), cutoff) + expect(cutoff).toHaveAttribute('aria-valuetext', expect.stringContaining('modulated')) + // And its own panel reads back where it went, which is what replaced the Target select. The + // row is written short — four of these stack in a hundred pixels — and carries the full name. + showPanel('L4') + const row = within(screen.getByRole('region', { name: 'Modulator 4' })).getByText('1 cutoff') + expect(row).toHaveAttribute('title', 'Layer 1 cutoff') + }) + + /** Four destinations was most of the reason a dropped modulator seemed to do nothing. */ + it('takes a modulator on the resonance, the pan and the phase depth as well as the four it had', () => { + open(arcadeCoin().id) + const where = (region: string, control: string) => + within(screen.getByRole('region', { name: region })).getByRole('slider', { name: control }) + expect(where('Filter', 'Reso')).toHaveAttribute('data-target', 'layers[0].resonance') + expect(where('Oscillators', 'PM1')).toHaveAttribute('data-target', 'layers[0].pm') + expect(where('Amp envelope', 'Pan')).toHaveAttribute('data-target', 'layers[0].pan') + // And a dial that is not a destination says so by carrying no target at all. + expect(where('Oscillators', 'Fall')).not.toHaveAttribute('data-target') + }) + + /** + * The Target select is gone, and this is what took its place. + * + * A select could name one destination, which stopped being true the moment a slot could hold + * four — and it was a second way of doing what the handle already did, with the two disagreeing + * about which of the four they meant. Now: drag the handle as many times as there are places to + * put it, read them back in the panel, take one off with its cross. + */ + it('puts one modulator on several controls at once, and reads them back', () => { + open(arcadeCoin().id) + const cutoff = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Cutoff' }) + const reso = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Reso' }) + const handle = () => screen.getByRole('button', { name: /^L4,/ }) + expect(handle()).toHaveAccessibleName(/moving 0 of four/) + + dropOn(handle(), cutoff) + dropOn(handle(), reso) + expect(handle()).toHaveAccessibleName(/moving 2 of four/) + expect(cutoff).toHaveAttribute('aria-valuetext', expect.stringContaining('modulated')) + expect(reso).toHaveAttribute('aria-valuetext', expect.stringContaining('modulated')) + expect(screen.getByRole('button', { name: /routed$/ })).toHaveTextContent('2 routed') + + // Its panel lists both, and dropping it where it already is does not make a third. + showPanel('L4') + const panel = screen.getByRole('region', { name: 'Modulator 4' }) + expect(within(panel).getByText('1 cutoff')).toBeInTheDocument() + expect(within(panel).getByText('1 resonance')).toBeInTheDocument() + dropOn(handle(), cutoff) + expect(handle()).toHaveAccessibleName(/moving 2 of four/) + + // And the cross beside one takes that one off, leaving the other where it is. + fireEvent.click(within(panel).getByRole('button', { name: 'Take L4 off Layer 1 cutoff' })) + expect(screen.getByRole('button', { name: /routed$/ })).toHaveTextContent('1 routed') + expect(cutoff).not.toHaveAttribute('aria-valuetext', expect.stringContaining('modulated')) + expect(reso).toHaveAttribute('aria-valuetext', expect.stringContaining('modulated')) + }) + + /** + * The bar's cell is one target for two gestures, and neither may fire the other. It used to be a + * seventeen-pixel cross for the drag with the name below it for the click, and the cross was the + * part nobody could hit. + */ + it('tells a click on a source from a drag of it', () => { + open(arcadeCoin().id) + const cutoff = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Cutoff' }) + // A press that does not travel shows the panel and assigns nothing. + showPanel('L4') + expect(screen.getByRole('region', { name: 'Modulator 4' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /routed$/ })).toHaveTextContent('Nothing routed') + // One that travels assigns and does not change which panel is shown. + dropOn(screen.getByRole('button', { name: /^L4,/ }), cutoff) + expect(screen.getByRole('button', { name: /routed$/ })).toHaveTextContent('1 routed') + }) + + /** One arc said the first source was the only one, which was a picture that lied about the sound. */ + it('wears one ring a source when two are pointed at the same dial, and says so', () => { + open(arcadeCoin().id) + const cutoff = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Cutoff' }) + const drop = (handle: string) => dropOn(screen.getByRole('button', { name: new RegExp(`^${handle},`) }), cutoff) + drop('L4') + expect(cutoff.querySelectorAll('.fp-knob__mod')).toHaveLength(1) + drop('L5') + expect(cutoff.querySelectorAll('.fp-knob__mod')).toHaveLength(2) + expect(cutoff).toHaveAttribute('aria-valuetext', expect.stringContaining('modulated by 2 sources')) + }) + + it('lists every routing in the patch, and what is on one control when asked', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const cutoff = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Cutoff' }) + dropOn(screen.getByRole('button', { name: /^L4,/ }), cutoff) + // The bar keeps the count, and the list names the source, the control and how far it swings. + const overlay = screen.getByRole('button', { name: /routed$/ }) + expect(overlay).toHaveTextContent('1 routed') + await user.click(overlay) + expect(screen.getByRole('menuitem')).toHaveTextContent('Layer 1 cutoff') + await user.keyboard('{Escape}') + // And the control itself answers who is on it, and lets one of them go. + fireEvent.contextMenu(cutoff, { clientX: 40, clientY: 40 }) + expect(await screen.findByRole('menuitem', { name: /Show L4/ })).toBeInTheDocument() + await user.click(screen.getByRole('menuitem', { name: 'Take L4 off it' })) + expect(cutoff.querySelectorAll('.fp-knob__mod')).toHaveLength(0) + expect(screen.getByRole('button', { name: /routed$/ })).toHaveTextContent('Nothing routed') + }) + + /** A macro is a control of the rig: dropped on a dial, it exposes that dial to Tune and the SDK. */ + it('assigns a macro by dropping its number on a control, and that is the document rig', async () => { + // A fresh document has no rig yet, so its macros are the default table. + // Named so as not to shadow the global document, whose elementFromPoint the drop reads. + const doc = createAudioDocument() + open(doc.id) + const filter = screen.getByRole('region', { name: 'Filter' }) + const cutoff = within(filter).getByRole('slider', { name: 'Cutoff' }) + // Cutoff wears macro 5 out of the box; the tenth macro will take it over. + expect(within(cutoff).getByText('5')).toBeInTheDocument() + const handle = screen.getByRole('button', { name: /^Macro 10/ }) + const under = document.elementFromPoint + document.elementFromPoint = () => cutoff + try { + fireEvent.pointerDown(handle, { clientX: 10, clientY: 10 }) + fireEvent.pointerUp(handle, { clientX: 20, clientY: 20 }) + } finally { + document.elementFromPoint = under + } + expect(within(cutoff).getByText('10')).toBeInTheDocument() + expect(within(cutoff).queryByText('5')).toBeNull() + expect(screen.queryByRole('region', { name: /destinations/i })).toBeNull() + const band = screen.getByRole('group', { name: 'Macros' }) + expect(within(band).getByRole('slider', { name: 'Detune' })).toBeInTheDocument() + await waitFor(() => { + const saved = getAudioDocument(doc.id) + expect(saved?.rig?.bindings.some((binding) => binding.parameterId === 'macro-10' && binding.property === 'layers[0].filterA.cutoff')).toBe(true) + expect(saved?.rig?.bindings.some((binding) => binding.property === 'layers[0].filterA.cutoff' && binding.parameterId === 'macro-5')).toBe(false) + }) + }) + + /** + * The same two moves from the keyboard, which is the only route a rig has: a macro is what + * exposes a control, and the Tune button only appears once something is exposed. Until this + * existed, none of it could be reached without a pointer. + */ + it('picks a macro up and puts it down with the keyboard', async () => { + const user = userEvent.setup() + const doc = createAudioDocument() + open(doc.id) + const reso = within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Reso' }) + const handle = screen.getByRole('button', { name: /^Macro 12/ }) + handle.focus() + await user.keyboard('{Enter}') + reso.focus() + await user.keyboard('{Enter}') + expect(within(reso).getByText('12')).toBeInTheDocument() + await waitFor(() => { + expect(getAudioDocument(doc.id)?.rig?.bindings.some( + (binding) => binding.parameterId === 'macro-12' && binding.property === 'layers[0].filterA.resonance', + )).toBe(true) + }) + // And Delete frees it again. + screen.getByRole('button', { name: /^Macro 12/ }).focus() + await user.keyboard('{Delete}') + expect(within(reso).queryByText('12')).toBeNull() + }, 15_000) + + it('keeps a second sound on the other side, and swaps between them', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const ab = screen.getByRole('group', { name: 'A and B' }) + expect(within(ab).getByRole('button', { name: 'A' })).toHaveAttribute('aria-pressed', 'true') + const cutoff = () => within(screen.getByRole('region', { name: 'Filter' })).getByRole('slider', { name: 'Cutoff' }) + const was = cutoff().getAttribute('aria-valuenow') + // B starts as a copy of A, so the first change to it is the thing being compared. + await user.click(within(ab).getByRole('button', { name: 'B' })) + expect(within(ab).getByRole('button', { name: 'B' })).toHaveAttribute('aria-pressed', 'true') + cutoff().focus() + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}') + const other = cutoff().getAttribute('aria-valuenow') + expect(other).not.toBe(was) + // And A is where it was left, not where B went. + await user.click(within(ab).getByRole('button', { name: 'A' })) + expect(cutoff().getAttribute('aria-valuenow')).toBe(was) + await user.click(within(ab).getByRole('button', { name: 'B' })) + expect(cutoff().getAttribute('aria-valuenow')).toBe(other) + }) + + it('finds a sound by name or by family, and says how many are left', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + await user.click(screen.getByRole('tab', { name: 'Sounds' })) + const browser = screen.getByRole('tabpanel') + const all = within(browser).getAllByRole('button').length + await user.type(within(browser).getByRole('searchbox', { name: 'Find a sound' }), 'laser') + expect(within(browser).getAllByRole('button').length).toBeLessThan(all) + expect(within(browser).getByRole('button', { name: /Laser/ })).toBeInTheDocument() + await user.clear(within(browser).getByRole('searchbox', { name: 'Find a sound' })) + await user.type(within(browser).getByRole('searchbox', { name: 'Find a sound' }), 'zzz') + expect(within(browser).getByText(/Nothing here is called that/)).toBeInTheDocument() + }) + + it('picks a sound from the browser', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + await user.click(screen.getByRole('tab', { name: 'Sounds' })) + // The name is in the rail as well now, so the browser has to be named. + const browser = screen.getByRole('tabpanel') + await user.click(within(browser).getByRole('button', { name: /Sub drop/ })) + expect(screen.getByText('1.50 s')).toBeInTheDocument() + }) + + it('switches a source on and off from its own panel head', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + // The source column: a tone, noise, or nothing at all. + const second = screen.getByRole('radiogroup', { name: 'Oscillator 2 source' }) + expect(within(second).getByRole('radio', { name: 'Off' })).toHaveAttribute('aria-checked', 'true') + await user.click(within(second).getByRole('radio', { name: 'Tone' })) + expect(within(second).getByRole('radio', { name: 'Tone' })).toHaveAttribute('aria-checked', 'true') + expect(within(second).getByRole('radio', { name: 'Off' })).toHaveAttribute('aria-checked', 'false') + }) + + it('puts the numbers that describe the sound in the transport', () => { + open(arcadeCoin().id) + expect(screen.getByRole('img', { name: /waveform/i })).toBeInTheDocument() + expect(screen.getByText('Length', { selector: 'dt' })).toBeInTheDocument() + expect(screen.getByText('450 ms')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument() + }) + + it('offers a sound menu with a step either side, and hearing in the library', () => { + open(arcadeCoin().id) + const rail = screen.getByRole('navigation', { name: 'Sounds' }) + for (const label of ['Play', 'Repeat', 'Hold', 'Record gesture', 'Gestures', 'Keep this sound', 'Randomize', 'Mutate', 'Save as WAV']) { + expect(within(rail).getByRole('button', { name: label })).toBeInTheDocument() + } + expect(screen.queryByRole('button', { name: 'Auto' })).toBeNull() + expect(screen.queryByRole('button', { name: 'One-shot' })).toBeNull() + expect(document.querySelector('details.audio-gestures')).toBeNull() + expect(screen.getByRole('button', { name: 'Previous sound' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next sound' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Sound:/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Look:/ })).toBeNull() + }) + + it('walks the library with the arrow keys', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const rail = screen.getByRole('navigation', { name: 'Sounds' }) + const first = within(rail).getByRole('button', { name: PRESET_ORDER[0]!.label }) + await user.click(first) + expect(first).toHaveAttribute('aria-current', 'true') + first.focus() + await user.keyboard('{ArrowDown}') + expect(within(rail).getByRole('button', { name: PRESET_ORDER[1]!.label })).toHaveAttribute('aria-current', 'true') + }, 15_000) + + /** The length as the transport prints it, so a test can name a sound by what it reads. */ + const lengthOf = (at: number) => { + const { duration } = PRESET_ORDER[at]!.build() + return duration < 1 ? `${Math.round(duration * 1000)} ms` : `${duration.toFixed(2)} s` + } + + /** Stepping is how these get used: you rarely know which sound you want, only that not this one. */ + it('steps through the sounds, one undoable step each', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + expect(screen.getByText('450 ms')).toBeInTheDocument() + const next = screen.getByRole('button', { name: 'Next sound' }) + await user.click(next) + expect(screen.getByText(lengthOf(0))).toBeInTheDocument() + await user.click(next) + expect(screen.getByText(lengthOf(1))).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Undo' })) + expect(screen.getByText(lengthOf(0))).toBeInTheDocument() + }, 15_000) + + it('steps backwards too, wrapping round the end of the list', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + await user.click(screen.getByRole('button', { name: 'Previous sound' })) + // Read from the list rather than written down, so adding a preset does not silently make this + // assert the wrong one — and from the order the arrows actually walk, which is the order every + // list on screen shows rather than the order the file was written in. + const last = PRESET_ORDER[PRESET_ORDER.length - 1]!.build() + const shown = last.duration < 1 ? `${Math.round(last.duration * 1000)} ms` : `${last.duration.toFixed(2)} s` + expect(screen.getByText(shown)).toBeInTheDocument() + }) + + /** The whole point of keeping one: getting back to somewhere you had already reached. */ + it('keeps a sound, finds it again, and lets it go', async () => { + const user = userEvent.setup() + const document = createAudioDocument() + open(document.id) + await user.click(screen.getByRole('button', { name: 'Keep this sound' })) + await user.click(screen.getByRole('button', { name: /^Sound:/ })) + const saved = await screen.findByRole('menuitem', { name: /Sound 1/ }) + await user.click(within(saved).getByRole('button', { name: 'Remove Sound 1' })) + // The menu is still open, so this is the row going rather than the menu closing over it. It + // used to be asserted with the menu shut, which passed for a while when the trash icon was + // loading the sound instead of removing it. + expect(screen.getByRole('menu', { name: /^Sound:/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Sound 1/ })).toBeNull() + }) + + /** Without it, every experiment on a saved sound leaves a near-identical copy behind. */ + it('replaces a saved sound instead of leaving a copy beside it', async () => { + const user = userEvent.setup() + const document = createAudioDocument() + open(document.id) + await user.click(screen.getByRole('button', { name: 'Keep this sound' })) + + const replace = screen.getByRole('button', { name: 'Replace the saved sound' }) + // Nothing has moved yet, so there is nothing to replace. + expect(replace).toHaveAttribute('aria-disabled', 'true') + + await user.click(screen.getByRole('button', { name: 'Mutate' })) + await user.click(within(await screen.findByRole('dialog', { name: 'Mutate' })).getByRole('button', { name: 'Mutate' })) + await waitFor(() => expect(screen.getByRole('button', { name: /^Sound: Sound 1 · edited/ })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Replace the saved sound' })).not.toHaveAttribute('aria-disabled') + + await user.click(screen.getByRole('button', { name: 'Replace the saved sound' })) + await new Promise((resolve) => setTimeout(resolve, 550)) + const stored = getAudioDocument(document.id) + expect(stored?.snapshots).toHaveLength(1) + expect(stored?.snapshots?.[0]?.patch).toEqual(stored?.patch) + expect(screen.getByRole('button', { name: /^Sound: Sound 1$/ })).toBeInTheDocument() + }, 15_000) + + it('writes what it kept back to the document, so it survives a reload', async () => { + const user = userEvent.setup() + const document = createAudioDocument() + open(document.id) + await user.click(screen.getByRole('button', { name: 'Keep this sound' })) + await new Promise((resolve) => setTimeout(resolve, 550)) + const stored = getAudioDocument(document.id) + expect(stored?.snapshots).toHaveLength(1) + expect(stored?.snapshots?.[0]?.patch.duration).toBe(document.patch.duration) + }) + + it('draws the envelope rather than listing its five times', () => { + open(arcadeCoin().id) + const layer = screen.getByRole('region', { name: 'Amp envelope' }) + for (const handle of ['Attack, layer 1', 'Hold, layer 1', 'Decay and sustain, layer 1', 'Release, layer 1']) { + expect(within(layer).getByRole('slider', { name: handle })).toBeInTheDocument() + } + // The curve dial is drawn with the reference's word, Shape; what it is called out loud says + // which layer's it is, because three envelopes are on the plate at once. + expect(within(layer).getByText('Shape')).toBeInTheDocument() + expect(within(layer).getByRole('slider', { name: 'Layer 1 envelope shape' })).toBeInTheDocument() + }) + + it('reads the envelope out in numbers beside the shape', () => { + open(arcadeCoin().id) + const layer = screen.getByRole('region', { name: 'Amp envelope' }) + expect(within(layer).getByText('D 260 ms')).toBeInTheDocument() + expect(within(layer).getByText('S 0.00')).toBeInTheDocument() + }) + + /** + * There used to be a permanent band across the foot of the window whose usual content was a + * report that nothing had gone wrong. The live region stays, and takes no room until it does. + */ + it('keeps no permanent band across the foot of the window', () => { + open(arcadeCoin().id) + expect(screen.getByRole('status', { name: 'Editor notice' })).toHaveAttribute('data-empty', 'true') + }) + + // The buttons carry aria-disabled rather than the attribute, so they stay focusable and a + // screen reader can still find them. Asserting the attribute keeps that choice honest. + it('has nothing to undo before anything is touched', () => { + const document = createAudioDocument() + open(document.id) + expect(screen.getByRole('button', { name: 'Undo' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'Redo' })).toHaveAttribute('aria-disabled', 'true') + }) + + it('renames the patch and writes it back to storage', async () => { + const user = userEvent.setup() + const document = createAudioDocument() + open(document.id) + const name = screen.getByLabelText('Patch name') + await user.clear(name) + await user.type(name, 'Door chime') + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(getAudioDocument(document.id)?.name).toBe('Door chime') + }) + + /** + * Found by opening the example in a browser and going back to the library, where it had moved + * from Examples to Projects. The page wrote on mount, which stamped a new updatedAt and defeated + * the guard that keeps a bundled patch bundled. + */ + it('does not turn a bundled example into a project by being opened', async () => { + open(arcadeCoin().id) + await new Promise((resolve) => setTimeout(resolve, 600)) + expect(isBundledAudioDocument(arcadeCoin().id)).toBe(true) + expect(localStorage.getItem('paramrig.audio-documents.v1')).toBeNull() + }) + + it('says so plainly when the patch is not in this browser', () => { + open('audio-nothing-here') + expect(screen.getByText(/not in this browser/i)).toBeInTheDocument() + }) + + it('offers Tune only once a patch has controls to tune', () => { + const plain = createAudioDocument() + const { unmount } = open(plain.id) + expect(screen.queryByRole('button', { name: 'Tune' })).toBeNull() + unmount() + open(arcadeCoin().id) + expect(screen.getByRole('button', { name: 'Tune' })).toBeInTheDocument() + }) +}) + +describe('AudioRigPreview', () => { + it('gives Tune the transport and nothing it does not need', () => { + render() + expect(screen.getByRole('img', { name: 'Arcade coin waveform' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Auto' })).toBeNull() + expect(screen.queryByRole('group', { name: 'Sound board' })).toBeNull() + }) + + it('says so plainly when the patch has gone', () => { + render() + expect(screen.getByText(/not in this browser/i)).toBeInTheDocument() + }) + + it('keeps working when a stored patch has been edited under it', () => { + const document = getAudioDocument(arcadeCoin().id) + if (!document) throw new Error('the example should be there') + saveAudioDocument({ ...document, patch: { ...document.patch, duration: 0.8 } }) + render() + // Under a second, a duration reads in milliseconds: this is the edited 0.8 s, not the 0.45 s + // the example ships with. + expect(screen.getByText('800 ms')).toBeInTheDocument() + }) +}) + +/** + * The history, and the two things that used to fall out of it. + * + * A step was only ever the patch, so an undo across a load left the bar naming a sound the patch + * on screen did not come from — and Replace writes to whatever the bar names. And a kept sound + * removed by a mis-aimed click on the small trash icon inside the row was gone, four hundred + * milliseconds later on disk, with Undo still enabled and undoing something else. + */ +describe('the history', () => { + it('redoes what it undid, and stops when there is nothing left', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const next = screen.getByRole('button', { name: 'Next sound' }) + const { duration: first } = PRESET_ORDER[0]!.build() + const { duration: second } = PRESET_ORDER[1]!.build() + const shown = (seconds: number) => (seconds < 1 ? `${Math.round(seconds * 1000)} ms` : `${seconds.toFixed(2)} s`) + await user.click(next) + await user.click(next) + expect(screen.getByText(shown(second))).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Undo' })) + expect(screen.getByText(shown(first))).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Redo' })) + expect(screen.getByText(shown(second))).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Redo' })).toHaveAttribute('aria-disabled', 'true') + }, 20_000) + + it('takes the name of the sound back with the sound', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + const next = screen.getByRole('button', { name: 'Next sound' }) + await user.click(next) + const first = screen.getByRole('button', { name: /^Sound:/ }).textContent + await user.click(next) + expect(screen.getByRole('button', { name: /^Sound:/ }).textContent).not.toBe(first) + await user.click(screen.getByRole('button', { name: 'Undo' })) + expect(screen.getByRole('button', { name: /^Sound:/ }).textContent).toBe(first) + }, 20_000) + + it('gives a removed sound back while the notice is still up', async () => { + const user = userEvent.setup() + open(createAudioDocument().id) + await user.click(screen.getByRole('button', { name: 'Keep this sound' })) + await user.click(screen.getByRole('button', { name: /^Sound:/ })) + const row = await screen.findByRole('menuitem', { name: /Sound 1/ }) + await user.click(within(row).getByRole('button', { name: /Remove/i })) + // The menu stays open — removing one of several is not a reason to close it — and while it is + // open the rest of the page is hidden from the accessibility tree, so it has to be let go of. + await user.keyboard('{Escape}') + expect(screen.getByRole('status', { name: 'Editor notice' })).toHaveTextContent('Removed Sound 1') + await user.click(within(screen.getByRole('status', { name: 'Editor notice' })).getByRole('button', { name: 'Undo' })) + await user.click(screen.getByRole('button', { name: /^Sound:/ })) + expect(await screen.findByRole('menuitem', { name: /Sound 1/ })).toBeInTheDocument() + }, 20_000) +}) + +it('flushes a pending macro edit when the browser leaves before autosave', () => { + const doc = createAudioDocument() + open(doc.id) + const macro = within(screen.getByRole('group', { name: 'Macros' })).getByRole('slider', { name: 'Pos1' }) + fireEvent.keyDown(macro, { key: 'ArrowRight' }) + const changed = macro.getAttribute('aria-valuenow') + // No debounce advance or React unmount: this is the lifecycle of a browser reload. + fireEvent(window, new Event('pagehide')) + const saved = getAudioDocument(doc.id) + expect(saved?.patch.layers[0]?.pitch.start).not.toBe(doc.patch.layers[0]?.pitch.start) + expect(saved?.rig?.parameters.find((parameter) => parameter.id === 'macro-1')?.defaultValue).toBe(Number(changed)) +}) + +it('restores an absent rig on undo instead of resurrecting the saved rig', async () => { + const user = userEvent.setup() + const doc = createAudioDocument() + open(doc.id) + await user.click(screen.getByRole('button', { name: 'Rename Pos1' })) + expect(screen.queryByRole('region', { name: /destinations/i })).toBeNull() + const name = screen.getByRole('textbox', { name: 'Macro name' }) + await user.clear(name) + await user.type(name, 'Motion{Enter}') + await waitFor(() => expect(getAudioDocument(doc.id)?.rig?.parameters[0]?.label).toBe('Motion')) + await user.click(screen.getByRole('button', { name: 'Undo' })) + await waitFor(() => expect(getAudioDocument(doc.id)?.rig).toBeUndefined()) +}) + +it('restores the saved seed exactly and preserves the selected sound across A/B', async () => { + const user = userEvent.setup() + const doc = createAudioDocument() + const snap = { id: 'saved-seed', name: 'Seed 987', createdAt: new Date().toISOString(), patch: { ...doc.patch, seed: 987 } } + saveAudioDocument({ ...doc, snapshots: [snap] }) + open(doc.id) + await user.click(within(screen.getByRole('navigation', { name: 'Sounds' })).getByRole('button', { name: 'Seed 987' })) + await waitFor(() => expect(getAudioDocument(doc.id)?.patch.seed).toBe(987)) + await user.click(screen.getByRole('button', { name: 'B' })) + await user.click(screen.getByRole('button', { name: 'Laser' })) + await user.click(screen.getByRole('button', { name: 'A' })) + expect(screen.getByRole('button', { name: 'Sound: Seed 987' })).toBeInTheDocument() + expect(getAudioDocument(doc.id)?.patch.seed).toBe(987) +}) + +it('opens randomize from the keyboard and remembers the family', async () => { + const user = userEvent.setup() + open(arcadeCoin().id) + await user.click(screen.getByRole('button', { name: 'Randomize' })) + const panel = await screen.findByRole('dialog', { name: 'Randomize' }) + expect(within(panel).getByRole('radiogroup', { name: 'Family' })).toBeInTheDocument() + expect(within(panel).getByRole('button', { name: 'Randomize' })).toBeInTheDocument() + await user.click(within(panel).getByRole('radio', { name: 'Mechanical' })) + await user.click(within(panel).getByRole('button', { name: 'Randomize' })) + expect(screen.getByRole('dialog', { name: 'Randomize' })).toBeInTheDocument() + await user.click(within(panel).getByRole('button', { name: 'Randomize' })) + expect(screen.getByRole('dialog', { name: 'Randomize' })).toBeInTheDocument() + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog', { name: 'Randomize' })).toBeNull() + await user.click(screen.getByRole('button', { name: 'Randomize' })) + expect(within(await screen.findByRole('dialog', { name: 'Randomize' })).getByRole('radio', { name: 'Mechanical' })).toBeChecked() +}, 30000) diff --git a/src/audio/AudioEditorPage.tsx b/src/audio/AudioEditorPage.tsx new file mode 100644 index 0000000..97fb063 --- /dev/null +++ b/src/audio/AudioEditorPage.tsx @@ -0,0 +1,1047 @@ +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ParamValue } from '@/rigs/types' +import { listRigs } from '@/rigs/registry' +import { WorkspaceShell } from '@/shell/WorkspaceShell' +import { useViewport } from '@/shell/useLayout' +import { updatePrefs } from '@/state/workspace' +import { Button, IconButton } from '@/ui/Button' +import { IconExport, IconRedo, IconSliders, IconUndo } from '@/ui/icons' +import { StatusMessage } from '@/ui/StatusMessage' +import { SelectField } from '@/ui/SelectField' +import { Tooltip } from '@/ui/Tooltip' +import { AudioFacePlate } from '@/audio/AudioFacePlate' +import { AudioSoundBar } from '@/audio/AudioSoundBar' +import { AudioSoundList } from '@/audio/AudioSoundList' +import { AudioLibraryDeck } from '@/audio/AudioLibraryDeck' +import { AudioTransport, type HearingMode } from '@/audio/AudioTransport' +import { boardParameters, boardValues, setBoardValue } from '@/audio/board' +import { AudioPresetsView } from '@/audio/AudioPresetsView' +import { getAudioDocument, MAX_SNAPSHOTS, saveAudioDocument, storageMessage, type AudioDocument, type AudioSnapshot } from '@/audio/document' +import type { AudioRig } from '@/audio/rig' +import { inspectWavetable, importWavetableFile, serializeAudioProject, restoreAudioAssets, MAX_IMPORT_BYTES, type WavetablePreview } from '@/audio/project' +import { decodeWav } from '@/audio/dsp/wav' +import { MAX_GESTURES, takeFromSamples } from '@/audio/gestures' +import { macroAmount, macrosOf, syncMacrosToPatch, writeMacros } from '@/audio/macros' +import { type AudioMode, readShufflePrefs, writeShufflePrefs, type ShufflePrefs } from '@/audio/prefs' +import { PRESETS } from '@/audio/presets' +import { mutateSound, randomPatch } from '@/audio/shuffle' +import { monoSum, renderPatch } from '@/audio/dsp/render' +import { gateLive, isLive, startLive, stopLive, triggerLive, updateLive, watchLiveMeter } from '@/audio/live' +import { useTransport } from '@/audio/useTransport' +import { layerProfiles } from '@/audio/profiles' +import { disposePlayback, playbackRate, audioContext } from '@/audio/playback' +import type { AudioPatch } from '@/audio/types' +import type { VoiceGate } from '@/audio/dsp/engine' +const AudioLabs = lazy(() => import('./labs/AudioLabs').then(module => ({ default: module.AudioLabs }))) +import { LabsPalette } from './labs/LabsPalette' +import { useLabs } from './labs/useLabs' +import { emptyLabSession, fingerprint, labSources, type LabSound } from './labs/model' + +const HISTORY_LIMIT = 100 + +/** What there is to play before a patch has loaded. */ +const EMPTY = { left: new Float32Array(0), right: new Float32Array(0) } + +/** + * Instrument, Sounds and Labs: editing, browsing and procedural research. + * + * Modulation used to be a view of its own, which put giving a sound movement and shaping the voice + * it moves in two places you could not occupy at once. It is a drawer under the instrument now, on + * screen while you work, the way every synthesiser worth copying arranges it. + */ +type ViewId = 'instrument' | 'sounds' | 'labs' +const VIEWS: { id: ViewId; label: string }[] = [ + { id: 'instrument', label: 'Instrument' }, + { id: 'sounds', label: 'Sounds' }, + { id: 'labs', label: 'Labs' }, +] + +/** + * Edit mode: the instrument. + * + * The layout follows from what a sound is. A drawing earns the big canvas because looking at it is + * the work; a sound does not, because the work is listening. So the parameters take the room and + * the waveform takes a strip, and the loop the whole screen is arranged around — reach for a + * control, hear the result — never has to go and find anything. + * + * The buffer is deferred rather than re-synthesised on every frame of a drag. Turning a knob has + * to feel like turning a knob, and the ear is not listening mid-drag anyway. + * + * The column on the left holds the library rather than the rig list, on the pattern the drawing + * and scene editors set: that column belongs to the document you have open, not to the ones you + * do not. Stepping through sounds while watching the panels change is how anyone finds one. + */ +type Step = { patch: AudioPatch; preset: string; touched: boolean; rig?: AudioRig; reference: AudioPatch } + +export function AudioEditorPage({ documentId, mode, onMode }: { + documentId: string + mode: AudioMode + onMode: (mode: AudioMode) => void +}) { + const navigate = useNavigate() + const [loaded] = useState(() => getAudioDocument(documentId)) + // The macro band is the document's rig; it is edited on the plate and saved with the patch. + const [rig, setRig] = useState(() => getAudioDocument(documentId)?.rig) + const [patch, setPatch] = useState(() => loaded?.patch ?? null) + const [name, setName] = useState(loaded?.name ?? '') + /** + * One step of the history: the sound, and which saved sound the bar was naming when it was taken. + * + * The selection used to be left out, so an undo put a patch on screen under somebody else's name + * with no "· edited" beside it — and Replace writes to whatever the name says, so one press + * afterwards overwrote a saved sound with a patch that never came from it. A step has to carry + * everything the step changed. + */ + const [past, setPast] = useState([]) + const [future, setFuture] = useState([]) + const [notice, setNotice] = useState('') + const [hearing, setHearing] = useState('oneshot') + const [liveOn, setLiveOn] = useState(false) + const [liveMeter, setLiveMeter] = useState({ peak: 0, left: 0, right: 0 }) + const [recording, setRecording] = useState(false) + const playRequest = useRef(0) + const [assetRevision, setAssetRevision] = useState(0) + const [tableImport, setTableImport] = useState<{ file: File; layer: number; preview: WavetablePreview; size: number } | null>(null) + const takeRef = useRef<{ times: number[]; values: number[]; started: number; macro: number; before: Step; past: Step[]; future: Step[] } | null>(null) + // Nothing is written until something is changed, or opening a bundled example would stamp a new + // updatedAt and quietly turn it into this browser's project. + const [dirty, setDirty] = useState(false) + const [preset, setPreset] = useState('') + const [view, setView] = useState('instrument') + const [mobilePanel, setMobilePanel] = useState<'nav' | 'main' | 'inspector'>('main') + const [snapshots, setSnapshots] = useState(() => loaded?.snapshots ?? []) + const [labs, setLabs] = useState(() => loaded?.labs ?? emptyLabSession()) + // The palette takes a fifth of the window, as the mockup gives it, within bounds its tiles read at. + const windowWidth = useViewport().width + const [touched, setTouched] = useState(false) + const gestureRef = useRef(false) + const capturedRef = useRef(false) + /** The patch as of the last write, so the end of a drag can hand it to the ear. */ + const latest = useRef(null) + const mutateRef = useRef(loaded?.patch ?? null) + const genToken = useRef(0) + const [generating, setGenerating] = useState(false) + const [shuffle, setShuffle] = useState(readShufflePrefs) + const generatingRef = useRef(false) + + const parameters = useMemo(() => boardParameters(), []) + const values = useMemo(() => (patch ? boardValues(patch) : {}), [patch]) + const rate = useMemo(() => playbackRate(), []) + /** + * The patch the ear is on. It follows the board immediately for anything discrete — a sound + * loaded, a dice, a typed value — and holds still through a drag, because re-synthesising a + * second and a half of audio sixty times a second is what makes a knob feel like treacle. + * + * This used to be a `useDeferredValue`, which deferred the wrong thing: it made the audio wait + * on a re-render of a hundred and two fields, so changing sound took a visible moment to be + * heard. Only the drag needs holding back, and a drag is something we already know about. + */ + const [heard, setHeard] = useState(() => loaded?.patch ?? null) + const samples = useMemo(() => { void assetRevision; return heard ? renderPatch(heard, rate) : EMPTY }, [heard, rate, assetRevision]) + // Playback is owned here rather than in the transport, because the waveform in the rail needs + // the same playhead and two of these would be two audio pipelines. Above the early return, as + // every hook must be. + const transport = useTransport(samples, rate, false) + const autoHeard = useRef(heard) + const pendingSave = useRef(null) + pendingSave.current = loaded && patch && dirty ? { ...loaded, name, patch: recording && takeRef.current ? takeRef.current.before.patch : patch, snapshots, labs, rig: recording && takeRef.current ? takeRef.current.before.rig : rig, updatedAt: new Date().toISOString() } : null + useEffect(() => { + const flush = () => { + if (pendingSave.current && saveAudioDocument(pendingSave.current).ok) pendingSave.current = null + } + // A browser reload does not unmount React. Flush the synchronous draft store before leaving. + window.addEventListener('pagehide', flush) + return () => { window.removeEventListener('pagehide', flush); flush() } + }, []) + const mono = useMemo(() => monoSum(samples), [samples]) + + useEffect(() => () => { playRequest.current += 1; stopLive(); disposePlayback() }, []) + useEffect(() => { + if (!loaded || ![loaded.patch, ...(loaded.snapshots ?? []).map((entry) => entry.patch), ...labSources(loaded.labs).map((entry) => entry.patch), ...(loaded.snapshots ?? []).flatMap((entry) => entry.lab?.parents.map((parent) => parent.patch) ?? [])].some((one) => one.layers.some((layer) => layer.source.table.startsWith('user:')))) return + let cancelled = false + void restoreAudioAssets(loaded).then((missing) => { + if (cancelled) return + setAssetRevision((value) => value + 1) + if (missing.length) setNotice(`Missing wavetables: ${missing.join(', ')}. Import them again to restore those layers.`) + }) + return () => { cancelled = true } + }, [loaded]) + + useEffect(() => { + if (!loaded || !patch || !dirty || recording) return + // Written on a delay so a drag lands once, not on every frame of itself. + const timer = setTimeout(() => { + const result = saveAudioDocument({ ...loaded, name, patch, snapshots, rig, labs, updatedAt: new Date().toISOString() }) + if (result.ok) { pendingSave.current = null; setDirty(false) } + setNotice(storageMessage(result) ?? '') + }, 400) + return () => clearTimeout(timer) + }, [dirty, loaded, name, patch, rig, snapshots, labs, recording]) + + /** + * Every one of these writes state from the callback rather than from inside another updater. + * + * Updaters have to be pure: React invokes them twice under StrictMode to catch exactly this, and + * a `setPreset` nested in a `setSnapshots` updater ran twice with a fresh uuid each time, so the + * id that was remembered belonged to a snapshot that was never kept and the menu read Unsaved + * straight after saving. The same shape was in undo and redo, where it pushed the redo stack + * twice. `latest` carries the newest patch so a drag — many writes before one render — still has + * something current to build on without reaching for an updater. + */ + const commit = useCallback((next: AudioPatch, nextRig?: AudioRig) => { + const current = latest.current ?? patch + if (!current) return + setDirty(true) + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + setFuture([]) + latest.current = next + setPatch(next) + setHeard(next) + if (nextRig !== undefined) setRig(nextRig) + if (isLive()) updateLive(next) + }, [patch, preset, touched, rig]) + + const change = useCallback((property: string, value: ParamValue) => { + const current = latest.current ?? patch + if (!current) return + setDirty(true) + setTouched(true) + if (!gestureRef.current || !capturedRef.current) { + capturedRef.current = true + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + setFuture([]) + } + const next = setBoardValue(current, property, value) + latest.current = next + mutateRef.current = next + setPatch(next) + if (!gestureRef.current) setHeard(next) + if (isLive()) updateLive(next) + }, [patch, preset, touched, rig]) + + /** + * A performer's row redrawn: one drag is one undo step, as a knob's is. + * + * `which` says which of the two grids a performer keeps is being written — the levels or the + * joinings — because they are drawn one above the other by the same gesture and go back into + * the patch the same way. + */ + const redraw = useCallback((which: 'patterns' | 'curves') => (performer: number, scene: number, steps: number[]) => { + const current = latest.current ?? patch + if (!current) return + setDirty(true) + setTouched(true) + if (!gestureRef.current || !capturedRef.current) { + capturedRef.current = true + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + setFuture([]) + } + const next: AudioPatch = { + ...current, + performers: current.performers.map((entry, at) => (at === performer + ? { ...entry, [which]: entry[which].map((row, index) => (index === scene ? steps : row)) } + : entry)), + } + latest.current = next + mutateRef.current = next + setPatch(next) + if (!gestureRef.current) setHeard(next) + if (isLive()) updateLive(next) + }, [patch, preset, touched, rig]) + /** + * The other side of the A/B — its sound and its history both — and which side is on screen. + * + * A side owns its own past. When only the patch was kept, flipping went through `commit`, which + * pushes an undo step: one Undo after a flip wrote the sound you had just left onto the side you + * had just arrived at, and both sides ended up holding the same patch. Flipping is not an edit, + * and the past you can walk back through is the past of the side you are standing on. + */ + const [spare, setSpare] = useState(null) + const [side, setSide] = useState<'a' | 'b'>('a') + const paint = useMemo(() => redraw('patterns'), [redraw]) + const joinUp = useMemo(() => redraw('curves'), [redraw]) + + const step = useCallback((to: Step) => { + latest.current = to.patch + mutateRef.current = to.reference + setPatch(to.patch) + setHeard(to.patch) + setPreset(to.preset) + setTouched(to.touched) + setRig(to.rig) + if (isLive()) updateLive(to.patch) + }, []) + + const undo = useCallback(() => { + const previous = past[past.length - 1] + const current = latest.current ?? patch + if (!previous || !current) return + setDirty(true) + setPast((stack) => stack.slice(0, -1)) + setFuture((ahead) => [{ patch: current, preset, touched, rig, reference: mutateRef.current ?? current }, ...ahead].slice(0, HISTORY_LIMIT)) + step(previous) + }, [past, patch, preset, touched, rig, step]) + + const redo = useCallback(() => { + const next = future[0] + const current = latest.current ?? patch + if (!next || !current) return + setDirty(true) + setFuture((ahead) => ahead.slice(1)) + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + step(next) + }, [future, patch, preset, touched, rig, step]) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const meta = event.metaKey || event.ctrlKey + if (view === 'labs' || event.defaultPrevented || !meta || !['z', 'y'].includes(event.key.toLowerCase())) return + const target = event.target + if (target instanceof HTMLElement && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return + event.preventDefault() + if (event.shiftKey || event.key.toLowerCase() === 'y') redo() + else undo() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [undo, redo, view]) + + /** The sound you are on, kept aside under whatever it is currently called. */ + const keep = useCallback(() => { + const current = latest.current ?? patch + if (!current) return + const from = PRESETS.find((entry) => entry.id === preset)?.label + const name = from + ? `${from} ${snapshots.filter((entry) => entry.name.startsWith(from)).length + 1}` + : `Sound ${snapshots.length + 1}` + const snapshot: AudioSnapshot = { id: `snap-${crypto.randomUUID()}`, name, createdAt: new Date().toISOString(), patch: current, rig } + setDirty(true) + // The oldest gives way rather than the list growing past the point of being readable. + setSnapshots((kept) => [...kept, snapshot].slice(-MAX_SNAPSHOTS)) + // You are on the thing you just kept, so the menu should say so. + setPreset(snapshot.id) + setTouched(false) + }, [patch, preset, snapshots, rig]) + + /** + * A kept sound removed, and a way back for as long as the notice is on screen. + * + * Snapshots are not in the undo history — the history is patches — so a mis-aimed click on the + * small trash icon *inside* the row you load a sound from used to be final, four hundred + * milliseconds later on disk, with Undo still enabled and undoing something else entirely. + * Removing is still one click, because confirming every delete is worse; it is the going back + * that was missing. + */ + const [removed, setRemoved] = useState<{ at: number; entry: AudioSnapshot } | null>(null) + const forget = useCallback((id: string) => { + // Read from the list rather than from inside an updater, as everything else here does: an + // updater has to be pure, and StrictMode runs it twice to make sure of it. + const at = snapshots.findIndex((entry) => entry.id === id) + const entry = snapshots[at] + if (!entry) return + setDirty(true) + setSnapshots((kept) => kept.filter((one) => one.id !== id)) + setRemoved({ at, entry }) + setNotice(`Removed ${entry.name}.`) + }, [snapshots]) + + const putBack = useCallback(() => { + if (!removed) return + setDirty(true) + setSnapshots((kept) => [...kept.slice(0, removed.at), removed.entry, ...kept.slice(removed.at)].slice(-MAX_SNAPSHOTS)) + setRemoved(null) + setNotice('') + }, [removed]) + + /** + * The same sound kept again under the name it already has. Without it every experiment on a + * saved sound leaves a copy behind, and a list of near-identical sounds is a list nobody reads. + */ + const overwrite = useCallback(() => { + const current = latest.current ?? patch + if (!current) return + setDirty(true) + setTouched(false) + setSnapshots((kept) => kept.map((entry) => ( + entry.id === preset ? { ...entry, patch: current, createdAt: new Date().toISOString(), rig } : entry + ))) + }, [patch, preset, rig]) + + /* + * Everything below is held still on purpose. + * + * The playhead is state on this component, so the whole page re-renders sixty times a second + * while a sound is playing. The face-plate is a hundred absolutely positioned controls with an + * SVG apiece and the rail is ninety-two rows; both are memoised, and a memo only bails out if + * every prop it is handed is the same object as last time. A callback written inline in the JSX + * is a new object every frame, and defeats it silently. + */ + const began = useCallback(() => { gestureRef.current = true; capturedRef.current = false }, []) + const ended = useCallback(() => { + gestureRef.current = false + capturedRef.current = false + if (latest.current) { + setHeard(latest.current) + if (isLive()) updateLive(latest.current) + } + }, []) + const writeRig = useCallback((next: AudioRig) => { + const current = latest.current ?? patch + if (!current) return + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + setFuture([]) + setRig(next) + setTouched(true) + setDirty(true) + }, [patch, preset, touched, rig]) + const writeMacrosTogether = useCallback((nextRig: AudioRig, nextPatch: AudioPatch, macroIndex?: number) => { + const current = latest.current ?? patch + if (!current) return + setDirty(true) + setTouched(true) + if (!takeRef.current && (!gestureRef.current || !capturedRef.current)) { + capturedRef.current = true + setPast((stack) => [...stack, { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }].slice(-HISTORY_LIMIT)) + setFuture([]) + } + latest.current = nextPatch + mutateRef.current = nextPatch + setPatch(nextPatch) + setRig(nextRig) + if (!gestureRef.current) setHeard(nextPatch) + if (isLive()) updateLive(nextPatch) + const take = takeRef.current + if (take && macroIndex !== undefined) { + const slots = macrosOf(nextRig, nextPatch) + if (take.macro < 0) { + take.macro = macroIndex + take.started = performance.now() + const request = ++playRequest.current + void startLive(nextPatch, 'oneshot').then((ok) => { + if (request !== playRequest.current) return + setLiveOn(ok) + }) + const initialSlot = macrosOf(take.before.rig, take.before.patch)[macroIndex] + const initial = initialSlot ? macroAmount(initialSlot) : 0 + take.times.push(0) + take.values.push(initial) + setNotice(`Recording ${slots[macroIndex]?.label || `macro ${macroIndex + 1}`}. Escape cancels.`) + } + if (take.macro === macroIndex && take.times.length < 8192) { + take.times.push(Math.min(current.duration, (performance.now() - take.started) / 1000)) + take.values.push(slots[macroIndex] ? macroAmount(slots[macroIndex]!) : 0) + } + } + }, [patch, preset, touched, rig]) + const seed = patch?.seed ?? 0 + const wantBuffer = useRef(false) + const cutVoice = useCallback(() => { + playRequest.current += 1 + stopLive() + setLiveOn(false) + transport.stop() + }, [transport]) + const hearNow = useCallback((next: AudioPatch) => { + cutVoice() + if (hearing === 'hold') setHearing('oneshot') + autoHeard.current = next + const gate: VoiceGate = hearing === 'repeat' ? 'repeat' : 'oneshot' + if (!audioContext()?.audioWorklet) { + wantBuffer.current = true + if (latest.current === next) transport.play() + return + } + const request = playRequest.current + void startLive(next, gate).then((ok) => { + if (request !== playRequest.current) return + setLiveOn(ok) + if (ok) transport.stop() + else wantBuffer.current = true + }) + }, [cutVoice, hearing, transport]) + const load = useCallback((next: AudioPatch, id: string) => { + if (id === preset && !touched) { + hearNow(latest.current ?? next) + return + } + const bundled = PRESETS.find((entry) => entry.id === id) + const snap = snapshots.find((entry) => entry.id === id) + const applied = snap || bundled?.group === 'Labs' ? next : { ...next, seed } + setPreset(id) + setTouched(false) + setRig(bundled?.rig?.(applied) ?? snap?.rig) + mutateRef.current = applied + hearNow(applied) + commit(applied) + }, [commit, seed, snapshots, preset, touched, hearNow]) + + const hearingGate = (mode: HearingMode): VoiceGate => (mode === 'hold' ? 'hold' : mode === 'repeat' ? 'repeat' : 'oneshot') + + const stopSound = useCallback(() => { + playRequest.current += 1 + const take = takeRef.current + if (take) { step(take.before); setPast(take.past); setFuture(take.future) } + if (hearing === 'hold') { + if (isLive()) gateLive('release') + else { + stopLive() + setLiveOn(false) + transport.stop() + } + setHearing('oneshot') + setRecording(false) + takeRef.current = null + return + } + stopLive() + setLiveOn(false) + setHearing('oneshot') + transport.stop() + setRecording(false) + takeRef.current = null + }, [hearing, transport, step]) + + const playSound = useCallback(async () => { + const current = latest.current ?? patch + if (!current) return + const gate = hearingGate(hearing) + if (isLive()) { + triggerLive(gate) + return + } + const request = ++playRequest.current + const ok = await startLive(current, gate) + if (request !== playRequest.current) return + setLiveOn(ok) + if (ok) transport.stop() + else { setHearing('oneshot'); setNotice('Real-time playback is unavailable. Playing the rendered one-shot.'); transport.play() } + }, [patch, hearing, transport]) + + useEffect(() => { + if (autoHeard.current === heard) return + autoHeard.current = heard + if (!recording) void playSound() + }, [heard, recording, playSound]) + + useEffect(() => { + if (!wantBuffer.current) return + wantBuffer.current = false + if (!isLive()) transport.play() + }, [samples, transport]) + + const hearingTransport = useMemo(() => ({ + ...transport, + playing: transport.playing || liveOn, + play: () => { void playSound() }, + stop: stopSound, + }), [transport, liveOn, playSound, stopSound]) + + useEffect(() => { + watchLiveMeter((meter) => { setLiveMeter({ peak: meter.peak, left: meter.left ?? meter.peak, right: meter.right ?? meter.peak }); if (meter.ended) setLiveOn(false) }) + return () => watchLiveMeter(null) + }, []) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + const target = event.target + if (target instanceof HTMLElement && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return + if (tableImport) { event.preventDefault(); setTableImport(null); return } + if (mobilePanel === 'nav') { + event.preventDefault() + setMobilePanel('main') + document.querySelector('.mobile-dock button')?.focus() + return + } + if (recording) { + event.preventDefault() + playRequest.current += 1 + const take = takeRef.current + setRecording(false) + takeRef.current = null + if (take) { step(take.before); setPast(take.past); setFuture(take.future) } + stopLive(); setLiveOn(false) + setNotice('Recording cancelled.') + return + } + if (hearing !== 'oneshot' || liveOn) { + event.preventDefault() + stopSound() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [recording, hearing, liveOn, stopSound, step, tableImport, mobilePanel]) + + const changeHearing = useCallback((next: HearingMode) => { + setHearing(next) + if (isLive()) { + if (next === 'oneshot' && hearing === 'hold') gateLive('release') + else gateLive(hearingGate(next)) + } + }, [hearing]) + + const toggleRecord = useCallback(() => { + const current = latest.current ?? patch + if (!current) return + if (recording) { + const take = takeRef.current + setRecording(false) + takeRef.current = null + if (!take || take.times.length < 2) { + if (take) { step(take.before); setPast(take.past); setFuture(take.future) } + stopLive(); setLiveOn(false) + setNotice('Nothing was recorded.') + return + } + const duration = Math.max(0.05, Math.min(current.duration, (performance.now() - take.started) / 1000)) + take.times.push(duration) + take.values.push(take.values[take.values.length - 1] ?? 0) + const points = takeFromSamples(take.times, take.values, 0, duration) + const slot = macrosOf(rig, current)[take.macro] + if (!slot || slot.destinations.length === 0) return + const gesture = { + id: `gesture-${crypto.randomUUID()}`, macro: take.macro, enabled: true, start: 0, + duration, points, destinations: slot.destinations, + } + setPast([...take.past, take.before].slice(-HISTORY_LIMIT)) + setFuture([]) + step({ ...take.before, touched: true, patch: { ...take.before.patch, gestures: [...(take.before.patch.gestures ?? []), gesture] } }) + setDirty(true) + stopLive(); setLiveOn(false) + setNotice(`Kept a take of ${slot.label || `macro ${take.macro + 1}`}.`) + + return + } + if ((current.gestures?.length ?? 0) >= MAX_GESTURES) { + setNotice('This patch already has 16 gestures. Remove a take before recording another.') + return + } + setHearing('oneshot') + setRecording(true) + takeRef.current = { times: [], values: [], started: performance.now(), macro: -1, before: { patch: current, preset, touched, rig, reference: mutateRef.current ?? current }, past, future } + stopLive() + const request = ++playRequest.current + void startLive(current, 'oneshot').then((ok) => { + if (request !== playRequest.current) return + setLiveOn(ok) + if (ok) { transport.stop(); if (takeRef.current) takeRef.current.started = performance.now() } + else { setRecording(false); takeRef.current = null; setNotice('Recording needs real-time audio, which is unavailable in this browser.') } + }) + setNotice('Move the macro you want to record. Escape cancels.') + }, [recording, patch, rig, preset, touched, past, future, step, transport]) + + const importTable = useCallback(async (layer: number) => { + const picker = window.document.createElement('input') + picker.type = 'file' + picker.accept = 'audio/wav,audio/wave,.wav' + picker.addEventListener('change', async () => { + const file = picker.files?.[0] + if (!file) return + if (file.size > MAX_IMPORT_BYTES) { setNotice('That wavetable is larger than 8 MB.'); return } + try { + const buffer = await file.arrayBuffer() + const wav = decodeWav(buffer) + if ('error' in wav) { setNotice(wav.error); return } + const preview = inspectWavetable(wav) + if ('error' in preview) { setNotice(preview.error); return } + if (preview.ambiguous) { + setTableImport({ file, layer, preview, size: preview.candidates.includes(1024) ? 1024 : preview.frameSize }) + return + } + const result = await importWavetableFile(file, preview.frameSize) + if ('error' in result) { setNotice(result.error); return } + change(`layers[${layer}].source.table`, result.tableName) + setNotice(`Imported ${result.name}.`) + } catch { setNotice('That wavetable could not be read or stored.') } + }) + picker.click() + }, [change]) + + const exportProject = useCallback(async () => { + if (!loaded || !patch) return + try { + await restoreAudioAssets({ ...loaded, patch, snapshots, labs }) + const blob = new Blob([serializeAudioProject({ ...loaded, name, patch, snapshots, rig, labs })], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = `${name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'sound'}.paramrig.audio.json` + link.click() + setTimeout(() => URL.revokeObjectURL(url), 0) + } catch (error) { setNotice(error instanceof Error ? error.message : 'The project could not be exported.') } + }, [loaded, name, patch, snapshots, rig, labs]) + /* + * The research bench. Its state is part of the document and its controls live in two columns of + * the shell — the palette on the left, the bench in the middle — so the hook sits here, where + * both can reach it, rather than inside either. + */ + const lab = useLabs({ + session: labs, + onChange: (next) => { setLabs(next); setDirty(true) }, + instrument: patch, rig, name, rate, + active: view === 'labs', + onBeforePlay: cutVoice, + onShowPalette: () => { + updatePrefs({ navCompact: false, navCollapsed: false }) + if (windowWidth < 1024) setMobilePanel('nav') + }, + onOpen: (sound: LabSound) => { + cutVoice() + const next = structuredClone(sound.patch) + commit(next, sound.rig) + mutateRef.current = next + setPreset(''); setTouched(true); setView('instrument') + }, + onSave: (sound: LabSound) => { + if (snapshots.some((snapshot) => snapshot.name === sound.name && fingerprint(snapshot.patch) === sound.fingerprint && JSON.stringify(snapshot.rig) === JSON.stringify(sound.rig))) return 'This sound is already saved.' + if (snapshots.length >= MAX_SNAPSHOTS) return 'Saved sounds are full. Remove a saved sound in Sounds before adding another.' + setSnapshots([...snapshots, { id: `snap-${crypto.randomUUID()}`, name: sound.name, createdAt: new Date().toISOString(), patch: structuredClone(sound.patch), rig: structuredClone(sound.rig), lab: structuredClone(sound) }]) + setDirty(true) + return null + }, + }) + const patterns = useMemo(() => patch?.performers.map((performer) => performer.patterns) ?? [], [patch]) + const curves = useMemo(() => patch?.performers.map((performer) => performer.curves) ?? [], [patch]) + const shownPatch = heard ?? patch + const profiles = useMemo(() => (shownPatch ? layerProfiles(shownPatch) : []), [shownPatch]) + const wave = useMemo( + () => ({ samples: mono, head: liveOn ? null : transport.head, profiles, label: name ?? '' }), + [mono, transport.head, profiles, name, liveOn], + ) + + if (!loaded || !patch) { + return ( + +
+ That patch is not in this browser. Open its project file to bring it back. + +
+
+ ) + } + + const exposed = rig?.parameters.length ?? 0 + + return ( + setMobilePanel((current) => next === current ? 'main' : next)} + mainLabel="Sound" + navLabel={view === 'labs' ? 'Palette' : 'Sounds'} + minNavWidth={view === 'labs' ? Math.round(Math.min(328, Math.max(272, windowWidth * 0.196))) : undefined} + renderNavigation={({ compact, inert, onNavigate }) => view === 'labs' ? ( + + ) : ( + macro.label)} + onToggleGesture={(id) => { + commit({ ...patch, gestures: patch.gestures?.map((entry) => entry.id === id ? { ...entry, enabled: !entry.enabled } : entry) }) + setTouched(true) + }} + onRemoveGesture={(id) => { + commit({ ...patch, gestures: patch.gestures?.filter((entry) => entry.id !== id) }) + setTouched(true) + }} + onSnapshot={keep} + onOverwrite={overwrite} + shuffle={shuffle} + generating={generating} + onShuffle={(next) => { + if (next.keepReference && !shuffle.keepReference) { + mutateRef.current = latest.current ?? patch + } + setShuffle(next) + writeShufflePrefs(next) + }} + onRandom={() => { + if (generatingRef.current) return + generatingRef.current = true + setGenerating(true) + const token = ++genToken.current + const family = shuffle.family + window.setTimeout(() => { + try { + if (token !== genToken.current) return + const next = randomPatch(Math.floor(Math.random() * 100000), rate, { family }) + if (token !== genToken.current) return + const current = latest.current ?? patch + const table = rig ? syncMacrosToPatch(macrosOf(rig, current), next) : undefined + const nextRig = table ? writeMacros(rig, table, next) : rig + mutateRef.current = next + setPreset('') + setTouched(false) + hearNow(next) + commit(next, nextRig) + } finally { + if (token === genToken.current) { + generatingRef.current = false + setGenerating(false) + } + } + }, 0) + }} + onMutate={() => { + const current = latest.current ?? patch + if (!current || generatingRef.current) return + generatingRef.current = true + setGenerating(true) + const token = ++genToken.current + const source = shuffle.keepReference ? (mutateRef.current ?? current) : current + const amount = shuffle.amount + const target = shuffle.target + const currentRig = rig + window.setTimeout(() => { + try { + if (token !== genToken.current) return + const macros = currentRig ? macrosOf(currentRig, source) : undefined + const result = mutateSound(source, Math.floor(Math.random() * 100000), { amount, target, macros }, rate) + if (token !== genToken.current) return + const table = result.macros + ? syncMacrosToPatch(result.macros, result.patch) + : (currentRig ? syncMacrosToPatch(macrosOf(currentRig, source), result.patch) : undefined) + const nextRig = table ? writeMacros(currentRig, table, result.patch) : currentRig + setTouched(true) + if (!shuffle.keepReference) mutateRef.current = result.patch + hearNow(result.patch) + commit(result.patch, nextRig) + } finally { + if (token === genToken.current) { + generatingRef.current = false + setGenerating(false) + } + } + }, 0) + }} + /> + } + /> + )} + > +

{loaded.name}

+ {/* + One row, as the reference keeps its top bar. The name, the transport with the sound menu + as its tools, the two views, the history, and Tune — where there had been a toolbar, a + transport with the waveform in it, and a tab strip, stacked, at a hundred and eighty pixels + that the face-plate below needed more than they did. + */} +
+ { setDirty(true); setName(event.target.value.slice(0, 120)) }} + /> + {view !== 'labs' ? + {/* + * A and B: the same page, two sounds, one keystroke apart. + * + * Judging a change to a two-hundred-millisecond sound by memory does not work — by the + * time the second one has played, the first is a feeling rather than a sound. B starts + * as a copy of A, so the first thing anybody does with it is change one number and + * flip back and forth. + */} +
+ {(['a', 'b'] as const).map((which) => ( + + + + ))} + + + +
+ + + } + /> : null} +
+ {VIEWS.map((entry) => ( + + ))} +
+ {view !== 'labs' ?
+ + + + + + +
:
+ + + + + + +
} + + + + {exposed > 0 && view !== 'labs' ? ( + + onMode(mode === 'edit' ? 'tune' : 'edit')}> + + + + ) : null} +
+ {/* Always in the tree so a screen reader keeps the live region, but no height until it has + something to say. A permanent band reporting that nothing is wrong is a band of nothing. */} +

+ {notice} + {removed ? : null} +

+ {tableImport ? ( +
+ {tableImport.file.name} · {tableImport.preview.channels} ch · {tableImport.preview.sampleRate} Hz + ({ value: String(size), label: `${size} samples · ${tableImport.preview.samples / size} frames` }))} + onChange={(size) => setTableImport({ ...tableImport, size: Number(size) })} /> + + +
+ ) : null} +
+
+ {view === 'labs' ? ( + Opening Sound Labs

}>
+ ) : view === 'sounds' ? ( + + ) : ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/src/audio/AudioEnvelope.tsx b/src/audio/AudioEnvelope.tsx new file mode 100644 index 0000000..af56fea --- /dev/null +++ b/src/audio/AudioEnvelope.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ParamValue } from '@/rigs/types' +import { envelopeAt, fitEnvelope } from '@/audio/dsp/envelope' +import type { AmpSettings } from '@/audio/types' + +/** + * The envelope, as an envelope. + * + * Six numeric fields describe the same thing and none of them shows it. Every synthesiser worth + * using draws this instead, because the shape is the parameter: you recognise a pluck, a swell or + * a knock at a glance and you reach for the corner that is wrong. + * + * The curve is drawn by `envelopeAt` — the function the synthesiser itself runs — so the picture + * is not an illustration of the sound, it is the sound's own envelope at drawing resolution. A + * discrepancy between the two is impossible rather than unlikely. + */ + +type Handle = 'attack' | 'hold' | 'decay' | 'release' + +const HANDLES: { id: Handle; label: string }[] = [ + { id: 'attack', label: 'Attack' }, + { id: 'hold', label: 'Hold' }, + { id: 'decay', label: 'Decay and sustain' }, + { id: 'release', label: 'Release' }, +] + +const PAD = 10 +const SAMPLES = 120 + +const ms = (seconds: number) => `${Math.round(seconds * 1000)} ms` + +export function AudioEnvelope({ layer, values, duration, onChange, onGestureStart, onGestureEnd, height = 88, pad = PAD, prefix, offsetId, name }: { + layer: number + values: Record + duration: number + onChange: (property: string, value: ParamValue) => void + onGestureStart?: () => void + onGestureEnd?: () => void + /** The face-plate draws the plot at the reference's size, sixty pixels with a pixel of margin. */ + height?: number + pad?: number + /** + * Where the stages live. A layer's amp envelope by default; a free envelope hands its own + * fields, the field that delays it, and what to call it to a screen reader. + */ + prefix?: string + offsetId?: string + name?: string +}) { + const root = prefix ?? `layers[${layer}].amp` + const delayId = offsetId ?? `layers[${layer}].offset` + const called = name ?? `layer ${layer + 1}` + const hostRef = useRef(null) + // Only the width is measured. The height is fixed because the plot is one line of a column and + // has to keep its place in the rhythm, not grow with whatever sits under it. + const [width, setWidth] = useState(240) + const dragRef = useRef(null) + + const offsetValue = values[delayId] + const offset = typeof offsetValue === 'number' ? offsetValue : 0 + const life = Math.max(0.02, duration - offset) + + // Memoised because `write` depends on it, and a fresh object every render would rebuild that + // callback on every render too. + const amp = useMemo(() => { + const read = (field: string, fallback: number) => { + const value = values[`${root}.${field}`] + return typeof value === 'number' ? value : fallback + } + return { + attack: read('attack', 0), hold: read('hold', 0), decay: read('decay', 0.1), + sustain: read('sustain', 0), release: read('release', 0.05), curve: read('curve', 2), + } + }, [root, values]) + const fitted = fitEnvelope(amp, life) + + useEffect(() => { + const host = hostRef.current + if (!host || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect + if (rect && rect.width > 0) setWidth(Math.round(rect.width)) + }) + observer.observe(host) + return () => observer.disconnect() + }, []) + + const plotW = Math.max(1, width - pad * 2) + const plotH = Math.max(1, height - pad * 2) + const x = (seconds: number) => pad + (seconds / life) * plotW + const y = (level: number) => pad + (1 - level) * plotH + + const path = Array.from({ length: SAMPLES + 1 }, (_, index) => { + const at = (index / SAMPLES) * life + return `${index === 0 ? 'M' : 'L'}${x(at).toFixed(2)},${y(envelopeAt(amp, fitted, at, life)).toFixed(2)}` + }).join('') + + const decayEnd = fitted.attack + fitted.hold + fitted.decay + const spots: Record = { + attack: { cx: x(fitted.attack), cy: y(1), text: ms(amp.attack) }, + hold: { cx: x(fitted.attack + fitted.hold), cy: y(1), text: ms(amp.hold) }, + decay: { cx: x(decayEnd), cy: y(amp.sustain), text: `${ms(amp.decay)}, sustain ${amp.sustain.toFixed(2)}` }, + release: { cx: x(life - fitted.release), cy: y(amp.sustain), text: ms(amp.release) }, + } + + /** + * Where each handle's hit area sits, which is not always where its dot is drawn. + * + * A stage of zero length puts its handle exactly on top of the one before it — and a fresh + * modulator envelope has a hold of zero, so its attack could not be grabbed at all: the hold's + * circle covered it entirely. The dots stay at the values they are drawing; the invisible discs + * behind them are pushed right until each has a strip of its own, nearest first. + */ + const apart = (() => { + const order: Handle[] = ['attack', 'hold', 'decay', 'release'] + const out = { attack: 0, hold: 0, decay: 0, release: 0 } as Record + let floor = -Infinity + for (const id of order) { + const wanted = Math.max(spots[id].cx, floor) + out[id] = wanted - spots[id].cx + floor = wanted + 15 + } + return out + })() + + /** + * The stage being dragged wins, and only its neighbours give way — in order, and only as far as + * they have to. + * + * Two earlier attempts were wrong in opposite directions. Capping each stage at whatever the + * other three left over meant every handle travelled a few pixels and then stopped dead on top + * of its neighbour, which reads as the point vanishing rather than as a limit. Shrinking all the + * others proportionally freed the handle but flattened the rest of the envelope with it: one + * pull of the decay and the attack, hold and release were gone. + * + * So a drag takes only from the side it is moving into, nearest first, and stops when that side + * is used up. Pulling the decay out shortens the release and leaves the attack alone; pulling + * the release in — it grows leftwards — eats the decay before the hold. + */ + const write = useCallback((handle: Handle, seconds: number, level: number | null) => { + const order: Handle[] = ['attack', 'hold', 'decay', 'release'] + const stages: Record = { + attack: amp.attack, hold: amp.hold, decay: amp.decay, release: amp.release, + } + const at = order.indexOf(handle) + // Release is measured from the end, so it grows into what precedes it; the rest grow forwards. + const gives = handle === 'release' ? order.slice(0, at).reverse() : order.slice(at + 1) + const fixed = order.filter((id) => id !== handle && !gives.includes(id)) + .reduce((sum, id) => sum + stages[id], 0) + + const wanted = handle === 'attack' ? seconds + : handle === 'hold' ? seconds - amp.attack + : handle === 'decay' ? seconds - amp.attack - amp.hold + : life - seconds + stages[handle] = Math.min(Math.max(0, life - fixed), Math.max(0, wanted)) + + let excess = order.reduce((sum, id) => sum + stages[id], 0) - life + for (const id of gives) { + if (excess <= 0) break + const taken = Math.min(stages[id], excess) + stages[id] -= taken + excess -= taken + } + + for (const id of order) { + if (stages[id] !== amp[id]) onChange(`${root}.${id}`, stages[id]) + } + if (handle === 'decay' && level !== null) { + onChange(`${root}.sustain`, Math.min(1, Math.max(0, level))) + } + }, [amp, root, life, onChange]) + + const fromPointer = (event: React.PointerEvent, handle: Handle) => { + /* + * Normalised by the box as it is drawn, not by the box as it is laid out. + * + * `getBoundingClientRect` includes every ancestor transform and the plate carries a + * `scale()`; `width` comes from a ResizeObserver's contentRect, which does not. Mixing the two + * meant the drag ran at the wrong rate everywhere the plate was not at exactly 1 — and since + * release is measured backwards from the end, on a large window the release handle ran away + * from the pointer rather than towards it. The fader compensates by reading `--fp-scale`; the + * ratio of the two boxes says the same thing without having to know the plate is there. + */ + const rect = event.currentTarget.getBoundingClientRect() + const acrossX = rect.width > 0 ? width / rect.width : 1 + const acrossY = rect.height > 0 ? height / rect.height : 1 + const seconds = (((event.clientX - rect.left) * acrossX - pad) / plotW) * life + const level = 1 - ((event.clientY - rect.top) * acrossY - pad) / plotH + write(handle, seconds, handle === 'decay' ? level : null) + } + + const nudge = (handle: Handle, event: React.KeyboardEvent) => { + const step = event.shiftKey ? life / 200 : life / 40 + const spot = spots[handle] + const seconds = ((spot.cx - pad) / plotW) * life + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + write(handle, seconds + (event.key === 'ArrowRight' ? step : -step), null) + return + } + if (handle === 'decay' && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + event.preventDefault() + onChange(`${root}.sustain`, Math.min(1, Math.max(0, amp.sustain + (event.key === 'ArrowUp' ? 0.05 : -0.05)))) + } + } + + return ( +
+ { + const handle = (event.target as Element).closest('[data-handle]')?.getAttribute('data-handle') as Handle | null + if (!handle) return + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = handle + onGestureStart?.() + }} + onPointerMove={(event) => { + if (!dragRef.current) return + event.preventDefault() + fromPointer(event, dragRef.current) + }} + onPointerUp={(event) => { + if (!dragRef.current) return + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + onGestureEnd?.() + }} + onPointerCancel={() => { + if (!dragRef.current) return + dragRef.current = null + onGestureEnd?.() + }} + > + + + + {HANDLES.map(({ id, label }) => ( + nudge(id, event)} + > + + + + ))} + +

+ A {ms(amp.attack)} + H {ms(amp.hold)} + D {ms(amp.decay)} + S {amp.sustain.toFixed(2)} + R {ms(amp.release)} +

+
+ ) +} diff --git a/src/audio/AudioFacePlate.tsx b/src/audio/AudioFacePlate.tsx new file mode 100644 index 0000000..9c127e1 --- /dev/null +++ b/src/audio/AudioFacePlate.tsx @@ -0,0 +1,2220 @@ +import { Fragment, createContext, memo, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type SyntheticEvent } from 'react' +import { createPortal } from 'react-dom' +import { tooltipDelay } from '@/ui/tooltipDelay' +import type { ParameterDef, ParamValue } from '@/rigs/types' +import { AudioKnob, type KnobMod, type KnobSize, type KnobTone } from '@/audio/AudioKnob' +import { AudioFader } from '@/audio/AudioFader' +import { AudioEnvelope } from '@/audio/AudioEnvelope' +import { FILTER_SLOTS, FX_SLOTS, INSERT_SLOTS, LFO_TARGET_LABELS, MOD_COUNT, MOD_ROUTES, PERFORMER_COUNT, PM_SOURCES, SCENE_COUNT, STEP_COUNT, routeAt } from '@/audio/fields' +import { AudioPattern } from '@/audio/AudioPattern' +import { waveAt } from '@/audio/dsp/osc' +import { warp } from '@/audio/dsp/osc' +import { listedWavetables, tableAt, tableOf, wavetable, wavetableOf } from '@/audio/dsp/wavetable' +import { RESPONSE_CEILING, RESPONSE_FLOOR, filterResponse, fxResponse, insertResponse } from '@/audio/dsp/response' +import { createInsert, insertSample } from '@/audio/dsp/insert' +import type { AudioPatch, FilterKind, FxKind, InsertKind, InsertPlace, InsertSlot } from '@/audio/types' +import { type AudioRig } from '@/audio/rig' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import type { PerformerShape } from '@/audio/types' +import { + applyMacros, bindMacro as addMacroDestination, inactiveMacroDestinations, macroIsMapped, + macroIsParked, macroPropertyLabel, macrosOf, prepareMacroMove, renameMacro, setMacroValue, + unbindMacro as removeMacro, writeMacros, type MacroSlot, +} from '@/audio/macros' +import { DEAL, fitPlate, plateBox, type Item, type Layout } from '@/audio/faces' + +/** + * The face-plate, transcribed from the reference at its own scale. + * + * Every number in this file is a measurement in CSS pixels off a Retina capture of the reference + * window: the plate is 1250 wide and 744 tall below its title bar, and each control is placed at + * the coordinates the capture gave for it. Earlier versions laid the same controls out with grids + * and flex boxes and asked the browser to distribute them, and the browser distributed them + * differently from the reference every time; a fixed object does not flow, so nothing here does. + * The stage scales the whole plate to fit whatever window it has, which is how the plugin itself + * handles a window that is not its own size — down to a point. Below it the plate folds to one of + * its other faces rather than shrink past reading, and if it must, the stage scrolls. The panels + * are flex items for that reason: a face is an order for them and a width for the row (faces.ts), + * and the browser wraps the row; inside a panel nothing flows. + * + * What the controls do is ParamRig's. Two oscillators and two noise generators are the four + * layers; the panels that act on one layer at a time — Comb, Filter, the amp envelope — follow + * the layer whose badge is lit in the oscillator or noise head. A few controls the reference has + * and this engine does not are drawn and inert, and say so in a title. + */ + +/** Where a text's cap line sits below its box top at line-height 1, in ems; Roboto's metrics. */ +const CAP = 0.054 + +type Num = Extract + +type Ctx = { + byId: Map + values: Record + onChange: (property: string, value: ParamValue) => void + onGestureStart?: () => void + onGestureEnd?: () => void + focus: number + setFocus: (index: number) => void + /** The sixteen macros as they stand: which properties each drives, if any. */ + macros: MacroSlot[] +} +/** The number a dial wears when a macro drives it. */ +const macroDigit = (ctx: Ctx, id: string) => { + const index = ctx.macros.findIndex((macro) => macro.destinations.some((dest) => dest.property === id)) + return index < 0 ? undefined : index + 1 +} +const num = (ctx: Ctx, id: string): Num | null => { + const parameter = ctx.byId.get(id) + return parameter && parameter.kind === 'number' ? (parameter as Num) : null +} +const read = (ctx: Ctx, id: string) => ctx.values[id] ?? ctx.byId.get(id)?.defaultValue +const readNum = (ctx: Ctx, id: string, fallback = 0) => { + const value = read(ctx, id) + return typeof value === 'number' ? value : fallback +} + +/* ── Modulation ──────────────────────────────────────────────────────────────────────────────── */ + +/** The colour of each kind of source, as the reference paints its buttons. */ +const SOURCE_COLOUR = { p: 'var(--fp-src-p)', e: 'var(--fp-src-e)', l: 'var(--fp-src-l)', t: 'var(--fp-src-t)', v: 'var(--fp-src-v)', m: 'var(--fp-red)' } as const + +/** The modulation target a parameter stands for, when an LFO may be pointed at it. */ +const targetOf = (id: string): string | undefined => { + const found = /^layers\[(\d)\]\.(.+)$/.exec(id) + if (!found) return undefined + // Width and position are one destination in the engine — how far along the shape sits — so a + // modulator dropped on either moves whichever of the two its source reads. + const where: Record = { + 'pitch.start': 'pitch', + 'filterA.cutoff': 'cutoff', + 'filterA.resonance': 'resonance', + 'filterB.cutoff': 'cutoff', + 'filterB.resonance': 'resonance', + 'source.pulseWidth': 'pulseWidth', + 'source.position': 'pulseWidth', + 'source.fmIndex': 'pm', + 'insertA.amount': 'insertA', + 'insertB.amount': 'insertB', + 'insertC.amount': 'insertC', + gain: 'gain', + pan: 'pan', + } + const destination = where[found[2] ?? ''] + return destination ? `layers[${found[1]}].${destination}` : undefined +} + +/** + * Every source pointed at a target, as the rings the control will wear: a performer in its amber, + * a free envelope in its blue, an oscillator in its green. + * + * Several may point at one control and the engine adds their swings, so the plate has to answer + * with several. It used to answer with the first, which was not a shorthand — it was a picture + * that said the other two were not there. + */ +function modsOf(ctx: Ctx, target: string | undefined): KnobMod[] { + if (!target) return [] + const found: KnobMod[] = [] + /* + * Every route of every source, not the first of each. + * + * A modulator holds four (target, depth) pairs and may be pointed at four places at once, so a + * control has to ask each of them rather than asking the slot where it goes. The id carries the + * route number because it is what the ring, the menu entry and React's key are all told apart + * by: the same oscillator may appear on this control once and on the next one twice. + */ + const routesOf = (id: string, name: string, colour: string, bipolar: boolean) => { + if (read(ctx, `${id}.enabled`) === false) return + for (let at = 0; at < MOD_ROUTES; at += 1) { + const route = routeAt(at) + if (read(ctx, `${id}.${route.target}`) !== target) continue + found.push({ + id: `${id}#${at}`, name, colour, bipolar, + depth: readNum(ctx, `${id}.${route.depth}`), + onDepth: (next) => ctx.onChange(`${id}.${route.depth}`, next), + onClear: () => ctx.onChange(`${id}.${route.target}`, 'off'), + }) + } + } + for (let index = 0; index < PERFORMER_COUNT; index += 1) { + routesOf(`performers[${index}]`, `P${index + 1}`, SOURCE_COLOUR.p, read(ctx, `performers[${index}].bipolar`) === true) + } + for (let index = 0; index < MOD_COUNT; index += 1) { + const envelope = read(ctx, `mods[${index}].kind`) === 'envelope' + // An envelope happens once and pushes one way, the sign of its depth saying which; an + // oscillator swings either side of the value. Drawn the same, the envelope's arc claimed a + // swing it does not have and its depth could not be dragged below zero. + routesOf(`mods[${index}]`, `${envelope ? 'E' : 'L'}${index + 2}`, envelope ? SOURCE_COLOUR.e : SOURCE_COLOUR.l, !envelope) + } + return found +} + +/* ── Hints ───────────────────────────────────────────────────────────────────────────────────── */ + +/** + * The line under the pointer. The plate's controls are drawn where the reference draws them and + * say no more than it does, which is nothing; so any of them can carry a `data-hint`, and the + * plate shows it — one floating line in the app's tooltip style, after the app's tooltip delay, + * over the control the pointer or the focus is on. The app's own Tooltip wraps its child in a + * span it measures, and a span around an absolutely placed control measures nothing. + */ +function Hint({ target }: { target: HTMLElement | null }) { + const [shown, setShown] = useState<{ left: number; top: number; text: string } | null>(null) + useLayoutEffect(() => { + if (!target) { setShown(null); return } + const timer = setTimeout(() => { + const rect = target.getBoundingClientRect() + setShown({ left: rect.left + rect.width / 2, top: rect.top, text: target.dataset.hint ?? '' }) + }, tooltipDelay()) + return () => clearTimeout(timer) + }, [target]) + if (!shown || !shown.text || typeof document === 'undefined') return null + return createPortal( +
{shown.text}
, + document.body, + ) +} + +/* ── Placement ───────────────────────────────────────────────────────────────────────────────── */ + +/** A panel's top-left corner on the plate, so its children can be placed in plate coordinates. */ +const Origin = createContext({ x: 0, y: 0 }) +function useAt() { + const origin = useContext(Origin) + return (x: number | string, y: number): CSSProperties => ({ left: typeof x === 'number' ? x - origin.x : x, top: y - origin.y }) +} + +function Panel({ x, y, w, h, label, tone, gap, follow, children }: { + x: number; y: number; w: number; h: number; label: string; tone?: 'panel' | 'noise' | 'bare' + /** The gap before it in its row, as the reference measures it. */ + gap?: number + /** Layer this panel follows; shown so a change of oscillator is a change of context, not a surprise. */ + follow?: number + children: ReactNode +}) { + // A flex item at the size it was measured at, which grows with its row in proportion to its + // width and centres what it holds; inside, every control keeps the coordinates it was measured at. + return ( + +
+
{children}
+
+
+ ) +} + +/** A word placed by the top of its capitals and, usually, its centre. */ +function Text({ x, y, size = 13, align = 'center', kind, u, onClick, checked, label, hint, children }: { + x: number; y: number; size?: number; align?: 'center' | 'left' | 'right' + kind?: 'label' | 'title' | 'bold' | 'macro' | 'digit' | 'dim' | 'source' + u?: boolean; onClick?: () => void; checked?: boolean; label?: string + /** A line under the pointer that says what it does. */ + hint?: string; children: ReactNode +}) { + const at = useAt() + const style = { ...at(x, y - size * CAP), '--size': `${size}px` } as CSSProperties + if (onClick) { + return ( + + ) + } + return {children} +} + +function Knob({ ctx, x, y, id, label, size = 'std', tone, digit, face, param, inert, idle, dots }: { + ctx: Ctx; x: number | string; y: number; id: string; label: string; size?: KnobSize; tone?: KnobTone; digit?: string | number; face?: ReactNode; param?: Num + /** Drawn where the reference draws it, wired to nothing: the fraction of the turn it shows. */ + inert?: number + /** Wired, but nothing reads it in the arrangement the patch is in: why, in a few words. */ + idle?: string + /** The two dots at the ends of the arc that mark a bipolar range. */ + dots?: boolean +}) { + const at = useAt() + if (inert !== undefined) { + return undefined} /> + } + const parameter = param ?? num(ctx, id) + if (!parameter) return + const current = read(ctx, id) + const target = targetOf(id) + // Macro-band dials keep the index to their left; they do not wear a red digit on the face. + const shownDigit = size === 'macro' ? undefined : (digit ?? macroDigit(ctx, id)) + return ( + ctx.onChange(id, next)} + onGestureStart={ctx.onGestureStart} + onGestureEnd={ctx.onGestureEnd} + /> + ) +} +const Ring = () => + +/** A knob over a list of options: the dial steps through them, the reference's way of choosing a shape. */ +function OptionKnob({ ctx, x, y, id, label, options, size = 'sm' }: { ctx: Ctx; x: number; y: number; id: string; label: string; options: readonly string[]; size?: KnobSize }) { + const at = useAt() + const current = read(ctx, id) + const index = Math.max(0, options.indexOf(typeof current === 'string' ? current : '')) + // Half a step of room either side, so the hand points at the middle of a sector rather than its edge. + const param: Num = { kind: 'number', id, label, group: '', min: -0.5, max: options.length - 0.5, step: 1, defaultValue: 0 } + return ( + ctx.onChange(id, options[Math.min(options.length - 1, Math.max(0, Math.round(next)))] ?? options[0] ?? '')} + onGestureStart={ctx.onGestureStart} onGestureEnd={ctx.onGestureEnd} /> + ) +} + +function Fader({ ctx, x, top, id, label, kind, digit }: { ctx: Ctx; x: number; top: number; id: string; label: string; kind?: 'osc' | 'noise'; digit?: string | number }) { + const at = useAt() + const parameter = num(ctx, id) + if (!parameter) return null + const current = read(ctx, id) + const target = targetOf(id) + return ( + ctx.onChange(id, next)} onGestureStart={ctx.onGestureStart} onGestureEnd={ctx.onGestureEnd} /> + ) +} + +/** The reference's quiet grey box: mid-grey, dark text, a pixel of radius. Lighter when chosen. */ +function Box({ x, y, w, h = 14.5, align = 'center', selected, onClick, label, checked, pressed, hint, children }: { + x: number; y: number; w: number; h?: number; align?: 'center' | 'right'; selected?: boolean; onClick?: () => void; label?: string; checked?: boolean; pressed?: boolean; hint?: string; children: ReactNode +}) { + const at = useAt() + const style = { ...at(x, y), width: w, height: h, lineHeight: `${h}px` } + if (onClick) { + return + } + return {children} +} + +/** The little light-grey markers: a hexagon, a circle, a square, or the serrated square of a noise generator. */ +function Badge({ x, y, kind, selected, onClick, label, tab, hint, children }: { + x: number; y: number; kind: 'hex' | 'circle' | 'square' | 'noise'; selected?: boolean; onClick?: () => void; label?: string; tab?: boolean; hint?: string; children: ReactNode +}) { + const at = useAt() + if (onClick) { + return + } + return +} + +/** + * What a table looks like across its knob, drawn by the function that plays it. + * + * Five readings of it, from the near end of the position dial to the far one, stacked back to + * front the way every wavetable has been drawn since the first one — because a single trace says + * what the wave is and a stack says what the dial will do to it, which is the thing being chosen. + * A picker of eight names is a picker nobody uses. + */ +function TableMark({ name }: { name: string }) { + const built = wavetableOf(name) ?? wavetable(tableOf(name)) + const frames = 5 + const points = 96 + return ( + + ) +} + +/** What a filter model does, measured by pushing tones through the filter itself. */ +function FilterMark({ kind }: { kind: FilterKind }) { + const curve = filterResponse(kind) + const d = Array.from(curve, (level, at) => + `${at === 0 ? 'M' : 'L'}${((at / (curve.length - 1)) * 60 + 2).toFixed(2)} ${responseY(level).toFixed(2)}`).join(' ') + return ( + + ) +} + +/** What an insert does to a sound, drawn by running one short burst through the insert itself. */ +function InsertMark({ kind }: { kind: InsertKind }) { + const shape = insertResponse(kind) + const d = Array.from(shape, (value, at) => { + const x = (at / (shape.length - 1)) * 60 + 2 + return `${at === 0 ? 'M' : 'L'}${x.toFixed(2)} ${(17 - value * 14).toFixed(2)}` + }).join(' ') + return ( + + ) +} + +/** + * The field to wake, for the three kinds whose resting value is an exact bypass. + * + * `saturate` returns its input at drive 0, `crushSample` skips the quantiser at sixteen bits, and + * a fold with no drive folds nothing — so picking Drive, Crusher or Fold on a fresh slot changed + * the sound by not one sample, and the slot read as broken. A slot that has been that kind before + * keeps whatever it was left at; only one still sitting at the silent value is given something. + */ +const WOKEN: Record = { + drive: { field: 'drive', silent: 0, value: 0.4 }, + fold: { field: 'drive', silent: 0, value: 0.4 }, + crusher: { field: 'bitDepth', silent: 16, value: 8 }, +} + +const INSERT_MODELS: { value: InsertKind; label: string; note: string }[] = [ + { value: 'off', label: 'Off', note: 'Nothing in this slot.' }, + { value: 'drive', label: 'Drive', note: 'Pushed until it rounds off and bites.' }, + { value: 'crusher', label: 'Crusher', note: 'Fewer bits, fewer samples. Cheap on purpose.' }, + { value: 'ring', label: 'Ring', note: 'Multiplied by a tone. Bells, radios, robots.' }, + { value: 'fold', label: 'Fold', note: 'Turned back at the rails. Harmonics from nowhere.' }, + { value: 'body', label: 'Body', note: 'Resonances it rings through: a struck thing.' }, + { value: 'comb', label: 'Comb', note: 'Added to itself a moment later. A pitch, or a tail.' }, +] + +const BODY_PROFILES: { value: string; label: string; note: string }[] = [ + { value: 'bar', label: 'Bar', note: 'The original struck rod. Kept so older patches stay themselves.' }, + { value: 'plate', label: 'Plate', note: 'Nearby modes, a longer shimmer.' }, + { value: 'cavity', label: 'Cavity', note: 'A hollow air, the first mode loud.' }, + { value: 'membrane', label: 'Membrane', note: 'A skin: the top dies fast.' }, + { value: 'glass', label: 'Glass', note: 'Bright, slow to lose its top.' }, + { value: 'aether', label: 'Aether', note: 'Invented spacings no object rings at.' }, +] + +/** + * What a master effect does, as the outline of both channels: the left above the line, the right + * below it. A widener changes neither channel on its own and only how far apart they are, so a + * picture of one of them would say it does nothing. + */ +function FxMark({ kind }: { kind: FxKind }) { + const shape = fxResponse(kind) + const points = shape.length / 2 + const at = (index: number) => (index / (points - 1)) * 60 + 2 + const top = Array.from({ length: points }, (_, index) => + `${index === 0 ? 'M' : 'L'}${at(index).toFixed(2)} ${(17 - (shape[index] ?? 0) * 13).toFixed(2)}`) + const bottom = Array.from({ length: points }, (_, index) => { + const back = points - 1 - index + return `L${at(back).toFixed(2)} ${(17 + (shape[points + back] ?? 0) * 13).toFixed(2)}` + }) + return ( + + ) +} + +const FX_MODELS: { value: FxKind; label: string; note: string }[] = [ + { value: 'off', label: 'Off', note: 'Nothing in this slot.' }, + { value: 'flanger', label: 'Flanger', note: 'A comb, swept. Jet engines and swoops.' }, + { value: 'chorus', label: 'Chorus', note: 'Copies that never quite agree. Thickens.' }, + { value: 'phaser', label: 'Phaser', note: 'Notches that move. Softer than a flanger.' }, + { value: 'delay', label: 'Delay', note: 'It happens again, and again after that.' }, + { value: 'reverb', label: 'Reverb', note: 'The room it happened in.' }, + { value: 'widener', label: 'Widener', note: 'Pushes the two channels apart.' }, +] + +/** + * The three dials each kind puts in its row, small, large, small — the reference's own shape. + * + * Chosen from what `fxSample` actually reads for that kind, which they were not: the phaser was + * given a Feed dial and no Depth, and depth is where its notches travel — the thing that makes a + * phaser a phaser. Its feedback stays at the resting 0.3, which is a usable resonance now that it + * is taken round with the right sign, and the flanger's stays at 0.4 for the same reason: three + * dials is the row's shape, and a field that is fixed at a good value is better than a dial that + * is fixed at nothing. Both remain reachable by binding a macro to them. + */ +const FX_KNOBS: Record = { + flanger: [{ field: 'rate', label: 'Rate' }, { field: 'mix', label: 'Mix' }, { field: 'depth', label: 'Depth' }], + chorus: [{ field: 'rate', label: 'Rate' }, { field: 'mix', label: 'Mix' }, { field: 'depth', label: 'Depth' }], + phaser: [{ field: 'rate', label: 'Rate' }, { field: 'mix', label: 'Mix' }, { field: 'depth', label: 'Depth' }], + delay: [{ field: 'time', label: 'Time' }, { field: 'mix', label: 'Mix' }, { field: 'feedback', label: 'Feed' }], + reverb: [{ field: 'size', label: 'Size' }, { field: 'mix', label: 'Mix' }, { field: 'damping', label: 'Damp' }], + widener: [{ field: 'width', label: 'Spread' }, { field: 'mix', label: 'Mix' }, { field: 'rate', label: 'Rate' }], +} + +/** + * What is pointed at one control, on a right-click. + * + * A ring can be dragged and double-clicked, which is enough for one source and not enough for + * three: the outermost ring is the one under the pointer whatever you meant, and there is no + * gesture at all for "which of these is the amber one". So the control answers the question in + * words — who is on it, how far each swings, and a way to send any of them away. + * + * It is portalled to the document because the plate carries a `transform: scale()`, and a fixed + * anchor inside a transformed ancestor is not fixed to the window at all. + */ +function Routed({ at, mods, onShow, onClose }: { + at: { x: number; y: number; label: string } | null + mods: KnobMod[] + onShow: (id: string) => void + onClose: () => void +}) { + if (typeof document === 'undefined') return null + return createPortal( + { if (!open) onClose() }}> + + + + + {children} + + + ) +} + +/** + * Shapes a row can start from. + * + * Sixteen bars at the floor is not a starting point, it is an empty page — and the rows anybody + * actually wants are the same handful every time. Random is the one that is not a shape: it is + * there because a performer is a thing you audition, and a throw of the dice is the fastest way + * to find out what a destination sounds like when it moves. + */ +const ROW_SHAPES: { label: string; of: (at: number, total: number) => number }[] = [ + { label: 'Flat', of: () => 0 }, + { label: 'Ramp up', of: (at, total) => at / (total - 1) }, + { label: 'Ramp down', of: (at, total) => 1 - at / (total - 1) }, + { label: 'Triangle', of: (at, total) => 1 - Math.abs((2 * at) / (total - 1) - 1) }, + { label: 'Sine', of: (at, total) => (Math.sin((2 * Math.PI * at) / total) + 1) / 2 }, + { label: 'Square', of: (at, total) => (at < total / 2 ? 1 : 0) }, + { label: 'Stairs', of: (at, total) => Math.floor(at / (total / 4)) / 3 }, + { label: 'Every other', of: (at) => (at % 2 === 0 ? 1 : 0) }, + { label: 'Random', of: () => Math.round(Math.random() * 100) / 100 }, +] + +/** The divisions the drawing can land on, stepped through in this order. */ +const GRIDS = [0, 2, 3, 4, 6, 8] + +/** What each phase-modulation source is called on the plate, where a word has to fit under a dial. */ +const PM_NAMES: Record = { + internal: 'Self', layer0: 'Osc 1', layer1: 'Osc 2', layer2: 'Noise 1', layer3: 'Noise 2', +} + +/** The three slots, as the panel head and the hints name them. */ +const INSERT_LETTERS = ['A', 'B', 'C'] as const + +/** And the two filters, on the same idiom. */ +const FILTER_LETTERS = ['A', 'B'] as const +const ROUTINGS = ['single', 'series', 'parallel'] +const ROUTING_NAMES: Record = { single: 'One filter', series: 'B after A', parallel: 'A and B at once' } + +/** + * One slot read off the board. + * + * Every field of every kind, because that is what the engine is handed: the picture under a dial + * and the sound coming out of the speaker are drawn by the same function from the same record, + * which is the only way the two cannot disagree. + */ +function slotAt(ctx: Ctx, layer: number, slot: number): InsertSlot { + const at = (field: string) => `layers[${layer}].${INSERT_SLOTS[slot] ?? 'insertA'}.${field}` + return { + kind: (read(ctx, at('kind')) ?? 'off') as InsertKind, + place: (read(ctx, at('place')) ?? 'pre') as InsertPlace, + amount: readNum(ctx, at('amount'), 1), + drive: readNum(ctx, at('drive'), 0), + bitDepth: readNum(ctx, at('bitDepth'), 16), + crush: readNum(ctx, at('crush'), 0), + ratio: readNum(ctx, at('ratio'), 2), + frequency: readNum(ctx, at('frequency'), 900), + spread: readNum(ctx, at('spread'), 0.7), + decay: readNum(ctx, at('decay'), 0.25), + partials: readNum(ctx, at('partials'), 4), + time: readNum(ctx, at('time'), 0.008), + feedback: readNum(ctx, at('feedback'), 0.5), + } +} + +/** + * The slots a layer's oscillator has already been through by the time the amplifier sees it, less + * the two that cannot be drawn. + * + * The glyph is a window about five milliseconds wide. A comb's shortest delay is longer than that, + * so inside the window it has nothing to hand back and the drawing goes flat; a body's ring starts + * many times louder than the excitation, so the drawing leaves the box. Neither is what those two + * do to the sound — it is what a five-millisecond look at them shows — so the glyph keeps the four + * that shape a waveform where it stands and leaves the two that work in time to the ear. + */ +const DRAWN_INSERTS = ['drive', 'fold', 'crusher', 'ring'] +const beforeAmp = (ctx: Ctx, layer: number) => + INSERT_SLOTS.map((_, slot) => slotAt(ctx, layer, slot)) + .filter((held) => held.place !== 'post' && DRAWN_INSERTS.includes(held.kind)) + +/** Where a frequency and a level fall in the box every response is drawn in. */ +const responseX = (hz: number) => (Math.log2(Math.min(16000, Math.max(60, hz)) / 60) / Math.log2(16000 / 60)) * 60 + 2 +const responseY = (db: number) => 31 - ((db - RESPONSE_FLOOR) / (RESPONSE_CEILING - RESPONSE_FLOOR)) * 28 + +/** + * The filter's own face: the model's measured curve, slid to the corner it is tuned to. + * + * The measurement is taken once, at twelve hundred hertz, and these models keep their shape as + * they are tuned — so moving the picture along the axis is a truthful account of what turning the + * dial does, and it costs a subtraction rather than a second measurement on every frame of a drag. + * The upright is the corner itself, which is the number the dial is actually setting. + */ +function FilterFace({ kind, cutoff }: { kind: FilterKind; cutoff: number }) { + const curve = filterResponse(kind) + const shift = responseX(cutoff) - responseX(1200) + const d = [ + `M${(2 - Math.abs(shift) - 4).toFixed(2)} ${responseY(curve[0] ?? 0).toFixed(2)}`, + ...Array.from(curve, (db, at) => `L${((at / (curve.length - 1)) * 60 + 2 + shift).toFixed(2)} ${responseY(db).toFixed(2)}`), + `L${(62 + Math.abs(shift) + 4).toFixed(2)} ${responseY(curve[curve.length - 1] ?? 0).toFixed(2)}`, + ].join(' ') + return ( + + ) +} + +const FILTER_MODELS: { value: FilterKind; label: string; note: string }[] = [ + { value: 'off', label: 'Off', note: 'Straight through, nothing taken away.' }, + { value: 'lowpass', label: 'Low', note: 'Takes the top off. What most sounds want.' }, + { value: 'highpass', label: 'High', note: 'Takes the bottom out. Thins and clears.' }, + { value: 'bandpass', label: 'Band', note: 'Keeps a band and drops both sides of it.' }, + { value: 'notch', label: 'Notch', note: 'Takes out a band, leaves the rest alone.' }, + { value: 'peak', label: 'Peak', note: 'Lifts a band without touching the rest.' }, + { value: 'ladder', label: 'Ladder', note: 'Four poles. Falls twice as fast, and growls.' }, + { value: 'comb', label: 'Comb', note: 'The sound added to itself a moment later.' }, + { value: 'formant', label: 'Vowel', note: 'Three mouth resonances. Cutoff walks the vowels.' }, +] + +/** + * The name of what is in a slot, and the picker that changes it. + * + * A list of names with a line of prose under each is a wall of text for choosing a shape. The + * shapes are the choice, so the picker is a grid of them and nothing else; the name and the line + * belong to whichever one the pointer or the keyboard is on, on a single strip at the foot that + * never changes height, so the menu does not resize under the hand. + * + * The trigger sits in plate coordinates and scales with the plate. The picker is portalled to the + * document, so it renders at the app's own size and stays legible on a plate scaled down. + */ +function SlotMenu({ x, y, w, label, value, options, onPick, hint, columns = 4, action }: { + x: number; y: number; w: number; label: string; value: string + options: { value: string; label: string; note?: string; mark?: ReactNode }[] + onPick: (next: string) => void; hint?: string; columns?: number + action?: { label: string; onPick: () => void } +}) { + const at = useAt() + const chosen = options.find((option) => option.value === value) + const [under, setUnder] = useState(null) + const said = options.find((option) => option.value === under) ?? chosen + + /** + * A grid answers the arrow keys as a grid. + * + * A menu is a column to Radix: down goes to the next cell written rather than the one below it, + * and left closes the whole thing, which in a grid is the wrong answer to wanting the cell + * before. All four keys are taken here, with Home and End for the ends. + */ + const grid = (event: React.KeyboardEvent) => { + const cells = [...event.currentTarget.querySelectorAll('[role="menuitem"]')] + const from = cells.findIndex((cell) => cell === document.activeElement) + if (from < 0) return + const step = event.key === 'ArrowDown' ? columns + : event.key === 'ArrowUp' ? -columns + : event.key === 'ArrowRight' ? 1 + : event.key === 'ArrowLeft' ? -1 + : 0 + const to = event.key === 'Home' ? cells[0] + : event.key === 'End' ? cells[cells.length - 1] + : step ? cells[Math.min(cells.length - 1, Math.max(0, from + step))] + : null + if (!to) return + event.preventDefault() + event.stopPropagation() + to.focus() + } + + return ( + { if (!open) setUnder(null) }}> + + + + + + {/* Caught on the way down: the roving focus answers the key on the cell itself, so a + handler that waits for the event to bubble has already lost the argument. */} +
+ {options.map((option) => ( + setUnder(option.value)} + onPointerMove={() => setUnder(option.value)} + onSelect={() => onPick(option.value)}> + {option.mark} + + ))} +
+

+ {said?.label ?? ''} + {said?.note ?? ''} +

+ {action ? ( + + {action.label} + + ) : null} +
+
+
+ ) +} + +/** + * A stack of choices in the box style, centred on `x`: the chosen one filled, the rest quiet. + * Three underlined words of the same size, one a shade brighter, is not a switch a person can read. + */ +function Choices({ x, y, w, row, label, options }: { + x: number; y: number; w: number; row: number; label: string + options: { word: string; on: boolean; onPick: () => void; hint: string }[] +}) { + const at = useAt() + return ( + + {options.map((option) => ( + + ))} + + ) +} + +/** A positioned block, placed by its top-left corner in plate coordinates. */ +function Block({ x, y, w, h, className, off, hint, children }: { x: number; y: number; w: number; h: number; className: string; off?: boolean; hint?: string; children: ReactNode }) { + const at = useAt() + return
{children}
+} + +/** A hairline, placed by its top-left corner. */ +function Line({ x, y, w, h = 1, colour = 'var(--fp-hairline)' }: { x: number; y: number; w: number; h?: number; colour?: string }) { + const at = useAt() + return