<!-- SS · part 25 -->
xtContent = (+this.value).toFixed(2); });
  $('#ss-bpl').addEventListener('change', renderLines);
  $('#ss-vvol').addEventListener('input', function () { if (E) E.voxBus.volume.value = +this.value; });
  function b64ToBuf(b64) { var bin = atob(b64), u = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; }
  $('#ss-render').addEventListener('click', function () {
    var btn = this, lines = lyricLines();
    if (!lines.length) { toast('No lyric lines to voice yet.'); return; }
    var st = $('#ss-vstatus');
    busy(btn, true); st.textContent = 'Rendering ' + lines.length + ' lines with ' + $('#ss-voice').value + '… this takes about ' + Math.max(15, Math.round(lines.length * 5 + 6)) + 's.';
    unlock().catch(function () {}).then(function () {
      return api('POST', '/api/ss/voice', { lines: lines, voice: $('#ss-voice').value, speed: +$('#ss-vspeed').value });
    }).then(function (start) {
      // Poll the background job (about 5s per line on the server).
      return new Promise(function (resolve, reject) {
        var tries = 0;
        (function poll() {
          if (++tries > 200) return reject(new Error('Voice took too long. Try fewer lines.'));
          api('GET', '/api/ss/voice/' + encodeURIComponent(start.job)).then(function (j) {
            if (j.status === 'done') return resolve(j);
            st.textContent = 'Voicing line ' + Math.min(j.total, j.done + 1) + ' of ' + j.total + '…';
            setTimeout(poll, 2000);
          }, reject);
        })();
      });
    }).then(function (j) {
      buildEngine();
      var ctx = Tone.getContext().rawContext;
      var clips = [];
      return Promise.all((j.clips || []).map(function (c) {
        return new Promise(function (res) {
          ctx.decodeAudioData(b64ToBuf(c.b64), function (buf) { clips[c.i] = { dur: buf.duration, buf: new Tone.ToneAudioBuffer(buf) }; res(); }, function () { res(); });
        });
      })).then(function () {
        S.voxClips = clips; S.voxLines = lines;
        var n = clips.filter(Boolean).length;
        st.textContent = n + ' of ' + lines.length + ' lines voiced. Press Play with beat.';
        renderLines();
      });
    }).catch(function (e) { st.textContent = e.message; })
      .then(function () { busy(btn, false); });
  });
  function voxPlan() {
    var intro = +$('#ss-intro').value, bpl = +$('#ss-bpl').value, out = [];
    (S.voxClips || []).forEach(function (c, i) { if (c) out.push({ bar: intro + i * bpl, clip: c }); });
    return out;
  }
  function scheduleVox() {
    (E.players || []).forEach(function (p) { try { p.unsync(); p.dispose(); } catch (e) {} });
    E.players = [];
    if (!$('#ss-vox-on').checked) return;
    voxPlan().forEach(function (v) {
      var p = new Tone.Player(v.clip.buf).connect(E.voxBus);
      p.sync().start(v.bar + ':0:0');
      E.players.push(p);
    });
  }
  function autoBars() {
    var plan = voxPlan();
    if (!plan.length || !$('#ss-vox-on').checked) return 8;
    var last = plan[plan.length - 1], bs = barSec();
    return Math.min(64, last.bar + Math.ceil(last.clip.dur / bs) + 1);
  }

  // ---------------------------------------------------------------- RECORD + SAVE
  var recBlob = null, recUrl = null;
  $('#ss-rec').addEventListener('click', function () {
    var btn = this;
    unlock().then(function () {
      if (!E.recorder) { toast('This browser cannot record audio. Try Safari 14.5+ or Chrome.'); return; }
      var v = $('#ss-rec-bars').value, bars = v === 'auto' ? autoBars() : +v;
      var secs = bars * barSec() + 1.2;
      if (secs > 300) { toast('That is longer than 5 minutes; pick fewer bars.'); return; }
      stop(); busy(btn, true);
      E.recorder.start();
      start().then(function () {
        var t0 = Date.now();
        var iv = setInterval(function () { btn.textContent = '● Recording ' + Math.max(0, Math.ceil(secs - (Date.now() - t0) / 1000)) + 's'; }, 250);
        setTimeout(function () {
          clearInterval(iv); stop();
          setTimeout(function () {
            E.recorder.stop().then(function (blob) {
              recBlob = blob; if (recUrl) URL.revokeObjectURL(recUrl); recUrl = URL.createObjectURL(blob);
              $('#ss-rec-audio').src = recUrl; $('#ss-rec-out').hidden = false;
              busy(btn, false); btn.textContent = '● Record again';
              toast('Recorded ' + bars + ' bars. Listen, then save.');
            });
          }, 900); // let reverb tails ring out
        }, secs * 1000);
      });
    }).catch(function () { busy(btn, false); toast('Tap again to start audio.'); });
  });
  $('#ss-rec-discard').addEventListener('click', function () { recBlob = null; $('#ss-rec-out').hidden = true; $('#ss-rec-audio').removeAttribute('src'); });
  function songForm(withAudio) {
    var fd = new FormData();
    fd.append('title', S.title || 'Untitled'); fd.append('lyrics', S.lyrics || ''); fd.append('bpm', S.bpm); fd.append('