| <!DOCTYPE html> |
| <html lang="ja"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>MEI プレイヤー</title> |
| <script src="https://www.verovio.org/javascript/latest/verovio-toolkit-wasm.js" defer></script> |
| <script src="https://www.midijs.net/lib/midi.js"></script> |
| <style> |
| :root { |
| --bg: #0f1220; |
| --panel: #171b2d; |
| --panel-2: #1f2440; |
| --text: #eef2ff; |
| --muted: #a9b0cc; |
| --accent: #7c9cff; |
| --accent-2: #9dffcc; |
| --danger: #ff7d7d; |
| --stroke: rgba(255,255,255,.12); |
| } |
| |
| * { box-sizing: border-box; } |
| html, body { |
| margin: 0; |
| padding: 0; |
| height: 100%; |
| background: linear-gradient(180deg, #0d1020 0%, #10152a 100%); |
| color: var(--text); |
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif; |
| } |
| |
| header { |
| padding: 16px 18px 10px; |
| border-bottom: 1px solid var(--stroke); |
| background: rgba(15,18,32,.92); |
| position: sticky; |
| top: 0; |
| backdrop-filter: blur(10px); |
| z-index: 10; |
| } |
| |
| h1 { |
| margin: 0 0 8px; |
| font-size: 18px; |
| font-weight: 700; |
| } |
| |
| .toolbar { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| align-items: center; |
| margin-bottom: 10px; |
| } |
| |
| .toolbar > * { |
| flex: 0 0 auto; |
| } |
| |
| input[type="file"] { |
| max-width: 100%; |
| color: var(--muted); |
| } |
| |
| button { |
| appearance: none; |
| border: 1px solid var(--stroke); |
| background: linear-gradient(180deg, var(--panel-2), var(--panel)); |
| color: var(--text); |
| border-radius: 12px; |
| padding: 10px 14px; |
| cursor: pointer; |
| font-weight: 600; |
| transition: transform .08s ease, border-color .15s ease, opacity .15s ease; |
| } |
| |
| button:hover { border-color: rgba(124,156,255,.65); } |
| button:active { transform: translateY(1px); } |
| button:disabled { |
| opacity: .45; |
| cursor: not-allowed; |
| } |
| |
| .status { |
| font-size: 13px; |
| color: var(--muted); |
| line-height: 1.45; |
| } |
| |
| .status strong { |
| color: var(--accent-2); |
| font-weight: 700; |
| } |
| |
| |
| .control-row { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 20px 30px; |
| margin-top: 12px; |
| padding: 10px 0 6px; |
| border-top: 1px solid var(--stroke); |
| } |
| |
| .control-group { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| flex-wrap: wrap; |
| } |
| |
| .control-group label { |
| font-size: 13px; |
| font-weight: 600; |
| color: var(--muted); |
| min-width: 36px; |
| } |
| |
| .control-group input[type="range"] { |
| width: 120px; |
| height: 4px; |
| border-radius: 2px; |
| background: var(--stroke); |
| outline: none; |
| -webkit-appearance: none; |
| appearance: none; |
| } |
| |
| .control-group input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 14px; |
| height: 14px; |
| border-radius: 50%; |
| background: var(--accent); |
| cursor: pointer; |
| border: 2px solid var(--bg); |
| box-shadow: 0 0 6px rgba(124,156,255,.4); |
| } |
| |
| .control-group input[type="range"]::-moz-range-thumb { |
| width: 14px; |
| height: 14px; |
| border-radius: 50%; |
| background: var(--accent); |
| cursor: pointer; |
| border: 2px solid var(--bg); |
| } |
| |
| .control-value { |
| font-size: 14px; |
| font-weight: 600; |
| min-width: 48px; |
| color: var(--text); |
| text-align: center; |
| } |
| |
| .control-group input[type="number"] { |
| width: 60px; |
| padding: 4px 6px; |
| border-radius: 8px; |
| border: 1px solid var(--stroke); |
| background: rgba(255,255,255,.04); |
| color: var(--text); |
| font-size: 13px; |
| font-weight: 500; |
| text-align: center; |
| } |
| |
| .control-group input[type="number"]:focus { |
| border-color: var(--accent); |
| outline: none; |
| } |
| |
| .control-group .unit { |
| color: var(--muted); |
| font-size: 13px; |
| } |
| |
| |
| main { |
| height: calc(100% - 200px); |
| padding: 14px; |
| } |
| |
| #scrollWrap { |
| height: 100%; |
| overflow-x: auto; |
| border: 1px solid var(--stroke); |
| border-radius: 18px; |
| background: rgba(255,255,255,.03); |
| box-shadow: 0 12px 28px rgba(0,0,0,.25); |
| scroll-behavior: smooth; |
| } |
| |
| #pages { |
| display: flex; |
| align-items: flex-start; |
| gap: 18px; |
| width: max-content; |
| padding: 16px; |
| min-height: 100%; |
| } |
| |
| .page { |
| background: #fff; |
| border-radius: 14px; |
| box-shadow: 0 12px 22px rgba(0,0,0,.20); |
| padding: 10px; |
| flex: 0 0 auto; |
| } |
| |
| .pageLabel { |
| color: #111; |
| font-size: 12px; |
| font-weight: 700; |
| margin: 0 0 8px; |
| opacity: .75; |
| letter-spacing: .02em; |
| } |
| |
| .page svg { |
| display: block; |
| max-width: none; |
| } |
| |
| g.note.playing, |
| g.chord.playing { |
| fill: #d61f45 !important; |
| stroke: #d61f45 !important; |
| } |
| |
| .hint { |
| color: var(--muted); |
| font-size: 12px; |
| margin-top: 6px; |
| } |
| |
| .pill { |
| display: inline-block; |
| padding: 3px 8px; |
| border-radius: 999px; |
| border: 1px solid var(--stroke); |
| background: rgba(255,255,255,.04); |
| margin-right: 6px; |
| } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>MEIアップローダー & プレイヤー</h1> |
| <div class="toolbar"> |
| <input id="meiFile" type="file" accept=".mei,.xml,.mei.xml,text/xml,application/xml" /> |
| <button id="loadBtn" disabled>読み込み</button> |
| <button id="playBtn" disabled>再生</button> |
| <button id="stopBtn" disabled>停止</button> |
| </div> |
| <div class="status" id="status"> |
| <span class="pill">Verovio</span>MEIファイルを選んで「読み込み」を押してください。 |
| </div> |
|
|
| |
| <div class="control-row"> |
| <div class="control-group"> |
| <label for="speedSlider">速度</label> |
| <input type="range" id="speedSlider" min="0.25" max="3.0" step="0.05" value="1.0"> |
| <span class="control-value" id="speedValue">1.00x</span> |
| <input type="number" id="speedInput" min="0.25" max="3.0" step="0.05" value="1.0"> |
| </div> |
| <div class="control-group"> |
| <label for="seekSlider">シーク</label> |
| <input type="range" id="seekSlider" min="0" max="100" value="0"> |
| <span class="control-value" id="seekTime">0:00</span> |
| <input type="number" id="seekInput" min="0" step="0.1" value="0"> |
| <span class="unit">秒</span> |
| </div> |
| </div> |
| </header> |
|
|
| <main> |
| <div id="scrollWrap"> |
| <div id="pages"></div> |
| </div> |
| </main> |
|
|
| <script> |
| let tk = null; |
| let loadedMEI = ""; |
| let currentFileName = ""; |
| let pageCount = 0; |
| let isRendering = false; |
| let isPlaying = false; |
| let scrollTimeout = null; |
| |
| |
| let currentSpeed = 1.0; |
| let currentTime = 0; |
| let duration = 0; |
| let isSeeking = false; |
| let playbackTimer = null; |
| |
| const els = { |
| file: document.getElementById("meiFile"), |
| loadBtn: document.getElementById("loadBtn"), |
| playBtn: document.getElementById("playBtn"), |
| stopBtn: document.getElementById("stopBtn"), |
| status: document.getElementById("status"), |
| pages: document.getElementById("pages"), |
| scrollWrap: document.getElementById("scrollWrap"), |
| speedSlider: document.getElementById("speedSlider"), |
| speedInput: document.getElementById("speedInput"), |
| speedValue: document.getElementById("speedValue"), |
| seekSlider: document.getElementById("seekSlider"), |
| seekInput: document.getElementById("seekInput"), |
| seekTime: document.getElementById("seekTime"), |
| }; |
| |
| function setStatus(html) { |
| els.status.innerHTML = html; |
| } |
| |
| function setButtons({ canLoad, canPlay, canStop }) { |
| els.loadBtn.disabled = !canLoad; |
| els.playBtn.disabled = !canPlay; |
| els.stopBtn.disabled = !canStop; |
| } |
| |
| function clearHighlights() { |
| els.pages.querySelectorAll("g.note.playing, g.chord.playing").forEach((el) => { |
| el.classList.remove("playing"); |
| }); |
| } |
| |
| function smoothScrollToPage(pageElement) { |
| if (!pageElement || !els.scrollWrap) return; |
| |
| const scrollWrap = els.scrollWrap; |
| const pageCenter = pageElement.offsetLeft + (pageElement.offsetWidth / 2); |
| const scrollWrapCenter = scrollWrap.clientWidth / 2; |
| const targetScrollLeft = Math.max( |
| 0, |
| pageCenter - scrollWrapCenter |
| ); |
| |
| scrollWrap.scrollTo({ |
| left: Math.max(0, targetScrollLeft), |
| behavior: "smooth", |
| }); |
| } |
| |
| function highlightElements(currentElements) { |
| clearHighlights(); |
| if (!currentElements) return; |
| |
| const ids = currentElements.notes || currentElements.note || []; |
| let targetPageElement = null; |
| |
| for (const id of ids) { |
| const el = document.getElementById(id); |
| if (!el) continue; |
| |
| el.classList.add("playing"); |
| |
| if (!targetPageElement) { |
| targetPageElement = el.closest(".page"); |
| } |
| } |
| |
| |
| if (!targetPageElement && currentTime >= 0) { |
| const pages = document.querySelectorAll(".page"); |
| if (pages.length > 0) { |
| const ratio = duration > 0 ? currentTime / duration : 0; |
| const index = Math.min( |
| pages.length - 1, |
| Math.floor(ratio * pages.length) |
| ); |
| targetPageElement = pages[index]; |
| } |
| } |
| |
| if (targetPageElement) { |
| targetPageElement.scrollIntoView({ |
| behavior: "smooth", |
| inline: "center", |
| block: "nearest" |
| }); |
| } |
| } |
| function renderAllPages() { |
| els.pages.innerHTML = ""; |
| pageCount = tk.getPageCount(); |
| |
| for (let page = 1; page <= pageCount; page++) { |
| const pageWrap = document.createElement("section"); |
| pageWrap.className = "page"; |
| pageWrap.dataset.page = String(page); |
| |
| const label = document.createElement("div"); |
| label.className = "pageLabel"; |
| label.textContent = `Page ${page} / ${pageCount}`; |
| pageWrap.appendChild(label); |
| |
| const svg = tk.renderToSVG(page); |
| const svgWrap = document.createElement("div"); |
| svgWrap.innerHTML = svg; |
| pageWrap.appendChild(svgWrap); |
| |
| els.pages.appendChild(pageWrap); |
| } |
| } |
| |
| function formatTime(seconds) { |
| if (!isFinite(seconds) || seconds < 0) return "0:00"; |
| const m = Math.floor(seconds / 60); |
| const s = Math.floor(seconds % 60); |
| return `${m}:${s.toString().padStart(2, "0")}`; |
| } |
| |
| function updateSeekControls(time) { |
| if (isSeeking) return; |
| |
| currentTime = time; |
| const pct = duration > 0 ? (time / duration) * 100 : 0; |
| els.seekSlider.value = pct; |
| els.seekInput.value = time.toFixed(1); |
| els.seekTime.textContent = formatTime(time); |
| } |
| |
| function setSpeed(value) { |
| value = Math.min(3.0, Math.max(0.25, value)); |
| currentSpeed = value; |
| els.speedSlider.value = value; |
| els.speedInput.value = value; |
| els.speedValue.textContent = value.toFixed(2) + "x"; |
| } |
| |
| function renderAtTime(time) { |
| if (!tk) return; |
| const elements = tk.getElementsAtTime(time * 1000); |
| highlightElements(elements); |
| updateSeekControls(time); |
| } |
| |
| function seekTo(time) { |
| if (!tk) return; |
| |
| if (duration > 0) { |
| time = Math.min(time, duration); |
| } |
| time = Math.max(0, time); |
| |
| currentTime = time; |
| renderAtTime(currentTime); |
| |
| const pct = duration > 0 ? (time / duration) * 100 : 0; |
| els.seekSlider.value = pct; |
| els.seekInput.value = time.toFixed(1); |
| els.seekTime.textContent = formatTime(time); |
| } |
| |
| function startPlaybackInternal(offset = 0) { |
| if (!tk || !loadedMEI) return; |
| |
| clearInterval(playbackTimer); |
| |
| currentTime = Math.max(0, Math.min(offset, duration)); |
| isPlaying = true; |
| |
| setStatus(`「<strong>${currentFileName}</strong>」を再生中です。`); |
| setButtons({ canLoad: true, canPlay: false, canStop: true }); |
| |
| renderAtTime(currentTime); |
| |
| const tickMs = 50; |
| |
| playbackTimer = setInterval(() => { |
| if (!isPlaying) return; |
| |
| currentTime += (tickMs / 1000) * currentSpeed; |
| |
| if (duration > 0 && currentTime >= duration) { |
| currentTime = duration; |
| renderAtTime(currentTime); |
| stopPlayback(); |
| return; |
| } |
| |
| renderAtTime(currentTime); |
| }, tickMs); |
| } |
| |
| async function readFileAsText(file) { |
| return await file.text(); |
| } |
| |
| async function loadMEI() { |
| const file = els.file.files && els.file.files[0]; |
| if (!file) { |
| setStatus("ファイルが選ばれていません。"); |
| return; |
| } |
| |
| if (!tk) { |
| setStatus("Verovioの準備中です。少し待ってからもう一度読み込んでください。"); |
| return; |
| } |
| |
| const text = await readFileAsText(file); |
| loadedMEI = text; |
| currentFileName = file.name; |
| |
| try { |
| isRendering = true; |
| isPlaying = false; |
| clearInterval(playbackTimer); |
| playbackTimer = null; |
| clearHighlights(); |
| |
| tk.loadData(loadedMEI); |
| tk.redoLayout(); |
| renderAllPages(); |
| |
| try { |
| const timemap = tk.renderToTimemap(); |
| if (timemap && timemap.length > 0) { |
| duration = timemap[timemap.length - 1].tstamp / 1000; |
| } else { |
| duration = 0; |
| } |
| } catch (e) { |
| console.error(e); |
| duration = 0; |
| } |
| |
| if (duration <= 0) { |
| duration = 300; |
| console.warn("曲の長さが取得できませんでした。デフォルト300秒を使用します。"); |
| } |
| |
| els.seekSlider.max = 100; |
| els.seekInput.max = duration; |
| els.seekInput.step = 0.1; |
| |
| currentTime = 0; |
| currentSpeed = 1.0; |
| els.speedSlider.value = 1.0; |
| els.speedInput.value = 1.0; |
| els.speedValue.textContent = "1.00x"; |
| |
| els.seekSlider.value = 0; |
| els.seekInput.value = 0; |
| els.seekTime.textContent = "0:00"; |
| |
| setStatus(`「<strong>${currentFileName}</strong>」を読み込みました。ページ数: <strong>${pageCount}</strong>`); |
| setButtons({ canLoad: true, canPlay: true, canStop: false }); |
| } catch (err) { |
| console.error(err); |
| setStatus("読み込みに失敗しました。MEIファイルの内容を確認してください。"); |
| setButtons({ canLoad: true, canPlay: false, canStop: false }); |
| } finally { |
| isRendering = false; |
| } |
| } |
| |
| function startPlayback() { |
| startPlaybackInternal(currentTime); |
| } |
| |
| function stopPlayback() { |
| clearInterval(playbackTimer); |
| playbackTimer = null; |
| |
| isPlaying = false; |
| clearHighlights(); |
| clearTimeout(scrollTimeout); |
| |
| setStatus(`停止しました。<strong>${currentFileName || ""}</strong>`); |
| setButtons({ canLoad: true, canPlay: !!loadedMEI, canStop: false }); |
| } |
| |
| |
| els.file.addEventListener("change", () => { |
| const file = els.file.files && els.file.files[0]; |
| setButtons({ canLoad: !!file, canPlay: false, canStop: false }); |
| setStatus(file ? `選択中: <strong>${file.name}</strong>。読み込みを押してください。` : "MEIファイルを選んでください。"); |
| }); |
| |
| els.loadBtn.addEventListener("click", loadMEI); |
| els.playBtn.addEventListener("click", startPlayback); |
| els.stopBtn.addEventListener("click", stopPlayback); |
| |
| |
| els.speedSlider.addEventListener("input", () => { |
| const val = parseFloat(els.speedSlider.value); |
| els.speedInput.value = val; |
| els.speedValue.textContent = val.toFixed(2) + "x"; |
| setSpeed(val); |
| }); |
| |
| els.speedInput.addEventListener("change", () => { |
| let val = parseFloat(els.speedInput.value); |
| if (isNaN(val)) val = 1.0; |
| val = Math.min(3.0, Math.max(0.25, val)); |
| els.speedInput.value = val; |
| els.speedSlider.value = val; |
| els.speedValue.textContent = val.toFixed(2) + "x"; |
| setSpeed(val); |
| }); |
| |
| |
| els.seekSlider.addEventListener("pointerdown", () => { |
| isSeeking = true; |
| }); |
| |
| els.seekSlider.addEventListener("input", () => { |
| const pct = parseFloat(els.seekSlider.value); |
| const time = (pct / 100) * duration; |
| els.seekInput.value = time.toFixed(1); |
| els.seekTime.textContent = formatTime(time); |
| |
| const elements = tk.getElementsAtTime(time * 1000); |
| highlightElements(elements); |
| }); |
| |
| els.seekSlider.addEventListener("pointerup", () => { |
| isSeeking = false; |
| const pct = parseFloat(els.seekSlider.value); |
| const time = (pct / 100) * duration; |
| seekTo(time); |
| }); |
| |
| els.seekInput.addEventListener("focus", () => { |
| isSeeking = true; |
| }); |
| |
| els.seekInput.addEventListener("change", () => { |
| let val = parseFloat(els.seekInput.value); |
| if (isNaN(val)) val = 0; |
| val = Math.min(duration, Math.max(0, val)); |
| els.seekInput.value = val.toFixed(1); |
| |
| const pct = duration > 0 ? (val / duration) * 100 : 0; |
| els.seekSlider.value = pct; |
| els.seekTime.textContent = formatTime(val); |
| |
| isSeeking = false; |
| seekTo(val); |
| }); |
| |
| els.seekInput.addEventListener("blur", () => { |
| isSeeking = false; |
| }); |
| |
| |
| document.addEventListener("DOMContentLoaded", () => { |
| verovio.module.onRuntimeInitialized = () => { |
| tk = new verovio.toolkit(); |
| tk.setOptions({ |
| pageWidth: 1800, |
| pageHeight: 1200, |
| scale: 45, |
| scaleToPageSize: false, |
| }); |
| |
| setStatus("Verovioの準備ができました。MEIファイルを選んでください。"); |
| setButtons({ canLoad: false, canPlay: false, canStop: false }); |
| }; |
| }); |
| </script> |
| </body> |
| </html> |