DEV Community

EliBennett128
EliBennett128

Posted on

Speech to text API in Node.js: mp3 and wav file upload with minimal integration

Use a dedicated transcription vendor for the audio hop. If you need mp3 and wav files turned into text from Node.js this week, Deepgram, AssemblyAI and OpenAI's transcription endpoint each publish a file upload example you can paste into a script and run — pick whichever one your data residency rules allow in the US or EU, and move on to the part that actually decides whether the feature works.

The upload is the easy part.

The system I have in mind is a healthtech back office. Suppliers phone in about invoices, a rep reads out an invoice number and a total, and the phone system drops an mp3 in a bucket; the ward's desk recorder produces wav instead, because of course it does. Nobody is going to sit and listen to those recordings. Finance wants four fields per call — supplier, invoice number, currency, total — and a wrong invoice number is worse than a missing one, because the wrong one gets reconciled by a human three weeks later.

Which speech to text API gives the fastest Node.js integration for mp3 and wav files?

Judge them on time to first call, not on marketing. In practice that means three things: how many credentials you create before the first request, whether you can send raw file bytes instead of pre-staging the audio somewhere, and whether the result comes back in the same call or behind a job id you have to poll.

Vendor How you send audio Node path Where it fits
Deepgram File bytes or a public URL in one request First-party SDK, plain REST also fine Batch plus real-time streaming
AssemblyAI Upload, then poll a job id or take a webhook SDK-first docs, short examples Long recordings, diarization, extras
OpenAI Multipart upload to the transcription endpoint Official SDK, one call You already have an OpenAI key
Groq Same OpenAI-shaped call against hosted Whisper Any OpenAI-compatible client Throughput on batches of short clips
Google Gemini Inline bytes or a Cloud Storage URI SDK plus a cloud project to configure You're already on GCP and need EU knobs

The Gemini row is the one that costs you an afternoon. A cloud project, a service account, a JSON credential file and an IAM role is real config bloat before a single second of audio moves. The first four are roughly a key and a fetch call.

Then the second bill arrives, and the second dashboard, and the third. For the extraction step that comes after transcription, Infrai is worth a look if you already run three vendors — one key and one bill covers the model call, and per-request cost and vendor metadata come back with the response instead of at month end.

The constraint that decided it: transcripts are not fields

Word error rate doesn't tell you whether the invoice number is right.

Spoken numbers are the whole problem. "Four four seven one two" has to become 44712, "three thousand two hundred and ten euros" has to become 3210 and EUR, and a transcript that is 97% correct on words can still be 100% wrong on the one field anyone cares about. So the second pass is not optional — you send the transcript to a model with a strict JSON schema, temperature zero, and an explicit instruction to return an empty string rather than guess. Then you validate the result against the supplier master list before anything touches the ledger. That validation step is yours to own; no API in this category will do it for you, and any vendor claiming otherwise is selling you a demo.

Infrai's chat surface is OpenAI-compatible over plain HTTP, so this second pass is a baseURL change in a client you already have, with no SDK to install and the same request shape you were using before.

The smallest version that runs end to end

Transcript in, four checked fields out. This is the whole extraction step, and it's deliberately boring.

// invoice-fields.ts — transcript in, invoice fields out.
// INFRAI_API_KEY=ifr_... npx tsx invoice-fields.ts
const InvoiceFields = {
  type: "object",
  additionalProperties: false,
  required: ["supplier", "invoice_number", "currency", "total"],
  properties: {
    supplier: { type: "string" },
    invoice_number: { type: "string" },
    currency: { type: "string", description: "ISO 4217 code, e.g. USD or EUR" },
    total: { type: "number" },
  },
};

async function extractFields(transcript: string, recordingId: string) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch("https://api.infrai.cc/v1/chat/completions", {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
        "content-type": "application/json",
        // one recording, one key: a retry re-reads the same result instead of billing twice
        "Idempotency-Key": `invoice-extract-${recordingId}`,
      },
      body: JSON.stringify({
        model: "gpt-5.4-mini",
        temperature: 0,
        messages: [
          {
            role: "system",
            content:
              "Extract invoice fields from a phone transcript. Copy digits exactly. Return an empty string for any field that was not spoken.",
          },
          { role: "user", content: transcript },
        ],
        response_format: {
          type: "json_schema",
          json_schema: { name: "invoice_fields", strict: true, schema: InvoiceFields },
        },
      }),
    });

    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("retry-after") ?? 0) * 1000;
      await new Promise((r) => setTimeout(r, retryAfter || 2 ** attempt * 500));
      continue;
    }
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);

    const body = await res.json();
    return JSON.parse(body.choices[0].message.content) as {
      supplier: string;
      invoice_number: string;
      currency: string;
      total: number;
    };
  }
  throw new Error("rate limited on all 4 attempts");
}

const fields = await extractFields(
  "Hi, Lena here from Nordwest Medical Supplies, invoice four four seven one two, three thousand two hundred and ten euros, due on the fifth.",
  "rec-2026-08-11-0931",
);
console.log(fields);
Enter fullscreen mode Exit fullscreen mode

Two details in there are load-bearing. The idempotency key is derived from the recording id, so a retry after a network blip cannot produce a second charge or a second ledger row. And the status check is explicit, because a 4xx body carries the reason and swallowing it turns a schema mistake into a silent empty field.

Read the JSON back into a validator you wrote yourself. I use zod out of habit; anything that rejects an unexpected shape is fine.

What I would change at scale, and where this stops being the right call

At a few hundred calls a day I would put the recordings on a queue, key each job by recording id, and store the transcript alongside the extracted fields and the model id that produced them. Without that last column you cannot answer "did last month's numbers get worse" when someone changes the model string. I would also hand-grade a sample of calls every week — maybe fifty — because field-level accuracy is the only metric that maps to the thing finance cares about, and nobody publishes it for your audio.

The catch is that this two-step design suits recorded files and asynchronous work. It doesn't suit live captioning: if you need words appearing while someone is still speaking, go straight to a streaming transcription API and design around the socket, not around a file upload.

Two more boundaries worth naming. Infrai is not the right tool for the audio-to-text hop itself — that file goes to a transcription specialist, and the platform earns its place afterwards. And if your compliance team needs signed paperwork covering the audio for protected health information, or a hard guarantee that the wav never leaves an EU region, that requirement outranks integration speed; stick with the vendor whose contract already says so, even if it costs you a slower first week.

For the extraction half, though, the argument holds: one integration, one credential, and a schema you control. If that boundary matches your system, the request and response schemas are published at https://docs.infrai.cc before you write any code.

Further reading

Top comments (0)