<!-- SS · part 17 -->
 save(); renderTracks(); toast(track.charAt(0).toUpperCase() + track.slice(1) + ' regenerated.'); })
      .catch(function (e) { toast(e.message, 5000); busy(btn, false); });
  }

  // ---------------------------------------------------------------- VOICE
  function lyricLines() {
    return String(S.lyrics || '').split(/\r?\n/).map(function (l) { return l.trim(); })
      .filter(function (l) { return l && !/^\[[^\]]*\]$/.test(l) && !/^\(.*\)$/.test(l); }).slice(0, 24)
      .map(function (l) { return l.slice(0, 160); });
  }
  function barSec() { return 4 * 60 / S.bpm; }
  function renderLines() {
    var lines = lyricLines(), bpl = +$('#ss-bpl').value, slot = bpl * barSec();
    var stale = S.voxClips && S.voxLines && S.voxLines.join('\n') !== lines.join('\n');
    $('#ss-lines').innerHTML = lines.length ? lines.map(function (l, i) {
      var c = S.voxClips && !stale ? S.voxClips[i] : null;
      var cls = c ? (c.dur > slot ? 'warn' : 'ok') : '';
      return '<li class="' + cls + '">' + esc(l) + (c ? '<span class="ss-ldur">' + c.dur.toFixed(1) + 's' + (c.dur > slot ? ' — longer than ' + bpl + ' bar' + (bpl > 1 ? 's' : '') + ', try speed up' : '') + '</span>' : '') + '</li>';
    }).join('') : '<li class="ss-hint">No lyrics yet — write a song on the Write tab (or type lyrics there).</li>';
    if (stale) $('#ss-vstatus').textContent = 'Lyrics changed since the last render — render again.';
  }
  $('#ss-vspeed').addEventListener('input', function () { $('#ss-vspeed-v').textContent = (+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.'); retu