<!-- SS · part 48 -->
st && ev.target.closest('.es-mtabs button[data-m="voice"]'); if (b) setTimeout(fillLines, 50); });
})();


===== FILE: public/ss/studio-wave.js =====
/* Epistles Studio — WAVE STUDIO: retro waveform editor + effects rack. Original implementation (WebAudio/Tone.js + server ffmpeg). Non-destructive: sources are never changed; results save as new stems/songs. */
(function () {
  'use strict';
  var host = document.getElementById('es-wave'); if (!host) return;
  var ES = null; function $(s) { return host.querySelector(s); }
  function toast(m) { if (ES) ES.toast(m, 4000); }
  function ctx() { return Tone.getContext().rawContext; }
  function mk(ch, len, sr) { return new AudioBuffer({ numberOfChannels: ch, length: Math.max(1, len), sampleRate: sr }); }
  function slice(b, a, z) { var o = mk(b.numberOfChannels, z - a, b.sampleRate); for (var c = 0; c < b.numberOfChannels; c++) o.copyToChannel(b.getChannelData(c).subarray(a, z), c); return o; }
  function concat(list, ch, sr) { var n = list.reduce(function (m, b) { return m + (b ? b.length : 0); }, 0), o = mk(ch, n, sr), p = 0; list.forEach(function (b) { if (!b || !b.length) return; for (var c = 0; c < ch; c++) o.copyToChannel(b.getChannelData(Math.min(c, b.numberOfChannels - 1)), c, p); p += b.length; }); return o; }
  function toRate(b, sr, ch) { if (b.sampleRate === sr && b.numberOfChannels === ch) return Promise.resolve(b); var OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext, oc = new OAC(ch, Math.ceil(b.duration * sr), sr), s = oc.createBufferSource(); s.buffer = b; s.connect(oc.destination); s.start(); return oc.startRendering(); }
  var W = { buf: null, name: '', sel: [0, 0], cur: 0, view: [0, 0], undo: [], redo: [], clip: null, pending: null, src: null };
  var FX = [
    ['declick', 'De-click / pop', 'srv'], ['declip', 'De-clip (snaps)', 'srv'], ['denoise', 'Noise reduction', 'srv'], ['normalize', 'Normalize', 'js'], ['amplify', 'Amplify', 'js'],
    ['fadein', 'Fade in', 'js'], ['fadeout', 'Fade out', 'js'], ['reverse', 'Reverse', 'js'], ['karaoke', 'Vocal remover', 'js'], ['pitch', 'Pitch shift', 'srv'], ['stretch', 'Time stretch', 'srv'],
    ['echo', 'Echo / delay', 'tone'], ['room', 'Reverb: Room', 'tone'], ['hall', 'Reverb: Hall', 'tone'], ['cathedral', 'Reverb: Cathedral', 'tone'], ['chorus', 'Chorus', 'tone'], ['flanger', 'Flanger', 'srv'], ['phaser', 'Phaser', 'tone'], ['distortion', 'Distortion', 'tone'], ['eq', 'Apply EQ preset', 'eq']
  ];
  var AMT = { amplify: [-24, 24, 0.5, 6, ' dB'], pitch: [-12, 12, 1, 2, ' st'], stretch: [0.5, 2, 0.05, 1.25, 'x speed'], denoise: [0, 1, 0.05, 0.5, ''], echo: [0, 1, 0.05, 0.4, ''], chorus: [0, 1, 0.05, 0.5, ''], flanger: [0, 1, 0.05, 0.5, ''], phaser: [0, 1, 0.05, 0.5, ''], distortion: [0, 1, 0.05, 0.4, ''], room: [0, 1, 0.05, 0.35, ' wet'], hall: [0, 1, 0.05, 0.35, ' wet'], cathedral: [0, 1, 0.05, 0.4, ' wet'], karaoke: [0, 1, 0.05, 1, ''] };
  host.innerHTML =
    '<h2 class="es-h es-wv-h"><span class="es-ic">∿</span> WAVE STUDIO <small>EDIT · EFFECTS · NON-DESTRUCTIVE</small></h2>' +
    '<div class="es-wv-src"><select id="es-wv-pick" aria-label="Source"></select><button type="button" class="es-btn es-btn-sm" id="es-wv-load">Load</button></div>' +
    '<div class="es-wv-lcd" id="es-wv-lcd">NO WAVE LOADED</div>' +
    '<canvas id="es-wv-cv" class="es-wv-cv"></canvas>' +
    '<div class="es-wv-bar"><button type="button" data-w="play">▶ Sel</button><button type="button" data-w="playall">▶ All</button><button type="button" data-w="stop">■</button><button type="button" data-w="zin">＋</button><button type="button" data-w="zout">－</button><button type="button" data-w="fit">Fit</button><button type="button" data-w="all">Sel all</button></div>' +
    '<div class="es-wv-bar"><button type="button" data-w="cut">Cut</button><button type="button" data-w="copy">Copy</button><button type="button" data-w="paste">Paste</button><button type="button" data-w="mix">Mix-paste</button><button type="button" data-w="del">Delete</button><button type="button" data-w="crop">Trim to sel</button><button type="button" data-w="silence">Insert silence</button><button type="button" data-w="undo">↶ Undo</button><button type="button" data-w="redo">↷ Redo</button></div>' +
    '<div class="es-wv-rack" id="es-wv-rack">' + FX.map(function (f) { return '<button type="button" data-fx="' + f[0] + '">' + f[1] + '</button>'; }).join('') + '</div>' +
    '<div class="es-wv-amt"><label class="es-slider"><span><i id="es-wv-fxn">Amount</i> <b id="es-wv-amt-v">—</b></span><input type="range" id="es-wv-amt" min="0" max="1" step="0.05" value="0.5" disabled></label><select id="es-wv-eqp" aria-label="EQ preset"></select></div>' +
    '<div class="es-wv-pend" id="es-wv-pend" hidden><span id="es-wv-pend-t"></span><button type="button" class="es-btn es-btn-sm" data-w="pplay">▶ Preview</button><button type="button" class="es-btn es-btn-sm es-btn-red" data-w="apply">Apply</button><button type="button" class="es-btn