polats Claude Opus 4.8 (1M context) commited on
Commit
a1a7733
·
1 Parent(s): a0e5062

Chat voice: speaker button right of bubble + real stop

Browse files

Move the per-reply voice control to a 🔊 speaker button to the RIGHT of the bubble
(horizontal row) instead of below it. Playback is now a proper toggle: tapping a playing
line STOPS it (🔊⇄⏹) via stopAudio/stopVoiceLive — the same primitives the creation
screen uses — and starting another line (or re-rendering) stops the previous one. Synth
started-then-stopped no longer plays late (guarded). Fixes 'pause doesn't stop the voice'.

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

Files changed (1) hide show
  1. web/characterChat.js +41 -24
web/characterChat.js CHANGED
@@ -9,28 +9,12 @@ import { getPersona, patchPersona, putChatAudio, getChatAudio } from '/web/perso
9
  import {
10
  ensureTts, createVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
11
  activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
 
12
  } from '/web/tts.js'
13
 
14
  const msgId = () => 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
15
  const HISTORY_CAP = 50
16
-
17
- // Speak `text` in the fighter's voice; cache the WAV under the message id for instant replay.
18
- async function speak(id, text, p, btn) {
19
- const set = (s) => { if (btn) btn.textContent = s }
20
- try {
21
- if (activeEngineIsNative()) { set('⏳'); await ensureTts(); await speakVoiceLive(p.voiceId || currentVoiceId(), text); return }
22
- let blob = await getChatAudio(id)
23
- let bytes
24
- if (blob) { bytes = await blob.arrayBuffer() }
25
- else {
26
- set('⏳'); await ensureTts()
27
- const wav = activeEngineIsDesign() ? await createVoiceWav(p.voice || '', text) : await synthVoiceWav(p.voiceId || currentVoiceId(), text)
28
- bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
29
- try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
30
- }
31
- set('⏸'); await playWav(bytes.slice(0))
32
- } catch { /* voice is best-effort */ } finally { set('▶') }
33
- }
34
 
35
  export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {}) {
36
  const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
@@ -42,16 +26,49 @@ export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {})
42
  send.style.cssText = 'flex:none;width:42px;height:40px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font-size:16px;cursor:pointer'
43
  form.append(input, send); wrap.append(thread, form); host.append(wrap)
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  const bubble = (role, text) => {
46
- const row = document.createElement('div'); row.style.cssText = `display:flex;flex-direction:column;max-width:88%;${role === 'user' ? 'align-self:flex-end;align-items:flex-end' : 'align-self:flex-start;align-items:flex-start'}`
47
- const b = document.createElement('div')
48
- b.style.cssText = `padding:8px 11px;border-radius:12px;line-height:1.4;font:13px var(--tac-font,system-ui);white-space:pre-wrap;${role === 'user' ? 'background:#1f6feb;color:#fff;border-bottom-right-radius:4px' : 'background:#1a2230;color:#e8e8e8;border:1px solid #232b36;border-bottom-left-radius:4px'}`
49
- b.textContent = text
50
  row.append(b); row._b = b; return row
51
  }
52
 
53
  function renderThread() {
54
  const p = getPersona(personaId)
 
55
  thread.replaceChildren()
56
  const hist = (p && p.chat && p.chat.history) || []
57
  if (!hist.length) {
@@ -61,8 +78,8 @@ export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {})
61
  for (const m of hist) {
62
  const row = bubble(m.role, m.text)
63
  if (m.role === 'assistant') {
64
- const play = document.createElement('button'); play.type = 'button'; play.textContent = '▶'
65
- play.title = 'Hear it'; play.style.cssText = 'margin-top:3px;background:none;border:none;color:#9aa4b2;font-size:12px;cursor:pointer;padding:2px 4px'
66
  play.addEventListener('click', () => speak(m.id, m.text, getPersona(personaId), play))
67
  row.append(play)
68
  }
 
9
  import {
10
  ensureTts, createVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
11
  activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
12
+ stopPreview, stopVoiceLive,
13
  } from '/web/tts.js'
14
 
15
  const msgId = () => 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
16
  const HISTORY_CAP = 50
17
+ const SPEAKER = '🔊', STOP = '⏹'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {}) {
20
  const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
 
26
  send.style.cssText = 'flex:none;width:42px;height:40px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font-size:16px;cursor:pointer'
27
  form.append(input, send); wrap.append(thread, form); host.append(wrap)
28
 
29
+ // Voice playback with a real stop. Only one line plays at a time; tapping the speaker while it's
30
+ // playing STOPS it (stopAudio/stopVoiceLive — same primitives the creation screen uses), and the
31
+ // WAV is cached per message for instant replay.
32
+ let playing = null // { id, btn }
33
+ const stopAll = () => {
34
+ try { stopVoiceLive() } catch { /* ignore */ }
35
+ try { stopPreview() } catch { /* ignore */ }
36
+ if (playing && playing.btn) playing.btn.textContent = SPEAKER
37
+ playing = null
38
+ }
39
+ async function speak(id, text, p, btn) {
40
+ if (playing && playing.id === id) { stopAll(); return } // tap the playing one → stop (toggle off)
41
+ stopAll() // stop any other line first
42
+ playing = { id, btn }; btn.textContent = STOP
43
+ try {
44
+ if (activeEngineIsNative()) { await ensureTts(); await speakVoiceLive(p.voiceId || currentVoiceId(), text) }
45
+ else {
46
+ let bytes
47
+ const blob = await getChatAudio(id)
48
+ if (blob) bytes = await blob.arrayBuffer()
49
+ else {
50
+ await ensureTts()
51
+ const wav = activeEngineIsDesign() ? await createVoiceWav(p.voice || '', text) : await synthVoiceWav(p.voiceId || currentVoiceId(), text)
52
+ bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
53
+ try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
54
+ }
55
+ if (playing && playing.id === id) await playWav(bytes.slice(0)) // skip if stopped during synth
56
+ }
57
+ } catch { /* voice is best-effort */ } finally { if (playing && playing.id === id) { btn.textContent = SPEAKER; playing = null } }
58
+ }
59
+
60
+ const bubbleCss = (role) => `padding:8px 11px;border-radius:12px;line-height:1.4;font:13px var(--tac-font,system-ui);white-space:pre-wrap;${role === 'user' ? 'background:#1f6feb;color:#fff;border-bottom-right-radius:4px' : 'background:#1a2230;color:#e8e8e8;border:1px solid #232b36;border-bottom-left-radius:4px'}`
61
+ // Horizontal row so the speaker can sit to the RIGHT of an assistant bubble; user bubbles hug right.
62
  const bubble = (role, text) => {
63
+ const row = document.createElement('div')
64
+ row.style.cssText = `display:flex;align-items:center;gap:6px;max-width:92%;${role === 'user' ? 'align-self:flex-end;flex-direction:row-reverse' : 'align-self:flex-start;flex-direction:row'}`
65
+ const b = document.createElement('div'); b.style.cssText = bubbleCss(role); b.textContent = text
 
66
  row.append(b); row._b = b; return row
67
  }
68
 
69
  function renderThread() {
70
  const p = getPersona(personaId)
71
+ stopAll()
72
  thread.replaceChildren()
73
  const hist = (p && p.chat && p.chat.history) || []
74
  if (!hist.length) {
 
78
  for (const m of hist) {
79
  const row = bubble(m.role, m.text)
80
  if (m.role === 'assistant') {
81
+ const play = document.createElement('button'); play.type = 'button'; play.textContent = SPEAKER
82
+ play.title = 'Hear it'; play.style.cssText = 'flex:none;background:none;border:none;color:#9aa4b2;font-size:18px;line-height:1;cursor:pointer;padding:2px;user-select:none'
83
  play.addEventListener('click', () => speak(m.id, m.text, getPersona(personaId), play))
84
  row.append(play)
85
  }