You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
565 lines
15 KiB
565 lines
15 KiB
var DELAY = 0; // play one note every quarter second |
|
var VELOCITY = 127; // how hard the note hits |
|
var ACTIVE_FEEDBACK_DURATION = 200; // milliseconds |
|
var NOTE_DURATION = 750; // milliseconds - how long a note plays |
|
var NOTE_OFF_DELAY = 0.75; // seconds - delay before note stops |
|
|
|
var pianoElement = null; |
|
var keyElementsMap = new Map(); // Maps note number to DOM element |
|
var activeNotes = new Set(); // Track currently playing notes to prevent duplicates |
|
var activeTimeouts = new Map(); // Track timeouts for cleanup |
|
|
|
var midiAccess = null; |
|
var midiInputs = new Map(); // Track connected MIDI input devices |
|
var midiDeviceStatus = { |
|
supported: false, |
|
connected: false, |
|
deviceCount: 0 |
|
}; |
|
|
|
/** |
|
* Validate MIDI note number |
|
* @param {number} note - The MIDI note number |
|
* @returns {boolean} True if valid |
|
*/ |
|
function isValidNote(note) { |
|
return !isNaN(note) && note >= 0 && note <= 127; |
|
} |
|
|
|
/** |
|
* Clear a timeout and remove it from tracking |
|
* @param {number} note - The note associated with the timeout |
|
*/ |
|
function clearNoteTimeout(note) { |
|
if (activeTimeouts.has(note)) { |
|
clearTimeout(activeTimeouts.get(note)); |
|
activeTimeouts.delete(note); |
|
} |
|
} |
|
|
|
/** |
|
* Initialize the piano - cache DOM elements and set up event handlers |
|
*/ |
|
function initializePiano() { |
|
pianoElement = document.getElementById('piano'); |
|
|
|
if (!pianoElement) { |
|
console.error('Piano element not found'); |
|
return; |
|
} |
|
|
|
// Cache all key elements in a Map for O(1) lookup |
|
var allKeys = document.querySelectorAll('[data-note]'); |
|
allKeys.forEach(function(keyElement) { |
|
var note = parseInt(keyElement.dataset.note, 10); |
|
if (isValidNote(note)) { |
|
keyElementsMap.set(note, keyElement); |
|
} |
|
}); |
|
|
|
// Set up event handlers |
|
assignHandlers(); |
|
} |
|
|
|
/** |
|
* Get the note number from a clicked/touched element |
|
* @param {HTMLElement} target - The clicked/touched element |
|
* @returns {number|null} The MIDI note number or null |
|
*/ |
|
function getNoteFromElement(target) { |
|
if (!target || !target.dataset) { |
|
return null; |
|
} |
|
|
|
// Check if clicked element has data-note |
|
if (target.dataset.note) { |
|
return parseInt(target.dataset.note, 10); |
|
} |
|
|
|
// Check if clicked element is inside an anchor or black_key |
|
var anchor = target.closest('.anchor, .black_key'); |
|
if (anchor && anchor.dataset && anchor.dataset.note) { |
|
return parseInt(anchor.dataset.note, 10); |
|
} |
|
|
|
// Check parent li for anchor or black_key |
|
var li = target.closest('li'); |
|
if (li) { |
|
var keyElement = li.querySelector('.anchor, .black_key'); |
|
if (keyElement && keyElement.dataset && keyElement.dataset.note) { |
|
return parseInt(keyElement.dataset.note, 10); |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Add visual feedback to a key element (CSS transitions handle the animation) |
|
* @param {HTMLElement} keyElement - The key element to highlight |
|
*/ |
|
function addKeyFeedback(keyElement) { |
|
if (keyElement) { |
|
keyElement.classList.add('active'); |
|
// CSS transition handles the visual feedback |
|
// Remove class after duration for cleanup |
|
setTimeout(function() { |
|
if (keyElement) { |
|
keyElement.classList.remove('active'); |
|
} |
|
}, ACTIVE_FEEDBACK_DURATION); |
|
} |
|
} |
|
|
|
/** |
|
* Play a note with proper cleanup |
|
* @param {number} note - The MIDI note number |
|
* @param {number} velocity - The velocity (0-127), defaults to VELOCITY constant |
|
*/ |
|
function playNoteInternal(note, velocity) { |
|
if (!isValidNote(note)) { |
|
console.warn('Invalid MIDI note:', note); |
|
return; |
|
} |
|
|
|
// Use provided velocity or default |
|
var noteVelocity = (velocity !== undefined && velocity >= 0 && velocity <= 127) ? velocity : VELOCITY; |
|
|
|
// Cancel any pending noteOff timeout for this note |
|
clearNoteTimeout(note); |
|
|
|
// Stop any currently playing note of the same pitch immediately |
|
// This ensures rapid repeats stop the previous note and start fresh |
|
MIDI.noteOff(0, note, 0); |
|
|
|
// Start the new note |
|
MIDI.setVolume(0, 127); |
|
MIDI.noteOn(0, note, noteVelocity, DELAY); |
|
|
|
// Schedule noteOff for this note |
|
var timeoutId = setTimeout(function() { |
|
MIDI.noteOff(0, note, 0); |
|
activeNotes.delete(note); |
|
activeTimeouts.delete(note); |
|
}, (DELAY + NOTE_OFF_DELAY) * 1000); |
|
|
|
activeTimeouts.set(note, timeoutId); |
|
} |
|
|
|
/** |
|
* Handle piano key clicks |
|
* @param {Event} event - The click event |
|
*/ |
|
function handlePianoClick(event) { |
|
var note = getNoteFromElement(event.target); |
|
|
|
if (note && isValidNote(note)) { |
|
activeNotes.delete(note); |
|
activeNotes.add(note); |
|
|
|
var keyElement = keyElementsMap.get(note); |
|
addKeyFeedback(keyElement); |
|
|
|
playNoteInternal(note); |
|
} |
|
} |
|
|
|
/** |
|
* Handle touch events for mobile devices |
|
* @param {TouchEvent} event - The touch event |
|
*/ |
|
function handleTouchStart(event) { |
|
event.preventDefault(); // Prevent scrolling |
|
var touch = event.touches[0] || event.changedTouches[0]; |
|
if (touch) { |
|
var target = document.elementFromPoint(touch.clientX, touch.clientY); |
|
var note = getNoteFromElement(target); |
|
|
|
if (note && isValidNote(note)) { |
|
activeNotes.delete(note); |
|
activeNotes.add(note); |
|
|
|
// Get cached key element |
|
var keyElement = keyElementsMap.get(note); |
|
addKeyFeedback(keyElement); |
|
|
|
playNoteInternal(note); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* @method assignHandlers creates the click and touch event handlers when the font is loaded |
|
*/ |
|
function assignHandlers() { |
|
if (!pianoElement) { |
|
console.error('Piano element not found'); |
|
return; |
|
} |
|
|
|
pianoElement.addEventListener('click', handlePianoClick); |
|
|
|
pianoElement.addEventListener('touchstart', handleTouchStart, { passive: false }); |
|
} |
|
|
|
|
|
/** |
|
* @method playNote plays a single note (public API). |
|
* @param {number} note - The MIDI note number (0-127) |
|
*/ |
|
function playNote(note) { |
|
if (!isValidNote(note)) { |
|
console.warn('Invalid MIDI note:', note); |
|
return; |
|
} |
|
|
|
var keyElement = keyElementsMap.get(note); |
|
if (keyElement) { |
|
addKeyFeedback(keyElement); |
|
} |
|
|
|
activeNotes.delete(note); |
|
activeNotes.add(note); |
|
playNoteInternal(note); |
|
} |
|
|
|
/** |
|
* @method multinotes plays multiple notes simultaneously (for chords). |
|
* @param {number} root - The root note |
|
* @param {number} third - The third note |
|
* @param {number} fifth - The fifth note |
|
*/ |
|
function multinotes(root, third, fifth) { |
|
if (!isValidNote(root) || !isValidNote(third) || !isValidNote(fifth)) { |
|
console.warn('Invalid MIDI note in chord:', root, third, fifth); |
|
return; |
|
} |
|
|
|
MIDI.noteOn(0, root, VELOCITY, DELAY); |
|
MIDI.noteOn(0, third, VELOCITY, DELAY); |
|
MIDI.noteOn(0, fifth, VELOCITY, DELAY); |
|
} |
|
|
|
/** |
|
* @method playRootMajor plays the major chord in the root position |
|
* @param {number} note |
|
*/ |
|
function playRootMajor(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note; |
|
var third = note + 4; |
|
var fifth = note + 7; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playFirstMajorInversion plays the major chord in the first inversion |
|
* @param {number} note |
|
*/ |
|
function playFirstMajorInversion(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note + 4; |
|
var third = root + 3; |
|
var fifth = root + 5; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playSecondMajorInversion plays the major chord in the second inversion |
|
* @param {number} note |
|
*/ |
|
function playSecondMajorInversion(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note + 7; |
|
var third = root + 5; |
|
var fifth = root + 4; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playRootMinor plays the minor chord in the root position |
|
* @param {number} note |
|
*/ |
|
function playRootMinor(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note; |
|
var third = note + 3; |
|
var fifth = note + 7; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playFirstMinorInversion plays the minor chord in the 1st inversion |
|
* @param {number} note |
|
*/ |
|
function playFirstMinorInversion(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note + 3; |
|
var third = root + 4; |
|
var fifth = root + 5; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playSecondMinorInversion plays the minor chord in the 2nd inversion |
|
* @param {number} note |
|
*/ |
|
function playSecondMinorInversion(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note + 7; |
|
var third = root + 5; |
|
var fifth = root + 3; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playAugmented plays an augmented chord |
|
* @param {number} note |
|
*/ |
|
function playAugmented(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note; |
|
var third = note + 4; |
|
var fifth = root + 8; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playDiminished plays a diminished chord |
|
* @param {number} note |
|
*/ |
|
function playDiminished(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note; |
|
var third = note + 3; |
|
var fifth = note + 6; // Fixed: was note + 3, should be note + 6 for diminished fifth |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* @method playSuspended plays a suspended chord |
|
* @param {number} note |
|
*/ |
|
function playSuspended(note) { |
|
if (!isValidNote(note)) return; |
|
var root = note; |
|
var third = note + 5; |
|
var fifth = note + 7; |
|
multinotes(root, third, fifth); |
|
} |
|
|
|
/** |
|
* Show error message to user |
|
* @param {string} message - Error message to display |
|
*/ |
|
function showError(message) { |
|
console.error(message); |
|
// You could add a visual error message here |
|
// var errorDiv = document.createElement('div'); |
|
// errorDiv.className = 'error-message'; |
|
// errorDiv.textContent = message; |
|
// document.body.appendChild(errorDiv); |
|
} |
|
|
|
/** |
|
* Update MIDI device status indicator |
|
*/ |
|
function updateMIDIStatus() { |
|
var statusElement = document.getElementById('midi-status'); |
|
if (!statusElement) { |
|
// Create status element if it doesn't exist |
|
statusElement = document.createElement('div'); |
|
statusElement.id = 'midi-status'; |
|
statusElement.style.cssText = 'position: fixed; top: 10px; right: 10px; padding: 8px 12px; background: rgba(0,0,0,0.7); color: white; border-radius: 4px; font-size: 12px; z-index: 10000; font-family: Arial, sans-serif;'; |
|
document.body.appendChild(statusElement); |
|
} |
|
|
|
if (!midiDeviceStatus.supported) { |
|
statusElement.textContent = 'MIDI: Not supported'; |
|
statusElement.style.background = 'rgba(200,0,0,0.7)'; |
|
} else if (midiDeviceStatus.connected && midiDeviceStatus.deviceCount > 0) { |
|
statusElement.textContent = 'MIDI: ' + midiDeviceStatus.deviceCount + ' device(s) connected'; |
|
statusElement.style.background = 'rgba(0,150,0,0.7)'; |
|
} else { |
|
statusElement.textContent = 'MIDI: No devices connected'; |
|
statusElement.style.background = 'rgba(150,150,0,0.7)'; |
|
} |
|
} |
|
|
|
/** |
|
* Handle MIDI message from physical keyboard |
|
* @param {MIDIMessageEvent} event - MIDI message event |
|
*/ |
|
function handleMIDIMessage(event) { |
|
var data = event.data; |
|
var command = data[0] & 0xf0; // Upper nibble is command |
|
var channel = data[0] & 0x0f; // Lower nibble is channel (we ignore for now) |
|
var note = data[1]; |
|
var velocity = data[2]; |
|
|
|
// Note On (0x90) or Note Off (0x80) |
|
if (command === 0x90 && velocity > 0) { |
|
// Note On |
|
if (isValidNote(note)) { |
|
// Remove from activeNotes so we can play the same note again |
|
// This allows rapid repeats - each press stops the previous and starts new |
|
activeNotes.delete(note); |
|
activeNotes.add(note); |
|
|
|
// Get cached key element for visual feedback |
|
var keyElement = keyElementsMap.get(note); |
|
if (keyElement) { |
|
addKeyFeedback(keyElement); |
|
} |
|
|
|
// Play note with velocity from MIDI keyboard |
|
playNoteInternal(note, velocity); |
|
} |
|
} else if (command === 0x80 || (command === 0x90 && velocity === 0)) { |
|
// Note Off |
|
if (isValidNote(note)) { |
|
// Remove from active notes |
|
activeNotes.delete(note); |
|
clearNoteTimeout(note); |
|
|
|
// Remove visual feedback |
|
var keyElement = keyElementsMap.get(note); |
|
if (keyElement) { |
|
keyElement.classList.remove('active'); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Handle MIDI input device connection |
|
* @param {MIDIInput} input - MIDI input device |
|
*/ |
|
function handleMIDIInputConnected(input) { |
|
console.log('MIDI input connected:', input.name, input.manufacturer); |
|
|
|
// Set up message handler |
|
input.onmidimessage = handleMIDIMessage; |
|
|
|
// Track the input |
|
midiInputs.set(input.id, input); |
|
|
|
// Update status |
|
midiDeviceStatus.connected = true; |
|
midiDeviceStatus.deviceCount = midiInputs.size; |
|
updateMIDIStatus(); |
|
} |
|
|
|
/** |
|
* Handle MIDI input device disconnection |
|
* @param {MIDIInput} input - MIDI input device |
|
*/ |
|
function handleMIDIInputDisconnected(input) { |
|
console.log('MIDI input disconnected:', input.name); |
|
|
|
// Remove message handler |
|
input.onmidimessage = null; |
|
|
|
// Remove from tracking |
|
midiInputs.delete(input.id); |
|
|
|
// Update status |
|
midiDeviceStatus.connected = midiInputs.size > 0; |
|
midiDeviceStatus.deviceCount = midiInputs.size; |
|
updateMIDIStatus(); |
|
} |
|
|
|
/** |
|
* Initialize Web MIDI API support |
|
*/ |
|
function initializeMIDI() { |
|
// Check if Web MIDI API is supported |
|
if (!navigator.requestMIDIAccess) { |
|
console.warn('Web MIDI API is not supported in this browser'); |
|
midiDeviceStatus.supported = false; |
|
updateMIDIStatus(); |
|
return; |
|
} |
|
|
|
midiDeviceStatus.supported = true; |
|
|
|
// Request MIDI access |
|
navigator.requestMIDIAccess({ sysex: false }) |
|
.then(function(access) { |
|
midiAccess = access; |
|
|
|
// Handle state changes (devices connecting/disconnecting) |
|
access.onstatechange = function(event) { |
|
if (event.port.state === 'connected' && event.port.type === 'input') { |
|
handleMIDIInputConnected(event.port); |
|
} else if (event.port.state === 'disconnected' && event.port.type === 'input') { |
|
handleMIDIInputDisconnected(event.port); |
|
} |
|
}; |
|
|
|
// Connect to existing inputs |
|
var inputs = access.inputs.values(); |
|
for (var input = inputs.next(); input && !input.done; input = inputs.next()) { |
|
if (input.value.state === 'connected') { |
|
handleMIDIInputConnected(input.value); |
|
} |
|
} |
|
|
|
updateMIDIStatus(); |
|
}) |
|
.catch(function(error) { |
|
console.error('Error accessing MIDI devices:', error); |
|
showError('Failed to access MIDI devices: ' + error.message); |
|
midiDeviceStatus.supported = false; |
|
updateMIDIStatus(); |
|
}); |
|
} |
|
|
|
/** |
|
* Initialize when DOM is ready |
|
*/ |
|
document.addEventListener('DOMContentLoaded', function() { |
|
// Initialize Web MIDI API for physical keyboard support |
|
initializeMIDI(); |
|
|
|
try { |
|
MIDI.loadPlugin({ |
|
soundfontUrl: "./soundfont/", |
|
instrument: "acoustic_grand_piano", |
|
callback: function() { |
|
// Trigger custom event to know when the plugin is loaded |
|
window.dispatchEvent(new Event('ready')); |
|
}, |
|
onerror: function(error) { |
|
showError('Failed to load MIDI plugin: ' + (error || 'Unknown error')); |
|
} |
|
}); |
|
} catch (error) { |
|
showError('Error initializing MIDI: ' + error.message); |
|
} |
|
}); |
|
|
|
// Set up handlers when MIDI plugin is ready |
|
window.addEventListener('ready', function() { |
|
try { |
|
initializePiano(); |
|
} catch (error) { |
|
showError('Error initializing piano: ' + error.message); |
|
} |
|
}); |
|
|
|
// Cleanup on page unload |
|
window.addEventListener('beforeunload', function() { |
|
// Clear all timeouts |
|
activeTimeouts.forEach(function(timeoutId) { |
|
clearTimeout(timeoutId); |
|
}); |
|
activeTimeouts.clear(); |
|
activeNotes.clear(); |
|
|
|
// Disconnect MIDI inputs |
|
midiInputs.forEach(function(input) { |
|
input.onmidimessage = null; |
|
}); |
|
midiInputs.clear(); |
|
});
|
|
|