<!-- SOLUV · part 657 -->
});
    if (!r.ok) { T.problem = 'Saved voices unavailable (HTTP ' + r.status + ')'; console.error('[soluv-voice] ' + T.problem); renderPanel(); return; }
    const j = await r.json();
    for (const id of ids) { T.asked.set('i' + id, now); delete T.server.byId[id]; }
    for (const n of names) { T.asked.set('n' + n, now); delete T.server.byName[n]; }
    Object.assign(T.server.byId, j.byId || {}); Object.assign(T.server.byName, j.byName || {});
    if (j.mine !== undefined && T.me && T.me.username) T.mine = j.mine;
    renderPanel();
  }

  /* ---------- speaking ---------- */
  function utter(text, kind) {
    const u = new SpeechSynthesisUtterance(text);
    const v = kind === 'male' ? T.picked.male : kind === 'female' ? T.picked.female : null;
    if (v) { u.voice = v; u.lang = v.lang; }
    // one device voice only (or both picks the same): pitch keeps male and female apart
    const same = T.picked.male && T.picked.male === T.picked.female;
    u.pitch = kind !== 'default' && (!v || same) ? PITCH[kind] : 1;
    u.onerror = e => { if (e.error !== 'interrupted' && e.error !== 'canceled') { T.problem = 'Read aloud failed: ' + e.error; console.error('[soluv-voice] ' + T.problem); renderPanel(); } };
    return u;
  }
  function speak(m) {
    if (!T.on || !synth) return;
    const key = m.user || 'guest:' + m.from, v = voiceOf(m);
    let text = String(m.text || '').trim(); if (!text) return;
    if (text.length > 1200) text = text.slice(0, 1200) + '. The rest is on the wall.';
    const say = (key !== T.lastSpeaker ? (m.from || 'Guest') + ' says: ' : '') + text;
    T.lastSpeaker = key; T.spoken++;
    synth.speak(utter(say, v.kind));
  }
  /* hear a wall: call with every wall; new lines (ids above what was there when you switched on) are read aloud */
  function attach(wall) {
    wall.addEventListener('messages', e => {
      const list = e.newest || [];
      for (const m of list) remember(m);
      const top = Math.max(0, ...list.map(m => m.id));
      if (!T.on && panel.hidden) { T.floor = T.floor === Infinity ? top : Math.max(T.floor, top); return; }   // data thrift: no voice lookups while silent
      fetchVoices(list).catch(err => { T.problem = 'Saved voices failed: ' + err.message; console.error('[soluv-voice] ' + T.problem); renderPanel(); })
        .then(() => { for (const m of list) if (m.id > T.floor) speak(m); for (const m of list) T.floor = Math.max(T.floor === Infinity ? m.id : T.floor, m.id); });
    });
    for (const m of wall.wall.messages.slice(-40)) remember(m);
  }
  function remember(m) { const k = lower(m.from); if (!k) return; T.recent = [m, ...T.recent.filter(x => lower(x.from) !== k)].slice(0, 12); if (panel && !panel.hidden) renderPanel(); }
  function setOn(on, walls) {
    T.on = !!on; localStorage.setItem(LS_ON, JSON.stringify(T.on));
    if (T.on) {
      T.floor = Math.max(0, ...((walls || []).flatMap(w => w.wall.messages.map(m => m.id))));
      if (synth) { synth.cancel(); synth.speak(utter('Read aloud on', T.mine || 'default')); }   // a tap starts speech on iPhone
    } else if (synth) synth.cancel();
    renderPanel(); return { ok: !!synth || !T.on, on: T.on, error: synth ? null : T.problem };
  }

  /* ---------- your own voice, saved for everyone ---------- */
  async function refreshMine() {
    T.me = await whoAmI();
    if (!T.me.username) { T.mine = null; renderPanel(); return null; }
    const r = await fetch(origin + '/api/soluv-voice?names=' + encodeURIComponent(T.me.username), { cache: 'no-store', credentials: 'same-origin' });
    if (!r.ok) { T.problem = 'Saved voices unavailable (HTTP ' + r.status + ')'; console.error('[soluv-voice] ' + T.problem); renderPanel(); return null; }
    const j = await r.json(); T.mine = j.mine || null; renderPanel(); return T.mine;
  }
  async function setMine(kind) {
    if (!['male', 'female', 'none'].includes(kind)) return { ok: false, error: 'voice must be male, female or none' };
    const me = T.me && T.me.csrf ? T.me : (T.me = await whoAmI());
    if (!me.username) return { ok: false, login: true, error: 'Log in to choose your voice' };
    const r = await fetch(origin + '/api/soluv-voice', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': me.csrf || '' }, body: JSON.stringify({ voice: kind }) });
    let j; try { j = await r.json(); } catch (e) { j = { ok: false, error: 'HTTP ' + r.status }; }
    if (!r.ok || !j.ok) { const err = r.status === 401 ? 'Log in to choose your voice' : r.status === 403 ? 'Session check failed: reload the page and try again' : r.status === 429 ? 'Too many changes: wait a minute' : (j.error || 'HTTP ' + r.status); renderPanel(err); return { ok: false, status: r.status, error: err }; }
    T.mine = j.voice; T.asked.clear(); renderPanel(); return { ok: true, voice: j.voice };
  }
  function voiceFor(name, kind) {
    const k = lower(name); if (!k) return { ok: false, error: 'name needed' };
    if (!['ma