Spaces:
Running
Running
Chat voice: speaker button right of bubble + real stop
Browse filesMove 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>
- 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')
|
| 47 |
-
|
| 48 |
-
b
|
| 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 = '
|
| 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 |
}
|