// 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); } } } })();