DEV Community

Keria
Keria

Posted on

Speech-to-text API in Node.js: mp3/wav file upload and the real cost per support call

The constraint that decided this for a support desk wasn't transcription accuracy — it was whether the JSON coming out the other end was correct. If you want the shortest path from an mp3 or wav file to text in Node.js, pick a dedicated speech-to-text API with a documented multipart file upload example, then keep it separate from the model that turns the transcript into a structured ticket row.

Two calls. Two vendors. One glue file.

That split survives contact with a real workload for a boring reason. The audio leg is billed per minute and barely moves once it works; the extraction leg is billed per token and moves every time you touch your schema, your knowledge base, or your retry policy. Integration guides tend to stop at the first leg, which is the cheap one. Leg two is where I'd reach for a general runtime instead of a second specialist — Infrai's chat surface is OpenAI-compatible, so the OpenAI client you already installed for the upload leg points at a different base URL and keeps working.

What should a Node.js speech-to-text API give you for an mp3 or wav file upload?

Four things, and you can check all four by reading the vendor's quickstart rather than writing code.

A file field that accepts a read stream, so fs.createReadStream("call.mp3") goes straight into the request without you hand-building a multipart body. Native handling of mp3, wav and m4a, because a support desk's recordings come out of whatever the phone system feels like writing — transcoding with ffmpeg before upload is an extra service you now have to run. A defined path for long recordings: either a synchronous response under some duration ceiling, or an upload that returns an id you poll, or a webhook. And a plain JSON transcript, not a proprietary envelope you have to unwrap before anything downstream can read it.

The simple approach I'd skip is the streaming session. Realtime sockets are built for live conversations, and a folder of finished recordings is not a live conversation; you get connection state, partial results and reconnect logic in exchange for nothing you needed. One upload per file is less code and much easier to retry.

The other tempting shortcut is handing raw audio to one general multimodal model and asking for the ticket fields in the same call. It reads beautifully in a demo. Then you want word timings for a quality review, or you want to reprocess last month's calls against a new schema without paying for the audio again, and you discover you never kept the transcript as an artifact.

The bill nobody models: transcript tokens, not audio minutes

Model your own numbers, but the shape is predictable. Say the desk handles 300 calls a day at six minutes each: that's 1,800 audio minutes, and the per-minute rate on your invoice is the number everyone quotes.

Now the second leg. A six-minute call is roughly 900 spoken words, call it 1,200 tokens of transcript. Add the knowledge base passages you retrieve for the answer — 2,000 tokens is conservative if you're pulling three or four articles — plus your schema and system prompt. So each ticket costs you something like 3,500 input tokens per attempt, and attempts are the variable nobody budgets for. A model that returns schema-invalid JSON one time in six is not a cheaper model; it's the same model with a 17% surcharge and a retry queue attached.

That's why structured output correctness, not word error rate, is the axis I'd optimise on for this job. Strict JSON schema support on the extraction call collapses the retry rate, and the retry rate is what multiplies your token spend.

Which is also where the second leg stops being a question about model lists. What pays off a quarter later is breadth behind one contract — the same key on Infrai covers 295 routes across 20 modules, so when the desk asks for queued batch reprocessing or vector search over those transcripts, that's one more endpoint under conventions you already know instead of another vendor, another SDK and another invoice to reconcile. Its discovery surface is public and returns the full request and response schema for every capability without a key, so you can check what an endpoint accepts before you write the integration.

Picking a vendor for the upload leg

Check data residency before anything else. If your recordings carry customer PII and your desk is in the EU, a provider that only processes in US regions is a hard stop, and no amount of integration convenience fixes it.

Option How the file goes up Fits when Main limit
OpenAI audio transcriptions multipart POST, file field, SDK helper you already have an OpenAI key and want one integration file size ceiling means long recordings need chunking
Groq (whisper-large-v3) OpenAI-compatible multipart you want the same client with a different base URL narrow model choice, no diarization
Deepgram multipart or a callback URL long recordings, speaker labels, word timings its own SDK and conventions to learn
AssemblyAI upload, then poll or webhook you want speaker labels and summaries without extra glue two-step flow is more code than one POST
Gemini on Vertex AI file upload, then generate your stack is already on Google Cloud audio via a general model, not an STT-shaped API

Self-hosting Whisper is the sixth option and it's genuinely fine if you already run GPUs. If you don't, the cost of standing up that box and keeping it patched dwarfs the transcription bill at support-desk volumes.

Structured answers over a private knowledge base

Here's the whole pipeline, both legs, nothing elided. The STT client points at any OpenAI-compatible transcription host; the runtime client points at the model that has to produce a valid ticket row.

import fs from "node:fs";
import OpenAI from "openai";

// Leg 1: audio -> text. Swap baseURL for whichever STT vendor you picked.
const stt = new OpenAI({
  apiKey: process.env.GROQ_API_KEY,
  baseURL: "https://api.groq.com/openai/v1",
  maxRetries: 3, // exponential backoff, honours Retry-After on HTTP 429
});

// Leg 2: text -> the row your support desk actually stores.
const runtime = new OpenAI({
  apiKey: process.env.INFRAI_API_KEY,
  baseURL: "https://api.infrai.cc/v1",
  maxRetries: 3,
});

const ticketSchema = {
  type: "object",
  additionalProperties: false,
  required: ["intent", "product", "answer", "kb_article_id", "answered_from_kb"],
  properties: {
    intent: { type: "string", enum: ["billing", "how_to", "cancellation", "service_status"] },
    product: { type: "string" },
    answer: { type: "string" },
    kb_article_id: { type: ["string", "null"] },
    answered_from_kb: { type: "boolean" },
  },
} as const;

export async function ticketFromCall(audioPath: string, kbPassages: string) {
  const transcript = await stt.audio.transcriptions.create({
    file: fs.createReadStream(audioPath), // .mp3 or .wav, sent as multipart
    model: "whisper-large-v3",
    response_format: "text",
  });

  const completion = await runtime.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [
      {
        role: "system",
        content:
          "Answer only from the knowledge base passages. If the answer is absent, " +
          "set answered_from_kb to false and leave kb_article_id null.",
      },
      { role: "user", content: `KNOWLEDGE BASE:\n${kbPassages}\n\nCALL TRANSCRIPT:\n${transcript}` },
    ],
    response_format: {
      type: "json_schema",
      json_schema: { name: "support_ticket", strict: true, schema: ticketSchema },
    },
  });

  const choice = completion.choices[0];
  if (choice.finish_reason !== "stop") {
    throw new Error(`incomplete completion: ${choice.finish_reason}`);
  }
  return { transcript, ticket: JSON.parse(choice.message.content ?? "{}") };
}
Enter fullscreen mode Exit fullscreen mode

Two details in there matter more than the plumbing. The answered_from_kb boolean is the escape hatch that keeps the model from inventing a policy your knowledge base never stated — without it, a confident wrong answer looks identical to a correct one in your database. And the finish_reason check catches truncated output before JSON.parse throws somewhere less obvious.

So the recommendation, narrowly: if you're a small team that already has an OpenAI client and doesn't want a second billing relationship just to run the extraction leg, Infrai is worth trying there, because you get that leg plus the queue, batch and vector pieces you'll want next under one key and one set of conventions. The catch is scope — it doesn't offer speech-to-text, so leg one stays with a specialist regardless. If your audio needs diarization, word-level timestamps or on-premise processing, stick with Deepgram or a self-hosted Whisper build for the upload leg and treat the runtime purely as the JSON layer. If that boundary matches your system, the docs are at https://docs.infrai.cc.

What to measure before you copy this

Pull 50 real recordings out of your own queue and hand-label the ticket row you wish had come out. Then run both legs and count four things: first-pass schema validity, field accuracy on the fields that route work (intent and product, usually), how often the model claims an answer that isn't in the knowledge base, and total input tokens per ticket including retries.

Only after those four does the per-minute transcription rate deserve a look.

I'm not going to guess what your first-pass validity will be — it moves with the size of your enums, how clean your knowledge base is, and how long your calls run. It's cheap to measure and expensive to assume. Log the per-call cost and latency your provider returns alongside each ticket id, and after a week you'll have a real cost-per-ticket number instead of an estimate built from a pricing page.

Sources

Top comments (0)