diff --git a/.claude/skills/utilities/SKILL.md b/.claude/skills/utilities/SKILL.md
index 1535ac492..38efcf8eb 100644
--- a/.claude/skills/utilities/SKILL.md
+++ b/.claude/skills/utilities/SKILL.md
@@ -231,6 +231,22 @@ Higher-level DSP blocks. Read each header before use.
---
+### `WsolaTimeStretcher.h` — pitch-preserving time stretch
+
+Needs ~`getRequiredInputFrames()` of PCM in its internal queue before the first OLA
+iteration.
+
+- **File / AudioTag:** early `SeekDecoderDaemon` fills the SPSC; construction
+ `primeWsolaInputFromDecoder()` waits until the analysis queue is full (no wall-clock
+ deadline). No first-quantum warmup — process only feeds the current quantum.
+- **Buffer sources:** `AudioBufferSourceNode::start` calls `primeWsolaInput()` after
+ setting `vReadIndex_` (advances cursor by frames fed). Prefill happens only there;
+ `processWithPitchCorrection` feeds one quantum's input. Compare cold-start with DC
+ ones sample + abs/relative first-sound logs in `WsolaTimeStretcher`. Scratch sized
+ with `scratchBufferFrames(sampleRate)`.
+
+---
+
### `SpectrumAnalyser.h` — shared windowed-FFT magnitude spectrum
Owns FFT scratch state (Blackman window, temp array, complex scratch, magnitude
diff --git a/apps/common-app/src/examples/AudioTag/AudioTag.tsx b/apps/common-app/src/examples/AudioTag/AudioTag.tsx
index ad4d4ab6b..449d33c8c 100644
--- a/apps/common-app/src/examples/AudioTag/AudioTag.tsx
+++ b/apps/common-app/src/examples/AudioTag/AudioTag.tsx
@@ -1,11 +1,10 @@
import React, {
useCallback,
useEffect,
- useMemo,
useRef,
useState,
} from 'react';
-import { Text, useWindowDimensions, View } from 'react-native';
+import { useWindowDimensions, View } from 'react-native';
import {
Audio,
AudioTagHandle,
@@ -97,41 +96,25 @@ const AudioTag: React.FC = () => {
// console.log('onVolumeChange', volume);
}, []);
- const audioTagElement = useMemo(
- () => (
-
- ),
- [
- handleEnded,
- handleError,
- handleLoad,
- handleLoadStart,
- handlePause,
- handlePlay,
- handlePositionChange,
- handleVolumeEvent,
- ]
- );
-
return (
- {audioTagElement}
+
= {
+ pad: {
+ label: 'Continuous pad',
+ hint: 'steady chord bed — best for hearing a join gap',
+ part1: PAD_P1,
+ part2: PAD_P2,
+ },
+ musicCont: {
+ label: 'Music · track4 (smoother)',
+ hint: 'real track, more continuous than track3',
+ part1: MUSIC_CONT_P1,
+ part2: MUSIC_CONT_P2,
+ },
+ tone: {
+ label: 'Tone 440 Hz',
+ hint: 'pure continuous tone — any join is obvious',
+ part1: TONE_P1,
+ part2: TONE_P2,
+ },
+};
+
+/**
+ * Experiment:
+ * ffmpeg-cut encoded WAV parts → decodeAudioData each →
+ * source1.start(t0); source2.start(t0 + buffer1.duration)
+ */
+const WavScheduleSplit: FC = () => {
+ const [sample, setSample] = useState('pad');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRunning, setIsRunning] = useState(false);
+ const [status, setStatus] = useState(
+ 'Ready — continuous WAV parts → decode → start(t0 + buffer1.duration)'
+ );
+ const [eventLog, setEventLog] = useState([]);
+ const contextRef = useRef(null);
+ const sourcesRef = useRef([]);
+
+ const appendLog = useCallback((line: string) => {
+ setEventLog((prev) => [...prev, line]);
+ }, []);
+
+ const getContext = useCallback(() => {
+ if (!contextRef.current) {
+ contextRef.current = new AudioContext();
+ }
+ return contextRef.current;
+ }, []);
+
+ const stopSources = useCallback(() => {
+ for (const source of sourcesRef.current) {
+ try {
+ source.onEnded = null;
+ source.stop();
+ } catch {
+ // already stopped
+ }
+ try {
+ source.disconnect();
+ } catch {
+ // already disconnected
+ }
+ }
+ sourcesRef.current = [];
+ setIsRunning(false);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ stopSources();
+ contextRef.current?.close();
+ contextRef.current = null;
+ };
+ }, [stopSources]);
+
+ const loadDecodedPair = useCallback(async () => {
+ const context = getContext();
+ if (context.state === 'suspended') {
+ await context.resume();
+ }
+
+ const { part1, part2 } = SAMPLES[sample];
+ const uri1 = Image.resolveAssetSource(part1).uri;
+ const uri2 = Image.resolveAssetSource(part2).uri;
+
+ const arrayBuffer1 = await fetch(uri1).then((res) => res.arrayBuffer());
+ const arrayBuffer2 = await fetch(uri2).then((res) => res.arrayBuffer());
+
+ const buffer1 = await context.decodeAudioData(arrayBuffer1);
+ const buffer2 = await context.decodeAudioData(arrayBuffer2);
+ return { context, buffer1, buffer2 };
+ }, [getContext, sample]);
+
+ const playGlued = useCallback(async () => {
+ setIsLoading(true);
+ stopSources();
+ setEventLog([]);
+
+ try {
+ const { context, buffer1, buffer2 } = await loadDecodedPair();
+
+ const sourceNode1 = context.createBufferSource();
+ sourceNode1.buffer = buffer1;
+ const sourceNode2 = context.createBufferSource();
+ sourceNode2.buffer = buffer2;
+
+ const t0 = context.currentTime + START_DELAY_SECONDS;
+ const joinAt = t0 + buffer1.duration;
+ sourceNode1.connect(context.destination);
+ sourceNode2.connect(context.destination);
+ sourcesRef.current = [sourceNode1, sourceNode2];
+
+ sourceNode1.onEnded = () => {
+ const now = context.currentTime;
+ const deltaMs = (now - joinAt) * 1000;
+ sourceNode1.disconnect();
+ appendLog(
+ `FIRST finished @ ${now.toFixed(3)}s (expected join ${joinAt.toFixed(3)}s, Δ ${deltaMs.toFixed(1)} ms)`
+ );
+ };
+ sourceNode2.onEnded = () => {
+ const now = context.currentTime;
+ sourceNode2.disconnect();
+ setIsRunning(false);
+ appendLog(`SECOND finished @ ${now.toFixed(3)}s`);
+ };
+
+ sourceNode1.start(t0);
+ sourceNode2.start(joinAt);
+ setIsRunning(true);
+
+ setStatus(
+ [
+ `glued · ${SAMPLES[sample].label}`,
+ SAMPLES[sample].hint,
+ `buffer1.duration=${buffer1.duration.toFixed(4)}s len=${buffer1.length}`,
+ `buffer2.duration=${buffer2.duration.toFixed(4)}s len=${buffer2.length}`,
+ `bufSr=${buffer1.sampleRate} ctxSr=${context.sampleRate}`,
+ `source1.start(${t0.toFixed(3)})`,
+ `source2.start(${joinAt.toFixed(3)})`,
+ `Waiting for onEnded…`,
+ ].join('\n')
+ );
+ appendLog(
+ `scheduled · first→${t0.toFixed(3)} second→${joinAt.toFixed(3)}`
+ );
+ } catch (error) {
+ console.error(error);
+ setStatus(`Error: ${String(error)}`);
+ setIsRunning(false);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [appendLog, loadDecodedPair, sample, stopSources]);
+
+ const playUnbroken = useCallback(async () => {
+ setIsLoading(true);
+ stopSources();
+ setEventLog([]);
+
+ try {
+ const { context, buffer1, buffer2 } = await loadDecodedPair();
+
+ const length = buffer1.length + buffer2.length;
+ const continuous = context.createBuffer(
+ buffer1.numberOfChannels,
+ length,
+ buffer1.sampleRate
+ );
+ for (let ch = 0; ch < buffer1.numberOfChannels; ch += 1) {
+ const out = continuous.getChannelData(ch);
+ out.set(buffer1.getChannelData(ch), 0);
+ out.set(buffer2.getChannelData(ch), buffer1.length);
+ }
+
+ const source = context.createBufferSource();
+ source.buffer = continuous;
+ source.connect(context.destination);
+ sourcesRef.current = [source];
+
+ const t0 = context.currentTime + START_DELAY_SECONDS;
+ const joinWouldBe = t0 + buffer1.duration;
+ source.onEnded = () => {
+ const now = context.currentTime;
+ source.disconnect();
+ setIsRunning(false);
+ appendLog(`FULL finished @ ${now.toFixed(3)}s`);
+ };
+ source.start(t0);
+ setIsRunning(true);
+
+ setStatus(
+ [
+ `unbroken · ${SAMPLES[sample].label} · single source`,
+ `duration=${continuous.duration.toFixed(4)}s`,
+ `join would be @ ${joinWouldBe.toFixed(3)}s`,
+ `source.start(${t0.toFixed(3)})`,
+ ].join('\n')
+ );
+ appendLog(`scheduled unbroken · start ${t0.toFixed(3)}`);
+ } catch (error) {
+ console.error(error);
+ setStatus(`Error: ${String(error)}`);
+ setIsRunning(false);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [appendLog, loadDecodedPair, sample, stopSources]);
+
+ const busy = isLoading || isRunning;
+ const playLabel = (idle: string) =>
+ isLoading ? 'Loading…' : isRunning ? 'Running…' : idle;
+
+ return (
+
+
+ WAV schedule split
+
+ Continuous sources cut with ffmpeg (5 s + rest) → decode each →
+ source2.start(t0 + buffer1.duration). Prefer the pad to hear join
+ gaps clearly.
+
+
+
+ Sample
+ {(Object.keys(SAMPLES) as SampleKey[]).map((key) => {
+ const selected = sample === key;
+ return (
+
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ },
+ body: {
+ paddingHorizontal: 20,
+ paddingTop: 24,
+ paddingBottom: 40,
+ },
+ title: {
+ color: colors.white,
+ fontSize: 20,
+ fontWeight: '600',
+ },
+ caption: {
+ color: colors.white,
+ opacity: 0.7,
+ marginTop: 8,
+ lineHeight: 20,
+ },
+ section: {
+ color: colors.white,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ sampleRow: {
+ marginBottom: 12,
+ },
+ hint: {
+ color: colors.white,
+ opacity: 0.55,
+ fontSize: 12,
+ marginTop: 4,
+ marginLeft: 4,
+ },
+ log: {
+ color: '#9BE7A0',
+ fontFamily: 'Courier',
+ fontSize: 13,
+ lineHeight: 20,
+ marginBottom: 8,
+ },
+ status: {
+ color: colors.white,
+ fontFamily: 'Courier',
+ fontSize: 12,
+ lineHeight: 18,
+ },
+});
+
+export default WavScheduleSplit;
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part1.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part1.wav
new file mode 100644
index 000000000..360f40801
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part1.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part2.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part2.wav
new file mode 100644
index 000000000..4a55a14cd
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-music-cont-part2.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part1.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part1.wav
new file mode 100644
index 000000000..42a7485d8
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part1.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part2.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part2.wav
new file mode 100644
index 000000000..c91898d35
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-pad-part2.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part1.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part1.wav
new file mode 100644
index 000000000..f8b715a5c
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part1.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part2.wav b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part2.wav
new file mode 100644
index 000000000..e5483079a
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/ffmpeg-tone-part2.wav differ
diff --git a/apps/common-app/src/examples/WavScheduleSplit/index.ts b/apps/common-app/src/examples/WavScheduleSplit/index.ts
new file mode 100644
index 000000000..8cfc75df3
--- /dev/null
+++ b/apps/common-app/src/examples/WavScheduleSplit/index.ts
@@ -0,0 +1 @@
+export { default } from './WavScheduleSplit';
diff --git a/apps/common-app/src/examples/WavScheduleSplit/music-track4-15s.wav b/apps/common-app/src/examples/WavScheduleSplit/music-track4-15s.wav
new file mode 100644
index 000000000..64829465a
Binary files /dev/null and b/apps/common-app/src/examples/WavScheduleSplit/music-track4-15s.wav differ
diff --git a/apps/common-app/src/examples/WsolaScheduleSplit/WsolaScheduleSplit.tsx b/apps/common-app/src/examples/WsolaScheduleSplit/WsolaScheduleSplit.tsx
new file mode 100644
index 000000000..a912a41fe
--- /dev/null
+++ b/apps/common-app/src/examples/WsolaScheduleSplit/WsolaScheduleSplit.tsx
@@ -0,0 +1,691 @@
+import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { ScrollView } from 'react-native-gesture-handler';
+import {
+ AudioBuffer,
+ AudioBufferSourceNode,
+ AudioContext,
+} from 'react-native-audio-api';
+
+import { Button, Container, Slider, Spacer } from '../../components';
+import { colors } from '../../styles';
+import voiceAsset from '../AudioFile/voice-sample-landing.mp3';
+import track3Asset from '../../demos/Crossfade/tracks/track3.mp3';
+
+/** Max content length of each half (seconds); actual half may be shorter. */
+const TARGET_HALF_SECONDS = 5;
+/** Delayed start for the first source (seconds). */
+const START_DELAY_SECONDS = 1;
+/** Synthetic DC buffer long enough for two halves. */
+const DC_ONES_DURATION_SECONDS = TARGET_HALF_SECONDS * 2;
+const MIN_PLAYBACK_RATE = 0.5;
+const MAX_PLAYBACK_RATE = 5;
+const PLAYBACK_RATE_STEP = 0.1;
+/** RMS window used to detect “lady speaking” / high energy. */
+const ENERGY_WINDOW_SECONDS = 0.03;
+/** Step when scanning for the loudest cut near mid-file. */
+const ENERGY_STEP_SECONDS = 0.01;
+/** Min gap between alternate speech-cut candidates. */
+const MIN_PEAK_SEPARATION_SECONDS = 2.0;
+/** How many distinct speech-cut fragments to offer. */
+const MAX_SPEECH_FRAGMENTS = 3;
+
+type SampleAsset = 'voice' | 'track3' | 'dcOnes';
+
+const SAMPLE_ASSETS: Partial> = {
+ voice: voiceAsset,
+ track3: track3Asset,
+};
+
+const SAMPLE_LABELS: Record = {
+ voice: 'Voice',
+ track3: 'Music (track3)',
+ dcOnes: 'DC ones (1.0)',
+};
+
+/** Equal halves around a high-energy cut. */
+type SplitPlan = {
+ /** Absolute cut frame in the original buffer. */
+ cutFrame: number;
+ /** Frames in each half. */
+ halfFrames: number;
+ /** Absolute cut time in the original buffer (seconds). */
+ cutSeconds: number;
+ /** Content length of each half (seconds). */
+ halfSeconds: number;
+ /** Start frame of the pruned region. */
+ regionStartFrame: number;
+ /** Start of the pruned region in the original buffer (seconds). */
+ regionStartSeconds: number;
+ /** Total pruned content length (= 2 × halfSeconds). */
+ regionDurationSeconds: number;
+ /** RMS at the chosen cut (speech / high-wave score). */
+ cutRms: number;
+ /** 1-based fragment index among detected speech peaks. */
+ fragmentIndex: number;
+};
+
+type LoadedSample = {
+ key: SampleAsset;
+ sourceId: number | 'dcOnes';
+ buffer: AudioBuffer;
+ plans: SplitPlan[];
+};
+
+/** Constant-amplitude buffer for fair WSOLA cold-start measurement (no leading silence). */
+function createDcOnesBuffer(
+ context: AudioContext,
+ durationSeconds: number
+): AudioBuffer {
+ const sampleRate = context.sampleRate;
+ const length = Math.max(1, Math.floor(durationSeconds * sampleRate));
+ const buffer = context.createBuffer(1, length, sampleRate);
+ buffer.getChannelData(0).fill(1);
+ return buffer;
+}
+
+/** Frame-accurate slice so half A end and half B start share the same cut frame. */
+function sliceAudioBufferFrames(
+ context: AudioContext,
+ source: AudioBuffer,
+ startFrame: number,
+ frameCount: number
+): AudioBuffer {
+ const safeStart = Math.max(0, Math.min(startFrame, source.length));
+ const safeCount = Math.max(0, Math.min(frameCount, source.length - safeStart));
+ const sliced = context.createBuffer(
+ source.numberOfChannels,
+ Math.max(1, safeCount),
+ source.sampleRate
+ );
+
+ for (let channel = 0; channel < source.numberOfChannels; channel += 1) {
+ const channelData = source.getChannelData(channel);
+ // Do not use copyToChannel(subarray(...)): native copyToChannel reads the
+ // underlying ArrayBuffer from index 0 and ignores the TypedArray view offset,
+ // so every slice would become the first half.
+ sliced
+ .getChannelData(channel)
+ .set(channelData.subarray(safeStart, safeStart + safeCount));
+ }
+
+ return sliced;
+}
+
+function windowRms(
+ channel: Float32Array,
+ centerFrame: number,
+ windowFrames: number
+): number {
+ const half = Math.floor(windowFrames / 2);
+ const from = Math.max(0, centerFrame - half);
+ const to = Math.min(channel.length, from + windowFrames);
+ const count = to - from;
+ if (count <= 0) {
+ return 0;
+ }
+ let sumSq = 0;
+ for (let i = from; i < to; i += 1) {
+ const s = channel[i]!;
+ sumSq += s * s;
+ }
+ return Math.sqrt(sumSq / count);
+}
+
+/**
+ * Find up to MAX_SPEECH_FRAGMENTS distinct high-energy cuts across the file
+ * (usable range leaves room for equal halves ≤ targetHalfSeconds), then prune
+ * equal halves around each cut.
+ */
+function findSpeechSplitPlans(
+ buffer: AudioBuffer,
+ targetHalfSeconds = TARGET_HALF_SECONDS
+): SplitPlan[] {
+ const { sampleRate, length, duration } = buffer;
+ const channel = buffer.getChannelData(0);
+ const windowFrames = Math.max(1, Math.floor(sampleRate * ENERGY_WINDOW_SECONDS));
+ const stepFrames = Math.max(1, Math.floor(sampleRate * ENERGY_STEP_SECONDS));
+ const minSeparationFrames = Math.floor(
+ sampleRate * MIN_PEAK_SEPARATION_SECONDS
+ );
+
+ // Cuts need room for equal halves on both sides.
+ const halfBudget = Math.min(targetHalfSeconds, duration / 2);
+ const minCutFrame = Math.max(windowFrames, Math.floor(sampleRate * halfBudget));
+ const maxCutFrame = Math.min(
+ length - windowFrames,
+ Math.floor(sampleRate * (duration - halfBudget))
+ );
+
+ if (maxCutFrame <= minCutFrame) {
+ const cutFrame = Math.floor(length / 2);
+ const halfFrames = Math.min(
+ Math.floor(targetHalfSeconds * sampleRate),
+ cutFrame,
+ length - cutFrame
+ );
+ const cutSeconds = cutFrame / sampleRate;
+ const halfSeconds = halfFrames / sampleRate;
+ return [
+ {
+ cutFrame,
+ halfFrames,
+ cutSeconds,
+ halfSeconds,
+ regionStartFrame: cutFrame - halfFrames,
+ regionStartSeconds: (cutFrame - halfFrames) / sampleRate,
+ regionDurationSeconds: halfSeconds * 2,
+ cutRms: windowRms(channel, cutFrame, windowFrames),
+ fragmentIndex: 1,
+ },
+ ];
+ }
+
+ type Peak = { frame: number; rms: number };
+ const samples: Peak[] = [];
+ for (let frame = minCutFrame; frame <= maxCutFrame; frame += stepFrames) {
+ samples.push({ frame, rms: windowRms(channel, frame, windowFrames) });
+ }
+
+ // Local maxima only (strictly louder than neighbors).
+ const localMaxima: Peak[] = [];
+ for (let i = 1; i < samples.length - 1; i += 1) {
+ const prev = samples[i - 1]!;
+ const cur = samples[i]!;
+ const next = samples[i + 1]!;
+ if (cur.rms >= prev.rms && cur.rms > next.rms) {
+ localMaxima.push(cur);
+ }
+ }
+ if (localMaxima.length === 0 && samples.length > 0) {
+ localMaxima.push(
+ samples.reduce((best, p) => (p.rms > best.rms ? p : best), samples[0]!)
+ );
+ }
+
+ localMaxima.sort((a, b) => b.rms - a.rms);
+
+ const selected: Peak[] = [];
+ for (const peak of localMaxima) {
+ if (selected.length >= MAX_SPEECH_FRAGMENTS) {
+ break;
+ }
+ const tooClose = selected.some(
+ (other) => Math.abs(other.frame - peak.frame) < minSeparationFrames
+ );
+ if (!tooClose) {
+ selected.push(peak);
+ }
+ }
+
+ // Stable UI order: earlier → later in the file.
+ selected.sort((a, b) => a.frame - b.frame);
+
+ return selected.map((peak, index) => {
+ const cutFrame = peak.frame;
+ const halfFrames = Math.min(
+ Math.floor(targetHalfSeconds * sampleRate),
+ cutFrame,
+ length - cutFrame
+ );
+ const cutSeconds = cutFrame / sampleRate;
+ const halfSeconds = halfFrames / sampleRate;
+ return {
+ cutFrame,
+ halfFrames,
+ cutSeconds,
+ halfSeconds,
+ regionStartFrame: cutFrame - halfFrames,
+ regionStartSeconds: (cutFrame - halfFrames) / sampleRate,
+ regionDurationSeconds: halfSeconds * 2,
+ cutRms: peak.rms,
+ fragmentIndex: index + 1,
+ };
+ });
+}
+
+type RunMode = 'glued' | 'unbroken';
+
+/**
+ * Experiment: detect several high-energy speech peaks, prune equal halves
+ * around the selected cut, then chain two AudioBufferSourceNodes so the second
+ * starts when the first should end (wall = half / rate). Listen at the cut to
+ * judge glue quality.
+ */
+const WsolaScheduleSplit: FC = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRunning, setIsRunning] = useState(false);
+ const [sample, setSample] = useState('voice');
+ const [playbackRate, setPlaybackRate] = useState(2);
+ const [fragmentIndex, setFragmentIndex] = useState(0);
+ const [plans, setPlans] = useState([]);
+ const [status, setStatus] = useState('Idle');
+ const contextRef = useRef(null);
+ const fullBufferRef = useRef(null);
+ const sourcesRef = useRef([]);
+
+ const plan = plans[fragmentIndex] ?? null;
+
+ const getContext = useCallback(() => {
+ if (!contextRef.current) {
+ contextRef.current = new AudioContext();
+ }
+ return contextRef.current;
+ }, []);
+
+ const stopSources = useCallback(() => {
+ for (const source of sourcesRef.current) {
+ try {
+ source.onEnded = null;
+ source.stop();
+ } catch {
+ // already stopped
+ }
+ // Finished sources must leave the graph, otherwise they stay wired to the
+ // destination and keep being mixed after playback ends.
+ try {
+ source.disconnect();
+ } catch {
+ // already disconnected
+ }
+ }
+ sourcesRef.current = [];
+ setIsRunning(false);
+ }, []);
+
+ const describePlans = useCallback((key: SampleAsset, nextPlans: SplitPlan[], duration: number) => {
+ return [
+ `sample=${key} loaded ${duration.toFixed(2)}s · ${nextPlans.length} speech fragment(s)`,
+ ...nextPlans.map(
+ (p) =>
+ ` #${p.fragmentIndex} cut @ ${p.cutSeconds.toFixed(3)}s rms=${p.cutRms.toFixed(3)} · 2×${p.halfSeconds.toFixed(2)}s`
+ ),
+ ].join('\n');
+ }, []);
+
+ const loadFullBuffer = useCallback(
+ async (key: SampleAsset) => {
+ const sourceId = key === 'dcOnes' ? 'dcOnes' : SAMPLE_ASSETS[key]!;
+ if (
+ fullBufferRef.current?.key === key &&
+ fullBufferRef.current.sourceId === sourceId
+ ) {
+ setPlans(fullBufferRef.current.plans);
+ return fullBufferRef.current;
+ }
+
+ const context = getContext();
+ const buffer =
+ key === 'dcOnes'
+ ? createDcOnesBuffer(context, DC_ONES_DURATION_SECONDS)
+ : await context.decodeAudioData(sourceId as number);
+ const nextPlans = findSpeechSplitPlans(buffer);
+ const loaded: LoadedSample = { key, sourceId, buffer, plans: nextPlans };
+ fullBufferRef.current = loaded;
+ setPlans(nextPlans);
+ setFragmentIndex(0);
+ return loaded;
+ },
+ [getContext]
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const loaded = await loadFullBuffer(sample);
+ if (!cancelled) {
+ setPlans(loaded.plans);
+ setFragmentIndex(0);
+ setStatus(
+ describePlans(sample, loaded.plans, loaded.buffer.duration)
+ );
+ }
+ } catch (error) {
+ if (!cancelled) {
+ setStatus(`Load error: ${String(error)}`);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [describePlans, loadFullBuffer, sample]);
+
+ const selectFragment = useCallback(
+ (index: number) => {
+ const next = plans[index];
+ if (!next) {
+ return;
+ }
+ setFragmentIndex(index);
+ setStatus(
+ [
+ `selected fragment #${next.fragmentIndex}`,
+ `speech cut @ ${next.cutSeconds.toFixed(3)}s (rms=${next.cutRms.toFixed(3)})`,
+ `pruned ${next.regionDurationSeconds.toFixed(2)}s = 2×${next.halfSeconds.toFixed(2)}s around cut`,
+ `region [${next.regionStartSeconds.toFixed(3)}, ${(
+ next.regionStartSeconds + next.regionDurationSeconds
+ ).toFixed(3)})s`,
+ ].join('\n')
+ );
+ },
+ [plans]
+ );
+ const runExperiment = useCallback(
+ async (mode: RunMode) => {
+ const rate = playbackRate;
+ // Pitch correction only when stretching; rate 1 is the plain path.
+ const pitchCorrection = rate !== 1;
+
+ setIsLoading(true);
+ stopSources();
+
+ try {
+ const context = getContext();
+ if (context.state === 'suspended') {
+ await context.resume();
+ }
+
+ const loaded = await loadFullBuffer(sample);
+ const { buffer: full, plans: availablePlans } = loaded;
+ const split =
+ availablePlans[fragmentIndex] ?? availablePlans[0] ?? null;
+
+ if (!split || split.halfSeconds < 0.25) {
+ setStatus(
+ split
+ ? `Cannot build equal halves around speech cut @ ${split.cutSeconds.toFixed(
+ 3
+ )}s (half=${split.halfSeconds.toFixed(3)}s).`
+ : 'No speech fragment available.'
+ );
+ return;
+ }
+
+ const halfContent = split.halfSeconds;
+ const halfFrames = split.halfFrames;
+ // Wall time must follow how many context frames the node will emit at this
+ // rate (1 buffer frame per context frame when sample rates match).
+ const firstHalfWallSeconds =
+ halfFrames / context.sampleRate / rate;
+ const fullWallSeconds = (halfFrames * 2) / context.sampleRate / rate;
+ const t0 = context.currentTime + START_DELAY_SECONDS;
+ const joinWall = t0 + firstHalfWallSeconds;
+
+ if (mode === 'unbroken') {
+ const continuous = sliceAudioBufferFrames(
+ context,
+ full,
+ split.regionStartFrame,
+ halfFrames * 2
+ );
+ const source = context.createBufferSource({
+ pitchCorrection,
+ });
+ source.playbackRate.value = rate;
+ source.buffer = continuous;
+ source.connect(context.destination);
+ sourcesRef.current = [source];
+
+ source.onEnded = () => {
+ setStatus(
+ (prev) =>
+ `${prev}\nfull ended @ ${context.currentTime.toFixed(3)}s`
+ );
+ source.disconnect();
+ setIsRunning(false);
+ };
+ source.start(t0);
+ setIsRunning(true);
+
+ setStatus(
+ [
+ `sample=${sample} mode=unbroken (no cut)`,
+ `pitchCorrection=${pitchCorrection} rate=${rate}`,
+ `ctxSr=${context.sampleRate} bufSr=${full.sampleRate} halfFrames=${halfFrames}`,
+ `speech cut would be @ content ${halfContent.toFixed(3)}s of pruned clip`,
+ ` (fragment #${split.fragmentIndex}, absolute ${split.cutSeconds.toFixed(3)}s, rms=${split.cutRms.toFixed(3)})`,
+ `pruned=${continuous.duration.toFixed(2)}s frames=${continuous.length}`,
+ `cut → wall ~${joinWall.toFixed(3)}s`,
+ `source.start(${t0.toFixed(3)}) wallDuration=${fullWallSeconds.toFixed(3)}s`,
+ ].join('\n')
+ );
+ return;
+ }
+
+ const firstHalf = sliceAudioBufferFrames(
+ context,
+ full,
+ split.regionStartFrame,
+ halfFrames
+ );
+ const secondHalf = sliceAudioBufferFrames(
+ context,
+ full,
+ split.cutFrame,
+ halfFrames
+ );
+
+ const source1 = context.createBufferSource({
+ pitchCorrection,
+ });
+ source1.playbackRate.value = rate;
+ const source2 = context.createBufferSource({
+ pitchCorrection,
+ });
+ source2.playbackRate.value = rate;
+
+ source1.buffer = firstHalf;
+ source2.buffer = secondHalf;
+
+ source1.connect(context.destination);
+ source2.connect(context.destination);
+
+ sourcesRef.current = [source1, source2];
+
+ // Prefer measured buffer length so join matches what half A will actually emit.
+ const joinFromBuffer =
+ t0 + firstHalf.length / context.sampleRate / rate;
+
+ source1.onEnded = () => {
+ setStatus(
+ (prev) =>
+ `${prev}\nfirst ended @ ${context.currentTime.toFixed(3)}s (expected ~${joinFromBuffer.toFixed(3)}) ← cut/join`
+ );
+ source1.disconnect();
+ };
+ source2.onEnded = () => {
+ setStatus(
+ (prev) =>
+ `${prev}\nsecond ended @ ${context.currentTime.toFixed(3)}s`
+ );
+ source2.disconnect();
+ setIsRunning(false);
+ };
+
+ source1.start(t0);
+ source2.start(joinFromBuffer);
+ setIsRunning(true);
+
+ setStatus(
+ [
+ `sample=${sample} mode=glued`,
+ `pitchCorrection=${pitchCorrection} rate=${rate}`,
+ `ctxSr=${context.sampleRate} bufSr=${full.sampleRate} halfFrames=${halfFrames}`,
+ `firstHalf.len=${firstHalf.length} dur=${firstHalf.duration.toFixed(4)}s`,
+ `fragment #${split.fragmentIndex} cutFrame=${split.cutFrame} @ ${split.cutSeconds.toFixed(3)}s (rms=${split.cutRms.toFixed(3)})`,
+ `halfA frames [${split.regionStartFrame}, ${split.cutFrame}) + halfB [${split.cutFrame}, ${split.cutFrame + halfFrames})`,
+ `join at wall ~${joinFromBuffer.toFixed(3)}s (= ${firstHalf.length}/${context.sampleRate}/${rate})`,
+ `source1.start(${t0.toFixed(3)})`,
+ `source2.start(${joinFromBuffer.toFixed(3)})`,
+ `wallDuration≈${(firstHalf.length * 2) / context.sampleRate / rate}s`,
+ ].join('\n')
+ );
+ } catch (error) {
+ console.error(error);
+ setStatus(`Error: ${String(error)}`);
+ setIsRunning(false);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [
+ fragmentIndex,
+ getContext,
+ loadFullBuffer,
+ playbackRate,
+ sample,
+ stopSources,
+ ]
+ );
+
+ useEffect(() => {
+ return () => {
+ stopSources();
+ fullBufferRef.current = null;
+ contextRef.current?.close();
+ contextRef.current = null;
+ };
+ }, [stopSources]);
+
+ const busy = isLoading || isRunning;
+ const playLabel = (idle: string) =>
+ isLoading ? 'Loading…' : isRunning ? 'Running…' : idle;
+
+ const halfContent = plan?.halfSeconds ?? TARGET_HALF_SECONDS;
+ const halfWallSeconds = halfContent / playbackRate;
+ const fullWallSeconds = (halfContent * 2) / playbackRate;
+ const rateLabel = playbackRate.toFixed(1);
+ const halfWallLabel = halfWallSeconds.toFixed(2);
+ const fullWallLabel = fullWallSeconds.toFixed(2);
+ const halfContentLabel = halfContent.toFixed(2);
+ const cutLabel = plan ? plan.cutSeconds.toFixed(2) : '…';
+ const rmsLabel = plan ? plan.cutRms.toFixed(3) : '…';
+
+ return (
+
+
+ WSOLA schedule split
+
+ Detects up to {MAX_SPEECH_FRAGMENTS} distinct high-energy speech peaks
+ (≥ {MIN_PEAK_SEPARATION_SECONDS} s apart), then prunes equal halves ≤{' '}
+ {TARGET_HALF_SECONDS}s around the selected cut. Rate 1 = no pitch
+ correction; ≠ 1 = WSOLA.
+
+
+ Sample
+
+ {(['voice', 'track3', 'dcOnes'] as SampleAsset[]).map((key, index) => (
+
+ {index > 0 ? : null}
+ setSample(key)}
+ disabled={busy}
+ />
+
+ ))}
+
+ Speech cut fragment
+
+ {plans.length === 0 ? (
+ Scanning…
+ ) : (
+ plans.map((candidate, index) => (
+
+ {index > 0 ? : null}
+ selectFragment(index)}
+ disabled={busy}
+ />
+
+ ))
+ )}
+
+ Playback rate
+
+
+
+
+ Fragment #{plan?.fragmentIndex ?? '…'} cut @ {cutLabel}s (rms {rmsLabel})
+ · 2×{halfContentLabel}s content → ~{fullWallLabel}s wall (join @{' '}
+ {halfWallLabel}s)
+
+
+ runExperiment('glued')}
+ disabled={busy || !plan}
+ />
+
+
+ Unbroken same pruned region → ~{fullWallLabel}s wall
+
+
+ runExperiment('unbroken')}
+ disabled={busy || !plan}
+ />
+
+
+
+ {status}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ },
+ body: {
+ paddingHorizontal: 20,
+ paddingTop: 24,
+ paddingBottom: 40,
+ },
+ title: {
+ color: colors.white,
+ fontSize: 20,
+ fontWeight: '600',
+ },
+ caption: {
+ color: colors.white,
+ opacity: 0.7,
+ marginTop: 8,
+ lineHeight: 20,
+ },
+ sectionLabel: {
+ color: colors.white,
+ fontSize: 14,
+ fontWeight: '600',
+ opacity: 0.85,
+ },
+ status: {
+ color: colors.white,
+ fontFamily: 'Courier',
+ fontSize: 12,
+ lineHeight: 18,
+ },
+});
+
+export default WsolaScheduleSplit;
diff --git a/apps/common-app/src/examples/WsolaScheduleSplit/index.ts b/apps/common-app/src/examples/WsolaScheduleSplit/index.ts
new file mode 100644
index 000000000..443f0b0af
--- /dev/null
+++ b/apps/common-app/src/examples/WsolaScheduleSplit/index.ts
@@ -0,0 +1 @@
+export { default } from './WsolaScheduleSplit';
diff --git a/apps/common-app/src/examples/index.ts b/apps/common-app/src/examples/index.ts
index ca0e12773..86b5b2fa8 100644
--- a/apps/common-app/src/examples/index.ts
+++ b/apps/common-app/src/examples/index.ts
@@ -14,6 +14,7 @@ import Record from './Record/Record';
import Worklets from './Worklets/Worklets';
import AudioStream from './AudioTag/AudioTag';
import ConvolverIR from './ConvolverIR';
+import WavScheduleSplit from './WavScheduleSplit';
import ChannelMergerSplitter from './ChannelMergerSplitter';
type NavigationParamList = {
@@ -33,6 +34,8 @@ type NavigationParamList = {
ConvolverIR: undefined;
ChannelMergerSplitter: undefined;
ChannelCount: undefined;
+ WsolaScheduleSplit: undefined;
+ WavScheduleSplit: undefined;
AudioParamPipeline: undefined;
TestScreen: undefined;
LatencyValidation: undefined;
@@ -138,4 +141,10 @@ export const Examples: Example[] = [
Icon: icons.Columns3,
screen: ChannelCount,
},
+ {
+ key: 'WavScheduleSplit',
+ title: 'WAV Schedule Split',
+ Icon: icons.AudioLines,
+ screen: WavScheduleSplit,
+ },
] as const;
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferBaseSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferBaseSourceNodeHostObject.cpp
index febedfec9..4f9691bf8 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferBaseSourceNodeHostObject.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferBaseSourceNodeHostObject.cpp
@@ -86,7 +86,7 @@ void AudioBufferBaseSourceNodeHostObject::initStretch(int channelCount, float sa
outputLatency_ = WsolaTimeStretcher::OUTPUT_LATENCY_MS / 1000.0;
auto playbackRateBuffer = std::make_shared(
- WsolaTimeStretcher::MAX_PLAYBACK_RATE * RENDER_QUANTUM_SIZE, channelCount, sampleRate);
+ WsolaTimeStretcher::scratchBufferFrames(sampleRate), channelCount, sampleRate);
auto event = [handle, node = bufferBaseSourceNode_, playbackRateBuffer, channelCount, sampleRate](
BaseAudioContext &) {
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h
index 6eeac0d47..2262119c5 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
#include
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp
index ed3c56639..a38cc89e3 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp
@@ -10,6 +10,8 @@
#include
#include
+#include
+
namespace audioapi {
AudioBufferBaseSourceNode::AudioBufferBaseSourceNode(
const std::shared_ptr &context,
@@ -48,6 +50,49 @@ void AudioBufferBaseSourceNode::initStretch(
const std::shared_ptr &playbackRateBuffer) {
wsolaStretcher_.configure(channelCount, sampleRate);
playbackRateBuffer_ = playbackRateBuffer;
+ wsolaPrimeDebugPending_ = true;
+}
+
+void AudioBufferBaseSourceNode::primeWsolaInput() {
+ if (!pitchCorrection_ || playbackRateBuffer_ == nullptr || isEmpty()) {
+ return;
+ }
+
+ std::shared_ptr context = context_.lock();
+ if (context == nullptr) {
+ return;
+ }
+
+ const float rate = std::fabs(
+ playbackRateParam_->processKRateParam(RENDER_QUANTUM_SIZE, context->getCurrentTime()));
+ // WSOLA path is only used when |rate| != 1; skip priming otherwise.
+ if (rate == 0.0f || rate == 1.0f) {
+ return;
+ }
+
+ wsolaStretcher_.reset();
+
+ const size_t framesNeeded =
+ std::max(wsolaStretcher_.getRequiredInputFrames(), wsolaStretcher_.getMinInputFramesToRun());
+ if (framesNeeded == 0) {
+ return;
+ }
+
+ const size_t inputFrames = std::min(framesNeeded, playbackRateBuffer_->getSize());
+ playbackRateBuffer_->zero();
+
+ // 1:1 forward copy into WSOLA's analysis queue; advances vReadIndex_ so the
+ // cursor stays aligned with what was fed before the first output quantum.
+ runBufferProcessor(playbackRateBuffer_, 0, inputFrames, 1.0f, false);
+ wsolaStretcher_.feedInput(*playbackRateBuffer_, inputFrames);
+
+ if (wsolaPrimeDebugPending_) {
+ wsolaPrimeDebugPending_ = false;
+ std::cout << "[AudioBufferBaseSourceNode] [WSOLA prime at start]"
+ << " buffered=" << wsolaStretcher_.getBufferedInputFrames()
+ << " required=" << framesNeeded << std::endl
+ << std::endl;
+ }
}
std::shared_ptr AudioBufferBaseSourceNode::getDetuneParam() const {
@@ -112,8 +157,13 @@ void AudioBufferBaseSourceNode::processWithPitchCorrection(
-WsolaTimeStretcher::MAX_PLAYBACK_RATE,
WsolaTimeStretcher::MAX_PLAYBACK_RATE);
- const int framesNeededToStretch =
- std::max(1, static_cast(std::ceil(rate * framesToProcess)));
+ // Prefill happens only in primeWsolaInput() at start(); here feed one quantum's
+ // worth of input for the current rate.
+ const float absRate = std::fabs(rate);
+ const size_t requestedInputFrames = std::max(
+ size_t{1}, static_cast(std::ceil(absRate * static_cast(framesToProcess))));
+ const size_t inputFrames = std::min(requestedInputFrames, playbackRateBuffer_->getSize());
+ const int framesNeededToStretch = static_cast(inputFrames);
playbackRateBuffer_->zero();
@@ -135,12 +185,12 @@ void AudioBufferBaseSourceNode::processWithPitchCorrection(
2.0f, // NOLINT(cppcoreguidelines-avoid-magic-numbers, readability-magic-numbers)
detune / static_cast(SEMITONES_PER_OCTAVE));
- const float bufferPlaybackRate = rate >= 0.0f ? rate : -rate;
- runBufferProcessor(playbackRateBuffer_, startOffset, offsetLength, bufferPlaybackRate, false);
+ // Non-interpolated path uses rate only for direction; abs keeps forward copies 1:1.
+ runBufferProcessor(playbackRateBuffer_, startOffset, offsetLength, absRate, false);
wsolaStretcher_.process(
*playbackRateBuffer_,
- static_cast(framesNeededToStretch),
+ inputFrames,
*processingBuffer,
static_cast(framesToProcess),
rate,
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h
index 3dfb69a4b..6f03c9ac1 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h
@@ -33,6 +33,12 @@ class AudioBufferBaseSourceNode : public AudioScheduledSourceNode {
float sampleRate,
const std::shared_ptr &playbackRateBuffer);
+ /// @brief Prefills WSOLA from the current @ref vReadIndex_ until the analysis queue is full.
+ /// Call from @c start() after the read cursor is set. Advances @ref vReadIndex_ by the
+ /// frames fed. This is the only WSOLA prefill path for buffer sources.
+ /// @note Audio Thread only
+ void primeWsolaInput();
+
[[nodiscard]] std::shared_ptr getDetuneParam() const;
[[nodiscard]] std::shared_ptr getPlaybackRateParam() const;
@@ -64,6 +70,8 @@ class AudioBufferBaseSourceNode : public AudioScheduledSourceNode {
const bool pitchCorrection_;
WsolaTimeStretcher wsolaStretcher_;
std::shared_ptr playbackRateBuffer_;
+ /// Debug: one-shot log of WSOLA prime-at-start.
+ bool wsolaPrimeDebugPending_{true};
const std::shared_ptr detuneParam_;
const std::shared_ptr playbackRateParam_;
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp
index 4a0fe5fce..a342372ec 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp
@@ -104,6 +104,9 @@ void AudioBufferSourceNode::start(double when, double offset, double duration) {
}
vReadIndex_ = static_cast(buffer_->getSampleRate() * offset);
+
+ // Prefill WSOLA from the start cursor before the first quantum.
+ // primeWsolaInput();
}
void AudioBufferSourceNode::disable() {
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp
index b78ce4613..34bd126a2 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp
@@ -14,10 +14,12 @@
#include
#include
#include
+#include
#include
#include
#include
#include
+#include
#include
#if !RN_AUDIO_API_FFMPEG_DISABLED
@@ -134,9 +136,70 @@ bool AudioFileSourceNode::initDecoder(
channelCount_,
context->getSampleRate());
+ startDecoderThread();
+ // Fill WSOLA's analysis queue before the audio thread runs (no wall-clock budget).
+ primeWsolaInputFromDecoder();
return true;
}
+void AudioFileSourceNode::startDecoderThread() {
+ if (seekDecoderDaemon_ == nullptr || seekDecoderThread_.joinable()) {
+ return;
+ }
+
+ seekDecoderThread_ = std::thread(std::move(*seekDecoderDaemon_));
+ seekDecoderDaemon_.reset();
+}
+
+void AudioFileSourceNode::primeWsolaInputFromDecoder() {
+ if (frameReceiver_ == nullptr || playbackRateBuffer_ == nullptr) {
+ return;
+ }
+
+ const size_t framesNeeded =
+ std::max(wsolaStretcher_.getRequiredInputFrames(), wsolaStretcher_.getMinInputFramesToRun());
+ if (framesNeeded == 0) {
+ return;
+ }
+
+ const size_t chunkCapacity = std::max(framesNeeded, static_cast(RENDER_QUANTUM_SIZE));
+ if (!ensurePlaybackRateBufferSize(chunkCapacity)) {
+ return;
+ }
+
+ DecoderData chunk;
+ while (wsolaStretcher_.getBufferedInputFrames() < framesNeeded) {
+ if (frameReceiver_->try_receive(chunk) != channels::spsc::ResponseStatus::SUCCESS) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ continue;
+ }
+
+ if (chunk.state == StreamState::END_OF_STREAM || chunk.state == StreamState::DISCONTINUOUS) {
+ break;
+ }
+ if (chunk.size == 0) {
+ continue;
+ }
+
+ if (!ensurePlaybackRateBufferSize(chunk.size)) {
+ break;
+ }
+
+ playbackRateBuffer_->zero();
+ size_t totalInputFrames = 0;
+ appendFromInterleaved(
+ chunk.interleavedBuffer.data(), chunk.size, 0, chunk.size, totalInputFrames);
+ if (totalInputFrames == 0) {
+ continue;
+ }
+
+ if (volume_ != 1.0f) {
+ playbackRateBuffer_->scale(volume_);
+ }
+ wsolaStretcher_.feedInput(*playbackRateBuffer_, totalInputFrames);
+ }
+}
+
void AudioFileSourceNode::setPlaybackRate(float v) {
if (decoderState_ == nullptr) {
return;
@@ -385,11 +448,6 @@ void AudioFileSourceNode::start(double when) {
endOfStreamStopPending_ = false;
endOfStreamDrainPending_ = false;
positionChanged_.requestFlush();
-
- if (seekDecoderDaemon_) {
- seekDecoderThread_ = std::thread(std::move(*seekDecoderDaemon_));
- seekDecoderDaemon_.reset();
- }
}
void AudioFileSourceNode::bindMediaElementSource(uint64_t bindingId) {
@@ -495,14 +553,12 @@ size_t AudioFileSourceNode::renderWithWsolaPitchPreservation(
DecoderData &incoming,
int framesToProcess,
float activeRate) {
+ // Prefill happens only in primeWsolaInputFromDecoder(); here feed one quantum.
const auto requestedInputFrames =
static_cast(std::ceil(activeRate * static_cast(framesToProcess)));
- const size_t bufferedInputFrames = wsolaStretcher_.getBufferedInputFrames();
- const size_t requiredInputFrames = wsolaStretcher_.getRequiredInputFrames();
- const size_t warmupFramesNeeded =
- bufferedInputFrames < requiredInputFrames ? requiredInputFrames - bufferedInputFrames : 0;
- const size_t inputFrames = accumulateStretchInput(
- incoming, activeRate, framesToProcess, requestedInputFrames + warmupFramesNeeded);
+
+ const size_t inputFrames =
+ accumulateStretchInput(incoming, activeRate, framesToProcess, requestedInputFrames);
if (inputFrames == 0) {
processingBuffer->zero();
return 0;
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h
index c38434494..3b208a6a3 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.h
@@ -188,6 +188,15 @@ class AudioFileSourceNode : public AudioScheduledSourceNode {
const std::shared_ptr &context,
AudioFileSourceOptions &options);
+ /// @brief Starts the SeekDecoderDaemon worker thread if it is not already running.
+ /// @note JS / construction thread only.
+ void startDecoderThread();
+
+ /// @brief Blocks until @ref wsolaStretcher_ has enough PCM for the first OLA iteration,
+ /// or the decoder signals EOS/discontinuity.
+ /// @note JS / construction thread only — must run before the audio thread consumes @ref frameReceiver_.
+ void primeWsolaInputFromDecoder();
+
/// @brief Attempts to read the next chunk of decoded frames from the daemon.
/// @param outData decoded frames and metadata; only valid if return value is true.
/// @return false if no decoded frames are available; true if @p outData is filled and ready to process.
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.cpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.cpp
index 0b005bbe4..0f3992da9 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.cpp
@@ -4,6 +4,7 @@
#include
#include
+#include
#include
#include
@@ -20,6 +21,15 @@ float periodicHann(size_t n, size_t size) {
} // namespace
+size_t WsolaTimeStretcher::scratchBufferFrames(float sampleRate) {
+ const float sr = sampleRate > 0.0f ? sampleRate : DEFAULT_SAMPLE_RATE;
+ // Match configure(): window (+1 if odd) + search, plus one max-rate quantum.
+ size_t window = framesFromMs(sr, OLA_WINDOW_MS);
+ window += window & 1U;
+ return window + framesFromMs(sr, SEARCH_INTERVAL_MS) +
+ static_cast(MAX_PLAYBACK_RATE * RENDER_QUANTUM_SIZE);
+}
+
void WsolaTimeStretcher::configure(size_t channels, float sampleRate) {
channels_ = channels;
sampleRate_ = sampleRate > 0.0f ? sampleRate : DEFAULT_SAMPLE_RATE;
@@ -71,6 +81,11 @@ void WsolaTimeStretcher::reset() {
searchBlockIndex_ = 0;
outputReadIndex_ = 0;
+ firstSampleFound_ = false;
+ firstRelativeSampleFound_ = false;
+ outputPeakAbs_ = 0.0f;
+ totalFramesOutput_ = 0;
+
for (auto &channel : inputQueue_) {
channel.clear();
}
@@ -82,6 +97,25 @@ void WsolaTimeStretcher::reset() {
}
}
+size_t WsolaTimeStretcher::getMinInputFramesToRun() const {
+ if (windowSize_ == 0 || searchIntervalFrames_ == 0) {
+ return 0;
+ }
+
+ // Last search candidate starts at searchBlockIndex_ + searchIntervalFrames_ - 1.
+ const int lastCandidateStart = searchBlockIndex_ + static_cast(searchIntervalFrames_) - 1;
+ const int targetNeed = maxSourceIndexForBlock(targetBlockIndex_);
+ const int searchNeed = maxSourceIndexForBlock(lastCandidateStart);
+ return static_cast(std::max(targetNeed, searchNeed) + 1);
+}
+
+void WsolaTimeStretcher::feedInput(const DSPAudioBuffer &input, size_t inputFrames) {
+ if (channels_ == 0 || windowSize_ == 0 || inputFrames == 0) {
+ return;
+ }
+ appendInput(input, inputFrames);
+}
+
void WsolaTimeStretcher::process(
const DSPAudioBuffer &input,
size_t inputFrames,
@@ -114,6 +148,54 @@ void WsolaTimeStretcher::process(
availableOutputFrames() < outputFrames * 2) {
runOneIteration(playbackRate);
}
+
+ if (!firstSampleFound_ || !firstRelativeSampleFound_) {
+ // Absolute floor catches any non-zero; relative waits for a meaningful peak so
+ // quiet leading content does not look like algorithmic latency.
+ constexpr float kAbsoluteThreshold = 1e-6f;
+ constexpr float kRelativeFraction = 0.5f;
+ constexpr float kMinPeakForRelative = 0.05f;
+
+ for (int i = 0; i < output.getNumberOfChannels(); ++i) {
+ auto *channel = output.getChannel(i);
+ for (int j = 0; j < channel->getSize(); ++j) {
+ const float absSample = std::abs(channel->operator[](j));
+ outputPeakAbs_ = std::max(outputPeakAbs_, absSample);
+
+ if (!firstSampleFound_ && absSample > kAbsoluteThreshold) {
+ firstSampleFound_ = true;
+ const size_t absoluteFrameIndex = totalFramesOutput_ + static_cast(j);
+ const double latencyMs = (static_cast(absoluteFrameIndex) / sampleRate_) * 1000.0;
+ std::cout << "[WSOLA] Pierwszy dzwiek (abs>1e-6)! "
+ << "Ramka wyjsciowa: " << absoluteFrameIndex << " | Opoznienie: " << latencyMs
+ << " ms"
+ << " | Playback Rate: " << playbackRate << std::endl;
+ }
+
+ if (!firstRelativeSampleFound_ && outputPeakAbs_ >= kMinPeakForRelative &&
+ absSample >= kRelativeFraction * outputPeakAbs_) {
+ firstRelativeSampleFound_ = true;
+ const size_t absoluteFrameIndex = totalFramesOutput_ + static_cast(j);
+ const double latencyMs = (static_cast(absoluteFrameIndex) / sampleRate_) * 1000.0;
+ std::cout << "[WSOLA] Pierwszy dzwiek (rel>=" << kRelativeFraction
+ << "*peak, peak>=" << kMinPeakForRelative << ")! "
+ << "Ramka wyjsciowa: " << absoluteFrameIndex << " | Opoznienie: " << latencyMs
+ << " ms"
+ << " | peak=" << outputPeakAbs_ << " | Playback Rate: " << playbackRate
+ << std::endl;
+ }
+
+ if (firstSampleFound_ && firstRelativeSampleFound_) {
+ break;
+ }
+ }
+ if (firstSampleFound_ && firstRelativeSampleFound_) {
+ break;
+ }
+ }
+ }
+
+ totalFramesOutput_ += outputFrames;
}
size_t
@@ -255,13 +337,18 @@ bool WsolaTimeStretcher::canRunIteration() const {
}
const int inputFrames = static_cast(inputQueue_[0].size());
- const int searchBlockSize = static_cast(searchIntervalFrames_ + windowSize_ - 1);
if (maxSourceIndexForBlock(targetBlockIndex_) >= inputFrames) {
return false;
}
- return maxSourceIndexForBlock(searchBlockIndex_ + searchBlockSize - 1) < inputFrames;
+ // Last search candidate window starts at searchBlockIndex_ + searchIntervalFrames_ - 1
+ // (not searchBlockSize - 1, which double-counted the trailing window).
+ if (searchIntervalFrames_ == 0) {
+ return false;
+ }
+ const int lastCandidateStart = searchBlockIndex_ + static_cast(searchIntervalFrames_) - 1;
+ return maxSourceIndexForBlock(lastCandidateStart) < inputFrames;
}
bool WsolaTimeStretcher::targetIsWithinSearchRegion() const {
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.h
index 9af54135b..8198d2659 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/WsolaTimeStretcher.h
@@ -18,6 +18,10 @@ class WsolaTimeStretcher {
return searchIntervalFrames_ + windowSize_;
}
+ /// Minimum input frames needed for @ref canRunIteration at the current analysis
+ /// pointers (search span + target window, accounting for pitchFactor_).
+ [[nodiscard]] size_t getMinInputFramesToRun() const;
+
[[nodiscard]] size_t getBufferedInputFrames() const {
return inputQueue_.empty() ? 0 : inputQueue_[0].size();
}
@@ -26,6 +30,9 @@ class WsolaTimeStretcher {
return availableOutputFrames();
}
+ /// Appends PCM to the analysis queue without rendering output (startup prefill).
+ void feedInput(const DSPAudioBuffer &input, size_t inputFrames);
+
void process(
const DSPAudioBuffer &input,
size_t inputFrames,
@@ -45,6 +52,9 @@ class WsolaTimeStretcher {
static constexpr float INPUT_LATENCY_MS = 20.0f;
static constexpr float OUTPUT_LATENCY_MS = 10.0f;
+ /// Scratch capacity for one cold-start warmup (~window+search) plus one max-rate quantum.
+ [[nodiscard]] static size_t scratchBufferFrames(float sampleRate);
+
private:
static constexpr float OLA_WINDOW_MS = 20.0f;
static constexpr float SEARCH_INTERVAL_MS = 30.0f;
@@ -70,6 +80,11 @@ class WsolaTimeStretcher {
int searchBlockIndex_{0};
size_t outputReadIndex_{0};
+ bool firstSampleFound_ = false;
+ bool firstRelativeSampleFound_ = false;
+ float outputPeakAbs_ = 0.0f;
+ size_t totalFramesOutput_ = 0;
+
std::vector olaWindow_;
std::vector transitionWindow_;
std::vector> inputQueue_;
diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/WsolaScheduleJoinLatencyTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/WsolaScheduleJoinLatencyTest.cpp
new file mode 100644
index 000000000..38d675aec
--- /dev/null
+++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/WsolaScheduleJoinLatencyTest.cpp
@@ -0,0 +1,206 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace audioapi;
+
+// NOLINTBEGIN
+
+namespace {
+
+constexpr int kSampleRate = 24000;
+constexpr int kQuantum = RENDER_QUANTUM_SIZE; // G.render(128)
+constexpr float kPlaybackRate = 1.5f; // WSOLA runs when pitchCorrection && rate != 1
+constexpr float kOnesThreshold = 0.5f;
+
+/// Part length in source frames. 128 is the schedule unit; may need to be longer
+/// so WSOLA has enough PCM to emit (see sufficientPartFrames()).
+constexpr size_t kScheduleUnitFrames = 128;
+
+/// Default join compensation (seconds). Override without rebuilding via:
+/// WSOLA_KL_SECONDS=-0.026 ./build/tests --gtest_filter='WsolaScheduleJoinLatencyTest.*'
+constexpr double kL = 0.0;
+
+double joinCompensationSeconds() {
+ if (const char *env = std::getenv("WSOLA_KL_SECONDS")) {
+ return std::atof(env);
+ }
+ return kL;
+}
+
+size_t sufficientPartFrames() {
+ WsolaTimeStretcher stretcher;
+ stretcher.configure(1, static_cast(kSampleRate));
+ const size_t minIn =
+ std::max(stretcher.getRequiredInputFrames(), stretcher.getMinInputFramesToRun());
+ // Enough ones after cold-start fill to emit a sustained run.
+ const size_t needed = minIn + 4 * kScheduleUnitFrames;
+ const size_t units = (needed + kScheduleUnitFrames - 1) / kScheduleUnitFrames;
+ return units * kScheduleUnitFrames;
+}
+
+std::shared_ptr makeOnesBuffer(size_t frames) {
+ auto buffer = std::make_shared(frames, 1, static_cast(kSampleRate));
+ auto *ch = buffer->getChannel(0)->begin();
+ for (size_t i = 0; i < frames; ++i) {
+ ch[i] = 1.0f;
+ }
+ return buffer;
+}
+
+struct OnesRun {
+ size_t start = 0;
+ size_t length = 0;
+};
+
+/// Calculates the longest continuous sequence of ones (values ≥ kOnesThreshold)
+OnesRun longestOnesRun(const std::vector &samples) {
+ OnesRun best;
+ size_t i = 0;
+ while (i < samples.size()) {
+ if (samples[i] < kOnesThreshold) {
+ ++i;
+ continue;
+ }
+ const size_t start = i;
+ while (i < samples.size() && samples[i] >= kOnesThreshold) {
+ ++i;
+ }
+ const size_t len = i - start;
+ if (len > best.length) {
+ best.start = start;
+ best.length = len;
+ }
+ }
+ return best;
+}
+
+size_t countOnesRuns(const std::vector &samples) {
+ size_t runs = 0;
+ size_t i = 0;
+ while (i < samples.size()) {
+ if (samples[i] < kOnesThreshold) {
+ ++i;
+ continue;
+ }
+ ++runs;
+ while (i < samples.size() && samples[i] >= kOnesThreshold) {
+ ++i;
+ }
+ }
+ return runs;
+}
+
+class TestableAudioBufferSourceNode : public AudioBufferSourceNode {
+ public:
+ explicit TestableAudioBufferSourceNode(
+ const std::shared_ptr &context,
+ const AudioBufferSourceOptions &options)
+ : AudioBufferSourceNode(context, options) {}
+
+ using AudioBufferSourceNode::initStretch;
+ using AudioBufferSourceNode::setBuffer;
+};
+
+template
+NodeT *nodeOf(utils::graph::HostGraph::Node *hostNode) {
+ return static_cast(hostNode->handle->audioNode->asAudioNode());
+}
+
+} // namespace
+
+/// Skeleton: two ones-buffers b1, b2 of length x, WSOLA path:
+/// b1.start(0)
+/// b2.start(x / (sr * playbackRate) + L)
+/// repeatedly G.render(128)
+/// Pass when output is: leading zeros (WSOLA latency), then ONE contiguous ones
+/// run (no internal gap). A gap ⇒ L is wrong.
+///
+/// kL is a stub (0). An agent should derive the formula for L that makes this pass.
+/// No search loop here — single fixed L only.
+TEST(WsolaScheduleJoinLatencyTest, ContiguousOnesAfterJoin) {
+ const double L = joinCompensationSeconds();
+ const size_t x = sufficientPartFrames();
+ // Ideal wall-clock ones from two abutted parts (OLA soft edges ⇒ allow < 100%).
+ const size_t idealOnes = static_cast(
+ std::lround(2.0 * static_cast(x) / static_cast(kPlaybackRate)));
+ const size_t minOnes = (idealOnes * 90) / 100;
+ const size_t captureFrames = idealOnes + static_cast(kSampleRate / 2); // 0.5s margin
+
+ auto eventRegistry = std::make_shared();
+ const size_t safetyMarginSize = 2;
+ auto context = std::make_shared(
+ 1, static_cast(captureFrames * safetyMarginSize), kSampleRate, eventRegistry);
+ auto destination = std::make_unique(context);
+ context->initialize(destination.get());
+ auto *destinationHostNode = context->getGraph()->addNode(std::move(destination));
+
+ BaseAudioBufferSourceOptions baseOptions;
+ baseOptions.pitchCorrection = true;
+ baseOptions.playbackRate = kPlaybackRate;
+ baseOptions.detune = 0.0f;
+ AudioBufferSourceOptions options(baseOptions);
+
+ auto makeSource = [&]() {
+ auto node = std::make_unique(context, options);
+ auto playbackRateBuffer = std::make_shared(
+ WsolaTimeStretcher::scratchBufferFrames(static_cast(kSampleRate)),
+ 1,
+ static_cast(kSampleRate));
+ node->initStretch(1, static_cast(kSampleRate), playbackRateBuffer);
+ auto outBuf = std::make_shared(kQuantum, 1, static_cast(kSampleRate));
+ node->setBuffer(makeOnesBuffer(x), outBuf);
+ auto *hostNode = context->getGraph()->addNode(std::move(node));
+ EXPECT_TRUE(context->getGraph()->addEdge(hostNode, destinationHostNode).is_ok());
+ return nodeOf(hostNode);
+ };
+
+ auto *b1 = makeSource();
+ auto *b2 = makeSource();
+
+ const double contentDur = static_cast(x) /
+ (static_cast(kSampleRate) * static_cast(kPlaybackRate));
+ b1->start(0.0, 0.0);
+ b2->start(contentDur + L, 0.0);
+
+ std::vector output;
+ output.reserve(captureFrames);
+ auto quantum = std::make_shared(kQuantum, 1, static_cast(kSampleRate));
+ while (output.size() < captureFrames) {
+ quantum->zero();
+ context->processGraph(quantum.get(), kQuantum); // G.render(128)
+ const float *ch = quantum->getChannel(0)->begin();
+ for (int i = 0; i < kQuantum; ++i) {
+ output.push_back(ch[i]);
+ }
+ }
+
+ const OnesRun run = longestOnesRun(output);
+ const size_t runs = countOnesRuns(output);
+
+ EXPECT_EQ(runs, 1u) << "Gap in ones ⇒ L is wrong. L=" << L << "s onesStart=" << run.start
+ << " onesLen=" << run.length;
+ EXPECT_GE(run.start, 1u) << "Expected leading zeros from WSOLA latency before ones";
+ EXPECT_GE(run.length, minOnes) << "Contiguous ones too short (need ≥90% of 2*x/rate). L=" << L
+ << "s onesLen=" << run.length << " min=" << minOnes
+ << " ideal=" << idealOnes;
+}
+
+// NOLINTEND
diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/WsolaTimeStretcherTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/WsolaTimeStretcherTest.cpp
index 48e84e7ce..f07c5d563 100644
--- a/packages/react-native-audio-api/common/cpp/test/src/utils/WsolaTimeStretcherTest.cpp
+++ b/packages/react-native-audio-api/common/cpp/test/src/utils/WsolaTimeStretcherTest.cpp
@@ -79,6 +79,42 @@ TEST_F(WsolaTimeStretcherTest, RequiredInputFramesAt44100Hz) {
EXPECT_EQ(stretcher_.getRequiredInputFrames(), 2205u);
}
+TEST_F(WsolaTimeStretcherTest, MinInputFramesToRunMatchesSearchSpanNeed) {
+ // Cold start: last candidate needs searchInterval + window - 1 source samples.
+ EXPECT_EQ(stretcher_.getMinInputFramesToRun(), stretcher_.getRequiredInputFrames() - 1u);
+ EXPECT_LE(stretcher_.getMinInputFramesToRun(), stretcher_.getRequiredInputFrames());
+}
+
+TEST_F(WsolaTimeStretcherTest, FeedInputPrimesForImmediateFirstQuantumOutput) {
+ const size_t framesNeeded = stretcher_.getMinInputFramesToRun();
+ ASSERT_GT(framesNeeded, 0u);
+
+ DSPAudioBuffer prime(framesNeeded, 1, kSampleRate);
+ for (size_t i = 0; i < framesNeeded; ++i) {
+ prime.getChannel(0)->span()[i] =
+ std::sin(2.0f * PI * 440.0f * static_cast(i) / kSampleRate);
+ }
+ stretcher_.feedInput(prime, framesNeeded);
+ EXPECT_GE(stretcher_.getBufferedInputFrames(), framesNeeded);
+
+ // One quantum of additional input at rate 1.5 — should emit non-zero in the first process().
+ const size_t inputFrames = inputFramesForQuantum(kPlaybackRate);
+ DSPAudioBuffer input(inputFrames, 1, kSampleRate);
+ DSPAudioBuffer output(kQuantum, 1, kSampleRate);
+ for (size_t i = 0; i < inputFrames; ++i) {
+ input.getChannel(0)->span()[i] =
+ std::sin(2.0f * PI * 440.0f * static_cast(framesNeeded + i) / kSampleRate);
+ }
+
+ stretcher_.process(input, inputFrames, output, kQuantum, kPlaybackRate);
+
+ float peak = 0.0f;
+ for (size_t i = 0; i < output.getSize(); ++i) {
+ peak = std::max(peak, std::abs(output.getChannel(0)->span()[i]));
+ }
+ EXPECT_GT(peak, 0.01f);
+}
+
TEST_F(WsolaTimeStretcherTest, SchedulingLatencyFormulaMatchesDocumentedConstants) {
EXPECT_NEAR(schedulingLatencySeconds(1.0f), 0.03f, 1e-6f);
EXPECT_NEAR(schedulingLatencySeconds(kPlaybackRate), 0.04f, 1e-6f);
diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts
index d47251a11..6fec06dd1 100644
--- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts
+++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts
@@ -4,6 +4,7 @@ import {
ContextState,
DecodeDataInput,
AudioBufferQueueSourceOptions,
+ AudioBufferSourceOptions,
} from '../types';
import AnalyserNode from './AnalyserNode';
import AudioBuffer from './AudioBuffer';
@@ -95,9 +96,9 @@ export default class BaseAudioContext {
return new BiquadFilterNode(this);
}
- createBufferSource(options?: {
- pitchCorrection: boolean;
- }): AudioBufferSourceNode {
+ createBufferSource(
+ options?: AudioBufferSourceOptions
+ ): AudioBufferSourceNode {
if (options !== undefined) {
return new AudioBufferSourceNode(this, options);
} else {