// ==UserScript== // @name MP3 to Piano for MPP // @namespace butter.lot // @version 1.0.1 // @description Play piano with MP3 file harmonics on Multiplayer Piano // @author MrButtersLot // @license Beerware // @match *://multiplayerpiano.net/* // @grant none // @downloadURL none // ==/UserScript== // "THE BEER-WARE LICENSE" (Revision 42): // As long as you retain this notice you can do whatever you want with this stuff. // If we meet some day, and you think this stuff is worth it, you can buy me a beer in return. (function() { 'use strict'; // ============= AUDIO ANALYSIS ============= const PIANO_MIN_MIDI = 21; // A0 const PIANO_MAX_MIDI = 108; // C8 function frequencyToMidi(frequency) { return Math.round(12 * Math.log2(frequency / 440) + 69); } function midiToNoteName(midi) { const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const octave = Math.floor(midi / 12) - 1; const note = notes[midi % 12]; return `${note}${octave}`; } function detectHarmonics(frequencyData, sampleRate, fftSize, threshold = 30) { const harmonics = []; const binWidth = sampleRate / fftSize; let maxMagnitude = 0; for (let i = 0; i < frequencyData.length; i++) { if (frequencyData[i] > maxMagnitude) maxMagnitude = frequencyData[i]; } if (maxMagnitude < threshold) return []; for (let i = 2; i < frequencyData.length / 2; i++) { const magnitude = frequencyData[i]; const dynamicThreshold = Math.max(threshold, maxMagnitude * 0.25); if (magnitude > dynamicThreshold) { const frequency = i * binWidth; if (frequency >= 50 && frequency <= 4000) { const midi = frequencyToMidi(frequency); if (midi >= PIANO_MIN_MIDI && midi <= PIANO_MAX_MIDI) { harmonics.push({ frequency, magnitude, midi, noteName: midiToNoteName(midi) }); } } } } return harmonics.sort((a, b) => b.magnitude - a.magnitude); } function analyzeAudio(analyser, maxHarmonics = 8, sensitivity = 30) { const frequencyData = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(frequencyData); let sum = 0; for (let i = 0; i < frequencyData.length; i++) { sum += frequencyData[i]; } const avgLevel = sum / frequencyData.length; const harmonics = detectHarmonics( frequencyData, analyser.context.sampleRate, analyser.fftSize, sensitivity ); return { harmonics: harmonics.slice(0, maxHarmonics), audioLevel: avgLevel }; } // ============= MP3 TO PIANO ENGINE ============= class MP3ToPiano { constructor() { this.isPlaying = false; this.audioContext = null; this.analyser = null; this.audioSource = null; this.audioBuffer = null; this.animationFrame = null; this.activeNotes = new Map(); this.sensitivity = 30; this.maxHarmonics = 12; this.onHarmonicsUpdate = null; this.onAudioLevelUpdate = null; this.onPlaybackUpdate = null; this.startTime = 0; this.pauseTime = 0; } async loadMP3(file) { try { const arrayBuffer = await file.arrayBuffer(); this.audioContext = new AudioContext(); this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); return true; } catch (error) { console.error('Error loading MP3:', error); return false; } } start() { if (!this.audioBuffer || !this.audioContext) return false; try { this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 8192; this.analyser.smoothingTimeConstant = 0.6; this.analyser.minDecibels = -80; this.analyser.maxDecibels = -10; this.audioSource = this.audioContext.createBufferSource(); this.audioSource.buffer = this.audioBuffer; this.audioSource.connect(this.analyser); // Don't connect to destination - we only want piano output, not original audio this.audioSource.onended = () => { this.stop(); if (this.onPlaybackUpdate) { this.onPlaybackUpdate('ended'); } }; const offset = this.pauseTime || 0; this.audioSource.start(0, offset); this.startTime = this.audioContext.currentTime - offset; this.isPlaying = true; this.startAnalysis(); return true; } catch (error) { console.error('Error starting playback:', error); return false; } } pause() { if (!this.isPlaying) return; this.pauseTime = this.audioContext.currentTime - this.startTime; this.stop(); } stop() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } if (this.audioSource) { try { this.audioSource.stop(); } catch (e) { // Already stopped } this.audioSource.disconnect(); this.audioSource = null; } this.activeNotes.forEach(({ key }) => { if (window.MPP && window.MPP.release) { MPP.release(key); } }); this.activeNotes.clear(); this.isPlaying = false; } reset() { this.stop(); this.pauseTime = 0; this.startTime = 0; } getCurrentTime() { if (!this.audioContext) return 0; if (this.isPlaying) { return this.audioContext.currentTime - this.startTime; } return this.pauseTime; } getDuration() { return this.audioBuffer ? this.audioBuffer.duration : 0; } startAnalysis() { const analyze = () => { if (!this.analyser || !this.isPlaying) return; const result = analyzeAudio(this.analyser, this.maxHarmonics, this.sensitivity); const filteredHarmonics = result.harmonics; if (this.onAudioLevelUpdate) { this.onAudioLevelUpdate(result.audioLevel); } if (this.onHarmonicsUpdate) { this.onHarmonicsUpdate(filteredHarmonics, this.activeNotes.size); } if (this.onPlaybackUpdate) { const currentTime = this.getCurrentTime(); const duration = this.getDuration(); this.onPlaybackUpdate('playing', currentTime, duration); } const newActiveNotes = new Map(); filteredHarmonics.forEach(harmonic => { const key = Object.keys(MPP.piano.keys)[harmonic.midi - 21]; if (key) { let volume = harmonic.magnitude / 255; volume = Math.min(Math.max(volume, 0.1), 1); newActiveNotes.set(harmonic.midi, { key, volume }); } }); this.activeNotes.forEach(({ key }, midi) => { if (!newActiveNotes.has(midi)) { if (window.MPP && window.MPP.release) { MPP.release(key); } } }); newActiveNotes.forEach(({ key, volume }, midi) => { if (window.MPP && window.MPP.press) { MPP.press(key, volume); } }); this.activeNotes = newActiveNotes; this.animationFrame = requestAnimationFrame(analyze); }; analyze(); } } // ============= UI STYLES ============= const styles = ` .voice-piano-window { position: fixed; top: 80px; left: 20px; width: 400px; background: #2d2d2d; border: 2px solid #8b5cf6; border-radius: 8px; box-shadow: 0 5px 20px rgba(139, 92, 246, 0.3); color: #eee; font-family: sans-serif; font-size: 14px; z-index: 850; display: none; } .voice-piano-window.visible { display: block; } .voice-piano-header { padding: 10px 12px; background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); cursor: move; border-top-left-radius: 6px; border-top-right-radius: 6px; border-bottom: 1px solid #7c3aed; user-select: none; font-weight: 600; display: flex; align-items: center; gap: 8px; } .voice-piano-header svg { width: 18px; height: 18px; fill: currentColor; } .voice-piano-content { padding: 16px; display: flex; flex-direction: column; gap: 16px; } .voice-piano-file-input { display: none; } .voice-piano-file-label { background: #8b5cf6; border: 1px solid #7c3aed; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 8px; transition: all 0.2s; justify-content: center; text-align: center; } .voice-piano-file-label:hover { background: #7c3aed; box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4); } .voice-piano-file-label svg { width: 16px; height: 16px; fill: currentColor; } .voice-piano-file-name { padding: 8px 12px; background: #222; border: 1px solid #444; border-radius: 6px; font-size: 12px; color: #999; text-align: center; font-style: italic; } .voice-piano-controls { display: flex; gap: 8px; align-items: center; justify-content: center; } .voice-piano-btn { background: #8b5cf6; border: 1px solid #7c3aed; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 8px; transition: all 0.2s; flex: 1; justify-content: center; } .voice-piano-btn:hover { background: #7c3aed; box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4); } .voice-piano-btn:disabled { background: #555; border-color: #444; cursor: not-allowed; opacity: 0.5; } .voice-piano-btn.playing { background: #dc2626; border-color: #b91c1c; } .voice-piano-btn.playing:hover:not(:disabled) { background: #b91c1c; } .voice-piano-btn svg { width: 16px; height: 16px; fill: currentColor; } .voice-piano-playback { background: #222; border: 1px solid #444; border-radius: 6px; padding: 12px; } .voice-piano-time { display: flex; justify-content: space-between; font-size: 12px; color: #999; margin-bottom: 8px; } .voice-piano-progress { height: 6px; background: #333; border-radius: 3px; overflow: hidden; } .voice-piano-progress-bar { height: 100%; background: linear-gradient(90deg, #8b5cf6, #6d28d9); transition: width 0.1s; } .voice-piano-slider-group { display: flex; flex-direction: column; gap: 8px; } .voice-piano-slider-label { display: flex; justify-content: space-between; font-size: 13px; color: #ccc; } .voice-piano-slider { width: 100%; height: 6px; border-radius: 3px; background: #444; outline: none; -webkit-appearance: none; } .voice-piano-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #8b5cf6; cursor: pointer; transition: all 0.2s; } .voice-piano-slider::-webkit-slider-thumb:hover { background: #7c3aed; box-shadow: 0 0 8px rgba(139, 92, 246, 0.6); } .voice-piano-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: #8b5cf6; cursor: pointer; border: none; transition: all 0.2s; } .voice-piano-harmonics { max-height: 240px; overflow-y: auto; background: #222; border: 1px solid #444; border-radius: 6px; padding: 8px; } .voice-piano-harmonics::-webkit-scrollbar { width: 8px; } .voice-piano-harmonics::-webkit-scrollbar-track { background: #333; border-radius: 4px; } .voice-piano-harmonics::-webkit-scrollbar-thumb { background: #8b5cf6; border-radius: 4px; } .voice-piano-harmonic { display: flex; justify-content: space-between; align-items: center; padding: 8px; margin-bottom: 6px; background: #2d2d2d; border: 1px solid #444; border-radius: 4px; font-size: 12px; } .voice-piano-harmonic-left { display: flex; align-items: center; gap: 10px; } .voice-piano-pulse { width: 6px; height: 6px; border-radius: 50%; background: #8b5cf6; animation: pulse 1s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.8); } } .voice-piano-note { font-family: monospace; font-weight: 600; color: #8b5cf6; min-width: 40px; } .voice-piano-freq { color: #999; font-size: 11px; } .voice-piano-magnitude { display: flex; align-items: center; gap: 8px; } .voice-piano-bar { width: 60px; height: 4px; background: #444; border-radius: 2px; overflow: hidden; } .voice-piano-bar-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #6d28d9); transition: width 0.1s; } .voice-piano-status { text-align: center; padding: 12px; background: #222; border: 1px solid #444; border-radius: 6px; color: #999; font-style: italic; font-size: 13px; } .voice-piano-level { margin-top: 8px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; position: relative; } .voice-piano-level-bar { height: 100%; background: linear-gradient(90deg, #22c55e, #16a34a); transition: width 0.1s; box-shadow: 0 0 10px rgba(34, 197, 94, 0.5); } .voice-piano-info { background: #1a1a1a; border: 1px solid #444; border-radius: 6px; padding: 12px; font-size: 12px; color: #999; line-height: 1.6; } .voice-piano-info strong { color: #8b5cf6; } `; // ============= UI CREATION ============= const ICON_MUSIC = ``; const ICON_UPLOAD = ``; const ICON_PLAY = ``; const ICON_PAUSE = ``; const ICON_STOP = ``; function formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } const playerHTML = `