From f338aeea18701b4a2b710326dca2305823a176ec Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Wed, 6 May 2026 08:23:12 +0200 Subject: [PATCH] fix: play completion sound in chat (#466) --- packages/client/src/stores/hermes/chat.ts | 21 ++++- packages/client/src/utils/completion-sound.ts | 68 ++++++++++++++++ packages/client/src/views/hermes/ChatView.vue | 10 ++- tests/client/completion-sound.test.ts | 81 +++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/utils/completion-sound.ts create mode 100644 tests/client/completion-sound.test.ts diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 3b8005e..f294203 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -5,6 +5,8 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useAppStore } from './app' import { useProfilesStore } from './profiles' +import { useSettingsStore } from './settings' +import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound' import { detectThinkingBoundary } from '@/utils/thinking-parser' // Re-export ContentBlock for convenience @@ -581,9 +583,23 @@ export const useChatStore = defineStore('chat', () => { target.updatedAt = Date.now() } + function primeCompletionBellIfEnabled() { + if (useSettingsStore().display.bell_on_complete) { + primeCompletionSound() + } + } + + function playCompletionBellIfEnabled() { + if (useSettingsStore().display.bell_on_complete) { + void playCompletionSound() + } + } + async function sendMessage(content: string, attachments?: Attachment[]) { if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return + primeCompletionBellIfEnabled() + if (!activeSession.value) { const session = createSession() switchSession(session.id) @@ -904,6 +920,8 @@ export const useChatStore = defineStore('chat', () => { content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.', timestamp: Date.now(), }) + } else { + playCompletionBellIfEnabled() } // 自动播放语音 @@ -1236,9 +1254,10 @@ export const useChatStore = defineStore('chat', () => { content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.', timestamp: Date.now(), }) + } else { + playCompletionBellIfEnabled() } - cleanup() updateSessionTitle(sid) break diff --git a/packages/client/src/utils/completion-sound.ts b/packages/client/src/utils/completion-sound.ts new file mode 100644 index 0000000..0a0fb99 --- /dev/null +++ b/packages/client/src/utils/completion-sound.ts @@ -0,0 +1,68 @@ +type AudioContextConstructor = typeof AudioContext + +type WindowWithWebkitAudio = Window & typeof globalThis & { + webkitAudioContext?: AudioContextConstructor +} + +let audioContext: AudioContext | null = null + +function getAudioContext(): AudioContext | null { + if (typeof window === 'undefined') return null + + const AudioContextCtor = window.AudioContext || (window as WindowWithWebkitAudio).webkitAudioContext + if (!AudioContextCtor) return null + + if (!audioContext) { + audioContext = new AudioContextCtor() + } + + return audioContext +} + +export function primeCompletionSound(): void { + const ctx = getAudioContext() + if (!ctx || ctx.state !== 'suspended') return + + void ctx.resume().catch(() => { + // Browser autoplay policy may still reject until a user gesture. Ignore; the + // next send action will try again. + }) +} + +export async function playCompletionSound(): Promise { + const ctx = getAudioContext() + if (!ctx) return false + + try { + if (ctx.state === 'suspended') { + await ctx.resume() + } + + const now = ctx.currentTime + const duration = 0.16 + const oscillator = ctx.createOscillator() + const gain = ctx.createGain() + + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(880, now) + oscillator.frequency.exponentialRampToValueAtTime(660, now + duration) + + gain.gain.setValueAtTime(0.0001, now) + gain.gain.exponentialRampToValueAtTime(0.18, now + 0.015) + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration) + + oscillator.connect(gain) + gain.connect(ctx.destination) + oscillator.start(now) + oscillator.stop(now + duration) + + return true + } catch (err) { + console.warn('Failed to play completion sound:', err) + return false + } +} + +export function __resetCompletionSoundForTests(): void { + audioContext = null +} diff --git a/packages/client/src/views/hermes/ChatView.vue b/packages/client/src/views/hermes/ChatView.vue index a22eaaf..e376631 100644 --- a/packages/client/src/views/hermes/ChatView.vue +++ b/packages/client/src/views/hermes/ChatView.vue @@ -4,15 +4,21 @@ import ChatPanel from '@/components/hermes/chat/ChatPanel.vue' import { useAppStore } from '@/stores/hermes/app' import { useChatStore } from '@/stores/hermes/chat' import { useProfilesStore } from '@/stores/hermes/profiles' +import { useSettingsStore } from '@/stores/hermes/settings' const appStore = useAppStore() const chatStore = useChatStore() const profilesStore = useProfilesStore() +const settingsStore = useSettingsStore() onMounted(async () => { appStore.loadModels() - // 先加载 profile,确保缓存 key 使用正确的 profile name - await profilesStore.fetchProfiles() + // 先加载 profile,确保缓存 key 使用正确的 profile name;同时预取显示设置, + // 让聊天完成提示音不依赖用户先打开 Settings 页面。 + await Promise.all([ + profilesStore.fetchProfiles(), + settingsStore.fetchSettings(), + ]) chatStore.loadSessions() }) diff --git a/tests/client/completion-sound.test.ts b/tests/client/completion-sound.test.ts new file mode 100644 index 0000000..c82aeff --- /dev/null +++ b/tests/client/completion-sound.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetCompletionSoundForTests, playCompletionSound, primeCompletionSound } from '@/utils/completion-sound' + +function installMockAudioContext(initialState: AudioContextState = 'running') { + const oscillator = { + type: 'sine' as OscillatorType, + frequency: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } + + const gain = { + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + } + + const context = { + state: initialState, + currentTime: 10, + destination: {}, + resume: vi.fn(async () => { + context.state = 'running' + }), + createOscillator: vi.fn(() => oscillator), + createGain: vi.fn(() => gain), + } + + const AudioContextMock = vi.fn(() => context) + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: AudioContextMock, + }) + + return { AudioContextMock, context, oscillator, gain } +} + +describe('completion sound', () => { + beforeEach(() => { + __resetCompletionSoundForTests() + vi.restoreAllMocks() + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: undefined, + }) + }) + + it('returns false when Web Audio is unavailable', async () => { + await expect(playCompletionSound()).resolves.toBe(false) + }) + + it('primes a suspended audio context from user interaction', () => { + const { context } = installMockAudioContext('suspended') + + primeCompletionSound() + + expect(context.resume).toHaveBeenCalledTimes(1) + }) + + it('plays a short tone through Web Audio', async () => { + const { context, oscillator, gain } = installMockAudioContext('running') + + await expect(playCompletionSound()).resolves.toBe(true) + + expect(context.createOscillator).toHaveBeenCalledTimes(1) + expect(context.createGain).toHaveBeenCalledTimes(1) + expect(oscillator.connect).toHaveBeenCalledWith(gain) + expect(gain.connect).toHaveBeenCalledWith(context.destination) + expect(oscillator.start).toHaveBeenCalledWith(10) + expect(oscillator.stop).toHaveBeenCalledWith(10.16) + }) +})