mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
72 lines
3.3 KiB
JavaScript
72 lines
3.3 KiB
JavaScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
import { createAudioCache } from "./audio-cache.js";
|
|
import { audioSourceUrl, parseAudioSourceList } from "./audio-sources.js";
|
|
|
|
export async function selectedAudioPlan(repository, sources, term, selection, signal) {
|
|
const source = sources.find(source => source.id === selection.sourceId && JSON.stringify(source) === selection.sourceKey);
|
|
if (!source || selection.expression !== term.expression || selection.reading !== term.reading) {
|
|
throw new Error("This pronunciation selection is no longer current. Choose it again.");
|
|
}
|
|
const candidates = await repository.candidates(source, term, signal);
|
|
const candidate = candidates[selection.index];
|
|
if (!candidate || (candidate.url ?? null) !== selection.url || candidate.name !== selection.name) {
|
|
throw new Error("The provider's pronunciation choices changed. Choose again.");
|
|
}
|
|
return { sources: [source], candidate: { ...candidate, index: selection.index } };
|
|
}
|
|
|
|
export function createAudioRepository({ window, fetch, now = () => performance.now() }) {
|
|
// GSM PR #549's retention budgets, not input or playback size limits.
|
|
const candidates = createAudioCache({ maxEntries: 256, maxBytes: 2 * 1024 * 1024, ttlMs: 5 * 60_000, now });
|
|
const media = createAudioCache({ maxEntries: 64, maxBytes: 64 * 1024 * 1024, ttlMs: 30 * 60_000, now,
|
|
onEvict(entry) { entry.retained = false; releaseUnused(entry); } });
|
|
const encoder = new TextEncoder();
|
|
|
|
function releaseUnused(entry) {
|
|
if (!entry.retained && entry.users === 0) window.URL.revokeObjectURL(entry.url);
|
|
}
|
|
|
|
async function response(url, signal) {
|
|
const result = await fetch(url, { credentials: "omit", signal });
|
|
if (!result.ok) throw new Error(`Audio provider returned HTTP ${result.status}.`);
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
async candidates(source, term, signal) {
|
|
signal.throwIfAborted();
|
|
const key = JSON.stringify([source, term.expression, term.reading]);
|
|
const cached = candidates.get(key);
|
|
if (cached) return cached;
|
|
let found = [];
|
|
if (source.type.startsWith("text-to-speech")) found = [{ name: source.voice || "Automatic Japanese" }];
|
|
else if (source.url) {
|
|
const url = audioSourceUrl(source.url, term);
|
|
if (source.type === "custom") found = [{ url, name: "" }];
|
|
else found = parseAudioSourceList(await (await response(url, signal)).json());
|
|
}
|
|
signal.throwIfAborted();
|
|
candidates.set(key, found, encoder.encode(key).byteLength + encoder.encode(JSON.stringify(found)).byteLength);
|
|
return found;
|
|
},
|
|
async acquire(candidate, signal) {
|
|
signal.throwIfAborted();
|
|
let entry = media.get(candidate.url);
|
|
if (entry) entry.users += 1;
|
|
else {
|
|
const blob = await (await response(candidate.url, signal)).blob();
|
|
signal.throwIfAborted();
|
|
entry = { blob, url: window.URL.createObjectURL(blob), users: 1, retained: false };
|
|
entry.retained = media.set(candidate.url, entry, blob.size + encoder.encode(candidate.url).byteLength);
|
|
}
|
|
return {
|
|
url: entry.url,
|
|
blob: entry.blob,
|
|
release() { entry.users -= 1; releaseUnused(entry); },
|
|
invalidate() { if (media.get(candidate.url) === entry) media.delete(candidate.url); },
|
|
};
|
|
},
|
|
clear() { candidates.clear(); media.clear(); },
|
|
};
|
|
}
|