diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 895826794a1..0676c2da77d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -29,6 +29,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { playWakeSound } from '@/lib/wake-sound' import { $billingSettingsRequest } from '@/store/billing-block' import { requestVoiceConversationStart } from '@/store/composer' import { setCronFocusJobId } from '@/store/cron' @@ -670,6 +671,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { | { profile?: null | string; start_new_session?: boolean } | undefined + // Audible confirmation that the wake registered, before voice capture + // starts. Gated by the shared sound-mute toggle. + playWakeSound() + // Multi-profile routing: a wake phrase enrolled by another profile // re-homes the gateway to that profile first (live swap — same path // as clicking it in the profile rail), then opens the fresh session diff --git a/apps/desktop/src/lib/wake-sound.test.ts b/apps/desktop/src/lib/wake-sound.test.ts new file mode 100644 index 00000000000..354eff8e44f --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $hapticsMuted } from '@/store/haptics' + +import { playWakeSound } from './wake-sound' + +// Minimal WebAudio doubles: enough to record that playWakeSound wired +// oscillators to the destination when it should, and stayed silent when muted. +class FakeParam { + setValueAtTime = vi.fn() + exponentialRampToValueAtTime = vi.fn() +} + +class FakeOscillator { + type = 'sine' + frequency = new FakeParam() + connect = vi.fn() + start = vi.fn() + stop = vi.fn() +} + +class FakeGain { + gain = new FakeParam() + connect = vi.fn() +} + +let oscillators: FakeOscillator[] + +class FakeAudioContext { + state = 'running' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + + createOscillator() { + const osc = new FakeOscillator() + oscillators.push(osc) + + return osc + } + + createGain() { + return new FakeGain() + } +} + +describe('playWakeSound', () => { + beforeEach(() => { + oscillators = [] + $hapticsMuted.set(false) + vi.stubGlobal('AudioContext', FakeAudioContext) + }) + + afterEach(() => { + vi.unstubAllGlobals() + $hapticsMuted.set(false) + }) + + it('plays a two-note rising chime when sound is on', () => { + playWakeSound() + + // G5 then C6 — two enveloped oscillators, both routed onward. + expect(oscillators).toHaveLength(2) + expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith(783.99, expect.any(Number)) + expect(oscillators[1].frequency.setValueAtTime).toHaveBeenCalledWith(1046.5, expect.any(Number)) + for (const osc of oscillators) { + expect(osc.start).toHaveBeenCalled() + expect(osc.stop).toHaveBeenCalled() + } + }) + + it('stays silent when the shared sound-mute toggle is on', () => { + $hapticsMuted.set(true) + playWakeSound() + expect(oscillators).toHaveLength(0) + }) + + it('never throws when WebAudio is unavailable', () => { + vi.stubGlobal('AudioContext', undefined) + expect(() => playWakeSound()).not.toThrow() + }) +}) diff --git a/apps/desktop/src/lib/wake-sound.ts b/apps/desktop/src/lib/wake-sound.ts new file mode 100644 index 00000000000..3eb9f1c0d86 --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.ts @@ -0,0 +1,88 @@ +// Wake-word activation chime. A short, bright, rising two-note "ding" that +// plays the moment "Hey Hermes" is detected, so it's obvious the wake +// registered before voice capture starts. Deliberately distinct from the +// turn-end completion cue (completion-sound.ts): this one RISES (open/ready), +// the completion cue settles (done). Reuses the same lightweight WebAudio +// synthesis approach — no asset file to ship. + +import { $hapticsMuted } from '@/store/haptics' + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One enveloped sine voice → master. Linear-ish attack into an exponential +// decay keeps the tail smooth and avoids the click you get ramping to zero. +function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) { + const osc = ac.createOscillator() + const env = ac.createGain() + const end = t0 + dur + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, t0) + + env.gain.setValueAtTime(0.0001, t0) + env.gain.exponentialRampToValueAtTime(Math.max(gain, 0.0002), t0 + 0.008) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(t0) + osc.stop(end + 0.02) +} + +// Play the wake chime. Honours the shared sound-mute toggle ($hapticsMuted), +// the same gate the completion cue uses, so muting turn-end sounds also +// silences this. Best-effort: never throws into the wake-event handler. +export function playWakeSound(): void { + if ($hapticsMuted.get()) { + return + } + + const ac = getCtx() + + if (!ac) { + return + } + + try { + const master = ac.createGain() + master.gain.setValueAtTime(0.5, ac.currentTime) + master.connect(ac.destination) + + const t0 = ac.currentTime + 0.01 + // Rising perfect-fourth: G5 -> C6. Short and bright — "listening". + ding(ac, master, t0, 783.99, 0.12, 0.06) + ding(ac, master, t0 + 0.1, 1046.5, 0.28, 0.07) + } catch { + // WebAudio can throw if the context died mid-call; a missed chime must + // never break wake handling. + } +}