feat(stage): add persistent speech mute controls (#2128)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 엔터
|
||||
ctrl-enter: Ctrl + Enter
|
||||
double-enter: Double 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
|
||||
speech-output:
|
||||
mute: Отключить озвучивание
|
||||
unmute: Включить озвучивание
|
||||
operations:
|
||||
load-models: Загрузить модели
|
||||
load-models-status:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user