Turbo.ai – Join Dropped Audio Files (Persistent Panel)

Drag and drop multiple audio files (.m4a, .mp3, .wav, .ogg, etc.) anywhere on https://www.turbo.ai/dashboard to join them client-side and download as one WAV file. Panel auto-restores if the site re-renders.

// ==UserScript==
// @name         Turbo.ai – Join Dropped Audio Files (Persistent Panel)
// @namespace    local.joiner
// @license MIT
// @version      1.2
// @description  Drag and drop multiple audio files (.m4a, .mp3, .wav, .ogg, etc.) anywhere on https://www.turbo.ai/dashboard to join them client-side and download as one WAV file. Panel auto-restores if the site re-renders.
// @match        https://www.turbo.ai/dashboard*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  /* ---------- Core Panel ---------- */
  function createPanel() {
    if (document.getElementById('audio-joiner-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'audio-joiner-panel';
    Object.assign(panel.style, {
      position: 'fixed',
      bottom: '12px',
      right: '12px',
      background: '#fff',
      border: '1px solid #ccc',
      boxShadow: '0 4px 10px rgba(0,0,0,.2)',
      padding: '12px',
      zIndex: 2147483647,
      borderRadius: '6px',
      fontFamily: 'system-ui, sans-serif',
      width: '320px'
    });
    panel.innerHTML = `
      <div style="font-weight:600;margin-bottom:6px;">Audio Joiner</div>
      <div id="dropZone" style="border:2px dashed #888;padding:20px;text-align:center;color:#555;cursor:pointer;">
        Drag & Drop audio files here (.m4a, .mp3, .wav, .ogg, etc.)
      </div>
      <div id="status" style="margin-top:8px;font-size:13px;color:#333;"></div>
    `;
    document.body.appendChild(panel);

    const dropZone = panel.querySelector('#dropZone');
    const status = panel.querySelector('#status');

    /* ---------- Helpers ---------- */

    function encodeWAV(interleaved, sampleRate, numChannels) {
      const buffer = new ArrayBuffer(44 + interleaved.length * 2);
      const view = new DataView(buffer);
      const writeStr = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
      let off = 0;
      writeStr(off, 'RIFF'); off += 4;
      view.setUint32(off, 36 + interleaved.length * 2, true); off += 4;
      writeStr(off, 'WAVE'); off += 4;
      writeStr(off, 'fmt '); off += 4;
      view.setUint32(off, 16, true); off += 4;
      view.setUint16(off, 1, true); off += 2;
      view.setUint16(off, numChannels, true); off += 2;
      view.setUint32(off, sampleRate, true); off += 4;
      view.setUint32(off, sampleRate * numChannels * 2, true); off += 4;
      view.setUint16(off, numChannels * 2, true); off += 2;
      view.setUint16(off, 16, true); off += 2;
      writeStr(off, 'data'); off += 4;
      view.setUint32(off, interleaved.length * 2, true); off += 4;
      for (let i = 0; i < interleaved.length; i++) {
        const s = Math.max(-1, Math.min(1, interleaved[i]));
        view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }
      return new Blob([view], { type: 'audio/wav' });
    }

    async function decodeFiles(fileList) {
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      const buffers = [];
      for (const file of fileList) {
        const ab = await file.arrayBuffer();
        const buf = await ctx.decodeAudioData(ab);
        buffers.push(buf);
      }
      await ctx.close();
      return buffers;
    }

    function concatAudio(buffers) {
      const sr = buffers[0].sampleRate;
      const chs = Math.max(...buffers.map(b => b.numberOfChannels));
      const total = buffers.reduce((s, b) => s + b.length, 0);
      const out = new Float32Array(total * chs);
      let pos = 0;
      for (const b of buffers) {
        for (let i = 0; i < b.length; i++) {
          for (let c = 0; c < chs; c++) {
            const val = b.getChannelData(Math.min(c, b.numberOfChannels - 1))[i];
            out[pos++] = val;
          }
        }
      }
      return { interleaved: out, sampleRate: sr, numChannels: chs };
    }

    function download(blob, name) {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = name;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 5000);
    }

    /* ---------- Drop Logic ---------- */
    dropZone.addEventListener('dragover', e => {
      e.preventDefault();
      dropZone.style.borderColor = '#0078d7';
    });
    dropZone.addEventListener('dragleave', e => {
      e.preventDefault();
      dropZone.style.borderColor = '#888';
    });
    dropZone.addEventListener('drop', async e => {
      e.preventDefault();
      dropZone.style.borderColor = '#888';
      const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('audio/'));
      if (!files.length) { status.textContent = 'No audio files dropped.'; return; }
      status.textContent = `Processing ${files.length} file(s)...`;
      try {
        const bufs = await decodeFiles(files);
        status.textContent = 'Concatenating...';
        const joined = concatAudio(bufs);
        status.textContent = 'Encoding WAV...';
        const blob = encodeWAV(joined.interleaved, joined.sampleRate, joined.numChannels);
        download(blob, 'joined_' + Date.now() + '.wav');
        status.textContent = 'Download started.';
      } catch (err) {
        console.error(err);
        status.textContent = 'Error: ' + err.message;
      }
    });
  }

  /* ---------- Persistence Watchdog ---------- */
  createPanel();
  const observer = new MutationObserver(() => {
    if (!document.getElementById('audio-joiner-panel')) createPanel();
  });
  observer.observe(document.body, { childList: true, subtree: true });

})();
// ==UserScript==
// @name         Turbo.ai – Join Dropped Audio Files (Persistent Panel)
// @namespace    local.joiner
// @version      1.2
// @description  Drag and drop multiple audio files (.m4a, .mp3, .wav, .ogg, etc.) anywhere on https://www.turbo.ai/dashboard to join them client-side and download as one WAV file. Panel auto-restores if the site re-renders.
// @match        https://www.turbo.ai/dashboard*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  /* ---------- Core Panel ---------- */
  function createPanel() {
    if (document.getElementById('audio-joiner-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'audio-joiner-panel';
    Object.assign(panel.style, {
      position: 'fixed',
      bottom: '12px',
      right: '12px',
      background: '#fff',
      border: '1px solid #ccc',
      boxShadow: '0 4px 10px rgba(0,0,0,.2)',
      padding: '12px',
      zIndex: 2147483647,
      borderRadius: '6px',
      fontFamily: 'system-ui, sans-serif',
      width: '320px'
    });
    panel.innerHTML = `
      <div style="font-weight:600;margin-bottom:6px;">Audio Joiner</div>
      <div id="dropZone" style="border:2px dashed #888;padding:20px;text-align:center;color:#555;cursor:pointer;">
        Drag & Drop audio files here (.m4a, .mp3, .wav, .ogg, etc.)
      </div>
      <div id="status" style="margin-top:8px;font-size:13px;color:#333;"></div>
    `;
    document.body.appendChild(panel);

    const dropZone = panel.querySelector('#dropZone');
    const status = panel.querySelector('#status');

    /* ---------- Helpers ---------- */

    function encodeWAV(interleaved, sampleRate, numChannels) {
      const buffer = new ArrayBuffer(44 + interleaved.length * 2);
      const view = new DataView(buffer);
      const writeStr = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
      let off = 0;
      writeStr(off, 'RIFF'); off += 4;
      view.setUint32(off, 36 + interleaved.length * 2, true); off += 4;
      writeStr(off, 'WAVE'); off += 4;
      writeStr(off, 'fmt '); off += 4;
      view.setUint32(off, 16, true); off += 4;
      view.setUint16(off, 1, true); off += 2;
      view.setUint16(off, numChannels, true); off += 2;
      view.setUint32(off, sampleRate, true); off += 4;
      view.setUint32(off, sampleRate * numChannels * 2, true); off += 4;
      view.setUint16(off, numChannels * 2, true); off += 2;
      view.setUint16(off, 16, true); off += 2;
      writeStr(off, 'data'); off += 4;
      view.setUint32(off, interleaved.length * 2, true); off += 4;
      for (let i = 0; i < interleaved.length; i++) {
        const s = Math.max(-1, Math.min(1, interleaved[i]));
        view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }
      return new Blob([view], { type: 'audio/wav' });
    }

    async function decodeFiles(fileList) {
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      const buffers = [];
      for (const file of fileList) {
        const ab = await file.arrayBuffer();
        const buf = await ctx.decodeAudioData(ab);
        buffers.push(buf);
      }
      await ctx.close();
      return buffers;
    }

    function concatAudio(buffers) {
      const sr = buffers[0].sampleRate;
      const chs = Math.max(...buffers.map(b => b.numberOfChannels));
      const total = buffers.reduce((s, b) => s + b.length, 0);
      const out = new Float32Array(total * chs);
      let pos = 0;
      for (const b of buffers) {
        for (let i = 0; i < b.length; i++) {
          for (let c = 0; c < chs; c++) {
            const val = b.getChannelData(Math.min(c, b.numberOfChannels - 1))[i];
            out[pos++] = val;
          }
        }
      }
      return { interleaved: out, sampleRate: sr, numChannels: chs };
    }

    function download(blob, name) {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = name;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 5000);
    }

    /* ---------- Drop Logic ---------- */
    dropZone.addEventListener('dragover', e => {
      e.preventDefault();
      dropZone.style.borderColor = '#0078d7';
    });
    dropZone.addEventListener('dragleave', e => {
      e.preventDefault();
      dropZone.style.borderColor = '#888';
    });
    dropZone.addEventListener('drop', async e => {
      e.preventDefault();
      dropZone.style.borderColor = '#888';
      const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('audio/'));
      if (!files.length) { status.textContent = 'No audio files dropped.'; return; }
      status.textContent = `Processing ${files.length} file(s)...`;
      try {
        const bufs = await decodeFiles(files);
        status.textContent = 'Concatenating...';
        const joined = concatAudio(bufs);
        status.textContent = 'Encoding WAV...';
        const blob = encodeWAV(joined.interleaved, joined.sampleRate, joined.numChannels);
        download(blob, 'joined_' + Date.now() + '.wav');
        status.textContent = 'Download started.';
      } catch (err) {
        console.error(err);
        status.textContent = 'Error: ' + err.message;
      }
    });
  }

  /* ---------- Persistence Watchdog ---------- */
  createPanel();
  const observer = new MutationObserver(() => {
    if (!document.getElementById('audio-joiner-panel')) createPanel();
  });
  observer.observe(document.body, { childList: true, subtree: true });

})();