01Problem
Text-to-speech engines read text aloud; they do not translate it. That distinction is invisible in English-only tools, but it breaks the moment you are multilingual: choosing a Bengali voice for an English sentence produces English pronounced by a Bengali speaker, which is not what anyone selecting "Bengali" expects.
The second problem is access. Every production-grade speech API — Google Cloud, Azure, AWS Polly — requires a billing account with a payment card before the first character is synthesised, even inside the free tier. For a student building a portfolio project, that is a hard stop rather than an inconvenience.
So the goal was a genuinely multilingual TTS app that speaks the language you pick, costs nothing to run, and requires no payment details to deploy.
02Approach
I built it as a two-stage pipeline — translate, then synthesise — with each stage behind an interface so the underlying provider can be swapped without touching the rest of the app.
For speech I used the neural voices behind Microsoft Edge's Read Aloud feature, which need no key and no billing and cover every Indian language the project targets. The provider layer still supports Google Cloud TTS and ElevenLabs through one environment variable, so the free option is a default rather than a lock-in.
For translation I initially called the endpoint from the server, which worked locally and failed in production — covered in the technical decisions below.
Data lives in PostgreSQL with voices and languages as proper foreign-key relations rather than free-text columns, generated audio goes to S3-compatible object storage, and authentication is JWT with bcrypt. Deployment is a split: static frontend on one host, API on another, with the database and object storage as managed services.
03Architecture
04Decisions & trade-offs
Decision 1
Translating on the server worked perfectly in development and failed in production. The translation endpoint rate-limits by IP, and a deployed server has one shared address for all its users, so it returned HTTP 429 and then a hard 403 block — while speech synthesis from the same host kept working. Adding retries and more endpoints only delayed the failure, because the address itself was the problem. Since those endpoints send permissive CORS headers, I moved translation into the browser, where every visitor supplies their own IP. Load distributes naturally, no single address gets hot, and the server keeps a fallback path for when browser-side translation is unavailable. This was only discoverable by testing against the deployed app, not locally.
Decision 2
Google Cloud TTS demands a billing account even within the free tier, which blocked setup entirely. Edge exposes the same class of neural voices used by its Read Aloud feature with no key, no account and no billing, and its language coverage turned out to be better for this project — two voices each for Hindi, Bengali, Tamil, Telugu, Gujarati and Marathi. The trade-off is honest: it is an undocumented endpoint with no uptime guarantee, so I kept ElevenLabs, Google and an offline mock selectable through a single environment variable, with an idempotent script to reseed the voice catalogue for whichever provider is active.
Decision 3
Generated audio was originally written to local disk, which works locally and quietly breaks in production: free hosting tiers use ephemeral filesystems that are wiped on every restart, redeploy and idle-sleep, so History playback would 404 on anything older than the current instance. I put persistence behind a storage interface with local disk as the default and S3-compatible object storage when configured, and moved production audio to Cloudflare R2 for its zero egress fees. The health endpoint now reports whether storage is ephemeral, so the failure mode is visible before users find it rather than after.
Decision 4
While evaluating fallback translation providers, one returned HTTP 200 with an empty translation body. Had I trusted the status code, that would have shipped as silently synthesised silence — a bug that looks like success and would have been miserable to trace. Every provider parser now validates that the response actually contains non-empty text before accepting it, and falls through to the next endpoint if not.
05Outcomes
Deployed end to end with a static frontend, a Node API, managed PostgreSQL and object storage — running at zero recurring cost and requiring no payment card to reproduce.
Verified by generating audio, stopping the server and deleting the local audio directory: playback still returned HTTP 200 from object storage.
06What I'd do differently
Deploy a skeleton to production on day one. Both of the hardest bugs — IP rate-limiting on server-side translation and audio vanishing from an ephemeral filesystem — were invisible locally and only appeared once the app was live.
Put every external dependency behind an interface before the first call, not after the first failure. Storage and speech providers ended up swappable, but only after each one broke; translation should have started that way too.
Treat a successful status code as a claim, not proof. Checking that each provider actually returned non-empty text caught a silent-failure mode that would have shipped as synthesised silence.


