| |
| |
|
|
| (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); |
|
|
| |
| 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 = ` |
| <div style="color:#e6f1ff;font-size:13px;font-weight:bold;margin-bottom:8px;border-bottom:1px solid rgba(100,255,218,0.2);padding-bottom:4px;"> |
| 🎵 トラック設定 |
| </div> |
| <div id="midi-track-settings" style="display:flex;flex-direction:column;gap:6px;"></div> |
| `; |
| 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); |
|
|
| |
| 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; |
|
|
| |
| 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 = '<div style="color:#888;font-size:12px;text-align:center;">トラックがありません</div>'; |
| 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 = `<div style="color:#ff6b6b;padding:20px;text-align:center;">MIDI読み込みエラー: ${err.message}</div>`; |
| } |
| } |
|
|
| 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); |
| } |
| } |
| } |
| })(); |