DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Tenant-Aware Speech-to-Text Explained — MP3/WAV File Uploads Across US/EU in 2026

Short answer: for a small fintech product that turns reviewer voice notes into structured code findings, start with one synchronous speech-to-text file-upload adapter for MP3 and WAV, but write every upload to a tenant ledger before making the transcription request. That is usually the fastest integration because it keeps the first release small while preserving per-tenant cost visibility and a clean path to regional routing.

Choice Shipping effort Tenant attribution Best fit Main constraint
Direct file upload Lowest Clear with an internal ledger Short reviewer notes Bound by the selected API's request and duration limits
Object storage plus async worker Medium Clear with job records Long or bursty recordings More states to operate
Self-hosted transcription Highest Fully internal Strict control requirements or sustained workloads Model serving becomes your job

My recommendation is the first row for the initial release. Keep the adapter replaceable, measure billed units rather than guessing from file size, and promote work to a queue only after real upload patterns justify it. The point isn't to find a universally fastest model. It is to ship weekly without losing the tenant-level evidence needed to understand margin.

How should a simple speech-to-text API handle MP3 and WAV file uploads?

Treat the upload as a business event, not as an anonymous call to an AI endpoint. Before sending any audio, create an internal record with tenantId, changeId, uploadId, media type, byte count, selected processing region, and a start timestamp. After transcription, add the external request identifier when one exists, the terminal status, and the billable unit reported by the selected service. A byte count is useful for capacity planning; it is not a substitute for actual billing data.

That distinction matters in a multi-tenant SaaS. One tenant may submit many short WAV notes, while another submits compressed MP3 files with longer conversations. Charging, margin analysis, and abuse detection become unreliable if the only retained metric is request count. The ledger should belong to the application because an upstream dashboard cannot understand your tenant boundary or your code-change identifier.

Keep the public application contract smaller than any provider contract. The caller sends one supported audio file and the code-change identifier. The application returns an upload identifier, a transcript state, and eventually structured review findings. Provider-specific model names, response shapes, and request IDs stay behind the adapter. This is mundane work. Good. Undifferentiated infrastructure should remain boring so feature work gets the revenue-producing hours.

A practical intake policy can be strict: accept only the media types you have tested, reject empty files, cap size before buffering, and assign a region from tenant policy rather than client input. MP3 and WAV are container labels, so successful filename validation alone does not prove that the audio is decodable. Validate the content at the boundary, then record a stable application error such as AUDIO_FORMAT_REJECTED without leaking upstream response text.

The two criteria that decide the architecture

The first criterion is cost attribution completeness. Every path, including retries and rejected uploads, needs an uploadId and tenantId. A retry should create an attempt under the same upload instead of a second unconnected cost event. If the provider exposes a billed duration or another usage unit, persist its name and value without converting it into a made-up universal unit. I'm not sure any cross-provider estimate stays accurate as encoding, silence handling, and billing policies change; invoices reconciled against your own immutable attempt records are what would resolve that uncertainty.

The second criterion is regional boundary clarity. “US/EU support” is too vague for a design review. Write down where the original audio is accepted, where it is stored, where transcription runs, where the transcript is retained, and which region owns logs and backups. Then make region a server-side tenant setting. A client-supplied region=eu query parameter is not a residency control.

These criteria also expose a common false shortcut. The first instinct is to compare one happy-path request per API and call the shortest snippet the fastest integration. Later, the missing work appears as tenant reconciliation, retry deduplication, deletion handling, and region-specific operations. The better measure is time to a production-shaped slice: one upload, one ledger entry, one deterministic retry path, one regional decision, and one structured result.

Ship that slice first.

No hidden spend.

A minimal TypeScript boundary

This example uses a generic HTTP endpoint supplied through configuration. It accepts only MP3 and WAV, creates an application-owned usage attempt, sends a multipart upload, and returns a normalized transcript. The storage functions and adapter interface are intentionally local contracts; their implementations depend on the database and transcription service you select.

import { readFile, stat } from "node:fs/promises";
import { basename, extname } from "node:path";

type Region = "us" | "eu";
type MediaType = "audio/mpeg" | "audio/wav";

type UploadInput = {
  tenantId: string;
  changeId: string;
  filePath: string;
  region: Region;
};

type Transcript = {
  text: string;
  requestId?: string;
  billedUnit?: { name: string; value: number };
};

const mediaTypes: Record<string, MediaType> = {
  ".mp3": "audio/mpeg",
  ".wav": "audio/wav",
};

async function transcribe(input: UploadInput): Promise<Transcript> {
  const extension = extname(input.filePath).toLowerCase();
  const mediaType = mediaTypes[extension];
  if (!mediaType) throw new Error("AUDIO_FORMAT_REJECTED");

  const file = await stat(input.filePath);
  if (file.size === 0) throw new Error("AUDIO_EMPTY");

  const attempt = await usageLedger.start({
    tenantId: input.tenantId,
    changeId: input.changeId,
    region: input.region,
    mediaType,
    bytes: file.size,
  });

  const form = new FormData();
  const audio = await readFile(input.filePath);
  form.set("file", new Blob([audio], { type: mediaType }), basename(input.filePath));

  try {
    const response = await fetch(endpointFor(input.region), {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.SPEECH_API_KEY}` },
      body: form,
    });

    if (!response.ok) throw new Error("TRANSCRIPTION_REJECTED");
    const transcript = (await response.json()) as Transcript;
    await usageLedger.complete(attempt.id, transcript);
    return transcript;
  } catch (error) {
    await usageLedger.fail(attempt.id);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Do not put tenantId into an arbitrary upstream header and assume it will appear on an invoice. The internal ledger is authoritative. The adapter may capture an upstream request ID for reconciliation, while the application keeps tenant identity, retry lineage, and code-change identity under its own control.

The next stage, converting transcript text into structured code findings, should be a separate job with its own usage entry. Store a schema version beside the findings. This separates speech cost from analysis cost and lets a failed schema validation retry without uploading the audio again. It also makes the product question visible: did a tenant pay for transcription, code analysis, or both?

Failure handling without duplicate spend

Use a client-generated uploadId as the idempotency key at your boundary. Consider a deterministic test with tenant tenant_17, upload up_2048, and change chg_91: the application records attempt one, sends the audio, and loses the connection before it can classify the outcome. A retry arriving with up_2048 must load that same record, verify that it still belongs to tenant_17, and avoid starting attempt two until reconciliation has resolved attempt one. If the outcome is already complete, return it. If it is unknown, use the recorded external request ID when the chosen API supports reconciliation; otherwise send the item to explicit review rather than silently charging the tenant twice. For a 429 response that is safe to retry, apply bounded backoff under the same upload identifier and record each attempt; do not generate a fresh business operation for every network attempt. This example is synthetic by design, but it gives the retry test exact identities and an assertion that can be automated.

Retries count.

There are three state transitions worth testing: accepted to complete, accepted to rejected, and accepted to unknown. Test region selection independently. Also test that a second request with the same upload identifier cannot cross tenant boundaries, even when the code-change identifier matches. That authorization case is more important than shaving a few lines from the multipart example.

Observability should follow the same IDs. Logs and traces can contain uploadId, attemptId, tenantId, region, media type, bytes, elapsed time, and normalized outcome, but they should not contain raw audio or full transcript text by default. Alerts belong on stuck state age, rejection rate, and ledger-to-invoice drift. Pick thresholds from your traffic and service objectives; invented universal numbers won't help.

This is where a one-person operation either stays manageable or becomes support work. A single adapter, ledger table, and state machine can be inspected in minutes. Three special-case SDK flows cannot.

When is the runner-up better?

Direct upload is not suitable when recordings exceed the synchronous limits of the selected API, uploads need resumability, traffic arrives in large bursts, or the product must continue accepting work while transcription capacity is constrained. In those cases, stick with object storage plus an asynchronous worker. The catch is the larger operational surface: object lifecycle, queue retries, job leases, deletion propagation, and more states in the tenant ledger.

Self-hosting is the better boundary when organizational policy requires audio and inference to remain entirely inside infrastructure you operate, or when control over model execution outweighs the time spent serving it. It is a poor first choice for a solo SaaS whose differentiator is review workflow rather than speech recognition. Model deployment, capacity planning, and regional operations consume the same hours that could ship customer-facing review features. Your mileage may vary once transcription becomes a large, stable share of the workload.

Batch processing is another runner-up for work that does not need an immediate transcript. It can fit overnight backfills or reprocessing, but it changes the product contract from request/response to submitted/pending/complete. Keep that state machine explicit. Vector similarity search may become relevant later for finding related transcripts or code findings; it is downstream retrieval, not part of the upload decision.

The final choice is intentionally conditional. Start with direct multipart upload plus an internal tenant ledger when short files and quick delivery dominate. Move to object storage and workers when workload shape demands it. Choose self-hosting when the control boundary is the product requirement. In every case, keep transcription behind one application interface and measure each attempt where tenant identity is known.

References

Top comments (0)