diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 490fbe3a4..67427ff37 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -382,6 +382,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | 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` | | iOS: `'to_chars' is unavailable: introduced in iOS 16.3` from `formatter_floating_point.h`, instantiated by `std::format<...>` | `std::format` in code compiled for iOS. libc++ availability-gates the whole `` library to iOS 16.3; the podspec minimum is `ios_min_version = '14.0'`. The desktop C++ test build and Android NDK have no such gate, so `yarn test:cpp` passes and only the iOS build fails. | Use `std::string` concatenation / `std::to_string` in `common/cpp` and `ios/`. Zero-pad by hand (`insert(0, n, '0')`). Android-only files (`android/src/main/cpp`) may keep `std::format`. | +| Android incremental build only: `Unresolved reference` to a top-level Kotlin function, or `Cannot access '…': it is internal in file`, while `--rerun-tasks` passes | Incremental Kotlin rounds treat the module's own earlier classes as a foreign module (library is built through the `node_modules/react-native-audio-api` symlink) | Use only public classes/objects across files in `android/src/main/java`; no top-level functions, no `internal` | | 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 diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index f41991963..a3ddd6b9b 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -176,6 +176,17 @@ Tests stay deterministic by writing at most a pool's worth of buffers (32) per o --- +## Convolver tail prefetch: never `wait()` on the audio thread + +`ConvolverNode` does not block on `ConvolverThreadPool`. `Convolver` (`dsp/Convolver.h`) splits the uniformly partitioned IR at `HEAD_SEGMENTS` (8 blocks): the head is multiplied on the audio thread in `process()`, the tail only needs input that is ≥ 8 blocks old, so `ConvolverNode::scheduleTails()` hands `computeTail(nextTailBlock())` to the pool right after `process()` and the worker has 8 quanta (≈21 ms) of slack. Output block k is Σ_i X[k-i]·H[i] over the IR partitions H[i]; only the first `HEAD_SEGMENTS` terms depend on recent input, which is why the tail of block k+HEAD_SEGMENTS is computable as soon as block k is in. A tail that is not published in time is computed inline for that block (the private `_tailMisses` counter, read in tests through `ConvolverInspector`) — a late worker costs CPU, never a stall or a glitch. Rules that keep it lock-free: + +- Tasks capture the `Convolver` by `shared_ptr` (never `this` / a node member), so a node released mid-flight cannot leave a dangling read (the second half of issue #1339). +- Each of the `TAIL_SLOT_COUNT` (= HEAD_SEGMENTS + 1) result slots has one atomic `state`: `readyState(block) = (block + 1) << 1`, plus `BUSY_BIT` while a worker writes. A worker claims with one `compare_exchange_strong` that succeeds only if the slot is neither busy nor owned by a newer block, so a straggler preempted anywhere can never overwrite a newer block's spectrum — a separate deadline re-check could not guarantee that. The claim `memset`s the spectrum before accumulating (`accumulateSegments` adds), and publishes `readyState(block)` with release. +- `process(block)` uses a slot only on an exact `== readyState(block)` match (acquire). That match proves the worker's delay-line reads finished before `process(block)`, and the first overwrite of anything it read is `process(block + 9)`, so a consumed result is always built from valid input. The audio thread never writes a slot outside `init`. +- A worker still reading the delay line ≥ 17 quanta after scheduling races `doFFT` on `_segments` (formally UB, result tagged with an already rendered block, so never read). Known and accepted; early abort in `accumulateSegments` would make it rarer, not impossible. +- The node hands tails over with `ThreadPool::trySchedule` (wait-free, `try_send` on the load-balancer queue). `schedule()` sends `WAIT_ON_FULL` and blocks the caller when the load-balancer queue (`CONVOLVER_THREAD_POOL_LOAD_BALANCER_QUEUE_SIZE`) is full, so it must never be called from the audio thread (in a device bench with starved workers the queue never actually filled — the balancer drains it in microseconds — but the contract matters more than the odds). A refused tail is just a miss: `process()` computes it inline. +- `ThreadPool::wait()` waits on `tasksScheduled` itself (atomic wait loop), not on a separate flag: a worker that finished batch N and was preempted before notifying could otherwise release the waiter of batch N+1 (issue #1339). Nothing on the audio thread should call `wait()` at all. + ## Driver synchronization (layered model) Control-plane synchronization uses two layers — both are non-recursive `std::mutex`, never held on the audio thread. diff --git a/apps/fabric-example/ios/FabricExample.xcodeproj/project.pbxproj b/apps/fabric-example/ios/FabricExample.xcodeproj/project.pbxproj index 201a7af99..91ec328de 100644 --- a/apps/fabric-example/ios/FabricExample.xcodeproj/project.pbxproj +++ b/apps/fabric-example/ios/FabricExample.xcodeproj/project.pbxproj @@ -8,13 +8,12 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; - 72E155D29021B19B49CFFC7C /* libPods-FabricExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C9E7DF0751531A2FB99EAFAA /* libPods-FabricExample.a */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780EF2CA45674006654EF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EE2CA45674006654EF /* SceneDelegate.swift */; }; - 7B6358F2E70964A524B87A35 /* libPods-FabricExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 923414A8CA62CFABDD4F960C /* libPods-FabricExampleTests.a */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 88485BE83AA320DC6673875F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; - 8D9351B21F62C33242F94D0A /* libPods-FabricExampleTestHost.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BAAE2A638C2263C3AA0B8ED4 /* libPods-FabricExampleTestHost.a */; }; + 92B7973CB13EBA699294BC72 /* libPods-FabricExampleTestHost.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E551BBA4AEA2C0CC48241A0 /* libPods-FabricExampleTestHost.a */; }; + 9801342285B22699A40B0197 /* libPods-FabricExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F86D9719F1086F75FC2B7841 /* libPods-FabricExample.a */; }; A1B2C3D41E00000100A0A001 /* AudioEngineTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D61E00000100A0A001 /* AudioEngineTests.mm */; }; A1B2C3E01E00000100A0A001 /* AudioSessionManagerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3E11E00000100A0A001 /* AudioSessionManagerTests.mm */; }; A1B2C3F01E00000100A0A001 /* SystemNotificationManagerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3F11E00000100A0A001 /* SystemNotificationManagerTests.mm */; }; @@ -23,6 +22,7 @@ A1B2C4201E00000100A0A001 /* IOSAudioRecorderTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4211E00000100A0A001 /* IOSAudioRecorderTests.mm */; }; A1B2C4301E00000100A0A001 /* AudioAPIModuleTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4311E00000100A0A001 /* AudioAPIModuleTests.mm */; }; B2C3D541E00000100A0A001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D511E00000100A0A001 /* AppDelegate.swift */; }; + FC287B6686C1B543EDE91ED2 /* libPods-FabricExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 39609459D2950A9119D6F65B /* libPods-FabricExampleTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,15 +36,20 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 02B600919D8DA326A514B148 /* Pods-FabricExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExample.release.xcconfig"; path = "Target Support Files/Pods-FabricExample/Pods-FabricExample.release.xcconfig"; sourceTree = ""; }; + 09E51E7D4925E17B2310506C /* Pods-FabricExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExample.debug.xcconfig"; path = "Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* FabricExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FabricExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = FabricExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = FabricExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = FabricExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 6FC1F94E16360B0045C20780 /* Pods-FabricExampleTestHost.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTestHost.debug.xcconfig"; path = "Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost.debug.xcconfig"; sourceTree = ""; }; + 39609459D2950A9119D6F65B /* libPods-FabricExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 3E551BBA4AEA2C0CC48241A0 /* libPods-FabricExampleTestHost.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExampleTestHost.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 40AE7C0005B23463081DC5AA /* Pods-FabricExampleTestHost.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTestHost.release.xcconfig"; path = "Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost.release.xcconfig"; sourceTree = ""; }; + 65C0BA6C68E9C97AF8105BA6 /* Pods-FabricExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTests.release.xcconfig"; path = "Target Support Files/Pods-FabricExampleTests/Pods-FabricExampleTests.release.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = FabricExample/AppDelegate.swift; sourceTree = ""; }; 761780EE2CA45674006654EF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = FabricExample/SceneDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = FabricExample/LaunchScreen.storyboard; sourceTree = ""; }; - 923414A8CA62CFABDD4F960C /* libPods-FabricExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 8506E6020FADC5A51DAFF680 /* Pods-FabricExampleTestHost.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTestHost.debug.xcconfig"; path = "Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost.debug.xcconfig"; sourceTree = ""; }; A1B2C3D51E00000100A0A001 /* FabricExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FabricExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D61E00000100A0A001 /* AudioEngineTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = AudioEngineTests.mm; sourceTree = ""; }; A1B2C3E11E00000100A0A001 /* AudioSessionManagerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = AudioSessionManagerTests.mm; sourceTree = ""; }; @@ -53,17 +58,12 @@ A1B2C4111E00000100A0A001 /* NativeAudioRecorderTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = NativeAudioRecorderTests.mm; sourceTree = ""; }; A1B2C4211E00000100A0A001 /* IOSAudioRecorderTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = IOSAudioRecorderTests.mm; sourceTree = ""; }; A1B2C4311E00000100A0A001 /* AudioAPIModuleTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = AudioAPIModuleTests.mm; sourceTree = ""; }; + A40296310E259D7AD6261782 /* Pods-FabricExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-FabricExampleTests/Pods-FabricExampleTests.debug.xcconfig"; sourceTree = ""; }; B2C3D501E00000100A0A001 /* FabricExampleTestHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FabricExampleTestHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; B2C3D511E00000100A0A001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B2C3D521E00000100A0A001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B968F87C4FAA0FE0B1F6725C /* Pods-FabricExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-FabricExampleTests/Pods-FabricExampleTests.debug.xcconfig"; sourceTree = ""; }; - BAAE2A638C2263C3AA0B8ED4 /* libPods-FabricExampleTestHost.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExampleTestHost.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - BAE7A3147A5D289130F550A5 /* Pods-FabricExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExample.release.xcconfig"; path = "Target Support Files/Pods-FabricExample/Pods-FabricExample.release.xcconfig"; sourceTree = ""; }; - C032367DA4D82DA1F90C924B /* Pods-FabricExampleTestHost.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTestHost.release.xcconfig"; path = "Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost.release.xcconfig"; sourceTree = ""; }; - C9E7DF0751531A2FB99EAFAA /* libPods-FabricExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; - F3C3DFD474A515B7270B0F85 /* Pods-FabricExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExampleTests.release.xcconfig"; path = "Target Support Files/Pods-FabricExampleTests/Pods-FabricExampleTests.release.xcconfig"; sourceTree = ""; }; - F8654E70680A3D1A5A67D2BE /* Pods-FabricExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FabricExample.debug.xcconfig"; path = "Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig"; sourceTree = ""; }; + F86D9719F1086F75FC2B7841 /* libPods-FabricExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-FabricExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -71,7 +71,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 72E155D29021B19B49CFFC7C /* libPods-FabricExample.a in Frameworks */, + 9801342285B22699A40B0197 /* libPods-FabricExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,7 +79,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7B6358F2E70964A524B87A35 /* libPods-FabricExampleTests.a in Frameworks */, + FC287B6686C1B543EDE91ED2 /* libPods-FabricExampleTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,7 +87,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 8D9351B21F62C33242F94D0A /* libPods-FabricExampleTestHost.a in Frameworks */, + 92B7973CB13EBA699294BC72 /* libPods-FabricExampleTestHost.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -111,9 +111,9 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - C9E7DF0751531A2FB99EAFAA /* libPods-FabricExample.a */, - BAAE2A638C2263C3AA0B8ED4 /* libPods-FabricExampleTestHost.a */, - 923414A8CA62CFABDD4F960C /* libPods-FabricExampleTests.a */, + F86D9719F1086F75FC2B7841 /* libPods-FabricExample.a */, + 3E551BBA4AEA2C0CC48241A0 /* libPods-FabricExampleTestHost.a */, + 39609459D2950A9119D6F65B /* libPods-FabricExampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -177,12 +177,12 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - F8654E70680A3D1A5A67D2BE /* Pods-FabricExample.debug.xcconfig */, - BAE7A3147A5D289130F550A5 /* Pods-FabricExample.release.xcconfig */, - 6FC1F94E16360B0045C20780 /* Pods-FabricExampleTestHost.debug.xcconfig */, - C032367DA4D82DA1F90C924B /* Pods-FabricExampleTestHost.release.xcconfig */, - B968F87C4FAA0FE0B1F6725C /* Pods-FabricExampleTests.debug.xcconfig */, - F3C3DFD474A515B7270B0F85 /* Pods-FabricExampleTests.release.xcconfig */, + 09E51E7D4925E17B2310506C /* Pods-FabricExample.debug.xcconfig */, + 02B600919D8DA326A514B148 /* Pods-FabricExample.release.xcconfig */, + 8506E6020FADC5A51DAFF680 /* Pods-FabricExampleTestHost.debug.xcconfig */, + 40AE7C0005B23463081DC5AA /* Pods-FabricExampleTestHost.release.xcconfig */, + A40296310E259D7AD6261782 /* Pods-FabricExampleTests.debug.xcconfig */, + 65C0BA6C68E9C97AF8105BA6 /* Pods-FabricExampleTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -194,7 +194,7 @@ isa = PBXNativeTarget; buildConfigurationList = A1B2C3DF1E00000100A0A001 /* Build configuration list for PBXNativeTarget "FabricExampleTests" */; buildPhases = ( - B56D4272B795D0A9B13660E7 /* [CP] Check Pods Manifest.lock */, + A4BD881732474AB0F0CDC393 /* [CP] Check Pods Manifest.lock */, A1B2C3DC1E00000100A0A001 /* Sources */, A1B2C3D71E00000100A0A001 /* Frameworks */, A1B2C3DB1E00000100A0A001 /* Resources */, @@ -213,13 +213,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "FabricExample" */; buildPhases = ( - 937122BD730162E46F07A308 /* [CP] Check Pods Manifest.lock */, + 5CB647908064CAA8C78BEFBC /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 09A340C4663365FDDB328C14 /* [CP] Embed Pods Frameworks */, - 15415C7CB07DA12B8FF22C41 /* [CP] Copy Pods Resources */, + 17A0ADD6AB7FB9F7E986EFCC /* [CP] Embed Pods Frameworks */, + 563A4003C6923956D457DDE5 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -234,12 +234,12 @@ isa = PBXNativeTarget; buildConfigurationList = B2C3D5D1E00000100A0A001 /* Build configuration list for PBXNativeTarget "FabricExampleTestHost" */; buildPhases = ( - 03229AA58BBBBE20903E7ACF /* [CP] Check Pods Manifest.lock */, + B59B78BADEEF9B73AF4F7E98 /* [CP] Check Pods Manifest.lock */, B2C3D571E00000100A0A001 /* Sources */, B2C3D581E00000100A0A001 /* Frameworks */, B2C3D591E00000100A0A001 /* Resources */, - 6E955C370A9853425DB7D9F3 /* [CP] Embed Pods Frameworks */, - 0DB5EE6B069304FBBCA658FD /* [CP] Copy Pods Resources */, + 991652A8F135F548711701AF /* [CP] Embed Pods Frameworks */, + F67A58149B3960C35439D12D /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -331,29 +331,7 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; - 03229AA58BBBBE20903E7ACF /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-FabricExampleTestHost-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 09A340C4663365FDDB328C14 /* [CP] Embed Pods Frameworks */ = { + 17A0ADD6AB7FB9F7E986EFCC /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -370,41 +348,46 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 0DB5EE6B069304FBBCA658FD /* [CP] Copy Pods Resources */ = { + 563A4003C6923956D457DDE5 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 15415C7CB07DA12B8FF22C41 /* [CP] Copy Pods Resources */ = { + 5CB647908064CAA8C78BEFBC /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FabricExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExample/Pods-FabricExample-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 6E955C370A9853425DB7D9F3 /* [CP] Embed Pods Frameworks */ = { + 991652A8F135F548711701AF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -421,7 +404,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 937122BD730162E46F07A308 /* [CP] Check Pods Manifest.lock */ = { + A4BD881732474AB0F0CDC393 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -436,14 +419,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-FabricExample-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-FabricExampleTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - B56D4272B795D0A9B13660E7 /* [CP] Check Pods Manifest.lock */ = { + B59B78BADEEF9B73AF4F7E98 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -458,13 +441,30 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-FabricExampleTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-FabricExampleTestHost-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + F67A58149B3960C35439D12D /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FabricExampleTestHost/Pods-FabricExampleTestHost-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -512,7 +512,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F8654E70680A3D1A5A67D2BE /* Pods-FabricExample.debug.xcconfig */; + baseConfigurationReference = 09E51E7D4925E17B2310506C /* Pods-FabricExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -552,7 +552,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BAE7A3147A5D289130F550A5 /* Pods-FabricExample.release.xcconfig */; + baseConfigurationReference = 02B600919D8DA326A514B148 /* Pods-FabricExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -782,7 +782,7 @@ }; A1B2C3DD1E00000100A0A001 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B968F87C4FAA0FE0B1F6725C /* Pods-FabricExampleTests.debug.xcconfig */; + baseConfigurationReference = A40296310E259D7AD6261782 /* Pods-FabricExampleTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -810,7 +810,7 @@ }; A1B2C3DE1E00000100A0A001 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F3C3DFD474A515B7270B0F85 /* Pods-FabricExampleTests.release.xcconfig */; + baseConfigurationReference = 65C0BA6C68E9C97AF8105BA6 /* Pods-FabricExampleTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -839,7 +839,7 @@ }; B2C3D5E1E00000100A0A001 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6FC1F94E16360B0045C20780 /* Pods-FabricExampleTestHost.debug.xcconfig */; + baseConfigurationReference = 8506E6020FADC5A51DAFF680 /* Pods-FabricExampleTestHost.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; @@ -878,7 +878,7 @@ }; B2C3D5F1E00000100A0A001 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C032367DA4D82DA1F90C924B /* Pods-FabricExampleTestHost.release.xcconfig */; + baseConfigurationReference = 40AE7C0005B23463081DC5AA /* Pods-FabricExampleTestHost.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp index 4f72bd149..903f26d2f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp @@ -78,16 +78,16 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff } auto threadPool = context->getConvolverThreadPool(); - std::vector> convolvers; + std::vector> convolvers; for (size_t i = 0; i < copiedBuffer->getNumberOfChannels(); ++i) { AudioArray channelData(*copiedBuffer->getChannel(i)); - convolvers.push_back(std::make_unique()); + convolvers.push_back(std::make_shared()); convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); } if (copiedBuffer->getNumberOfChannels() == 1) { // add one more convolver, because right now input is always stereo AudioArray channelData(*copiedBuffer->getChannel(0)); - convolvers.push_back(std::make_unique()); + convolvers.push_back(std::make_shared()); convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); } @@ -98,7 +98,7 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff struct SetupData { std::shared_ptr buffer; - std::vector> convolvers; + std::vector> convolvers; std::shared_ptr threadPool; std::shared_ptr internalBuffer; std::shared_ptr intermediateBuffer; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index 14dd8e2dc..3be3f454e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -120,8 +120,10 @@ utils::DisposerImpl *BaseAudioContext::getDisposer() cons std::shared_ptr BaseAudioContext::getConvolverThreadPool() { if (convolverThreadPool_ == nullptr) { - convolverThreadPool_ = - std::make_shared(CONVOLVER_THREAD_POOL_WORKER_COUNT); + convolverThreadPool_ = std::make_shared( + CONVOLVER_THREAD_POOL_WORKER_COUNT, + CONVOLVER_THREAD_POOL_LOAD_BALANCER_QUEUE_SIZE, + CONVOLVER_THREAD_POOL_WORKER_QUEUE_SIZE); } return convolverThreadPool_; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index cb28d1086..05b864954 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -97,8 +97,9 @@ class BaseAudioContext : public std::enable_shared_from_this { /// @brief Lazily created pool shared by every ConvolverNode of this context. Nodes render /// sequentially on the one render thread, so sharing keeps the pool single-producer. + /// @return The pool, or null when the context wants convolver tails computed inline. /// @note JS thread only. - std::shared_ptr getConvolverThreadPool(); + virtual std::shared_ptr getConvolverThreadPool(); /// @brief Assigns the audio destination node to the context. /// @param destination The audio destination node to be associated with the context. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp index c16c2b10a..3ea287be8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp @@ -163,6 +163,10 @@ void OfflineAudioContext::startRendering(const std::shared_ptr OfflineAudioContext::getConvolverThreadPool() { + return nullptr; +} + bool OfflineAudioContext::isDriverRunning() const { return state_.load(std::memory_order_acquire) == ContextState::RUNNING; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.h index 3e6610e71..8f3b3520b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.h @@ -26,6 +26,9 @@ class OfflineAudioContext : public BaseAudioContext { bool suspend(double when, const std::shared_ptr &promise); void startRendering(const std::shared_ptr &promise); + /// @brief Always null, so convolver tails are computed inline on the render thread. + std::shared_ptr getConvolverThreadPool() override; + private: std::unordered_map> scheduledSuspends_; std::shared_ptr resultPromise_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp index 767a4634d..e76e4ea95 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #ifdef ANDROID #include @@ -27,7 +28,7 @@ ConvolverNode::ConvolverNode( void ConvolverNode::setBuffer( const std::shared_ptr &buffer, - std::vector> convolvers, + std::vector> convolvers, const std::shared_ptr &threadPool, const std::shared_ptr &internalBuffer, const std::shared_ptr &intermediateBuffer, @@ -155,10 +156,8 @@ int ConvolverNode::computeTailFrames() const { void ConvolverNode::performConvolution(const std::shared_ptr &processingBuffer) { if (processingBuffer->getNumberOfChannels() == 1) { for (int i = 0; i < convolvers_.size(); ++i) { - threadPool_->schedule([&, i] { - convolvers_[i]->process( - *processingBuffer->getChannel(0), *intermediateBuffer_->getChannel(i)); - }); + convolvers_[i]->process( + *processingBuffer->getChannel(0), *intermediateBuffer_->getChannel(i)); } } else if (processingBuffer->getNumberOfChannels() == 2) { if (convolvers_.size() == 2) { @@ -169,13 +168,26 @@ void ConvolverNode::performConvolution(const std::shared_ptr &pr outputChannelMap_ = {0, 3, 2, 1}; } for (int i = 0; i < convolvers_.size(); ++i) { - threadPool_->schedule([this, i, &processingBuffer] { - convolvers_[i]->process( - *processingBuffer->getChannel(inputChannelMap_[i]), - *intermediateBuffer_->getChannel(outputChannelMap_[i])); - }); + convolvers_[i]->process( + *processingBuffer->getChannel(inputChannelMap_[i]), + *intermediateBuffer_->getChannel(outputChannelMap_[i])); } } - threadPool_->wait(); + scheduleTails(); +} + +void ConvolverNode::scheduleTails() { + if (threadPool_ == nullptr) { + return; + } + for (const auto &convolver : convolvers_) { + if (!convolver->hasTail()) { + continue; + } + // A full queue means the workers are starved; the tail is then simply never published + // and process() computes it inline when that block comes up. + (void)threadPool_->trySchedule( + [convolver, block = convolver->nextTailBlock()] { convolver->computeTail(block); }); + } } } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h index 90060aca3..ef22d80ee 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h @@ -28,7 +28,7 @@ class ConvolverNode : public AudioNode { /// @note Audio Thread only void setBuffer( const std::shared_ptr &buffer, - std::vector> convolvers, + std::vector> convolvers, const std::shared_ptr &threadPool, const std::shared_ptr &internalBuffer, const std::shared_ptr &intermediateBuffer, @@ -56,12 +56,17 @@ class ConvolverNode : public AudioNode { // buffer to hold internal processed data std::shared_ptr internalBuffer_; // vectors of convolvers, one per channel - std::vector> convolvers_; + std::vector> convolvers_; std::shared_ptr threadPool_; std::array inputChannelMap_; std::array outputChannelMap_; void performConvolution(const std::shared_ptr &processingBuffer); + + /// @brief Hands each convolver's next tail block to the pool. Tasks own their convolver + /// through a shared_ptr, so a node released mid-flight cannot leave a dangling read. + /// @note Audio Thread only. Never waits for the pool, not even on a full queue. + void scheduleTails(); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h index d9dc5a4f8..48678b63c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h @@ -34,6 +34,9 @@ inline constexpr size_t PROMISE_VENDOR_THREAD_POOL_LOAD_BALANCER_QUEUE_SIZE = 32 inline constexpr size_t PROMISE_VENDOR_THREAD_POOL_WORKER_QUEUE_SIZE = 32; // One partitioned-convolution job per IR channel (at most 4) runs in parallel. inline constexpr size_t CONVOLVER_THREAD_POOL_WORKER_COUNT = 4; +// Every convolver queues one tail per quantum; a refused tail is computed on the audio thread. +inline constexpr size_t CONVOLVER_THREAD_POOL_LOAD_BALANCER_QUEUE_SIZE = 128; +inline constexpr size_t CONVOLVER_THREAD_POOL_WORKER_QUEUE_SIZE = 128; // Disposer payload size (= sizeof(std::vector)) inline constexpr size_t DISPOSER_PAYLOAD_SIZE = 24; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp index 4d8beaf6c..34dd9b687 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace audioapi { @@ -20,19 +21,20 @@ Convolver::Convolver() _segSize(0), _segCount(0), _fftComplexSize(0), - _fft(nullptr), - _current(0) {} + _fft(nullptr) {} void Convolver::reset() { _blockSize = 0; _segSize = 0; _segCount = 0; _fftComplexSize = 0; - _current = 0; _fft = nullptr; _segments.clear(); _segmentsIR.clear(); _preMultiplied.clear(); + _tailSlots.clear(); + _blocksProcessed.store(0, std::memory_order_relaxed); + _tailMisses = 0; if (_fftBuffer != nullptr) { _fftBuffer->zero(); } @@ -67,10 +69,11 @@ bool Convolver::init(size_t blockSize, const AudioArray &ir, size_t irLen) { _fftBuffer = std::make_unique(_segSize); // segments preparation - for (int i = 0; i < _segCount; ++i) { - aligned_vec_complex vec(_fftComplexSize, std::complex(0.0f, 0.0f)); - _segments.push_back(vec); - } + // The spare HEAD_SEGMENTS slots keep the blocks a tail worker reads from + // being overwritten while its result can still be used. + _segments.assign( + _segCount + HEAD_SEGMENTS, + aligned_vec_complex(_fftComplexSize, std::complex(0.0f, 0.0f))); // ir preparation for (int i = 0; i < _segCount; ++i) { @@ -78,23 +81,38 @@ bool Convolver::init(size_t blockSize, const AudioArray &ir, size_t irLen) { const size_t remainingSamples = irLen - (i * _blockSize); const size_t samplesToCopy = std::min(_blockSize, remainingSamples); + // Each sub filter is zero-padded to length 2B and transformed using a + // 2B-point real-to-complex FFT. The whole buffer is cleared so a final, + // partial sub filter does not keep samples of the previous one. + _fftBuffer->zero(); if (samplesToCopy > 0) { _fftBuffer->copy(ir, i * _blockSize, 0, samplesToCopy); } - // Each sub filter is zero-padded to length 2B and transformed using a - // 2B-point real-to-complex FFT. - _fftBuffer->zero(_blockSize, _blockSize); _fft->doFFT(*_fftBuffer, segment); _segmentsIR.push_back(segment); } _preMultiplied = aligned_vec_complex(_fftComplexSize); _inputBuffer = std::make_unique(_segSize); - _current = 0; + + _tailSlots = std::vector(TAIL_SLOT_COUNT); + for (auto &slot : _tailSlots) { + slot.spectrum.assign(_fftComplexSize, std::complex(0.0f, 0.0f)); + } + // The tails of the first HEAD_SEGMENTS blocks only reach input from before the stream + // started, so they are silent and ready up front. + for (size_t block = 0; block < HEAD_SEGMENTS; ++block) { + _tailSlots[block % TAIL_SLOT_COUNT].state.store(readyState(block), std::memory_order_relaxed); + } return true; } +const Convolver::aligned_vec_complex &Convolver::inputSpectrum(size_t block, size_t age) const { + const size_t ringSize = _segments.size(); + return _segments[(block % ringSize + ringSize - age) % ringSize]; +} + /// @brief Fast pairwise complex multiplication using ARM NEON intrinsics /// @param ir Impulse response /// @param audio Input audio signal @@ -167,30 +185,52 @@ void pairwise_complex_multiply_fast( #endif } +void Convolver::accumulateSegments(size_t block, size_t fromSegment, aligned_vec_complex &into) + const { + for (size_t age = fromSegment; age < _segCount; ++age) { + pairwise_complex_multiply_fast(_segmentsIR[age], inputSpectrum(block, age), into); + } +} + void Convolver::process(const DSPAudioArray &input, DSPAudioArray &output) { + if (_segCount == 0) { + output.zero(); + return; + } + const size_t block = _blocksProcessed.load(std::memory_order_relaxed); + // The input buffer acts as a 2B-point sliding window of the input signal. // With each new input block, the right half of the input buffer is shifted // to the left and the new block is stored in the right half. _inputBuffer->copyWithin(_blockSize, 0, _blockSize); _inputBuffer->copy(input, 0, _blockSize, _blockSize); - // All contents (DFT spectra) in the FDL are shifted up by one slot. - _current = (_current > 0) ? _current - 1 : _segCount - 1; // A 2B-point real-to-complex FFT is computed from the input buffer, // resulting in B+1 complex-conjugate symmetric DFT coefficients. The - // result is stored in the first FDL slot. - // _current marks first FDL slot, which is the current input block. - _fft->doFFT(*_inputBuffer, _segments[_current]); + // result is stored in the delay line slot for this block. + _fft->doFFT(*_inputBuffer, _segments[block % _segments.size()]); + _blocksProcessed.store(block + 1, std::memory_order_release); // The P sub filter spectra are pairwisely multiplied with the input spectra // in the FDL. The results are accumulated in the frequency-domain. memset(_preMultiplied.data(), 0, _preMultiplied.size() * sizeof(std::complex)); - for (size_t i = 0; i < _segCount; ++i) { - const int indexAudio = (_current + i) % _segCount; - const auto &impulseResponseSegment = _segmentsIR[i]; - const auto &audioSegment = _segments[indexAudio]; - pairwise_complex_multiply_fast(impulseResponseSegment, audioSegment, _preMultiplied); + const size_t headSegments = std::min(HEAD_SEGMENTS, _segCount); + for (size_t age = 0; age < headSegments; ++age) { + pairwise_complex_multiply_fast(_segmentsIR[age], inputSpectrum(block, age), _preMultiplied); + } + + if (hasTail()) { + const TailSlot &slot = _tailSlots[block % TAIL_SLOT_COUNT]; + if (slot.state.load(std::memory_order_acquire) == readyState(block)) { + for (size_t bin = 0; bin < _fftComplexSize; ++bin) { + _preMultiplied[bin] += slot.spectrum[bin]; + } + } else { + ++_tailMisses; + accumulateSegments(block, HEAD_SEGMENTS, _preMultiplied); + } } + // Of the accumulated spectral convolutions, an 2B-point complex-to-real // IFFT is computed. From the resulting 2B samples, the left half is // discarded and the right half is returned as the next output block. @@ -198,4 +238,25 @@ void Convolver::process(const DSPAudioArray &input, DSPAudioArray &output) { output.copy(*_fftBuffer, _blockSize, 0, _blockSize); } + +void Convolver::computeTail(size_t block) { + if (!hasTail() || _blocksProcessed.load(std::memory_order_acquire) > block) { + return; + } + TailSlot &slot = _tailSlots[block % TAIL_SLOT_COUNT]; + size_t state = slot.state.load(std::memory_order_acquire); + if ((state & BUSY_BIT) != 0 || state > readyState(block)) { + return; // another worker is writing, or the slot already belongs to a newer one + } + + // Claim the slot only if it still holds the state checked above, so a worker preempted + // between the check and the claim can never take a slot a newer block has claimed since. + if (!slot.state.compare_exchange_strong( + state, readyState(block) | BUSY_BIT, std::memory_order_acq_rel)) { + return; // the slot changed since the check; the audio thread will compute this tail inline + } + memset(slot.spectrum.data(), 0, slot.spectrum.size() * sizeof(std::complex)); + accumulateSegments(block, HEAD_SEGMENTS, slot.spectrum); + slot.state.store(readyState(block), std::memory_order_release); +} } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.h index 9d3396c0f..1d6c06580 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.h @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include #include @@ -11,33 +11,87 @@ namespace audioapi { +/// @brief Uniformly partitioned FFT convolver whose impulse response is split into a head, +/// rendered on the audio thread, and a tail that a helper thread computes ahead of time. class Convolver { using aligned_vec_complex = std::vector, AlignedAllocator, 16>>; public: + /// IR partitions rendered on audio thread. + /// (8 × 128 frames ≈ 21 ms at 48 kHz). + static constexpr size_t HEAD_SEGMENTS = 8; + Convolver(); bool init(size_t blockSize, const AudioArray &ir, size_t irLen); void process(const DSPAudioArray &input, DSPAudioArray &output); + + /// @brief Whether the IR is long enough for part of it to be computed ahead of time. + [[nodiscard]] bool hasTail() const { + return _segCount > HEAD_SEGMENTS; + } + + /// @brief Tail block that became computable with the latest `process()` call; hand it to + /// `computeTail` on a helper thread right after `process()` returns. + /// @note Audio thread only. + [[nodiscard]] size_t nextTailBlock() const { + return _blocksProcessed.load(std::memory_order_relaxed) - 1 + HEAD_SEGMENTS; + } + + /// @brief Computes the tail for `block` and publishes it for the audio thread. + /// @note Any thread. Safe to call after `process()` moved past `block`: a late result is + /// published but never read, and a call older than the slot's current block does nothing. + /// @note Reads the input delay line without a lock. The line keeps `HEAD_SEGMENTS` spare + /// slots, so the data stays valid unless the worker is more than 2 × HEAD_SEGMENTS blocks + /// late — a result that late is never read, since `process()` already moved past `block`. + void computeTail(size_t block); + void reset(); [[nodiscard]] size_t getSegCount() const { return _trueSegmentCount; } private: + /// `state` packs the block the spectrum belongs to with a busy bit, so claiming the slot and + /// checking that no newer block owns it is one CAS. + struct TailSlot { + std::atomic state{EMPTY_SLOT}; + aligned_vec_complex spectrum; + }; + static constexpr size_t EMPTY_SLOT = 0; + static constexpr size_t BUSY_BIT = 1; + static constexpr size_t TAIL_SLOT_COUNT = HEAD_SEGMENTS + 1; + + /// @brief Slot state meaning "the tail of `block` is complete". Always even (bit 0 is + /// `BUSY_BIT`) and above `EMPTY_SLOT`; ordered like the blocks, so `>` compares block numbers. + /// Blocks 0, 1, 2 map to 0b10, 0b100, 0b110. + static constexpr size_t readyState(size_t block) { + return (block + 1) << 1U; + } + + [[nodiscard]] const aligned_vec_complex &inputSpectrum(size_t block, size_t age) const; + void accumulateSegments(size_t block, size_t fromSegment, aligned_vec_complex &into) const; + size_t _trueSegmentCount; size_t _blockSize; size_t _segSize; size_t _segCount; size_t _fftComplexSize; + /// Delay line of input spectra, one per block, `_segCount + HEAD_SEGMENTS` slots. std::vector _segments; std::vector _segmentsIR; std::unique_ptr _fftBuffer; std::shared_ptr _fft; aligned_vec_complex _preMultiplied; - size_t _current; std::unique_ptr _inputBuffer; + std::vector _tailSlots; + + /// Count of blocks whose spectrum is in the delay line; published by the audio thread. + std::atomic _blocksProcessed{0}; + size_t _tailMisses{0}; + /// Test-only window on the prefetch bookkeeping; hit and miss produce identical output. + friend struct ConvolverInspector; friend void pairwise_complex_multiply_fast( const aligned_vec_complex &ir, const aligned_vec_complex &audio, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/ThreadPool.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/ThreadPool.hpp index 6e40a5dd0..eded757a0 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/ThreadPool.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/ThreadPool.hpp @@ -31,7 +31,6 @@ class ThreadPool { using Event = std::variant; struct Cntrl { - std::atomic waitingForTasks{false}; std::atomic tasksScheduled{0}; }; @@ -115,40 +114,32 @@ class ThreadPool { void schedule(Func &&task, Args &&...args) noexcept requires(std::is_invocable_r_v) { - controlBlock_->tasksScheduled.fetch_add(1, std::memory_order_release); + loadBalancerSender.send( + TaskEvent{bindTask(std::forward(task), std::forward(args)...)}); + } - /// We know that lifetime of each worker thus spsc thus lambda is strongly bounded by ThreadPool lifetime - /// so we can safely capture control block pointer unsafely here - Cntrl *cntrl = controlBlock_.get(); - auto boundTask = [cntrl, - f = std::forward(task), - ... capturedArgs = std::forward(args)]() mutable { - f(std::forward(capturedArgs)...); - size_t left = cntrl->tasksScheduled.fetch_sub(1, std::memory_order_acq_rel) - 1; - if (left == 0) { - cntrl->waitingForTasks.store(false, std::memory_order_release); - cntrl->waitingForTasks.notify_one(); - } - }; - loadBalancerSender.send(TaskEvent{std::move(boundTask)}); + /// @brief Like `schedule`, but never blocks: returns false and runs nothing when the + /// load-balancer queue is full (workers starved). The caller then does the work itself + /// or skips it, so an audio thread can hand off work without ever waiting on the pool. + /// @note Wait-free. Same single-scheduler-thread rule as `schedule`. + template + [[nodiscard]] bool trySchedule(Func &&task, Args &&...args) noexcept + requires(std::is_invocable_r_v) + { + TaskEvent event{bindTask(std::forward(task), std::forward(args)...)}; + if (loadBalancerSender.try_send(std::move(event)) == channels::spsc::ResponseStatus::SUCCESS) { + return true; + } + markTaskFinished(controlBlock_.get()); + return false; } /// @brief Waits for all scheduled tasks to complete void wait() { - /// This logic might seem incorrect at first glance - /// Main principle for this is that there is only one thread scheduling tasks - /// If he is waiting for the tasks he CANNOT schedule new tasks so we can assume partial - /// synchronization here. - /// We first store true so if any task finishes at this moment he will flip it - /// Then we check if there are any tasks scheduled - /// If there are none we can return immediately - /// If there are some we wait until the last task flips the flag to false - controlBlock_->waitingForTasks.store(true, std::memory_order_release); - if (controlBlock_->tasksScheduled.load(std::memory_order_acquire) == 0) { - controlBlock_->waitingForTasks.store(false, std::memory_order_release); - return; + size_t pending; + while ((pending = controlBlock_->tasksScheduled.load(std::memory_order_acquire)) != 0) { + controlBlock_->tasksScheduled.wait(pending, std::memory_order_acquire); } - controlBlock_->waitingForTasks.wait(true, std::memory_order_acquire); } private: @@ -158,6 +149,28 @@ class ThreadPool { std::unique_ptr controlBlock_; bool movedFrom_ = false; + static void markTaskFinished(Cntrl *cntrl) noexcept { + if (cntrl->tasksScheduled.fetch_sub(1, std::memory_order_acq_rel) == 1) { + cntrl->tasksScheduled.notify_all(); + } + } + + /// Counts the task as scheduled and wraps it so completion is counted too. + template + auto bindTask(Func &&task, Args &&...args) noexcept { + controlBlock_->tasksScheduled.fetch_add(1, std::memory_order_release); + + /// We know that lifetime of each worker thus spsc thus lambda is strongly bounded by ThreadPool lifetime + /// so we can safely capture control block pointer unsafely here + Cntrl *cntrl = controlBlock_.get(); + return [cntrl, + f = std::forward(task), + ... capturedArgs = std::forward(args)]() mutable { + f(std::forward(capturedArgs)...); + markTaskFinished(cntrl); + }; + } + void workerThreadFunc(Receiver &&receiver) { Receiver localReceiver = std::move(receiver); while (true) { @@ -167,7 +180,9 @@ class ThreadPool { /// and whenever we receive StopEvent we can burn some cycles as it will not be expected to execute fast. if (std::holds_alternative(event)) [[unlikely]] { break; - } else if (std::holds_alternative(event)) [[likely]] { + } + + if (std::holds_alternative(event)) [[likely]] { std::get(event).task(); } } @@ -188,7 +203,9 @@ class ThreadPool { localWorkerSender.send(StopEvent{}); } break; - } else if (std::holds_alternative(event)) [[likely]] { + } + + if (std::holds_alternative(event)) [[likely]] { // Dispatch task to the next worker in round-robin fashion auto &taskEvent = std::get(event); localWorkerSenders[nextWorker].send(std::move(taskEvent)); diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverThreadPoolTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverThreadPoolTest.cpp index c42b9c9df..23121b571 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverThreadPoolTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverThreadPoolTest.cpp @@ -16,12 +16,23 @@ namespace { constexpr float kSampleRate = 48000.0f; +/// Stands in for AudioContext, which needs a platform audio player the test build lacks. +class RealtimeContext : public BaseAudioContext { + public: + using BaseAudioContext::BaseAudioContext; + + private: + [[nodiscard]] bool isDriverRunning() const override { + return false; + } +}; + class ConvolverThreadPoolTest : public ::testing::Test { protected: std::shared_ptr eventRegistry; - std::shared_ptr makeContext() { - return std::make_shared(2, kSampleRate, kSampleRate, eventRegistry); + std::shared_ptr makeContext() { + return std::make_shared(kSampleRate, eventRegistry); } void SetUp() override { @@ -39,6 +50,12 @@ TEST_F(ConvolverThreadPoolTest, ContextHandsOutOneSharedPool) { EXPECT_EQ(first, second) << "Repeated buffer assignments must reuse the context pool"; } +TEST_F(ConvolverThreadPoolTest, OfflineContextComputesTailsInline) { + auto context = std::make_shared(2, kSampleRate, kSampleRate, eventRegistry); + + EXPECT_EQ(context->getConvolverThreadPool(), nullptr); +} + TEST_F(ConvolverThreadPoolTest, PoolIsPerContext) { auto contextA = makeContext(); auto contextB = makeContext(); diff --git a/packages/react-native-audio-api/common/cpp/test/src/dsp/ConvolverTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/dsp/ConvolverTest.cpp new file mode 100644 index 000000000..60062f2ce --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/dsp/ConvolverTest.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace audioapi { + +/// Test-side definition of the friend declared in Convolver.h. +struct ConvolverInspector { + static size_t tailMisses(const Convolver &convolver) { + return convolver._tailMisses; + } +}; +} // namespace audioapi + +namespace audioapi::test { + +namespace { + +constexpr size_t kBlockSize = 128; +// Float accumulation over thousands of taps drifts by a few ULPs of the result. +constexpr float kAbsTolerance = 1e-4f; +constexpr float kRelTolerance = 1e-5f; + +std::vector randomSamples(size_t count, unsigned seed) { + std::mt19937 rng(seed); + std::uniform_real_distribution dist(-1.0f, 1.0f); + std::vector samples(count); + for (auto &sample : samples) { + sample = dist(rng); + } + return samples; +} + +/// Plain time-domain convolution, the ground truth every FFT path must match. +std::vector directConvolution( + const std::vector &input, + const std::vector &ir) { + std::vector out(input.size(), 0.0f); + for (size_t n = 0; n < input.size(); ++n) { + for (size_t i = 0; i < ir.size() && i <= n; ++i) { + out[n] += input[n - i] * ir[i]; + } + } + return out; +} + +struct Fixture { + std::vector ir; + std::vector input; + std::vector expected; + size_t blocks; + + Fixture(size_t irLength, size_t blockCount, unsigned seed) + : ir(randomSamples(irLength, seed)), + input(randomSamples(blockCount * kBlockSize, seed + 1)), + expected(directConvolution(input, ir)), + blocks(blockCount) {} + + std::shared_ptr makeConvolver() const { + AudioArray irArray(ir.size()); + for (size_t i = 0; i < ir.size(); ++i) { + irArray[i] = ir[i]; + } + auto convolver = std::make_shared(); + EXPECT_TRUE(convolver->init(kBlockSize, irArray, ir.size())); + return convolver; + } + + void feedBlock(size_t block, DSPAudioArray &in) const { + for (size_t i = 0; i < kBlockSize; ++i) { + in[i] = input[block * kBlockSize + i]; + } + } + + void expectBlock(size_t block, const DSPAudioArray &out) const { + for (size_t i = 0; i < kBlockSize; ++i) { + const float want = expected[block * kBlockSize + i]; + ASSERT_NEAR(out[i], want, kAbsTolerance + kRelTolerance * std::abs(want)) + << "block " << block << " sample " << i; + } + } +}; + +} // namespace + +TEST(ConvolverTest, InlineTailMatchesDirectConvolution) { + Fixture fx(/*irLength=*/2000, /*blockCount=*/40, /*seed=*/1); + auto convolver = fx.makeConvolver(); + ASSERT_TRUE(convolver->hasTail()); + DSPAudioArray in(kBlockSize), out(kBlockSize); + + for (size_t block = 0; block < fx.blocks; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + } + // Nothing precomputed the tails, so every block past the silent prefix fell back. + EXPECT_EQ(ConvolverInspector::tailMisses(*convolver), fx.blocks - Convolver::HEAD_SEGMENTS); +} + +TEST(ConvolverTest, ShortIrHasNoTailAndMatchesDirectConvolution) { + Fixture fx(/*irLength=*/300, /*blockCount=*/12, /*seed=*/2); + auto convolver = fx.makeConvolver(); + EXPECT_FALSE(convolver->hasTail()); + DSPAudioArray in(kBlockSize), out(kBlockSize); + + for (size_t block = 0; block < fx.blocks; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + } + EXPECT_EQ(ConvolverInspector::tailMisses(*convolver), 0u); +} + +TEST(ConvolverTest, PrecomputedTailMatchesDirectConvolution) { + Fixture fx(/*irLength=*/3000, /*blockCount=*/60, /*seed=*/3); + auto convolver = fx.makeConvolver(); + DSPAudioArray in(kBlockSize), out(kBlockSize); + + for (size_t block = 0; block < fx.blocks; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + // A worker that always makes its deadline. + convolver->computeTail(convolver->nextTailBlock()); + } + EXPECT_EQ(ConvolverInspector::tailMisses(*convolver), 0u); +} + +TEST(ConvolverTest, LateTailIsDroppedAndOutputStaysCorrect) { + Fixture fx(/*irLength=*/2000, /*blockCount=*/40, /*seed=*/4); + auto convolver = fx.makeConvolver(); + DSPAudioArray in(kBlockSize), out(kBlockSize); + + for (size_t block = 0; block < 20; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + } + // The worker for block 10 shows up long after the audio thread rendered it. + convolver->computeTail(10); + const size_t missesSoFar = ConvolverInspector::tailMisses(*convolver); + + for (size_t block = 20; block < fx.blocks; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + } + EXPECT_EQ(ConvolverInspector::tailMisses(*convolver), missesSoFar + (fx.blocks - 20)); +} + +TEST(ConvolverTest, TailsScheduledOnThreadPoolMatchDirectConvolution) { + Fixture fx(/*irLength=*/4000, /*blockCount=*/300, /*seed=*/5); + auto convolver = fx.makeConvolver(); + ConvolverThreadPool pool(2); + DSPAudioArray in(kBlockSize), out(kBlockSize); + + for (size_t block = 0; block < fx.blocks; ++block) { + fx.feedBlock(block, in); + convolver->process(in, out); + fx.expectBlock(block, out); + pool.schedule([convolver, tail = convolver->nextTailBlock()] { convolver->computeTail(tail); }); + } + pool.wait(); + // Hits and misses depend on scheduling; the output must not. + EXPECT_LT(ConvolverInspector::tailMisses(*convolver), fx.blocks); +} + +} // namespace audioapi::test diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/ThreadPoolTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/ThreadPoolTest.cpp index 97da32832..b247728b9 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/utils/ThreadPoolTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/ThreadPoolTest.cpp @@ -5,7 +5,6 @@ namespace audioapi::test { -// TODO: replace this testcase with more meaningful ones. TEST(ThreadPoolTest, ScheduleRunsTaskAndWaitReturns) { std::atomic counter{0}; ThreadPool pool(2, 8, 8); @@ -16,4 +15,45 @@ TEST(ThreadPoolTest, ScheduleRunsTaskAndWaitReturns) { EXPECT_EQ(counter.load(std::memory_order_relaxed), 1); } +TEST(ThreadPoolTest, WaitCoversEveryTaskOfEachBatch) { + std::atomic counter{0}; + ThreadPool pool(4, 8, 8); + + constexpr int kBatches = 2000; + constexpr int kTasksPerBatch = 3; + for (int batch = 1; batch <= kBatches; ++batch) { + for (int i = 0; i < kTasksPerBatch; ++i) { + pool.schedule([&counter] { counter.fetch_add(1, std::memory_order_relaxed); }); + } + pool.wait(); + ASSERT_EQ(counter.load(std::memory_order_relaxed), batch * kTasksPerBatch); + } +} + +TEST(ThreadPoolTest, TryScheduleRefusesWhenQueueIsFullAndWaitStillBalances) { + std::atomic counter{0}; + std::atomic gate{false}; + // 1 worker, 1-slot queues (an SPSC ring of size 2 holds one element): the worker takes one + // task and blocks on the gate, the load balancer parks one more in the worker's queue, + // after that the balancer queue fills. + ThreadPool pool(1, 2, 2); + + int accepted = 0; + int refused = 0; + for (int i = 0; i < 16; ++i) { + bool ok = pool.trySchedule([&] { + gate.wait(false); + counter.fetch_add(1, std::memory_order_relaxed); + }); + ok ? ++accepted : ++refused; + } + EXPECT_GT(refused, 0) << "a 1-slot pool cannot accept 16 blocked tasks"; + EXPECT_GT(accepted, 0); + + gate = true; + gate.notify_all(); + pool.wait(); // must not hang: refused tasks were never counted as pending + EXPECT_EQ(counter.load(std::memory_order_relaxed), accepted); +} + } // namespace audioapi::test diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs index 53516044d..d77ad1cba 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs @@ -108,6 +108,14 @@ const candidateCategories = categoryPassMap(candidate); */ const isAssertionFailure = (message) => message.startsWith('X '); +/** + * An assertion message also carries what the run measured — a trailing `Got .` and, + * for array comparisons, an `Index Actual Expected` table after the first line. A subtest + * that fails in both runs with a different value is still the same failing subtest, so only + * the first line without the measured value identifies it. + */ +const subtestIdentity = (message) => message.split('\n', 1)[0].replace(/ Got .*$/, ''); + /** * Exact per-file comparison. Category pass counts cannot see an equal-count * swap (test X regresses while test Y starts passing), so when both reports @@ -139,10 +147,12 @@ function compareFiles(baselineReport, candidateReport) { continue; } - const baseFailures = new Set((base.failures ?? []).filter(isAssertionFailure)); + const baseFailures = new Set( + (base.failures ?? []).filter(isAssertionFailure).map(subtestIdentity) + ); const subtests = (head.failures ?? []) .filter(isAssertionFailure) - .filter((message) => !baseFailures.has(message)); + .filter((message) => !baseFailures.has(subtestIdentity(message))); if (subtests.length > 0) { newFailures.push({ path: filePath, subtests }); }