DEV Community

UriahHawkins5489
UriahHawkins5489

Posted on

Transcribe Audio Files with a REST API Multipart Upload — Fetch and Provider Portability

Short answer: use fetch with FormData for the audio file, let the browser create the multipart boundary, and hide provider-specific behavior behind a server-side REST API adapter.

A portable transcription integration should make the upload protocol boring and the provider decision reversible. For an edtech code-review service, that means accepting one audio file at an application boundary, preserving a small job record, and translating provider-specific responses behind an adapter. The browser should never need to know which speech engine is active.

The useful answer is a fetch request with FormData, a server-owned endpoint, and a contract that treats transcription as an asynchronous job when files or queues make that necessary. Do not set the multipart Content-Type header yourself. The browser adds the boundary; a hand-written header commonly leaves the server unable to parse the body.

What should the upload boundary promise?

A student may attach a short spoken explanation of a code change. The review system needs text and structured findings, but those are two separate operations. First, authenticate the upload and record its metadata. Next, transcribe it. Finally, send normalized text to the review pipeline. Keeping those steps separate lets a team change speech providers without changing the code-review prompt, retry policy, or student-facing API.

The contract below is intentionally narrow. It accepts a Blob, returns a job identifier, and exposes normalized status later. An implementation can route the job to AWS Transcribe, Google Cloud Speech-to-Text, or Azure AI Speech, but none of those names belongs in the browser contract. Their SDKs and response shapes are different boundaries to test, not application state to leak.

Keep this small.

type TranscriptionJob = {
  id: string;
  status: "queued" | "running" | "completed" | "failed";
  text?: string;
  error?: { code: string; message: string };
};

export async function uploadAudio(file: Blob, filename = "answer.webm"): Promise<TranscriptionJob> {
  const form = new FormData();
  form.append("audio", file, filename);

  const response = await fetch("/api/transcriptions", {
    method: "POST",
    body: form,
    credentials: "include",
    signal: AbortSignal.timeout(30_000),
  });

  if (!response.ok) {
    throw new Error(`Upload failed with status ${response.status}`);
  }

  return response.json() as Promise<TranscriptionJob>;
}
Enter fullscreen mode Exit fullscreen mode

That function is runnable in a modern browser or a compatible server runtime. FormData handles the multipart framing, while the server decides whether the bytes are forwarded immediately or stored before a worker processes them. The timeout is an upload timeout, not a promise that the transcription itself finishes in 30 seconds. That distinction matters once a classroom assignment contains a long recording.

Why does multipart work fail before speech recognition starts?

The annoying failures happen at the seam. A filename can be missing or misleading. A browser can produce audio/webm while a downstream decoder only accepts another container. A reverse proxy can reject a body before application code sees it. A retry can upload the same recording twice. None of these is a speech-recognition problem.

Validate the declared media type, byte length, and filename on the server, then inspect the file signature when the risk justifies it. Keep an allowlist for formats your selected transcription adapters actually support. Store an opaque object key rather than trusting a client path. The server should also attach a request id and an idempotency key to the job record, so a network timeout does not force the client to guess whether it created work.

The boundary deserves its own test fixture. Send a real multipart body with a filename, a deliberately wrong declared type, and a byte count just over the configured limit. Confirm that the request is rejected before any provider call, that the job table has no phantom transcription, and that the response contains a stable application error rather than a parser stack trace. Then repeat the same accepted upload with the same idempotency key after simulating a client timeout. The second request should resolve to the original job. This is more useful than a happy-path assertion because it checks the exact place where browser framing, proxy limits, storage state, and provider work meet.

There is a tempting shortcut: upload directly from the browser to a speech provider and use the returned text. It reduces one hop, but it also moves credentials, provider coupling, retention decisions, and abuse controls into the least trusted part of the system. For an education product, that is a poor place to make a portability trade.

This design has limits. A synchronous endpoint is fine for a short classroom clip, but it becomes a poor fit for long recordings, unreliable mobile networks, or strict regional retention rules. In those cases, store the object, enqueue a job, and expose status; the extra state is the trade for resumability and policy control.

How can you transcribe an audio file through a REST API multipart upload?

Use an internal interface that expresses the work your application needs, not every feature exposed by an SDK.

type AudioInput = {
  bytes: Uint8Array;
  contentType: string;
  filename: string;
};

type Transcript = {
  text: string;
  language?: string;
  segments?: Array<{ startMs: number; endMs: number; text: string }>;
};

interface SpeechAdapter {
  transcribe(input: AudioInput, signal: AbortSignal): Promise<Transcript>;
}

export async function reviewSpokenPatch(
  adapter: SpeechAdapter,
  audio: AudioInput,
  signal: AbortSignal,
): Promise<{ transcript: Transcript; findings: string[] }> {
  const transcript = await adapter.transcribe(audio, signal);
  const findings = await reviewCodeExplanation(transcript.text, signal);
  return { transcript, findings };
}

async function reviewCodeExplanation(text: string, signal: AbortSignal): Promise<string[]> {
  const response = await fetch("/internal/code-review", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text }),
    signal,
  });
  if (!response.ok) throw new Error("Code review request failed");
  return response.json() as Promise<string[]>;
}
Enter fullscreen mode Exit fullscreen mode

The adapter is where authentication, provider-specific multipart fields, polling, and response mapping belong. The review workflow only sees Transcript. That gives you a clean comparison surface: recognition quality on classroom recordings, maximum input duration, supported media containers, regional processing requirements, failure semantics, and the amount of useful timing metadata. Price is one input to that decision. It should not be the architecture.

A practical test matrix includes three real services and one local or self-hosted candidate, but it evaluates behavior rather than brand familiarity. Run the same five-minute recordings through AWS Transcribe, Google Cloud Speech-to-Text, and Azure AI Speech, then compare the normalized output and operational boundaries. Record which service accepts each file type, how an unavailable region is reported, how retries behave, and whether word or segment timestamps survive your adapter. The fourth candidate matters because portability is easier to claim when there is an exit path that does not depend on another hosted API.

I would keep raw audio, transcript text, and review findings under separate retention rules. A transcript may be needed for a student appeal; the original audio may not. That policy is easier to enforce when the upload job has explicit states instead of one oversized request that does everything synchronously.

One more trade-off is easy to miss: timestamps and diarization can be useful to a reviewer, but carrying every provider-specific field into the common interface makes replacement harder. Start with text and optional segments. Add a field only when the product has a test and a user-visible reason to keep it.

What should be tested before shipping?

Start with the boring cases: an empty file, an unsupported container, a file at the byte limit, a filename containing path separators, a client disconnect during upload, and a duplicate idempotency key. Then test an adapter timeout, a provider rate limit, malformed provider JSON, and a worker restart after the audio has been stored. The expected result is a stable application error and a retryable job state, never a provider-specific error string rendered to a student.

For observability, log request id, job id, byte count, media type, queue latency, processing duration, adapter name, and normalized failure code. Do not log the audio bytes or put a transcript in ordinary request logs. Metrics should separate upload failures from transcription failures; otherwise a rising proxy limit can look like declining speech recognition.

The final checklist is short enough to keep beside the implementation: let FormData create the boundary, enforce file policy on the server, make creation idempotent, persist state before long work, normalize adapter output, and test at least three hosted interfaces against the same recordings. Keep the code-review endpoint unaware of the speech vendor. That is the portability boundary worth paying for.

Sources

Top comments (0)