Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/skills/audio-nodes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ enum class PlaybackState {

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, {})`.
When the node finishes, `AudioScheduledSourceNode::disable()` fires `ENDED` through its `EventCaller` (`dispatchEmptyFromAudioThread`); a node that will never render (stop-before-start) defers it with `EventCaller::deferEmpty` instead — see `thread-safety-itc`.

Pitfalls:
- `start(double)` in this base class is a separate overload from `AudioBufferSourceNode::start(when, offset, duration)`. Calling `node->start(when)` on an ABSN skips the derived logic; the HostObject always passes three arguments, and so must tests.
- `AudioBufferBaseSourceNode::processNode` is `final` and returns early on `isEmpty()`. Whether a started-but-empty source ends there (`ended` fires) is `endsWhenStartedEmpty()`: the base default is true (spec: a null buffer forces `stop = currentTime`); `AudioBufferQueueSourceNode` overrides it to false because an empty queue is waiting for the next enqueue. The check runs per quantum on the audio thread on purpose, because the buffer setter legitimately acquires content assigned after `start()` but before the first render.

---

Expand Down
33 changes: 33 additions & 0 deletions .claude/skills/build-compilation-dependencies/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,39 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`)
| 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` |
| clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` |

## clangd compile database

`packages/react-native-audio-api/common/cpp/clangd/` generates the repo-root
`compile_commands.json` that clangd reads (`yarn setup:clangd`, or
`yarn setup:clangd:clean` after a native dependency bump). It is editor tooling
only — it never participates in a real build. See its `SETUP.md`.

The iOS half reuses whatever `pod install` resolved for `apps/fabric-example`,
reading **both** `HEADER_SEARCH_PATHS` and `FRAMEWORK_SEARCH_PATHS` out of
`Pods/Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig`.

Framework search paths matter because React-Core ships prebuilt as
`React.xcframework` since RN 0.87: `#import <React/RCTBridgeModule.h>` resolves
via `-F`, not `-I`, so an `-I`-only database fails every `.mm` that imports
React. Xcode's own `-F` points at `PODS_XCFRAMEWORKS_BUILD_DIR`, which only
exists after Xcode has unpacked the slices — the CMakeLists globs the
`ios-*simulator*` slices inside the `.xcframework` bundles instead, so a bare
`pod install` suffices.

To check the database rather than guessing at clangd, replay an entry directly:

```bash
python3 -c "
import json,shlex,subprocess
e=[x for x in json.load(open('compile_commands.json')) if x['file'].endswith('ios/AudioAPIModule.mm')][0]
toks=[t for t in shlex.split(e['command']) if t!='-c']
subprocess.run(toks[:toks.index('-o')]+toks[toks.index('-o')+2:]+['-fsyntax-only'], cwd=e['directory'])
"
```

If that reproduces the error, the database is wrong — not the editor's LSP setup.

---

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Throttled events (e.g. `positionChanged`) use `PositionChangedDispatcher`, which
- Clear callbacks in the **most-derived HostObject first** (each layer clears its own events; base destructors run afterward and clear parent events)
- Do **not** inherit `EventCaller` on multiple node bases (ambiguous API); add member `EventCaller` fields per event instead

**Time-delayed dispatch outside the node's lifetime** (e.g. firing `ended` at a context time the render path will not reach for this node): use `BaseAudioContext::deferEmptyEventDispatch(event, callbackId, dueTime)`. The context keeps a preallocated pending list drained each quantum in `processAudioEvents()`; the context clock is the timer (suspend pauses it, offline fires at render speed) and the context holds no node reference. Never use a detached timer thread for this — spawning a thread from an audio-event body runs on the audio thread (allocation + syscalls), and every capture choice has been a real bug in `AudioScheduledSourceNode::stop()`: raw `this` was a use-after-free, `weak_from_this().lock()` ran the node destructor on the timer thread (bypassing the graph's disposal path), and `std::move`-ing the `EventCaller` member raced the JS/GC-thread `assignCallbackId` paths (the registry `shared_ptr` inside `EventCaller` is only safe for concurrent access while it is never mutated — which is why `EventCaller` is non-movable).
**Time-delayed dispatch outside the node's lifetime** (e.g. firing `ended` at a context time the render path will not reach for this node): use `EventCaller::deferEmpty(context->getDeferredEvents(), dueTime)`. The context keeps a preallocated `DeferredEventQueue` drained each quantum in `processAudioEvents()`; the context clock is the timer (suspend pauses it, offline fires at render speed) and the context holds no node reference. Never use a detached timer thread for this — spawning a thread from an audio-event body runs on the audio thread (allocation + syscalls), and every capture choice has been a real bug in `AudioScheduledSourceNode::stop()`: raw `this` was a use-after-free, `weak_from_this().lock()` ran the node destructor on the timer thread (bypassing the graph's disposal path), and `std::move`-ing the `EventCaller` member raced the JS/GC-thread `assignCallbackId` paths (the registry `shared_ptr` inside `EventCaller` is only safe for concurrent access while it is never mutated — which is why `EventCaller` is non-movable).

Low-level registry API (used internally by `EventCaller`):

Expand Down
1 change: 1 addition & 0 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ const Record: FC = () => {

const onStopRecording = useCallback(async () => {
const info = await Recorder.stop();
AudioRecorder.consumeLastRecordingResult();
RecordingNotificationManager.hide();
setIsInterrupted(false);
setState(RecordingState.Loading);
Expand Down
2 changes: 1 addition & 1 deletion apps/fabric-example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 2fbbdb3fb754264aa345dc912d51c3c6d295cdd9

COCOAPODS: 1.17.0
COCOAPODS: 1.16.2
Loading
Loading