JMIDIPlayer

High performance MIDI Player for Multiplayer Piano

Mint 2025.10.04.. Lásd a legutóbbi verzió

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         JMIDIPlayer
// @namespace    seq.wtf
// @version      1.2.0
// @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;

          this.emit("fileLoaded");

          const parseTime = performance.now() - start;
          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);
          tempoEvents.push(...result.tempoEvents);
        }

        // 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) {
          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\/(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', () => {
  ui.statusText.title = '';
  ui.statusText.classList.remove('error');
  ui.statusText.textContent = `Ready: ${player.trackCount} tracks`;
  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;
});