I read that Lyria 3.5 had moved into public preview on September 3rd while I was in the middle of reshuffling candidate tracks for the next update of my healing-sound app. Full-length music generation, finer control over length and structure, 44.1kHz stereo output — I stopped reading there.
What stopped me was closer to unease than excitement. Looking at the folder in front of me, the tracks I had chosen by hand and the takes I had generated for comparison were sitting at the same level. I had prefixed the generated ones with draft_, but that prefix was the whole of my boundary.
Let me say up front that this is not a piece about how well the model performs. The better generation gets, the more the decision about what belongs in the shipped product has to be protected by a mechanism rather than by mood. I ended up putting that mechanism into a forty-odd-line script.
A filename prefix disappears on the first copy
For a while I assumed the prefix was enough. Generated takes got draft_, hand-picked tracks kept their names, and I could tell them apart by eye.
That did not hold up. Run the files through an export tool, move them to another machine, renumber a batch — the prefix comes off each time, and the only moment I could notice was the next time I happened to open that file.
Sure enough, a file called calm_13_new.wav was still sitting in the candidate folder. Looking back, it was something I had set aside meaning to decide later, and then forgot. From the name alone I could not tell whether I had chosen it myself or dropped it in as a generated comparison.
Filenames, locations, and modification times all get rewritten by a single human action. Unless the primary key is something that does not get rewritten, the boundary will not hold.
What Lyria 3.5 changed (public preview, September 3, 2026)
Here are the facts first.
| Item | Detail |
|---|---|
| Availability | Public preview (September 3, 2026) |
| Length | Full-length music generation |
| Improvements | Musical coherence, natural vocals, finer control over length and structure |
| Input | Text and images |
| Output | High-fidelity 44.1kHz stereo audio |
The line that mattered to me was not about fidelity. It was that a whole piece now arrives in one go. A 30-second clip announces itself as a sketch the moment you hear it. Several minutes of finished-sounding music sits in the folder looking exactly like a candidate.
You cannot keep sorting things by eye once they stop looking different. That is the point where I decided to change how the folders were laid out.
Only the hash of the contents survives a copy
So I moved the primary key of my ledger from the filename to the SHA-256 of the contents. Rename the file, move the folder, touch the timestamp — as long as the bytes are the same, the hash is the same. And if I re-export with different settings, the hash changes too, which means "I rebuilt it and forgot to register it" gets caught by the same mechanism.
The ledger is one line per track, tab-separated, with three columns.
| Column | Value | Meaning |
|---|---|---|
| 1 | SHA-256 | Fingerprint of the contents. This is the key |
| 2 | origin | hand (chosen by me) / generated / reference |
| 3 | note | A human-readable memo, usually the original filename |
And only hand is allowed in the ship folder.
Tracks I chose by hand go out; generated takes stay on the scouting shelf. Writing that single rule into a constant in the script, rather than keeping it in my head, is what made the hesitation go away.
The forty-odd-line ledger gate
Here is the script as it stands. Standard library only.
#!/usr/bin/env python3
"""Gate that lets only approved audio into the ship folder."""
import hashlib
import pathlib
import sys
AUDIO_SUFFIXES = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".caf"}
SHIPPABLE = {"hand"}
def digest(path: pathlib.Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def load_ledger(path: pathlib.Path) -> dict:
ledger = {}
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) < 3:
sys.exit(f"ledger line {lineno} has too few columns: {raw!r}")
ledger[parts[0]] = (parts[1], parts[2])
return ledger
def main(ship_dir: str, ledger_file: str) -> int:
ledger = load_ledger(pathlib.Path(ledger_file))
unregistered, blocked, passed = [], [], 0
for path in sorted(pathlib.Path(ship_dir).rglob("*")):
if not path.is_file() or path.suffix.lower() not in AUDIO_SUFFIXES:
continue
entry = ledger.get(digest(path))
if entry is None:
unregistered.append(path)
elif entry[0] not in SHIPPABLE:
blocked.append((path, entry[0]))
else:
passed += 1
for path in unregistered:
print(f"unregistered: {path}")
for path, origin in blocked:
print(f"not shippable ({origin}): {path}")
print(f"passed {passed} / unregistered {len(unregistered)} / blocked {len(blocked)}")
return 1 if unregistered or blocked else 0
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: audit.py <ship_dir> <ledger.tsv>")
sys.exit(main(sys.argv[1], sys.argv[2]))Three notes on why it is written this way.
First, SHIPPABLE is a set. Right now it holds only hand, but a day will come when I add purchased material as licensed. When that happens I would rather add one word to a set than rewrite a condition. Keeping the criterion in one place is a kindness to whoever reads this next, which is usually me.
Second, unregistered and blocked are counted separately. Both stop the build, but they call for different actions. Unregistered means a step was skipped — check it, register it, move on. Blocked means a judgment went wrong, and nothing should proceed until that file leaves the ship folder. Collapsing them into a single count throws that distinction away.
Third, the file is read in 1 MiB chunks. Full-length pieces get large, and I would rather not load an entire library into memory to fingerprint it.
The numbers I got on my own machine
I first ran it against a layout close to the candidate folder I described at the start: 19 audio files, 17 of them registered.
unregistered: ship/bgm/calm_13_new.wav
not shippable (generated): ship/bgm/draft_lyria_take3.wav
passed 17 / unregistered 1 / blocked 1
Exit code 1. The two files I could no longer tell apart by name were stopped for two different reasons. Those two lines were exactly what I wanted.
I timed it as well. Against 40 tracks of 30-second 44.1kHz stereo audio, 203 MB in total, it took 0.50 seconds on all three runs. Hashing reads every byte, so the time scales with total size rather than file count. Even for a library of a few gigabytes, running it once right before an export is not a wait worth optimizing away.
I put it immediately before the shipping export starts. Noticing afterwards means rebuilding the artifact.
Inspecting the audio itself is a separate stage. For things that can be settled numerically without listening — loop seams, for instance — I wrote that up in Screen Loop Seam Clicks With Numbers Before You Hand the Audio to Gemini. Keeping the "where did this come from" check apart from the "is this good" check keeps both of them short.
Not shutting generation out, but giving it a destination
One thing I would rather not be misread on: I have not decided against using generated audio. I have only separated the inside of the shipped product from the outside.
Generated takes now live in three places. One is screen prototypes, where they stand in as placeholder audio while I settle length and structure. Another is as a rough draft when I am assembling something myself, to see one possible shape of the arrangement. The third is as a comparison point when I want to check whether an existing track has gone flat. None of those reach a listener's device.
The models on the Gemini side have shifted generations several times in the past few weeks alone. On how a bill can grow even when the per-token price stays put, I wrote Gemini 3.8 Flash Costs the Same Per Token and Can Still Raise Your Bill. Fixing the boundary in place is what keeps me from redoing the judgment every time a model changes underneath it.
If you try one thing today, scanning your ship folder once and re-registering it by hash instead of by filename is enough. That is where I started too.