<!-- SOLID CITY · part 3608 -->
.textContent = 'RUN AGAIN';
    log('test complete — ' + turnData.length + ' turn(s)');
  }

  function stopMic() {
    try { clearTimeout(restartTimer); } catch (e) {}
    try { if (rec) { rec.onend = null; rec.onerror = null; rec.stop(); } } catch (e) {}
    rec = null;
  }

  function startMic(onFinal) {
    if (!running) return;
    if (!SR) { log('no speech recognition in this browser'); onFinal(null, 'no-sr'); return; }
    set('s-mic', 'speech-recognition');
    micT0 = Date.now();
    set('s-phase', 'listening');
    log('mic start (turn ' + turn + ')');
    try {
      rec = new SR();
      rec.continuous = true;
      rec.interimResults = true;
      rec.lang = 'en-US';
      var finalText = '', gotFinal = false;
      rec.onresult = function (ev) {
        var s = '';
        for (var i = 0; i < ev.results.length; i++) {
          s += ev.results[i][0].transcript;
          if (ev.results[i].isFinal) gotFinal = true;
        }
        if (gotFinal && !finalText) {
          finalText = s.trim();
          log('final transcript: "' + finalText.slice(0, 80) + '"');
          stopMic();
          onFinal(finalText, null);
        }
      };
      rec.onerror = function (ev) {
        log('mic error: ' + ev.error + ' (turn ' + turn + ')');
        if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') {
          stopMic(); onFinal(null, ev.error);
        }
        // other errors: let onend trigger the restart loop
      };
      rec.onend = function () {
        if (!running || finalText) return;
        // auto-restart: the hands-free loop under test
        if (restarts < maxRestarts) {
          restarts++;
          set('s-restart', String(restarts));
          log('mic auto-restart #' + restarts);
          restartTimer = setTimeout(function () { startMic(onFinal); }, 400);
        } else {
          log('mic restart budget exhausted');
          onFinal(null, 'restart-exhausted');
        }
      };
      rec.start();
    } catch (e) {
      log('mic start failed: ' + e.message);
      onFinal(null, 'mic-start-failed');
    }
  }

  function askSable(text, cb) {
    brainT0 = Date.now();
    set('s-phase', 'thinking');
    log('brain request (turn ' + turn + ')');
    fetch('/api/city/sable', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
      body: JSON.stringify({ message: text, history: [] })
    }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { s: r.status, j: j }; }); })
    .then(function (o) {
      var ms = Date.now() - brainT0;
      var reply = o.j && (o.j.sable || o.j.reply);
      if (!reply) { log('brain failed (' + (o.j && o.j.error || o.s) + ') after ' + ms + 'ms'); cb(null, ms); return; }
      log('brain reply in ' + ms + 'ms: "' + String(reply).slice(0, 80) + '"');
      cb(reply, ms);
    }).catch(function (e) {
      log('brain fetch failed: ' + e.message);
      cb(null, Date.now() - brainT0);
    });
  }

  function speakReply(text, cb) {
    synthT0 = Date.now();
    set('s-phase', 'speaking');
    log('synthesis start (turn ' + turn + ')');
    try {
      speechSynthesis.cancel();
      var u = new SpeechSynthesisUtterance(String(text).slice(0, 600));
      u.rate = 1.2; // FAST default, per Voice Speed V1
      var done = false;
      function end(ok) {
        if (done) return; done = true;
        var ms = Date.now() - synthT0;
        log('synthesis ' + (ok ? 'finished' : 'interrupted') + ' after ' + ms + 'ms');
        cb(ms);
      }
      u.onend = function () { end(true); };
      u.onerror = function (ev) { log('synthesis error: ' + (ev.error || '?')); end(false); };
      // watchdog: iOS sometimes never fires onend
      setTimeout(function () { end(false); }, 30000);
      speechSynthesis.speak(u);
    } catch (e) {
      log('synthesis failed: ' + e.message);
      cb(0);
    }
  }

  function runTurn() {
    if (!running) return;
    if (turn >= maxTurns) { finish(); return; }
    turn++;
    set('s-turn', turn + ' / ' + maxTurns);
    turnT0 = Date.now();
    restarts = 0; set('s-restart', '0');
    var d = { capture: 0, brain: 0, speech: 0, total: 0, restarts: 0, error: null };
    log('—— TURN ' + turn + ' —— speak now');
    startMic(function (text, err) {
      d.capture = Date.now() - micT0;
      d.restarts = restarts;
      if (err || !text) {
        d.error = err || 'no transcript'; d.total = Date.now() - turnT0;
        turnData.push(d); turnCard(turn, d);
        log('turn ' + turn + ' FAILED: ' + d.error);
        runTurn(); // continue to next turn even on failure — the test measures recovery
        return;
      }
      askSable(text, function (reply, brainMs) {
        d.brain = brainMs;
        if (!reply) {
          d.error = 'no reply'; d.total = Date.now() - turnT0;
          turnData.push(d); turnCard(turn, d);
          runTurn();
          return;
 