diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index b27465fad..617ddd48e 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -289,6 +289,18 @@ Callback IDs are stored as `std::atomic` on the node. `0` means no lis ### 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. +### Settable channel attributes (channelCount / channelCountMode / channelInterpretation) +These are mutable after construction. `AudioNode` (core) exposes virtual `setChannelCount` / `setChannelCountMode` / `setChannelInterpretation`. `channelCount` and `channelCountMode` are read only on the host thread during negotiation, so the JSI setter updates the core field directly then calls `HostNode::renegotiate()` → `Graph::renegotiateNodeChannels()` → `HostGraph::renegotiateNodeChannels()` (reuses `collectNegotiations` + an `AGEvent` buffer swap, self-drain aware when there is no audio/render consumer — offline construction/suspend and realtime suspended/stopped windows). When `AudioBufferSourceNode` `setBuffer` changes channel width, update `channelCount_` on the host thread then `renegotiate()` so MAX/CLAMPED_MAX downstream nodes update; the audio event still installs the prebuilt buffer (no audio-thread alloc). `channelInterpretation` is read on the audio thread in `processInputs` (`getInputBuffer()->sum(*input, channelInterpretation_)`), so it MUST be applied via `scheduleAudioEvent`, not mutated directly. + +### Idle-node stale-buffer gating (isProcessable() at the mix boundary) +`AudioGraph::iter()` filters to `isProcessable()` nodes, so a node that has gone idle (e.g. a finished source, or the gain feeding off it) is skipped and its output buffer is NOT refreshed — it keeps the samples from an earlier quantum. Because downstream consumers read input buffers via `pool_.view(input_head)` regardless of processable state, that stale buffer would otherwise get re-summed into the destination every quantum, producing ghost echoes (this broke the `audionode-channel-rules` ~170-node discrete-mixing WPT test). + +Fix: `GraphObject::process()` skips any input whose `isProcessable()` is false when collecting buffers to mix — an idle node presents silence to its consumers instead of a stale buffer. Nodes are visited in topological order (sources before consumers), so an upstream node's processable state for the quantum is already settled by the time a consumer reads it. + +For this gate to be correct, a node must stay `isProcessable()` for the **entire** quantum in which it produces its final output, and flip on the **next** quantum. A one-shot source would otherwise flip to `NOT_PROCESSABLE` mid-quantum (inside `processNode` → `handleStopScheduled` → `disable`), which — since consumers run later in the same topological pass — would drop that final output (produces `0` where a real sample was expected). `AudioNode::disable()` therefore only sets `pendingDisable_`; `AudioNode::processInputs()` commits `setProcessableState(NOT_PROCESSABLE)` at the start of the *next* quantum and presents silence. Deferral lives at this single chokepoint, so no per-source-type edits are needed. + +Tail-bearing nodes (Delay/Convolver/Biquad) need no special handling: while connected they stay `CONDITIONAL_PROCESSABLE` (processable via the first `isProcessable()` branch) so they are never skipped and never go stale; the `tailState_ != FINISHED` branch only matters after a disconnect, where either there is no consumer or consumers correctly gate on `isProcessable()`. + --- ## Implementing a New Node — Checklist @@ -328,7 +340,7 @@ All graph mutations are queued via `AudioGraphManager` using its own SPSC channe 7. **Spec compliance** - Check the Web Audio API spec for default values, parameter ranges, and behavior - - See `web-audio-api.md` skill + - See `web-audio-api` skill 8. **Tests and docs** — see the `flow` skill diff --git a/.claude/skills/flow/SKILL.md b/.claude/skills/flow/SKILL.md index c3b884ca5..f4f1f92e1 100644 --- a/.claude/skills/flow/SKILL.md +++ b/.claude/skills/flow/SKILL.md @@ -112,14 +112,17 @@ Files: `packages/react-native-audio-api/src/core/` 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); + constructor(context: BaseAudioContext, options?: MyNodeOptions) { + // Node-specific validation (if any) goes here, before create. + const node = context.context.createMyNode(options || {}); + // Pass options so AudioNode validates channelCount / mode / interpretation. + super(context, node, options); } // getters/setters forwarded to (this.node as IMyNode) } ``` + `MyNodeOptions` must extend `AudioNodeOptions`. Shared `AudioNodeOptions` validation lives in `AudioNode`'s constructor (`validateAudioNodeOptions`) — do not re-validate those fields in per-node validators. 2. Add a factory method `createMyNode(options?)` to `src/core/BaseAudioContext.ts`. 3. Export from `src/index.ts`. diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md index 08cd1ec5c..9aaea515a 100644 --- a/.claude/skills/host-objects/SKILL.md +++ b/.claude/skills/host-objects/SKILL.md @@ -30,6 +30,7 @@ Golden references: `GainNodeHostObject.h/.cpp` (effect node), `OscillatorNodeHos - **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. +- **Typed node pointers are `T *const`.** Mirror `AudioNode *const audioNode_` — declare as `GainNode *const gainNode_;` (const pointer, mutable object), initialize in the ctor initializer list via `typedAudioNode(node_)`. Never use `T *foo_ = nullptr`. --- diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index fe1f9ea1f..3bf5ae7b9 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -165,6 +165,8 @@ On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because O **Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`). +**Graph Channel A producer self-drain:** `Graph::setProducerSelfDrain(true)` makes the JS/main producer drain Channel A after each enqueue. Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend). Before disabling for `start`/`resume`/`renderAudio`, call `processEvents()` once (still as sole consumer) so the bounded channel is empty, then disable, then start the audio/render consumer; re-enable if start/resume fails. After enabling, call `processEvents()` once to flush backlog (avoids `WAIT_ON_FULL` deadlock if the channel was already full). + --- ## Common Mistakes diff --git a/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx b/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx new file mode 100644 index 000000000..9753ef876 --- /dev/null +++ b/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx @@ -0,0 +1,349 @@ +import React, { useEffect, useRef, useState, FC } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + AudioBuffer, + AudioBufferSourceNode, + AudioContext, + BaseAudioContext, + GainNode, + OfflineAudioContext, +} from 'react-native-audio-api'; +import type { + ChannelCountMode, + ChannelInterpretation, +} from 'react-native-audio-api'; + +import { Container, Slider, Spacer, Button, Select } from '../../components'; +import { colors, layout } from '../../styles'; + +// The source is a single buffer with SOURCE_CHANNELS channels, each carrying a +// distinct tone at an ascending amplitude (channel c peaks at (c + 1) / +// SOURCE_CHANNELS). Distinct per-channel content is what makes channelCount / +// channelCountMode / channelInterpretation changes measurable: with `discrete` +// the mix node keeps the first N channels and drops/zero-pads the rest, so the +// peak bars form a ramp that channelCount truncates. A stereo-only source would +// only ever populate 2 channels no matter how high channelCount goes. +const SOURCE_CHANNELS = 8; +const BASE_FREQUENCY = 220; + +const CHANNEL_COUNT_MODES: ChannelCountMode[] = [ + 'max', + 'clamped-max', + 'explicit', +]; +const CHANNEL_INTERPRETATIONS: ChannelInterpretation[] = [ + 'speakers', + 'discrete', +]; + +const ANALYSIS_CHANNELS = 8; +const labelWidth = 120; + +// Builds a multi-channel test buffer. Channel c is a sine at +// BASE_FREQUENCY * (c + 1) scaled to amplitude (c + 1) / SOURCE_CHANNELS. +function createTestBuffer(ctx: BaseAudioContext, frames: number): AudioBuffer { + const buffer = ctx.createBuffer(SOURCE_CHANNELS, frames, ctx.sampleRate); + for (let c = 0; c < SOURCE_CHANNELS; c += 1) { + const data = buffer.getChannelData(c); + const frequency = BASE_FREQUENCY * (c + 1); + const amplitude = (c + 1) / SOURCE_CHANNELS; + for (let i = 0; i < frames; i += 1) { + data[i] = + amplitude * Math.sin((2 * Math.PI * frequency * i) / ctx.sampleRate); + } + } + return buffer; +} + +const ChannelCount: FC = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [channelCount, setChannelCount] = useState(2); + const [channelCountMode, setChannelCountMode] = + useState('explicit'); + const [channelInterpretation, setChannelInterpretation] = + useState('discrete'); + const [analyzing, setAnalyzing] = useState(false); + const [peaks, setPeaks] = useState(null); + + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + const mixRef = useRef(null); + + const setup = () => { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + const ctx = audioContextRef.current; + + const source = ctx.createBufferSource(); + source.buffer = createTestBuffer(ctx, Math.floor(ctx.sampleRate)); + source.loop = true; + + const mix = ctx.createGain(); + mix.channelCountMode = channelCountMode; + mix.channelInterpretation = channelInterpretation; + mix.channelCount = channelCount; + + source.connect(mix); + mix.connect(ctx.destination); + + sourceRef.current = source; + mixRef.current = mix; + }; + + const teardown = () => { + sourceRef.current?.stop(0); + sourceRef.current = null; + mixRef.current = null; + }; + + const handlePlayPause = () => { + if (isPlaying) { + teardown(); + } else { + setup(); + sourceRef.current?.start(0); + } + + setIsPlaying((prev) => !prev); + }; + + // Live-apply attribute changes to the currently playing mix node. This is the + // real exercise for the renegotiation path: the graph must re-negotiate the + // channel layout mid-render without a glitch or crash. + const handleChannelCountChange = (value: number) => { + const next = Math.round(value); + setChannelCount(next); + if (mixRef.current) { + mixRef.current.channelCount = next; + } + }; + + const handleModeChange = (value: ChannelCountMode) => { + setChannelCountMode(value); + if (mixRef.current) { + mixRef.current.channelCountMode = value; + } + }; + + const handleInterpretationChange = (value: ChannelInterpretation) => { + setChannelInterpretation(value); + if (mixRef.current) { + mixRef.current.channelInterpretation = value; + } + }; + + // Deterministic verification: render the same graph offline with the current + // settings and report the peak amplitude of every output channel. The + // destination is forced to discrete/explicit so per-channel content is + // preserved for measurement instead of being mixed down. + const handleAnalyze = async () => { + if (analyzing) { + return; + } + setAnalyzing(true); + + try { + const sampleRate = 44100; + const frames = 4096; + const ctx = new OfflineAudioContext( + ANALYSIS_CHANNELS, + frames, + sampleRate + ); + ctx.destination.channelCount = ANALYSIS_CHANNELS; + ctx.destination.channelCountMode = 'explicit'; + ctx.destination.channelInterpretation = 'discrete'; + + const source = ctx.createBufferSource(); + source.buffer = createTestBuffer(ctx, frames); + + const mix = ctx.createGain(); + mix.channelCountMode = channelCountMode; + mix.channelInterpretation = channelInterpretation; + mix.channelCount = channelCount; + + source.connect(mix); + mix.connect(ctx.destination); + + source.start(0); + + const rendered = await ctx.startRendering(); + + const nextPeaks: number[] = []; + for (let c = 0; c < rendered.numberOfChannels; c += 1) { + const data = rendered.getChannelData(c); + let peak = 0; + for (let i = 0; i < data.length; i += 1) { + const abs = Math.abs(data[i]); + if (abs > peak) { + peak = abs; + } + } + nextPeaks.push(peak); + } + setPeaks(nextPeaks); + } catch (error) { + console.error('Channel analysis failed:', error); + } finally { + setAnalyzing(false); + } + }; + + useEffect(() => { + return () => { + audioContextRef.current?.close(); + audioContextRef.current = null; + }; + }, []); + + return ( + +