// ==UserScript==
// @name JMIDIPlayer
// @namespace seq.wtf
// @version 1.2.3
// @description High performance MIDI Player for Multiplayer Piano
// @author Seq
// @license Beerware
// @match *://multiplayerpiano.net/*
// @grant none
// ==/UserScript==
// "THE BEER-WARE LICENSE" (Revision 42):
// <[email protected]> wrote this file.
// 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.
// - James
// TODO (soon™):
// - playback speed control in ui
// - fallback for non-mppnet-style button layouts
// - nq integration? will be super choppy on legacy nq
const HEADER_LENGTH = 14;
const DEFAULT_TEMPO = 500000; // 120 bpm / 500ms/qn
const EVENT_SIZE = 8;
const EVENT_CODE = {
NOTE_ON: 0x09,
NOTE_OFF: 0x08,
CONTROL_CHANGE: 0x0B,
SET_TEMPO: 0x51,
END_OF_TRACK: 0x2F
};
class JMIDIPlayer {
// playback state
#isPlaying = false;
#currentTick = 0;
#currentTempo = DEFAULT_TEMPO;
#playbackWorker = null;
// loading state & file data
#isLoading = false;
#totalEvents = 0;
#totalTicks = 0;
#songTime = 0;
#ppqn = 0;
#numTracks = 0;
#timeMap = [];
// configurable properties
#playbackSpeed = 1; // multiplier
// event listeners
#eventListeners = {};
constructor() {
this.#eventListeners = {};
this.#createWorker();
}
on(event, callback) {
if (!this.#eventListeners[event]) {
this.#eventListeners[event] = [];
}
this.#eventListeners[event].push(callback);
}
off(event, callback) {
if (!this.#eventListeners[event]) return;
const index = this.#eventListeners[event].indexOf(callback);
if (index > -1) {
this.#eventListeners[event].splice(index, 1);
}
}
emit(event, data) {
if (!this.#eventListeners[event]) return;
for (const callback of this.#eventListeners[event]) {
callback(data);
}
}
async loadArrayBuffer(arrbuf) {
const start = performance.now();
this.#isLoading = true;
return new Promise((resolve, reject) => {
const handleMessage = (e) => {
const msg = e.data;
if (msg.type === 'parseComplete') {
this.#playbackWorker.removeEventListener('message', handleMessage);
this.#isLoading = false;
this.#totalEvents = msg.totalEvents;
this.#totalTicks = msg.totalTicks;
this.#songTime = msg.songTime;
this.#ppqn = msg.ppqn;
this.#numTracks = msg.numTracks;
this.#timeMap = msg.timeMap;
const parseTime = performance.now() - start;
this.emit("fileLoaded", { parseTime });
resolve([0, parseTime]); // [readTime, parseTime]
} else if (msg.type === 'parseError') {
this.#playbackWorker.removeEventListener('message', handleMessage);
this.unload();
reject(new Error(msg.error));
}
};
this.#playbackWorker.addEventListener('message', handleMessage);
// transfer the buffer to the worker
this.#playbackWorker.postMessage({
type: 'load',
buffer: arrbuf
}, [arrbuf]);
});
}
async loadFile(file) {
const arrbuf = await file.arrayBuffer();
return this.loadArrayBuffer(arrbuf);
}
unload() {
this.stop();
if (this.#isLoading) {
this.#isLoading = false;
}
this.#numTracks = 0;
this.#ppqn = 0;
this.#totalEvents = 0;
this.#totalTicks = 0;
this.#songTime = 0;
this.#timeMap = [];
this.#currentTick = 0;
this.#currentTempo = DEFAULT_TEMPO / this.#playbackSpeed;
if (this.#playbackWorker) {
this.#playbackWorker.postMessage({
type: 'unload'
});
}
this.emit("unloaded");
}
play() {
if (this.#isPlaying) return;
if (this.#isLoading) return;
if (this.#totalTicks === 0) throw new Error("No MIDI data loaded.");
this.#isPlaying = true;
this.#playbackWorker.postMessage({
type: 'play'
});
this.emit("play");
}
pause() {
if (!this.#isPlaying) return;
this.#isPlaying = false;
this.#playbackWorker.postMessage({
type: 'pause'
});
this.emit("pause");
}
stop() {
if (!this.#isPlaying && this.#currentTick === 0) return;
const needsEmit = this.#currentTick > 0;
this.#isPlaying = false;
this.#currentTick = 0;
this.#currentTempo = DEFAULT_TEMPO / this.#playbackSpeed;
this.#playbackWorker.postMessage({
type: 'stop'
});
if (needsEmit) this.emit("stop");
}
seek(tick) {
if (this.#isLoading || this.#totalTicks === 0) return;
tick = Math.min(Math.max(0, tick), this.#totalTicks);
if (Number.isNaN(tick)) return;
const wasPlaying = this.#isPlaying;
if (wasPlaying) this.pause();
this.#currentTick = tick;
this.#playbackWorker.postMessage({
type: 'seek',
tick
});
this.emit("seek", {
tick
});
if (wasPlaying) this.play();
}
#createWorker() {
const workerCode = `
const EVENT_SIZE = 8;
const DEFAULT_TEMPO = 500000;
const EVENT_CODE = { NOTE_ON: 0x09, NOTE_OFF: 0x08, CONTROL_CHANGE: 0x0B, SET_TEMPO: 0x51, END_OF_TRACK: 0x2F };
const HEADER_LENGTH = 14;
// Parsed MIDI data
let tracks = [];
let ppqn = 0;
let tempoEvents = [];
let totalTicks = 0;
let numTracks = 0;
let format = 0;
// Playback state
let playbackSpeed = 1;
let isPlaying = false;
let currentTick = 0;
let currentTempo = DEFAULT_TEMPO;
let trackEventPointers = [];
let startTick = 0;
let startTime = 0;
let playLoopInterval = null;
const sampleRate = 5; // ms
function parseVarlen(view, offset) {
let value = 0;
let startOffset = offset;
let checkNextByte = true;
while (checkNextByte) {
const currentByte = view.getUint8(offset);
value = (value << 7) | (currentByte & 0x7F);
++offset;
checkNextByte = !!(currentByte & 0x80);
}
return [value, offset - startOffset];
}
function parseTrack(view, trackOffset) {
let eventIndex = 0;
let capacity = 2048;
let packedBuffer = new ArrayBuffer(capacity * EVENT_SIZE);
let packedView = new DataView(packedBuffer);
const trackTempoEvents = [];
let totalTicks = 0;
let currentTick = 0;
let runningStatus = 0;
const trackLength = view.getUint32(trackOffset + 4);
let offset = trackOffset + 8;
const endOffset = offset + trackLength;
while (offset < endOffset) {
const deltaTimeVarlen = parseVarlen(view, offset);
offset += deltaTimeVarlen[1];
currentTick += deltaTimeVarlen[0];
let statusByte = view.getUint8(offset);
if (statusByte < 0x80) {
statusByte = runningStatus;
} else {
runningStatus = statusByte;
++offset;
}
const eventType = statusByte >> 4;
let ignore = false;
let eventCode, p1, p2, p3;
switch (eventType) {
case 0x8: // note off
case 0x9: // note on
eventCode = eventType;
const note = view.getUint8(offset++);
const velocity = view.getUint8(offset++);
p1 = statusByte & 0x0F; // channel
p2 = note;
p3 = velocity;
break;
case 0xB: // control change
eventCode = eventType;
const ccNum = view.getUint8(offset++);
const ccValue = view.getUint8(offset++);
if (ccNum !== 64) ignore = true;
p1 = statusByte & 0x0F; // channel
p2 = ccNum;
p3 = ccValue;
break;
case 0xA: // polyphonic key pressure
case 0xE: // pitch wheel change
++offset; // fallthrough
case 0xC: // program change
case 0xD: // channel pressure
++offset;
ignore = true;
break;
case 0xF: // system common / meta event
if (statusByte === 0xFF) {
const metaType = view.getUint8(offset++);
const lengthVarlen = parseVarlen(view, offset);
offset += lengthVarlen[1];
switch (metaType) {
case 0x51: // set tempo
if (lengthVarlen[0] !== 3) {
ignore = true;
} else {
p1 = view.getUint8(offset);
p2 = view.getUint8(offset + 1);
p3 = view.getUint8(offset + 2);
const uspq = (p1 << 16) | (p2 << 8) | p3;
trackTempoEvents.push({ tick: currentTick, uspq: uspq });
eventCode = EVENT_CODE.SET_TEMPO;
}
break;
case 0x2F: // end of track
eventCode = EVENT_CODE.END_OF_TRACK;
offset = endOffset;
break;
default:
ignore = true;
break;
}
offset += lengthVarlen[0];
} else if (statusByte === 0xF0 || statusByte === 0xF7) {
ignore = true;
const lengthVarlen = parseVarlen(view, offset);
offset += lengthVarlen[0] + lengthVarlen[1];
} else {
ignore = true;
}
break;
default:
ignore = true;
break;
}
if (!ignore) {
if (eventIndex >= capacity) {
capacity *= 2;
const newBuffer = new ArrayBuffer(capacity * EVENT_SIZE);
new Uint8Array(newBuffer).set(new Uint8Array(packedBuffer));
packedBuffer = newBuffer;
packedView = new DataView(packedBuffer);
}
const byteOffset = eventIndex * EVENT_SIZE;
if (currentTick > 0xFFFFFFFF) {
throw new Error(\`MIDI file too long! Track tick count exceeds maximum.\`);
}
packedView.setUint32(byteOffset, currentTick);
packedView.setUint8(byteOffset + 4, eventCode);
packedView.setUint8(byteOffset + 5, p1 || 0);
packedView.setUint8(byteOffset + 6, p2 || 0);
packedView.setUint8(byteOffset + 7, p3 || 0);
++eventIndex;
}
}
packedBuffer = packedBuffer.slice(0, eventIndex * EVENT_SIZE);
totalTicks = currentTick;
return { packedBuffer, tempoEvents: trackTempoEvents, totalTicks };
}
function parseMIDI(buffer) {
const view = new DataView(buffer);
// HEADER
const magic = view.getUint32(0);
if (magic !== 0x4d546864) {
throw new Error(\`Invalid MIDI magic! Expected 4d546864, got \${magic.toString(16).padStart(8, "0")}.\`);
}
const length = view.getUint32(4);
if (length !== 6) {
throw new Error(\`Invalid header length! Expected 6, got \${length}.\`);
}
format = view.getUint16(8);
numTracks = view.getUint16(10);
if (format === 0 && numTracks > 1) {
throw new Error(\`Invalid track count! Format 0 MIDIs should only have 1 track, got \${numTracks}.\`);
}
if (format >= 2) {
throw new Error(\`Unsupported MIDI format: \${format}.\`);
}
ppqn = view.getUint16(12);
if (ppqn === 0) {
throw new Error(\`Invalid PPQN/division value!\`);
}
if ((ppqn & 0x8000) !== 0) {
throw new Error(\`SMPTE timecode format is not supported!\`);
}
// TRACK OFFSETS
const trackOffsets = new Array(numTracks);
let currentOffset = HEADER_LENGTH;
for (let i = 0; i < numTracks; ++i) {
if (currentOffset >= buffer.byteLength) {
throw new Error(\`Reached EOF while looking for track \${i}. Tracks reported: \${numTracks}.\`);
}
const trackMagic = view.getUint32(currentOffset);
if (trackMagic !== 0x4d54726b) {
throw new Error(\`Invalid track \${i} magic! Expected 4d54726b, got \${trackMagic.toString(16).padStart(8, "0")}.\`);
}
const trackLength = view.getUint32(currentOffset + 4);
trackOffsets[i] = currentOffset;
currentOffset += trackLength + 8;
}
// PARSE TRACKS
tracks = new Array(numTracks);
totalTicks = 0;
tempoEvents = [];
for (let i = 0; i < numTracks; ++i) {
const result = parseTrack(view, trackOffsets[i]);
tracks[i] = {
packedBuffer: result.packedBuffer,
eventCount: result.packedBuffer.byteLength / EVENT_SIZE,
view: new DataView(result.packedBuffer)
};
totalTicks = Math.max(totalTicks, result.totalTicks);
result.tempoEvents.forEach(event => tempoEvents.push(event));
}
// Calculate song time
tempoEvents.sort((a, b) => a.tick - b.tick);
const tempoMap = [{ tick: 0, uspq: DEFAULT_TEMPO }];
for (const event of tempoEvents) {
const lastTempo = tempoMap[tempoMap.length - 1];
if (event.tick === lastTempo.tick) {
lastTempo.uspq = event.uspq;
} else {
tempoMap.push(event);
}
}
let totalMs = 0;
const timeMap = [{ tick: 0, time: 0, uspq: DEFAULT_TEMPO }];
for (let i = 0; i < tempoMap.length; ++i) {
const lastTimeData = timeMap[timeMap.length-1];
const lastUspq = lastTimeData.uspq;
const currentTempoEvent = tempoMap[i];
const ticksSinceLast = currentTempoEvent.tick - lastTimeData.tick;
const msSinceLast = (ticksSinceLast * (lastUspq / 1000)) / ppqn;
const cumulativeTime = lastTimeData.time + msSinceLast;
timeMap.push({
tick: currentTempoEvent.tick,
time: cumulativeTime,
uspq: currentTempoEvent.uspq
});
}
const lastTimeData = timeMap[timeMap.length - 1];
const ticksInFinalSegment = totalTicks - lastTimeData.tick;
const msInFinalSegment = (ticksInFinalSegment * (lastTimeData.uspq / 1000)) / ppqn;
totalMs = lastTimeData.time + msInFinalSegment;
const songTime = totalMs / 1000;
const totalEvents = tracks.map(t => t?.eventCount || 0).reduce((a, b) => a + b, 0);
return { totalEvents, totalTicks, songTime, ppqn, numTracks, timeMap };
}
function findNextEventIndex(trackIndex, tick) {
const track = tracks[trackIndex];
if (track.eventCount === 0) return 0;
let low = 0;
let high = track.eventCount;
while (low < high) {
const mid = Math.floor(low + (high - low) / 2);
const eventTick = track.view.getUint32(mid * EVENT_SIZE);
if (eventTick < tick) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
function getCurrentTick() {
if (!startTime) return startTick;
const tpms = ppqn / (currentTempo / 1000);
const ms = performance.now() - startTime;
return Math.round(tpms * ms) + startTick;
}
function playLoop() {
if (!isPlaying) {
clearInterval(playLoopInterval);
playLoopInterval = null;
return;
}
currentTick = getCurrentTick();
//console.log(\`PLAYING \${currentTick}\`)
if (tracks.every((track, i) => trackEventPointers[i] >= track.eventCount) || currentTick > totalTicks) {
isPlaying = false;
clearInterval(playLoopInterval);
playLoopInterval = null;
currentTick = 0;
startTick = 0;
startTime = 0;
postMessage({ type: 'endOfFile' });
return;
}
const eventPointers = [];
let totalEventsToPlay = 0;
for (let i = 0; i < tracks.length; ++i) {
const track = tracks[i];
if (!track) continue;
let ptr = trackEventPointers[i];
const startPtr = ptr;
while (ptr < track.eventCount && track.view.getUint32(ptr * EVENT_SIZE) <= currentTick) {
const eventData = track.view.getUint32((ptr * EVENT_SIZE) + 4);
const eventTypeCode = eventData >> 24;
// handle tempo changes immediately
if (eventTypeCode === EVENT_CODE.SET_TEMPO) {
const eventTick = track.view.getUint32(ptr * EVENT_SIZE);
const uspq = eventData & 0xFFFFFF;
const oldTempo = currentTempo * playbackSpeed;
const msAfterTempoEvent = ((currentTick - eventTick) * (oldTempo / 1000)) / ppqn;
startTick = eventTick;
startTime = performance.now() - msAfterTempoEvent;
currentTempo = uspq / playbackSpeed;
}
++ptr;
}
const numEventsInTrack = ptr - startPtr;
if (numEventsInTrack > 0) {
eventPointers.push({ trackIndex: i, start: startPtr, count: numEventsInTrack });
totalEventsToPlay += numEventsInTrack;
}
}
if (totalEventsToPlay > 0) {
const buffer = new ArrayBuffer(totalEventsToPlay * EVENT_SIZE);
const destView = new Uint8Array(buffer);
let destOffset = 0;
for (const pointer of eventPointers) {
const track = tracks[pointer.trackIndex];
const sourceByteOffset = pointer.start * EVENT_SIZE;
const sourceByteLength = pointer.count * EVENT_SIZE;
const sourceView = new Uint8Array(track.packedBuffer, sourceByteOffset, sourceByteLength);
destView.set(sourceView, destOffset);
destOffset += sourceByteLength;
trackEventPointers[pointer.trackIndex] += pointer.count;
}
postMessage({ type: 'events', buffer: buffer, currentTick }, [buffer]);
}
}
self.onmessage = function(e) {
const msg = e.data;
try {
switch (msg.type) {
case 'load':
const result = parseMIDI(msg.buffer);
trackEventPointers = new Array(tracks.length).fill(0);
currentTick = 0;
currentTempo = DEFAULT_TEMPO / playbackSpeed;
postMessage({
type: 'parseComplete',
totalEvents: result.totalEvents,
totalTicks: result.totalTicks,
songTime: result.songTime,
ppqn: result.ppqn,
numTracks: result.numTracks,
timeMap: result.timeMap
});
break;
case 'unload':
tracks = [];
ppqn = 0;
tempoEvents = [];
totalTicks = 0;
numTracks = 0;
trackEventPointers = [];
currentTick = 0;
currentTempo = DEFAULT_TEMPO / playbackSpeed;
isPlaying = false;
if (playLoopInterval) {
clearInterval(playLoopInterval);
playLoopInterval = null;
}
break;
case 'play':
if (isPlaying) return;
if (tracks.length === 0) return;
isPlaying = true;
startTime = performance.now();
playLoopInterval = setInterval(playLoop, sampleRate);
break;
case 'pause':
if (!isPlaying) return;
isPlaying = false;
clearInterval(playLoopInterval);
playLoopInterval = null;
startTick = getCurrentTick();
currentTick = startTick;
startTime = 0;
postMessage({ type: 'tickUpdate', tick: currentTick });
break;
case 'stop':
isPlaying = false;
clearInterval(playLoopInterval);
playLoopInterval = null;
currentTick = 0;
startTick = 0;
startTime = 0;
currentTempo = DEFAULT_TEMPO / playbackSpeed;
break;
case 'seek':
const tick = msg.tick;
// binary search for tempo
if (tempoEvents.length > 0) {
let low = 0;
let high = tempoEvents.length - 1;
let bestMatch = -1;
while (low <= high) {
const mid = Math.floor(low + (high - low) / 2);
if (tempoEvents[mid].tick <= tick) {
bestMatch = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
currentTempo = ((bestMatch !== -1) ? tempoEvents[bestMatch].uspq : DEFAULT_TEMPO) / playbackSpeed;
}
for (let i = 0; i < tracks.length; ++i) {
trackEventPointers[i] = findNextEventIndex(i, tick);
}
currentTick = tick;
startTick = tick;
postMessage({ type: 'tickUpdate', tick });
break;
case 'setPlaybackSpeed':
const oldSpeed = playbackSpeed;
playbackSpeed = msg.speed;
if (isPlaying) {
const tick = getCurrentTick();
currentTempo = (currentTempo * oldSpeed) / playbackSpeed;
startTick = tick;
startTime = performance.now();
}
break;
}
} catch (error) {
console.error(error);
postMessage({ type: 'parseError', error: error.message });
}
};
`;
const blob = new Blob([workerCode], {
type: 'application/javascript'
});
const workerUrl = URL.createObjectURL(blob);
this.#playbackWorker = new Worker(workerUrl);
this.#playbackWorker.onmessage = (e) => {
const msg = e.data;
switch (msg.type) {
case 'events':
this.#currentTick = msg.currentTick;
const view = new DataView(msg.buffer);
const numEvents = msg.buffer.byteLength / EVENT_SIZE;
for (let i = 0; i < numEvents; i++) {
const byteOffset = i * EVENT_SIZE;
const eventTick = view.getUint32(byteOffset);
const eventData = view.getUint32(byteOffset + 4);
const eventTypeCode = eventData >> 24;
const event = {
tick: eventTick
};
switch (eventTypeCode) {
case EVENT_CODE.NOTE_ON:
case EVENT_CODE.NOTE_OFF:
event.type = eventTypeCode;
event.channel = (eventData >> 16) & 0xFF;
event.note = (eventData >> 8) & 0xFF;
event.velocity = eventData & 0xFF;
break;
case EVENT_CODE.CONTROL_CHANGE:
event.type = 0x0B;
event.channel = (eventData >> 16) & 0xFF;
event.ccNum = (eventData >> 8) & 0xFF;
event.ccValue = eventData & 0xFF;
break;
case EVENT_CODE.SET_TEMPO:
event.type = 0xFF;
event.metaType = 0x51;
event.uspq = eventData & 0xFFFFFF;
this.#currentTempo = event.uspq / this.#playbackSpeed;
this.emit("tempoChange");
break;
case EVENT_CODE.END_OF_TRACK:
event.type = 0xFF;
event.metaType = 0x2F;
break;
}
this.emit("midiEvent", event);
}
break;
case 'endOfFile':
this.#isPlaying = false;
this.#currentTick = 0;
this.emit("endOfFile");
this.emit("stop");
break;
case 'tickUpdate':
this.#currentTick = msg.tick;
break;
}
};
this.#playbackWorker.onerror = (error) => {
console.error('Worker error:', error);
};
}
getTimeAtTick(tick) {
if (!this.#timeMap || this.#timeMap.length === 0 || this.#ppqn === 0) {
return 0;
}
let low = 0;
let high = this.#timeMap.length - 1;
let bestMatchIndex = 0;
while (low <= high) {
const mid = Math.floor(low + (high - low) / 2);
const midTick = this.#timeMap[mid].tick;
if (midTick <= tick) {
bestMatchIndex = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
const segment = this.#timeMap[bestMatchIndex];
const ticksSinceSegmentStart = tick - segment.tick;
const msSinceSegmentStart = (ticksSinceSegmentStart * (segment.uspq / 1000)) / this.#ppqn;
const totalMs = segment.time + msSinceSegmentStart;
return totalMs / 1000;
}
get isLoading() {
return this.#isLoading;
}
get isPlaying() {
return this.#isPlaying;
}
get trackCount() {
return this.#numTracks;
}
get songTime() {
return this.#songTime;
}
get ppqn() {
return this.#ppqn;
}
get currentTempo() {
return 60_000_000 / this.#currentTempo;
}
get totalEvents() {
return this.#totalEvents;
}
get totalTicks() {
return this.#totalTicks;
}
get currentTick() {
return this.#currentTick;
}
get playbackSpeed() {
return this.#playbackSpeed;
}
set playbackSpeed(speed) {
speed = +speed;
if (Number.isNaN(speed)) throw new Error("Playback speed must be a valid number!");
if (speed <= 0) throw new Error("Playback speed must be a positive number!");
const oldSpeed = this.#playbackSpeed;
if (speed === oldSpeed) return;
this.#playbackSpeed = speed;
if (this.#playbackWorker) {
this.#playbackWorker.postMessage({
type: 'setPlaybackSpeed',
speed
});
}
}
}
function handleMidiEvent(event) {
if (event.channel === 9) return;
if (sustainedNotes[event.channel] === undefined) {
sustainedNotes[event.channel] = new Set();
sustainState[event.channel] = false;
}
if (event.type === EVENT_CODE.CONTROL_CHANGE && event.ccNum === 64) {
const isSustainOn = event.ccValue >= 64;
sustainState[event.channel] = isSustainOn;
if (!isSustainOn) {
for (const note of sustainedNotes[event.channel]) {
const key = Object.keys(MPP.piano.keys)[note - 21];
if (key) MPP.release(key);
}
sustainedNotes[event.channel].clear();
}
return;
}
if ((event.type >> 1) !== 4) return; // note on/off only
const key = Object.keys(MPP.piano.keys)[event.note - 21];
if (!key) return;
const isNoteOn = event.type === EVENT_CODE.NOTE_ON && event.velocity > 0;
if (isNoteOn) {
sustainedNotes[event.channel].delete(event.note);
MPP.press(key, event.velocity / 127);
} else {
if (sustainState[event.channel]) {
sustainedNotes[event.channel].add(event.note);
} else {
MPP.release(key);
}
}
}
// svg icons
const ICON_PLAY = `<svg viewBox="0 0 16 16"><path d="M3 2 L13 8 L3 14 Z"></path></svg>`;
const ICON_PAUSE = `<svg viewBox="0 0 16 16"><path d="M3 2 H6 V14 H3 Z M10 2 H13 V14 H10 Z"></path></svg>`;
const ICON_STOP = `<svg viewBox="0 0 16 16"><path d="M3 2 H13 V12 H3 Z"></path></svg>`;
const styles = `
.jmidi-player-window {
position: fixed;
top: 20px;
left: 20px;
width: 350px;
background: #2d2d2d;
border: 1px solid #555;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.5);
color: #eee;
font-family: sans-serif;
font-size: 14px;
z-index: 99999;
display: none;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.jmidi-player-window.visible {
display: block;
}
.jmidi-player-window.dragover {
border-color: #0a84ff;
}
.jmidi-header {
padding: 8px 12px;
background: #3a3a3a;
cursor: move;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 1px solid #555;
user-select: none;
}
.jmidi-content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.jmidi-controls {
display: flex;
gap: 8px;
align-items: center;
}
#jmidi-file-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
text-align: center;
}
.jmidi-controls button, #jmidi-file-label {
background: #555;
border: 1px solid #777;
color: #eee;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
}
.jmidi-controls button {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.jmidi-controls button svg {
width: 16px;
height: 16px;
fill: #eee;
}
.jmidi-controls button:hover, #jmidi-file-label:hover {
background: #666;
}
.jmidi-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.jmidi-file-input input[type="file"] {
display: none;
}
.jmidi-seekbar-track {
width: 100%;
height: 10px;
background-color: #444;
border-radius: 5px;
cursor: ew-resize;
overflow: hidden;
position: relative;
}
.jmidi-seekbar-track.jmidi-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.jmidi-seekbar-progress {
height: 100%;
width: 0%;
background-color: #0a84ff;
border-radius: 5px;
pointer-events: none;
}
.jmidi-info-area {
min-height: 32px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.jmidi-status-text, .jmidi-tick-display {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.jmidi-status-text {
font-style: italic;
color: #aaa;
}
.jmidi-status-text.error {
color: #ff6b6b;
}
.jmidi-tick-display {
text-align: left;
font-family: monospace;
}
.jmidi-time-display {
text-align: left;
font-family: monospace;
color: #ccc;
}
`;
const playerHTML = `
<div id="jmidi-player-window" class="jmidi-player-window">
<div class="jmidi-header">JMIDIPlayer</div>
<div class="jmidi-content">
<div class="jmidi-file-input">
<input type="file" id="jmidi-file-input" accept=".mid,.midi">
<label for="jmidi-file-input" id="jmidi-file-label">Load MIDI File</label>
</div>
<div id="jmidi-seekbar-track" class="jmidi-seekbar-track jmidi-disabled">
<div id="jmidi-seekbar-progress" class="jmidi-seekbar-progress"></div>
</div>
<div class="jmidi-controls">
<button id="jmidi-play-pause-btn" disabled></button>
<button id="jmidi-stop-btn" disabled></button>
</div>
<div class="jmidi-info-area">
<span id="jmidi-status-text" class="jmidi-status-text">No file loaded.</span>
<span id="jmidi-time-display" class="jmidi-time-display">00:00 / 00:00</span>
<span id="jmidi-tick-display" class="jmidi-tick-display">0.00% | 0 / 0</span>
</div>
</div>
</div>
`;
const toggleButtonHTML = `<div class="ugly-button" id="jmidi-toggle-btn">Toggle Player</div>`;
// inject ui
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 element references
const ui = {
window: document.getElementById('jmidi-player-window'),
header: document.querySelector('.jmidi-header'),
fileInput: document.getElementById('jmidi-file-input'),
playPauseBtn: document.getElementById('jmidi-play-pause-btn'),
stopBtn: document.getElementById('jmidi-stop-btn'),
seekbarTrack: document.getElementById('jmidi-seekbar-track'),
seekbarProgress: document.getElementById('jmidi-seekbar-progress'),
statusText: document.getElementById('jmidi-status-text'),
tickDisplay: document.getElementById('jmidi-tick-display'),
timeDisplay: document.getElementById('jmidi-time-display'),
fileLabel: document.getElementById('jmidi-file-label'),
toggleBtn: document.getElementById('jmidi-toggle-btn')
};
const player = new JMIDIPlayer();
let isSeeking = false; // flag to prevent ui loop from updating seekbar while dragging
let animStartTick = 0;
let animStartTime = 0;
let sustainState = {}; // { [channel]: boolean }
let sustainedNotes = {}; // { [channel]: Set<note> }
function resetSustain() {
for (const channel in sustainedNotes) {
if (sustainedNotes[channel].size > 0) {
for (const note of sustainedNotes[channel]) {
const key = Object.keys(MPP.piano.keys)[note - 21];
if (key) MPP.release(key);
}
}
}
sustainState = {};
sustainedNotes = {};
}
// ui update & helpers
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
function setControlsEnabled(enabled) {
ui.playPauseBtn.disabled = !enabled;
ui.stopBtn.disabled = !enabled;
ui.seekbarTrack.classList.toggle('jmidi-disabled', !enabled);
}
function resetUIForNewFile() {
ui.playPauseBtn.innerHTML = ICON_PLAY;
ui.stopBtn.innerHTML = ICON_STOP;
setControlsEnabled(true);
updateProgressDisplay();
}
let currentFileName = null;
async function loadFile(file) {
if (!file || !file.type.match(/audio\/(mid|midi|x-midi)/)) {
ui.statusText.textContent = `Error: Not a MIDI file.`;
ui.statusText.classList.add('error');
return;
}
player.unload();
ui.statusText.textContent = 'Loading...';
ui.statusText.classList.remove('error');
setControlsEnabled(false);
try {
currentFileName = file.name;
const buffer = await file.arrayBuffer();
await player.loadArrayBuffer(buffer);
} catch (error) {
currentFileName = null;
const errorMessage = `Error: ${error.message}`;
ui.statusText.textContent = errorMessage;
ui.statusText.title = errorMessage;
ui.statusText.classList.add('error');
console.error("Failed to load MIDI file:", error);
player.unload();
}
}
function updateProgressDisplay(displayTick) {
const currentTick = displayTick !== undefined ? displayTick : player.currentTick;
const totalTicks = player.totalTicks || 1;
const percentage = Math.min(100, (currentTick / totalTicks) * 100);
const clampedTick = Math.min(player.totalTicks, Math.max(0, Math.round(currentTick)));
ui.tickDisplay.textContent = `${percentage.toFixed(2)}% | ${clampedTick} / ${totalTicks}`;
ui.seekbarProgress.style.width = `${percentage}%`;
const totalTime = player.songTime || 0;
const currentTime = player.getTimeAtTick(clampedTick);
ui.timeDisplay.textContent = `${formatTime(currentTime)} / ${formatTime(totalTime)}`;
}
function animationLoop() {
if (player.isPlaying) {
const ppqn = player.ppqn;
const tempoBPM = player.currentTempo;
if (ppqn > 0 && tempoBPM > 0) {
const ticksPerSecond = (ppqn * tempoBPM) / 60;
const elapsedMs = performance.now() - animStartTime;
const elapsedTicks = (elapsedMs / 1000) * ticksPerSecond;
const visualTick = Math.floor(animStartTick + elapsedTicks);
updateProgressDisplay(visualTick);
}
}
requestAnimationFrame(animationLoop);
}
animationLoop();
// initial button states
ui.playPauseBtn.innerHTML = ICON_PLAY;
ui.stopBtn.innerHTML = ICON_STOP;
// player events
player.on('fileLoaded', ({ parseTime }) => {
ui.statusText.title = '';
ui.statusText.classList.remove('error');
ui.statusText.textContent = `Ready: ${player.totalEvents.toLocaleString('en-US')} events in ${parseTime.toFixed(2)} ms.`;
ui.fileLabel.textContent = currentFileName;
resetUIForNewFile();
});
player.on('play', () => {
ui.statusText.textContent = 'Playing...';
ui.playPauseBtn.innerHTML = ICON_PAUSE;
// Set animation anchor point
animStartTime = performance.now();
animStartTick = player.currentTick;
});
player.on('pause', () => {
ui.statusText.textContent = 'Paused.';
ui.playPauseBtn.innerHTML = ICON_PLAY;
});
player.on('stop', () => {
resetSustain();
ui.statusText.textContent = 'Stopped.';
ui.playPauseBtn.innerHTML = ICON_PLAY;
player.seek(0);
animStartTick = 0;
updateProgressDisplay();
});
player.on('tempoChange', () => {
animStartTime = performance.now();
animStartTick = player.currentTick;
});
player.on('endOfFile', () => {
ui.statusText.textContent = 'Finished.';
ui.playPauseBtn.innerHTML = ICON_PLAY;
updateProgressDisplay();
});
player.on('unloaded', () => {
resetSustain();
if (!ui.statusText.classList.contains('error')) {
ui.statusText.textContent = 'No file loaded.';
}
ui.fileLabel.textContent = 'Load MIDI File';
currentFileName = null;
ui.tickDisplay.textContent = '0.00% | 0 / 0';
setControlsEnabled(false);
updateProgressDisplay();
});
player.on('midiEvent', handleMidiEvent);
ui.toggleBtn.addEventListener('click', () => {
ui.window.classList.toggle('visible');
});
// file input
ui.fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
loadFile(e.target.files[0]);
}
});
// drag'n'drop
ui.window.addEventListener('dragover', (e) => {
e.preventDefault();
ui.window.classList.add('dragover');
});
ui.window.addEventListener('dragleave', () => {
ui.window.classList.remove('dragover');
});
ui.window.addEventListener('drop', (e) => {
e.preventDefault();
ui.window.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
loadFile(e.dataTransfer.files[0]);
}
});
// player controls
ui.playPauseBtn.addEventListener('click', () => {
if (player.isPlaying) {
player.pause();
ui.playPauseBtn.innerHTML = ICON_PLAY;
} else {
player.play();
ui.playPauseBtn.innerHTML = ICON_PAUSE;
}
});
ui.stopBtn.addEventListener('click', () => {
player.stop();
ui.playPauseBtn.innerHTML = ICON_PLAY;
});
// seekbar
function seekFromEvent(e) {
if (player.totalTicks === 0) return;
const rect = ui.seekbarTrack.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const percentage = x / rect.width;
const tick = Math.round(percentage * player.totalTicks);
resetSustain();
player.seek(tick);
updateProgressDisplay(); // update ui immediately
}
ui.seekbarTrack.addEventListener('mousedown', (e) => {
if (player.totalTicks === 0) return;
isSeeking = true;
seekFromEvent(e);
});
document.addEventListener('mousemove', (e) => {
if (isSeeking) {
seekFromEvent(e);
}
});
document.addEventListener('mouseup', () => {
if (isSeeking) {
isSeeking = false;
}
});
// 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;
});