diff --git a/src/components/video-editor/audio/audioResourceVersion.test.ts b/src/components/video-editor/audio/audioResourceVersion.test.ts index f6a6198e..a264f9e1 100644 --- a/src/components/video-editor/audio/audioResourceVersion.test.ts +++ b/src/components/video-editor/audio/audioResourceVersion.test.ts @@ -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"); + }); }); diff --git a/src/components/video-editor/audio/audioResourceVersion.ts b/src/components/video-editor/audio/audioResourceVersion.ts index 30021054..9a608233 100644 --- a/src/components/video-editor/audio/audioResourceVersion.ts +++ b/src/components/video-editor/audio/audioResourceVersion.ts @@ -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( diff --git a/src/components/video-editor/audio/waveform/WaveformGenerator.ts b/src/components/video-editor/audio/waveform/WaveformGenerator.ts index a3a408c4..76e1e063 100644 --- a/src/components/video-editor/audio/waveform/WaveformGenerator.ts +++ b/src/components/video-editor/audio/waveform/WaveformGenerator.ts @@ -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(); + private peaksCache = new VersionedWaveformCache( + MAX_WAVEFORM_CACHE_ENTRIES, + ); private pending = new Map>(); private workerRequestSeq = 0; private workerResolvers = new Map void; reject: (err: Error) => void }>(); @@ -66,7 +73,9 @@ export class WaveformGenerator { peakCount = WAVEFORM_DEFAULT_PEAK_COUNT, resourceVersion = 0, ): Promise { - 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; }); diff --git a/src/components/video-editor/audio/waveform/waveformCache.test.ts b/src/components/video-editor/audio/waveform/waveformCache.test.ts new file mode 100644 index 00000000..da3dc9e8 --- /dev/null +++ b/src/components/video-editor/audio/waveform/waveformCache.test.ts @@ -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(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(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"); + }); +}); diff --git a/src/components/video-editor/audio/waveform/waveformCache.ts b/src/components/video-editor/audio/waveform/waveformCache.ts new file mode 100644 index 00000000..34c5ba46 --- /dev/null +++ b/src/components/video-editor/audio/waveform/waveformCache.ts @@ -0,0 +1,65 @@ +export class VersionedWaveformCache { + private readonly values = new Map(); + private readonly latestKeyByScope = new Map(); + private readonly scopeByKey = new Map(); + + 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); + } + } + } +}