TealMIDIPlayer (JMP only)

MIDI Player bot for MPP. (Based off of Teal's MIDI player)

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         TealMIDIPlayer (JMP only)
// @name:pt-BR   Tocador de MIDIs do Teal
// @homepage     https://ccjt.sad.ovh/
// @version      1.2.1
// @description  MIDI Player bot for MPP. (Based off of Teal's MIDI player)
// @description:pt-BR  Bot tocador de MIDIs para MPP. (Baseado no tocador de MIDIs do Teal)
// @author       ccjt
// @match        *://multiplayerpiano.net/*
// @match        *://multiplayerpiano.org/*
// @match        *://piano.mpp.community/*
// @match        *:///*
// @match        *://www.multiplayerpiano.dev/*
// @match        *://mpp.smp-meow.net/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM_info
// @license      MIT
// @namespace    https://greasyfork.org/users/1459137
// ==/UserScript==

// change this however you like - mude isso para o que quiser
const prefix = "p."
// you'll have to use commands by starting with the characters in the text above. - você vai precisar usar comandos com os caracteres no texto acima.

const sep = '-'

const SCRIPT = GM_info.script
const name = SCRIPT.name
const version = SCRIPT.version
const author = SCRIPT.author
const link = SCRIPT.homepage

// JMIDIPlayer
// "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

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

        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();

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

let player = new JMIDIPlayer()

const charLimit = 512
function send(msg, reply, replyTo) {
    if (typeof msg == "string") {
        msg = msg
    } else {
        msg = JSON.stringify(msg)
    }
    msg = "⇀ " + msg
    if (msg.length > charLimit) {
        for (let i = 0; i < Math.floor(msg.length / charLimit) + 1; i++) {
            MPP.chat.send(msg.slice(charLimit * i, charLimit * (i + 1)));
        }
    } else {
        MPP.chat.send(msg)
    }
}
function midiLoading() {
    if (!player.isPlaying) MPP.press('as3', 1);
    setTimeout(() => {
        MPP.release('as3');
        if (!player.isPlaying) MPP.press('cs4', 1)
    }, 250);
    setTimeout(() => {
        MPP.release('cs4');
        if (!player.isPlaying) MPP.press('fs4', 1)
    }, 500);
    setTimeout(() => {
        MPP.release('fs4');
    }, 750);
    setTimeout(() => {
        MPP.release('c4')
    }, 1000)
}
let loadnotes
function loadNotes(start) {
    if (start) {
        midiLoading()
        loadnotes = setInterval(midiLoading, 1e3)
    }
    else clearInterval(loadnotes)
}
function validUrl(url) {
    let result
    try {
        new URL(url)
        result = true
    }
    catch {
        result = false
    }
    return result
}
const signal = new AbortController().signal
async function loadStuff(a, url, id) {
    function validMidi(arrbuf) {
        const decoder = new TextDecoder('utf8');
        let textdata = decoder.decode(arrbuf);
        if (textdata.startsWith('MThd')) return true
        else return false
    }
    let out = await a.arrayBuffer();
    if (validMidi(out)) return out
    else throw new Error('The file provided is invalid, as it doesn\'t start with the header `MThd`.')
}
let playing = {}
async function playMidiFromUrl(url, id) {
    let fetchtime
    let fetchstart
    let parsetime
    loadNotes(true)
    setTimeout(()=>{}, 50)
    let result
    fetchtime = 0
    fetchstart = Date.now()
    console.log('trying to play', url)
    if (validUrl(url)) {
        if (player.isPlaying) {
            player.stop();
            player.unload()
        }
        try {
            fetch(url, {
                method: 'get',
                signal: signal
            }).then(async (a) => {
                try {
                    let out = await loadStuff(a, url, id)
                    return out
                } catch (err) {
                    send("There was an error when playing the file. " + sep + " Error: " + err.message)
                    console.log(err)
                    loadNotes(false)
                    return false
                }
            }).then(async a => {
                fetchtime = Date.now() - fetchstart
                if (a) {
                    let parsestart = Date.now()
                    try {
                        await player.loadArrayBuffer(a)
                        player.play()
                        parsetime = Date.now() - parsestart
                    }
                    catch (err) {
                        parsetime = Date.now() - parsestart
                        result = false
                        send("There was an error when playing the file. ||" + err + "||")
                        console.log(err)
                        loadNotes(false)
                        return
                    }
                    console.log("Fetch time: " + fetchtime + "ms\nParse time: " + parsetime + "ms")
                    send("Fetched MIDI in " + fetchtime + "ms. " + sep + " Parsed MIDI in " + parsetime + "ms. " + sep + " Now playing `" + decodeURIComponent(url.split("/")[url.split("/").length - 1]) + "`.")
                    playing.url = url
                    loadNotes(false)
                } else {
                    loadNotes(false)
                    return
                }
            })
        } catch (err) {
            result = false
            send('Error')
            loadNotes(false)
        }
    } else {
        result = false
        send("There was an error when playing the file. ||Invalid URL||")
    }
    loadNotes(false)
    return result
}
let looping = false
let sustain = false
let transpose = 0
let volume = 1
let jevents = {
    noteon: 9,
    noteoff: 8,
    ctrlChange: 0x0B,
    setTempo: 0x51,
    end: 0x2F,
    meta: 0xFF
};
let eventsplayed = 0
let keys = Object.keys(MPP.piano.keys)
let currenttick
player.on('midiEvent', (event) => {
    eventsplayed++
    currenttick = event.tick
    if (event.type == jevents.noteon && event.velocity !== 0 && event.channel !== 9) {
        MPP.press(keys[event.note - 21 + transpose], (event.velocity / 127) * volume)
    } else if (event.type == jevents.noteoff || event.velocity == 0) {
        if (!sustain) MPP.release(keys[event.note - 21 + transpose])
    }
})
player.on('endOfFile', async () => {
    if (looping) {
        setTimeout(()=>{
            eventsplayed = 0;
            player.seek(0);
            player.play();
        }, 15)
    } else {
        loadNotes(false)
        send("Finished playing track. Duration: `" + player.songTime.toFixed(2) + "s` " + sep + " Played `" + eventsplayed + "` out of `" + player.totalEvents + "` (" + (eventsplayed / player.totalEvents * 100).toFixed(2) + "%) events.")
        MPP.client.sendArray([{
            m: "n",
            n: stopnotes
        }])
        eventsplayed = 0
        sustain = false
    }
})
let stopnotes = []
for (let i = 0; i < keys.length; i++) {
    stopnotes.push({
        'n': keys[i],
        's': 1
    })
}

let cmds = {
    help: {
        aliases: ['h'],
        about: "Shows commands and command info.",
        func: (...args) => {
            let ogcmd = args[0]
            if (args.length == 1)
                send(`Commands ${sep} ${createcmdstr()} ${sep} Use \`${ogcmd} <command name>\` to get info about a specific command.`)
            else
                if (Object.keys(cmds).includes(args[1])) {
                    console.log(cmds[args[1]])
                    let cmdinfo = cmds[args[1]]
                    let aliases = cmdinfo.aliases.length > 0 ? `${prefix}${cmdinfo.aliases.join(", " + prefix)}` : "*(none)*"
                    send(`${args[1]} ${sep} Description: ${cmdinfo.about} ${sep} Aliases: ${aliases}`)
                } else send("That command doesn't exist.")
        }
    },
    about: {
        aliases: ['ab'],
        about: "Tells info about the script.",
        func: (...args) => {
            send(`${name} v${version} by ${author} ${sep} ccjt's site: ${link} ${sep} JMIDIPlayer module originally made by seq.wtf ${sep} Get this userscript at https://greasyfork.org/en/scripts/554578-jmidiplayer`)
        }
    },
    play: {
        aliases: ['p'],
        about: "Plays a MIDI file from the web.",
        func: (...args) => {
            let ogcmd = args[0]
            if (args.length === 1)
                send(`Please specify a direct download URL to the desired MIDI file to play. ${sep} Usage: \`${ogcmd} <URL>\``)
            else {
                function getFileName(url) {
                    let filename = ""
                    filename = url.split('/')
                    filename = filename[filename.length - 1]
                    filename = decodeURIComponent(filename)
                    return filename
                }
                player.stop()
                send("Downloading...")
                playing.name = getFileName(args[1])
                playMidiFromUrl(args[1])
            }
        }
    },
    stop: {
        aliases: ['s'],
        about: "Stops the current track.",
        func: (...args) => {
            loadNotes(false)
            player.stop()
            send("Stopped playing.")
        }
    },
    volume: {
        aliases: ['vol', 'v'],
        about: "Adjusts track volume.",
        func: (...args) => {
            let ogcmd = args[0]
            if (args.length === 1) {
                send(`Please specify a volume to set to. ${sep} Range: \`0.0 to 1.0\` ${sep} Usage: \`${ogcmd} <volume from 0.0 to 1.0>\``)
            } else {
                args[1] = parseFloat(args[1])
                if (args[1] >= 0 && args[1] <= 1) {
                    volume = args[1]
                    send(`Volume set to \`${volume}\`.`)
                } else {
                    send("That value is out of range. Please specify a value between `0.0` and `1.0`.")
                }
            }
        }
    },
    pause: {
        aliases: ['pa'],
        about: "Pauses and resumes a track.",
        func: (...args) => {
            if (!player.isLoading && player.trackCount > 0) {
                if (player.isPlaying) {
                    player.pause()
                    send("Paused track.")
                } else {
                    player.play()
                    send("Resumed track.")
                }
            } else send("Nothing loaded yet.")
        }
    },
    resume: {
        aliases: ['re'],
        about: "Resumes a track.",
        func: (...args) => {
            if (!player.isLoading && player.trackCount > 0) {
                player.play()
                send("Resumed track.")
            } else send("Nothing loaded yet.")
        }
    },
    transpose: {
        aliases: ['tr'],
        about: "Changes the key of the current track.",
        func: (...args) => {
            let ogcmd = args[0]
            if (args.length === 1)
                send(`Please specify a value between \`-24\` and \`36\`. ${sep} Usage: \`${ogcmd} <value>\``)
            else {
                args[1] = parseInt(args[1])
                if (isNaN(args[1]))
                    send("Please specify a *number*.")
                else
                    if (args[1] > 36 || args[1] < -24)
                        send("Please specify a value under `36` and above `-24`.")
                    else {
                        transpose = args[1]
                        send(`Transposition set to \`${transpose}\`. ||Note that this will reset after the track has finished.||`)
                    }
            }
        }
    },
    loop: {
        aliases: ['l'],
        about: "Toggles looping.",
        func: (...args) => {
            looping = !looping
            if (looping) send("Now looping track.")
            else send("Stopped looping track.")
        }
    },
    track: {
        aliases: ["t"],
        about: "Shows info about the current playing track.",
        func: (...args) => {
            if (!player.isLoading && player.trackCount > 0) {
                let remaining = ((currenttick * (60 * 1000 / (player.currentTempo * player.ppqn)) / 1000) / player.songTime) * 100
                let progressbar = []
                for (let i = 0; i < remaining / 10; i++) {
                    progressbar.push('█')
                }
                let secondsstr = ""
                let played = currenttick * (60 * 1000 / (player.currentTempo * player.ppqn)) / 1000
                let playedsecondsstr = ""
                let songtime = player.songTime
                if (songtime % 60 > 0) secondsstr = " " + parseInt(songtime % 60) + "s"
                if (played % 60 > 0) playedsecondsstr = " " + parseInt(played % 60) + "s"
                let length = songtime > 60 ? parseInt(songtime / 60) + "m" + secondsstr : songtime.toFixed(3) + "s"
                let lengthplayed = played > 60 ? parseInt(played / 60) + "m" + playedsecondsstr : played.toFixed(3) + "s"
                let totalevents = player.totalEvents
                let bpm = player.currentTempo
                send("`Track name: " + playing.name + " - Track BPM: " + bpm + " - Track length: " + length + " - [" + progressbar.join('').padEnd(10, " ") + "] - " + remaining.toFixed(2) + "% (" + lengthplayed + ") played  - Events played: " + eventsplayed + " out of " + totalevents + " - Looping: " + looping + "`")
            } else {
                send("Nothing loaded yet.")
            }
        }
    }
}
function createcmdstr() {
    let result = []
    for (let i = -1; ++i < Object.keys(cmds).length;) {
        result.push(prefix + Object.keys(cmds)[i])
        if (cmds[Object.keys(cmds)[i]].aliases.length > 0) {
            result[i] += " (" + prefix + cmds[Object.keys(cmds)[i]].aliases.join(', ' + prefix) + ")"
        }
    }
    return result.join(', ')
}
MPP.client.on('a', (data) => {
    if (data.p._id === MPP.client.getOwnParticipant()._id) {
        let args = data.a.split(' ')
        let cmd = args[0].toLowerCase()
        if (cmd.startsWith(prefix)) {
            cmd = cmd.substring(prefix.length)
            if (Object.keys(cmds).includes(cmd)) {
                cmds[cmd].func(...args)
            } else {
                for (let i = -1; ++i < Object.keys(cmds).length;) {
                    if (Object.values(cmds)[i].aliases.includes(cmd)) {
                        cmds[Object.keys(cmds)[i]].func(...args)
                    }
                }
            }
        }
    }
})