<!-- SS · part 93 -->
];
    vocalPlan().forEach(function (v) { var c = VOX.clips[v.text]; if (!c) return; c.rms.forEach(function (r, j) { pts.push([(v.t + j / c.rms.length * c.dur) / tot * w, r]); }); });
    if (!pts.length) { g.fillStyle = '#777'; g.fillText('Render vocals to see the expression curve', 34, h / 2 + 3); return; }
    var bias = S.voice.pitch / 12; g.strokeStyle = '#ff3048'; g.lineWidth = 1.6; g.beginPath();
    pts.forEach(function (p, i) { var y = h - 6 - Math.max(0, Math.min(1, p[1] * 3.2 + 0.15 + bias * 0.3)) * (h - 12); if (i) g.lineTo(p[0], y); else g.moveTo(p[0], y); }); g.stroke();
  }
  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; }
  function analyse(buf) { var d = buf.getChannelData(0), N = 160, peaks = [], rms = [], step = Math.max(1, Math.floor(d.length / N)); for (var i = 0; i < N; i++) { var mx = 0, s = 0; for (var j = i * step; j < (i + 1) * step && j < d.length; j++) { var a = Math.abs(d[j]); if (a > mx) mx = a; s += d[j] * d[j]; } peaks.push(Math.min(1, mx * 1.4)); rms.push(Math.sqrt(s / step)); } return { peaks: peaks, rms: rms }; }
  function renderVocals(btn) {
    var lines = uniqueLines(); if (!lines.length) { toast('No lyrics placed in the song yet — generate or write lyrics first.'); return Promise.resolve(false); }
    if (lines.length > 40) { lines = lines.slice(0, 40); toast('Voicing the first 40 unique lines.'); }
    var st = $('#es-vstatus'), badge = $('#es-av-state'); busy(btn, true); badge.textContent = 'RENDERING'; badge.classList.add('busy');
    st.textContent = 'Rendering ' + lines.length + ' unique lines with ' + S.voice.name + '… about ' + Math.round(lines.length * 5 + 8) + 's.';
    return unlock().catch(function () {}).then(function () {
      return api('POST', '/api/ss/voice', { lines: lines, voice: S.voice.name, speed: S.voice.speed, pitch: S.voice.pitch });
    }).then(function (start) {
      return new Promise(function (resolve, reject) {
        var tries = 0;
        (function poll() {
          if (++tries > 250) 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) {
      engine(); var ctx = Tone.getContext().rawContext, fresh = {};
      return Promise.all((j.clips || []).map(function (c) {
        return new Promise(function (res) { ctx.decodeAudioData(b64ToBuf(c.b64), function (buf) { var a = analyse(buf); fresh[lines[c.i]] = { buf: new Tone.ToneAudioBuffer(buf), dur: buf.duration, peaks: a.peaks, rms: a.rms }; res(); }, function () { res(); }); });
      })).then(function () {
        VOX.clips = fresh; var n = Object.keys(fresh).length, over = (VOX.clips = fresh, vocalPlan().filter(function (v) { return v.spill > 0.3; }).length);
        st.textContent = n + ' of ' + lines.length + ' lines voiced and placed on the timeline.' + (over ? ' ' + over + ' line(s) spill past their section — raise the voice speed or the section bars.' : '');
        drawTimeline(); drawCurve(); return true;
      });
    }).catch(function (e) { st.textContent = e.message; return false; })
      .then(function (ok) { busy(btn, false); badge.textContent = 'READY'; badge.classList.remove('busy'); return ok; });
  }
  $('#es-render').addEventListener('click', function () { renderVocals(this); });

  // ------------------------------------------------------------------ writer
  function chatAdd(who, text) { S.chat.push({ who: who, text: text }); S.chat = S.chat.slice(-12); renderChat(); save(); }
  function renderChat() {
    var c = $('#es-chat'), msgs = S.chat.length ? S.chat : [{ who: 'ai', text: 'Describe the song — I\'ll write the lyrics, build every section (intro, verse, chorus, bridge, outro), arrange it and voice it.' }];
    c.innerHTML = msgs.map(function (m) { return '<div class="es-msg' + (m.who === 'me' ? ' me' : '') + '">' + esc(m.text) + '</div>'; }).join(''); c.scrollTop = c.scrollHeight;
  }
  function renderBlocks() {
    var bl = blocks();
    $('#es-blocks').innerHTML = bl.length ? bl.map(function (b, i) { return '<div class="es-block"><b>' + esc(b.label.toUpperCase()) + '</b><textarea data-bi="' + i + '" rows="' + Math.max(1, b.lines.length) + '" aria-label="' + esc(b.label) + ' lyrics">' + esc(b.lines.join('\n')) + '</textarea></div>'; }).join('')
      : '<div class="es-hint">Lyrics appear here as section blocks. You can edit any line.</div>';
    $('#es-suno').value = S.suno || '';
  }
  $('#es-blocks').addEventListener('input', function (e) {
    var t = e.target; if (!t.hasAttribute('data-bi')) return;
    t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px';
    var bl = blocks(); bl[+t.getAttr