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:
- 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.
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 correct — RecordingConfig.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.
Summary
On iOS,
interval(andintervalAnalysis,channels,bitDepth) passed tostartRecordingis silently ignored. The emitter always falls back to its default, soonAudioStreamfires 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.Observed on iOS — one frame per second, each carrying 1.1 s of audio:
Expected (and what Android already does) — ~10 frames per second of 100 ms each.
Cause
ios/RecordingSettings.swiftreads the integer options with a strictas? Int:The module receives options as
[String: Any](AsyncFunction("startRecording") { (options: [String: Any], ...) }). Expo bridges a JS number into that dictionary as aDouble, and Swift'sas?performs no numeric conversion —(100.0 as Any) as? Intisnil. Sosettings.intervalisnilandAudioStreamManagerfalls back to its default:Two things confirm the mechanism rather than merely correlating with it:
Doublereads work and theIntreads don't.sampleRateis read asas? Doubleand takes effect (frames really do arrive at 16 kHz);intervalis read asas? Intand does not. That isolates the failure to the cast, not to the option plumbing.maxDurationMsin this very file already works around it by going throughNSNumberfirst:channelsandbitDepthare affected too, but the bug is invisible there because their??defaults (1,16) happen to equal the values callers normally pass.Android is correct —
RecordingConfig.ktreads numbers tolerantly viagetNumberOrDefault, which is why this is iOS-only.Suggested fix
Read the integer options through
NSNumber, which bridges fromDouble,IntandNSNumberalike — the same tolerant read Android does and thatmaxDurationMsalready uses:With that patch applied, the same code above emits 1600-sample (100 ms) frames at ~10 Hz as documented.
Secondary note:
intervalat the tap-buffer period is unreliableOnce the cast is fixed, asking for exactly
interval: 100still yields ~5 Hz on this device. iOS delivers tap buffers at ~100 ms, and the emitter gates ontimeSinceLastEmission >= emissionInterval; with the two periods equal, normal jitter makes every other buffer miss the gate, so frames alternate 200 ms / 100 ms: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
NSNumberfix if useful.