mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
3d2cc39158
commit
a8bc64a418
3 changed files with 175 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
82
apps/desktop/src/lib/wake-sound.test.ts
Normal file
82
apps/desktop/src/lib/wake-sound.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
88
apps/desktop/src/lib/wake-sound.ts
Normal file
88
apps/desktop/src/lib/wake-sound.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue