mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(audio): bound versioned waveform cache
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user