// midipane.js - MIDI ピアノプレイヤー(スクロール対応版)
// 依存: @tonejs/midi のみ(Tone.js は不要)
(function() {
'use strict';
window.MIDIPane = {
create: createMIDIPane,
dispose: disposeMIDIPane,
syncAll: syncAllMIDIPanes,
setPlaybackRate: setAllPlaybackRate,
playAll: playAllMIDIPanes,
pauseAll: pauseAllMIDIPanes,
stopAll: stopAllMIDIPanes,
seekAll: seekAllMIDIPanes
};
const instances = new Map();
function createMIDIPane(container, filePath, headerElement, offset = 0) {
if (instances.has(container)) {
disposeMIDIPane(container);
}
if (!headerElement) {
const pane = container.closest('.pane');
if (pane) headerElement = pane.querySelector('.pane-header');
}
const instance = new MIDIPlayer(container, filePath, headerElement, offset);
instances.set(container, instance);
return instance;
}
function disposeMIDIPane(container) {
if (instances.has(container)) {
const inst = instances.get(container);
inst.destroy();
instances.delete(container);
}
}
function syncAllMIDIPanes(time) {
instances.forEach(inst => {
if (inst && typeof inst.seekTo === 'function') {
inst.seekTo(time || 0);
}
});
}
function setAllPlaybackRate(rate) {
instances.forEach(inst => {
if (inst && typeof inst.setSpeed === 'function') {
inst.setSpeed(rate);
}
});
}
function playAllMIDIPanes() {
instances.forEach(inst => {
if (inst && typeof inst.play === 'function') inst.play();
});
}
function pauseAllMIDIPanes() {
instances.forEach(inst => {
if (inst && typeof inst.pause === 'function') inst.pause();
});
}
function stopAllMIDIPanes() {
instances.forEach(inst => {
if (inst && typeof inst.stop === 'function') inst.stop();
});
}
function seekAllMIDIPanes(time) {
instances.forEach(inst => {
if (inst && typeof inst.seekTo === 'function') inst.seekTo(time);
});
}
function convertTrackName(trackName, filePath, visibleIndex) {
const fileName = filePath.split('/').pop().replace(/\.[^/.]+$/, '');
const cleaned = (trackName || '').replace(/[\r\n]+/g, ' ').trim();
if (cleaned === '') {
return `${fileName} / Track${visibleIndex + 1}`;
}
if (cleaned === 'Piano') return 'ピアノ';
return cleaned;
}
const DEFAULT_COLORS = [
'#FF595E', '#FFCA3A', '#8AC926', '#1982C4', '#6A4C93',
'#F564A9', '#4CC9F0', '#FF9F1C', '#00F5D4', '#E0B1CB'
];
class MIDIPlayer {
constructor(container, filePath, headerElement, offset = 0) {
this.container = container;
this.filePath = filePath;
this.headerElement = headerElement;
this.offset = offset;
this.speed = 1.0;
this.isPlaying = false;
this.totalDuration = 0;
this.originalDuration = 0;
this.trackEntries = [];
this.keyMap = {};
this.animationId = null;
this.playheadSeconds = 0;
this.lastFrameTime = 0;
this.settingsPanelVisible = false;
this.lastMasterTime = 0;
this.keyboardHeightRatio = 0.18;
this.isResizingKeyboard = false;
this.scrollY = 0;
this.isDragging = false;
this.dragStartY = 0;
this.dragStartScrollY = 0;
this.isMouseDown = false;
// ★ イベントハンドラをバインド(後で解除できるように)
this._onResizeBarMouseDown = this._onResizeBarMouseDown.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this._onWheel = this._onWheel.bind(this);
this.buildUI();
this.loadMIDI(filePath);
}
buildUI() {
const container = this.container;
container.innerHTML = '';
container.className = 'midi-container';
container.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;background:#0a0a0a;position:relative;';
// ★ ノート表示用キャンバスコンテナ(スクロール可能領域)
const canvasOuter = document.createElement('div');
canvasOuter.style.cssText = 'flex:1;position:relative;min-height:0;overflow:hidden;cursor:grab;';
canvasOuter.id = 'midi-canvas-outer';
const canvasContainer = document.createElement('div');
canvasContainer.style.cssText = 'position:relative;width:100%;height:100%;';
canvasContainer.id = 'midi-canvas-container';
this.canvas = document.createElement('canvas');
this.canvas.style.cssText = 'display:block;width:100%;height:100%;';
canvasContainer.appendChild(this.canvas);
canvasOuter.appendChild(canvasContainer);
container.appendChild(canvasOuter);
this.keyboardResizeBar = document.createElement('div');
this.keyboardResizeBar.style.cssText = `
height:6px;
cursor:ns-resize;
background:#444;
flex-shrink:0;
`;
container.appendChild(this.keyboardResizeBar);
// ★ 鍵盤は固定(canvasOuterの外)
this.keyboardDiv = document.createElement('div');
this.keyboardDiv.style.cssText = `
position:relative;
background:#000;
overflow:hidden;
flex-shrink:0;
`;
container.appendChild(this.keyboardDiv);
// ★ リサイズバーのイベント(一度だけ登録)
this.keyboardResizeBar.addEventListener('mousedown', this._onResizeBarMouseDown);
// 設定パネル
const paneElement = container.closest('.pane');
if (paneElement) {
const paneContainer = paneElement.parentElement;
this.settingsPanel = document.createElement('div');
this.settingsPanel.style.cssText = `
position: absolute;
top: ${paneElement.offsetTop + 32}px;
right: 10px;
background: #112240;
border: 1px solid #64ffda;
border-radius: 8px;
padding: 12px;
z-index: 100;
min-width: 280px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
display: none;
box-shadow: 0 4px 20px rgba(0,0,0,0.8);
pointer-events: auto;
`;
this.settingsPanel.innerHTML = `
🎵 トラック設定
`;
paneContainer.style.position = 'relative';
paneContainer.appendChild(this.settingsPanel);
}
if (this.headerElement) {
const existingBtn = this.headerElement.querySelector('.midi-settings-btn');
if (!existingBtn) {
const settingsBtn = document.createElement('button');
settingsBtn.className = 'midi-settings-btn';
settingsBtn.textContent = '⚙';
settingsBtn.style.cssText = `
background: rgba(100,255,218,0.1);
border: 1px solid rgba(100,255,218,0.3);
color: #e6f1ff;
border-radius: 3px;
padding: 2px 6px;
font-size: 14px;
cursor: pointer;
line-height: 1.4;
margin-left: auto;
flex-shrink: 0;
`;
settingsBtn.title = 'トラック設定';
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSettings();
});
const zoomBtn = this.headerElement.querySelector('.pane-zoom');
if (zoomBtn) {
this.headerElement.insertBefore(settingsBtn, zoomBtn);
} else {
this.headerElement.appendChild(settingsBtn);
}
this.settingsBtn = settingsBtn;
} else {
this.settingsBtn = existingBtn;
}
}
this.createKeyboard();
this._resizeObserver = new ResizeObserver(() => {
this.updateKeyboardHeight();
this.resizeCanvas();
});
this._resizeObserver.observe(canvasOuter);
// ★ マウスイベント(canvasOuter に対して設定)- バインド済みメソッドを使用
this.canvasOuter = canvasOuter;
this.canvasContainer = canvasContainer;
canvasOuter.addEventListener('mousedown', this._onMouseDown);
canvasOuter.addEventListener('mousemove', this._onMouseMove);
canvasOuter.addEventListener('mouseup', this._onMouseUp);
canvasOuter.addEventListener('mouseleave', this._onMouseUp);
canvasOuter.addEventListener('wheel', this._onWheel, { passive: false });
this.drawLoop();
document.addEventListener('click', (e) => {
if (this.settingsPanel && this.settingsPanelVisible) {
if (!this.settingsPanel.contains(e.target) && e.target !== this.settingsBtn) {
this.hideSettings();
}
}
});
this.updateKeyboardHeight();
}
// ★ リサイズバーのマウスダウン処理(クラスメソッドとして独立)
_onResizeBarMouseDown(e) {
e.preventDefault();
e.stopPropagation();
if (this.isResizingKeyboard) return;
this.isResizingKeyboard = true;
const startY = e.clientY;
const startRatio = this.keyboardHeightRatio;
const paneHeight = this.container.clientHeight;
// ★ 名前付き関数で mousemove / mouseup を定義
const onMouseMove = (ev) => {
const delta = startY - ev.clientY;
let newRatio = startRatio + delta / paneHeight;
newRatio = Math.max(0.08, Math.min(0.5, newRatio));
this.keyboardHeightRatio = newRatio;
this.updateKeyboardHeight();
this.resizeCanvas();
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
this.isResizingKeyboard = false;
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
updateKeyboardHeight() {
const paneHeight = this.container.clientHeight;
const keyboardHeight = Math.max(40, paneHeight * this.keyboardHeightRatio);
this.keyboardDiv.style.height = keyboardHeight + 'px';
void this.keyboardDiv.offsetHeight;
}
// ★ マウスイベントハンドラ(クラスメソッドとして定義)
_onMouseDown(e) {
if (e.button !== 0) return;
this.isDragging = true;
this.isMouseDown = true;
this.dragStartY = e.clientY;
this.dragStartScrollY = this.scrollY;
this.canvasOuter.style.cursor = 'grabbing';
e.preventDefault();
}
_onMouseMove(e) {
if (!this.isDragging) {
if (this.canvasOuter) {
this.canvasOuter.style.cursor = 'grab';
}
return;
}
const deltaY = (e.clientY - this.dragStartY) * 1.0;
this.scrollY = this.dragStartScrollY + deltaY;
}
_onMouseUp(e) {
if (this.isDragging) {
this.isDragging = false;
this.isMouseDown = false;
if (this.canvasOuter) {
this.canvasOuter.style.cursor = 'grab';
}
}
}
_onWheel(e) {
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
this.scrollY += e.deltaY * 0.8;
}
setOffset(newOffset) {
this.offset = newOffset;
}
toggleSettings() {
if (this.settingsPanelVisible) this.hideSettings();
else this.showSettings();
}
showSettings() {
if (!this.settingsPanel) return;
this.settingsPanel.style.display = 'block';
this.settingsPanelVisible = true;
this.renderTrackSettings();
this.updateSettingsPosition();
}
hideSettings() {
if (!this.settingsPanel) return;
this.settingsPanel.style.display = 'none';
this.settingsPanelVisible = false;
}
updateSettingsPosition() {
const paneElement = this.container.closest('.pane');
if (paneElement && this.settingsPanel) {
const paneRect = paneElement.getBoundingClientRect();
const containerRect = paneElement.parentElement.getBoundingClientRect();
const top = paneRect.top - containerRect.top + 32;
const right = containerRect.right - paneRect.right + 10;
this.settingsPanel.style.top = top + 'px';
this.settingsPanel.style.right = right + 'px';
}
}
createKeyboard() {
const keyboardDiv = this.keyboardDiv;
keyboardDiv.innerHTML = '';
this.keyMap = {};
const minNote = 21;
const maxNote = 95;
let whiteIndex = 0;
const numWhiteKeys = 44;
for (let i = minNote; i <= maxNote; i++) {
const isBlack = [1, 3, 6, 8, 10].includes(i % 12);
const el = document.createElement('div');
el.className = 'key ' + (isBlack ? 'black' : 'white');
el.style.cssText = `
position:absolute;
box-sizing:border-box;
border-radius:3px;
${isBlack ? `
background:#111;
box-shadow:inset 0 0 0 1px #000, inset 0 -4px 0 1px #000;
height:55%;
z-index:3;
` : `
background:#f5f5f5;
background-image:linear-gradient(to bottom, transparent, #fff, transparent);
border:1px solid rgba(0,0,0,0.1);
border-right-width:0;
border-bottom-width:2px;
height:100%;
z-index:1;
margin-left:-1px;
`}
`;
const origColor = isBlack ? '#111' : '#f5f5f5';
el.dataset.originalColor = origColor;
let leftPct, widthPct;
if (!isBlack) {
leftPct = (whiteIndex / numWhiteKeys) * 100;
widthPct = (1 / numWhiteKeys) * 100;
el.style.left = leftPct + '%';
el.style.width = widthPct + '%';
this.keyMap[i] = {
el,
left: leftPct,
width: widthPct,
black: false,
active: false
};
whiteIndex++;
} else {
leftPct = (whiteIndex / numWhiteKeys) * 100;
widthPct = ((2 / 3) / numWhiteKeys) * 100;
el.style.left = (leftPct - widthPct / 2) + '%';
el.style.width = widthPct + '%';
this.keyMap[i] = {
el,
left: leftPct - widthPct / 2,
width: widthPct,
black: true,
active: false
};
}
keyboardDiv.appendChild(el);
}
const lastWhite = keyboardDiv.querySelector('.key.white:last-child');
if (lastWhite) lastWhite.style.borderRightWidth = '1px';
}
renderTrackSettings() {
const container = this.settingsPanel?.querySelector('#midi-track-settings');
if (!container) return;
container.innerHTML = '';
if (this.trackEntries.length === 0) {
container.innerHTML = 'トラックがありません
';
return;
}
this.trackEntries.forEach((entry, index) => {
const row = document.createElement('div');
row.style.cssText = `
display:flex;
align-items:center;
gap:8px;
padding:4px 6px;
background:rgba(255,255,255,0.03);
border-radius:4px;
min-height:28px;
`;
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = entry.color;
colorInput.style.cssText = `
width:24px;
height:24px;
border:none;
padding:0;
cursor:pointer;
background:none;
flex-shrink:0;
`;
colorInput.addEventListener('input', () => {
entry.color = colorInput.value;
});
const nameSpan = document.createElement('span');
nameSpan.style.cssText = `
flex:1;
font-size:12px;
color:#e6f1ff;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
min-width:0;
`;
nameSpan.textContent = entry.trackName || `Track${index + 1}`;
const checkLabel = document.createElement('label');
checkLabel.style.cssText = `
display:flex;
align-items:center;
gap:4px;
font-size:12px;
color:#ccd6f6;
flex-shrink:0;
cursor:pointer;
`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = entry.visible;
checkbox.style.cssText = 'cursor:pointer;';
checkbox.addEventListener('change', () => {
entry.visible = checkbox.checked;
});
const checkText = document.createElement('span');
checkText.textContent = '表示';
checkLabel.appendChild(checkbox);
checkLabel.appendChild(checkText);
row.appendChild(colorInput);
row.appendChild(nameSpan);
row.appendChild(checkLabel);
container.appendChild(row);
});
}
async loadMIDI(filePath) {
try {
const response = await fetch(filePath);
if (!response.ok) throw new Error('MIDI file not found: ' + filePath);
const arrayBuffer = await response.arrayBuffer();
this.disposePlayback();
const parsedMidi = new Midi(arrayBuffer);
this.parsedMidi = parsedMidi;
this.trackEntries = [];
let maxTime = 0;
parsedMidi.tracks.forEach(t => {
t.notes.forEach(n => {
const end = n.time + (n.duration || 0);
if (end > maxTime) maxTime = end;
});
});
this.originalDuration = maxTime;
this.totalDuration = maxTime;
let globalTrackIndex = 0;
let visibleTrackIndex = 0;
parsedMidi.tracks.forEach((track, idx) => {
if (track.notes.length === 0) return;
this.normalizeTrack(track);
const color = DEFAULT_COLORS[globalTrackIndex % DEFAULT_COLORS.length];
globalTrackIndex++;
const notes = track.notes.map(n => ({
time: n.time,
name: n.name,
duration: Math.max(n.duration || 0, 0.02),
velocity: n.velocity || 0.8,
midi: n.midi
}));
const entry = {
fileName: filePath.split('/').pop(),
trackName: convertTrackName(track.name, filePath, visibleTrackIndex),
color,
track,
notes,
visible: (convertTrackName(track.name, filePath, visibleTrackIndex) === 'ピアノ')
};
visibleTrackIndex++;
this.trackEntries.push(entry);
});
this.clearKeys();
this.playheadSeconds = 0;
this.scrollY = 0;
this.isPlaying = false;
this.resizeCanvas();
this.renderTrackSettings();
} catch (err) {
console.error('MIDI load error:', err);
this.container.innerHTML = `MIDI読み込みエラー: ${err.message}
`;
}
}
normalizeTrack(track) {
const sortedNotes = [...track.notes].sort((a, b) => a.time - b.time);
const noteMap = new Map();
sortedNotes.forEach(note => {
const key = `${note.midi}_${Math.floor(note.time * 1000) / 1000}`;
if (noteMap.has(key)) {
const existing = noteMap.get(key);
if (note.duration > existing.duration) noteMap.set(key, note);
} else {
noteMap.set(key, note);
}
});
const MAX_DURATION = 10;
const limitedNotes = [];
noteMap.forEach(note => {
if (note.duration > MAX_DURATION) {
limitedNotes.push({
...note,
duration: MAX_DURATION
});
} else {
limitedNotes.push(note);
}
});
limitedNotes.sort((a, b) => a.time - b.time);
track.notes = limitedNotes;
}
clearKeys() {
Object.values(this.keyMap).forEach(k => {
k.active = false;
const orig = k.el.dataset.originalColor || (k.black ? '#111' : '#f5f5f5');
k.el.style.backgroundColor = orig;
k.el.classList.remove('active');
});
}
setSpeed(rate) {
this.speed = rate;
}
play() {
if (!this.parsedMidi || this.trackEntries.length === 0) return;
if (this.isPlaying) return;
this.isPlaying = true;
this.lastFrameTime = performance.now();
this.scrollY = 0;
if (this.offset > 0 && this.playheadSeconds === 0) {
const remainingDelay = Math.max(0, this.offset - (this.lastMasterTime || 0)) * 1000;
this.playStartDelayUntil = performance.now() + remainingDelay;
} else {
this.playStartDelayUntil = 0;
}
}
pause() {
this.isPlaying = false;
}
stop() {
this.isPlaying = false;
this.playheadSeconds = 0;
this.scrollY = 0;
this.clearKeys();
}
getCurrentTime() {
return this.playheadSeconds + this.offset;
}
seekTo(masterTime) {
this.lastMasterTime = masterTime;
let internalTime = masterTime - this.offset;
if (internalTime < 0) internalTime = 0;
const clamped = Math.min(internalTime, this.originalDuration);
this.playheadSeconds = clamped;
this.clearKeys();
}
resizeCanvas() {
const rect = this.canvasOuter.getBoundingClientRect();
const w = rect.width || 300;
const h = rect.height || 200;
this.canvas.width = Math.max(100, w * 2);
this.canvas.height = Math.max(100, h * 2);
this.canvas.style.width = w + 'px';
this.canvas.style.height = h + 'px';
}
drawLoop() {
this.animationId = requestAnimationFrame(() => this.drawLoop());
if (this.isPlaying) {
const now = performance.now();
if (this.playStartDelayUntil && now < this.playStartDelayUntil) {
this.lastFrameTime = now;
this.draw();
return;
}
if (this.playStartDelayUntil && now >= this.playStartDelayUntil) {
this.playStartDelayUntil = 0;
}
if (this.lastFrameTime) {
const delta = (now - this.lastFrameTime) / 1000;
this.playheadSeconds += delta * this.speed;
if (this.totalDuration > 0 && this.playheadSeconds >= this.totalDuration) {
this.playheadSeconds = this.totalDuration;
this.isPlaying = false;
}
}
this.lastFrameTime = now;
} else {
this.lastFrameTime = performance.now();
}
this.draw();
}
draw() {
const canvas = this.canvas;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (this.trackEntries.length === 0) {
ctx.fillStyle = '#555';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('MIDI読み込み中...', W / 2, H / 2);
return;
}
const now = this.playheadSeconds;
const SPEED = 250;
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let i = 0; i < 44; i++) {
const x = (i / 44) * W;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
ctx.stroke();
}
this.trackEntries.forEach(entry => {
if (!entry.visible) return;
const notes = entry.notes || [];
const color = entry.color;
notes.forEach(note => {
const midi = note.midi;
const keyData = this.keyMap[midi];
if (!keyData) return;
const duration = Math.max(note.duration || 0, 0.02);
const yBottom = H - (note.time - now) * SPEED + this.scrollY;
const yTop = yBottom - (duration * SPEED);
if (yBottom <= -10 || yTop >= H + 10) return;
if (now >= note.time && now < note.time + duration) {
const kd = this.keyMap[midi];
if (kd) {
kd.active = true;
kd.el.style.backgroundColor = color;
kd.el.classList.add('active');
}
}
const x = (keyData.left / 100) * W;
const w = (keyData.width / 100) * W;
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fillRect(x + 1, yTop, Math.max(w - 2, 1), Math.max(yBottom - yTop, 2));
ctx.globalAlpha = 1.0;
});
});
Object.values(this.keyMap).forEach(k => {
if (!k.active) {
const orig = k.el.dataset.originalColor || (k.black ? '#111' : '#f5f5f5');
k.el.style.backgroundColor = orig;
k.el.classList.remove('active');
}
k.active = false;
});
ctx.strokeStyle = 'rgba(100,255,218,0.4)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 8]);
const lineY = H / 2;
ctx.beginPath();
ctx.moveTo(0, lineY);
ctx.lineTo(W, lineY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
const displayTime = (now + this.offset).toFixed(1);
ctx.fillText(`⏱ ${displayTime}s`, 10, 20);
}
disposePlayback() {
this.clearKeys();
}
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
this.disposePlayback();
// ★ イベントリスナーを解除
if (this.keyboardResizeBar) {
this.keyboardResizeBar.removeEventListener('mousedown', this._onResizeBarMouseDown);
}
if (this.canvasOuter) {
this.canvasOuter.removeEventListener('mousedown', this._onMouseDown);
this.canvasOuter.removeEventListener('mousemove', this._onMouseMove);
this.canvasOuter.removeEventListener('mouseup', this._onMouseUp);
this.canvasOuter.removeEventListener('mouseleave', this._onMouseUp);
this.canvasOuter.removeEventListener('wheel', this._onWheel);
}
if (this.settingsPanel && this.settingsPanel.parentNode) {
this.settingsPanel.parentNode.removeChild(this.settingsPanel);
}
if (this.settingsBtn && this.settingsBtn.parentNode) {
this.settingsBtn.parentNode.removeChild(this.settingsBtn);
}
if (this.container) {
this.container.innerHTML = '';
}
if (instances.has(this.container)) {
instances.delete(this.container);
}
}
}
})();