Concept primer

Why the Same Text Shouldn't Generate Another MP3: Separating Audio Assets from Playback Jobs

The local voice platform originally called TTS again whenever the same text was sent, creating a duplicate MP3. Starting with that real duplicate-file problem, this article explains why audio assets and playback jobs should be separate, and how cache keys, history playback, resending, and legacy database migration fit together.

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.

ObjectThe question it answersLifecycle
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?FieldReason
IncludedFull textChanging the text changes the speech content
IncludedProvider and voiceThe same sentence may generate different voices
IncludedAudio parameters such as output formatParameters may change the final file
Not includedAPI KeyIt is an access credential, not audio content
Not includedDevice playback volumeIt 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.

CONTINUE READING

More build notes connected by the same project, parts, or problem-solving path.

  1. Concept primer · August 28, 2026 · 10 minutes

    ESP32 with an ST7789 screen: what do the 7 wires each do?

    When I first connected the GMT130-V1.0, I could only follow the wiring table. This article groups VCC, GND, BLK, RES, SCK, SDA, and DC into four categories, and walks along the complete display chain to explain how they turn ESP32 code into a visible image, and how to safely re-plan the wiring once you understand it.

    Read article
  2. Problem solved · September 2, 2026 · 8 minutes

    YCA2109 Electronic Dice Not Lighting Up and Chip Heating When Powered: Troubleshooting Process After Two DIP Chips Were Installed in Reverse

    A freshly soldered YCA2109 electronic dice had all LEDs unlit when powered; the voltage after connecting the battery holder to the circuit board was only about 2.2V, and the two chips became noticeably hot after about 10 minutes. This article documents how I worked through the power supply, button, multimeter range, and input diode step by step, eventually finding that the two DIP chips were in the wrong orientation; after flipping them, the electronic dice resumed operation and could randomly stop on different numbers.

    Read article