0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

A Retry-Safe MediaRecorder State Machine for Browser Voice Takes

0
Posted at

Why a recording button needs a state machine

A browser voice recorder looks simple: request microphone access, call MediaRecorder.start(), and stop when the user is finished. The edge cases appear when the interface supports repeated character lines, retakes, playback, and asynchronous uploads.

Without an explicit state model, a second click can start another recorder while the first one is still stopping. A late dataavailable event can overwrite a newer take. A failed permission request can leave the UI showing “recording” even though no recorder exists.

This article describes a small state machine that keeps each take isolated and makes retries predictable.

Define states before writing event handlers

Use a closed set of states instead of several unrelated booleans:

type RecorderState =
  | { kind: "idle" }
  | { kind: "requesting-permission" }
  | { kind: "recording"; takeId: string; startedAt: number }
  | { kind: "stopping"; takeId: string }
  | { kind: "review"; takeId: string; url: string; blob: Blob }
  | { kind: "error"; message: string };

This prevents impossible combinations such as isRecording === true and hasError === true while no microphone stream exists. Rendering becomes a direct function of one state value.

Give every attempt an identity

Create a unique ID before starting each recording. Event callbacks capture that ID and ignore events belonging to an older attempt.

const chunks = new Map<string, BlobPart[]>();
let currentTakeId: string | null = null;

function createTakeId() {
  return crypto.randomUUID();
}

function isCurrentTake(takeId: string) {
  return currentTakeId === takeId;
}

The identity check matters because stop() is asynchronous. The user may start a new take before the previous recorder emits its final data. Without IDs, the old callback can replace the new recording.

Start recording as a transaction

Treat startup as a sequence that either completes or rolls back:

async function beginTake() {
  if (state.kind !== "idle" && state.kind !== "review") return;

  state = { kind: "requesting-permission" };
  render();

  const takeId = createTakeId();
  let stream: MediaStream | undefined;

  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: false
      }
    });

    currentTakeId = takeId;
    chunks.set(takeId, []);

    const recorder = new MediaRecorder(stream);

    recorder.addEventListener("dataavailable", (event) => {
      if (!isCurrentTake(takeId) || event.data.size === 0) return;
      chunks.get(takeId)?.push(event.data);
    });

    recorder.addEventListener("stop", () => {
      finalizeTake(takeId, recorder.mimeType, stream!);
    }, { once: true });

    activeRecorder = recorder;
    recorder.start(250);

    state = {
      kind: "recording",
      takeId,
      startedAt: performance.now()
    };
  } catch (error) {
    stream?.getTracks().forEach((track) => track.stop());
    currentTakeId = null;
    chunks.delete(takeId);
    state = {
      kind: "error",
      message: error instanceof Error ? error.message : "Microphone unavailable"
    };
  }

  render();
}

The 250 millisecond timeslice is not mandatory, but periodic chunks make long recordings less dependent on one final event.

Make stop idempotent

Rapid clicks and keyboard shortcuts can call stop more than once. The stop function should accept the first request and ignore the rest.

function stopTake() {
  if (state.kind !== "recording") return;
  if (!activeRecorder || activeRecorder.state === "inactive") return;

  const takeId = state.takeId;
  state = { kind: "stopping", takeId };
  render();

  activeRecorder.stop();
}

Disable the record and stop controls while the state is stopping. Do not transition to review until the final blob has been assembled.

Finalize only the current take

function finalizeTake(
  takeId: string,
  mimeType: string,
  stream: MediaStream
) {
  stream.getTracks().forEach((track) => track.stop());

  if (!isCurrentTake(takeId)) {
    chunks.delete(takeId);
    return;
  }

  const blob = new Blob(chunks.get(takeId) ?? [], { type: mimeType });
  chunks.delete(takeId);
  activeRecorder = null;

  if (blob.size === 0) {
    state = { kind: "error", message: "The recording was empty." };
    render();
    return;
  }

  revokeReviewUrl();
  const url = URL.createObjectURL(blob);
  reviewUrl = url;
  state = { kind: "review", takeId, url, blob };
  render();
}

Always stop every media track. Stopping the recorder does not guarantee that the microphone indicator disappears; the underlying stream must also be released.

Object URLs also need ownership. Revoke the previous URL when replacing a take and when unmounting the recorder screen:

function revokeReviewUrl() {
  if (!reviewUrl) return;
  URL.revokeObjectURL(reviewUrl);
  reviewUrl = null;
}

Keep the last good take during a retry

A useful retake interface does not destroy a valid take at the moment the user presses Record again. Keep the reviewed blob as the committed take and store the new recording as a candidate. Replace the committed take only after the candidate finishes successfully and the user accepts it.

A minimal data model is:

type LineRecording = {
  committed?: { blob: Blob; durationMs: number };
  candidate?: { blob: Blob; durationMs: number };
};

This is especially helpful in multi-line character voice workflows. If permission changes, the tab loses focus, or the new attempt is empty, the previous usable line remains available.

Handle cancellation separately from failure

Cancellation is an expected action, not an exception. When the user cancels:

  1. Detach or invalidate callbacks by changing currentTakeId.
  2. Stop the recorder if it is active.
  3. Stop all media tracks.
  4. Remove chunks for the cancelled ID.
  5. Return to the previous review state or idle state.

Do not show a red error message for a deliberate cancel. Reserve the error state for conditions the user must understand or fix.

Testing the race conditions

The most valuable tests exercise event ordering instead of only the happy path:

  • Click Record twice before microphone permission resolves.
  • Click Stop twice in rapid succession.
  • Stop and immediately start another take.
  • Deliver the old recorder's final chunk after the new take begins.
  • Reject microphone permission.
  • Produce an empty blob.
  • Navigate away while recording.
  • Replace a review URL and assert the old URL is revoked.

A fake recorder that lets tests emit dataavailable and stop in any order is more useful than a browser-only happy-path test.

Product-level lesson

The state machine is small, but it changes the user experience: controls no longer contradict the actual microphone state, retakes do not silently destroy good work, and late browser events cannot attach audio to the wrong line.

I applied these principles while reviewing browser-based voice workflows such as ChoicerVoicer, where a session can contain multiple character lines and repeated takes. The same design works for interview recorders, pronunciation tools, audio comments, and lightweight podcast editors.

The general rule is simple: model each recording attempt as an owned transaction, keep asynchronous events scoped to that attempt, and commit a new blob only after it is complete.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?