JMIDIPlayer

High performance MIDI Player for Multiplayer Piano

// ==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;
});