Skip to content

Commit 5c52232

Browse files
vanceingallsclaude
andcommitted
fix(studio-server): key the waveform cache on the file, not just its path
Two takes written to the same path returned the first one's waveform, so a re-recorded track drew the shape of the audio it replaced. The key now carries size and mtime, which is enough to notice the bytes changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3d81756 commit 5c52232

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildWaveformCacheKey } from "./waveform.js";
3+
4+
describe("buildWaveformCacheKey", () => {
5+
it("is stable for the same file", () => {
6+
const a = buildWaveformCacheKey("assets/music-bed.m4a", { size: 4187869, mtimeMs: 1000 });
7+
const b = buildWaveformCacheKey("assets/music-bed.m4a", { size: 4187869, mtimeMs: 1000 });
8+
expect(a).toBe(b);
9+
});
10+
11+
it("changes when the file behind the path is replaced", () => {
12+
// The case this exists for: an asset rebuilt in place — same name, new
13+
// content. Keyed on the path alone the cache served the old peaks forever,
14+
// so a bed whose ducking had just been removed still drew as ducked.
15+
const before = buildWaveformCacheKey("assets/music-bed.m4a", { size: 4187869, mtimeMs: 1000 });
16+
const after = buildWaveformCacheKey("assets/music-bed.m4a", { size: 3900000, mtimeMs: 2000 });
17+
expect(after).not.toBe(before);
18+
});
19+
20+
it("separates two files of the same size edited at different times, and vice versa", () => {
21+
const base = { size: 100, mtimeMs: 1000 };
22+
expect(buildWaveformCacheKey("a.m4a", base)).not.toBe(
23+
buildWaveformCacheKey("a.m4a", { ...base, mtimeMs: 1001 }),
24+
);
25+
expect(buildWaveformCacheKey("a.m4a", base)).not.toBe(
26+
buildWaveformCacheKey("a.m4a", { ...base, size: 101 }),
27+
);
28+
});
29+
30+
it("keeps distinct assets apart and stays a plain filename", () => {
31+
const fp = { size: 10, mtimeMs: 5 };
32+
expect(buildWaveformCacheKey("a/b.m4a", fp)).not.toBe(buildWaveformCacheKey("a/c.m4a", fp));
33+
expect(buildWaveformCacheKey("a/b.m4a", fp)).not.toMatch(/[/\\]/);
34+
expect(buildWaveformCacheKey("a/b.m4a", fp)).toMatch(/\.json$/);
35+
});
36+
});

packages/studio-server/src/helpers/waveform.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,28 @@ const SAMPLE_RATE = 4000;
77
const PEAK_COUNT = 4000;
88
const WAVEFORM_CACHE_VERSION = "v2";
99

10-
export function buildWaveformCacheKey(assetPath: string): string {
11-
return `${WAVEFORM_CACHE_VERSION}_${assetPath.replace(/[/\\]/g, "_")}.json`;
10+
/**
11+
* Cache filename for one asset's peaks, keyed on its content as well as its name.
12+
*
13+
* The path alone is not an identity. An asset rebuilt in place — a bed
14+
* re-encoded without its ducking, a plate swapped for the right one — keeps its
15+
* name and gets new samples, and a path-keyed entry then served the old peaks
16+
* for the rest of the project's life: the timeline drew a duck that was no
17+
* longer in the file, which reads as the render having done it. Size and mtime
18+
* are what a rebuild always changes, and both are already on the stat the route
19+
* takes to check the file exists.
20+
*
21+
* Without a fingerprint it falls back to the old path-only key, so a caller that
22+
* cannot stat still gets caching rather than an error.
23+
*/
24+
export function buildWaveformCacheKey(
25+
assetPath: string,
26+
fingerprint?: { size: number; mtimeMs: number },
27+
): string {
28+
const name = assetPath.replace(/[/\\]/g, "_");
29+
if (!fingerprint) return `${WAVEFORM_CACHE_VERSION}_${name}.json`;
30+
const stamp = `${fingerprint.size}-${Math.round(fingerprint.mtimeMs)}`;
31+
return `${WAVEFORM_CACHE_VERSION}_${name}_${stamp}.json`;
1232
}
1333

1434
function computePeaks(floats: Float32Array, count: number): number[] {

packages/studio-server/src/routes/waveform.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
22
import { join } from "node:path";
33
import type { Hono } from "hono";
44
import type { StudioApiAdapter } from "../types.js";
@@ -13,10 +13,13 @@ export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): vo
1313
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
1414
);
1515
const audioPath = join(project.dir, assetPath);
16-
if (!existsSync(audioPath)) return c.json({ error: "file not found" }, 404);
16+
const stats = statSync(audioPath, { throwIfNoEntry: false });
17+
if (!stats) return c.json({ error: "file not found" }, 404);
1718

1819
const cacheDir = join(project.dir, ".waveform-cache");
19-
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
20+
// Keyed on the file's size and mtime as well as its name, so re-encoding an
21+
// asset in place invalidates its peaks instead of drawing the old ones.
22+
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath, stats));
2023

2124
if (existsSync(cachePath)) {
2225
try {

0 commit comments

Comments
 (0)