AudioDecisionModel / audio-player.js
gojiteji's picture
Audio Decision Model demo
148af80
Raw History Blame Contribute Delete
7.46 kB
const playIcon = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.3v13.4c0 .8.9 1.3 1.6.8l10.1-6.7a1 1 0 0 0 0-1.6L9.6 4.5A1 1 0 0 0 8 5.3Z"/></svg>`;
const pauseIcon = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4.5" height="14" rx="1"/><rect x="13.5" y="5" width="4.5" height="14" rx="1"/></svg>`;
function formatTime(value) {
const seconds = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
function userError(error) {
if (error?.name === "NotAllowedError") return "再生がブラウザにより停止されました。再生ボタンをもう一度押してください。";
if (error?.name === "NotSupportedError") return "この音声形式はブラウザで再生できません。別の音声ファイルをお試しください。";
return "音声を再生できませんでした。音声ファイルを確認して、もう一度お試しください。";
}
/** Controls a native audio element and draws decoded mono 16 kHz samples locally. */
export function createAudioPlayer({ audio, container, canvas, playButton, seek, time, speed, onError } = {}) {
if (!audio || !container || !canvas || !playButton || !seek || !time || !speed) {
throw new Error("音声プレーヤーのUI要素が不足しています。");
}
const context = canvas.getContext("2d");
let samples = new Float32Array();
let animationFrame = 0;
let lastDrawAt = 0;
let disposed = false;
let resizeObserver = null;
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "音声の波形と再生位置");
seek.min = "0"; seek.step = "0.01"; seek.value = "0";
seek.setAttribute("aria-label", "再生位置");
function duration() { return Number.isFinite(audio.duration) ? audio.duration : 0; }
function setState(value) { container.dataset.playerState = value; }
function setButton(playing) {
playButton.setAttribute("aria-label", playing ? "一時停止" : "再生");
playButton.setAttribute("aria-pressed", String(playing));
playButton.innerHTML = playing ? pauseIcon : playIcon;
}
function updateText() { time.textContent = `${formatTime(audio.currentTime)} / ${formatTime(duration())}`; }
function updateSeek() { seek.max = String(duration()); seek.value = String(Math.min(duration(), audio.currentTime || 0)); updateText(); }
function canvasSize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
return { width, height, dpr };
}
function draw() {
const { width, height, dpr } = canvasSize();
context.clearRect(0, 0, width, height);
const style = getComputedStyle(container);
const brand = style.getPropertyValue("--brand").trim() || "#7203ff";
const muted = style.getPropertyValue("--wave-muted").trim() || "#c6c8d0";
const binCount = Math.max(24, Math.min(140, Math.round((width / dpr) / 4)));
const progress = duration() ? Math.max(0, Math.min(1, audio.currentTime / duration())) : 0;
const gap = Math.max(1, Math.round(dpr));
const barWidth = Math.max(1, (width - gap * (binCount - 1)) / binCount);
const minHeight = Math.max(2 * dpr, height * 0.07);
for (let bin = 0; bin < binCount; bin += 1) {
const start = Math.floor(bin * samples.length / binCount);
const end = Math.max(start + 1, Math.floor((bin + 1) * samples.length / binCount));
let peak = 0;
for (let index = start; index < end; index += 1) peak = Math.max(peak, Math.abs(samples[index] || 0));
const barHeight = Math.max(minHeight, peak * (height * 0.9));
const x = bin * (barWidth + gap);
context.fillStyle = (bin + 0.5) / binCount <= progress ? brand : muted;
context.beginPath();
if (typeof context.roundRect === "function") context.roundRect(x, (height - barHeight) / 2, barWidth, barHeight, Math.min(barWidth / 2, 3 * dpr));
else context.rect(x, (height - barHeight) / 2, barWidth, barHeight);
context.fill();
}
}
function animate(now) {
if (audio.paused || disposed) { animationFrame = 0; return; }
animationFrame = requestAnimationFrame(animate);
if (now - lastDrawAt >= 50) { lastDrawAt = now; updateSeek(); draw(); }
}
function startAnimation() {
if (!animationFrame) { lastDrawAt = 0; animationFrame = requestAnimationFrame(animate); }
}
function stopAnimation() { cancelAnimationFrame(animationFrame); animationFrame = 0; }
async function togglePlay() {
if (!audio.paused) { audio.pause(); return; }
setState("waiting"); playButton.disabled = true;
try { await audio.play(); }
catch (error) { setState("error"); setButton(false); onError?.(new Error(userError(error))); }
finally { playButton.disabled = false; }
}
function onMetadata() {
container.hidden = false; updateSeek(); setButton(false); setState("ready"); draw();
}
function onSourceReset() {
stopAnimation(); seek.value = "0"; time.textContent = "0:00 / 0:00"; setButton(false); setState("loading"); draw();
}
function onPlay() { setState("playing"); setButton(true); startAnimation(); }
function onPause() { setState("paused"); setButton(false); stopAnimation(); updateSeek(); draw(); }
function onEnded() { setState("ended"); setButton(false); stopAnimation(); updateSeek(); draw(); }
function onWaiting() { setState("waiting"); }
function onErrorEvent() { setState("error"); setButton(false); stopAnimation(); onError?.(new Error(userError(audio.error))); }
const listeners = [
["loadedmetadata", onMetadata], ["durationchange", updateSeek], ["loadstart", onSourceReset], ["emptied", onSourceReset],
["timeupdate", () => { updateSeek(); if (audio.paused) draw(); }], ["play", onPlay], ["pause", onPause], ["ended", onEnded], ["waiting", onWaiting], ["error", onErrorEvent]
];
listeners.forEach(([event, handler]) => audio.addEventListener(event, handler));
playButton.addEventListener("click", togglePlay);
seek.addEventListener("input", () => { const position = Number(seek.value); if (Number.isFinite(position)) { audio.currentTime = position; updateText(); draw(); } });
speed.addEventListener("change", () => { const rate = Number(speed.value); if (Number.isFinite(rate) && rate > 0) audio.playbackRate = rate; });
if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(draw); resizeObserver.observe(canvas); }
function setSamples(value) {
if (!(value instanceof Float32Array)) throw new TypeError("波形データはFloat32Arrayで指定してください。");
samples = value.slice(); draw();
}
function dispose() {
disposed = true; stopAnimation(); resizeObserver?.disconnect(); resizeObserver = null;
listeners.forEach(([event, handler]) => audio.removeEventListener(event, handler));
playButton.removeEventListener("click", togglePlay);
audio.pause();
}
const initialRate = Number(speed.value); if (Number.isFinite(initialRate) && initialRate > 0) audio.playbackRate = initialRate;
setButton(false); updateText(); setState("idle"); draw();
return { setSamples, refreshTheme: draw, dispose };
}