<!-- SOLID CITY · part 23 -->
{ color: 0xff9a30, transparent: true, opacity: 0.85 }));
  flame.position.y = 14; root.add(flame);
  var flameGlow = new T.PointLight(0xff9a30, 1.4, 120);
  flameGlow.position.y = 14; root.add(flameGlow);
  foot.push([0, 0, 32, 32, 0, 9, 0]);
  // North ramp: a stair from the terrace down toward the city
  var ramp = new T.Mesh(new T.BoxGeometry(40, 2, 120), brickMat);
  ramp.position.set(0, -18, 595); ramp.rotation.x = 0.32; root.add(ramp);
  // stepped collision for the ramp (AABBs cannot slope)
  foot.push([0, 550, 40, 30, -9, 1, 0]);
  foot.push([0, 580, 40, 30, -19, -9, 0]);
  foot.push([0, 610, 40, 30, -29, -19, 0]);
  foot.push([0, 640, 40, 30, -37, -29, 0]);
  root.userData = {
    footprint: foot, center: { x: 0, z: 0 }, radius: 560, height: 30,
    update: function (t) {
      flame.scale.y = 1 + Math.sin(t * 3.1) * 0.12;
      flameGlow.intensity = 1.3 + Math.sin(t * 3.1) * 0.25;
    }
  };
  return root;
}
export default createAncestorRoads;


===== FILE: public/city/audio-drive.js =====
/* KINGDOM COMMUNICATION RELAY — Audio Drive (King's order 2026-10-11)
 * One shared relay. One lightweight receiver per player session, bound at ENTER.
 * Every AI citizen and human player communicates through TEXT.
 * The account's receive mode decides: TEXT only, AUDIO only, or BOTH.
 * For audio-enabled players, incoming text is synthesized ONCE, then discarded.
 *
 * THE AUDIO LAW (enforced here):
 *  1. No recording or archiving of message audio — synthesis only, never capture.
 *  2. No synthesized voice files stored in player accounts — nothing is written.
 *  3. Each message spoken only once per recipient — spoken-id set, session memory.
 *  4. No automatic replay after reconnection — the set is never persisted; history renders as text only.
 *  5. Temporary audio discarded after playback — utterance hooks released on end.
 *  6. Text history independent of audio — render() draws text; speech is separate.
 *  7. Citizen identity and voice profile preserved — stable per-citizen voice mapping.
 *  8. Human mic input transient — the existing path sends text; no audio retained.
 */
(function (root) {
  'use strict';
  var MODE_KEY = 'sc-receive-mode';
  var MODES = { text: 1, audio: 1, both: 1 };
  var spoken = {}; // msgId -> true. Session memory ONLY. Never persisted, never cleared for replay.
  var seq = 0;
  var attached = false;

  function getMode() {
    try {
      var store = (typeof localStorage !== 'undefined') ? localStorage : null;
      var m = store && store.getItem(MODE_KEY);
      if (m && MODES[m]) return m;
    } catch (e) {}
    return 'both';
  }
  function setMode(m) {
    if (!MODES[m]) return getMode();
    try { if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, m); } catch (e) {}
    paint();
    return m;
  }

  // Voice identity per citizen. SABLE keeps her established voice.
  // Deterministic per-citizen assignment for the rest. No persona controls in V1.
  function voiceProfile(id) {
    if (id === 'sable') return { pitch: 0.95, re: /samantha|female|zira|aria|jenny|serena|karen|moira|victoria|google uk english female/i };
    var h = 0, s = String(id == null ? '?' : id);
    for (var i = 0; i < s.length; i++) h = ((h * 31) + s.charCodeAt(i)) >>> 0;
    var pitches = [1.0, 1.08, 0.92, 1.15, 0.85];
    return { pitch: pitches[h % pitches.length], re: /./ };
  }
  function pickVoice(profile) {
    try {
      var vs = speechSynthesis.getVoices() || [];
      var v = vs.filter(function (x) { return /en/i.test(x.lang) && profile.re.test(x.name); })[0]
           || vs.filter(function (x) { return /^en/i.test(x.lang); })[0];
      return v || null;
    } catch (e) { return null; }
  }

  // Speak once. Interruption: the newest message owns the air.
  // Nothing recorded, nothing retained, nothing replayed.
  function speakOnce(text, profile) {
    try {
      if (typeof speechSynthesis === 'undefined' || !text) return;
      speechSynthesis.cancel();
      var clean = String(text).replace(/[*_#`>\[\]]/g, '').slice(0, 600);
      var u = new SpeechSynthesisUtterance(clean);
      var v = pickVoice(profile);
      if (v) u.voice = v;
      u.rate = (root.CityVoiceSpeed ? root.CityVoiceSpeed.rate() : 1.2);
      u.pitch = profile.pitch || 1.0;
      if (root.CityPlayer) { try { root.CityPlayer.duck(true); } catch (e) {} }
      var done = false;
      function release() {
        if (done) return; done = true;
        root.__sableSpeaking = false;
        try { if (root.CityPlayer) root.CityPlayer.duck(false); } catch (e2) {}
        u.onend = u.onerror = null; // discarded after playback. No hooks remain.
        try { var ML2 = root.micLock || null; if (ML2 && ML2.on) { ML2.lastVoice = Date.now(); ML2.aborts = 0; if (typeof root.micRecStart === 'function') setTimeout(root.micRecStart, 350); } } catch (e6) {}
      }
      u.onend = release; u.onerror = release;
      root.__sableSpeaking = true;
      try { var ML = root.micLock |