Skip to content

[audio-studio] iOS: interval (and other Int options) silently ignored — onAudioStream pinned at 1 Hz #423

Description

@jgwhitebeard

Summary

On iOS, interval (and intervalAnalysis, channels, bitDepth) passed to startRecording is silently ignored. The emitter always falls back to its default, so onAudioStream fires at 1 Hz no matter what you ask for. Android is unaffected.

This makes the library unusable for anything that needs timely frames — a live input-level meter, VAD, or low-latency streaming to an STT service.

Reproduction

@siteed/audio-studio@3.2.1, Expo SDK 57, iPhone 12 mini / iOS 26.5, dev-client build.

await recorder.startRecording({
  sampleRate: 16000,
  channels: 1,
  encoding: 'pcm_16bit',
  interval: 100,            // <-- ignored on iOS
  onAudioStream: async (event) => {
    const pcm = pcmFromAudioData(event.data);
    console.log(`dt=${Date.now() - last}ms samples=${pcm.length}`);
    last = Date.now();
  },
});

Observed on iOS — one frame per second, each carrying 1.1 s of audio:

dt=1086ms samples=17600   (1100ms of audio @ 16kHz)
dt=1087ms samples=17600

Expected (and what Android already does) — ~10 frames per second of 100 ms each.

Cause

ios/RecordingSettings.swift reads the integer options with a strict as? Int:

settings.numberOfChannels = dict["channels"] as? Int ?? 1
settings.bitDepth         = dict["bitDepth"] as? Int ?? 16
settings.interval         = dict["interval"] as? Int          // <-- always nil
settings.intervalAnalysis = dict["intervalAnalysis"] as? Int  // <-- always nil

The module receives options as [String: Any] (AsyncFunction("startRecording") { (options: [String: Any], ...) }). Expo bridges a JS number into that dictionary as a Double, and Swift's as? performs no numeric conversion(100.0 as Any) as? Int is nil. So settings.interval is nil and AudioStreamManager falls back to its default:

emissionInterval = max(10.0, Double(settings.interval ?? 1000)) / 1000.0  // -> 1.0s

Two things confirm the mechanism rather than merely correlating with it:

  1. In the same dictionary, the Double reads work and the Int reads don't. sampleRate is read as as? Double and takes effect (frames really do arrive at 16 kHz); interval is read as as? Int and does not. That isolates the failure to the cast, not to the option plumbing.
  2. maxDurationMs in this very file already works around it by going through NSNumber first:
    if let maxDurationNumber = dict["maxDurationMs"] as? NSNumber {
        settings.maxDurationMs = maxDurationNumber.int64Value
    }

channels and bitDepth are affected too, but the bug is invisible there because their ?? defaults (1, 16) happen to equal the values callers normally pass.

Android is correctRecordingConfig.kt reads numbers tolerantly via getNumberOrDefault, which is why this is iOS-only.

Suggested fix

Read the integer options through NSNumber, which bridges from Double, Int and NSNumber alike — the same tolerant read Android does and that maxDurationMs already uses:

settings.numberOfChannels = (dict["channels"] as? NSNumber)?.intValue ?? 1
settings.bitDepth         = (dict["bitDepth"] as? NSNumber)?.intValue ?? 16
settings.interval         = (dict["interval"] as? NSNumber)?.intValue
settings.intervalAnalysis = (dict["intervalAnalysis"] as? NSNumber)?.intValue

With that patch applied, the same code above emits 1600-sample (100 ms) frames at ~10 Hz as documented.

Secondary note: interval at the tap-buffer period is unreliable

Once the cast is fixed, asking for exactly interval: 100 still yields ~5 Hz on this device. iOS delivers tap buffers at ~100 ms, and the emitter gates on timeSinceLastEmission >= emissionInterval; with the two periods equal, normal jitter makes every other buffer miss the gate, so frames alternate 200 ms / 100 ms:

dt=200ms samples=3200 (200ms audio)
dt=192ms samples=3200
dt=120ms samples=1600 (100ms audio)
dt=200ms samples=3200

Requesting interval: 50 (anything below the tap period) clears the gate on every buffer and gives a steady 100 ms cadence. Callers can work around it, but it may be worth either documenting, or emitting when the accumulated audio reaches the requested interval rather than gating purely on elapsed wall-clock.

Happy to open a PR with the NSNumber fix if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions