From 571ec275a3b42cc1ab8ec5c38a27b5f0f1fa2de3 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 16 Sep 2026 14:08:19 +0200 Subject: [PATCH 1/2] fix(rn): release the previous native composer on every parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PatternComposer_parsePattern` and `parsePatternWithSound` each build a brand-new native composer and file it under a fresh id. `parse()` overwrote the hook's id and dropped the old one on the floor, so only the last composer was ever released, on unmount. Every orphan keeps its `registerAudioResource` registration alive on the shared haptic engine — `releaseAudio()` only runs on that composer's own next parse, which never comes. The engine's audio capacity is finite, so after a handful of plays registration starts failing, `hasSound` falls back to false, and the preset plays the AudioSimulator rendering of its haptics instead of its own audio. Replaying or seeking a single preset is enough to get there, because both re-parse. Android leaks the same way, one SoundPool/MediaPlayer per parse. --- .../src/__tests__/usePatternComposer.test.tsx | 101 ++++++++++++++++++ .../src/usePatternComposer.ts | 15 +-- 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 react-native/react-native-pulsar/src/__tests__/usePatternComposer.test.tsx diff --git a/react-native/react-native-pulsar/src/__tests__/usePatternComposer.test.tsx b/react-native/react-native-pulsar/src/__tests__/usePatternComposer.test.tsx new file mode 100644 index 00000000..dbd8a59a --- /dev/null +++ b/react-native/react-native-pulsar/src/__tests__/usePatternComposer.test.tsx @@ -0,0 +1,101 @@ +import usePatternComposer from '../usePatternComposer'; +import Pulsar from '../NativeRNPulsar'; + +jest.mock('react', () => { + const actual = jest.requireActual('react'); + return { + ...actual, + useCallback: (fn: unknown) => fn, + useEffect: (fn: () => void) => { + fn(); + }, + }; +}); + +jest.mock('../useSharableState', () => ({ + useSharableState: (initialValue: number) => { + let value = initialValue; + return { + get: () => value, + set: (next: number) => { + value = next; + }, + }; + }, +})); + +jest.mock('../NativeRNPulsar', () => ({ + __esModule: true, + default: { + PatternComposer_parsePattern: jest.fn(), + PatternComposer_parsePatternWithSound: jest.fn(), + PatternComposer_play: jest.fn(), + PatternComposer_stop: jest.fn(), + PatternComposer_release: jest.fn(), + }, +})); + +const native = Pulsar as jest.Mocked; + +const pattern = (amplitude: number) => ({ + continuousPattern: { + amplitude: [{ time: 0, value: amplitude }], + frequency: [{ time: 0, value: 0.5 }], + }, + discretePattern: [{ time: 0, amplitude, frequency: 0.5 }], +}); + +const withSound = (amplitude: number) => ({ + ...pattern(amplitude), + sound: { uri: 'file:///clip.wav' }, +}); + +beforeEach(() => { + jest.clearAllMocks(); + let nextId = 100; + native.PatternComposer_parsePattern.mockImplementation(() => nextId++); + native.PatternComposer_parsePatternWithSound.mockImplementation( + () => nextId++ + ); +}); + +it('releases the previous native composer when it parses again', () => { + const composer = usePatternComposer(); + + composer.parse(pattern(0.4)); + expect(native.PatternComposer_release).not.toHaveBeenCalled(); + + composer.parse(pattern(0.8)); + expect(native.PatternComposer_release).toHaveBeenCalledWith(100); + + composer.parse(pattern(0.2)); + expect(native.PatternComposer_release).toHaveBeenCalledWith(101); + expect(native.PatternComposer_release).toHaveBeenCalledTimes(2); +}); + +it('releases the previous composer before registering the next audio resource', () => { + const calls: string[] = []; + native.PatternComposer_release.mockImplementation((id) => + calls.push(`release:${id}`) + ); + native.PatternComposer_parsePatternWithSound.mockImplementation(() => { + calls.push('parseWithSound'); + return 200; + }); + + const composer = usePatternComposer(); + composer.parse(withSound(0.4)); + composer.parse(withSound(0.8)); + + expect(calls).toEqual(['parseWithSound', 'release:200', 'parseWithSound']); +}); + +it('plays the composer it parsed last', () => { + const composer = usePatternComposer(); + + composer.parse(pattern(0.4)); + composer.parse(pattern(0.8)); + composer.play(); + + expect(native.PatternComposer_play).toHaveBeenCalledWith(101); +}); diff --git a/react-native/react-native-pulsar/src/usePatternComposer.ts b/react-native/react-native-pulsar/src/usePatternComposer.ts index 15731a9f..6b17b73a 100644 --- a/react-native/react-native-pulsar/src/usePatternComposer.ts +++ b/react-native/react-native-pulsar/src/usePatternComposer.ts @@ -33,7 +33,15 @@ export default function usePatternComposer(pattern?: Pattern): PatternComposer { } }, []); + const release = useCallback(() => { + const id = patternId.get(); + if (id === -1) return; + Pulsar.PatternComposer_release(id); + patternId.set(-1); + }, []); + const parse = useCallback((pattern: Pattern) => { + release(); const resolvedUri = pattern.sound ? resolveSoundUri(pattern.sound.uri) : undefined; let newPatternId: number; if (pattern.sound && resolvedUri) { @@ -54,12 +62,7 @@ export default function usePatternComposer(pattern?: Pattern): PatternComposer { parse(pattern); } - return () => { - const id = patternId.get(); - if (id !== -1) { - Pulsar.PatternComposer_release(id); - } - }; + return release; }, [pattern]); return { play, stop, parse, isParsed }; From 11ac8ff4591275be0b69ba5284b68d0ef460a9f9 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 16 Sep 2026 14:08:20 +0200 Subject: [PATCH 2/2] fix(ios): release a composer's players before it parses again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse()` overwrote `continuousPlayerId`/`discretePlayerId` without handing the old players back, so every re-parse left two dead players in the engine's 20-slot registry. Once the registry filled, each new player evicted the oldest — which could be one still playing. Players are released before the audio resource: Core Haptics refuses to unregister a resource a live player still holds, and a registration that fails to unregister stays loaded in the engine. --- .../Pulsar/Composers/PatternComposer.swift | 21 ++++++++++--------- .../PulsarTests/CoreHapticsMockTests.swift | 18 ++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 45e884a6..667b43f0 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -32,14 +32,14 @@ public class PatternComposer: NSObject { /// `fromMs` starts the pattern that far into its own timeline. @objc public func parsePattern(hapticsData: PatternData, fromMs: Double = 0) { - releaseAudio() + releaseCurrentPattern() parse(hapticsData: PatternSeek.pattern(hapticsData, from: fromMs), audioEvent: nil) } /// `start`/`duration` are the authored trim window in the file; `fromMs` seeks the whole /// preset, moving audio and haptics together. @objc public func parsePatternWithSound(hapticsData: PatternData, uri: String, volume: Float = 1, offset: Double = 0, start: Double = 0, duration: Double = 0, fromMs: Double = 0) { - releaseAudio() + releaseCurrentPattern() let window = PatternSeek.soundWindow(offset: offset, start: start, duration: duration, from: fromMs) let audioEvent = makeAudioEvent( uri: uri, @@ -162,7 +162,14 @@ public class PatternComposer: NSObject { } } - private func releaseAudio() { + private func releaseCurrentPattern() { + if let id = continuousPlayerId { engine.removePlayer(id: id) } + if let id = discretePlayerId { engine.removePlayer(id: id) } + continuousPlayerId = nil + discretePlayerId = nil + continuousPattern = nil + discretePattern = nil + if let id = audioResourceID { engine.unregisterAudioResource(id) audioResourceID = nil @@ -202,14 +209,8 @@ public class PatternComposer: NSObject { @objc public func dispose() { stop() - if let id = continuousPlayerId { engine.removePlayer(id: id) } - if let id = discretePlayerId { engine.removePlayer(id: id) } - continuousPlayerId = nil - discretePlayerId = nil - continuousPattern = nil - discretePattern = nil + releaseCurrentPattern() audioBuffer = nil hasSound = false - releaseAudio() } } diff --git a/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift b/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift index f9b184cc..b0f84ade 100644 --- a/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift +++ b/iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift @@ -113,6 +113,24 @@ struct CoreHapticsMockTests { #expect(last?.sharpness == 0.0) // clamped low-bound } + @Test func reparsingAPatternComposerReleasesItsPreviousPlayers() { + CoreHapticsMock.install() + defer { CoreHapticsMock.uninstall() } + + let engine = activeEngine() + let composer = PatternComposer(engine: engine, audioSimulator: AudioSimulator()) + let data = PatternData(line: [[[0, 1.0], [100, 0.0]], [[0, 0.5]]], bar: [[0, 1.0, 0.3]]) + + composer.parsePattern(hapticsData: data) + let perParse = HapticMockRecorder.shared.playersCreated + #expect(perParse >= 1) + + composer.parsePattern(hapticsData: data) + + #expect(HapticMockRecorder.shared.playersCreated == perParse * 2) + #expect(HapticMockRecorder.shared.playerStops >= perParse) + } + @Test func stopDeactivatesTheRealtimeComposer() { CoreHapticsMock.install() defer { CoreHapticsMock.uninstall() }