Skip to content
Merged
24 changes: 22 additions & 2 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import { notify, notifyError } from '@/store/notifications'
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
import { resumeWakeAfterVoice } from '@/store/wake-word'

import type { ComposerTarget } from '../focus'
Expand Down Expand Up @@ -208,6 +208,26 @@ export function useComposerVoice({
}
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])

// 'Say "stop" to end the voice chat.' notice when the conversation starts.
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
useEffect(() => {
if (!voiceConversationActive) {
return
}

const phrase = $voiceStopPhrase.get()

if (phrase) {
notify({
id: 'voice-stop-hint',
kind: 'info',
icon: 'mic',
message: t.notifications.voice.sayStopToEnd(phrase)
})
}
}, [t, voiceConversationActive])

useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])

// Explicit start/end for the on-screen conversation controls (the hotkey uses
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'

import { useI18n } from '@/i18n'
import { startThinkingSound, stopThinkingSound } from '@/lib/thinking-sound'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
markVoicePlaybackInterrupted,
Expand Down Expand Up @@ -574,6 +575,22 @@ export function useVoiceConversation({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])

// Ambient "thinking" sound: while the agent works (status 'thinking') no
// audio flows, which reads as dead air mid-conversation. Calm bubble blips
// fill the gap; they stop the INSTANT speech starts, the mic re-arms, or the
// conversation ends. Gated by voice.thinking_sound + the shared sound mute.
useEffect(() => {
if (enabled && !muted && status === 'thinking') {
startThinkingSound()

return stopThinkingSound
}

stopThinkingSound()

return undefined
}, [enabled, muted, status])

// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/app/session/hooks/use-hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
setDefaultReasoningEffort,
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
import { applyAutoSpeakFromConfig, applyThinkingSoundFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs'

const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
Expand Down Expand Up @@ -105,6 +105,8 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
applyAutoSpeakFromConfig(config)
applyVoiceStopPhraseFromConfig(config)
applyThinkingSoundFromConfig(config)
} catch {
// Config is nice-to-have; chat still works without it.
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const ar = defineLocale({
noSpeechDetected: 'لم يتم اكتشاف كلام',
playbackFailed: 'فشل تشغيل الصوت',
recordingFailed: 'فشل التسجيل',
sayStopToEnd: phrase => `قل "${phrase}" لإنهاء المحادثة الصوتية.`,
transcriptionFailed: 'فشل التفريغ النصي',
transcriptionUnavailable: 'التفريغ النصي غير متاح.',
tryRecordingAgain: 'حاول التسجيل مرة أخرى.',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const en: Translations = {
noSpeechDetected: 'No speech detected',
playbackFailed: 'Voice playback failed',
recordingFailed: 'Voice recording failed',
sayStopToEnd: phrase => `Say "${phrase}" to end the voice chat.`,
transcriptionFailed: 'Voice transcription failed',
transcriptionUnavailable: 'Voice transcription is not available yet.',
tryRecordingAgain: 'Try recording again.',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const ja = defineLocale({
noSpeechDetected: '音声が検出されませんでした',
playbackFailed: '音声再生に失敗しました',
recordingFailed: '音声録音に失敗しました',
sayStopToEnd: phrase => `「${phrase}」と言うと音声チャットを終了できます。`,
transcriptionFailed: '音声文字起こしに失敗しました',
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
tryRecordingAgain: 'もう一度録音してください。',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export interface Translations {
noSpeechDetected: string
playbackFailed: string
recordingFailed: string
sayStopToEnd: (phrase: string) => string
transcriptionFailed: string
transcriptionUnavailable: string
tryRecordingAgain: string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export const zhHant = defineLocale({
noSpeechDetected: '未偵測到語音',
playbackFailed: '語音播放失敗',
recordingFailed: '語音錄製失敗',
sayStopToEnd: phrase => `說「${phrase}」即可結束語音對話。`,
transcriptionFailed: '語音轉寫失敗',
transcriptionUnavailable: '語音轉寫暫不可用。',
tryRecordingAgain: '請再錄製一次。',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export const zh: Translations = {
noSpeechDetected: '没有检测到语音',
playbackFailed: '语音播放失败',
recordingFailed: '语音录制失败',
sayStopToEnd: phrase => `说“${phrase}”即可结束语音对话。`,
transcriptionFailed: '语音转写失败',
transcriptionUnavailable: '语音转写暂不可用。',
tryRecordingAgain: '请再录一次。',
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/src/lib/thinking-sound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/store/haptics', () => ({ $hapticsMuted: { get: vi.fn(() => false) } }))
vi.mock('@/hermes', () => ({
getHermesConfigRecord: vi.fn(async () => ({})),
saveHermesConfig: vi.fn(async () => undefined)
}))

import { $hapticsMuted } from '@/store/haptics'
import { $thinkingSoundEnabled } from '@/store/voice-prefs'

import { isThinkingSoundActive, startThinkingSound, stopThinkingSound } from './thinking-sound'

class FakeOscillator {
type = 'sine'
frequency = { exponentialRampToValueAtTime: vi.fn(), setValueAtTime: vi.fn() }
connect = vi.fn()
start = vi.fn()
stop = vi.fn()
}

class FakeGain {
gain = { exponentialRampToValueAtTime: vi.fn(), setValueAtTime: vi.fn() }
connect = vi.fn()
}

const started: FakeOscillator[] = []

class FakeAudioContext {
currentTime = 0
destination = {}
state = 'running'

createOscillator() {
const osc = new FakeOscillator()

started.push(osc)

return osc
}

createGain() {
return new FakeGain()
}

resume() {
return Promise.resolve()
}
}

describe('thinking-sound', () => {
beforeEach(() => {
vi.useFakeTimers()
started.length = 0
$thinkingSoundEnabled.set(true)
vi.mocked($hapticsMuted.get).mockReturnValue(false)
vi.stubGlobal('AudioContext', FakeAudioContext)
})

afterEach(() => {
stopThinkingSound()
vi.unstubAllGlobals()
vi.useRealTimers()
})

it('plays repeating blips while active and stops instantly', () => {
startThinkingSound()
expect(isThinkingSoundActive()).toBe(true)

vi.advanceTimersByTime(5_000)
expect(started.length).toBeGreaterThanOrEqual(3)

const count = started.length

stopThinkingSound()
expect(isThinkingSoundActive()).toBe(false)
vi.advanceTimersByTime(5_000)
expect(started.length).toBe(count) // nothing after stop
})

it('respects the voice.thinking_sound config gate', () => {
$thinkingSoundEnabled.set(false)
startThinkingSound()
expect(isThinkingSoundActive()).toBe(false)
vi.advanceTimersByTime(3_000)
expect(started.length).toBe(0)
})

it('stays silent while sounds are muted but keeps the loop alive', () => {
vi.mocked($hapticsMuted.get).mockReturnValue(true)
startThinkingSound()
vi.advanceTimersByTime(3_000)
expect(started.length).toBe(0)

// Unmute mid-loop → blips resume without a restart.
vi.mocked($hapticsMuted.get).mockReturnValue(false)
vi.advanceTimersByTime(3_000)
expect(started.length).toBeGreaterThan(0)
})

it('start is idempotent', () => {
startThinkingSound()
startThinkingSound()
vi.advanceTimersByTime(1_300)

// One loop: at most ~2 blips in 1.3s (first at 400ms, next ≥800ms later).
expect(started.length).toBeLessThanOrEqual(2)
})

it('never throws when WebAudio is unavailable', () => {
vi.stubGlobal('AudioContext', undefined)
expect(() => {
startThinkingSound()
vi.advanceTimersByTime(2_000)
}).not.toThrow()
stopThinkingSound()
})
})
108 changes: 108 additions & 0 deletions apps/desktop/src/lib/thinking-sound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Ambient "thinking" sound for the desktop voice conversation. While the agent
// works (status === 'thinking') no audio flows, which reads as "it died" during
// long thinking/tool stretches. A calm, quiet, repeating pair of soft bubble
// blips fills the gap — same WebAudio oscillator synthesis approach as
// wake-sound.ts / completion-sound.ts (no asset to ship), mirroring the
// backend's numpy-synthesized blips in tools/voice_mode.py so CLI and desktop
// sound alike.
//
// Honours the shared sound-mute toggle ($hapticsMuted) and the
// voice.thinking_sound config gate ($thinkingSoundEnabled). Stops instantly on
// stopThinkingSound() — callers fire it the moment TTS starts, the mic re-arms,
// or the conversation ends.

import { $hapticsMuted } from '@/store/haptics'
import { $thinkingSoundEnabled } from '@/store/voice-prefs'

let ctx: AudioContext | null = null
let timer: number | null = null
let blipIndex = 0

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

if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}

return ctx
} catch {
return null
}
}

// One soft "blub": short sine with a gentle downward pitch glide and a smooth
// attack into an exponential decay — no clicks, deliberately quiet.
function blub(ac: AudioContext, freq: number) {
const t0 = ac.currentTime + 0.01
const dur = 0.16
const osc = ac.createOscillator()
const env = ac.createGain()

osc.type = 'sine'
osc.frequency.setValueAtTime(freq, t0)
osc.frequency.exponentialRampToValueAtTime(freq * 0.72, t0 + dur)

env.gain.setValueAtTime(0.0001, t0)
env.gain.exponentialRampToValueAtTime(0.08, t0 + 0.02)
env.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)

osc.connect(env)
env.connect(ac.destination)
osc.start(t0)
osc.stop(t0 + dur + 0.02)
}

export function isThinkingSoundActive(): boolean {
return timer !== null
}

/** Start the repeating thinking blips (idempotent). Best-effort, never throws. */
export function startThinkingSound(): void {
if (timer !== null || !$thinkingSoundEnabled.get()) {
return
}

const tick = () => {
if ($hapticsMuted.get() === false) {
const ac = getCtx()

if (ac) {
try {
// Alternate two calm pitches (G4 / E4), matching the backend blips.
blub(ac, blipIndex % 2 === 0 ? 392 : 329.6)
} catch {
// Audio backend unavailable — stay silent, keep the loop harmless.
}
}
}

blipIndex += 1
// ~0.8-1.2s spacing with slight randomization so it reads organic.
timer = window.setTimeout(tick, 800 + Math.random() * 400)
}

timer = window.setTimeout(tick, 400)
}

/** Stop the thinking blips instantly (idempotent). */
export function stopThinkingSound(): void {
if (timer !== null) {
window.clearTimeout(timer)
timer = null
}
}
Loading
Loading