Skip to content

Commit c951b12

Browse files
committed
feat(chat): add live voice input waveform
1 parent 895d40e commit c951b12

9 files changed

Lines changed: 351 additions & 17 deletions

File tree

apps/desktop/src/main/ipc.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ import {
130130
} from '@/main/browser-import'
131131
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
132132
import { trackInputActivity } from '@/main/input-activity'
133-
import { type IpcDeps, registerIpcHandlers } from '@/main/ipc'
133+
import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc'
134134
import { LocalFilesystemService } from '@/main/local-filesystem'
135135
import { TerminalRegistry } from '@/main/terminal/registry'
136136
import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes'
@@ -337,6 +337,26 @@ describe('registerIpcHandlers', () => {
337337
expect(shell.openExternal).toHaveBeenCalledTimes(1)
338338
})
339339

340+
it('opens microphone privacy settings only for the trusted app origin', async () => {
341+
const { invoke } = collectHandlers()
342+
const handler = invoke.get('desktop:open-microphone-settings')
343+
344+
expect(await handler?.(evilEvent)).toBe(false)
345+
expect(await handler?.(appEvent)).toBe(process.platform === 'darwin')
346+
expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0)
347+
})
348+
349+
it('uses fixed native microphone settings URLs', async () => {
350+
await expect(openMicrophoneSettings('darwin')).resolves.toBe(true)
351+
expect(shell.openExternal).toHaveBeenLastCalledWith(
352+
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
353+
)
354+
355+
await expect(openMicrophoneSettings('win32')).resolves.toBe(true)
356+
expect(shell.openExternal).toHaveBeenLastCalledWith('ms-settings:privacy-microphone')
357+
await expect(openMicrophoneSettings('linux')).resolves.toBe(false)
358+
})
359+
340360
it('keeps live search suggestions behind the app origin and privacy preference', async () => {
341361
const { invoke } = collectHandlers()
342362
const handler = invoke.get('browser-agent:search-suggestions')

apps/desktop/src/main/ipc.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ import {
1919
isDesktopZoomPercent,
2020
isPendingDesktopScopeId,
2121
} from '@sim/desktop-bridge'
22+
import { createLogger } from '@sim/logger'
2223
import {
2324
isTerminalOperation,
2425
isTerminalToolName,
2526
type TerminalToolArgs,
2627
} from '@sim/terminal-protocol'
28+
import { getErrorMessage } from '@sim/utils/errors'
2729
import { isRecordLike } from '@sim/utils/object'
2830
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
29-
import { clipboard, ipcMain } from 'electron'
31+
import { clipboard, ipcMain, shell } from 'electron'
3032
import {
3133
type BrowserToolQueueBoundary,
3234
cancelActiveTool,
@@ -84,9 +86,35 @@ import type { ScopedEventRouter } from '@/main/scoped-event-router'
8486
import type { TerminalRegistry } from '@/main/terminal/registry'
8587
import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes'
8688

89+
const logger = createLogger('DesktopIpc')
90+
8791
/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */
8892
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
8993

94+
const MICROPHONE_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
95+
darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
96+
win32: 'ms-settings:privacy-microphone',
97+
}
98+
99+
/** Opens the native microphone privacy pane without accepting a renderer-provided URL. */
100+
export async function openMicrophoneSettings(
101+
platform: NodeJS.Platform = process.platform
102+
): Promise<boolean> {
103+
const settingsUrl = MICROPHONE_SETTINGS_URLS[platform]
104+
if (!settingsUrl) return false
105+
106+
try {
107+
await shell.openExternal(settingsUrl)
108+
return true
109+
} catch (error) {
110+
logger.warn('Could not open microphone privacy settings', {
111+
error: getErrorMessage(error),
112+
platform,
113+
})
114+
return false
115+
}
116+
}
117+
90118
/**
91119
* Desktop state is partitioned by the existing chat id. A new-chat view uses
92120
* the composer’s existing provisional key until the server assigns that id.
@@ -627,6 +655,12 @@ export function registerIpcHandlers(deps: IpcDeps): void {
627655
handler: (url) =>
628656
typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false,
629657
},
658+
'desktop:open-microphone-settings': {
659+
kind: 'invoke',
660+
gate: 'app-origin',
661+
denied: false,
662+
handler: () => openMicrophoneSettings(),
663+
},
630664
// OAuth connect handoff: the whole flow runs in the system browser (state
631665
// is cookie-bound to the initiating user agent), returning via loopback.
632666
'desktop:oauth-connect': {

apps/desktop/src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ function shellVersion(): string {
114114
const api: SimDesktopApi = {
115115
version: shellVersion(),
116116
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke('desktop:open-external', url),
117+
openMicrophoneSettings: (): Promise<boolean> =>
118+
ipcRenderer.invoke('desktop:open-microphone-settings'),
117119
beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise<boolean> =>
118120
ipcRenderer.invoke('desktop:oauth-connect', providerId, scope),
119121
onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button'
8+
9+
let container: HTMLDivElement
10+
let root: Root
11+
let animationFrameCallback: FrameRequestCallback | undefined
12+
let nextAnimationFrameId: number
13+
14+
function setReducedMotion(matches: boolean): void {
15+
vi.stubGlobal(
16+
'matchMedia',
17+
vi.fn(() => ({
18+
matches,
19+
media: '(prefers-reduced-motion: reduce)',
20+
onchange: null,
21+
addEventListener: vi.fn(),
22+
removeEventListener: vi.fn(),
23+
addListener: vi.fn(),
24+
removeListener: vi.fn(),
25+
dispatchEvent: vi.fn(),
26+
}))
27+
)
28+
}
29+
30+
function render(isListening: boolean, onToggle = vi.fn(), levels = new Float32Array(5)) {
31+
act(() => {
32+
root.render(
33+
<MicButton
34+
isListening={isListening}
35+
audioLevelsRef={{ current: levels }}
36+
onToggle={onToggle}
37+
/>
38+
)
39+
})
40+
return onToggle
41+
}
42+
43+
beforeEach(() => {
44+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
45+
container = document.createElement('div')
46+
document.body.appendChild(container)
47+
root = createRoot(container)
48+
animationFrameCallback = undefined
49+
nextAnimationFrameId = 1
50+
setReducedMotion(false)
51+
vi.stubGlobal(
52+
'requestAnimationFrame',
53+
vi.fn((callback: FrameRequestCallback) => {
54+
animationFrameCallback = callback
55+
return nextAnimationFrameId++
56+
})
57+
)
58+
vi.stubGlobal('cancelAnimationFrame', vi.fn())
59+
})
60+
61+
afterEach(() => {
62+
act(() => root.unmount())
63+
container.remove()
64+
vi.unstubAllGlobals()
65+
})
66+
67+
describe('MicButton', () => {
68+
it('keeps the waveform inside the circular active control', () => {
69+
render(true)
70+
71+
const button = container.querySelector('button')
72+
const waveform = container.querySelector('svg[viewBox="0 0 18 18"]')
73+
const bars = waveform?.querySelectorAll('line')
74+
75+
expect(button?.className).toContain('size-[28px]')
76+
expect(button?.className).toContain('overflow-hidden')
77+
expect(button?.className).toContain('rounded-full')
78+
expect(waveform?.classList.contains('size-[18px]')).toBe(true)
79+
expect(bars).toHaveLength(5)
80+
expect(Array.from(bars ?? []).map((bar) => bar.getAttribute('x1'))).toEqual([
81+
'3',
82+
'6',
83+
'9',
84+
'12',
85+
'15',
86+
])
87+
})
88+
89+
it('animates SVG attributes from the shared audio buffer without a React render', () => {
90+
render(true, vi.fn(), new Float32Array([1, 1, 1, 1, 1]))
91+
const firstBar = container.querySelector('line')
92+
const initialY1 = firstBar?.getAttribute('y1')
93+
94+
act(() => animationFrameCallback?.(16))
95+
96+
expect(firstBar?.getAttribute('y1')).not.toBe(initialY1)
97+
expect(requestAnimationFrame).toHaveBeenCalledTimes(2)
98+
})
99+
100+
it('exposes pressed state, toggles on click, and stops work for reduced motion', () => {
101+
setReducedMotion(true)
102+
const onToggle = render(true)
103+
const button = container.querySelector('button')
104+
105+
expect(button?.getAttribute('aria-pressed')).toBe('true')
106+
expect(button?.getAttribute('aria-label')).toBe('Stop listening')
107+
expect(requestAnimationFrame).not.toHaveBeenCalled()
108+
109+
act(() => button?.click())
110+
expect(onToggle).toHaveBeenCalledOnce()
111+
})
112+
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,119 @@
11
'use client'
22

3-
import React from 'react'
4-
import { cn, Mic, Tooltip } from '@sim/emcn'
3+
import { memo, type RefObject, useEffect, useRef } from 'react'
4+
import { Button, cn, Tooltip, usePrefersReducedMotion } from '@sim/emcn'
5+
import { Mic } from '@sim/emcn/icons'
6+
7+
const WAVEFORM_BAR_COUNT = 5
8+
const WAVEFORM_MIN_HEIGHT = 3
9+
const WAVEFORM_MAX_HEIGHT = 14
10+
const WAVEFORM_CENTER = 9
11+
const WAVEFORM_EASING = 0.24
512

613
interface MicButtonProps {
714
isListening: boolean
15+
audioLevelsRef: RefObject<Float32Array>
816
onToggle: () => void
917
}
1018

11-
export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) {
19+
interface VoiceWaveformProps {
20+
audioLevelsRef: RefObject<Float32Array>
21+
isListening: boolean
22+
}
23+
24+
function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) {
25+
const prefersReducedMotion = usePrefersReducedMotion()
26+
const barRefs = useRef<Array<SVGLineElement | null>>([])
27+
28+
useEffect(() => {
29+
if (!isListening || prefersReducedMotion) return
30+
31+
const heights = new Float32Array(WAVEFORM_BAR_COUNT).fill(WAVEFORM_MIN_HEIGHT)
32+
let animationFrameId = 0
33+
34+
const draw = () => {
35+
const levels = audioLevelsRef.current
36+
37+
for (let index = 0; index < WAVEFORM_BAR_COUNT; index++) {
38+
const level = levels?.[index] ?? 0
39+
const targetHeight =
40+
WAVEFORM_MIN_HEIGHT +
41+
Math.sqrt(Math.max(0, level)) * (WAVEFORM_MAX_HEIGHT - WAVEFORM_MIN_HEIGHT)
42+
heights[index] += (targetHeight - heights[index]) * WAVEFORM_EASING
43+
44+
const bar = barRefs.current[index]
45+
if (!bar) continue
46+
const halfHeight = heights[index] / 2
47+
bar.setAttribute('y1', String(WAVEFORM_CENTER - halfHeight))
48+
bar.setAttribute('y2', String(WAVEFORM_CENTER + halfHeight))
49+
}
50+
51+
animationFrameId = window.requestAnimationFrame(draw)
52+
}
53+
54+
animationFrameId = window.requestAnimationFrame(draw)
55+
return () => window.cancelAnimationFrame(animationFrameId)
56+
}, [audioLevelsRef, isListening, prefersReducedMotion])
57+
58+
return (
59+
<svg aria-hidden viewBox='0 0 18 18' className='size-[18px] overflow-hidden'>
60+
{Array.from({ length: WAVEFORM_BAR_COUNT }, (_, index) => {
61+
const x = 3 + index * 3
62+
return (
63+
<line
64+
key={x}
65+
ref={(element) => {
66+
barRefs.current[index] = element
67+
}}
68+
x1={x}
69+
x2={x}
70+
y1={WAVEFORM_CENTER - WAVEFORM_MIN_HEIGHT / 2}
71+
y2={WAVEFORM_CENTER + WAVEFORM_MIN_HEIGHT / 2}
72+
stroke='currentColor'
73+
strokeLinecap='round'
74+
strokeWidth='1.7'
75+
/>
76+
)
77+
})}
78+
</svg>
79+
)
80+
}
81+
82+
export const MicButton = memo(function MicButton({
83+
isListening,
84+
audioLevelsRef,
85+
onToggle,
86+
}: MicButtonProps) {
1287
return (
1388
<Tooltip.Root>
1489
<Tooltip.Trigger asChild>
15-
<button
90+
<Button
1691
type='button'
92+
variant={isListening ? 'active' : 'ghost'}
1793
onClick={onToggle}
1894
aria-label={isListening ? 'Stop listening' : 'Voice input'}
95+
aria-pressed={isListening}
1996
className={cn(
20-
'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors',
21-
isListening
22-
? 'bg-red-500 text-white hover:bg-red-600'
23-
: 'text-[var(--text-icon)] hover:bg-[#F7F7F7] dark:hover:bg-[#303030]'
97+
'relative size-[28px] overflow-hidden rounded-full p-0 transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100',
98+
!isListening &&
99+
'text-[var(--text-icon)] hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-icon)]'
24100
)}
25101
>
26-
<Mic className='h-[16px] w-[16px]' />
27-
</button>
102+
<span
103+
className={cn(
104+
'absolute inset-0 flex items-center justify-center transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
105+
isListening ? 'scale-100 opacity-100 blur-0' : 'scale-[0.25] opacity-0 blur-[4px]'
106+
)}
107+
>
108+
<VoiceWaveform audioLevelsRef={audioLevelsRef} isListening={isListening} />
109+
</span>
110+
<Mic
111+
className={cn(
112+
'size-[16px] transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
113+
isListening ? 'scale-[0.25] opacity-0 blur-[4px]' : 'scale-100 opacity-100 blur-0'
114+
)}
115+
/>
116+
</Button>
28117
</Tooltip.Trigger>
29118
<Tooltip.Content side='top'>{isListening ? 'Stop listening' : 'Voice input'}</Tooltip.Content>
30119
</Tooltip.Root>

0 commit comments

Comments
 (0)