Skip to content
Merged
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
21 changes: 11 additions & 10 deletions iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
18 changes: 18 additions & 0 deletions iOS/Pulsar/Tests/PulsarTests/CoreHapticsMockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof Pulsar>;

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);
});
15 changes: 9 additions & 6 deletions react-native/react-native-pulsar/src/usePatternComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
Expand Down
Loading