<!-- SS · part 95 -->
erOfChannels), src = ab.sampleRate, len = Math.floor(ab.length * rate / src), data = [];
    for (var c = 0; c < ch; c++) data.push(ab.getChannelData(c));
    var buf = new ArrayBuffer(44 + len * ch * 2), v = new DataView(buf);
    function w(o, s) { for (var i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }
    w(0, 'RIFF'); v.setUint32(4, 36 + len * ch * 2, true); w(8, 'WAVE'); w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, ch, true);
    v.setUint32(24, rate, true); v.setUint32(28, rate * ch * 2, true); v.setUint16(32, ch * 2, true); v.setUint16(34, 16, true); w(36, 'data'); v.setUint32(40, len * ch * 2, true);
    var o = 44, ratio = src / rate;
    for (var i = 0; i < len; i++) { var si = Math.min(ab.length - 1, Math.floor(i * ratio)); for (var c2 = 0; c2 < ch; c2++) { var s = Math.max(-1, Math.min(1, data[c2][si])); v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true); o += 2; } }
    return new Blob([buf], { type: 'audio/wav' });
  }
  function projectForm(audio, name) {
    var fd = new FormData();
    fd.append('title', S.title || 'Untitled'); fd.append('lyrics', S.lyrics || ''); fd.append('bpm', S.bpm); fd.append('key', S.key);
    fd.append('patterns', JSON.stringify(S.sections.verse.patterns));
    fd.append('arrangement', JSON.stringify({ sections: S.sections, arrangement: S.arrangement }));
    var prov = {}; hook('provenance').forEach(function (o) { Object.assign(prov, o); });
    fd.append('studio', JSON.stringify({ provenance: Object.keys(prov).length ? prov : undefined, v: 2, sounds: S.sounds, vols: S.vols, mutes: S.mutes, solos: S.solos, swing: S.swing, chords: S.chords, suno: S.suno, genre: S.genre, mood: S.mood, master: S.master, voice: S.voice }));
    if (audio) fd.append('audio', audio, name);
    return fd;
  }
  function ensureTitle() { if (S.title) return true; var t = prompt('Song title?', 'Untitled'); if (t === null) return false; S.title = t.trim() || 'Untitled'; save(); return true; }
  function showX(msg) { $('#es-exportbox').hidden = false; $('#es-xstatus').textContent = msg; }
  // Offline render of the whole arrangement (+ placed vocals) — returns an AudioBuffer.
  function renderOffline() {
    var dur = songEnd() + 2.5, vp = vocalPlan();
    return Tone.Offline(function () {
      var G = makeGraph(false), steps = PLAN.bars * 16;
      for (var st = 0; st < steps; st++) triggerStep(G, st, st * stepSec() + swingOffset(st % 16) + 0.02);
      vp.forEach(function (v) { var c = VOX.clips[v.text]; if (c) new Tone.Player(c.buf).connect(G.vin).start(v.t + 0.02); });
      hook('schedule', G, false);
      return G.verb.ready;
    }, dur, 2, 44100).then(function (tb) { return tb.get ? tb.get() : tb; });
  }
  window.__esRenderOffline = renderOffline; // used by the headless smoke test only
  $('#es-export').addEventListener('click', function () {
    var btn = this; if (!window.Tone || !ensureTitle()) return;
    stop(); busy(btn, true);
    showX('Rendering ' + mmss(songEnd() + 2.5) + ' offline (full arrangement' + (Object.keys(VOX.clips).length ? ' + vocals' : '') + ')…');
    unlock().catch(function () {}).then(renderOffline).then(function (ab) {
      var rate = 44100; if (ab.length * 4 > 44 * 1048576) rate = 32000; if (ab.length * (rate / ab.sampleRate) * 4 > 44 * 1048576) rate = 22050;
      var wav = wavEncode(ab, rate); var dl = $('#es-dl-wav'); if (dl.href) URL.revokeObjectURL(dl.href); dl.href = URL.createObjectURL(wav); dl.download = (S.title || 'epistles-studio').replace(/[^\w\- ]+/g, '').trim() + '.wav'; dl.hidden = false; showX('Uploading ' + (wav.size / 1048576).toFixed(1) + ' MB WAV → MP3…');
      return api('POST', '/api/ss/save', projectForm(wav, 'epistles-studio.wav'));
    }).then(function (j) { showX('Exported "' + j.song.title + '" to My Songs as MP3.'); var a = $('#es-rec-audio'); a.src = j.song.url; a.hidden = false; $('#es-rec-row').hidden = true; songsLoaded = false; toast('Exported to My Songs.'); })
      .catch(function (e) { showX('Export failed: ' + (e && e.message || e)); })
      .then(function () { busy(btn, false); });
  });
  var recBlob = null, recUrl = null, recording = false;
  $('#es-rec').addEventListener('click', function () {
    var btn = this;
    unlock().then(function () {
      if (!E.recorder) return toast('This browser cannot record audio — use Export instead.');
      if (recording) return finishRec();
      recording = true; btn.classList.add('is-on'); btn.textContent = '■ STOP';
      E.recorder.start(); play(0);
      showX('Recording live… press ■ STOP (or let the song finish).');
      E.T.scheduleOnce(function (t) { Tone.getDraw().schedule(function () { if (recording) finishRec(); }, t); }, totalSec() + 1.5);
    }).catch(function () { toast('Tap again to start audio.'); });
  });
  function finishRec() {
    recording = false; var btn = $('#es-rec'); btn.classList.remove('is-on'); btn.textContent = '● REC'; stop();
    setTimeout(function () { E.