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
132 lines
4.8 KiB
JavaScript
132 lines
4.8 KiB
JavaScript
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
import { createAudioRepository } from "./audio-repository.js";
|
|
import { resolveSpeech } from "./speech.js";
|
|
|
|
// One pronunciation owner; playback leases and native speech callbacks belong
|
|
// to that operation. The shared offscreen repository owns warm media, not WASM.
|
|
export function createAudioPlayer({ window, fetch, repository = createAudioRepository({ window, fetch }) }) {
|
|
let current = null;
|
|
|
|
function stop(reason = new DOMException("Playback stopped.", "AbortError")) {
|
|
current?.abort(reason);
|
|
current = null;
|
|
}
|
|
|
|
async function playUrl(candidate, signal, onPlaying) {
|
|
const lease = await repository.acquire(candidate, signal);
|
|
let audio;
|
|
let abort;
|
|
try {
|
|
signal.throwIfAborted();
|
|
audio = new window.Audio(lease.url);
|
|
const ended = new Promise((resolve, reject) => {
|
|
audio.onended = resolve;
|
|
audio.onplaying = () => { if (!signal.aborted) onPlaying?.(candidate); };
|
|
audio.onerror = () => {
|
|
lease.invalidate();
|
|
reject(new Error("The pronunciation could not be decoded or played."));
|
|
};
|
|
abort = () => reject(signal.reason);
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
});
|
|
await Promise.all([Promise.resolve().then(() => audio.play()), ended]);
|
|
} finally {
|
|
if (audio) {
|
|
signal.removeEventListener("abort", abort);
|
|
audio.onended = audio.onerror = audio.onplaying = null;
|
|
audio.pause();
|
|
audio.removeAttribute("src");
|
|
audio.load();
|
|
}
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
async function playSpeech(source, term, signal, onPlaying) {
|
|
const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal);
|
|
signal.throwIfAborted();
|
|
let abort;
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
utterance.onend = resolve;
|
|
utterance.onstart = () => { if (!signal.aborted) onPlaying?.(candidate); };
|
|
utterance.onerror = event => {
|
|
const detail = event.error ? ` (${event.error})` : "";
|
|
reject(new Error(`Text-to-speech could not be played${detail}.`));
|
|
};
|
|
abort = () => {
|
|
// Cancel synchronously, before a newer operation can speak. A late
|
|
// finally calling global speech.cancel() would stop that new voice.
|
|
utterance.onend = utterance.onerror = utterance.onstart = null;
|
|
speech.cancel();
|
|
reject(signal.reason);
|
|
};
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
speech.speak(utterance);
|
|
});
|
|
return candidate;
|
|
} finally {
|
|
signal.removeEventListener("abort", abort);
|
|
utterance.onend = utterance.onerror = utterance.onstart = null;
|
|
}
|
|
}
|
|
|
|
async function firstPlayable(found, signal, onPlaying, onResolving) {
|
|
let failure;
|
|
for (const [index, entry] of found.entries()) {
|
|
try {
|
|
onResolving?.();
|
|
const candidate = { ...entry, index: entry.index ?? index };
|
|
await playUrl(candidate, signal, onPlaying);
|
|
return candidate;
|
|
} catch (error) {
|
|
signal.throwIfAborted();
|
|
failure = error;
|
|
}
|
|
}
|
|
throw failure;
|
|
}
|
|
|
|
async function playSources(sources, term, { onPlaying, onResolving, candidate: selectedCandidate } = {}) {
|
|
stop();
|
|
const controller = new AbortController();
|
|
current = controller;
|
|
const { signal } = controller;
|
|
try {
|
|
let failure;
|
|
for (const source of sources) {
|
|
try {
|
|
onResolving?.();
|
|
const playing = candidate => onPlaying?.({ sourceId: source.id, candidate });
|
|
const candidate = await playSource(source, term, signal, playing, onResolving, selectedCandidate);
|
|
signal.throwIfAborted();
|
|
if (candidate) return { status: "success", sourceId: source.id, candidate };
|
|
} catch (error) {
|
|
signal.throwIfAborted();
|
|
failure = error;
|
|
}
|
|
}
|
|
if (failure) throw failure;
|
|
return { status: "no-result" };
|
|
} catch (error) {
|
|
if (signal.aborted && signal.reason?.name === "AbortError") return { status: "cancelled" };
|
|
throw signal.aborted ? signal.reason : error;
|
|
} finally {
|
|
if (current === controller) current = null;
|
|
}
|
|
}
|
|
|
|
async function playSource(source, term, signal, onPlaying, onResolving, selectedCandidate) {
|
|
if (source.type.startsWith("text-to-speech")) return playSpeech(source, term, signal, onPlaying);
|
|
const found = selectedCandidate ? [selectedCandidate] : await repository.candidates(source, term, signal);
|
|
signal.throwIfAborted();
|
|
return found.length ? firstPlayable(found, signal, onPlaying, onResolving) : null;
|
|
}
|
|
|
|
return {
|
|
stop, playSources,
|
|
play: (source, term) => playSources([source], term),
|
|
dispose() { stop(); repository.clear(); },
|
|
};
|
|
}
|