feat(desktop): play an activation chime when the wake word fires

A short, bright, rising two-note ding (G5 -> C6) plays the moment 'Hey
Hermes' is detected, before voice capture starts, so it's obvious the
wake registered. Deliberately distinct from the turn-end completion cue
(that one settles; this one rises = 'listening'). Reuses the same
lightweight WebAudio synthesis as completion-sound.ts — no asset to ship
— and is gated by the shared sound-mute toggle ($hapticsMuted), so
muting turn-end sounds silences it too.

- apps/desktop/src/lib/wake-sound.ts: playWakeSound()
- wiring.tsx: fire it at the top of the wake.detected handler
- 3 tests (plays two-note chime, silent when muted, never throws with
  no WebAudio)
This commit is contained in:
Teknium 2026-07-28 09:35:06 -07:00
parent 3d2cc39158
commit a8bc64a418
No known key found for this signature in database
3 changed files with 175 additions and 0 deletions

View file

@ -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

View file

@ -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()
})
})

View file

@ -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.
}
}