<!-- SS · part 46 -->
 gridLines() {
    var bs = 60 / (ES.S.bpm || 92), seen = {}, out = [];
    ES.vocalPlan(true).forEach(function (v) { var beats = Math.max(2, Math.round(v.slot / bs * 4) / 4), k = v.text + '|' + beats; if (!seen[k]) { seen[k] = 1; out.push({ text: v.text, beats: beats, bar: Math.round(v.bar || 0), key: k }); } });
    return out;
  }
  var curLine = null, selNote = -1;
  function fillLines() { var L = gridLines(), s = $('#es-sg-line'), v = s.value; s.innerHTML = L.length ? L.map(function (l) { return '<option value="' + esc(l.key) + '">' + (MEL[l.key] ? '♪ ' : '· ') + esc(l.text.slice(0, 48)) + '</option>'; }).join('') : '<option value="">No lyrics on the timeline yet</option>'; if (v) s.value = v; curLine = s.value || null; draw(); }
  $('#es-sg-line').addEventListener('change', function () { curLine = this.value; selNote = -1; draw(); noteUI(); });
  $('#es-sg-line').addEventListener('focus', fillLines);
  // ---- melody
  $('#es-sg-write').addEventListener('click', function () {
    var L = gridLines(); if (!L.length) return say('Generate or write lyrics first.');
    var b = this; ES.busy(b, true); say('Writing a melody for ' + L.length + ' lines…');
    ES.api('POST', '/api/ss/melody', { key: ES.S.key, bpm: ES.S.bpm, chords: ES.S.chords || [], lo: G.lo, hi: G.hi, lines: L.map(function (l) { return { text: l.text, beats: l.beats, bar: l.bar }; }) })
      .then(function (j) { j.lines.forEach(function (l) { MEL[l.text + '|' + l.beats] = { notes: l.notes, source: l.source }; }); store(MK, MEL); fillLines(); say('Melody ready: ' + j.ai + ' lines by AI, ' + j.rules + ' by rules. ' + (j.note || 'Tweak notes, then Sing.')); })
      .catch(function (e) { say(e.message); }).then(function () { ES.busy(b, false); });
  });
  // ---- singing
  function lineNotes(key) { return (MEL[key] && MEL[key].notes) || null; }
  function sing(keys, btn) {
    keys = keys.filter(lineNotes); if (!keys.length) return say('Write a melody first.');
    var params = {}; CTL.forEach(function (c) { params[c[0]] = G[c[0]]; });
    ES.busy(btn, true); say('Singing ' + keys.length + ' line(s)… about ' + Math.round(keys.reduce(function (m, k) { return m + new Set(lineNotes(k).map(function (n) { return n.w; })).size; }, 0) * 3 + 10) + ' s.');
    ES.api('POST', '/api/ss/sing', { voice: ES.S.voice.name, speed: ES.S.voice.speed, bpm: ES.S.bpm, engine: G.engine, params: params, lines: keys.map(function (k) { return { notes: lineNotes(k) }; }) })
      .then(function (st) { return new Promise(function (res, rej) { var n = 0; (function poll() { if (++n > 300) return rej(new Error('Singing took too long.')); ES.api('GET', '/api/ss/sing/' + st.job).then(function (j) { if (j.status === 'done') return res(j); say('Singing line ' + Math.min(j.total, j.done + 1) + ' of ' + j.total + '…'); setTimeout(poll, 2000); }, rej); })(); }); })
      .then(function (j) {
        var c = Tone.getContext().rawContext;
        return Promise.all((j.clips || []).map(function (cl) { return new Promise(function (res) { c.decodeAudioData(ES.b64ToBuf(cl.b64), function (buf) { var a = ES.analyse(buf); ES.VOX.clips[keys[cl.i].split('|')[0]] = { buf: new Tone.ToneAudioBuffer(buf), dur: buf.duration, peaks: a.peaks, rms: a.rms, sung: true }; res(); }, function () { res(); }); }); }))
          .then(function () { ES.drawTimeline(); say('Sung ' + (j.clips || []).length + ' line(s) in ' + (j.stats ? j.stats.elapsed + ' s, peak ' + j.stats.peak_mb + ' MB' : '') + '. Press ▶ to hear it over the beat.'); });
      }).catch(function (e) { say(e.message); }).then(function () { ES.busy(btn, false); });
  }
  $('#es-sg-all').addEventListener('click', function () { ES.unlock().catch(function () {}); sing(gridLines().map(function (l) { return l.key; }), this); });
  $('#es-sg-one').addEventListener('click', function () { if (!curLine) return; ES.unlock().catch(function () {}); sing([curLine], this); });
  $('#es-sg-play').addEventListener('click', function () { var t = curLine && curLine.split('|')[0], c = t && ES.VOX.clips[t]; if (!c) return say('Sing this line first.'); ES.unlock().then(function () { new Tone.Player(c.buf).toDestination().start(); }); });
  // ---- piano-roll style melody editor
  var cv = $('#es-sg-cv'), drag = null;
  function geo() { var ns = lineNotes(curLine) || [], tot = ns.reduce(function (m, n) { return m + n.beats; }, 0) || 8, lo = G.lo - 5, hi = G.hi + 5; return { ns: ns, tot: tot, lo: lo, hi: hi, w: cv.clientWidth, h: cv.clientHeight }; }
  function draw() {
    var g0 = geo(), w = g0.w, h = g0.h; if (!w) return; var dpr = window.devicePixelRatio || 1; cv.width = w * dpr; cv.height = h * dpr; var g = cv.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0);
    g.fillStyle = '#050505'; g.fillRect(0, 0, w, h); var rows = g0.hi - g0.lo + 1, rh = h / rows;
    for (var m = g0.lo; m <= g0.hi; m++) { var y = h - (m - g0.lo + 1) * rh; g.fillStyle = /#|b/.test(NN[m % 12]) ? '#0b0b0b' : '#121212'; g.fillRect(0, y, w, rh); if (m % 12 =