diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 000000000..c4c65a35c --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,164 @@ +# Claude Code Setup + +This directory contains project-specific configuration for [Claude Code](https://claude.ai/code). + +## Structure + +``` +.claude/ +├── settings.json # Tool permissions and MCP server configuration +├── last-knowledge-update # Tracks last SHA processed by /pre-push-update +├── commands/ +│ └── pre-push-update.md # /pre-push-update slash command +├── skills/ +│ ├── audio-nodes/ +│ │ ├── SKILL.md # C++ audio node engine +│ │ ├── gainnode-example.md +│ │ └── maintenance.md # For /pre-push-update only +│ ├── host-objects/ +│ │ ├── SKILL.md # JSI HostObject layer +│ │ ├── examples.md +│ │ └── maintenance.md +│ ├── build-compilation-dependencies/ +│ │ ├── SKILL.md # CMake, Gradle, podspec, prebuilt libs +│ │ ├── build-details.md +│ │ └── maintenance.md +│ ├── utilities/ +│ │ ├── SKILL.md # Shared C++ and TS utilities +│ │ ├── api.md +│ │ └── maintenance.md +│ ├── native-ios/ +│ │ ├── SKILL.md # iOS native layer +│ │ └── maintenance.md +│ ├── native-android/ +│ │ ├── SKILL.md # Android native layer +│ │ └── maintenance.md +│ ├── turbo-modules/ +│ │ ├── SKILL.md # TurboModule/JSI wiring +│ │ └── maintenance.md +│ ├── web-audio-api/ +│ │ ├── SKILL.md # Web Audio API spec conformance +│ │ └── maintenance.md +│ ├── thread-safety-itc/ +│ │ ├── SKILL.md # Audio thread safety & ITC +│ │ └── maintenance.md +│ ├── post-work-checks/ +│ │ ├── SKILL.md # Checklist after every change +│ │ └── maintenance.md +│ ├── flow/ +│ │ ├── SKILL.md # End-to-end feature flow +│ │ └── maintenance.md +│ └── writing-skills/ +│ ├── SKILL.md # How to write and maintain skill files (meta) +│ └── maintenance.md +└── README.md # This file +``` + +## Skills + +Skill files are a reference library for Claude. Each skill lives in its own directory (`.claude/skills//SKILL.md`) and is **auto-loaded** by Claude Code based on the YAML frontmatter `name` and `description` fields. The description contains trigger phrases — when the conversation context matches, the skill is surfaced automatically. + +Skills use a **three-level progressive disclosure model**: +1. **Frontmatter** — always loaded; name + description with trigger phrases +2. **`SKILL.md` body** — loaded when the skill is triggered; concise patterns and APIs +3. **Supporting `.md` files** (e.g. `gainnode-example.md`, `build-details.md`) — linked from `SKILL.md`; loaded explicitly when deep reference is needed + +Skills are intentionally kept concise (under 500 lines). They answer "what exists and how do I use it", not "how is it implemented". Verbose material (full code examples, deep build analysis, complete API docs for large `.hpp` files) lives alongside `SKILL.md` in the same skill directory. + +**To update a skill manually**: edit the relevant `SKILL.md` file directly. + +**To keep skills in sync with code automatically**: use `/pre-push-update` (see below). + +## Maintenance Files + +Every skill directory has a `maintenance.md` file. It maps source file path patterns to what needs checking in that skill when those paths change. This file is **not loaded during normal skill usage** — only `/pre-push-update` reads it. + +**Purpose**: when `/pre-push-update` runs, it reads each relevant skill's `maintenance.md` to decide exactly which sections to review. Without this table, Claude has to guess — with it, the mapping is explicit and reliable. + +**Format** (same in every `maintenance.md`): + +```markdown +# Maintenance — skill-name + +> Used by /pre-push-update only — not loaded during skill usage. + +| Path | What to check | +|---|---| +| `path/to/file.*` | What in this skill to review or update | +``` + +Each `SKILL.md` ends with a single footer line: `*Maintenance: see [maintenance.md](maintenance.md).*` + +Supporting files (e.g. `gainnode-example.md`) do **not** have their own maintenance sections — their rows are merged into the skill's `maintenance.md`. + +**Rule**: if you add a new pattern, invariant, or code example to a skill, also add or update the relevant row in `maintenance.md` so future runs of `/pre-push-update` know to revisit it. + +## `/pre-push-update` command + +A slash command that reviews all commits since its last run and updates skill files to reflect what changed. + +### How it works + +1. `scripts/collect-knowledge-changes.sh` reads `.claude/last-knowledge-update` for the last-processed git SHA. On first run (empty file), it falls back to `HEAD~10`. +2. The script outputs: + - All commits in the range + - **All changed files** (full stat, unfiltered) — Claude uses this to triage what is interesting + - **Source diff** filtered to `*.h / *.hpp / *.cpp / *.mm / *.kt / *.ts / *.tsx` inside the tracked source directories + - **Maintenance tables** — all `maintenance.md` files concatenated, so Claude knows exactly which sections to review without extra file reads +3. Claude reads the output, classifies each changed path against the skill map, and makes targeted additions or corrections. +4. Claude advances `.claude/last-knowledge-update` to the new HEAD SHA. + +### When to run it + +Run it before pushing a branch, after merging a PR, or whenever you feel the skill files may have drifted from the code. It is safe to run at any time — it only reads git history and writes to `.claude/`. + +```bash +# Inside a Claude Code session: +/pre-push-update +``` + +### What it updates + +| Change type | Action | +|---|---| +| New audio node class | Add entry to `audio-nodes/SKILL.md` | +| New HostObject pattern | Add entry to `host-objects/SKILL.md` | +| New utility helper | Add entry to `utilities/SKILL.md` | +| Renamed/moved class referenced in a skill | Correct the reference | +| New thread-safety invariant | Add to `thread-safety-itc/SKILL.md` | +| Pure formatting / test-only / CI changes | Skipped | + +### What it does NOT do + +- Rewrite skills from scratch +- Document internal implementation details +- Process binary files, lock files, or generated code +- Touch anything outside `.claude/` + +### Marker file + +`.claude/last-knowledge-update` contains the SHA of the last commit that was successfully processed. If it is empty or the SHA is not found in history, the script falls back to `HEAD~10`. You can reset it manually by writing any valid commit SHA. + +```bash +# Reset to a specific commit (process everything since that point next run) +git rev-parse > .claude/last-knowledge-update + +# Reset to process the last 20 commits next run +git rev-parse HEAD~20 > .claude/last-knowledge-update +``` + +### Diff limits + +The script caps the source diff at **4000 lines**. If a batch of commits exceeds this, the diff is truncated with a warning. In that case run `/pre-push-update` more frequently, or review large refactors manually. + +## `settings.json` + +Defines tool permissions for Claude Code: + +- **Always allow**: `yarn build/lint/format/test`, read-only git commands, reading all source files, writing/editing `.claude/**` and `**/CLAUDE.md`, common inspection commands (`ls`, `which`, etc.) +- **Ask before**: destructive git operations (`commit`, `push`, `reset`, `checkout`, etc.), `yarn clean` +- **Always deny**: force push, `rm -rf`, `sudo`, reading build artifacts and binary files + +Also configures MCP servers: +- `filesystem` — `@modelcontextprotocol/server-filesystem` pointed at the repo root +- `lsp` — `mcp-language-server` using `typescript-language-server` for TS/JS code intelligence diff --git a/.claude/commands/pre-push-update.md b/.claude/commands/pre-push-update.md new file mode 100644 index 000000000..59e915e61 --- /dev/null +++ b/.claude/commands/pre-push-update.md @@ -0,0 +1,81 @@ +Run the knowledge update process: collect all commits since the last update, analyse what changed, and update skill files and CLAUDE.md accordingly. + +## Step 1 — Collect changes + +Run the collection script: + +```bash +bash scripts/collect-knowledge-changes.sh +``` + +If the output starts with `NO_NEW_COMMITS`, stop here and tell the user there is nothing to update. + +## Step 2 — Triage the changed files + +Look at the **ALL CHANGED FILES** section first. Use it to decide which changes are worth deep analysis. Classify each changed path: + +| Path pattern | Potentially affects | +|---|---| +| `common/cpp/audioapi/core/` | `audio-nodes/SKILL.md` (+ `audio-nodes/gainnode-example.md`) | +| `common/cpp/audioapi/HostObjects/` | `host-objects/SKILL.md` (+ `host-objects/examples.md`) | +| `common/cpp/audioapi/utils/` or `dsp/` | `utilities/SKILL.md` (+ `utilities/api.md`) | +| `common/cpp/audioapi/events/` or `core/utils/Audio*` | `thread-safety-itc/SKILL.md` | +| `android/src/main/` | `native-android/SKILL.md` | +| `ios/` | `native-ios/SKILL.md` | +| `src/specs/` or `AudioAPIModule.*` | `turbo-modules/SKILL.md` | +| `src/` (TypeScript) | `turbo-modules/SKILL.md` or `web-audio-api/SKILL.md` | +| `CMakeLists.txt`, `*.podspec`, `*.gradle` | `build-compilation-dependencies/SKILL.md` (+ `build-compilation-dependencies/build-details.md`) | +| `.claude/` | CLAUDE.md itself | + +For each identified skill, check the **MAINTENANCE TABLES** section at the end of the script output — it contains every skill's `maintenance.md` so you can see exactly which sections to review without additional file reads. + +**Skip a file entirely if:** +- It is a test file with no new patterns (e.g. adding a test for existing behaviour) +- The change is purely formatting/whitespace +- It is a dependency version bump +- It is CI config, example app, or lock file + +## Step 3 — Read the source diff + +Read the **SOURCE DIFF** section for the files you decided are interesting. For each interesting change ask: + +1. **New API or class?** — Is there a new class, method, or utility that a developer working in this area would want to know about? If yes, add a concise entry to the relevant skill file. + +2. **New pattern or invariant?** — Did the change reveal a non-obvious pattern, rule, or constraint (e.g. "this must always be called before X", "this field is audio-thread only")? If yes, document it in the relevant skill file. + +3. **Broken reference?** — Does the diff rename, move, or delete something that is currently mentioned in a skill file or CLAUDE.md? If yes, correct the reference. + +4. **New utility?** — Was a utility helper added to `utils/` or `dsp/`? If yes, add it to `utilities.md` following the existing format (brief usage note for `.h` files, inline docs for `.hpp`). + +5. **Nothing documentable?** — If the change is purely internal implementation with no effect on the documented API, patterns, or invariants — skip it. + +## Step 4 — Update skill files + +Apply only targeted, minimal additions or corrections. Do **not**: +- Rewrite skill files from scratch +- Add documentation for every changed line +- Document internal implementation details that are only useful when reading that specific file + +Read each skill file you intend to modify before editing it. + +## Step 5 — Advance the marker + +Extract the `HEAD_SHA=` line from the script output. Write only that SHA (no newline, no extra text) to `.claude/last-knowledge-update`. + +```bash +# replace with the actual SHA from the last line of script output +printf '%s' '' > .claude/last-knowledge-update +``` + +To reset the marker to "empty" (so the next run falls back to HEAD~10), use: +```bash +> .claude/last-knowledge-update +``` +(`touch` only updates the timestamp — it does **not** clear the file contents.) + +## Step 6 — Report + +Tell the user: +- Which skill files were updated and why (one line each) +- Which files were skipped and why (brief) +- The new marker SHA diff --git a/.claude/hooks/double-prompt.js b/.claude/hooks/double-prompt.js new file mode 100644 index 000000000..abf2ec0f4 --- /dev/null +++ b/.claude/hooks/double-prompt.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// UserPromptSubmit hook — repeats the user's prompt as additionalContext. +// Research suggests repeating the instruction improves instruction-following. + +let data = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { data += chunk; }); +process.stdin.on('end', () => { + try { + const input = JSON.parse(data); + const prompt = (input.prompt || '').trim(); + if (prompt.length >= 500) { + process.stdout.write(JSON.stringify({ additionalContext: prompt })); + } + } catch (_) { + // parsing failed — output nothing, hook is a no-op + } +}); diff --git a/.claude/last-knowledge-update b/.claude/last-knowledge-update new file mode 100644 index 000000000..a86c91b5c --- /dev/null +++ b/.claude/last-knowledge-update @@ -0,0 +1 @@ +69e3f98f9413eca44ba6ef375636873a92184587 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..78963d682 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "respectGitIgnore": true, + "permissions": { + "allow": [ + "Bash(yarn build)", + "Bash(yarn lint*)", + "Bash(yarn format*)", + "Bash(yarn typecheck)", + "Bash(yarn test)", + "Bash(yarn check-audio-enum-sync)", + "Bash(yarn workspace *)", + "Bash(git status)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(git show*)", + "Bash(cmake *)", + "Bash(clang-format *)", + "Bash(node *)", + "Bash(npx prettier*)", + + "Bash(ls*)", + "Bash(pwd)", + "Bash(which *)", + "Bash(type *)", + "Bash(file *)", + "Bash(wc *)", + "Bash(bash scripts/collect-knowledge-changes.sh*)", + "Bash(printf '%s' * > .claude/last-knowledge-update)", + "Bash(> .claude/last-knowledge-update)", + + "Read(**)", + + "WebFetch(https://webaudio.github.io/*)", + "WebFetch(https://developer.mozilla.org/*)", + "WebFetch(https://en.cppreference.com/*)", + "WebFetch(https://reactnative.dev/*)", + "WebSearch(*)", + + "Write(.claude/**)", + "Edit(.claude/**)", + "Write(CLAUDE.md)", + "Edit(CLAUDE.md)", + "Write(apps/CLAUDE.md)", + "Edit(apps/CLAUDE.md)", + "Write(packages/*/CLAUDE.md)", + "Edit(packages/*/CLAUDE.md)", + "Write(packages/react-native-audio-api/tests/CLAUDE.md)", + "Edit(packages/react-native-audio-api/tests/CLAUDE.md)" + ], + "ask": [ + "Bash(git add*)", + "Bash(git commit*)", + "Bash(git push*)", + "Bash(git merge*)", + "Bash(git rebase*)", + "Bash(git reset*)", + "Bash(git checkout *)", + "Bash(git stash*)", + "Bash(yarn clean)" + ], + "deny": [ + "Bash(git push --force*)", + "Bash(rm -rf *)", + "Bash(sudo *)", + + "Read(**/node_modules/**)", + "Read(**/Pods/**)", + "Read(**/vendor/bundle/**)", + + "Read(**/build/**)", + "Read(**/lib/**)", + "Read(**/.turbo/**)", + "Read(**/.gradle/**)", + "Read(**/.cxx/**)", + "Read(**/.kotlin/**)", + "Read(**/DerivedData/**)", + "Read(**/*.xcuserstate)", + "Read(**/*.pbxuser)", + "Read(**/*.hmap)", + "Read(**/*.ipa)", + + "Read(**/jniLibs/**)", + "Read(**/*.a)", + "Read(**/*.xcframework/**)", + "Read(**/ffmpeg_ios/**)", + "Read(**/openssl-prebuilt/**)", + "Read(**/prebuilt_libs/**)", + "Read(**/output/**)", + + "Read(**/CMakeFiles/**)", + + "Read(yarn.lock)", + "Read(**/Gemfile.lock)", + "Read(**/Podfile.lock)", + + "Read(**/*.png)", + "Read(**/*.jpg)", + "Read(**/*.jpeg)", + "Read(**/*.gif)", + "Read(**/*.webp)", + "Read(**/*.ico)", + "Read(**/*.svg)", + "Read(**/*.ttf)", + "Read(**/*.otf)", + "Read(**/*.woff)", + "Read(**/*.woff2)", + "Read(**/*.mp3)", + "Read(**/*.wav)", + "Read(**/*.ogg)", + "Read(**/*.aac)" + ] + }, + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/double-prompt.js" + } + ] + } + ] + }, + "mcpServers": { + "lsp": { + "command": "npx", + "args": ["-y", "mcp-language-server", "--stdio"], + "env": { + "LSP_COMMAND": "typescript-language-server", + "LSP_ARGS": "--stdio" + } + } + } +} diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md new file mode 100644 index 000000000..2068a1297 --- /dev/null +++ b/.claude/skills/audio-nodes/SKILL.md @@ -0,0 +1,335 @@ +--- +name: audio-nodes +description: > + C++ audio node engine for react-native-audio-api. Covers the AudioNode class hierarchy, the processNode() audio-thread contract (no allocs, no locks, no blocking I/O), AudioParam a-rate/k-rate processing, cross-thread communication patterns (CrossThreadEventScheduler, IAudioEventHandlerRegistry), and a step-by-step checklist for implementing a new node end-to-end. Use this skill when implementing a new Web Audio API node, modifying audio graph traversal or processing logic, or debugging audio rendering artifacts. Trigger phrases: "add a new node", "implement AudioNode", "processNode", "audio thread", "AudioParam automation". +--- + +# Skill: AudioNodes + +Golden references: `GainNode.h/.cpp` (effect node), `OscillatorNode.h/.cpp` (scheduled source). Mirror their structure for any new node. See [gainnode-example.md](gainnode-example.md) for an annotated header + .cpp. + +**If spec defaults or parameter ranges are unclear → fetch https://webaudio.github.io/web-audio-api/ before writing any constructor code.** + +--- + +## Directory Structure + +``` +common/cpp/audioapi/core/ +├── AudioNode.h / .cpp # Base class for all nodes +├── AudioParam.h / .cpp # Automatable parameter +├── BaseAudioContext.h / .cpp # Engine + node factory +├── AudioContext.h / .cpp # Real-time context +├── OfflineAudioContext.h / .cpp # Offline rendering context +├── sources/ +│ ├── AudioScheduledSourceNode.h # Base for start/stop sources (INTERNAL) +│ ├── AudioBufferBaseSourceNode.h # Base for buffer playback (INTERNAL) +│ ├── OscillatorNode.h / .cpp +│ ├── AudioBufferSourceNode.h / .cpp +│ ├── AudioBufferQueueSourceNode.h / .cpp +│ ├── ConstantSourceNode.h / .cpp +│ ├── StreamerNode.h / .cpp # FFmpeg-based (conditional) +│ ├── WorkletSourceNode.h / .cpp +│ └── RecorderAdapterNode.h / .cpp +├── effects/ +│ ├── GainNode.h / .cpp +│ ├── BiquadFilterNode.h / .cpp +│ ├── DelayNode.h / .cpp +│ ├── IIRFilterNode.h / .cpp +│ ├── StereoPannerNode.h / .cpp +│ ├── WaveShaperNode.h / .cpp +│ ├── ConvolverNode.h / .cpp +│ ├── WorkletNode.h / .cpp +│ └── PeriodicWave.h / .cpp # Wave table (not a node) +├── analysis/ +│ └── AnalyserNode.h / .cpp +├── destinations/ +│ └── AudioDestinationNode.h / .cpp +├── inputs/ +│ └── AudioRecorder.h / .cpp +└── utils/ + └── AudioGraphManager.h / .cpp +``` + +--- + +## The Audio Thread Contract + +`processNode()` runs on the **audio thread** — the real-time rendering thread driven by the native audio driver (Oboe on Android, CoreAudio on iOS). This thread has strict requirements: + +**MUST NOT in `processNode()`:** +- Allocate or free memory (`new`, `delete`, `malloc`, `free`, `std::vector::push_back` that grows, etc.) +- Acquire any mutex or lock (`std::mutex`, `std::lock_guard`, etc.) +- Make any blocking syscall (file I/O, socket, `sleep`, `wait`) +- Call into JavaScript — no JSI calls, no `callInvoker_->invokeSync()` +- Throw exceptions (or rely on exception unwinding paths that allocate) + +**Preallocate everything in the constructor:** +```cpp +// Constructor — JS thread, allocations OK +GainNode::GainNode(const std::shared_ptr &context, const GainOptions &options) + : AudioNode(context, options) { + // Preallocate the AudioBuffer used during processing + audioBuffer_ = std::make_shared(channelCount_, context->getBufferSize()); + + // Preallocate params — they own their internal AudioBuffer too + gainParam_ = std::make_shared( + options.gain, -3.4028234663852886e+38f, 3.4028234663852886e+38f, context); +} + +// processNode — audio thread, NO allocations +std::shared_ptr GainNode::processNode( + const std::shared_ptr &processingBuffer, + int framesToProcess) { + // Already-allocated buffer reused each render quantum + auto gainValues = gainParam_->processARateParam(framesToProcess, time); + for (size_t i = 0; i < processingBuffer->getNumberOfChannels(); i++) { + processingBuffer->getChannel(i)->multiply(*gainValues->getChannel(0), framesToProcess); + } + return processingBuffer; +} +``` + +--- + +## Class Hierarchy + +```mermaid +classDiagram + direction TD + + class AudioScheduledSourceNode { + <> + start(when) + stop(when) + } + class AudioBufferBaseSourceNode { + <> + playbackRate AudioParam + detune AudioParam + } + + AudioNode <|-- AudioScheduledSourceNode + AudioNode <|-- GainNode + AudioNode <|-- BiquadFilterNode + AudioNode <|-- DelayNode + AudioNode <|-- IIRFilterNode + AudioNode <|-- StereoPannerNode + AudioNode <|-- WaveShaperNode + AudioNode <|-- ConvolverNode + AudioNode <|-- WorkletNode + AudioNode <|-- AnalyserNode + AudioNode <|-- AudioDestinationNode + AudioNode <|-- AudioRecorder + + AudioScheduledSourceNode <|-- AudioBufferBaseSourceNode + AudioScheduledSourceNode <|-- OscillatorNode + AudioScheduledSourceNode <|-- ConstantSourceNode + AudioScheduledSourceNode <|-- StreamerNode + AudioScheduledSourceNode <|-- WorkletSourceNode + + AudioBufferBaseSourceNode <|-- AudioBufferSourceNode + AudioBufferBaseSourceNode <|-- AudioBufferQueueSourceNode +``` + +### AudioScheduledSourceNode (internal only — not exposed to JS directly) + +Base class for source nodes that have a scheduled start and stop time. **Not instantiated directly.** + +```cpp +// Playback state machine +enum class PlaybackState { + UNSCHEDULED, // before start() called + SCHEDULED, // start() called, waiting for startTime_ + PLAYING, // actively producing audio + STOP_SCHEDULED, // stop() called, waiting for stopTime_ + FINISHED // done, node will be disabled +}; +``` + +Subclasses call `updatePlaybackInfo(currentTime, framesToProcess)` at the top of `processNode()` to transition the state machine and handle sample-accurate start/stop. + +When the node finishes, fire the `ENDED` event to JS via `audioEventHandlerRegistry_->invokeHandlerWithEventBody(AudioEvent::ENDED, {})`. + +--- + +## processNode() Signature + +```cpp +protected: + // Audio-thread only + virtual std::shared_ptr processNode( + const std::shared_ptr &processingBuffer, + int framesToProcess) = 0; +``` + +- `processingBuffer` — already contains the mixed input from all connected input nodes. Modify in-place and return it. +- `framesToProcess` — number of samples per channel to process, typically 128 (RENDER_QUANTUM_SIZE). +- Called by `AudioNode::processAudio()` which handles input mixing, channel count modes, and deduplication (via `lastRenderedFrame_`). + +--- + +## Thread Annotations in Header Files + +**Annotate every method with the thread it is safe to call from.** Use comments in the header: + +```cpp +class MyNode : public AudioNode { + public: + // JS-thread only + void setSomething(float value); + float getSomething() const; + + protected: + // Audio-thread only + std::shared_ptr processNode( + const std::shared_ptr &processingBuffer, + int framesToProcess) override; +}; +``` + +In `AudioParam.h` the pattern is: +```cpp +/// JS-Thread only methods +[[nodiscard]] inline float getValue() const noexcept { ... } +void setValue(float value); +void setValueAtTime(float value, double startTime); + +/// Audio-Thread only methods +std::shared_ptr processARateParam(int framesToProcess, double time); +float processKRateParam(int framesToProcess, double time); +``` + +--- + +## AudioParam — Automatable Parameters + +Every automatable property (frequency, gain, detune, Q, etc.) is an `AudioParam`. + +```cpp +gainParam_ = std::make_shared( + defaultValue, + minValue, + maxValue, + context +); +``` + +### A-rate vs K-rate + +- **A-rate (audio-rate)**: one value per sample — use when the parameter can change significantly within a render quantum (e.g. frequency modulation) + ```cpp + // Call processARateParam() for per-sample values — returns AudioBuffer, no allocation + auto gainValues = gainParam_->processARateParam(framesToProcess, time); + float *values = gainValues->getChannel(0)->getData(); + // values[i] is the gain for frame i + ``` + +- **K-rate (control-rate)**: one value per render quantum — use when the parameter changes slowly + ```cpp + // Call processKRateParam() for a single block-wide value + float gain = gainParam_->processKRateParam(framesToProcess, time); + // Single value for the whole block + ``` + +### JS → Audio Thread parameter updates + +`CrossThreadEventScheduler` is a lock-free SPSC channel. When JS calls `param.setValueAtTime(...)`, it enqueues a lambda on the scheduler. The audio thread drains the queue at the start of each `processARateParam` / `processKRateParam` call. + +```cpp +// JS-thread (in AudioParam): +void AudioParam::setValueAtTime(float value, double startTime) { + eventScheduler_.scheduleEvent([value, startTime](AudioParam ¶m) { + param.eventsQueue_.insertEvent(...); + }); +} + +// Audio-thread (inside processARateParam): +eventScheduler_.processAllEvents(*this); // drain all pending events +``` + +**Important**: HostObject setters forward to the node/param asynchronously through this scheduler. By the time `processNode()` runs, the queued update may or may not have been applied yet, depending on timing. Design accordingly — never assume immediate consistency. + +--- + +## Cross-Thread Communication Patterns + +### JS → Audio (parameter/graph updates) +Use `CrossThreadEventScheduler` (lock-free SPSC queue). See `utils/CrossThreadEventScheduler.hpp`. + +### Audio → JS (events like `ended`, `loopEnded`, `positionChanged`) +Use `IAudioEventHandlerRegistry::invokeHandlerWithEventBody()` which internally calls `callInvoker_->invokeAsync()` — this safely schedules the JS callback on the JS thread from the audio thread. + +```cpp +// Audio-thread: fire 'ended' event +audioEventHandlerRegistry_->invokeHandlerWithEventBody( + AudioEvent::ENDED, {}); +``` + +Callback IDs are stored as `std::atomic` on the node. `0` means no listener registered. + +### JS → Audio (graph mutations: connect/disconnect) +All graph mutations are queued via `AudioGraphManager` using its own SPSC channel (`addPendingNodeConnection`, `addPendingParamConnection`). The audio thread calls `graphManager_->preProcessGraph()` before each render pass to apply pending changes. + +--- + +## Implementing a New Node — Checklist + +1. **Subclass the right base** + - `AudioNode` — standard effect or analysis node + - `AudioScheduledSourceNode` — source with start/stop scheduling + - `AudioBufferBaseSourceNode` — source that plays back an AudioBuffer with pitch control + +2. **Header file** (`core//MyNode.h`) + - Annotate every method with `// JS-thread only` or `// Audio-thread only` + - Declare `processNode()` in `protected:` + - Declare `AudioParam` members for automatable properties + - Preallocate all buffers you'll need in `private:` state + +3. **Constructor** (runs on JS thread) + - Call `AudioNode(context, options)` base constructor with correct `numberOfInputs`, `numberOfOutputs` + - Create all `AudioParam` instances with correct default/min/max values from the Web Audio spec + - Preallocate any DSP state buffers (IIR delay lines, ring buffers, etc.) + - Do NOT call `context_->...` in `processNode()` for anything that could block + +4. **processNode()** (runs on audio thread) + - Call `context_.lock()` to get a `shared_ptr` — return early if null + - Call `context->getCurrentTime()` for automation timing + - Use `processARateParam()` or `processKRateParam()` to read param values + - Process samples in-place on `processingBuffer` + - No allocations, no locks, no blocking I/O + +5. **HostObject** (see the `host-objects` skill) + - Create `MyNodeHostObject` extending `AudioNodeHostObject` + - Add factory method to `BaseAudioContextHostObject` (`createMyNode`) + - Add factory method to `BaseAudioContext` C++ class + +6. **TypeScript API** (see the `turbo-modules` skill) + - Add TS class in `src/core/` + - Export from package index + +7. **Spec compliance** + - Check the Web Audio API spec for default values, parameter ranges, and behavior + - See `web-audio-api.md` skill + +8. **Tests and docs** — see the `flow` skill + +See [full GainNode example](gainnode-example.md) for a complete header + .cpp reference implementation. + +--- + +## Web Audio API Spec Reference + +All node behavior (parameter names, default values, valid ranges, processing semantics) must match the spec: +- https://webaudio.github.io/web-audio-api/ + +Key spec-defined constraints already encoded in the codebase: +- `AudioParam` min/max values come from spec tables +- `GainNode.gain` default = 1.0, no clamping +- `BiquadFilterNode.frequency` default = 350 Hz, range [Nyquist - epsilon, Nyquist] +- `OscillatorNode.frequency` default = 440 Hz +- Render quantum = 128 frames + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/audio-nodes/gainnode-example.md b/.claude/skills/audio-nodes/gainnode-example.md new file mode 100644 index 000000000..a9656d79a --- /dev/null +++ b/.claude/skills/audio-nodes/gainnode-example.md @@ -0,0 +1,71 @@ +# Reference: Complete GainNode C++ Example + +This file contains the full GainNode header and implementation, extracted from `SKILL.md` to keep the main skill file under budget. + +## `GainNode.h` + +```cpp +#pragma once +#include "audioapi/core/AudioNode.h" +#include "audioapi/core/AudioParam.h" + +namespace audioapi { + +class GainNode : public AudioNode { + public: + // JS-thread only + explicit GainNode( + const std::shared_ptr &context, + const GainOptions &options); + + // JS-thread only + [[nodiscard]] std::shared_ptr getGainParam() const; + + protected: + // Audio-thread only + std::shared_ptr processNode( + const std::shared_ptr &processingBuffer, + int framesToProcess) override; + + private: + std::shared_ptr gainParam_; +}; + +} // namespace audioapi +``` + +## `GainNode.cpp` + +```cpp +GainNode::GainNode( + const std::shared_ptr &context, + const GainOptions &options) + : AudioNode(context, options) { + // Preallocate param — constructor is on JS thread, allocation OK + gainParam_ = std::make_shared( + options.gain, + -3.4028234663852886e+38f, + 3.4028234663852886e+38f, + context); +} + +std::shared_ptr GainNode::processNode( + const std::shared_ptr &processingBuffer, + int framesToProcess) { + std::shared_ptr context = context_.lock(); + if (!context) return processingBuffer; + + double time = context->getCurrentTime(); + + // A-rate: per-sample gain values — no allocation, reuses preallocated buffer + auto gainParamValues = gainParam_->processARateParam(framesToProcess, time); + auto gainValues = gainParamValues->getChannel(0); + + for (size_t i = 0; i < processingBuffer->getNumberOfChannels(); i++) { + processingBuffer->getChannel(i)->multiply(*gainValues, framesToProcess); + } + + return processingBuffer; +} +``` + diff --git a/.claude/skills/audio-nodes/maintenance.md b/.claude/skills/audio-nodes/maintenance.md new file mode 100644 index 000000000..cfa29910e --- /dev/null +++ b/.claude/skills/audio-nodes/maintenance.md @@ -0,0 +1,16 @@ +# Maintenance — audio-nodes + +> Used by `/pre-push-update` only — not loaded when the `audio-nodes` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `common/cpp/audioapi/core/AudioNode.*` | Base class contract, `processNode()` signature, `processAudio()` graph traversal | +| `common/cpp/audioapi/core/AudioParam.*` | A-rate / k-rate section, automation method list | +| `common/cpp/audioapi/core/sources/AudioScheduledSourceNode.*` | Playback state machine, `updatePlaybackInfo` contract | +| `common/cpp/audioapi/core/` | Add new node to class hierarchy diagram and directory tree | +| `common/cpp/audioapi/core/effects/GainNode.h` | `gainnode-example.md` — constructor signature, AudioParam declarations, thread annotations | +| `common/cpp/audioapi/core/effects/GainNode.cpp` | `gainnode-example.md` — `processNode()` body, AudioParam initialization | + +Update the **class hierarchy diagram** in `SKILL.md` when a node is added, removed, or changes its base class. diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md new file mode 100644 index 000000000..62a0e8963 --- /dev/null +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -0,0 +1,230 @@ +--- +name: build-compilation-dependencies +description: > + Build system overview for react-native-audio-api across all platforms — CMakeLists.txt, + android/build.gradle, RNAudioAPI.podspec, prebuilt external libraries, and the standalone + C++ test build. Use this skill when adding a new source file, modifying CMakeLists or podspec, + debugging compilation errors, integrating a new dependency, or understanding why includes work + differently in tests vs the app. Trigger phrases: "add source file", "CMakeLists", "podspec", + "build.gradle", "prebuilt binaries", "FFmpeg disabled", "pod install", "new architecture", + "compile error", "undefined symbol", "SIMD", "worklets build flag", "C++ tests", "conditional + compilation", "include path", "gradle build fails", "link error". +--- + +# Skill: Build, Compilation & Dependencies + +For deep CMake/Gradle/podspec analysis see [build-details.md](build-details.md). + +--- + +## Repository Build Overview + +``` +react-native-audio-api/ +├── package.json # Yarn 4 workspaces root +├── packages/react-native-audio-api/ # Main library +│ ├── android/ +│ │ ├── build.gradle # Android build config (Gradle) +│ │ ├── CMakeLists.txt # Android CMake root (delegates to subdirectory) +│ │ └── src/main/cpp/audioapi/ +│ │ └── CMakeLists.txt # Actual Android C++ build target +│ ├── common/cpp/audioapi/ # Shared C++ (used by all platforms) +│ │ └── external/ # Prebuilt binaries per platform +│ │ ├── android/ # .a static libs (Opus, Ogg, Vorbis, OpenSSL) +│ │ ├── iphoneos/ # iOS device .a libs +│ │ ├── iphonesimulator/ # iOS sim .a libs +│ │ ├── macosx/ # macOS .a libs +│ │ ├── ffmpeg_ios/ # FFmpeg .xcframeworks (iOS only) +│ │ ├── include/ # Headers for Opus/Ogg/Vorbis/OpenSSL +│ │ └── include_ffmpeg/ # Headers for FFmpeg +│ ├── common/cpp/test/ +│ │ ├── CMakeLists.txt # Standalone test build (no Android/iOS) +│ │ ├── RunTests.sh # Test runner script +│ │ └── src/ # Google Test files +│ ├── RNAudioAPI.podspec # CocoaPods spec for iOS +│ └── scripts/ +│ ├── download-prebuilt-binaries.sh # Downloads externals from GitHub Releases +│ ├── rnaa_utils.rb # Ruby helpers for podspec (path resolution, worklets check) +│ └── validate-worklets-version.js +└── apps/ + └── fabric-example/ + └── ios/ + └── Podfile # Consumer Podfile (new arch enabled) +``` + +--- + +## Prebuilt Binaries + +External libraries (Opus, Ogg, Vorbis, OpenSSL, FFmpeg) are **not compiled from source** — they are downloaded as prebuilt `.a` / `.so` / `.xcframework` archives from: + +``` +https://github.com/software-mansion-labs/rn-audio-libs/releases/download// +``` + +Current tag: **v3.0.0** (see `scripts/download-prebuilt-binaries.sh`). + +The download script is triggered automatically: +- **iOS**: by podspec `prepare_command` during `pod install` +- **Android**: by `downloadPrebuiltBinaries` Gradle task, which runs before `preBuild` + +Downloaded artifacts land in: +- `common/cpp/audioapi/external/android//` — `.a` static libs for Android ABIs +- `android/src/main/jniLibs//` — FFmpeg `.so` shared libs for Android (loaded at runtime) +- `common/cpp/audioapi/external/ffmpeg_ios/` — FFmpeg `.xcframework` files for iOS +- `common/cpp/audioapi/external/iphoneos/` / `iphonesimulator/` / `macosx/` — Opus/Ogg/etc `.a` + +**These directories are gitignored.** If they're missing, run `pod install` (iOS) or Gradle build (Android) to re-download them. Do not commit them. + +--- + +## Android Build — high-level summary + +### Files +- `android/build.gradle` — Gradle library config +- `android/CMakeLists.txt` — Android CMake root (SIMD detection, RN version flags, delegates to subdirectory) +- `android/src/main/cpp/audioapi/CMakeLists.txt` — actual build target (sources, prebuilt libs, include paths) + +### Key behaviors +- Feature flags (`newArchEnabled`, `disableAudioapiFFmpeg`) are read from app's `gradle.properties` and forwarded to both CMake and Kotlin `BuildConfig` +- DSP sources always compiled with `-O3` regardless of overall build type +- Sources gathered with `GLOB_RECURSE CONFIGURE_DEPENDS` — CMake re-runs automatically when files are added/removed +- Worklets must be merged before the audio API CMake build starts (explicit Gradle task dependency) +- 16KB page size alignment enabled for Android 15+ + +For full per-line analysis see [build-details.md](build-details.md#android-androidcmakeliststxt-root--detailed-analysis). + +--- + +## iOS Build (CocoaPods) — high-level summary + +### Files +- `RNAudioAPI.podspec` — library spec with subspecs +- `scripts/rnaa_utils.rb` — Ruby helpers called by podspec +- `apps/fabric-example/ios/Podfile` — consumer + +### Key behaviors +- Four subspecs split compilation: `audioapi` (core C++), `audioapi/ios` (ObjC++), `audioapi/audioapi_dsp` (DSP with `-O3`), `audioapi/miniaudio_impl` (compiled as `-x objective-c++`) +- Static prebuilt libs linked with `-force_load` to prevent dead-stripping +- FFmpeg xcframeworks listed in `s.ios.vendored_frameworks` — CocoaPods handles embedding and signing +- `Accelerate` framework linked, enabling `HAVE_ACCELERATE=1` for vDSP SIMD on iOS +- Header search paths split: `pod_target_xcconfig` (library compilation) vs `xcconfig` (app consumers) +- `rnaa_utils.rb` resolves dynamic paths at `pod install` time (not hardcoded) + +For full per-line analysis see [build-details.md](build-details.md#ios-rnaudioapipodspec--detailed-analysis). + +--- + +## Building the Apps + +### iOS (fabric-example) + +```bash +# From the monorepo root first: +yarn install + +# Then install pods — must be done from the ios/ directory: +cd apps/fabric-example/ios +pod install + +# Run the app (from repo root): +yarn workspace fabric-example ios +# or open Xcode: +open apps/fabric-example/ios/FabricExample.xcworkspace +``` + +**When to re-run `pod install`**: +- After `yarn install` (any dependency change) +- After changing `RNAudioAPI.podspec` +- After adding/removing iOS source files that need to be picked up +- After changing `rnaa_utils.rb` or `scripts/validate-worklets-version.js` +- When prebuilt binaries need to be re-downloaded (podspec `prepare_command` runs on `pod install`) + +**Disable FFmpeg on iOS**: +```bash +DISABLE_AUDIOAPI_FFMPEG=1 pod install +``` + +### Android (fabric-example) + +```bash +yarn workspace fabric-example android +# or open in Android Studio: +open apps/fabric-example/android +``` + +**Disable FFmpeg on Android**: set in `android/gradle.properties`: +``` +disableAudioapiFFmpeg=true +``` + +**Clean CMake cache** (fixes most mysterious native build failures): +```bash +yarn workspace react-native-audio-api clean # or manually: +rm -rf packages/react-native-audio-api/android/.cxx +``` + +--- + +## C++ Tests (standalone build) + +### Location +`packages/react-native-audio-api/common/cpp/test/` + +### How to run +```bash +yarn test # from monorepo root — runs RunTests.sh +``` + +`RunTests.sh` does: +```bash +cd packages/react-native-audio-api/common/cpp/test +cmake -S . -B build -Wno-dev +cd build && make -j10 +./tests --gtest_print_time=1 +``` + +The `build/` directory is deleted after each run. + +### Key design decisions +- Completely standalone — no Gradle, no Xcode, no prebuilt Android libraries needed +- Sources resolved from `node_modules` (symlinked to `packages/` in yarn workspaces) +- HostObjects, worklets nodes, AudioContext, and FFmpegDecoding are excluded from the test build +- Compile definitions: `RN_AUDIO_API_ENABLE_WORKLETS=0`, `RN_AUDIO_API_TEST=1`, `RN_AUDIO_API_FFMPEG_DISABLED=1` +- Google Test auto-fetched via `FetchContent` if not installed locally +- New test files in `test/src/**/*.cpp` are picked up automatically by glob — no CMakeLists edit needed + +For `MockAudioEventHandlerRegistry`, `TestableXxx` pattern, and full CMakeLists analysis see [build-details.md](build-details.md#c-test-build--commoncpptestcmakeliststxt--detailed-analysis). + +--- + +## Conditional Compilation Flags Summary + +| Flag | Android (CMake) | iOS (podspec) | Tests | +|---|---|---|---| +| `RN_AUDIO_API_FFMPEG_DISABLED` | `-DRN_AUDIO_API_FFMPEG_DISABLED` | `-DRN_AUDIO_API_FFMPEG_DISABLED=1` | Always set to 1 | +| `RN_AUDIO_API_ENABLE_WORKLETS` | `-DRN_AUDIO_API_ENABLE_WORKLETS=1/0` | `-DRN_AUDIO_API_ENABLE_WORKLETS=1` | Always set to 0 | +| `RCT_NEW_ARCH_ENABLED` | `-DRCT_NEW_ARCH_ENABLED` | `-DRCT_NEW_ARCH_ENABLED` | Not set | +| `HAVE_ARM_NEON_INTRINSICS` | Set by CMake SIMD detection | Set by Xcode/Clang for arm64 | Set by CMake SIMD detection | +| `HAVE_X86_SSE2` | Set by CMake SIMD detection | Not used | Set by CMake SIMD detection | +| `HAVE_ACCELERATE` | Not set | `GCC_PREPROCESSOR_DEFINITIONS` | Not set | +| `RN_AUDIO_API_TEST` | Not set | Not set | Always set to 1 | + +--- + +## Common Build Failure Patterns + +| Symptom | Likely cause | Fix | +|---|---|---| +| `file not found: libopus.a` | Prebuilt binaries not downloaded | Run `pod install` (iOS) or Gradle build (triggers download task) | +| `No such module 'RNAudioAPI'` | Pod not installed | `cd apps/fabric-example/ios && pod install` | +| `undefined symbol: av_*` | FFmpeg .so not in jniLibs | Build triggers download; verify `disableAudioapiFFmpeg` not set unexpectedly | +| CMake error on clean build | Stale `.cxx` cache | `rm -rf packages/react-native-audio-api/android/.cxx` | +| Test build: `Cannot open include file: audioapi/...` | Node modules not linked | `yarn install` from root, then re-run tests | +| New `.cpp` not compiled in tests | Glob picks it up automatically — may need cmake reconfigure | Delete `test/build/` and re-run | +| iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | +| `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/build-compilation-dependencies/build-details.md b/.claude/skills/build-compilation-dependencies/build-details.md new file mode 100644 index 000000000..970e99d92 --- /dev/null +++ b/.claude/skills/build-compilation-dependencies/build-details.md @@ -0,0 +1,313 @@ +# Build Details — CMake, Gradle & Podspec Deep Reference + +> This file contains the detailed per-platform build system analysis for `CMakeLists.txt`, `android/build.gradle`, `RNAudioAPI.podspec`, and the C++ test build. +> +> For the high-level overview, prebuilt binaries, building apps, conditional flags, and common failures, see [SKILL.md](SKILL.md). + +--- + +## Android: `build.gradle` — detailed analysis + +### Feature flags (set by app's `gradle.properties`) + +```groovy +def isNewArchitectureEnabled() { + return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +} +def isFFmpegDisabled() { + return rootProject.hasProperty("disableAudioapiFFmpeg") && rootProject.getProperty("disableAudioapiFFmpeg") == "true" +} +``` + +### Forwarding flags to CMake + +```groovy +"-DRN_AUDIO_API_FFMPEG_DISABLED=${IS_RN_AUDIO_API_FFMPEG_DISABLED}" +"-DRN_AUDIO_API_WORKLETS_ENABLED=${isWorkletsAvailable}" +"-DIS_NEW_ARCHITECTURE_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}" +``` + +### Forwarding flags to Kotlin via BuildConfig + +```groovy +buildConfigField "boolean", "RN_AUDIO_API_FFMPEG_DISABLED", isFFmpegDisabled().toString() +buildConfigField "boolean", "RN_AUDIO_API_ENABLE_WORKLETS", "${isWorkletsAvailable}" +``` + +### 16KB page size alignment (Android 15+) + +```groovy +packagingOptions { + jniLibs { useLegacyPackaging = false } +} +``` + +### Worklets dependency ordering + +Worklets must be merged before the audio API's CMake build starts. Gradle task dependency is wired explicitly: + +```groovy +tasks.getByName("buildCMakeDebug").dependsOn(rnWorkletsProject.tasks.getByName("mergeDebugNativeLibs")) +``` + +### Minimum RN version enforcement + +Enforced in Gradle task `assertMinimalReactNativeVersionTask` (currently RN 76+). + +--- + +## Android: `android/CMakeLists.txt` (root) — detailed analysis + +Thin delegator. Sets up SIMD detection, applies Folly + React Native flags, then: + +```cmake +add_subdirectory("${ANDROID_CPP_DIR}/audioapi") +``` + +### SIMD detection (affects DSP performance) + +```cmake +if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") + set(HAVE_ARM_NEON_INTRINSICS TRUE) +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + set(HAVE_X86_SSE2 TRUE) +endif() +``` + +### RN version-dependent flags (workaround for RN 0.80+ flag changes) + +```cmake +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 80) + include("${REACT_NATIVE_DIR}/ReactCommon/cmake-utils/react-native-flags.cmake") + target_compile_reactnative_options(react-native-audio-api PRIVATE) +else() + string(APPEND CMAKE_CXX_FLAGS " -fexceptions -frtti ...") +endif() +``` + +--- + +## Android: `android/src/main/cpp/audioapi/CMakeLists.txt` — detailed analysis + +Builds `react-native-audio-api` as a shared library. + +### Source gathering + +Uses `GLOB_RECURSE CONFIGURE_DEPENDS` — CMake will re-run automatically when files are added/removed: + +```cmake +file(GLOB_RECURSE COMMON_CPP_SOURCES CONFIGURE_DEPENDS "${COMMON_CPP_DIR}/audioapi/*.cpp") +``` + +### FFmpeg conditional exclusion + +```cmake +if(RN_AUDIO_API_FFMPEG_DISABLED) + list(REMOVE_ITEM COMMON_CPP_SOURCES + "${COMMON_CPP_DIR}/audioapi/libs/ffmpeg/FFmpegDecoding.cpp" + ) +endif() +``` + +### DSP sources get `-O3` (always, regardless of overall build type) + +```cmake +set_source_files_properties(${DSP_CPP_SOURCES} PROPERTIES COMPILE_FLAGS "-O3") +``` + +### Static prebuilt libs imported via `IMPORTED_LOCATION` + +```cmake +foreach(lib IN ITEMS opus opusfile ogg vorbis vorbisenc vorbisfile crypto ssl) + add_library(${lib} STATIC IMPORTED) + set_target_properties(${lib} PROPERTIES IMPORTED_LOCATION ${EXTERNAL_DIR}/${ANDROID_ABI}/lib${lib}.a) +endforeach() +``` + +### FFmpeg shared libs (`.so`) in `jniLibs/` linked as shared IMPORTED + +```cmake +add_library(${lib} SHARED IMPORTED) +set_target_properties(${lib} PROPERTIES IMPORTED_LOCATION ${JNI_LIBS_DIR}/${ANDROID_ABI}/lib${lib}.so) +``` + +### Include paths for Android (key paths) + +``` +common/cpp — for #include +android/src/main/cpp — for Android-specific headers +external/include — Opus/Ogg/Vorbis/OpenSSL headers +external/include/opus — nested Opus headers +external/include_ffmpeg — FFmpeg headers +ReactCommon — JSI headers +ReactAndroid/src/main/jni/... — TurboModule JNI headers +``` + +--- + +## iOS: `RNAudioAPI.podspec` — detailed analysis + +### Subspecs split compilation + +| Subspec | Sources | Special | +|---|---|---| +| `audioapi` | `common/cpp/audioapi/**/*.{cpp,h,hpp}` | Excludes FFmpeg sources if disabled | +| `audioapi/ios` | `ios/audioapi/**/*.{mm,h}` | ObjC++ platform layer | +| `audioapi/audioapi_dsp` | `common/cpp/audioapi/dsp/**/*.cpp` | Compiled with `-O3` | +| `audioapi/miniaudio_impl` | `utils/MiniaudioImplementation.cpp` | Compiled as `-x objective-c++` (required by miniaudio) | + +### `miniaudio_impl` workaround + +miniaudio's implementation file must be compiled as Objective-C++ on iOS (it uses Apple APIs). CocoaPods can't set per-file compiler flags the same way CMake can, so it gets its own subspec with `compiler_flags = "-x objective-c++"`. + +### Prebuilt binaries linked via `-force_load` + +All static libs (Opus, Ogg, Vorbis, OpenSSL) use `-force_load` to prevent dead-stripping of symbols needed at runtime but not referenced at link time: + +```ruby +s.xcconfig = { + 'OTHER_LDFLAGS' => %W[ + -force_load #{lib_dir}/libopus.a + -force_load #{lib_dir}/libogg.a + ... + ].join(" ") +} +``` + +### FFmpeg xcframeworks listed in `s.ios.vendored_frameworks` + +CocoaPods handles embedding and signing automatically: + +```ruby +s.ios.vendored_frameworks = $RN_AUDIO_API_FFMPEG_DISABLED ? [] : [ + 'common/cpp/audioapi/external/ffmpeg_ios/libavcodec.xcframework', + ... +] +``` + +### Header search paths split between pod target and consumers + +- `pod_target_xcconfig` — `HEADER_SEARCH_PATHS` for compiling the library itself (Boost, Folly, Yoga, external headers) +- `xcconfig` — `HEADER_SEARCH_PATHS` for app targets consuming this pod (ReactCommon, dynamic framework dirs) + +### `rnaa_utils.rb` — dynamic path resolution at `pod install` time + +Resolves three paths that vary per machine/monorepo layout: + +1. `react_native_common_dir` — ReactCommon path relative to Pods root +2. `dynamic_frameworks_audio_api_dir` — this pod's dir relative to Pods root +3. `dynamic_frameworks_worklets_dir` — worklets pod dir (only if enabled) + +These vary per machine/monorepo layout, so they're computed at install time rather than hardcoded. + +### System frameworks linked + +```ruby +s.ios.frameworks = 'Accelerate', 'AVFoundation', 'MediaPlayer' +``` + +`Accelerate` enables `HAVE_ACCELERATE=1` for vDSP SIMD on iOS. + +### Podfile (fabric-example) + +Enables New Architecture: `ENV['RCT_NEW_ARCH_ENABLED'] = '1'` at the top. This is the only example app using the new arch — always test new features here. + +The podfile resolves the minimum iOS version from RN's `min_ios_version_supported` helper — do not hardcode `14.0` in the Podfile itself. + +--- + +## C++ Test Build — `common/cpp/test/CMakeLists.txt` — detailed analysis + +### Design: completely standalone + +No Gradle, no Xcode, no prebuilt Android libraries needed. + +### Sources resolved from `node_modules` (not from monorepo source directly) + +```cmake +set(ROOT ${CMAKE_SOURCE_DIR}/../../../../..) +set(REACT_NATIVE_AUDIO_API_DIR "${ROOT}/node_modules/react-native-audio-api") + +file(GLOB_RECURSE RNAUDIOAPI_SRC CONFIGURE_DEPENDS + "${REACT_NATIVE_AUDIO_API_DIR}/common/cpp/audioapi/*.cpp" +) +``` + +This means **tests build against the installed/published version of the library** in `node_modules`, not the local `packages/` source directly. In a monorepo with yarn workspaces, `node_modules/react-native-audio-api` symlinks to `packages/react-native-audio-api`, so this works fine locally. + +### Excluded from tests (not relevant for unit testing) + +```cmake +list(FILTER RNAUDIOAPI_SRC EXCLUDE REGEX ".*/audioapi/HostObjects/.*\\.cpp$") # no JSI in tests +list(FILTER RNAUDIOAPI_SRC EXCLUDE REGEX ".*/Worklet.*Node\\.cpp$") # worklets not compiled +list(REMOVE_ITEM RNAUDIOAPI_SRC ... "AudioContext.cpp") # needs real audio I/O +list(REMOVE_ITEM RNAUDIOAPI_SRC ... "FFmpegDecoding.cpp") # FFmpeg disabled in tests +``` + +### Compile definitions always set in tests + +```cmake +add_compile_definitions(RN_AUDIO_API_ENABLE_WORKLETS=0) +add_compile_definitions(RN_AUDIO_API_TEST=1) +add_compile_definitions(RN_AUDIO_API_FFMPEG_DISABLED=1) +``` + +Use `RN_AUDIO_API_TEST` in source code to conditionally compile test-only hooks. + +### Google Test auto-fetched if not installed + +```cmake +find_package(GTest QUIET) +if(NOT GTest_FOUND) + include(FetchContent) + FetchContent_Declare(googletest URL https://github.com/google/googletest/archive/.zip) + FetchContent_MakeAvailable(googletest) +endif() +``` + +### JSI headers included for compilation + +JSI headers are included from `node_modules/react-native/ReactCommon/jsi` so core audio classes that depend on JSI types can compile even without a running JS engine. + +### `MockAudioEventHandlerRegistry` — test fixture boilerplate + +`test/src/MockAudioEventHandlerRegistry.h` provides a Google Mock implementation of `IAudioEventHandlerRegistry`. Pass it to `OfflineAudioContext` in every test fixture: + +```cpp +class MyNodeTest : public ::testing::Test { + protected: + std::shared_ptr eventRegistry; + std::shared_ptr context; + + void SetUp() override { + eventRegistry = std::make_shared(); + context = std::make_shared( + 2, 5 * sampleRate, sampleRate, eventRegistry, RuntimeRegistry{}); + context->initialize(); + } +}; +``` + +### `TestableXxx` pattern — white-box unit testing + +Expose `processNode()` and internal state for white-box unit testing (since `processNode` is `protected`): + +```cpp +class TestableGainNode : public GainNode { + public: + explicit TestableGainNode(std::shared_ptr ctx) + : GainNode(ctx, GainOptions()) {} + + std::shared_ptr processNode( + const std::shared_ptr &buf, int frames) override { + return GainNode::processNode(buf, frames); // call through to real implementation + } +}; +``` + +### Adding a new test file + +1. Create `test/src/core//MyNodeTest.cpp` +2. `GLOB_RECURSE test_src "src/*.cpp"` picks it up automatically — no CMakeLists edit needed. +3. Verify `context->initialize()` is called in `SetUp()` if your node needs the context to be running. + diff --git a/.claude/skills/build-compilation-dependencies/maintenance.md b/.claude/skills/build-compilation-dependencies/maintenance.md new file mode 100644 index 000000000..cf6787859 --- /dev/null +++ b/.claude/skills/build-compilation-dependencies/maintenance.md @@ -0,0 +1,16 @@ +# Maintenance — build-compilation-dependencies + +> Used by `/pre-push-update` only — not loaded when the `build-compilation-dependencies` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `android/CMakeLists.txt` | SIMD detection, RN version flag workaround | +| `android/src/main/cpp/audioapi/CMakeLists.txt` | Source glob patterns, FFmpeg exclusion, DSP `-O3` targets, IMPORTED lib list, include paths | +| `android/build.gradle` | Feature flag detection, CMake arg forwarding, BuildConfig fields, worklets task dependency, packaging options | +| `RNAudioAPI.podspec` | Subspecs table, `miniaudio_impl` workaround, `-force_load` list, xcframeworks list, `rnaa_utils.rb` dynamic paths | +| `apps/fabric-example/ios/Podfile` | New Architecture enablement, minimum iOS version helper | +| `common/cpp/test/CMakeLists.txt` | Excluded sources list, compile definitions, GoogleTest fetch URL, include paths | +| `common/cpp/test/src/MockAudioEventHandlerRegistry.h` | Mock interface — update fixture boilerplate in `build-details.md` if signature changes | +| `scripts/download-prebuilt-binaries.sh` | New download artifacts, new TAG version | diff --git a/.claude/skills/flow/SKILL.md b/.claude/skills/flow/SKILL.md new file mode 100644 index 000000000..e6aa59ad9 --- /dev/null +++ b/.claude/skills/flow/SKILL.md @@ -0,0 +1,286 @@ +--- +name: flow +description: > + End-to-end process for shipping a feature or bug fix in react-native-audio-api. Covers all required deliverables in order: Web Audio API spec review, TypeScript interface and types, C++ AudioNode implementation, HostObject wiring, TypeScript class, TurboModule spec (when needed), C++ tests (Google Test), JS tests (Jest), documentation (audiodocs MDX), and post-work checks. Also covers the bug-fix flow: MRE first, C++ test when applicable, root-cause analysis, post-mortem. Use this skill at the start of any feature implementation or bug fix. Trigger phrases: "implement a feature", "add a node", "fix a bug", "what steps", "where do I start", "PR checklist", "how to write tests". +--- + +# Skill: Feature Implementation Flow + +## Quick Reference + +**Feature checklist (all 9 steps required for a PR):** +1. Read spec → define exactly what you're building +2. TypeScript interface + option types (`src/interfaces/`, `src/types/`) +3. C++ AudioNode (`core//MyNode.h/.cpp`) — see `audio-nodes` skill +4. HostObject (`HostObjects/MyNodeHostObject.h/.cpp`) — see `host-objects` skill +5. TypeScript class (`src/core/MyNode.ts`) +6. TurboModule spec entry — **only** if a new RN native method is needed +7. C++ tests (Google Test) — **write tests first, confirm they fail, then implement** +8. JS/TS tests (Jest) +9. Docs page (`packages/audiodocs/`) +Then: `post-work-checks` skill + +**Bug fix checklist:** +1. Reproduce (write failing test or MRE first) +2. Identify the layer (use the table in §Bug Fix Flow) +3. Fix + verify tests pass +4. Post-mortem (update skill if a new pitfall discovered) + +--- + +## Feature Implementation Flow + +### 1 — Define exactly what you are building + +Before touching any code. **If spec behavior is unclear → fetch https://webaudio.github.io/web-audio-api/ before proceeding.** + +- Read the Web Audio API spec section for the node/feature. Nail down: + - All properties and methods + - Parameter default values, min/max, and automation behaviour + - Edge cases (what happens when `start()` is called twice? what if feedback[0] === 0?) +- Decide the **TypeScript interface shape**: what does the JS consumer see? +- Identify if **native platform code** is needed (e.g. new permission, device routing, new Oboe/CoreAudio API surface). If yes, plan iOS + Android separately. + +--- + +### 2 — TypeScript interface and types + +Files: `packages/react-native-audio-api/src/` + +1. Add the interface in `src/interfaces/` — `IMyNode.ts` + ```ts + export interface IMyNode extends IAudioNode { + readonly myParam: IAudioParam; + someMethod(arg: number): void; + } + ``` + +2. Add the options type in `src/types/` — `NodeOptions.ts` (or add to existing file) + ```ts + export interface MyNodeOptions extends AudioNodeOptions { + myParam?: number; // default: 1.0 + } + ``` + +3. Export both from `src/interfaces/index.ts` and `src/types/index.ts`. + +--- + +### 3 — C++ AudioNode + +Files: `packages/react-native-audio-api/common/cpp/audioapi/core/` + +See the `audio-nodes` skill for the full contract. **If unsure which base class to use → check the class hierarchy in that skill.** Summary: + +1. Create `core//MyNode.h` and `MyNode.cpp`. +2. Subclass the right base (`AudioNode`, `AudioScheduledSourceNode`, `AudioBufferBaseSourceNode`). +3. Annotate every method with `// JS-thread only` or `// Audio-thread only`. +4. Declare `processNode()` in `protected:` — audio thread. +5. Preallocate all `AudioParam`s and scratch buffers in the constructor (JS thread). +6. Add `createMyNode(const MyNodeOptions &options)` factory to `BaseAudioContext`. +7. Add the `.cpp` file to `CMakeLists.txt` (check existing entries for the pattern). + +**No allocations, no locks, no blocking I/O in `processNode()`.** + +--- + +### 4 — HostObject + +Files: `packages/react-native-audio-api/common/cpp/audioapi/HostObjects/` + +See the `host-objects` skill for the full macro system and shadow state patterns. **If unsure whether to use shadow state or atomics → check the decision table in that skill.** Summary: + +1. Create `HostObjects/MyNodeHostObject.h` and `MyNodeHostObject.cpp`. +2. Extend `AudioNodeHostObject`. +3. Expose properties with `JSI_PROPERTY_GETTER/SETTER_DECL/IMPL` + `JSI_EXPORT_PROPERTY_GETTER/SETTER`. +4. Expose methods with `JSI_HOST_FUNCTION_DECL/IMPL` + `JSI_EXPORT_FUNCTION`. +5. Use **shadow state** (JS-thread copy + `scheduleAudioEvent`) for any property that: is read back by JS, is written from JS, AND must be applied on the audio thread. See `host-objects.md` for the decision table. +6. Wire factory in `BaseAudioContextHostObject`: add `createMyNode` host function that calls `context->createMyNode(options)` and wraps in `MyNodeHostObject`. +7. Add to `CMakeLists.txt`. + +--- + +### 5 — TypeScript class + +Files: `packages/react-native-audio-api/src/core/` + +1. Create `src/core/MyNode.ts` — extends `AudioNode`, wraps the JSI HostObject. + ```ts + import { IMyNode } from '../interfaces'; + import AudioNode from './AudioNode'; + import BaseAudioContext from './BaseAudioContext'; + import { MyNodeOptions } from '../types'; + + export default class MyNode extends AudioNode implements IMyNode { + constructor(context: BaseAudioContext, options: MyNodeOptions) { + const node = context.context.createMyNode(options); + super(context, node); + } + // getters/setters forwarded to (this.node as IMyNode) + } + ``` + +2. Add a factory method `createMyNode(options?)` to `src/core/BaseAudioContext.ts`. + +3. Export from `src/index.ts`. + +4. Add web passthrough in `src/web-core/MyNode.ts` if the node has a browser equivalent, or add a stub/throw if not supported on web. The web system always delegates to the browser's `AudioContext`. + +--- + +### 6 — TurboModule spec (only if new native entry point needed) + +File: `src/specs/NativeAudioAPIModule.ts` + +Only needed if the feature requires a *new* RN native method (e.g. a permission check, a new audio session configuration). Most audio nodes do **not** need this — they are exposed entirely through JSI HostObjects. + +If needed: add the method signature to the spec, then implement on iOS (`AudioAPIModule.mm`) and Android (`AudioAPIModule.kt`). + +--- + +### 7 — Tests + +#### C++ tests (Google Test) + +Path: `common/cpp/test/src/core//MyNodeTest.cpp` + +Pattern: +```cpp +#include +#include +#include +#include +#include +using namespace audioapi; + +class MyNodeTest : public ::testing::Test { + protected: + std::shared_ptr eventRegistry; + std::shared_ptr context; + static constexpr int sampleRate = 44100; + + void SetUp() override { + eventRegistry = std::make_shared(); + context = std::make_shared( + 2, 5 * sampleRate, sampleRate, eventRegistry, RuntimeRegistry{}); + context->initialize(); + } +}; + +// Use TestableXxx subclass to expose processNode() and internal state for white-box tests +class TestableMyNode : public MyNode { + public: + explicit TestableMyNode(std::shared_ptr ctx) + : MyNode(ctx, MyNodeOptions()) {} + std::shared_ptr processNode( + const std::shared_ptr &buf, int frames) override { + return MyNode::processNode(buf, frames); + } +}; + +TEST_F(MyNodeTest, CanBeCreated) { ... } +TEST_F(MyNodeTest, ProcessesAudioCorrectly) { ... } +TEST_F(MyNodeTest, EdgeCase) { ... } +``` + +New test files are picked up automatically by `GLOB_RECURSE` — no CMakeLists edit needed. + +Run with: +```bash +yarn test # from repo root +``` + +#### JS/TS tests (Jest) + +Path: `packages/react-native-audio-api/tests/` + +- `integration.test.ts` — graph construction, node creation, property access through the mock API +- `mock.test.ts` — mock implementation correctness + +Run with: +```bash +yarn workspace react-native-audio-api test +``` + +#### Example app (fabric-example) + +If the feature has a visible or audible component, add a demo in `apps/fabric-example/App.tsx` (or a sub-screen). This acts as a manual smoke test and documents intended usage. + +--- + +### 8 — Documentation + +Path: `packages/audiodocs/` + +Every **public API** that ships must have a documentation page. The docs package uses MDX. Look at an existing node's page for the format. Cover: + +- Constructor / factory call +- All properties and their types/ranges +- All methods with parameter descriptions +- A minimal usage example (TypeScript snippet) +- Any spec deviations or limitations (e.g. "offline rendering not supported") + +--- + +### 9 — Post-work checks + +See the `post-work-checks` skill. At minimum before any PR: + +```bash +yarn format # auto-fix formatting +yarn lint # JS/TS/C++/Kotlin linting +yarn typecheck # TypeScript +yarn test # C++ Google Tests +yarn check-audio-enum-sync # if you touched AudioEvent enums +``` + +--- + +## Bug Fix Flow + +### 1 — Reproduce (MRE first) + +- **If an MRE is provided** → good, use it as your starting point. +- **If no MRE** → reproduce in `apps/fabric-example/App.tsx`. Add the minimal code that triggers the bug. Keep it in the app until the fix is verified, then remove or clean it up. +- **If the bug is in C++ DSP/processing logic** → write a **failing C++ test first** (`common/cpp/test/`). This gives a fast edit-compile-run loop without needing a device, and the test stays as a permanent regression guard. + +--- + +### 2 — Identify the layer + +| Symptom | Likely layer | Where to look | +|---|---|---| +| Wrong default value / validation error | TypeScript | `src/core/MyNode.ts`, `src/types/` | +| Property read-back returns wrong value | HostObject shadow state | `HostObjects/MyNodeHostObject` | +| Wrong argument parsed from JS | HostObject | argument parsing in `get`/`call` | +| Audio output sounds wrong | C++ `processNode()` | `core//MyNode.cpp` | +| DSP math wrong | DSP helpers | `dsp/` utilities | +| Crash on audio thread | Thread safety violation | `cross-thread` patterns — `audio-nodes.md` | +| Platform-specific (only iOS or only Android) | Native layer | `ios/` or `android/` | +| Only in new RN architecture | TurboModule / JSI wiring | `src/specs/`, HostObject init | + +--- + +### 3 — Fix and verify + +1. Make the fix. +2. If a C++ test was written in step 1 → it must now pass. +3. If no C++ test was written but the fix touches `processNode()` or DSP code → add one now. It will stay as a regression test. +4. Run post-work checks (see above). + +--- + +### 4 — Post-mortem (short, but do it) + +After the fix is working, spend 2 minutes asking: + +- **Why did this bug exist?** Was it a missing validation? A wrong default value? A thread-safety assumption that wasn't documented? +- **Should a skill file be updated?** If the bug revealed a non-obvious invariant (e.g. "feedback[0] must not be 0"), add it to the relevant skill (`audio-nodes.md`, `host-objects.md`, etc.) under a pitfalls or constraints section. +- **Should a new test be added?** If no test existed that would have caught this, add one. +- **Should docs be updated?** If the spec says X but the implementation did Y, and the fix brings it in line — update the docs page. + +These small post-mortems compound over time and prevent the same class of bug from recurring. + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/flow/maintenance.md b/.claude/skills/flow/maintenance.md new file mode 100644 index 000000000..96ed120ed --- /dev/null +++ b/.claude/skills/flow/maintenance.md @@ -0,0 +1,14 @@ +# Maintenance — flow + +> Used by `/pre-push-update` only — not loaded when the `flow` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `common/cpp/test/CMakeLists.txt` | Test build section — new exclusions, new defines | +| `packages/react-native-audio-api/tests/` | JS test section — new test patterns | +| `packages/audiodocs/` | Docs section — new documentation conventions | +| `package.json` scripts | Post-work checks step — new or renamed commands | + +This skill describes **process** — update it when the team's workflow changes, not for every new node added. diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md new file mode 100644 index 000000000..994230a73 --- /dev/null +++ b/.claude/skills/host-objects/SKILL.md @@ -0,0 +1,457 @@ +--- +name: host-objects +description: > + Covers how to create, structure, and maintain JSI HostObjects that bridge C++ audio nodes to + JavaScript in react-native-audio-api. Explains naming conventions, property/method exposure via + macros, shadow state for JS↔audio-thread communication, JSI argument parsing, return value + patterns, memory pressure, factory wiring in BaseAudioContextHostObject, and common pitfalls. + Use this skill when creating a new audio node HostObject, modifying existing HostObject get/set/call + logic, wiring a new node into the context factory, or debugging JSI-related crashes and type errors. + Trigger phrases: "add HostObject", "create JSI bridge", "expose C++ node to JS", "shadow state", + "scheduleAudioEvent from setter", "JSI property getter", "HostObject crashes", "new audio node". +--- + +# Skill: HostObjects + +Scope: C++ JSI HostObject layer — `packages/react-native-audio-api/common/cpp/audioapi/HostObjects/` + +HostObjects are the middle layer between the TypeScript API and the C++ audio engine. They expose C++ audio node state and methods to JavaScript via JSI (no bridge serialization), and route state changes to the audio thread via a lock-free SPSC event queue. + +Golden references: `GainNodeHostObject.h/.cpp` (effect node), `OscillatorNodeHostObject.h/.cpp` (source node). Mirror their structure for any new HostObject. See [full examples](examples.md) for annotated implementations. + +--- + +## Critical Pitfalls — Read Before Writing Any Code + +- **NEVER read from `node_` in a getter** if the property can be written by the audio thread. Use shadow state or atomics instead. +- **NEVER call `node_->someMethod()` directly from a setter** — always schedule via `scheduleAudioEvent`. The audio thread may be mid-render. +- **ALWAYS register getters/setters/functions in the constructor.** Anything not added to `addGetters`/`addSetters`/`addFunctions` is silently missing from JS. +- **Match property names exactly.** The string in `JSI_EXPORT_PROPERTY_GETTER` becomes the JS property name. A typo means the property doesn't exist in JS. +- **Clear callback IDs in the destructor** for any HO that registers audio events. Otherwise the audio thread fires into a destroyed JS function. +- **Call `setExternalMemoryPressure`** when returning HOs or typed arrays backed by large native buffers. +- **Shadow state must be initialized** from `options` in the constructor — JS may read a property before ever setting it. + +--- + +## Three-Layer Architecture + +Every audio node has three layers. HostObject is the middle one: + +```mermaid +flowchart TD + TS["TypeScript class\n(src/core/)"] + HO["HostObject\n(C++ — JsiHostObject subclass)"] + AN["AudioNode\n(C++ core — audio thread)"] + + TS <--> |"JSI — direct memory, no serialization"| HO + HO <--> |"shared_ptr\nscheduleAudioEvent → SPSC"| AN +``` + +There is **no strong typing** between the C++ HostObject and the TypeScript interface. Alignment is by convention — property names and function signatures must match manually. + +--- + +## Directory Structure + +``` +HostObjects/ +├── AudioNodeHostObject.h/.cpp # Base for all audio node HOs +├── AudioParamHostObject.h/.cpp # AudioParam wrapper +├── BaseAudioContextHostObject.h/.cpp # Factory — all createXxx() methods live here +├── AudioContextHostObject.h/.cpp # Realtime context (adds close/resume/suspend) +├── OfflineAudioContextHostObject.h/.cpp +├── analysis/ +│ └── AnalyserNodeHostObject.h/.cpp +├── destinations/ +│ └── AudioDestinationNodeHostObject.h +├── effects/ +│ ├── GainNodeHostObject.h/.cpp +│ ├── BiquadFilterNodeHostObject.h/.cpp +│ ├── DelayNodeHostObject.h/.cpp +│ ├── IIRFilterNodeHostObject.h/.cpp +│ ├── StereoPannerNodeHostObject.h/.cpp +│ ├── WaveShaperNodeHostObject.h/.cpp +│ ├── ConvolverNodeHostObject.h/.cpp +│ ├── WorkletNodeHostObject.h/.cpp +│ └── WorkletProcessingNodeHostObject.h/.cpp +├── sources/ +│ ├── AudioScheduledSourceNodeHostObject.h/.cpp # Base for timed sources +│ ├── AudioBufferBaseSourceNodeHostObject.h/.cpp # Base for buffer sources +│ ├── OscillatorNodeHostObject.h/.cpp +│ ├── AudioBufferSourceNodeHostObject.h/.cpp +│ ├── AudioBufferQueueSourceNodeHostObject.h/.cpp +│ ├── ConstantSourceNodeHostObject.h/.cpp +│ ├── StreamerNodeHostObject.h/.cpp +│ ├── AudioBufferHostObject.h/.cpp # Data container, not a node +│ └── RecorderAdapterNodeHostObject.h/.cpp +├── inputs/ +│ └── AudioRecorderHostObject.h/.cpp +├── events/ +│ └── AudioEventHandlerRegistryHostObject.h/.cpp +└── utils/ + ├── JsEnumParser.h/.cpp # Enum ↔ string conversions + ├── NodeOptionsParser.h # Parses JS option objects into C++ structs + ├── AudioDecoderHostObject.h/.cpp + └── AudioStretcherHostObject.h/.cpp +``` + +--- + +## Macro System + +All HostObjects use macros defined in `jsi/JsiHostObject.h`. Always use these — never write raw JSI dispatch code. + +### Declaration macros (in .h) + +```cpp +JSI_PROPERTY_GETTER_DECL(gain) // jsi::Value gain(jsi::Runtime &runtime) +JSI_PROPERTY_SETTER_DECL(gain) // void gain(jsi::Runtime &runtime, const jsi::Value &value) +JSI_HOST_FUNCTION_DECL(setValueAtTime) // jsi::Value setValueAtTime(jsi::Runtime &, const jsi::Value &, const jsi::Value *, size_t) +``` + +### Implementation macros (in .cpp) + +```cpp +JSI_PROPERTY_GETTER_IMPL(GainNodeHostObject, gain) { ... } +JSI_PROPERTY_SETTER_IMPL(GainNodeHostObject, gain) { ... } +JSI_HOST_FUNCTION_IMPL(GainNodeHostObject, setValueAtTime) { ... } +``` + +### Registration macros (in constructor) + +```cpp +addGetters( + JSI_EXPORT_PROPERTY_GETTER(GainNodeHostObject, gain)); +addSetters( + JSI_EXPORT_PROPERTY_SETTER(GainNodeHostObject, fftSize)); +addFunctions( + JSI_EXPORT_FUNCTION(GainNodeHostObject, connect), + JSI_EXPORT_FUNCTION(GainNodeHostObject, disconnect)); +``` + +**All getters, setters, and functions must be registered in the constructor.** Anything not registered is invisible to JS. + +--- + +## Shadow State + +Shadow state is the core pattern for JS↔audio-thread communication (introduced in PR #942). + +### The Problem + +The audio node's C++ state is read and written on the **audio thread**. JS runs on a different thread. Without shadow state, reading a property from JS would require either a lock (forbidden on the audio thread) or an atomic (only works for primitives). + +### The Solution + +The HostObject maintains its own **copy** of the node's properties — the shadow state. This copy: +- Is read/written **only by the JS thread** +- Is always in sync with what JS last set +- Is the source of truth for JS reads + +When JS sets a property: +1. Update the shadow copy immediately (JS thread) +2. Schedule an event on `CrossThreadEventScheduler` that will apply the change on the audio thread + +When JS reads a property: +1. Return the shadow copy — **do not touch the C++ node** + +```cpp +// Header — shadow state declared as private member +class OscillatorNodeHostObject : public AudioScheduledSourceNodeHostObject { + public: + JSI_PROPERTY_GETTER_DECL(type); + JSI_PROPERTY_SETTER_DECL(type); + private: + OscillatorType type_; // shadow copy of node_->type_ +}; + +// Getter — returns shadow, never touches audio thread +JSI_PROPERTY_GETTER_IMPL(OscillatorNodeHostObject, type) { + return jsi::String::createFromUtf8( + runtime, js_enum_parser::oscillatorTypeToString(type_)); +} + +// Setter — updates shadow + schedules audio thread update +JSI_PROPERTY_SETTER_IMPL(OscillatorNodeHostObject, type) { + auto oscillatorNode = std::static_pointer_cast(node_); + auto type = js_enum_parser::oscillatorTypeFromString( + value.asString(runtime).utf8(runtime)); + + // 1. Update shadow state (JS thread, immediate) + type_ = type; + + // 2. Schedule audio thread update (lock-free SPSC) + auto event = [oscillatorNode, type](BaseAudioContext &) { + oscillatorNode->setType(type); + }; + oscillatorNode->scheduleAudioEvent(std::move(event)); +} +``` + +### When NOT to use shadow state + +| Scenario | Pattern | +|---|---| +| Primitive, only written by JS | Shadow state (standard) | +| Non-primitive, only written by JS | Store in TS layer, pass to AudioNode when needed | +| Primitive, can be written by audio thread | `std::atomic` on the C++ node; read directly via getter | +| Non-primitive, can be written by audio thread | Triple buffer pattern (see `AnalyserNode` for reference) | + +### AudioParam is a special case + +`AudioParam::value_` is `std::atomic` because it can be updated by the audio thread during automation. The HO reads it directly: + +```cpp +JSI_PROPERTY_GETTER_IMPL(AudioParamHostObject, value) { + return {param_->getValue()}; // atomic read, no shadow needed +} + +JSI_PROPERTY_SETTER_IMPL(AudioParamHostObject, value) { + auto event = [param = param_, v = static_cast(value.getNumber())](BaseAudioContext &) { + param->setValue(v); + }; + param_->scheduleAudioEvent(std::move(event)); +} +``` + +### Shadow state must be initialized in the constructor + +Initialize shadow members from `options` in the constructor — JS may read a property before ever setting it: + +```cpp +OscillatorNodeHostObject::OscillatorNodeHostObject(...) { + type_ = options.type; // Initialize shadow from options +} +``` + +--- + +## Argument Parsing + +### Primitives + +```cpp +float v = static_cast(args[0].getNumber()); +double d = args[0].getNumber(); +int i = static_cast(args[0].getNumber()); +bool b = args[0].getBool(); +std::string s = args[0].getString(runtime).utf8(runtime); +``` + +### Optional arguments — check count first + +```cpp +// args[2] is optional, default -1 +double duration = (count > 2 && !args[2].isUndefined()) ? args[2].getNumber() : -1.0; +``` + +Use `jsiutils::argToString(runtime, args, count, index, defaultValue)` for optional string args. + +### TypedArrays (Float32Array, Uint8Array, etc.) + +JS typed arrays are passed as objects with a `.buffer` property: + +```cpp +JSI_HOST_FUNCTION_IMPL(AnalyserNodeHostObject, getByteFrequencyData) { + auto arrayBuffer = args[0] + .getObject(runtime) + .getPropertyAsObject(runtime, "buffer") + .getArrayBuffer(runtime); + auto data = arrayBuffer.data(runtime); + auto length = static_cast(arrayBuffer.size(runtime)); + + auto analyserNode = std::static_pointer_cast(node_); + analyserNode->getByteFrequencyData(data, length); + return jsi::Value::undefined(); +} +``` + +For Float32Arrays (reinterpret the bytes): + +```cpp +auto rawValues = reinterpret_cast(arrayBuffer.data(runtime)); +auto length = static_cast(arrayBuffer.size(runtime) / sizeof(float)); +``` + +### HostObject arguments (node-to-node) + +```cpp +JSI_HOST_FUNCTION_IMPL(AudioNodeHostObject, connect) { + auto obj = args[0].getObject(runtime); + if (obj.isHostObject(runtime)) { + auto other = obj.getHostObject(runtime); + node_->connect(other->node_); + } else if (obj.isHostObject(runtime)) { + auto param = obj.getHostObject(runtime); + node_->connect(param->param_); + } + return jsi::Value::undefined(); +} +``` + +### Extracting a HostObject's inner C++ object + +```cpp +auto periodicWave = args[0] + .getObject(runtime) + .getHostObject(runtime); +oscillatorNode->setPeriodicWave(periodicWave->periodicWave_); +``` + +--- + +## Return Value Patterns + +### Primitives + +```cpp +return {fftSize_}; // int/float/double +return {true}; // bool +return jsi::String::createFromUtf8(runtime, "suspended"); // string +return jsi::Value::undefined(); // void +return jsi::Value::null(); // null +``` + +### A HostObject + +```cpp +JSI_PROPERTY_GETTER_IMPL(GainNodeHostObject, gain) { + auto gainNode = std::static_pointer_cast(node_); + auto gainParam = std::make_shared(gainNode->getGainParam()); + return jsi::Object::createFromHostObject(runtime, gainParam); +} +``` + +### A plain JS object + +```cpp +auto result = jsi::Object(runtime); +result.setProperty(runtime, "status", jsi::String::createFromUtf8(runtime, "success")); +result.setProperty(runtime, "path", jsi::String::createFromUtf8(runtime, path)); +return result; +``` + +### A Float32Array wrapping native memory + +```cpp +JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { + auto channel = static_cast(args[0].getNumber()); + auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel); + auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); + + auto float32ArrayCtor = runtime.global() + .getPropertyAsFunction(runtime, "Float32Array"); + auto float32Array = float32ArrayCtor + .callAsConstructor(runtime, arrayBuffer) + .getObject(runtime); + + float32Array.setExternalMemoryPressure(runtime, audioArrayBuffer->size()); + return float32Array; +} +``` + +### External memory pressure + +Call `setExternalMemoryPressure` whenever returning a HostObject or typed array that wraps a large native buffer. This lets the JS GC schedule collection correctly: + +```cpp +jsiObject.setExternalMemoryPressure(runtime, bufferHostObject->getSizeInBytes()); +``` + +--- + +## Enum Parsing + +Use `JsEnumParser` (`utils/JsEnumParser.h`) for all enum ↔ string conversions. Never hardcode strings. + +```cpp +// String → enum +auto type = js_enum_parser::oscillatorTypeFromString( + value.asString(runtime).utf8(runtime)); + +// Enum → string +return jsi::String::createFromUtf8( + runtime, js_enum_parser::oscillatorTypeToString(type_)); +``` + +When adding a new enum, add both directions to `JsEnumParser`. + +--- + +## Destructor: Clearing Callbacks + +When a HostObject is garbage collected, registered audio callbacks must be cleared. Otherwise the audio thread fires into a destroyed JS function. + +```cpp +AudioScheduledSourceNodeHostObject::~AudioScheduledSourceNodeHostObject() { + auto node = std::static_pointer_cast(node_); + node->setOnEndedCallbackId(0); // 0 = no listener +} +``` + +Apply this for every `std::atomic` callback ID on the node. + +--- + +## TypeScript Counterpart + +Each HO must have a matching TS interface and class in `packages/react-native-audio-api/src/core/`. + +```ts +// Interface — mirrors C++ HO properties exactly (src/core/interfaces/) +export interface IGainNode extends IAudioNode { + readonly gain: IAudioParam; +} + +// TS class — wraps the C++ HO (src/core/) +class GainNode extends AudioNode { + readonly gain: AudioParam; + + constructor(context: BaseAudioContext, options?: TGainOptions) { + // context.context is the C++ BaseAudioContextHostObject + const gainNode: IGainNode = context.context.createGain(options ?? {}); + super(context, gainNode); + this.gain = new AudioParam(gainNode.gain, context); + } +} +``` + +See the `turbo-modules` skill for full TS wiring details. + +--- + +## Adding to BaseAudioContextHostObject + +Every new node needs a factory method. Three steps: + +### 1. Declare in `BaseAudioContextHostObject.h` + +```cpp +JSI_HOST_FUNCTION_DECL(createMyNode); +``` + +### 2. Register in `BaseAudioContextHostObject` constructor + +```cpp +addFunctions( + // ... existing entries + JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createMyNode)); +``` + +### 3. Implement in `BaseAudioContextHostObject.cpp` + +```cpp +JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createMyNode) { + MyNodeOptions options = NodeOptionsParser::parseMyNodeOptions(runtime, args, count); + auto myNode = std::make_shared(context_, options); + return jsi::Object::createFromHostObject(runtime, myNode); +} +``` + +Also add `createMyNode()` to the C++ `BaseAudioContext` factory — see the `audio-nodes` skill. + +--- + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/host-objects/examples.md b/.claude/skills/host-objects/examples.md new file mode 100644 index 000000000..ac2e5779d --- /dev/null +++ b/.claude/skills/host-objects/examples.md @@ -0,0 +1,146 @@ +# HostObject Examples + +## GainNodeHostObject (no shadow state) + +`gain` is an `AudioParam` — no shadow state needed because its `value_` is `std::atomic`. + +### `effects/GainNodeHostObject.h` + +```cpp +#pragma once +#include "audioapi/HostObjects/AudioNodeHostObject.h" +#include "audioapi/HostObjects/AudioParamHostObject.h" +#include "audioapi/core/effects/GainNode.h" + +namespace audioapi { + +class GainNodeHostObject : public AudioNodeHostObject { + public: + explicit GainNodeHostObject( + const std::shared_ptr &context, + const GainOptions &options); + + // JS-thread only + JSI_PROPERTY_GETTER_DECL(gain); +}; + +} // namespace audioapi +``` + +### `effects/GainNodeHostObject.cpp` + +```cpp +GainNodeHostObject::GainNodeHostObject( + const std::shared_ptr &context, + const GainOptions &options) + : AudioNodeHostObject(context->createGain(options)) { + addGetters( + JSI_EXPORT_PROPERTY_GETTER(GainNodeHostObject, gain)); +} + +JSI_PROPERTY_GETTER_IMPL(GainNodeHostObject, gain) { + auto gainNode = std::static_pointer_cast(node_); + auto gainParam = std::make_shared(gainNode->getGainParam()); + return jsi::Object::createFromHostObject(runtime, gainParam); +} +``` + +--- + +## OscillatorNodeHostObject (with shadow state) + +`type` is a plain enum — not atomic — so it uses shadow state. `AudioParam` wrappers for `frequency` and `detune` are preallocated in the constructor and returned directly (no shadow needed since the underlying `value_` is atomic on the node). + +### `sources/OscillatorNodeHostObject.h` + +```cpp +#pragma once +#include "audioapi/HostObjects/sources/AudioScheduledSourceNodeHostObject.h" +#include "audioapi/core/sources/OscillatorNode.h" + +namespace audioapi { + +class OscillatorNodeHostObject : public AudioScheduledSourceNodeHostObject { + public: + explicit OscillatorNodeHostObject( + const std::shared_ptr &context, + const OscillatorOptions &options); + + // JS-thread only + JSI_PROPERTY_GETTER_DECL(frequency); + JSI_PROPERTY_GETTER_DECL(detune); + JSI_PROPERTY_GETTER_DECL(type); + JSI_PROPERTY_SETTER_DECL(type); + JSI_HOST_FUNCTION_DECL(setPeriodicWave); + + private: + // Shadow state — JS thread only + OscillatorType type_; + // AudioParam wrappers — not shadow state, wrap atomic state on the node + std::shared_ptr frequencyParam_; + std::shared_ptr detuneParam_; +}; + +} // namespace audioapi +``` + +### `sources/OscillatorNodeHostObject.cpp` + +```cpp +OscillatorNodeHostObject::OscillatorNodeHostObject( + const std::shared_ptr &context, + const OscillatorOptions &options) + : AudioScheduledSourceNodeHostObject(context->createOscillator(options)) { + auto oscillatorNode = std::static_pointer_cast(node_); + + // Initialize shadow from options + type_ = options.type; + + // Preallocate AudioParam wrappers + frequencyParam_ = std::make_shared(oscillatorNode->getFrequencyParam()); + detuneParam_ = std::make_shared(oscillatorNode->getDetuneParam()); + + addGetters( + JSI_EXPORT_PROPERTY_GETTER(OscillatorNodeHostObject, frequency), + JSI_EXPORT_PROPERTY_GETTER(OscillatorNodeHostObject, detune), + JSI_EXPORT_PROPERTY_GETTER(OscillatorNodeHostObject, type)); + addSetters( + JSI_EXPORT_PROPERTY_SETTER(OscillatorNodeHostObject, type)); + addFunctions( + JSI_EXPORT_FUNCTION(OscillatorNodeHostObject, setPeriodicWave)); +} + +JSI_PROPERTY_GETTER_IMPL(OscillatorNodeHostObject, frequency) { + return jsi::Object::createFromHostObject(runtime, frequencyParam_); +} + +JSI_PROPERTY_GETTER_IMPL(OscillatorNodeHostObject, type) { + // Return shadow — never read from node_ + return jsi::String::createFromUtf8( + runtime, js_enum_parser::oscillatorTypeToString(type_)); +} + +JSI_PROPERTY_SETTER_IMPL(OscillatorNodeHostObject, type) { + auto oscillatorNode = std::static_pointer_cast(node_); + auto type = js_enum_parser::oscillatorTypeFromString( + value.asString(runtime).utf8(runtime)); + + // 1. Shadow update — immediate, JS thread + type_ = type; + + // 2. Audio thread update — async, lock-free + auto event = [oscillatorNode, type](BaseAudioContext &) { + oscillatorNode->setType(type); + }; + oscillatorNode->scheduleAudioEvent(std::move(event)); +} + +JSI_HOST_FUNCTION_IMPL(OscillatorNodeHostObject, setPeriodicWave) { + auto oscillatorNode = std::static_pointer_cast(node_); + auto periodicWave = args[0].getObject(runtime) + .getHostObject(runtime); + oscillatorNode->setPeriodicWave(periodicWave->periodicWave_); + return jsi::Value::undefined(); +} +``` + diff --git a/.claude/skills/host-objects/maintenance.md b/.claude/skills/host-objects/maintenance.md new file mode 100644 index 000000000..c1dfa401e --- /dev/null +++ b/.claude/skills/host-objects/maintenance.md @@ -0,0 +1,16 @@ +# Maintenance — host-objects + +> Used by `/pre-push-update` only — not loaded when the `host-objects` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `common/cpp/audioapi/HostObjects/**` | New HostObjects, macro usage changes, shadow state patterns | +| `common/cpp/audioapi/jsi/JsiHostObject.*` | Macro signatures, new export macros — update both `SKILL.md` and `examples.md` | +| `common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.*` | Factory wiring section (3-step checklist) | +| Any new `*HostObject.h` | Add to directory structure, document any new patterns it introduces | +| `common/cpp/audioapi/HostObjects/effects/GainNodeHostObject.*` | `examples.md` — macro usage, AudioParam wrapping pattern | +| `common/cpp/audioapi/HostObjects/sources/OscillatorNodeHostObject.*` | `examples.md` — shadow state pattern, enum parsing | + +Update the **three-layer architecture diagram** in `SKILL.md` only if the communication mechanism between layers changes. diff --git a/.claude/skills/post-work-checks/SKILL.md b/.claude/skills/post-work-checks/SKILL.md new file mode 100644 index 000000000..1cbd7b674 --- /dev/null +++ b/.claude/skills/post-work-checks/SKILL.md @@ -0,0 +1,106 @@ +--- +name: post-work-checks +description: > + Ordered quality gate checklist to run after every code change in react-native-audio-api. + Covers formatting, linting, type checking, C++ tests, JS tests, and enum sync validation. + Documents what lefthook pre-commit hooks run automatically vs what must be run manually. + Use at the end of any implementation task before opening a PR. + Trigger phrases: "post-work", "before PR", "before commit", "check quality", "run linter", + "run tests", "format code", "lefthook", "pre-commit", "yarn test", "yarn lint". +--- + +# Skill: Post-Work Checks + +Run these checks after any code change and before opening a PR. + +--- + +## Quick Reference + +```bash +yarn format # auto-fix all formatting +yarn lint # lint all workspaces +yarn typecheck # TypeScript type checking +yarn test # C++ Google Tests +yarn check-audio-enum-sync # only if AudioEvent enum touched +``` + +--- + +## What lefthook Runs Automatically + +`lefthook` runs pre-commit hooks on every `git commit` — these run automatically: + +| Hook | Command | +|---|---| +| Format | `yarn format` (JS via Prettier, C++ via clang-format, Kotlin via ktlint) | +| Lint | `yarn lint` (JS/TS + C++ + Kotlin) | +| Type check | `yarn typecheck` | +| Commit message | `commitlint` | + +**If a hook fails, the commit is aborted.** Fix the issue and re-commit — do NOT use `--no-verify`. + +--- + +## What Must Be Run Manually + +These are NOT run by lefthook: + +### C++ tests + +```bash +yarn test # from monorepo root +``` + +Runs `packages/react-native-audio-api/common/cpp/test/RunTests.sh` via CMake + Google Test. + +**When**: after any change to `common/cpp/audioapi/core/`, `dsp/`, or `utils/` C++ files. + +### AudioEvent enum sync check + +```bash +yarn check-audio-enum-sync +``` + +**When**: only when you modify the `AudioEvent` enum or any file that maps event names across C++/Kotlin/TypeScript. + +--- + +## Per-Language Commands + +When you need to target a specific language: + +```bash +# TypeScript/JavaScript +yarn lint:js # ESLint +yarn format:js # Prettier + +# Shared C++ +yarn format:common # clang-format for common/cpp/ +yarn lint:cpp # cpplint + +# Android +yarn format:android:cpp # clang-format for android/src/main/cpp/ +yarn format:android:kotlin # KtLint +yarn lint:kotlin # Kotlin linter + +# iOS +yarn format:ios # clang-format for ios/ +yarn lint:ios # iOS Objective-C++ format checks +``` + +--- + +## Recommended Order + +Later steps may surface issues caused by earlier ones — run in this order: + +1. `yarn format` — fix formatting first (removes noise from lint) +2. `yarn lint` — catch remaining code issues +3. `yarn typecheck` — catch TypeScript errors +4. `yarn test` — catch C++ regressions (when C++ changed) +5. `yarn check-audio-enum-sync` — when `AudioEvent` enum was touched + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/post-work-checks/maintenance.md b/.claude/skills/post-work-checks/maintenance.md new file mode 100644 index 000000000..989c5d8b1 --- /dev/null +++ b/.claude/skills/post-work-checks/maintenance.md @@ -0,0 +1,12 @@ +# Maintenance — post-work-checks + +> Used by `/pre-push-update` only — not loaded when the `post-work-checks` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| Root `package.json` scripts | New or renamed lint/format/test commands | +| `packages/react-native-audio-api/package.json` scripts | Package-level command changes | +| `.lefthook.yml` or lefthook config | Pre-commit hook changes | +| `scripts/check-audio-enum-sync*` | Enum sync check details | diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md new file mode 100644 index 000000000..2d426ea6e --- /dev/null +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -0,0 +1,150 @@ +--- +name: thread-safety-itc +description: > + Audio thread safety rules, lock-free inter-thread communication patterns, and the audio event + system in react-native-audio-api. Covers the three-thread model (JS / audio / worker), + CrossThreadEventScheduler for JS→audio scheduling, IAudioEventHandlerRegistry for audio→JS events, + AudioGraphManager for graph mutations, shadow state vs atomics decision table, TaskOffloader for + off-thread work, and SpscChannel low-level API. Use when implementing cross-thread data flow, + adding audio events, debugging thread-safety crashes or data races, or deciding which ITC + primitive to use. + Trigger phrases: "lock-free", "SPSC", "thread safety", "ITC", "cross-thread", "audio thread race", + "scheduleAudioEvent", "invokeHandlerWithEventBody", "TaskOffloader", "off-thread", + "SpscChannel", "CrossThreadEventScheduler", "shadow state", "atomic". +--- + +# Skill: Thread Safety & Inter-Thread Communication + +Three threads interact in this codebase. Every line of code that crosses a thread boundary must use the correct primitive or it is a bug. + +**When in doubt about which ITC primitive to use → go to the Decision Table below first.** + +--- + +## The Three Threads + +| Thread | Alias | Runs | +|---|---|---| +| React Native JS thread | "JS thread" | User code, HostObject methods, `scheduleAudioEvent` calls | +| Audio thread | "audio thread" | `processNode()` — driven by Oboe (Android) / CoreAudio (iOS) | +| Worker threads | "off-thread" | FFmpeg decoding, file I/O, `TaskOffloader` tasks | + +**Audio thread is real-time.** It has a hard deadline (~3ms at 44100 Hz, 128 frames). Missing it causes audible glitches. + +--- + +## Audio Thread Contract + +`processNode()` **MUST NOT**: +- Allocate or free memory (`new`, `delete`, `malloc`, `free`, any `push_back` that grows) +- Acquire any mutex (`std::mutex`, `std::lock_guard`, `std::unique_lock`) +- Make blocking syscalls (file I/O, socket I/O, `sleep`, `wait`) +- Call into JavaScript (no JSI calls, no `callInvoker_->invokeSync()`) +- Throw exceptions + +**Preallocate everything in the constructor (JS thread).** The audio thread only uses what was already allocated. + +--- + +## JS → Audio: `CrossThreadEventScheduler` + +The standard way to send property updates from JS to the audio thread. + +```cpp +// JS thread (HostObject setter): +auto oscillatorNode = std::static_pointer_cast(node_); +auto event = [oscillatorNode, type](BaseAudioContext &) { + oscillatorNode->setType(type); // runs on audio thread +}; +oscillatorNode->scheduleAudioEvent(std::move(event)); +``` + +`scheduleAudioEvent()` is defined on `AudioNode`. It enqueues a lambda into the node's `CrossThreadEventScheduler`. The audio thread drains the queue at the start of each render cycle. + +**Never assume immediate consistency** — by the time the audio thread processes the event, several render quanta may have passed. + +--- + +## Audio → JS: `IAudioEventHandlerRegistry` + +Send events from the audio thread back to JS (e.g. `ended`, `loopEnded`, `positionChanged`). + +```cpp +// Audio thread: fire event with no payload +audioEventHandlerRegistry_->invokeHandlerWithEventBody(AudioEvent::ENDED, {}); + +// Audio thread: fire event with payload +audioEventHandlerRegistry_->invokeHandlerWithEventBody( + AudioEvent::POSITION_CHANGED, {{"position", currentPosition}}); +``` + +Internally calls `callInvoker_->invokeAsync()` — safe to call from the audio thread. + +### Callback ID pattern + +Callbacks are stored as `std::atomic` on the C++ node. `0` means no listener. + +```cpp +// C++ node header +std::atomic onEndedCallbackId_{0}; + +// HostObject destructor — MUST clear to prevent firing into a destroyed JS function +~AudioScheduledSourceNodeHostObject() { + auto node = std::static_pointer_cast(node_); + node->setOnEndedCallbackId(0); +} +``` + +**Always clear callback IDs in the HostObject destructor.** + +--- + +## Graph Mutations: `AudioGraphManager` + +Connect/disconnect operations queue via `AudioGraphManager` (its own internal SPSC channel). The audio thread calls `graphManager_->preProcessGraph()` before each render pass to apply pending changes. + +Do not call `AudioGraphManager` directly — go through `AudioNode::connect()` / `disconnect()`. + +--- + +## Decision Table + +| Scenario | Correct pattern | +|---|---| +| JS sets a property → audio thread reads it | Shadow state in HostObject + `scheduleAudioEvent` | +| Audio thread fires an event → JS callback | `invokeHandlerWithEventBody()` | +| JS connects/disconnects nodes | `AudioNode::connect()` → `AudioGraphManager` | +| Property written by audio thread, JS reads it | `std::atomic` on C++ node; getter reads directly | +| Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | +| CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | + +--- + +## Off-Thread Work: `TaskOffloader` + +For work that would block both the JS thread and the audio thread (decoding, file writing): + +```cpp +TaskOffloader offloader([](MyWorkItem item) { + // runs on dedicated worker thread — allocs OK, blocking I/O OK + item.process(); +}); +offloader.scheduleTask(std::move(workItem)); +``` + +See the `utilities` skill for full API. + +--- + +## Common Mistakes + +- **Reading `node_->field_` in a getter** when that field is written by the audio thread → use shadow state or atomics. +- **Calling `node_->method()` directly from a setter** → always schedule via `scheduleAudioEvent`. +- **Not clearing callback IDs in the HostObject destructor** → audio thread fires into destroyed JS function. +- **`std::vector::push_back` in `processNode()`** → may allocate; preallocate in constructor. +- **`std::mutex` anywhere in `processNode()`** → deadlock risk and real-time violation. +- **Copying `shared_ptr` inside `processNode()`** — increments atomic refcount; capture before entering hot path. + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/thread-safety-itc/maintenance.md b/.claude/skills/thread-safety-itc/maintenance.md new file mode 100644 index 000000000..b3b16dd31 --- /dev/null +++ b/.claude/skills/thread-safety-itc/maintenance.md @@ -0,0 +1,14 @@ +# Maintenance — thread-safety-itc + +> Used by `/pre-push-update` only — not loaded when the `thread-safety-itc` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `common/cpp/audioapi/events/**` | New event types, handler patterns, `AudioEvent` enum | +| `common/cpp/audioapi/utils/SpscChannel.hpp` | Lock-free queue API changes | +| `common/cpp/audioapi/utils/CrossThreadEventScheduler.hpp` | Scheduler API changes — update decision table | +| `common/cpp/audioapi/core/AudioNode.*` | Audio thread contract changes | +| `common/cpp/audioapi/core/utils/AudioGraphManager.*` | Graph mutation queue changes | +| Any new cross-thread primitive in `utils/` | Document in the decision table | diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md new file mode 100644 index 000000000..f81bb5024 --- /dev/null +++ b/.claude/skills/turbo-modules/SKILL.md @@ -0,0 +1,234 @@ +--- +name: turbo-modules +description: > + TurboModule spec, JSI binding injection, and platform native module entry points. Use when adding + a new native method to NativeAudioAPIModule.ts, wiring a new top-level HostObject through + AudioAPIModuleInstaller, debugging "native module not found" or "undefined is not a function" + for a global, or understanding the install() bootstrap flow on iOS and Android. + Trigger phrases: "native module not found", "install()", "NativeAudioAPIModule", + "injectJSIBindings", "AudioAPIModuleInstaller", "JSI global", "TurboModule spec", + "module bootstrap", "undefined is not a function". +--- + +# Skill: TurboModules & JSI Installation + +## When to Use TurboModule vs HostObjects + +**Rule**: if it needs `AVAudioSession`, `AudioManager`, `MediaSession`, or OS permissions → TurboModule. If it's audio processing → HostObject installed by `AudioAPIModuleInstaller`. + +| Use TurboModule method | Use HostObject property/method | +|---|---| +| Platform system APIs (audio session, permissions, notifications, device routing) | All audio graph operations (create/connect/disconnect nodes, AudioParam, scheduling) | +| One-time initialization (`install`) | Real-time playback control | +| Features that need native UI thread or system callbacks | Features that only need the JSI runtime | +| Android-only or iOS-only behavior | Cross-platform C++ engine behavior | + +Most audio nodes do **not** need a TurboModule method — they are exposed entirely through JSI HostObjects injected by `injectJSIBindings`. + +--- + +## Reference + +- RN TurboModules (new arch): https://reactnative.dev/docs/the-new-architecture/pure-cxx-modules +- RN JSI: https://reactnative.dev/docs/the-new-architecture/architecture-glossary#javascript-interface-jsi +- fbjni (Android JNI bridge): https://github.com/facebookincubator/fbjni + +--- + +## The Big Picture + +Most audio functionality is exposed **not** through TurboModule method calls but through **JSI HostObjects** placed directly on the JS global object. The TurboModule's job is narrow: + +1. Be available early via the standard RN module system +2. Provide `install()` — one synchronous call that injects HostObject factories onto `globalThis` +3. Expose platform-specific system APIs (audio session, permissions, notifications, devices) that cannot be done through pure JSI + +```mermaid +flowchart TD + A["JS: NativeAudioAPIModule.install()"] + + subgraph ios["iOS — AudioAPIModule.mm"] + B["init AudioSessionManager · AudioEngine\nget jsi::Runtime* · CallInvoker"] + end + + subgraph android["Android — AudioAPIModule.kt + .cpp"] + C["MediaSessionManager.initialize()\nJNI: injectJSIBindings()"] + end + + D["AudioAPIModuleInstaller::injectJSIBindings\n(runtime, callInvoker, eventRegistry, uiRuntime?)"] + + subgraph globals["globalThis — set by injectJSIBindings"] + G["createAudioContext\ncreateOfflineAudioContext\ncreateAudioRecorder\ncreateAudioBuffer\ncreateAudioDecoder\ncreateAudioStretcher\nAudioEventEmitter HostObject"] + end + + A --> ios + A --> android + ios --> D + android --> D + D --> globals +``` + +After `install()` returns, TypeScript code can call `globalThis.createAudioContext(sampleRate)` and get back a HostObject — no serialization, no bridge round-trip. + +--- + +## TurboModule Spec (`src/specs/NativeAudioAPIModule.ts`) + +Defines the TypeScript interface for the native module. Codegen (React Native's codegen) generates the native binding glue from this file. + +```ts +interface Spec extends TurboModule { + install(): boolean; // synchronous — MUST run first + getDevicePreferredSampleRate(): number; // synchronous + setAudioSessionActivity(enabled: boolean): Promise; + setAudioSessionOptions(...): void; + observeAudioInterruptions(focusType, enabled): void; + requestRecordingPermissions(): Promise; + // ... audio devices, notifications ... +} + +const NativeAudioAPIModule = TurboModuleRegistry.get('AudioAPIModule')!; +``` + +**`install()` is synchronous** (`RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD` on iOS, `override fun install(): Boolean` on Android). It must complete before the app tries to use any audio API. It is called once during module bootstrap in `src/AudioAPIModule/index.ts`. + +**Web mock** (`src/specs/NativeAudioAPIModule.web.ts`): on the web platform, the whole spec is mocked with no-ops and resolved promises (e.g. permissions always return `'Granted'`). This file replaces the native spec at bundle time for web builds. + +--- + +## `AudioAPIModuleInstaller` (`common/cpp/audioapi/AudioAPIModuleInstaller.h`) + +The single C++ class that owns JSI injection. It is `#include`-d by both iOS (`.mm`) and Android (`.cpp`) native modules, keeping the injection logic platform-agnostic. + +```cpp +class AudioAPIModuleInstaller { + public: + static void injectJSIBindings( + jsi::Runtime *jsiRuntime, + const std::shared_ptr &jsCallInvoker, + const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr uiRuntime = nullptr); +}; +``` + +Each `get*Function()` private method creates a `jsi::Function` via `jsi::Function::createFromHostFunction()`. The lambda captures the dependencies it needs (`jsCallInvoker`, `audioEventHandlerRegistry`, `uiRuntime`) by value (shared_ptr). + +**Worklets guard**: functions that need the worklets runtime (AudioContext, OfflineAudioContext) have an `#if RN_AUDIO_API_ENABLE_WORKLETS` branch: +```cpp +#if RN_AUDIO_API_ENABLE_WORKLETS + auto runtimeRegistry = RuntimeRegistry{.uiRuntime = uiRuntime}; + if (count > 1 && args[1].isObject()) { + runtimeRegistry.audioRuntime = worklets::extractWorkletRuntime(runtime, args[1]); + } +#else + auto runtimeRegistry = RuntimeRegistry{}; +#endif +``` + +**Adding a new top-level global**: add a `static jsi::Function getCreateXxxFunction(...)` private method and a `setProperty("createXxx", ...)` call in `injectJSIBindings`. This is only needed for objects that JS creates directly (not objects created as properties of another HostObject). + +--- + +## iOS Native Module (`AudioAPIModule.mm`) + +Uses ObjC++ with `RCT_EXPORT_MODULE` and `RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD`. + +**Key steps in `install`**: +1. Allocate platform managers (`AudioSessionManager`, `AudioEngine`, `SystemNotificationManager`, `NotificationRegistry`) +2. Get `jsi::Runtime *` from the bridge: `reinterpret_cast(self.bridge.runtime)` +3. Get `CallInvoker` — different paths for old Bridge vs New Architecture: + ```objc + #if defined(RCT_NEW_ARCH_ENABLED) + auto jsCallInvoker = _callInvoker.callInvoker; + #else + auto jsCallInvoker = self.bridge.jsCallInvoker; + #endif + ``` +4. Create `AudioEventHandlerRegistry` (owns JS callbacks, needs runtime + callInvoker) +5. Call `AudioAPIModuleInstaller::injectJSIBindings(...)` + +**New Architecture support** (`getTurboModule`): +```objc +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif +``` +This connects the ObjC implementation to the codegen-generated C++ TurboModule spec. + +**`methodQueue`**: the module runs on a dedicated serial queue (`com.swmansion.audioapi.MainModuleQueue`), not the main thread. + +**`invokeHandlerWithEventName:eventBody:`**: called from Objective-C system callbacks (audio session interruption, volume change, etc.) to fire events back into JS. It converts `NSDictionary` to `std::unordered_map` and calls `audioEventHandlerRegistry_->invokeHandlerWithEventBody(...)`. + +--- + +## Android Native Module (`AudioAPIModule.kt` + `AudioAPIModule.cpp`) + +Android uses **fbjni HybridObject** — a pattern where a Kotlin class holds a C++ peer via `HybridData`. The Kotlin class handles the Java/Kotlin TurboModule protocol; the C++ peer owns the JSI runtime pointer and call invoker. + +### Initialization flow + +``` +AudioAPIModule.kt (init block) + ├── System.loadLibrary("react-native-audio-api") // load .so + ├── get CallInvokerHolderImpl from reactContext + ├── get WorkletsModule if worklets enabled + └── mHybridData = initHybrid(workletsModule, jsContext, callInvokerHolder) + │ + ▼ + AudioAPIModule.cpp::initHybrid (JNI) + ├── unwrap jsCallInvoker from holder + ├── reinterpret_cast(jsContext) + ├── [if worklets] get WorkletsModuleProxy + └── makeCxxInstance(...) → creates C++ AudioAPIModule peer + └── stores: jsiRuntime_, jsCallInvoker_, audioEventHandlerRegistry_ +``` + +``` +AudioAPIModule.kt::install() + ├── MediaSessionManager.initialize(...) + ├── NativeFileInfo.initialize(...) + └── injectJSIBindings() // external fun → JNI call + │ + ▼ + AudioAPIModule.cpp::injectJSIBindings() + └── AudioAPIModuleInstaller::injectJSIBindings(...) +``` + +**`external fun`**: Kotlin keyword for JNI-implemented methods. These are registered in `AudioAPIModule.cpp::registerNatives()`: +```cpp +void AudioAPIModule::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", AudioAPIModule::initHybrid), + makeNativeMethod("injectJSIBindings", AudioAPIModule::injectJSIBindings), + makeNativeMethod("invokeHandlerWithEventNameAndEventBody", ...), + }); +} +``` + +`registerNatives()` is called from `android/src/main/cpp/audioapi/android/OnLoad.cpp` at `.so` load time. + +**`invokeHandlerWithEventNameAndEventBody`**: called from Kotlin (MediaSessionManager callbacks) to fire events into JS. Takes a Java `Map` and converts to `std::unordered_map`. + +--- + +## Two-Architecture Paths Summary + +| | Old Bridge | New Architecture (TurboModules/Fabric) | +|---|---|---| +| iOS CallInvoker source | `self.bridge.jsCallInvoker` | `_callInvoker.callInvoker` | +| iOS runtime source | `self.bridge.runtime` (cast) | same | +| iOS codegen | `RCT_EXPORT_MODULE` only | `+ getTurboModule:` implemented | +| Android CallInvoker | `reactContext.jsCallInvokerHolder` | same | +| Android codegen | `NativeAudioAPIModuleSpec` base class | same (generated) | + +The JSI injection itself (`AudioAPIModuleInstaller`) is identical for both architectures — it only needs the `jsi::Runtime *` and `CallInvoker`, which are obtained differently but behave the same. + +--- + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/turbo-modules/maintenance.md b/.claude/skills/turbo-modules/maintenance.md new file mode 100644 index 000000000..2695f035b --- /dev/null +++ b/.claude/skills/turbo-modules/maintenance.md @@ -0,0 +1,15 @@ +# Maintenance — turbo-modules + +> Used by `/pre-push-update` only — not loaded when the `turbo-modules` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `src/specs/NativeAudioAPIModule.ts` | Spec interface — new methods, changed signatures | +| `common/cpp/audioapi/AudioAPIModuleInstaller.h` | New globals injected, new HostObjects installed | +| `ios/audioapi/ios/AudioAPIModule.mm` | iOS install flow, new platform managers, new RCT_EXPORT_METHOD | +| `android/src/main/java/.../AudioAPIModule.kt` | Android `install()`, new `external fun`, lifecycle changes | +| `android/src/main/cpp/audioapi/android/AudioAPIModule.cpp` | New `registerNatives` entries | + +Update the **install flow diagram** when new globals are added to `injectJSIBindings`. diff --git a/.claude/skills/utilities/SKILL.md b/.claude/skills/utilities/SKILL.md new file mode 100644 index 000000000..142c00524 --- /dev/null +++ b/.claude/skills/utilities/SKILL.md @@ -0,0 +1,266 @@ +--- +name: utilities +description: > + Overview of all shared utility helpers, data structures, and DSP primitives available in + react-native-audio-api. Covers C++ utilities in common/cpp/audioapi/utils/, + common/cpp/audioapi/core/utils/, common/cpp/audioapi/dsp/, and TypeScript utilities in + src/utils/. Use this skill before writing new math, buffer management, or cross-thread code — + check if a utility already exists. Trigger phrases: "add a new utility", "what helpers are + available", "cross-thread communication", "audio buffer", "DSP math", "circular buffer", + "lock-free queue", "off-thread", "SIMD buffer", "time conversion", "parameter automation", + "audio constants", "TypeScript utils". +--- + +# Skill: Utilities + +## `common/cpp/audioapi/utils/` — Core data structures & primitives + +### `AudioArray.h` — float audio array (read header for full API) + +Single-channel float buffer. The foundational data type. Use for any per-channel audio data. + +Key operations: `zero()`, `sum(source, gain)`, `multiply(source)`, `copy(source)`, `copyReverse(...)`, `copyTo(float*)`, `copyWithin(...)`, `scale(float)`, `normalize()`, `getMaxAbsValue()`, `computeConvolution(kernel)`. + +Access: `operator[]`, `begin()`/`end()`, `span()`, `subSpan(length, offset)`. + +Constructors copy data — `AudioArray` always owns its buffer. + +--- + +### `AudioBuffer.h` — multi-channel container (read header for full API) + +Holds N channels of `AudioArrayBuffer`. Handles up/down-mixing automatically on `sum()` and `copy()`. + +Key operations: `getChannel(index)`, `getSharedChannel(index)`, `zero()`, `sum(source, interpretation)`, `copy(source)`, `deinterleaveFrom(float*, frames)`, `interleaveTo(float*, frames)`, `normalize()`, `scale(float)`. + +Channel layout constants: `ChannelMono=0`, `ChannelLeft=0`, `ChannelRight=1`, `ChannelCenter=2`, `ChannelLFE=3`, `ChannelSurroundLeft=4`, `ChannelSurroundRight=5`. + +`getChannel()` returns a non-owning `AudioArray*`. `getSharedChannel()` returns `shared_ptr` (use for JSI transfer). + +--- + +### `AudioArrayBuffer.hpp` — JSI-transferable audio array + +`AudioArray` + `jsi::MutableBuffer`. Allows zero-copy transfer of audio data to JS as an `ArrayBuffer`. Use in `getChannelData()` patterns. + +```cpp +// Typical usage in HostObject — no copy, JS sees the native memory +auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel); +auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); +auto float32Array = runtime.global() + .getPropertyAsFunction(runtime, "Float32Array") + .callAsConstructor(runtime, arrayBuffer) + .getObject(runtime); +float32Array.setExternalMemoryPressure(runtime, audioArrayBuffer->size()); +``` + +--- + +### `CircularAudioArray.h` — circular float buffer (read header) + +`AudioArray` subclass acting as a circular queue for streaming audio. **Not thread-safe** — use only from one thread. + +Key operations: +- `push_back(AudioArray&, size)` / `push_back(float*, size)` — write frames +- `pop_front(AudioArray&, size)` / `pop_front(float*, size)` — read oldest frames +- `pop_back(AudioArray&, size, offset)` — read newest frames +- `getNumberOfAvailableFrames()` — how many frames ready to read + +Used in delay lines and streaming buffers. + +--- + +### `CircularOverflowableAudioArray.h` — overwritable circular buffer + +Like `CircularAudioArray` but overwrites oldest data when full instead of rejecting. Use for recording input where you always want the latest data, not the oldest. + +--- + +### `SpscChannel.hpp` — lock-free SPSC channel + +Bounded single-producer, single-consumer queue built on aligned atomics. **Do not use directly — prefer `CrossThreadEventScheduler` or `TaskOffloader`** unless you need fine-grained control. + +For full API see [api.md](api.md#spscchannelhpp--lock-free-spsc-channel). + +--- + +### `CrossThreadEventScheduler.hpp` — JS→audio event queue + +High-level wrapper over `SpscChannel` for scheduling lambdas from the JS thread to be executed on the audio thread. This is **the standard way** to send updates from JS to audio. + +For full API see [api.md](api.md#crossthreadeventschedulerhpp--jsaudio-event-queue). + +--- + +### `AlignedAllocator.hpp` — aligned STL allocator + +STL-compatible allocator that guarantees `N`-byte alignment (default 16 bytes, SIMD-friendly). Use when creating buffers that will be processed by SIMD code in `VectorMath`. + +For full API see [api.md](api.md#alignedallocatorhpp--aligned-stl-allocator). + +--- + +### `MoveOnlyFunction.hpp` — non-copyable function wrapper + +Backport of C++23 `std::move_only_function`. Use instead of `std::function` when the callable captures a move-only type (e.g. a `unique_ptr`). + +For full API see [api.md](api.md#moveonlyfunctionhpp--non-copyable-function-wrapper). + +--- + +### `Result.hpp` — Rust-style Result + +Represents either success (`Ok`) or error (`Err`). Use at API boundaries (e.g. `AudioRecorder::start()`). Use `NoneType` / `None` for void variants. + +For full API see [api.md](api.md#resulthpp--rust-style-resultte). + +--- + +### `RingBiDirectionalBuffer.hpp` — compile-time capacity ring deque + +Non-thread-safe bounded ring buffer with push/pop from both ends. Capacity is a **compile-time** power-of-two template parameter. Used for `AudioParamEventQueue`. + +For full API see [api.md](api.md#ringbidirectionalbufferhpp--compile-time-capacity-ring-deque). + +--- + +### `TaskOffloader.hpp` — worker thread with SPSC input + +Spawns a dedicated worker thread that processes items from a SPSC channel. Use when you need to offload a recurring task (e.g. file writing, decoding) to a dedicated thread. + +For full API see [api.md](api.md#taskoffloaderhpp--worker-thread-with-spsc-input). + +--- + +### `Benchmark.hpp` — timing utilities (dev/debug only) + +Use `getExecutionTime()` for one-shot nanosecond timing. **Do not leave `logAvgExecutionTime` in production code.** + +For full API see [api.md](api.md#benchmarkhpp--timing-utilities-devdebug-only). + +--- + +### `UnitConversion.h` — byte unit constants + +```cpp +audioapi::KB_IN_BYTES // 1024.0 +audioapi::MB_IN_BYTES // 1024 * 1024.0 +audioapi::GB_IN_BYTES // 1024^3.0 +``` + +--- + +## `common/cpp/audioapi/core/utils/` — Node and context utilities + +### `Constants.h` — global audio constants + +```cpp +RENDER_QUANTUM_SIZE = 128 // frames per render block — never hardcode 128 +MAX_FFT_SIZE = 32768 +MAX_CHANNEL_COUNT = 32 +OCTAVE_RANGE = 1200 // cents per octave +PI = std::numbers::pi_v +MOST_POSITIVE_SINGLE_FLOAT / MOST_NEGATIVE_SINGLE_FLOAT +PROMISE_VENDOR_THREAD_POOL_WORKER_COUNT = 4 +``` + +--- + +### `AudioDestructor.hpp` — off-thread destruction + +Offloads `shared_ptr` destruction to a dedicated worker thread. Use for any object whose destructor may block or deallocate large buffers — both are forbidden on the audio thread. + +For full API see [api.md](api.md#audiodestructorhpp--off-thread-destruction). + +--- + +### `ParamChangeEvent.hpp` — AudioParam automation event + +Represents a single Web Audio API automation command (`setValueAtTime`, `linearRampToValueAtTime`, etc.). Move-only. Used exclusively within `AudioParamEventQueue` — do not construct outside of `AudioParam` scheduling methods. + +For full API see [api.md](api.md#paramchangeeventhpp--audioparam-automation-event). + +--- + +### `AudioParamEventQueue.h` — sorted automation event queue + +Stores and processes `ParamChangeEvent` objects in time order on the audio thread. Read the header for full API. + +--- + +### `AudioGraphManager.h` — thread-safe graph mutation queue + +Queues connect/disconnect operations from the JS thread for application before each render pass. Do not call its methods directly — go through `AudioNode::connect()`/`disconnect()`. Read the header for implementation details. + +--- + +### `Locker.h` — nullable mutex RAII wrapper + +RAII mutex wrapper that can hold `nullptr` (no-op). Supports `Locker::tryLock(mutex)` for non-blocking acquisition. **Do not use on the audio thread.** Locks are forbidden in `processNode()`. + +--- + +### Other `core/utils/` classes + +- **`AudioDecoder.h`** — decodes audio files to `AudioBuffer` (FFmpeg, conditional). Read the header. +- **`AudioFileWriter.h`** — writes PCM to audio files. Read the header. +- **`AudioStretcher.h`** — pitch/time stretching (signalsmith-stretch). Read the header. +- **`AudioRecorderCallback.h`** — callback adapter for the platform recorder. Internal. +- **`worklets/WorkletsRunner.h`** — manages JS worklet execution on the audio thread. Internal. + +--- + +## `common/cpp/audioapi/dsp/` — DSP helpers + +### `AudioUtils.hpp` — inline DSP math + +Provides `timeToSampleFrame()`, `sampleFrameToTime()`, `linearInterpolate()`, `linearToDecibels()`, `decibelsToLinear()`. + +For full API see [api.md](api.md#audioutilshpp--inline-dsp-math). + +--- + +### `VectorMath.h` — SIMD-optimized vector math + +SIMD-accelerated array operations (ARM NEON / x86 SSE2). Use for per-channel hot-path processing. Read the header for available functions before writing manual loops. + +--- + +### `FFT.h` / `Convolver.h` / `Resampler.h` / `WaveShaper.h` / `Windows.hpp` + +Higher-level DSP blocks. Read each header before use. + +--- + +## `src/utils/` — TypeScript utilities + +### `paths.ts` + +```ts +import { isRemoteSource, isBase64Source, isDataBlobString } from './utils/paths'; + +isRemoteSource(url) // true if starts with http:// or https:// +isBase64Source(data) // true if 'data:audio/...;base64,...' +isDataBlobString(data) // true if starts with 'blob:' +``` + +Use before passing a URL/path to decoder or streaming APIs to determine the source type. + +--- + +### `filePresets.ts` + +```ts +import FilePreset from './utils/filePresets'; + +FilePreset.Low // 22050 Hz, 48kbps, 16-bit +FilePreset.Medium // 44100 Hz, 128kbps, 16-bit +FilePreset.High // 48000 Hz, 192kbps, 24-bit +FilePreset.Lossless // 48000 Hz, 320kbps, 24-bit, FLAC L8 +``` + +Use when configuring `AudioRecorder` file output instead of building `FilePresetType` objects manually. + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/utilities/api.md b/.claude/skills/utilities/api.md new file mode 100644 index 000000000..3d7c8a04d --- /dev/null +++ b/.claude/skills/utilities/api.md @@ -0,0 +1,257 @@ +# Utilities API Reference — Full .hpp Documentation + +> This file contains the detailed API documentation for template/inline utilities in `common/cpp/audioapi/utils/`, `common/cpp/audioapi/core/utils/`, and `common/cpp/audioapi/dsp/`. +> +> For a concise overview and when-to-use guidance, see [SKILL.md](SKILL.md). + +--- + +## `SpscChannel.hpp` — lock-free SPSC channel + +Bounded single-producer, single-consumer queue built on aligned atomics. **Do not use directly — prefer `CrossThreadEventScheduler` or `TaskOffloader`** unless you need fine-grained control. + +```cpp +// Create a channel +auto [sender, receiver] = channels::spsc::channel(capacity); +// Optional template params: OverflowStrategy, WaitStrategy + +// Sender (producer thread) +sender.try_send(value); // non-blocking, returns ResponseStatus +sender.send(value); // blocks until space available + +// Receiver (consumer thread) +ResponseStatus s = receiver.try_receive(value); // non-blocking +T val = receiver.receive(); // blocks until data available +``` + +**OverflowStrategy:** +- `WAIT_ON_FULL` (default) — `try_send` returns `CHANNEL_FULL` when full +- `OVERWRITE_ON_FULL` — overwrites oldest element (use with `BUSY_LOOP`) + +**WaitStrategy** (affects blocking `send`/`receive`): +- `BUSY_LOOP` (default) — spin loop; lowest latency, highest CPU +- `YIELD` — `std::this_thread::yield()`; lower CPU, higher latency +- `ATOMIC_WAIT` — `std::atomic::wait()`; good for long waits (destructor threads) + +**ResponseStatus:** `SUCCESS`, `CHANNEL_FULL`, `CHANNEL_EMPTY`, `SKIP_DUE_TO_OVERWRITE`. + +Real capacity rounds up to next power of two. + +--- + +## `CrossThreadEventScheduler.hpp` — JS→audio event queue + +High-level wrapper over `SpscChannel` for scheduling lambdas from the JS thread to be executed on the audio thread. This is **the standard way** to send updates from JS to audio. + +```cpp +// Declaration (e.g. in AudioNode or AudioParam) +CrossThreadEventScheduler eventScheduler_{/*capacity=*/64}; + +// JS thread — schedule a change +eventScheduler_.scheduleEvent([newValue](MyNode &node) { + node.setSomething(newValue); +}); +// returns false if queue is full (drop the event or handle it) + +// Audio thread — drain all pending events before processNode() +eventScheduler_.processAllEvents(*this); +``` + +Template parameter `T` is the type passed by reference to each event lambda. For `AudioNode` subclasses, `T = BaseAudioContext` (events are called with the context). For `AudioParam`, `T = AudioParam` itself. + +Not copyable. Use `std::shared_ptr` if shared ownership is needed across threads. + +--- + +## `AlignedAllocator.hpp` — aligned STL allocator + +STL-compatible allocator that guarantees `N`-byte alignment. Default alignment is 16 bytes (SIMD-friendly). + +```cpp +// 16-byte aligned float vector (default) +std::vector> buf(1024); + +// Custom alignment (e.g. 64 bytes for AVX-512) +std::vector> buf(1024); +``` + +Use when creating buffers that will be processed by SIMD code in `VectorMath`. + +--- + +## `MoveOnlyFunction.hpp` — non-copyable function wrapper + +Backport of C++23 `std::move_only_function`. Use instead of `std::function` when the callable captures a move-only type (e.g. a `unique_ptr`). + +```cpp +audioapi::move_only_function event = [ptr = std::move(uniquePtr)](BaseAudioContext &) { + ptr->doSomething(); +}; +// move into the scheduler +eventScheduler_.scheduleEvent(std::move(event)); +``` + +Not copyable. Throws `std::bad_function_call` if invoked when empty. + +--- + +## `Result.hpp` — Rust-style Result + +Represents either success (`Ok`) or error (`Err`). Used at API boundaries (e.g. `AudioRecorder::start()`). + +```cpp +// Creating +Result res = Ok(std::string("path/to/file")); +Result err = Err(std::string("permission denied")); + +// Checking and extracting +if (result.is_ok()) { + std::string path = std::move(result).unwrap(); +} +if (result.is_err()) { + std::string msg = std::move(result).unwrap_err(); +} + +// With default +std::string path = std::move(result).unwrap_or(std::string("default")); + +// Transforming +auto mapped = std::move(result).map([](std::string s) { return s.size(); }); +auto mapped_err = std::move(result).map_err([](std::string e) { return -1; }); + +// Chaining +auto chained = std::move(result).and_then([](std::string s) -> Result { + return Ok(42); +}); +``` + +Use `NoneType` / `None` for void variants: `Result`. + +--- + +## `RingBiDirectionalBuffer.hpp` — compile-time capacity ring deque + +Non-thread-safe bounded ring buffer with push/pop from both ends. Capacity is a **compile-time** power-of-two template parameter. + +```cpp +RingBiDirectionalBuffer queue; // capacity must be power of 2 + +queue.pushBack(event); // add to back, returns false if full +queue.pushFront(event); // add to front, returns false if full + +MyEvent e; +queue.popFront(e); // remove from front into e, returns false if empty +queue.popBack(e); // remove from back into e, returns false if empty +queue.popFront(); // discard front element +queue.popBack(); // discard back element + +const MyEvent &front = queue.peekFront(); // const peek, no removal +MyEvent &back = queue.peekBackMut(); // mutable peek + +queue.isEmpty(); queue.isFull(); +queue.size(); queue.getCapacity(); // real capacity = getCapacity() - 1 +``` + +Used for `AudioParamEventQueue` (parameter automation event storage on the audio thread). + +--- + +## `TaskOffloader.hpp` — worker thread with SPSC input + +Spawns a dedicated worker thread that processes items from a SPSC channel. Use when you need to offload a recurring task (e.g. file writing, decoding) to a dedicated thread. + +```cpp +// T must be default_initializable +TaskOffloader offloader{ + /*capacity=*/64, + [](AudioData data) { // runs on worker thread + writeToFile(data); + } +}; + +// Push work from any thread +auto *sender = offloader.getSender(); +sender->send(audioData); +``` + +Not copyable or movable. Destructor sends a dummy value to unblock the worker and joins the thread. + +--- + +## `Benchmark.hpp` — timing utilities (dev/debug only) + +```cpp +#include + +// One-shot timing — returns nanoseconds +double ns = audioapi::benchmarks::getExecutionTime([]{ doSomething(); }); + +// Running average with logging (NOT thread-safe, not for production) +audioapi::benchmarks::logAvgExecutionTime("render quantum", []{ renderAudio(); }); +``` + +**Do not leave `logAvgExecutionTime` in production code.** + +--- + +## `AudioDestructor.hpp` — off-thread destruction + +Offloads `shared_ptr` destruction to a dedicated worker thread. Use for any object whose destructor may block or deallocate large buffers — both are forbidden on the audio thread. + +```cpp +// Typically owned by AudioContext or BaseAudioContext +AudioDestructor destructor; + +// Audio thread — hand off a node for destruction without blocking +bool ok = destructor.tryAddForDeconstruction(std::move(nodePtr)); +// if false (queue capacity=1024 is full), the shared_ptr is NOT moved +``` + +The worker thread blocks on receive and drops the `shared_ptr`, triggering the destructor off the audio thread. + +--- + +## `ParamChangeEvent.hpp` — AudioParam automation event + +Represents a single Web Audio API automation command (`setValueAtTime`, `linearRampToValueAtTime`, etc.). Stores timing, start/end values, and a `calculateValue_` lambda. Move-only. + +```cpp +ParamChangeEvent event( + startTime, endTime, startValue, endValue, + [](double start, double end, float startVal, float endVal, double currentTime) -> float { + return computedValue; // interpolation logic + }, + ParamChangeEventType::LINEAR_RAMP +); + +event.getStartTime(); event.getEndTime(); +event.getStartValue(); event.getEndValue(); +event.getCalculateValue(); // the interpolation lambda +event.getType(); + +// Mutators used by AudioParamEventQueue when adjusting event boundaries: +event.setEndTime(t); event.setStartValue(v); event.setEndValue(v); +``` + +Used exclusively within `AudioParamEventQueue`. Do not construct outside of `AudioParam` scheduling methods. + +--- + +## `AudioUtils.hpp` — inline DSP math + +```cpp +#include +using namespace audioapi::dsp; + +size_t frame = timeToSampleFrame(time, sampleRate); // double → size_t +double t = sampleFrameToTime(frame, sampleRate); // int → double + +// Linear interpolation with edge-case handling (extrapolates at end of array) +float v = linearInterpolate(span, firstIndex, secondIndex, factor); + +float db = linearToDecibels(linearValue); // 20 * log10(v) +float linear = decibelsToLinear(dbValue); // 10^(db/20) +``` + diff --git a/.claude/skills/utilities/maintenance.md b/.claude/skills/utilities/maintenance.md new file mode 100644 index 000000000..e1f08ca92 --- /dev/null +++ b/.claude/skills/utilities/maintenance.md @@ -0,0 +1,22 @@ +# Maintenance — utilities + +> Used by `/pre-push-update` only — not loaded when the `utilities` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `common/cpp/audioapi/utils/*.h` | New utility added — add brief usage note to `SKILL.md` | +| `common/cpp/audioapi/utils/SpscChannel.hpp` | `api.md` — API, OverflowStrategy/WaitStrategy values, ResponseStatus enum | +| `common/cpp/audioapi/utils/CrossThreadEventScheduler.hpp` | `api.md` — template parameter meaning, `scheduleEvent` return value | +| `common/cpp/audioapi/utils/AlignedAllocator.hpp` | `api.md` — default alignment, constructor signature | +| `common/cpp/audioapi/utils/MoveOnlyFunction.hpp` | `api.md` — signature, throw behaviour | +| `common/cpp/audioapi/utils/Result.hpp` | `api.md` — Ok/Err constructors, method signatures | +| `common/cpp/audioapi/utils/RingBiDirectionalBuffer.hpp` | `api.md` — push/pop/peek method names, capacity rules | +| `common/cpp/audioapi/utils/TaskOffloader.hpp` | `api.md` — constructor, `getSender`, destructor behaviour | +| `common/cpp/audioapi/utils/Benchmark.hpp` | `api.md` — function names, return type | +| `common/cpp/audioapi/core/utils/AudioDestructor.hpp` | `api.md` — `tryAddForDeconstruction` signature, capacity | +| `common/cpp/audioapi/core/utils/ParamChangeEvent.hpp` | `api.md` — constructor args, getters/setters | +| `common/cpp/audioapi/dsp/AudioUtils.hpp` | `api.md` — new DSP helpers added or signatures changed | +| `common/cpp/audioapi/core/utils/Constants.h` | Constants section in `SKILL.md` | +| `src/utils/**` | TypeScript utils section in `SKILL.md` | diff --git a/.claude/skills/web-audio-api/SKILL.md b/.claude/skills/web-audio-api/SKILL.md new file mode 100644 index 000000000..4fa02d182 --- /dev/null +++ b/.claude/skills/web-audio-api/SKILL.md @@ -0,0 +1,187 @@ +--- +name: web-audio-api +description: > + Web Audio API spec reference and browser passthrough layer (src/web-core/). Use when implementing + a node that must match the Web Audio API spec, checking parameter default values and ranges, + adding or modifying src/web-core/ wrapper classes, deciding whether a feature belongs to the spec + or is an RN-specific extension, or updating the coverage table. + Trigger phrases: "web-core", "spec compliance", "coverage table", "api.web.ts", "Web Audio spec", + "parameter default", "browser passthrough", "not in spec", "spec deviation", + "webaudio.github.io". +--- + +# Skill: Web Audio API + +## Spec Reference + +Everything that has a counterpart in the Web Audio API specification must match it: + +- **Spec**: https://webaudio.github.io/web-audio-api/ +- **MDN reference**: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API + +Key spec sections to check when implementing or reviewing a node: +- Processing model and render quantum (128 frames) +- `AudioNode` channel count rules and `channelCountMode` / `channelInterpretation` +- `AudioParam` automation methods, value clamping, and timing +- Each node's constructor options, property defaults, and valid ranges + +--- + +## Platform Routing + +The library ships **one TypeScript API** that runs on two platforms. The entry point swap happens via package.json field resolution: + +``` +index.ts # re-exports from api.ts (or api.web.ts on web) +├── api.ts # React Native — re-exports from src/core/* +└── api.web.ts # Browser — re-exports from src/web-core/* +``` + +On **React Native**: classes in `src/core/` hold a reference to a C++ JSI HostObject. All method calls go to native. + +On **Web (browser)**: classes in `src/web-core/` wrap the corresponding `globalThis.*` (browser Web Audio API) object. All method calls delegate directly to the browser engine. + +Both sides share: +- The same TypeScript interfaces (`src/interfaces.ts`) +- The same types (`src/types.ts`) +- The same error classes (`src/errors/`) +- The same hooks (`src/hooks/`) + +--- + +## `src/web-core/` — Browser Passthrough Layer + +Each file in `src/web-core/` is a thin wrapper around the corresponding browser API object. The pattern is: + +**Constructor**: instantiate the browser node via `globalThis.XxxNode` (or `new window.AudioContext`), store it as `this.node` (or `this.context`), read readonly properties from it. + +**Getters/setters/methods**: delegate directly to `this.node`. + +**AudioParam**: wrapped in the local `AudioParam` class (which stores `this.param: globalThis.AudioParam` and delegates all automation calls). + +```ts +// Example: GainNode.tsx (minimal) +export default class GainNode extends AudioNode { + readonly gain: AudioParam; + + constructor(context: BaseAudioContext, gainOptions?: GainOptions) { + const gain = new globalThis.GainNode(context.context, gainOptions); + super(context, gain); + this.gain = new AudioParam(gain.gain, context); + } +} + +// Example: OscillatorNode.tsx (with extra validation) +public set type(value: OscillatorType) { + if (value === 'custom') { + throw new InvalidStateError("'type' cannot be set to 'custom' directly..."); + } + (this.node as globalThis.OscillatorNode).type = value; +} +``` + +**`AudioContext`** (web) wraps `window.AudioContext`. It also adds validation matching the RN side (e.g. sampleRate range check) so error behaviour is consistent across platforms. + +**`decodeAudioData`** on web additionally supports a `string` URL source (fetches the file, then decodes). This is a deliberate extension beyond the browser spec signature (which only takes `ArrayBuffer`), mirroring the RN native implementation. + +### Rules for web-core code + +- Every public method/property must have a direct counterpart in the spec (or be in `custom/`) +- Extra validation (range checks, length checks) is fine — it makes error messages consistent with the RN side +- No business logic — the browser engine is the source of truth for audio processing +- If a node does not exist in the browser, it goes in `src/web-core/custom/` + +--- + +## `src/web-core/custom/` — RN Extensions on Web + +Nodes or features that don't exist in the Web Audio API spec but are in this library as mobile extensions. The `custom/index.ts` re-exports them and `api.web.ts` re-exports the custom barrel. + +Currently: signalsmith-stretch WASM wrapper (`LoadCustomWasm.ts`, `signalsmithStretch/`) for time-stretch on web. + +When adding a new RN-specific feature that should also work on web, implement the web version here. + +--- + +## Implementation Coverage + +Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`): + +### Fully implemented ✅ +`AnalyserNode`, `AudioBuffer`, `AudioBufferSourceNode`, `AudioDestinationNode`, `AudioNode`, `AudioParam`, `AudioScheduledSourceNode`, `BiquadFilterNode`, `ConstantSourceNode`, `ConvolverNode`, `DelayNode`, `GainNode`, `IIRFilterNode`, `OfflineAudioContext`, `OscillatorNode`, `PeriodicWave`, `StereoPannerNode`, `WaveShaperNode` + +### Partially implemented 🚧 +| Interface | What's available | +|---|---| +| `AudioContext` | `close`, `suspend`, `resume`, `currentTime`, `destination`, `sampleRate`, `state` | +| `BaseAudioContext` | `currentTime`, `destination`, `sampleRate`, `state`, `decodeAudioData`, all `create*` for available nodes | + +### Not yet implemented ❌ +`AudioListener`, `AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `ChannelMergerNode`, `ChannelSplitterNode`, `DynamicsCompressorNode`, `MediaElementAudioSourceNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode`, `PannerNode` + +**Goal**: everything in the Web Audio API spec should eventually be in this library. If you implement a node from the ❌ list, update the coverage table in `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`. + +--- + +## RN-Specific Extensions (beyond spec) + +These are exported from `api.ts` but **not** from `api.web.ts` (or have a stub/custom web implementation): + +| Class | Purpose | +|---|---| +| `AudioBufferQueueSourceNode` | Queue of audio buffers, plays them sequentially — no Web Audio spec equivalent | +| `StreamerNode` | FFmpeg-backed streaming decoder — no Web Audio spec equivalent | +| `AudioRecorder` | Microphone input recording — no Web Audio spec equivalent | +| `RecorderAdapterNode` | Connects recorder to the audio graph | +| `WorkletNode` / `WorkletSourceNode` / `WorkletProcessingNode` | JS-on-audio-thread via React Native Worklets — different from browser `AudioWorkletNode` | +| `AudioManager` | iOS/Android audio session management (permissions, routing, interruption handling) | +| `changePlaybackSpeed` (`AudioStretcher`) | Time-stretch without pitch change | +| `decodeAudioData` (standalone) | Standalone decode utility (not on context) | +| `decodePCMInBase64` | Decode raw PCM from base64 | + +When implementing these on the RN side, a web stub or polyfill in `src/web-core/custom/` should be considered if the feature can be reasonably approximated in a browser. + +--- + +## Adding a New Spec Node — Web Layer Checklist + +When adding a new Web Audio API node that is in the spec: + +1. **Implement `src/web-core/MyNode.tsx`** + - Extend the right base class (`AudioNode`, `AudioScheduledSourceNode`) + - Constructor: `new globalThis.MyNode(context.context, options)` + - Wrap all `AudioParam` properties in `new AudioParam(node.myParam, context)` + - Delegate all getters/setters/methods to `this.node` + - Add any validation that matches the RN side's error behaviour + +2. **Export from `src/api.web.ts`** + ```ts + export { default as MyNode } from './web-core/MyNode'; + ``` + +3. **Ensure the interface in `src/interfaces.ts`** (or a dedicated interface file) is shared between both paths. + +4. **Update the coverage table** in `packages/audiodocs/docs/other/web-audio-api-coverage.mdx` — move the node from ❌ to ✅. + +If the node **does not exist in the browser** (e.g. `AudioBufferQueueSourceNode`): +- Add a stub or alternative implementation in `src/web-core/custom/` +- Export it from `src/web-core/custom/index.ts` +- It will be picked up automatically by the `export * from './web-core/custom'` line in `api.web.ts` + +--- + +## Spec Compliance Notes + +When in doubt, cross-check the spec. Key invariants that are easy to get wrong: + +- **`feedback[0]` must not be 0** for `IIRFilterNode` — the spec requires it; we validate in TypeScript and in C++. +- **`feedforward` all-zeros** must throw `InvalidStateError` — the spec requires at least one non-zero coefficient. +- **`OscillatorNode.type = 'custom'`** must throw `InvalidStateError` — use `setPeriodicWave()` instead. +- **`AudioParam` min/max** — must match the spec's exact values; do not invent ranges. +- **`createBuffer` / `createDelay` / `createPeriodicWave`** — the spec defines when these throw and what error type. Match it. +- **`decodeAudioData`** — on RN side, we extend it to accept a URL string in addition to `ArrayBuffer`. This is intentional and documented. +- **`sampleRate` range** — spec says [8000, 96000]; enforced in `AudioContext` constructor on both platforms. + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/web-audio-api/maintenance.md b/.claude/skills/web-audio-api/maintenance.md new file mode 100644 index 000000000..a783539e1 --- /dev/null +++ b/.claude/skills/web-audio-api/maintenance.md @@ -0,0 +1,13 @@ +# Maintenance — web-audio-api + +> Used by `/pre-push-update` only — not loaded when the `web-audio-api` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `src/web-core/**` | New or modified web wrapper — update pattern docs or coverage table | +| `src/api.web.ts` | New export — add to coverage table or RN-extensions table | +| `src/api.ts` | New RN-only export — add to RN-specific extensions table | +| `packages/audiodocs/docs/other/web-audio-api-coverage.mdx` | Coverage table sync | +| `src/web-core/custom/**` | New web-side RN extension | diff --git a/.claude/skills/writing-skills/SKILL.md b/.claude/skills/writing-skills/SKILL.md new file mode 100644 index 000000000..976e74e53 --- /dev/null +++ b/.claude/skills/writing-skills/SKILL.md @@ -0,0 +1,312 @@ +--- +name: writing-skills +description: > + How to write, structure, and maintain Claude skill files. Covers the three-level progressive + disclosure model, all YAML frontmatter fields (context:fork, allowed-tools, + disable-model-invocation, user-invocable, hooks, argument-hint), invocation control patterns, + string substitutions ($ARGUMENTS, ${CLAUDE_SKILL_DIR}), shell preprocessing with backtick + syntax, and the Maintenance section contract. Use when creating a new skill file, rewriting an + existing one, or reviewing a skill for quality. + Trigger phrases: "add a skill", "write a skill", "create a skill file", "update skill", + "skill quality", "skill review", "context fork", "allowed-tools", "user-invocable". +user-invocable: false +--- + +# Skill: Writing Skills + +A skill file is a scoped knowledge document that Claude loads on demand. Good skills are concise, trigger reliably, and stay accurate over time. + +--- + +## Three-Level Model + +``` +Level 1 — YAML frontmatter always loaded, ~100 tokens +Level 2 — skill body (SKILL.md) loaded on trigger, < 500 lines / ~5 k tokens +Level 3 — supporting .md files loaded explicitly when more detail is needed +``` + +Claude sees the `description` field in every conversation. It reads the skill body only when the topic is relevant. It reads a supporting file only when the skill body links to it and more detail is needed. + +**Design each level independently.** The frontmatter alone must be enough to trigger the skill. The skill body must be useful without any supporting file. Supporting files are supplementary. + +--- + +## File Structure + +Each skill lives in its own directory: + +``` +.claude/skills// +├── SKILL.md # Required — main skill body with YAML frontmatter +└── .md # Optional — verbose reference material linked from SKILL.md +``` + +Supporting files (e.g. `gainnode-example.md`, `api.md`, `build-details.md`) live alongside `SKILL.md` in the same directory. They are plain `.md` files (not `SKILL.md`). Alternatively group them under an optional `references/` subdirectory when you have several related documents, or `scripts/` for executable code Claude can run. + +Link from the parent skill: + +```markdown +See [gainnode-example.md](gainnode-example.md) for a complete header + .cpp. +``` + +--- + +## YAML Frontmatter + +Required: `name` and `description`. Everything else is optional. + +```yaml +--- +name: kebab-case-name # matches the directory name; no claude/anthropic prefix +description: > + What the skill covers. When to use it. + Trigger phrases: "phrase one", "phrase two". + +# — optional fields — +context: fork # run as isolated subagent (see below) +agent: Explore # subagent type: Explore / Plan / general-purpose / custom +allowed-tools: "Read Grep Glob" # tools available without per-use approval +model: claude-sonnet-4-6 # model override for this skill +disable-model-invocation: true # user can /invoke; Claude won't auto-load +user-invocable: false # Claude auto-loads; hidden from the /menu +argument-hint: "[node-name]" # shown in slash-command autocomplete +hooks: # hooks scoped to this skill's lifecycle +metadata: + author: team + version: 1.0.0 +--- +``` + +### Rules + +- `name`: kebab-case, matches directory name. Forbidden prefixes: `claude`, `anthropic`. +- `description`: max 1024 characters. Include **what**, **when**, and **trigger phrases** (quoted, comma-separated at the end). +- Trigger phrases must match how developers actually speak: `"add a node"`, `"processNode"`, `"shadow state"` — not `"audio processing implementation"`. +- No XML angle brackets (`<` or `>`) anywhere in frontmatter — security restriction. + +### Anti-patterns + +```yaml +# BAD — vague, no trigger phrases +description: This skill covers audio node implementation details. + +# BAD — too long (gets cut at 1024 chars) +description: > + This skill covers every aspect of ... [500 words] ... + +# BAD — missing the literal "Trigger phrases:" label (skill will never auto-load) +description: > + How to create a C++ audio node. Covers processNode() contract, AudioParam a-rate/k-rate, + cross-thread scheduling. Use when implementing a new node or debugging audio rendering. + "add a node", "processNode", "audio thread", "AudioParam automation". + +# GOOD +description: > + How to create a C++ audio node. Covers processNode() contract, AudioParam a-rate/k-rate, + cross-thread scheduling. Use when implementing a new node or debugging audio rendering. + Trigger phrases: "add a node", "processNode", "audio thread", "AudioParam automation". +``` + +**The `Trigger phrases:` label is mandatory** — without the exact label, Claude Code does not recognise the list as trigger phrases and the skill will not auto-load. + +--- + +## Invocation Control + +By default a skill is both user-invokable (`/skill-name`) and auto-loaded by Claude. Override with: + +| Frontmatter | User `/invoke` | Claude auto-load | Use when | +|---|---|---|---| +| (default) | Yes | Yes | General knowledge skills | +| `disable-model-invocation: true` | Yes | No | Task-only / deployment skills | +| `user-invocable: false` | No | Yes | Internal / meta knowledge | + +--- + +## context: fork + +Runs the skill in an isolated subagent. The skill body becomes the task prompt — the subagent has no access to conversation history. + +```yaml +--- +name: deep-research +description: Research a topic thoroughly +context: fork +agent: Explore # Explore / Plan / general-purpose / custom .claude/agents/ name +--- + +Research $ARGUMENTS thoroughly: +1. Find relevant files using Glob and Grep +2. Read and analyze the code +3. Summarize findings with specific file references +``` + +**Only useful for task-oriented skills** with concrete step-by-step instructions. Do NOT set on reference/knowledge skills — the subagent receives guidelines but has no actionable task. + +--- + +## String Substitutions + +Available in skill body content: + +``` +$ARGUMENTS all arguments passed when user invokes /skill-name arg +$ARGUMENTS[0] first argument by index (0-based); $0 is shorthand +${CLAUDE_SESSION_ID} current session ID +${CLAUDE_SKILL_DIR} absolute path to the skill directory — use to reference bundled scripts +``` + +## Shell Preprocessing + +`` !`command` `` runs a shell command **before** the skill content is sent to Claude. Output replaces the placeholder at load time — Claude sees only the result, not the syntax. + +```markdown +## Context +Branch: !`git branch --show-current` +Last commits: !`git log --oneline -5` +``` + +Useful for skills that need live repository state injected automatically. + +--- + +## Skill Body + +### Structure + +```markdown +--- +name: my-skill +description: > ... +--- + +# Skill: My Skill + +One-sentence summary of what this covers (not a repeat of frontmatter — add context). + +--- + +## Most Important Section First + +Critical invariants, hard constraints, common mistakes go at the top. +Readers may stop reading early — put the highest-value content first. + +## Secondary Sections + +... + +--- + +## Maintenance + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `path/to/file.*` | What to review in this skill | +``` + +### Rules + +- **Under 500 lines** — non-negotiable. If the file exceeds 500 lines, move verbose content to a supporting file in the same directory. +- **Imperative form**: "Use `AudioParam`", "Declare in protected:", "Call `scheduleAudioEvent`". Not: "You should use", "It is recommended to call". +- **Code over prose**: a 5-line snippet teaches faster than two paragraphs. Prefer concrete examples. +- **Critical first**: MUST NOT lists, common pitfalls, and hard constraints go in the FIRST section, not buried at the bottom. Readers stop reading early. +- **No scope blockquotes**: do not add `> **Scope**: ...` / `> **What this skill covers**: ...` / `> **When Claude should consult this skill**: ...` — this duplicates the frontmatter and wastes lines. +- **Escape hatches**: add "If unsure → [do X]" guidance wherever a wrong choice causes hard-to-debug bugs (e.g. "If unsure which ITC primitive → check the decision table in `thread-safety-itc`"). +- **Golden references**: link to one or two existing files that exemplify the patterns in the skill. These let Claude anchor new code to proven implementations. + +### What belongs in the skill body vs supporting files + +| Belongs in skill body | Move to supporting file | +|---|---| +| API overview (1-line per method) | Complete header + .cpp of a class | +| Key usage patterns (5–15 line snippets) | Full working example (50+ lines) | +| Decision tables, checklists | Exhaustive per-flag build analysis | +| Common pitfalls (concise) | Line-by-line CMakeLists commentary | +| Links to spec or docs | Full `.hpp` API with every overload | + +--- + +## The Maintenance File + +Each skill directory has a `maintenance.md` file mapping source paths to what needs checking. It is **not loaded during normal skill usage** — only `/pre-push-update` reads it. + +```markdown +# Maintenance — skill-name + +> Used by /pre-push-update only — not loaded during skill usage. + +| Path | What to check | +|---|---| +| `path/to/file.*` | Specific section or element to review | +``` + +`SKILL.md` ends with a single footer line linking to it: + +```markdown +*Maintenance: see [maintenance.md](maintenance.md).* +``` + +Supporting files do **not** have their own maintenance sections — their rows go into the same `maintenance.md`. + +### Why separate + +`## Maintenance` embedded in `SKILL.md` wastes tokens on every skill load. A dedicated `maintenance.md` is only read by `/pre-push-update`. + +### Rules + +- Use glob-style patterns (`*`, `**`) for directories. +- The "What to check" column must name the specific section or element to review — not just "update if needed". +- Add a row whenever you add a new documented pattern to the skill. +- Supporting file paths belong in the skill's `maintenance.md` — not in the supporting file itself. + +--- + +## Supporting Files + +For content that is too large for the skill body but still useful to load on demand. + +### When to create a supporting file + +- A complete class header + implementation (50+ lines of code) +- Full API documentation for a large `.hpp` template file +- Deep line-by-line analysis of a build file +- A complete worked example spanning multiple files + +### Location + +Place supporting files in the same directory as `SKILL.md`: + +``` +.claude/skills/audio-nodes/ +├── SKILL.md +└── gainnode-example.md # supporting file +``` + +Link from the parent skill: + +```markdown +See [gainnode-example.md](gainnode-example.md) for a complete header + .cpp. +``` + +Supporting files do **not** need YAML frontmatter or a `name` field — they are plain markdown. + +--- + +## Checklist: New Skill File + +1. Create directory `.claude/skills//` +2. Create `SKILL.md` with YAML frontmatter (`name`, `description` with what + when + trigger phrases, ≤1024 chars) +3. `# Skill: Name` heading — no scope blockquotes below it +4. Most important content first +5. Under 500 lines — move verbose content to a supporting `.md` file in the same directory +6. Imperative form, code snippets preferred over prose +7. Create `maintenance.md` with path → what-to-check table; add `*Maintenance: see [maintenance.md](maintenance.md).*` at the bottom of `SKILL.md` +8. If supporting files created: their maintenance rows go into `maintenance.md` (not in the supporting files) +9. Add the skill directory to the table in `CLAUDE.md` and the tree in `.claude/README.md` + +--- + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/writing-skills/maintenance.md b/.claude/skills/writing-skills/maintenance.md new file mode 100644 index 000000000..6b79ba2db --- /dev/null +++ b/.claude/skills/writing-skills/maintenance.md @@ -0,0 +1,12 @@ +# Maintenance — writing-skills + +> Used by `/pre-push-update` only — not loaded when the `writing-skills` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `.claude/skills/*/SKILL.md` | New patterns observed — update best-practices or anti-patterns sections | +| `.claude/skills/**/*.md` | Supporting file or maintenance.md conventions changed — update the relevant section | +| `.claude/commands/pre-push-update.md` | Maintenance contract changed — update the `maintenance.md` rules | +| `CLAUDE.md` | Skill table updated — update the checklist step that references `CLAUDE.md` | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..aec80112e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +`react-native-audio-api` is a high-performance Web Audio API-compatible audio engine for React Native, maintained by Software Mansion. It provides audio playback, recording, DSP processing, and real-time analysis across iOS, Android, and Web. + +## Architecture + +### Monorepo Structure +``` +packages/react-native-audio-api/ # Main library +apps/common-app/ # Example RN app +apps/fabric-example/ # New Architecture example app +packages/audiodocs/ # Documentation +packages/custom-node-generator/ # Code generation tooling +``` + +### Layers (from JS to hardware) + +1. **TypeScript API** (`src/`) — node implementations (`src/core/`), browser passthrough (`src/web-core/`), platform system APIs (`src/system/`), TurboModule specs (`src/specs/`), hooks, events, utils +2. **C++ Engine** (`common/cpp/audioapi/`) — node engine (`core/`), SIMD DSP (`dsp/`), JSI HostObjects, audio events, prebuilt external libraries (`external/`) +3. **Android Native** (`android/`) — CMake + Gradle, Kotlin module, C++ JNI glue (`src/main/cpp/`), Oboe 1.9.3 audio I/O +4. **iOS Native** (`ios/audioapi/ios/`) — Objective-C++ (`.mm`), CocoaPods (`RNAudioAPI.podspec`), CoreAudio I/O + +### Key Architectural Patterns +- **JSI**: Audio nodes are exposed as C++ JSI HostObjects — no bridge serialization +- **Audio Thread Safety**: Real-time audio processing happens on a dedicated audio thread; JS-side calls must not block it +- **Dual Platform**: TypeScript code has separate paths for React Native (native engine) and Web (delegates to browser Web Audio API) +- **New Architecture Ready**: Supports both old Bridge and new TurboModules/Fabric +- **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out +- **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets + +### Native Module Entry Points +- iOS: `ios/audioapi/ios/AudioAPIModule.mm` +- Android: `android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt` +- TurboModule spec: `src/specs/NativeAudioAPIModule.ts` + +## Web Resources + +The following URLs can be fetched without approval and are useful during development: + +| URL | When to use | +|---|---| +| https://webaudio.github.io/web-audio-api/ | Web Audio API W3C spec — parameter defaults, processing semantics, error conditions | +| https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API | MDN — friendlier spec reference, examples | +| https://en.cppreference.com/ | C++ standard library reference — containers, algorithms, atomics, memory model | +| https://reactnative.dev/docs/the-new-architecture/pure-cxx-modules | RN New Architecture — TurboModules, JSI, fbjni patterns | + +Fetch these proactively when implementing a new node (to check spec compliance), when using an unfamiliar C++ stdlib class, or when working on the TurboModule / JSI installation layer. + +## Autonomous Parallelization + +Claude MUST decide independently whether to parallelize work and run subagents — do not wait for the user to ask. Default to parallel execution whenever tasks are independent. + +**Web research**: When a task requires fetching or summarizing content from multiple URLs, or searching for information across multiple topics, launch parallel `general-purpose` or `Explore` subagents — one per source/topic. Each agent fetches, filters, and returns only the relevant excerpt. Never fetch URLs sequentially when they are independent. + +**Codebase exploration**: When investigating a bug or implementing a feature that spans multiple layers (TypeScript + HostObject + C++ node + iOS + Android), launch parallel `Explore` agents to read each layer simultaneously rather than reading files one by one. + +**Skill updates**: When `pre-push-update` identifies multiple skill files to review, use parallel background agents to update them simultaneously. + +The user expects Claude to make these parallelization decisions without being prompted. Spawning a subagent costs less than waiting for sequential work. + +## Golden Reference Implementations + +When implementing anything new, mirror structure and style from these proven files. Ask the agent to explain any intentional deviation. + +| Task | Reference file(s) | +|---|---| +| New C++ effect/analysis node | `common/cpp/audioapi/core/effects/GainNode.h` + `.cpp` | +| New JSI HostObject | `common/cpp/audioapi/HostObjects/effects/GainNodeHostObject.h` + `.cpp` | +| New scheduled source node | `common/cpp/audioapi/core/sources/OscillatorNode.h` + `.cpp` | +| New TypeScript API class | `packages/react-native-audio-api/src/core/GainNode.ts` | + +--- + +## Skills + +Detailed skill files live in `.claude/skills/`. Each skill lives in its own directory as `/SKILL.md` and is auto-loaded by Claude Code based on YAML frontmatter trigger phrases. Consult the relevant skill before starting work in that area. + +| Skill directory | Domain | +|---|---| +| `host-objects/` | C++ JSI HostObject layer — creating and maintaining HostObjects | +| `audio-nodes/` | C++ audio node engine — implementing and connecting audio nodes | +| `turbo-modules/` | TurboModule/JSI wiring — spec → native → HostObject installation | +| `web-audio-api/` | Web Audio API spec conformance and browser passthrough layer | +| `build-compilation-dependencies/` | CMake, Gradle, podspec, prebuilt libraries | +| `thread-safety-itc/` | Audio thread safety, lock-free patterns, event system | +| `post-work-checks/` | Ordered checklist to run after every change | +| `flow/` | End-to-end feature implementation flow (tests + docs required) | +| `utilities/` | Shared DSP and C++/TS utility helpers | +| `writing-skills/` | How to write, structure, and maintain skill files | + +See `.claude/README.md` for a full description of the Claude Code setup and the `/pre-push-update` command. + +## Self-Modification Instructions + +After completing any task, Claude MUST review whether any of the following apply and make the appropriate edits if so: + +1. **New pattern discovered**: If a fix or investigation revealed a non-obvious pattern, invariant, or pitfall that is not yet documented in the relevant skill file, add a concise note to that skill file. + +2. **Inconsistency found**: If a path, folder, or file mentioned in this CLAUDE.md or any skill file does not actually exist, correct the reference. If an important path exists in the repo but is not mentioned anywhere, add it to the relevant skill file or to this file. + +3. **Outdated information**: If something described here contradicts reality (wrong command, moved file, renamed module), update it immediately — do not leave a known-wrong description in place. + +4. **Skill gaps**: If work required knowledge that is not covered by any skill file, note it in the closest matching skill file or propose a new one. + +5. **Skill quality issues**: When reading or modifying a skill file and noticing any of the following, fix them immediately — do not leave known-bad skill files in place: + - Missing `Trigger phrases: "..."` label in the `description` (skill will not auto-load without it) + - Cross-references using `.md` suffix (e.g. `audio-nodes.md`) instead of bare skill name (e.g. `audio-nodes`) + - Stale cross-references to renamed or moved files + - A skill body that exceeds 500 lines without moving verbose content to a supporting file + +The goal is that these files stay accurate and grow more useful over time through incremental updates from real work, not just manual maintenance. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md new file mode 100644 index 000000000..0132554e3 --- /dev/null +++ b/apps/CLAUDE.md @@ -0,0 +1,311 @@ +# apps/ — Example Applications + +The main example app is `fabric-example` — it uses the New Architecture (TurboModules/Fabric) and serves as the primary manual smoke-test environment for new features. + +## Architecture + +`fabric-example` is a **thin wrapper**. Almost all code lives in the shared `common-app` workspace: + +``` +apps/ +├── fabric-example/ # RN app shell (native + metro config) +│ ├── App.tsx # Re-exports App from common-app +│ ├── metro.config.js # Monorepo-aware Metro (watches workspaces) +│ └── android/ ios/ # Native projects +└── common-app/ # Actual app code — edit this + └── src/ + ├── App.tsx # Navigation root (Stack + Bottom Tabs) + ├── examples/ # "Tests" tab — focused feature examples + │ └── index.ts # Exports Examples array — register new screens here + ├── demos/ # "Demo Apps" tab — full mini-apps + │ └── index.ts # Exports demos array — register new screens here + ├── components/ # Shared UI: Container, Button, Slider, Spacer, Switch, Select + ├── utils/ + │ ├── soundEngines/ # Reusable synth classes (MetronomeSound, Kick, HiHat, Clap) + │ └── usePlayer.tsx # Hook for pattern-based scheduled playback + ├── singletons/ # Global audioContext and audioRecorder instances + └── styles.ts # Shared colors and layout constants +``` + +## Navigation + +``` +Bottom Tabs +├── Tests → 2-column grid of Examples (simple feature demos) +├── Demo Apps → Single-column list of Demos (full mini-apps) +└── Other → Placeholder +``` + +Each item navigates into a Stack screen. No manual stack registration needed — `examples/index.ts` and `demos/index.ts` drive it automatically. + +## Adding a New Example (Tests tab) + +Examples are focused single-feature demos (Oscillator, Metronome, AudioFile...). + +### 1. Create the screen + +``` +apps/common-app/src/examples/MyExample/ +├── index.ts # re-export default +└── MyExample.tsx # the screen component +``` + +```ts +// index.ts +export { default } from './MyExample'; +``` + +```tsx +// MyExample.tsx +import React, { useEffect, useRef, FC } from 'react'; +import { AudioContext, GainNode } from 'react-native-audio-api'; +import { Container, Button, Slider } from '../../components'; + +const MyExample: FC = () => { + const audioContextRef = useRef(null); + const gainRef = useRef(null); + + useEffect(() => { + audioContextRef.current = new AudioContext(); + return () => { + audioContextRef.current?.close(); + }; + }, []); + + return ( + + {/* UI here */} + + ); +}; + +export default MyExample; +``` + +### 2. Register in the index + +```ts +// apps/common-app/src/examples/index.ts +import MyExample from './MyExample'; +import { icons } from 'lucide-react-native'; // pick any icon + +export const Examples = [ + // ... existing + { + key: 'MyExample', + title: 'My Example', + Icon: icons.Zap, + screen: MyExample, + }, +]; +``` + +Done — the screen appears in the Tests tab automatically. + +## Adding a New Demo (Demo Apps tab) + +Demos are full mini-apps (PedalBoard, voice memo recorder...). + +### 1. Create the screen (same pattern as above) + +``` +apps/common-app/src/demos/MyDemo/ +└── MyDemo.tsx +``` + +### 2. Register in the index + +```ts +// apps/common-app/src/demos/index.ts +import MyDemo from './MyDemo/MyDemo'; + +export const demos = [ + // ... existing + { + key: 'MyDemo', + title: 'My Demo', + subtitle: 'One-line description shown in the list.', + icon: icons.Guitar, + screen: MyDemo, + }, +]; +``` + +## Code Patterns + +### Core rule: use `useRef` for audio nodes, `useState` for UI + +Audio nodes are C++ objects — they must not be re-created on every render. + +```tsx +// ✅ Correct +const gainRef = useRef(null); + +// ❌ Wrong — re-creates the node on every render +const [gain, setGain] = useState(null); +``` + +### Standard screen skeleton + +```tsx +const MyExample: FC = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [volume, setVolume] = useState(1.0); + + const audioContextRef = useRef(null); + const gainRef = useRef(null); + + // Initialize audio context once + useEffect(() => { + audioContextRef.current = new AudioContext(); + return () => { + audioContextRef.current?.close(); + }; + }, []); + + // Real-time parameter update + const handleVolumeChange = (value: number) => { + setVolume(value); + if (gainRef.current) { + gainRef.current.gain.value = value; + } + }; + + return ( + +