The local voice platform initially did one thing: take text input, call TTS to generate an MP3, and let the ESP32 play it. But I soon ran into a product-level waste: every time I clicked “Generate and Send” for the same sentence, the backend called TTS again and saved a new MP3.
After checking the duplicate files, their SHA-256 hashes were exactly the same. This showed that I had not gotten two different speech clips; instead, I had paid the generation time and call cost twice for the same content.
The problem was not just “no caching”; it was that two objects had been conflated into one
The original speech_jobs table served two responsibilities at once: it stored the generated audio and recorded a device playback. asset_id also had a unique constraint, so one piece of audio could not naturally correspond to multiple playback jobs.
| Object | The question it answers | Lifecycle |
|---|---|---|
| Audio asset | “Which MP3 was generated from this text and configuration?” | Can be stored long-term, previewed, and reused |
| Playback job | “Which device played which asset, and when?” | Created anew on every send, and records statuses such as queued, playing, and completed |
A sentence can generate just one audio asset, but if it is sent 10 times, there should still be 10 playback jobs. Conversely, if the same sentence uses a different voice or output format, it should not incorrectly hit the old asset.
Final data model
After adjustment, the platform splits the data into three layers:
-
audio_assets: stores text, provider, voice, file path, size, cache key, and SHA-256. -
speech_jobs: each send creates an independent job that references an existing audio asset. -
playback_events: records events such as device download, playback start, and completion.
This split makes “generate once” and “play many times” two things that can be independently tracked and retried. As a result, the page can display historical speech, preview it directly in the browser, and send the same asset to the ESP32 again.
What should be included in the cache key
The platform combines the normalized full text, TTS provider, voice, and output parameters that would change the audio file to generate a stable cache key. The Python standard library’s hashlib documentation explains the standard interface for SHA-256 digests. In this project, the digest generates stable identifiers and compares file contents; it is not used for password storage.
| Included in cache key? | Field | Reason |
|---|---|---|
| Included | Full text | Changing the text changes the speech content |
| Included | Provider and voice | The same sentence may generate different voices |
| Included | Audio parameters such as output format | Parameters may change the final file |
| Not included | API Key | It is an access credential, not audio content |
| Not included | Device playback volume | It only changes playback and should not generate a new MP3 |
Here, “same text” does not mean merely comparing a single input string. What should really be reused is the output of “same content plus same generation configuration.”
Even after a cache hit, a task still needs to be created
When the same text is submitted a second time, the backend skips TTS and directly references the existing asset_id; however, it still creates a new speech_job. Clicking “Send again” in the history list works the same way: reuse the asset, not the job.
This preserves two types of information:
-
Cost and speed: the same content is no longer generated repeatedly.
-
Device logs: each actual send and playback can still be tracked independently.
Old data was not simply deleted
Before the migration, I backed up the real SQLite database. Afterward, the original 9 jobs, 27 events, and 9 assets were retained, and the unique constraint on speech_jobs.asset_id was removed. The page collapses duplicate historical files by their MP3 SHA-256, while preserving all old jobs and events.
Verification included 4 cache regression tests, the complete 16-test Python suite, C++ device protocol tests, and checks that the local page returned HTTP 200 and displayed the history list. I also tested the history list, browser playback, “Send again,” and repeated submission of the same text, confirming that the existing audio was reused.
What this design changes in practice
For me, the two biggest benefits are lower TTS call costs and faster responses for repeated content. This design also lays the groundwork for letting other users access only pre-generated audio packs: assets can be managed and reused, while playback jobs continue to preserve each device action.