Skip to content

[Feature] Native maxDurationMs cap with pause-aware MaxDurationReached event #400

Description

@Teczer

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:

  1. 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.
  2. 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)

  1. 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.
  2. 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.
  3. 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.

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