Summary
Add an optional maxDurationMs field to RecordingConfig and a matching MaxDurationReached event so the native module can notify JS once the cumulative active recording time reaches a configurable hard limit. The recorder remains active when the event fires — JS owns the stop decision.
Motivation
We use @siteed/audio-studio 3.1.0 (RN, iOS + Android) to drive a speech-to-text feature for healthcare professionals. The product enforces a strict 5-minute maximum per recording as a business rule.
Today the library has no built-in way to enforce this. The only options are:
- Poll
durationMs from a JS useEffect. Works, but:
- Drifts when JS is throttled (background, heavy renders, JSI back-pressure).
- Has to manually de-duplicate calls across pause/resume transitions.
- Imposes a status-change re-render to evaluate the threshold.
- Schedule a JS
setTimeout(maxDurationMs) at startRecording. Worse:
- Doesn't account for pause/resume — needs manual bookkeeping.
- Fires while the app is suspended on iOS.
Both approaches duplicate state that the native side already owns (active duration, pause state). It would be much cleaner to let the module run a single pause-aware timer and emit one event, the same way onRecordingInterrupted already works for system interruptions.
Proposed public API
RecordingConfig
interface RecordingConfig {
// ...existing fields
/**
* Optional hard limit on active recording duration, in milliseconds.
*
* When set to a positive number, the native recorder runs an internal
* pause-aware timer. Once the cumulative active recording time reaches
* this value, the module emits a `MaxDurationReached` event to JavaScript.
*
* The native session is NOT torn down automatically — the caller is
* responsible for calling `stopRecording()` when the event is received.
* This avoids races between the auto-stop and a user-initiated stop, and
* lets the JS layer decide whether to finalize, prompt the user, or keep
* recording.
*
* Setting this to `undefined`, `0`, or a negative value disables the timer.
*/
maxDurationMs?: number
/**
* Callback invoked by `useAudioRecorder` when the native module emits
* the `MaxDurationReached` event.
*/
onMaxDurationReached?: (event: MaxDurationReachedEvent) => void
}
New event
export interface MaxDurationReachedEvent {
/** Active recording duration that triggered the event, in milliseconds. */
durationMs: number
}
export function addMaxDurationReachedListener(
listener: (event: MaxDurationReachedEvent) => void
): EventSubscription
Hook integration
useAudioRecorder subscribes to MaxDurationReached, dispatches the call to the user-supplied onMaxDurationReached callback (read from the recordingConfigRef already in place for onRecordingInterrupted), and removes the subscription on unmount.
Design rationale: notify, don't stop
I considered three behaviors for the native side:
| Approach |
Pros |
Cons |
| Auto-stop natively, emit event after stop |
Most "automatic" |
Races with user-driven stopRecording. Promise contract becomes weird. Hard to test. Forces JS to discover the stop happened. |
| Emit event then auto-stop |
Simpler than above |
Same races + JS can't intercept (e.g. show a prompt). |
| Emit event only, leave session alive |
No promise/race issues. JS owns lifecycle. Trivial to extend (autoStop flag later). |
Caller must remember to call stopRecording(). |
I went with the third option in our patch — it has the smallest blast radius and mirrors how onRecordingInterrupted already behaves. An optional autoStopOnMaxDuration: boolean could be added later if there is demand.
Pause-aware accounting
The timer must only count active recording time. That means:
- On
startRecording → start a timer for maxDurationMs ms; record segmentStart = now().
- On
pauseRecording → cancel the timer; add now() - segmentStart to accumulatedActiveMs.
- On
resumeRecording → schedule a new timer for maxDurationMs - accumulatedActiveMs ms; reset segmentStart.
- On
stopRecording / module destroy → cancel timer and reset state.
- On timer fire → compute
totalMs = accumulatedActiveMs + (segmentStart ? now() - segmentStart : 0), emit { durationMs: totalMs }, mark fired so a late pause/resume doesn't double-fire.
This matches the semantics of durationMs that the module already exposes, so the consumer never sees a mismatch.
Implementation hints (matching the patch I already maintain locally)
JavaScript
- Add
maxDurationMs?: number and onMaxDurationReached? to RecordingConfig in src/AudioStudio.types.ts.
- Add
MaxDurationReachedEvent to the same file.
- Add
addMaxDurationReachedListener in src/events.ts (mirrors addRecordingInterruptionListener).
- In
src/useAudioRecorder.tsx:
- Destructure
onMaxDurationReached out of validatedOptions (and recordingOptions in prepareRecording) so it doesn't leak to the native bridge.
- Add a
useEffect that subscribes to addMaxDurationReachedListener and invokes recordingConfigRef.current?.onMaxDurationReached?.(event).
iOS (Swift, ios/AudioStudioModule.swift)
State kept on the module (not on AudioStreamManager, so the patch surface stays minimal):
private let maxDurationLock = NSLock()
private var maxDurationTimer: DispatchSourceTimer?
private var maxDurationTargetMs: Int64 = 0
private var maxDurationAccumulatedActiveMs: Int64 = 0
private var maxDurationSegmentStart: Date?
private var maxDurationFired: Bool = false
Wiring:
- Register
"MaxDurationReached" in Events([...]).
- After
streamManager.startRecording(...) succeeds, if options["maxDurationMs"] > 0, call startMaxDurationTimer(targetMs:).
- Call
pauseMaxDurationTimer() / resumeMaxDurationTimer() from the Function("pauseRecording") / Function("resumeRecording") definitions.
- Call
cancelMaxDurationTimer() at the top of AsyncFunction("stopRecording") and in OnDestroy.
- The timer fires on the existing
audioLifecycleQueue; the event is sent on the main thread.
Android (Kotlin, android/.../AudioStudioModule.kt)
State kept on the module:
private val maxDurationLock = Any()
private val maxDurationHandler = Handler(Looper.getMainLooper())
private var maxDurationRunnable: Runnable? = null
private var maxDurationTargetMs: Long = 0L
private var maxDurationAccumulatedActiveMs: Long = 0L
private var maxDurationSegmentStartElapsed: Long = 0L
private var maxDurationFired: Boolean = false
Wiring:
- Register
Constants.MAX_DURATION_REACHED_EVENT = "MaxDurationReached" in Events(...).
- In
AsyncFunction("startRecording"), wrap the inbound Promise so that resolve(value) calls startMaxDurationTimer(maxDurationMs) (read from options) before propagating to the caller.
- Same wrapping for
pauseRecording (calls pauseMaxDurationTimer() on resolve) and resumeRecording (calls resumeMaxDurationTimer() on resolve), so the timer follows the actual native pause/resume state, not just the JS request.
- Cancel the timer at the top of
stopRecording and in OnDestroy.
- Use
SystemClock.elapsedRealtime() (monotonic) for accumulation.
- Emit via the existing
safeSendEvent helper with bundleOf("durationMs" to totalMs).
Known limitations of my current patch (worth discussing for upstream)
- System-driven pause/resume bypasses the timer. When a phone call interrupts the recording, the lib pauses internally via
AudioStreamManager / AudioRecorderManager without going through the module's pauseRecording function. The max-duration timer keeps running during the interruption, so it can fire earlier than expected on resume. Upstream, the cleanest fix is to hook the pause/resume notifications inside the stream/recorder managers themselves rather than in the module wrapper.
- No web implementation. I only patched iOS + Android because that's what we ship. The web recorder in
WebRecorder.web.ts would need an equivalent setTimeout-based pause-aware timer.
- No
autoStopOnMaxDuration flag. Intentionally omitted to keep the patch small; can be added later as a discoverable opt-in.
Use case
In our app, the wiring at the call site reduces to:
const { ... } = useStt({
maxDurationSeconds: 300, // 5 min
onMaxDurationReached: () => {
void finalize().then(() => {
showToast('Maximum duration reached, recording finalized.')
})
},
})
The previous code path needed a polling useEffect plus an autoFinalizedRef guard against double-fires. The patched version removes that complexity entirely.
Happy to send a PR
I've been running this patched locally for [duration]. If you're open to upstreaming, I can break the changes into a PR matching your style guidelines (tests, web parity, optional autoStopOnMaxDuration flag, etc.). Just let me know what scope you'd accept.
Thanks for the great library.
Summary
Add an optional
maxDurationMsfield toRecordingConfigand a matchingMaxDurationReachedevent so the native module can notify JS once the cumulative active recording time reaches a configurable hard limit. The recorder remains active when the event fires — JS owns the stop decision.Motivation
We use
@siteed/audio-studio3.1.0 (RN, iOS + Android) to drive a speech-to-text feature for healthcare professionals. The product enforces a strict 5-minute maximum per recording as a business rule.Today the library has no built-in way to enforce this. The only options are:
durationMsfrom a JSuseEffect. Works, but:setTimeout(maxDurationMs)atstartRecording. Worse:Both approaches duplicate state that the native side already owns (active duration, pause state). It would be much cleaner to let the module run a single pause-aware timer and emit one event, the same way
onRecordingInterruptedalready works for system interruptions.Proposed public API
RecordingConfigNew event
Hook integration
useAudioRecordersubscribes toMaxDurationReached, dispatches the call to the user-suppliedonMaxDurationReachedcallback (read from therecordingConfigRefalready in place foronRecordingInterrupted), and removes the subscription on unmount.Design rationale: notify, don't stop
I considered three behaviors for the native side:
stopRecording. Promise contract becomes weird. Hard to test. Forces JS to discover the stop happened.autoStopflag later).stopRecording().I went with the third option in our patch — it has the smallest blast radius and mirrors how
onRecordingInterruptedalready behaves. An optionalautoStopOnMaxDuration: booleancould be added later if there is demand.Pause-aware accounting
The timer must only count active recording time. That means:
startRecording→ start a timer formaxDurationMsms; recordsegmentStart = now().pauseRecording→ cancel the timer; addnow() - segmentStarttoaccumulatedActiveMs.resumeRecording→ schedule a new timer formaxDurationMs - accumulatedActiveMsms; resetsegmentStart.stopRecording/ module destroy → cancel timer and reset state.totalMs = accumulatedActiveMs + (segmentStart ? now() - segmentStart : 0), emit{ durationMs: totalMs }, mark fired so a late pause/resume doesn't double-fire.This matches the semantics of
durationMsthat the module already exposes, so the consumer never sees a mismatch.Implementation hints (matching the patch I already maintain locally)
JavaScript
maxDurationMs?: numberandonMaxDurationReached?toRecordingConfiginsrc/AudioStudio.types.ts.MaxDurationReachedEventto the same file.addMaxDurationReachedListenerinsrc/events.ts(mirrorsaddRecordingInterruptionListener).src/useAudioRecorder.tsx:onMaxDurationReachedout ofvalidatedOptions(andrecordingOptionsinprepareRecording) so it doesn't leak to the native bridge.useEffectthat subscribes toaddMaxDurationReachedListenerand invokesrecordingConfigRef.current?.onMaxDurationReached?.(event).iOS (Swift,
ios/AudioStudioModule.swift)State kept on the module (not on
AudioStreamManager, so the patch surface stays minimal):Wiring:
"MaxDurationReached"inEvents([...]).streamManager.startRecording(...)succeeds, ifoptions["maxDurationMs"] > 0, callstartMaxDurationTimer(targetMs:).pauseMaxDurationTimer()/resumeMaxDurationTimer()from theFunction("pauseRecording")/Function("resumeRecording")definitions.cancelMaxDurationTimer()at the top ofAsyncFunction("stopRecording")and inOnDestroy.audioLifecycleQueue; the event is sent on the main thread.Android (Kotlin,
android/.../AudioStudioModule.kt)State kept on the module:
Wiring:
Constants.MAX_DURATION_REACHED_EVENT = "MaxDurationReached"inEvents(...).AsyncFunction("startRecording"), wrap the inboundPromiseso thatresolve(value)callsstartMaxDurationTimer(maxDurationMs)(read from options) before propagating to the caller.pauseRecording(callspauseMaxDurationTimer()on resolve) andresumeRecording(callsresumeMaxDurationTimer()on resolve), so the timer follows the actual native pause/resume state, not just the JS request.stopRecordingand inOnDestroy.SystemClock.elapsedRealtime()(monotonic) for accumulation.safeSendEventhelper withbundleOf("durationMs" to totalMs).Known limitations of my current patch (worth discussing for upstream)
AudioStreamManager/AudioRecorderManagerwithout going through the module'spauseRecordingfunction. The max-duration timer keeps running during the interruption, so it can fire earlier than expected on resume. Upstream, the cleanest fix is to hook the pause/resume notifications inside the stream/recorder managers themselves rather than in the module wrapper.WebRecorder.web.tswould need an equivalentsetTimeout-based pause-aware timer.autoStopOnMaxDurationflag. Intentionally omitted to keep the patch small; can be added later as a discoverable opt-in.Use case
In our app, the wiring at the call site reduces to:
The previous code path needed a polling
useEffectplus anautoFinalizedRefguard against double-fires. The patched version removes that complexity entirely.Happy to send a PR
I've been running this patched locally for [duration]. If you're open to upstreaming, I can break the changes into a PR matching your style guidelines (tests, web parity, optional
autoStopOnMaxDurationflag, etc.). Just let me know what scope you'd accept.Thanks for the great library.