fix(audio): bound versioned waveform cache

This commit is contained in:
wiiiii123
2026-07-11 13:06:33 +07:00
parent 08e335f8c0
commit 0bd3daecd9
5 changed files with 160 additions and 17 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getAudioResourceCacheScope,
getAudioResourceVersionKey,
getVersionedAudioResourceUrl,
isAudioResourceLoadCurrent,
@@ -47,4 +48,17 @@ describe("getAudioResourceVersionKey", () => {
"https://cdn.example/audio.wav?sig=abc",
);
});
it("uses one cache scope for every version of a loopback resource", () => {
const baseUrl =
"http://127.0.0.1:43123/video?path=C%3A%5CRecordly%5Crecording.mic.wav";
const versionedUrl = `${baseUrl}&recordlyAudioVersion=4`;
expect(getAudioResourceCacheScope(versionedUrl)).toBe(baseUrl);
expect(
getAudioResourceCacheScope(
"https://cdn.example/audio.wav?recordlyAudioVersion=4&sig=abc",
),
).toBe("https://cdn.example/audio.wav?recordlyAudioVersion=4&sig=abc");
});
});
@@ -3,27 +3,42 @@ export function getAudioResourceVersionKey(resource: string, version = 0): strin
return `${resource}::recordly-audio-v${safeVersion}`;
}
function parseLoopbackMediaServerUrl(resourceUrl: string): URL | null {
try {
const url = new URL(resourceUrl);
const isLoopbackMediaServer =
(url.protocol === "http:" || url.protocol === "https:") &&
(url.hostname === "127.0.0.1" || url.hostname === "localhost") &&
url.pathname === "/video";
return isLoopbackMediaServer ? url : null;
} catch {
return null;
}
}
export function getAudioResourceCacheScope(resourceUrl: string): string {
const url = parseLoopbackMediaServerUrl(resourceUrl);
if (!url) {
return resourceUrl;
}
url.searchParams.delete("recordlyAudioVersion");
return url.href;
}
export function getVersionedAudioResourceUrl(resourceUrl: string, version = 0): string {
const safeVersion = Number.isFinite(version) ? Math.max(0, Math.trunc(version)) : 0;
if (safeVersion === 0) {
return resourceUrl;
}
try {
const url = new URL(resourceUrl);
const isLoopbackMediaServer =
(url.protocol === "http:" || url.protocol === "https:") &&
(url.hostname === "127.0.0.1" || url.hostname === "localhost") &&
url.pathname === "/video";
if (!isLoopbackMediaServer) {
return resourceUrl;
}
url.searchParams.set("recordlyAudioVersion", String(safeVersion));
return url.href;
} catch {
const url = parseLoopbackMediaServerUrl(resourceUrl);
if (!url) {
return resourceUrl;
}
url.searchParams.set("recordlyAudioVersion", String(safeVersion));
return url.href;
}
export function isAudioResourceLoadCurrent(
@@ -1,14 +1,21 @@
import { WAVEFORM_DEFAULT_PEAK_COUNT } from "../../timeline/core/constants";
import type { AudioPeaksData } from "../../timeline/core/timelineTypes";
import { getAudioResourceVersionKey } from "../audioResourceVersion";
import {
getAudioResourceCacheScope,
getAudioResourceVersionKey,
} from "../audioResourceVersion";
import WorkerConstructor from "./waveform.worker?worker";
import { VersionedWaveformCache } from "./waveformCache";
const MAX_WAVEFORM_PEAKS = 200_000;
const MAX_WAVEFORM_CACHE_ENTRIES = 24;
export class WaveformGenerator {
private audioContext: AudioContext;
private worker: Worker;
private peaksCache = new Map<string, AudioPeaksData>();
private peaksCache = new VersionedWaveformCache<AudioPeaksData>(
MAX_WAVEFORM_CACHE_ENTRIES,
);
private pending = new Map<string, Promise<AudioPeaksData>>();
private workerRequestSeq = 0;
private workerResolvers = new Map<number, { resolve: (peaks: Float32Array) => void; reject: (err: Error) => void }>();
@@ -66,7 +73,9 @@ export class WaveformGenerator {
peakCount = WAVEFORM_DEFAULT_PEAK_COUNT,
resourceVersion = 0,
): Promise<AudioPeaksData> {
const cacheKey = `${getAudioResourceVersionKey(url, resourceVersion)}::${peakCount}`;
const cacheScope = `${getAudioResourceCacheScope(url)}::${peakCount}`;
const cacheKey = getAudioResourceVersionKey(cacheScope, resourceVersion);
this.peaksCache.activate(cacheScope, cacheKey);
const cached = this.peaksCache.get(cacheKey);
if (cached) return cached;
@@ -119,11 +128,12 @@ export class WaveformGenerator {
peaks,
durationMs: decoded.duration * 1000,
};
this.peaksCache.set(cacheKey, result);
this.peaksCache.setIfCurrent(cacheScope, cacheKey, result);
this.pending.delete(cacheKey);
return result;
})().catch((error) => {
this.pending.delete(cacheKey);
this.peaksCache.deactivateIfCurrent(cacheScope, cacheKey);
throw error;
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { VersionedWaveformCache } from "./waveformCache";
describe("VersionedWaveformCache", () => {
it("prunes superseded versions and rejects stale in-flight results", () => {
const cache = new VersionedWaveformCache<string>(2);
cache.activate("mic::100", "mic-v1");
expect(cache.setIfCurrent("mic::100", "mic-v1", "version one")).toBe(true);
cache.activate("mic::100", "mic-v2");
expect(cache.get("mic-v1")).toBeUndefined();
expect(cache.setIfCurrent("mic::100", "mic-v1", "stale version one")).toBe(false);
expect(cache.setIfCurrent("mic::100", "mic-v2", "version two")).toBe(true);
expect(cache.get("mic-v2")).toBe("version two");
cache.activate("mic::100", "mic-v3");
cache.deactivateIfCurrent("mic::100", "mic-v3");
expect(cache.setIfCurrent("mic::100", "mic-v3", "failed version three")).toBe(false);
});
it("evicts the least recently used resource when the cache reaches its bound", () => {
const cache = new VersionedWaveformCache<string>(2);
cache.activate("mic-a", "mic-a-v1");
cache.setIfCurrent("mic-a", "mic-a-v1", "a");
cache.activate("mic-b", "mic-b-v1");
cache.setIfCurrent("mic-b", "mic-b-v1", "b");
expect(cache.get("mic-a-v1")).toBe("a");
cache.activate("mic-c", "mic-c-v1");
cache.setIfCurrent("mic-c", "mic-c-v1", "c");
expect(cache.get("mic-a-v1")).toBe("a");
expect(cache.get("mic-b-v1")).toBeUndefined();
expect(cache.get("mic-c-v1")).toBe("c");
});
});
@@ -0,0 +1,65 @@
export class VersionedWaveformCache<T> {
private readonly values = new Map<string, T>();
private readonly latestKeyByScope = new Map<string, string>();
private readonly scopeByKey = new Map<string, string>();
constructor(private readonly maxEntries: number) {
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
throw new RangeError("Waveform cache size must be a positive integer");
}
}
activate(scope: string, key: string): void {
const previousKey = this.latestKeyByScope.get(scope);
if (previousKey && previousKey !== key) {
this.values.delete(previousKey);
this.scopeByKey.delete(previousKey);
}
this.latestKeyByScope.set(scope, key);
}
get(key: string): T | undefined {
const value = this.values.get(key);
if (value === undefined) {
return undefined;
}
this.values.delete(key);
this.values.set(key, value);
return value;
}
setIfCurrent(scope: string, key: string, value: T): boolean {
if (this.latestKeyByScope.get(scope) !== key) {
return false;
}
this.values.delete(key);
this.values.set(key, value);
this.scopeByKey.set(key, scope);
this.evictOverflow();
return true;
}
deactivateIfCurrent(scope: string, key: string): void {
if (this.latestKeyByScope.get(scope) === key && !this.values.has(key)) {
this.latestKeyByScope.delete(scope);
}
}
private evictOverflow(): void {
while (this.values.size > this.maxEntries) {
const oldestKey = this.values.keys().next().value;
if (oldestKey === undefined) {
return;
}
this.values.delete(oldestKey);
const scope = this.scopeByKey.get(oldestKey);
this.scopeByKey.delete(oldestKey);
if (scope && this.latestKeyByScope.get(scope) === oldestKey) {
this.latestKeyByScope.delete(scope);
}
}
}
}