Skip to content

pitchCorrection: audible periodic tone at any playbackRate other than 1 (iOS, realtime only) #1259

Description

@claydough

Summary

With pitchCorrection: true, an AudioBufferSourceNode played at any
playbackRate other than exactly 1 adds an audible, steady periodic tone on a
physical device. The tone's level follows the material's envelope, so on a drum
loop it reads as a "beep" behind the kit that swells with each hit.

At playbackRate === 1 the output is clean, and the artifact appears as soon as
the rate leaves 1 — including small deviations such as 1.05.

What is affected

Time-stretched playback of pre-recorded audio — in our case a drum-loop practice
feature, where the whole point is to play a loop recorded at 92 BPM at some
other tempo while keeping its pitch. pitchCorrection: true is exactly the
right tool for that and it is unusable at present, so we have had to fall back
to plain resampling and let the pitch move with the tempo.

Reproduction

const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(require('./drums.wav')); // any dense/percussive material

const src = ctx.createBufferSource({ pitchCorrection: true });
src.buffer = buf;
src.playbackRate.value = 1.14;   // clean at exactly 1.0; rings at 1.02 and above
src.connect(ctx.destination);
src.start(ctx.currentTime + 0.05);

Listen on a device. Percussive, broadband material makes it obvious; a sustained
tone or speech hides it.

It does not reproduce offline

This is the part that may narrow it down. Rendering the identical graph through
OfflineAudioContext produces no artifact at any rate:

  • Rendered the same buffer at rates 1.00, 1.02, 1.05, 1.14 and 1.30
    and measured narrowband energy (peak-to-median across a Goertzel bin bank,
    taking the 10th-percentile level per bin across frames so a sustained tone
    separates from percussive transients). All five score the same, ~11x. Nothing
    is added by stretching.
  • At playbackRate === 1, pitchCorrection: true and false render
    bit-identical output (worst sample difference 0.00000), which is
    consistent with the stretcher being bypassed at unity — and unity is the one
    case with no audible artifact.

So the fault appears to be in the realtime path rather than in the WSOLA
arithmetic itself.

Source observations

From common/cpp/audioapi/dsp/WsolaTimeStretcher.{h,cpp}:

  • OLA_WINDOW_MS = 20.0f, hopSize_ = windowSize_ / 2 — a fixed 10 ms
    synthesis hop, i.e. a 100 Hz periodicity, which is roughly the character of
    what we hear.
  • SEARCH_DECIMATION = 12 — the similarity search is decimated 12x, so poor
    splice points seem plausible if the realtime path is also under-feeding the
    stretcher.
  • None of these are reachable from JS, so there is no way to tune around it from
    the application side.

WsolaTimeStretcher.cpp is byte-identical between 0.13.3,
1.0.0-nightly-1c00547-20260729 and 1.0.0-nightly-c12bd7c-20260901, so this
is not a regression — it reproduces on all three.

Environment

react-native-audio-api 1.0.0-nightly-1c00547-20260729 (also present in 0.13.3 and the 09-01 nightly)
react-native 0.86.3
expo 57.0.18
Platform iOS 26.6, physical iPhone 17 Pro Max, Release build
Context sample rate 48000 (source files also 48 kHz, so no resampling involved)

Happy to supply the offline measurement harness or a sample file if that would
help.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working as expected or produces unexpected errorsiosNative iOS implementation, CoreAudio/AVFoundation bindings, or iOS-specific issues

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions