polats Claude Opus 4.8 (1M context) commited on
Commit
750ca83
·
1 Parent(s): 585578b

Settings: recommended quality presets + device readout; persist all choices

Browse files

New "Recommended settings" section at the top of Settings (above Local AI Model):
- High / Medium / Low presets that set the LLM model AND voice provider together,
like a game's graphics presets:
High = Qwen3 0.6B + Kokoro 82M
Medium = Qwen2.5 0.5B + Kitten TTS
Low = Qwen2.5 0.5B + Web Speech
Presets resolve the model within the active engine's catalog (family fallback).
Changing the model or voice by hand drops to Custom.
- Expandable "Your device" readout (CPU threads, RAM, WebGPU/GPU, storage, screen,
UA) for debugging and benchmarking.

Persistence: the LLM engine + per-engine model (runtime.js), the voice provider
(tts.js), and the preset choice (qualityPreset.js) are all saved to localStorage,
so settings survive a refresh. runtime.js now validates a stored model against the
active engine and emits onModelChange; the model/voice bars re-sync when a preset
changes them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/deviceInfo.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Best-effort device/GPU/RAM probe for the Settings "Your device" panel — handy for
2
+ // debugging and benchmarking which quality preset a machine can handle. Everything is
3
+ // guarded: unsupported APIs just read "—" rather than throwing.
4
+ import { storageEstimate } from '/web/storage.js'
5
+ import { fmtBytes } from '/web/modelCatalog.js'
6
+
7
+ function webglRenderer() {
8
+ try {
9
+ const c = document.createElement('canvas')
10
+ const gl = c.getContext('webgl') || c.getContext('experimental-webgl')
11
+ if (!gl) return '—'
12
+ const dbg = gl.getExtension('WEBGL_debug_renderer_info')
13
+ return dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : (gl.getParameter(gl.RENDERER) || '—')
14
+ } catch { return '—' }
15
+ }
16
+
17
+ async function webgpuInfo() {
18
+ try {
19
+ if (!navigator.gpu) return 'unavailable'
20
+ const ad = await navigator.gpu.requestAdapter()
21
+ if (!ad) return 'no adapter'
22
+ const info = ad.info || (ad.requestAdapterInfo ? await ad.requestAdapterInfo() : null)
23
+ const desc = info ? [info.vendor, info.architecture, info.description].filter(Boolean).join(' ').trim() : ''
24
+ return desc || 'available'
25
+ } catch { return 'error' }
26
+ }
27
+
28
+ // Returns an ordered array of [label, value] rows.
29
+ export async function gatherDeviceInfo() {
30
+ const rows = []
31
+ const add = (k, v) => rows.push([k, v == null || v === '' ? '—' : String(v)])
32
+
33
+ add('CPU threads', navigator.hardwareConcurrency)
34
+ add('Device memory', navigator.deviceMemory ? `${navigator.deviceMemory} GB (approx)` : '— (not reported)')
35
+ add('WebGPU', await webgpuInfo())
36
+ add('GPU (WebGL)', webglRenderer())
37
+ try {
38
+ const { usage, quota } = await storageEstimate()
39
+ add('Storage cache', quota ? `${fmtBytes(usage || 0)} / ${fmtBytes(quota)}` : '—')
40
+ } catch { add('Storage cache', '—') }
41
+ try { add('Screen', `${screen.width}×${screen.height} @ ${window.devicePixelRatio || 1}×`) } catch { add('Screen', '—') }
42
+ add('Platform', (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform)
43
+ add('Language', navigator.language)
44
+ add('User agent', navigator.userAgent)
45
+ return rows
46
+ }
web/modelBar.js CHANGED
@@ -6,7 +6,7 @@
6
  import {
7
  listEngines, getEngineId, setEngine,
8
  listModels, currentModel, setModel,
9
- cacheSupported, cachedSet, deleteCached, backendLabel,
10
  } from '/web/runtime.js'
11
  import { fmtBytes } from '/web/modelCatalog.js'
12
  import { storageEstimate } from '/web/storage.js'
@@ -76,6 +76,10 @@ export function mountModelBar(host, { onChange } = {}) {
76
  if (info.textContent.startsWith('delete failed')) setTimeout(() => { info.textContent = prev }, 2500)
77
  })
78
 
 
 
 
 
79
  render()
80
  refresh()
81
  return { refresh }
 
6
  import {
7
  listEngines, getEngineId, setEngine,
8
  listModels, currentModel, setModel,
9
+ cacheSupported, cachedSet, deleteCached, backendLabel, onModelChange,
10
  } from '/web/runtime.js'
11
  import { fmtBytes } from '/web/modelCatalog.js'
12
  import { storageEstimate } from '/web/storage.js'
 
76
  if (info.textContent.startsWith('delete failed')) setTimeout(() => { info.textContent = prev }, 2500)
77
  })
78
 
79
+ // A preset (or any other surface) may change the engine/model — re-sync this bar.
80
+ // render() updates the selects immediately; refresh() then refreshes cache/size info.
81
+ onModelChange(() => { engSel.value = getEngineId(); render(); refresh() })
82
+
83
  render()
84
  refresh()
85
  return { refresh }
web/qualityBar.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // "Recommended settings" — a High/Medium/Low/Custom preset picker (sets the LLM model +
2
+ // voice provider together) plus an expandable "Your device" readout for debugging and
3
+ // benchmarking. Mounted at the TOP of the Settings page, above Local AI Model.
4
+ import { PRESETS, applyPreset, getActivePreset, onPresetChange } from '/web/qualityPreset.js'
5
+ import { gatherDeviceInfo } from '/web/deviceInfo.js'
6
+
7
+ function el(tag, props = {}, kids = []) {
8
+ const n = document.createElement(tag)
9
+ for (const [k, v] of Object.entries(props)) {
10
+ if (k === 'class') n.className = v
11
+ else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
12
+ else if (v != null) n.setAttribute(k, v)
13
+ }
14
+ for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
15
+ return n
16
+ }
17
+
18
+ export function mountQualityBar(host) {
19
+ // Preset buttons (+ a derived "Custom" that only highlights, never applies).
20
+ const OPTS = [...PRESETS, { id: 'custom', label: 'Custom', sub: 'Your own model + voice' }]
21
+ const btns = {}
22
+ const row = el('div', { class: 'tac-preset-row' }, OPTS.map((p) => {
23
+ const b = el('button', { class: 'tac-preset-btn', type: 'button', 'data-id': p.id },
24
+ [el('span', { class: 'tac-preset-name' }, p.label), el('span', { class: 'tac-preset-sub' }, p.sub)])
25
+ if (p.id !== 'custom') b.addEventListener('click', () => applyPreset(p.id))
26
+ else b.disabled = true // Custom is a status, not an action
27
+ btns[p.id] = b
28
+ return b
29
+ }))
30
+
31
+ function highlight(active) {
32
+ for (const id of Object.keys(btns)) btns[id].classList.toggle('active', id === active)
33
+ }
34
+ highlight(getActivePreset())
35
+ onPresetChange((id) => highlight(id))
36
+
37
+ // Expandable device readout (debug / benchmark).
38
+ const devBody = el('div', { class: 'tac-device-body' }, 'reading…')
39
+ const dev = el('details', { class: 'tac-device' },
40
+ [el('summary', {}, 'Your device (GPU / RAM / storage)'), devBody])
41
+ dev.addEventListener('toggle', async () => {
42
+ if (!dev.open || dev.dataset.loaded) return
43
+ dev.dataset.loaded = '1'
44
+ try {
45
+ const rows = await gatherDeviceInfo()
46
+ devBody.replaceChildren(...rows.map(([k, v]) =>
47
+ el('div', { class: 'tac-device-row' }, [el('span', { class: 'tac-device-k' }, k), el('span', { class: 'tac-device-v' }, v)])))
48
+ } catch { devBody.textContent = 'could not read device info' }
49
+ })
50
+
51
+ host.append(el('div', { class: 'tac-quality' }, [row, dev]))
52
+ return { refresh: () => highlight(detectPreset()) }
53
+ }
web/qualityPreset.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Quality presets — a game-graphics-style High/Medium/Low/Custom picker that sets BOTH
2
+ // the in-browser LLM model and the voice provider at once. Picking a preset applies it;
3
+ // changing the model or voice by hand drops the picker to "Custom" (even if the new combo
4
+ // happens to equal another preset — like a game's graphics dropdown). The choice is
5
+ // persisted (and the model/voice themselves are persisted by runtime.js / tts.js), so it
6
+ // survives a refresh.
7
+ import { setModel, currentModelId, listModels, onModelChange } from '/web/runtime.js'
8
+ import { setTtsEngine, getTtsEngineId, onTtsEngineChange } from '/web/tts.js'
9
+
10
+ // model = preferred exact id; family = prefix fallback if the active engine lacks it
11
+ // (e.g. Transformers.js has no Qwen3 — then High can't be honored and reads as Custom).
12
+ export const PRESETS = [
13
+ { id: 'high', label: 'High', model: 'qwen3-0.6b', family: 'qwen3', voice: 'kokoro', sub: 'Qwen3 · Kokoro 82M' },
14
+ { id: 'medium', label: 'Medium', model: 'qwen2.5-0.5b', family: 'qwen2.5', voice: 'kitten', sub: 'Qwen2.5 · Kitten TTS' },
15
+ { id: 'low', label: 'Low', model: 'qwen2.5-0.5b', family: 'qwen2.5', voice: 'webspeech', sub: 'Qwen2.5 · Web Speech' },
16
+ ]
17
+ const KEY = 'tinyarmy.qualityPreset'
18
+ const VALID = new Set([...PRESETS.map((p) => p.id), 'custom'])
19
+
20
+ const byId = (id) => PRESETS.find((p) => p.id === id)
21
+ // Resolve a preset's model to an id that exists in the ACTIVE engine's catalog.
22
+ function resolveModel(p) {
23
+ const ms = listModels()
24
+ const m = ms.find((x) => x.id === p.model) || ms.find((x) => x.id.startsWith(p.family))
25
+ return m ? m.id : ''
26
+ }
27
+
28
+ // Which preset does the current (model, voice) match? 'custom' if none.
29
+ export function detectPreset() {
30
+ const mid = currentModelId(), v = getTtsEngineId()
31
+ const p = PRESETS.find((x) => (mid === x.model || mid.startsWith(x.family)) && v === x.voice)
32
+ return p ? p.id : 'custom'
33
+ }
34
+
35
+ let _applying = false
36
+ // The active preset id. Load the saved one, but only trust a saved preset id if the
37
+ // persisted model+voice still match it; honor a saved 'custom' as-is.
38
+ let _current = (() => {
39
+ let saved = ''
40
+ try { saved = localStorage.getItem(KEY) || '' } catch { /* ignore */ }
41
+ if (!VALID.has(saved)) return detectPreset()
42
+ if (saved === 'custom') return 'custom'
43
+ return detectPreset() === saved ? saved : detectPreset()
44
+ })()
45
+
46
+ export const getActivePreset = () => _current
47
+
48
+ function commit(id) {
49
+ _current = id
50
+ try { localStorage.setItem(KEY, id) } catch { /* ignore */ }
51
+ for (const fn of _listeners) { try { fn(id) } catch { /* ignore */ } }
52
+ }
53
+
54
+ export function applyPreset(id) {
55
+ const p = byId(id)
56
+ if (!p) return
57
+ _applying = true
58
+ const mid = resolveModel(p)
59
+ if (mid) setModel(mid) // fires onModelChange (ignored while _applying)
60
+ setTtsEngine(p.voice) // fires onTtsEngineChange (ignored while _applying)
61
+ _applying = false
62
+ commit(id)
63
+ }
64
+
65
+ const _listeners = new Set()
66
+ export function onPresetChange(fn) { _listeners.add(fn); return () => _listeners.delete(fn) }
67
+ // A model/voice change NOT caused by applyPreset = a manual edit → Custom.
68
+ const _onManual = () => { if (!_applying) commit('custom') }
69
+ onModelChange(_onManual)
70
+ onTtsEngineChange(_onManual)
web/runtime.js CHANGED
@@ -8,21 +8,49 @@ import { engine as webllm } from '/web/engineWebllm.js'
8
  import { ensurePersistentStorage } from '/web/storage.js'
9
 
10
  const ENGINES = [wllama, transformers, webllm]
11
- // Default to WebLLM (fastest on mobile WebGPU); fall back to wllama where there's no
12
- // WebGPU so the app still works. Both default to Qwen3 0.6B (see each engine).
13
- let activeId = webllm.available() ? 'webllm' : 'wllama'
14
- const modelSel = {} // engineId -> chosen model id (remembered per engine)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
17
 
18
  export const listEngines = () => ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available() }))
19
  export const getEngineId = () => activeId
20
- export function setEngine(id) { if (ENGINES.some((e) => e.id === id)) activeId = id }
 
 
 
21
 
22
  export const listModels = () => eng().models
23
- export const currentModelId = () => modelSel[activeId] || eng().defaultModel
 
 
 
 
 
24
  export const currentModel = () => eng().models.find((m) => m.id === currentModelId()) || eng().models[0]
25
- export function setModel(id) { modelSel[activeId] = id }
 
 
 
26
 
27
  export const ensureModel = async (onProgress) => {
28
  await ensurePersistentStorage() // keep downloads from being evicted across engine switches
 
8
  import { ensurePersistentStorage } from '/web/storage.js'
9
 
10
  const ENGINES = [wllama, transformers, webllm]
11
+ // Persisted choices (survive refresh). Defaults: WebLLM where there's WebGPU (fastest),
12
+ // else wllama so the app still works without it.
13
+ const ENGINE_KEY = 'tinyarmy.llmEngine', MODELS_KEY = 'tinyarmy.llmModels'
14
+ const loadJSON = (k, fb) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb } catch { return fb } }
15
+ const loadStr = (k) => { try { return localStorage.getItem(k) || '' } catch { return '' } }
16
+
17
+ let activeId = (() => {
18
+ const saved = loadStr(ENGINE_KEY)
19
+ const e = ENGINES.find((x) => x.id === saved)
20
+ return e && e.available() ? saved : (webllm.available() ? 'webllm' : 'wllama')
21
+ })()
22
+ const modelSel = loadJSON(MODELS_KEY, {}) // engineId -> chosen model id (remembered per engine)
23
+
24
+ function persist() {
25
+ try { localStorage.setItem(ENGINE_KEY, activeId); localStorage.setItem(MODELS_KEY, JSON.stringify(modelSel)) } catch { /* ignore */ }
26
+ }
27
+
28
+ // Change listeners (the Settings "Recommended" preset bar + the model bar re-render).
29
+ const _listeners = new Set()
30
+ export function onModelChange(fn) { _listeners.add(fn); return () => _listeners.delete(fn) }
31
+ const _notify = () => { for (const fn of _listeners) { try { fn() } catch { /* ignore */ } } }
32
 
33
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
34
 
35
  export const listEngines = () => ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available() }))
36
  export const getEngineId = () => activeId
37
+ export function setEngine(id) {
38
+ if (!ENGINES.some((e) => e.id === id) || id === activeId) return
39
+ activeId = id; persist(); _notify()
40
+ }
41
 
42
  export const listModels = () => eng().models
43
+ // A stored model id only counts if it actually exists in the active engine's catalog
44
+ // (otherwise fall back to that engine's default — handles cross-engine presets cleanly).
45
+ export const currentModelId = () => {
46
+ const sel = modelSel[activeId]
47
+ return (sel && eng().models.some((m) => m.id === sel)) ? sel : eng().defaultModel
48
+ }
49
  export const currentModel = () => eng().models.find((m) => m.id === currentModelId()) || eng().models[0]
50
+ export function setModel(id) {
51
+ if (modelSel[activeId] === id) return
52
+ modelSel[activeId] = id; persist(); _notify()
53
+ }
54
 
55
  export const ensureModel = async (onProgress) => {
56
  await ensurePersistentStorage() // keep downloads from being evicted across engine switches
web/settingsPanel.js CHANGED
@@ -9,6 +9,7 @@
9
  import { mountModelBar } from '/web/modelBar.js'
10
  import { mountTtsBar } from '/web/ttsBar.js'
11
  import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
 
12
 
13
  function el(tag, props = {}, kids = []) {
14
  const n = document.createElement(tag)
@@ -38,7 +39,11 @@ export function mountSettingsPanel() {
38
  const tryInject = () => {
39
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
40
  if (!sample) return
41
- // Insert Model first, then Voice (each goes just above Display Theme).
 
 
 
 
42
  injectSection(sample, 'tac-model-settings', 'Local AI Model',
43
  'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
44
  'your device; models cache in your browser.', mountModelBar)
 
9
  import { mountModelBar } from '/web/modelBar.js'
10
  import { mountTtsBar } from '/web/ttsBar.js'
11
  import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
12
+ import { mountQualityBar } from '/web/qualityBar.js'
13
 
14
  function el(tag, props = {}, kids = []) {
15
  const n = document.createElement(tag)
 
39
  const tryInject = () => {
40
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
41
  if (!sample) return
42
+ // Recommended (preset) first so it sits at the very top, then Model, Voice, etc.
43
+ // Each injectSection inserts just above Display Theme, so call order = on-screen order.
44
+ injectSection(sample, 'tac-quality-settings', 'Recommended settings',
45
+ 'Pick a quality preset — it sets the AI model and voice together, like graphics ' +
46
+ 'presets in a game. Changing either by hand switches to Custom.', mountQualityBar)
47
  injectSection(sample, 'tac-model-settings', 'Local AI Model',
48
  'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
49
  'your device; models cache in your browser.', mountModelBar)
web/shell/persona.css CHANGED
@@ -257,6 +257,31 @@
257
  .tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
258
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  /* Persona-prompt editor (Settings → Persona Prompt). */
261
  .persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; width: 100%; }
262
  .persona-prompt-edit {
 
257
  .tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
258
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
259
 
260
+ /* Recommended settings — quality preset picker (High / Medium / Low / Custom). */
261
+ .tac-quality { display: flex; flex-direction: column; gap: 12px; }
262
+ .tac-preset-row { display: flex; gap: 8px; flex-wrap: wrap; }
263
+ .tac-preset-btn {
264
+ flex: 1 1 120px; display: flex; flex-direction: column; gap: 3px; text-align: left;
265
+ background: var(--p-card); border: 1.5px solid var(--p-ink); border-radius: 0;
266
+ padding: 9px 11px; cursor: pointer; color: var(--p-ink); transition: background .12s;
267
+ }
268
+ .tac-preset-btn:hover:not(:disabled) { background: var(--p-paper-2); }
269
+ .tac-preset-btn.active { background: var(--p-ink); color: var(--p-paper); box-shadow: 2px 2px 0 var(--p-transmit); }
270
+ .tac-preset-btn:disabled { cursor: default; }
271
+ .tac-preset-name { font-family: var(--p-mono); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
272
+ .tac-preset-sub { font-family: var(--p-mono); font-size: 10px; opacity: .7; }
273
+
274
+ /* Expandable device readout (debug / benchmark). */
275
+ .tac-device { border: 1px dashed var(--p-ink); padding: 8px 10px; }
276
+ .tac-device > summary {
277
+ cursor: pointer; font-family: var(--p-mono); font-size: 11px; letter-spacing: .08em;
278
+ text-transform: uppercase; color: var(--p-transmit);
279
+ }
280
+ .tac-device-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
281
+ .tac-device-row { display: flex; gap: 10px; font-family: var(--p-mono); font-size: 11px; line-height: 1.4; }
282
+ .tac-device-k { flex: 0 0 130px; color: var(--p-muted); }
283
+ .tac-device-v { flex: 1; min-width: 0; color: var(--p-ink); word-break: break-word; }
284
+
285
  /* Persona-prompt editor (Settings → Persona Prompt). */
286
  .persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; width: 100%; }
287
  .persona-prompt-edit {
web/tts.js CHANGED
@@ -12,8 +12,14 @@ import { ensurePersistentStorage } from '/web/storage.js'
12
  const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
13
  // Default voice provider: local-GPU Qwen3-TTS on localhost (your GPU designs voices),
14
  // in-browser Kokoro in prod (runs on the device — no exhaustible cloud quota). Cloud
15
- // Qwen3-TTS and the others remain selectable in Settings.
16
- let activeId = isLocalhost() ? 'qwen3local' : 'kokoro'
 
 
 
 
 
 
17
 
18
  // Qwen3-TTS designs a voice from a free-form description (the persona's `voice`).
19
  // Panels set it before narrating; previewVoice() plays a one-off sample.
@@ -82,6 +88,7 @@ export function onTtsEngineChange(fn) { _engineListeners.add(fn); return () => _
82
  export function setTtsEngine(id) {
83
  if (!ENGINES.some((e) => e.id === id) || id === activeId) return
84
  activeId = id
 
85
  for (const fn of _engineListeners) { try { fn(id) } catch { /* ignore */ } }
86
  }
87
 
 
12
  const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
13
  // Default voice provider: local-GPU Qwen3-TTS on localhost (your GPU designs voices),
14
  // in-browser Kokoro in prod (runs on the device — no exhaustible cloud quota). Cloud
15
+ // Qwen3-TTS and the others remain selectable in Settings. Persisted across refreshes.
16
+ const TTS_ENGINE_KEY = 'tinyarmy.ttsEngine'
17
+ let activeId = (() => {
18
+ let saved = ''
19
+ try { saved = localStorage.getItem(TTS_ENGINE_KEY) || '' } catch { /* ignore */ }
20
+ const e = ENGINES.find((x) => x.id === saved)
21
+ return e && e.available() ? saved : (isLocalhost() ? 'qwen3local' : 'kokoro')
22
+ })()
23
 
24
  // Qwen3-TTS designs a voice from a free-form description (the persona's `voice`).
25
  // Panels set it before narrating; previewVoice() plays a one-off sample.
 
88
  export function setTtsEngine(id) {
89
  if (!ENGINES.some((e) => e.id === id) || id === activeId) return
90
  activeId = id
91
+ try { localStorage.setItem(TTS_ENGINE_KEY, id) } catch { /* ignore */ }
92
  for (const fn of _engineListeners) { try { fn(id) } catch { /* ignore */ } }
93
  }
94
 
web/ttsBar.js CHANGED
@@ -5,7 +5,7 @@
5
  // the shared tts.js facade; every page reads that choice.
6
  import {
7
  listTtsEngines, getTtsEngineId, setTtsEngine,
8
- ttsBackendLabel, ttsNeedsDownload, activeEngineIsDesign,
9
  } from '/web/tts.js'
10
 
11
  function el(tag, props = {}, kids = []) {
@@ -40,6 +40,8 @@ export function mountTtsBar(host, { onChange } = {}) {
40
  }
41
 
42
  engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderInfo(); onChange && onChange() })
 
 
43
 
44
  renderInfo()
45
  return { refresh: renderInfo }
 
5
  // the shared tts.js facade; every page reads that choice.
6
  import {
7
  listTtsEngines, getTtsEngineId, setTtsEngine,
8
+ ttsBackendLabel, ttsNeedsDownload, activeEngineIsDesign, onTtsEngineChange,
9
  } from '/web/tts.js'
10
 
11
  function el(tag, props = {}, kids = []) {
 
40
  }
41
 
42
  engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderInfo(); onChange && onChange() })
43
+ // A preset (Settings → Recommended) may switch the provider — keep this select in sync.
44
+ onTtsEngineChange((id) => { engSel.value = id; renderInfo() })
45
 
46
  renderInfo()
47
  return { refresh: renderInfo }