feat(stage): add persistent speech mute controls (#2128)

This commit is contained in:
RainbowBird
2026-07-28 17:58:50 +08:00
committed by GitHub
parent b544d91d03
commit 899131b0a1
19 changed files with 257 additions and 18 deletions
@@ -34,6 +34,12 @@ const { platform } = useAppRuntime()
<div><span select-none whitespace-nowrap text-sm>{{ title }}</span></div>
</div>
<div w-full drag-region />
<div
flex items-center gap-1
class="[-webkit-app-region:no-drag]"
>
<slot name="actions" />
</div>
<div
bg="hover:neutral-200 hover:dark:neutral-800"
transition="all duration-200 ease-in-out"
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import InteractiveArea from '../components/InteractiveArea.vue'
import WindowTitleBar from '../components/Window/TitleBar.vue'
const sessionsDrawerOpen = shallowRef(false)
const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton()
const { t } = useI18n()
</script>
<template>
@@ -14,7 +18,27 @@ const sessionsDrawerOpen = shallowRef(false)
title="Chat"
icon="i-solar:chat-line-bold"
@title-click="sessionsDrawerOpen = true"
/>
>
<template #actions>
<button
data-testid="speech-mute-button"
:class="[
'h-7 w-7 flex items-center justify-center rounded-md outline-none',
'text-base transition-colors transition-transform active:scale-95',
speechMuted
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
: 'text-neutral-400 hover:bg-neutral-200 hover:text-primary-500 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
<div v-else class="i-solar:volume-loud-bold-duotone" />
</button>
</template>
</WindowTitleBar>
<InteractiveArea
v-model:sessions-drawer-open="sessionsDrawerOpen"
class="interaction-area block"
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Enter
ctrl-enter: Ctrl + Enter
double-enter: Double Enter
speech-output:
mute: Mute voice
unmute: Unmute voice
operations:
load-models: Load Models
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Intro
ctrl-enter: Ctrl + Intro
double-enter: Entrada doble
speech-output:
mute: Silenciar voz
unmute: Activar voz
operations:
load-models: Cargar Modelos
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Entrée
ctrl-enter: Ctrl + Entrée
double-enter: Double Entrée
speech-output:
mute: Couper la voix
unmute: Réactiver la voix
operations:
load-models: Charger les modèles
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Enter
ctrl-enter: Ctrl + Enter
double-enter: Enter二回
speech-output:
mute: 音声をミュート
unmute: ミュートを解除
operations:
load-models: モデルの読み込み
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: 엔터
ctrl-enter: Ctrl + Enter
double-enter: Double Enter
speech-output:
mute: 음성 음소거
unmute: 음소거 해제
operations:
load-models: 모델 불러오기
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Enter
ctrl-enter: Ctrl + Enter
double-enter: Двойной Enter
speech-output:
mute: Отключить озвучивание
unmute: Включить озвучивание
operations:
load-models: Загрузить модели
load-models-status:
+3
View File
@@ -20,6 +20,9 @@ send-mode:
enter: Nhập
ctrl-enter: Ctrl + Enter
double-enter: Nhập hai lần
speech-output:
mute: Tắt tiếng nói
unmute: Bật tiếng nói
operations:
load-models: Tải mô hình
load-models-status:
@@ -20,6 +20,9 @@ send-mode:
enter: Enter
ctrl-enter: Ctrl + Enter
double-enter: 双击Enter
speech-output:
mute: 静音语音
unmute: 恢复语音
operations:
load-models: 加载模型
load-models-status:
@@ -20,6 +20,9 @@ send-mode:
enter: Enter
ctrl-enter: Ctrl + Enter
double-enter: Enter + Enter
speech-output:
mute: 靜音語音
unmute: 恢復語音
operations:
load-models: 載入模型
load-models-status:
@@ -94,7 +94,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
isStageTamagotchi,
},
)
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, toggleSpeechMuted } = useStopSpeakingButton()
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
async function handleSubmit() {
@@ -227,6 +227,23 @@ onMounted(() => {
</Transition>
</button>
</HearingConfigDialog>
<button
data-testid="speech-mute-button"
:class="[
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
speechMuted
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="toggleDark()">
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
@@ -5,15 +5,19 @@ import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-sto
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
const { cleanupMessages } = useChatMaintenanceStore()
const { messages } = storeToRefs(useChatSessionStore())
const { trackChatMessagesCleared } = useAnalytics()
const { isDark, toggleDark } = useTheme()
const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton()
const { t } = useI18n()
const backgroundDialogOpen = ref(false)
@@ -30,6 +34,23 @@ function handleCleanupMessages() {
<template>
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
<div absolute bottom--8 right-0 flex gap-2>
<button
data-testid="speech-mute-button"
:class="[
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
'text-lg transition-colors transition-transform active:scale-95',
speechMuted
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
: 'bg-neutral-100 text-neutral-500 hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
]"
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
:aria-pressed="speechMuted"
@click="toggleSpeechMuted"
>
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
<div v-else class="i-solar:volume-loud-bold-duotone" />
</button>
<ViewControls />
<button
class="max-h-[10lh] min-h-[1lh]"
@@ -302,7 +302,7 @@ watch(sendMode, () => {
</div>
<div
absolute bottom-2 right-2 z-10 flex items-center
absolute bottom-2 right-2 z-10 flex items-center gap-1
>
<button
v-if="showStopSpeakingButton"
@@ -4,7 +4,9 @@ import { ref } from 'vue'
import { useStopSpeakingButton } from './useStopSpeakingButton'
const nowSpeaking = ref(false)
const speechMuted = ref(false)
const requestStopSpeakingMock = vi.fn()
const toggleSpeechMutedMock = vi.fn()
const trackTtsStopClickedMock = vi.fn()
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
@@ -16,6 +18,8 @@ vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
vi.mock('@proj-airi/stage-ui/stores/speech-output-control', () => ({
useSpeechOutputControlStore: () => ({
requestStopSpeaking: requestStopSpeakingMock,
speechMuted,
toggleSpeechMuted: toggleSpeechMutedMock,
}),
}))
@@ -69,4 +73,17 @@ describe('useStopSpeakingButton', () => {
reason: 'manual-all',
})
})
it('exposes persisted mute state and toggles it through the shared output store', () => {
speechMuted.value = true
toggleSpeechMutedMock.mockClear()
const controls = useStopSpeakingButton()
expect(controls.speechMuted.value).toBe(true)
controls.toggleSpeechMuted()
expect(toggleSpeechMutedMock).toHaveBeenCalledOnce()
})
})
@@ -5,20 +5,15 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
/**
* Connects chat UI stop-speaking controls to the active stage speech output host.
* Connects chat speech controls to the active Stage output host.
*
* Use when:
* - Chat input UI needs to stop assistant TTS playback without cancelling text generation.
*
* Expects:
* - A Stage instance is mounted and consumes speech output stop requests.
*
* Returns:
* - Visibility state for the button and click handlers for manual stops.
* Manual stops affect current playback without cancelling text generation.
* Mute is persisted by the shared store and also blocks future TTS sessions.
*/
export function useStopSpeakingButton() {
const { nowSpeaking } = storeToRefs(useSpeakingStore())
const speechOutputControlStore = useSpeechOutputControlStore()
const { speechMuted } = storeToRefs(speechOutputControlStore)
const { trackTtsStopClicked } = useAnalytics()
const showStopSpeakingButton = computed(() => nowSpeaking.value)
@@ -35,7 +30,9 @@ export function useStopSpeakingButton() {
return {
showStopSpeakingButton,
speechMuted,
stopSpeakingFromChat,
stopAllSpeaking,
toggleSpeechMuted: speechOutputControlStore.toggleSpeechMuted,
}
}
@@ -93,7 +93,8 @@ const {
const { mouthOpenSize, nowSpeaking } = storeToRefs(useSpeakingStore())
const { audioContext } = useAudioContext()
const currentAudioSource = ref<AudioBufferSourceNode>()
const { latestStopRequest } = storeToRefs(useSpeechOutputControlStore())
const speechOutputControlStore = useSpeechOutputControlStore()
const { latestStopRequest, speechMuted } = storeToRefs(speechOutputControlStore)
const lastVrmInteractionAt = new Map<VrmInteractionTarget, number>()
const VRM_INTERACTION_COOLDOWN_MS = 450
@@ -391,6 +392,9 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
if (signal.aborted)
return null
if (speechMuted.value)
return null
if (activeSpeechProvider.value === 'speech-noop')
return null
@@ -693,6 +697,9 @@ function resolveStreamingSessionModel(): string | null {
}
function buildStreamingSnapshot(): StreamingSessionSnapshot | null {
if (speechMuted.value)
return null
// Snapshotted once per session, so a mid-session provider/voice swap
// does not corrupt an in-flight session the watcher below detects
// changes and tears down explicitly. Returns `null` when streaming
@@ -790,15 +797,24 @@ watch(latestStopRequest, (request) => {
stopSpeechOutput(request.reason)
})
watch(speechMuted, (muted) => {
if (muted)
stopSpeechOutput('muted')
}, { immediate: true })
chatHookCleanups.push(onBeforeMessageComposed(async () => {
officialAutoTtsTrackedForTurn = false
playbackManager.stopAll('new-message')
setupAnalyser()
await setupLipSync()
resetAssistantSpeechSurface('new-message')
currentSession?.cancel('new-message')
currentSession = null
if (speechMuted.value)
return
setupAnalyser()
await setupLipSync()
currentSession = openTtsSession()
}))
@@ -811,6 +827,13 @@ chatHookCleanups.push(onTokenLiteral(async (literal) => {
}))
chatHookCleanups.push(onTokenSpecial(async (special) => {
// Muting speech must not suppress non-audio signals such as emotion, motion,
// delay, or plugin calls that normally travel through the TTS session.
if (speechMuted.value) {
await playSpecialToken(special)
return
}
currentSession?.appendSpecial(special)
}))
@@ -1,13 +1,61 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useSpeechOutputControlStore } from './speech-output-control'
class MemoryStorage implements Storage {
readonly values = new Map<string, string>()
get length() {
return this.values.size
}
clear() {
this.values.clear()
}
getItem(key: string) {
return this.values.get(key) ?? null
}
key(index: number) {
return [...this.values.keys()][index] ?? null
}
removeItem(key: string) {
this.values.delete(key)
}
setItem(key: string, value: string) {
this.values.set(key, value)
}
}
describe('speech output control store', () => {
beforeEach(() => {
const localStorage = new MemoryStorage()
vi.stubGlobal('Storage', MemoryStorage)
vi.stubGlobal('StorageEvent', class {
constructor(
readonly type: string,
readonly init: StorageEventInit,
) {}
})
vi.stubGlobal('localStorage', localStorage)
vi.stubGlobal('window', {
addEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
localStorage,
removeEventListener: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('records manual chat stop-speaking requests with monotonic sequence numbers', () => {
const store = useSpeechOutputControlStore()
@@ -38,4 +86,37 @@ describe('speech output control store', () => {
reason: 'manual-all',
})
})
it('persists mute state and requests an immediate stop when mute is enabled', async () => {
const store = useSpeechOutputControlStore()
expect(store.speechMuted).toBe(false)
store.setSpeechMuted(true)
expect(store.speechMuted).toBe(true)
expect(store.latestStopRequest).toEqual({
id: 1,
reason: 'muted',
})
await nextTick()
setActivePinia(createPinia())
const restoredStore = useSpeechOutputControlStore()
expect(restoredStore.speechMuted).toBe(true)
})
it('does not publish another stop request when speech output is unmuted', () => {
const store = useSpeechOutputControlStore()
store.setSpeechMuted(true)
store.setSpeechMuted(false)
expect(store.speechMuted).toBe(false)
expect(store.latestStopRequest).toEqual({
id: 1,
reason: 'muted',
})
})
})
@@ -1,7 +1,8 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type SpeechOutputStopReason = 'manual-chat' | 'manual-all'
export type SpeechOutputStopReason = 'manual-chat' | 'manual-all' | 'muted'
/**
* Represents a user-requested stop-speaking command for the stage output host.
@@ -14,6 +15,9 @@ export interface SpeechOutputStopRequest {
}
export const useSpeechOutputControlStore = defineStore('speech-output-control', () => {
const speechMuted = useLocalStorage('settings/speech/output-muted', false, {
window: typeof window === 'undefined' ? undefined : window,
})
const latestStopRequest = ref<SpeechOutputStopRequest>()
let nextRequestId = 1
@@ -36,8 +40,30 @@ export const useSpeechOutputControlStore = defineStore('speech-output-control',
}
}
/**
* Enables or disables automatic assistant speech output.
*
* Enabling mute also publishes a stop request so an active Stage host can
* cancel synthesis, streaming transport, queued audio, and current playback.
*/
function setSpeechMuted(muted: boolean) {
if (speechMuted.value === muted)
return
speechMuted.value = muted
if (muted)
requestStopSpeaking('muted')
}
function toggleSpeechMuted() {
setSpeechMuted(!speechMuted.value)
}
return {
latestStopRequest,
speechMuted,
requestStopSpeaking,
setSpeechMuted,
toggleSpeechMuted,
}
})