Greasy Fork is available in English.
Play piano with MP3 file harmonics on Multiplayer Piano
当前为
// ==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
// ==/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 = `<svg viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`;
const ICON_UPLOAD = `<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;
const ICON_PLAY = `<svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>`;
const ICON_PAUSE = `<svg viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`;
const ICON_STOP = `<svg viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"/></svg>`;
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
const playerHTML = `
<div id="voice-piano-window" class="voice-piano-window">
<div class="voice-piano-header">
${ICON_MUSIC}
<span>MP3 to Piano</span>
</div>
<div class="voice-piano-content">
<input type="file" id="voice-piano-file-input" class="voice-piano-file-input" accept="audio/mp3,audio/mpeg">
<label for="voice-piano-file-input" class="voice-piano-file-label">
${ICON_UPLOAD}
<span>Load MP3 File</span>
</label>
<div id="voice-piano-file-name" class="voice-piano-file-name" style="display: none;">
No file loaded
</div>
<div id="voice-piano-playback" class="voice-piano-playback" style="display: none;">
<div class="voice-piano-time">
<span id="voice-piano-current-time">0:00</span>
<span id="voice-piano-duration">0:00</span>
</div>
<div class="voice-piano-progress">
<div id="voice-piano-progress-bar" class="voice-piano-progress-bar" style="width: 0%"></div>
</div>
</div>
<div class="voice-piano-controls">
<button id="voice-piano-play-btn" class="voice-piano-btn" disabled>
${ICON_PLAY}
<span>Play</span>
</button>
<button id="voice-piano-stop-btn" class="voice-piano-btn" disabled>
${ICON_STOP}
<span>Stop</span>
</button>
</div>
<div class="voice-piano-slider-group">
<div class="voice-piano-slider-label">
<span>Sensitivity</span>
<span id="voice-piano-sensitivity-value">30</span>
</div>
<input type="range" id="voice-piano-sensitivity" class="voice-piano-slider" min="10" max="80" value="30">
</div>
<div class="voice-piano-slider-group">
<div class="voice-piano-slider-label">
<span>Max Notes</span>
<span id="voice-piano-max-harmonics-value">12</span>
</div>
<input type="range" id="voice-piano-max-harmonics" class="voice-piano-slider" min="2" max="24" value="12">
</div>
<div id="voice-piano-status" class="voice-piano-status">
Load an MP3 file to begin
<div class="voice-piano-level">
<div id="voice-piano-level-bar" class="voice-piano-level-bar" style="width: 0%"></div>
</div>
</div>
<div id="voice-piano-harmonics" class="voice-piano-harmonics" style="display: none;">
</div>
<div class="voice-piano-info">
<strong>How it works:</strong> Load an MP3 file and the system will analyze its frequencies in real-time, playing corresponding piano keys as the music plays.
</div>
</div>
</div>
`;
const toggleButtonHTML = `<div class="ugly-button" id="voice-piano-menu-btn">MP3 to Piano</div>`;
// ============= INITIALIZATION =============
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
document.body.insertAdjacentHTML('beforeend', playerHTML);
const buttonsContainer = document.querySelector('#buttons');
if (buttonsContainer) {
buttonsContainer.insertAdjacentHTML('beforeend', toggleButtonHTML);
} else {
document.body.insertAdjacentHTML('beforeend', toggleButtonHTML);
}
// ============= UI ELEMENTS =============
const ui = {
window: document.getElementById('voice-piano-window'),
header: document.querySelector('.voice-piano-header'),
fileInput: document.getElementById('voice-piano-file-input'),
fileName: document.getElementById('voice-piano-file-name'),
playBtn: document.getElementById('voice-piano-play-btn'),
stopBtn: document.getElementById('voice-piano-stop-btn'),
menuBtn: document.getElementById('voice-piano-menu-btn'),
sensitivitySlider: document.getElementById('voice-piano-sensitivity'),
sensitivityValue: document.getElementById('voice-piano-sensitivity-value'),
maxHarmonicsSlider: document.getElementById('voice-piano-max-harmonics'),
maxHarmonicsValue: document.getElementById('voice-piano-max-harmonics-value'),
status: document.getElementById('voice-piano-status'),
harmonicsContainer: document.getElementById('voice-piano-harmonics'),
levelBar: document.getElementById('voice-piano-level-bar'),
playback: document.getElementById('voice-piano-playback'),
currentTime: document.getElementById('voice-piano-current-time'),
duration: document.getElementById('voice-piano-duration'),
progressBar: document.getElementById('voice-piano-progress-bar')
};
const engine = new MP3ToPiano();
// ============= EVENT HANDLERS =============
engine.onHarmonicsUpdate = (harmonics, activeCount) => {
const statusText = activeCount > 0
? `Playing... ${activeCount} note${activeCount !== 1 ? 's' : ''} active`
: 'Playing... Analyzing audio';
ui.status.childNodes[0].textContent = statusText;
if (harmonics.length === 0) {
ui.harmonicsContainer.innerHTML = '<div class="voice-piano-status">Analyzing audio...</div>';
} else {
ui.harmonicsContainer.innerHTML = harmonics.map(h => `
<div class="voice-piano-harmonic">
<div class="voice-piano-harmonic-left">
<div class="voice-piano-pulse"></div>
<span class="voice-piano-note">${h.noteName}</span>
<span class="voice-piano-freq">${h.frequency.toFixed(1)} Hz</span>
</div>
<div class="voice-piano-magnitude">
<div class="voice-piano-bar">
<div class="voice-piano-bar-fill" style="width: ${(h.magnitude / 255 * 100)}%"></div>
</div>
<span>${Math.round(h.magnitude / 255 * 100)}%</span>
</div>
</div>
`).join('');
}
};
engine.onAudioLevelUpdate = (level) => {
const percentage = Math.min(100, (level / 128) * 100);
ui.levelBar.style.width = `${percentage}%`;
};
engine.onPlaybackUpdate = (status, currentTime, duration) => {
if (status === 'playing' && currentTime !== undefined && duration !== undefined) {
ui.currentTime.textContent = formatTime(currentTime);
ui.duration.textContent = formatTime(duration);
const percentage = (currentTime / duration) * 100;
ui.progressBar.style.width = `${percentage}%`;
} else if (status === 'ended') {
ui.playBtn.innerHTML = `${ICON_PLAY}<span>Play</span>`;
ui.playBtn.classList.remove('playing');
ui.status.innerHTML = 'Playback finished<div class="voice-piano-level"><div id="voice-piano-level-bar" class="voice-piano-level-bar" style="width: 0%"></div></div>';
ui.levelBar = document.getElementById('voice-piano-level-bar');
ui.harmonicsContainer.style.display = 'none';
}
};
ui.fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) {
ui.status.textContent = 'Loading MP3...';
const success = await engine.loadMP3(file);
if (success) {
ui.fileName.textContent = file.name;
ui.fileName.style.display = 'block';
ui.playBtn.disabled = false;
ui.stopBtn.disabled = false;
ui.status.textContent = 'MP3 loaded! Click Play to start.';
ui.playback.style.display = 'block';
ui.duration.textContent = formatTime(engine.getDuration());
ui.currentTime.textContent = '0:00';
console.log('[MP3 to Piano] MP3 loaded successfully:', file.name);
} else {
ui.status.textContent = 'Error loading MP3. Try another file.';
}
}
});
ui.playBtn.addEventListener('click', () => {
if (!engine.isPlaying) {
const success = engine.start();
if (success) {
ui.playBtn.innerHTML = `${ICON_PAUSE}<span>Pause</span>`;
ui.playBtn.classList.add('playing');
ui.status.innerHTML = 'Playing...<div class="voice-piano-level"><div id="voice-piano-level-bar" class="voice-piano-level-bar" style="width: 0%"></div></div>';
ui.levelBar = document.getElementById('voice-piano-level-bar');
ui.harmonicsContainer.style.display = 'block';
}
} else {
engine.pause();
ui.playBtn.innerHTML = `${ICON_PLAY}<span>Play</span>`;
ui.playBtn.classList.remove('playing');
ui.status.textContent = 'Paused';
ui.harmonicsContainer.style.display = 'none';
}
});
ui.stopBtn.addEventListener('click', () => {
engine.reset();
ui.playBtn.innerHTML = `${ICON_PLAY}<span>Play</span>`;
ui.playBtn.classList.remove('playing');
ui.status.textContent = 'Stopped';
ui.harmonicsContainer.style.display = 'none';
ui.currentTime.textContent = '0:00';
ui.progressBar.style.width = '0%';
ui.levelBar.style.width = '0%';
});
ui.sensitivitySlider.addEventListener('input', (e) => {
const value = e.target.value;
ui.sensitivityValue.textContent = value;
engine.sensitivity = parseInt(value);
});
ui.maxHarmonicsSlider.addEventListener('input', (e) => {
const value = e.target.value;
ui.maxHarmonicsValue.textContent = value;
engine.maxHarmonics = parseInt(value);
});
ui.menuBtn.addEventListener('click', () => {
ui.window.classList.toggle('visible');
});
// ============= DRAGGABLE WINDOW =============
let isDragging = false;
let offsetX, offsetY;
ui.header.addEventListener('mousedown', (e) => {
isDragging = true;
const rect = ui.window.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
ui.window.style.left = `${e.clientX - offsetX}px`;
ui.window.style.top = `${e.clientY - offsetY}px`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
console.log('[MP3 to Piano] Loaded successfully! Click the "MP3 to Piano" button to open.');
})();