Spaces:
Runtime error
Runtime error
File size: 7,464 Bytes
148af80 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | 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 };
}
|