player2 / mei-test.html
izuemon's picture
Update mei-test.html
dbb688f
Raw
History Blame Contribute Delete
17.9 kB
<!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");
}
}
// ノートIDが取れない場合は時間からページを推定
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;
});
// Verovio 初期化
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>