Skip to content

Commit 022b207

Browse files
committed
Implement voice input functionality by adding audio recording handlers, backend processing via model providers, and a microphone interface in the prompt field
1 parent 54e9c36 commit 022b207

11 files changed

Lines changed: 329 additions & 16 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { PanelProvider } from '../panel-provider'
2+
import { SetRecordingStateMessage } from '../../types/messages'
3+
import { spawn } from 'child_process'
4+
import { Logger } from '@shared/utils/logger'
5+
import {
6+
ModelProvidersManager,
7+
ToolConfig
8+
} from '@/services/model-providers-manager'
9+
import { PROVIDERS } from '@shared/constants/providers'
10+
import * as vscode from 'vscode'
11+
import { apply_reasoning_effort } from '@/utils/apply-reasoning-effort'
12+
import axios from 'axios'
13+
import { make_api_request } from '@/utils/make-api-request'
14+
15+
const start_recording = (panel_provider: PanelProvider) => {
16+
panel_provider.audio_chunks = []
17+
try {
18+
panel_provider.recording_process = spawn('rec', ['-q', '-t', 'wav', '-'])
19+
20+
panel_provider.recording_process.stdout.on('data', (chunk: Buffer) => {
21+
panel_provider.audio_chunks.push(chunk)
22+
})
23+
} catch (error: any) {
24+
Logger.error({
25+
function_name: 'start_recording',
26+
message: 'Failed to start recording',
27+
data: { error }
28+
})
29+
}
30+
}
31+
32+
const stop_recording = async (panel_provider: PanelProvider) => {
33+
if (panel_provider.recording_process) {
34+
panel_provider.recording_process.kill()
35+
panel_provider.recording_process = null
36+
37+
const audio_buffer = Buffer.concat(panel_provider.audio_chunks)
38+
const base64_audio = audio_buffer.toString('base64')
39+
40+
panel_provider.audio_chunks = []
41+
42+
try {
43+
const model_providers_manager = new ModelProvidersManager(
44+
panel_provider.context
45+
)
46+
const configs =
47+
await model_providers_manager.get_voice_input_tool_configs()
48+
49+
if (configs.length == 0) {
50+
vscode.window.showWarningMessage(
51+
'No Voice Input configuration found. Please configure it in settings.',
52+
{ modal: true }
53+
)
54+
return
55+
}
56+
57+
let config: ToolConfig | undefined =
58+
await model_providers_manager.get_default_voice_input_config()
59+
60+
if (!config) {
61+
if (configs.length == 1) {
62+
config = configs[0]
63+
} else {
64+
const selected = await vscode.window.showQuickPick(
65+
configs.map((c) => ({
66+
label: c.model,
67+
description: c.provider_name,
68+
config: c
69+
})),
70+
{
71+
title: 'Configurations',
72+
placeHolder: 'Select a configuration'
73+
}
74+
)
75+
if (selected) {
76+
config = selected.config
77+
} else {
78+
return
79+
}
80+
}
81+
}
82+
83+
const provider = await model_providers_manager.get_provider(
84+
config!.provider_name
85+
)
86+
87+
if (!provider) {
88+
vscode.window.showErrorMessage(
89+
`Provider ${config.provider_name} not found.`
90+
)
91+
return
92+
}
93+
94+
let endpoint_url = ''
95+
if (provider.type == 'built-in') {
96+
const provider_info = PROVIDERS[provider.name as keyof typeof PROVIDERS]
97+
if (provider_info) {
98+
endpoint_url = provider_info.base_url
99+
}
100+
} else {
101+
endpoint_url = provider.base_url
102+
}
103+
104+
const body: { [key: string]: any } = {
105+
model: config.model,
106+
messages: [
107+
{
108+
role: 'user',
109+
content: [
110+
{
111+
type: 'text',
112+
text: 'Transcribe this audio'
113+
},
114+
{
115+
type: 'input_audio',
116+
input_audio: {
117+
data: base64_audio,
118+
format: 'wav'
119+
}
120+
}
121+
]
122+
}
123+
]
124+
}
125+
126+
apply_reasoning_effort(body, provider, config.reasoning_effort)
127+
128+
const cancel_token_source = axios.CancelToken.source()
129+
130+
const result = await make_api_request({
131+
endpoint_url,
132+
api_key: provider.api_key,
133+
body,
134+
cancellation_token: cancel_token_source.token
135+
})
136+
137+
if (result?.response) {
138+
panel_provider.add_text_at_cursor_position(result.response)
139+
}
140+
} catch (error: any) {
141+
Logger.error({
142+
function_name: 'stop_recording',
143+
message: 'Failed to process audio',
144+
data: { error }
145+
})
146+
vscode.window.showErrorMessage(
147+
'Failed to process audio: ' + error.message
148+
)
149+
}
150+
}
151+
}
152+
153+
export const handle_voice_input = async (
154+
panel_provider: PanelProvider,
155+
message: SetRecordingStateMessage
156+
) => {
157+
if (panel_provider.is_recording === message.is_recording) {
158+
return
159+
}
160+
161+
panel_provider.is_recording = message.is_recording
162+
panel_provider.send_message({
163+
command: 'RECORDING_STATE',
164+
is_recording: panel_provider.is_recording
165+
})
166+
167+
if (panel_provider.is_recording) {
168+
start_recording(panel_provider)
169+
} else {
170+
await stop_recording(panel_provider)
171+
}
172+
}

‎apps/editor/src/views/panel/backend/message-handlers/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,4 @@ export * from './handle-open-prompt-image'
6666
export * from './handle-save-prompt-document'
6767
export * from './handle-open-prompt-document'
6868
export * from './handle-paste-url'
69+
export * from './handle-voice-input'

‎apps/editor/src/views/panel/backend/panel-provider.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from 'vscode'
22
import * as path from 'path'
3+
import { ChildProcessWithoutNullStreams } from 'child_process'
34
import { WebSocketManager } from '@/services/websocket-manager'
45
import { FrontendMessage, BackendMessage } from '../types/messages'
56
import { ApiManager } from '@/services/api-manager'
@@ -82,7 +83,8 @@ import {
8283
handle_open_prompt_image,
8384
handle_save_prompt_document,
8485
handle_open_prompt_document,
85-
handle_paste_url
86+
handle_paste_url,
87+
handle_voice_input
8688
} from './message-handlers'
8789
import { SelectionState } from '../types/messages'
8890
import {
@@ -146,6 +148,10 @@ export class PanelProvider implements vscode.WebviewViewProvider {
146148
timestamp: number
147149
} | null = null
148150

151+
public is_recording = false
152+
public recording_process: ChildProcessWithoutNullStreams | null = null
153+
public audio_chunks: Buffer[] = []
154+
149155
public preview_switch_choice_resolver:
150156
| ((choice: 'Switch' | undefined) => void)
151157
| undefined = undefined
@@ -788,6 +794,8 @@ export class PanelProvider implements vscode.WebviewViewProvider {
788794
await handle_open_prompt_document(message)
789795
} else if (message.command == 'PASTE_URL') {
790796
await handle_paste_url(this, message)
797+
} else if (message.command == 'SET_RECORDING_STATE') {
798+
await handle_voice_input(this, message)
791799
}
792800
} catch (error: any) {
793801
Logger.error({

‎apps/editor/src/views/panel/frontend/Main/Main.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ type Props = {
6969
on_paste_document: (text: string) => void
7070
on_open_document: (hash: string) => void
7171
on_paste_url: (url: string) => void
72+
is_recording: boolean
73+
on_recording_started: () => void
74+
on_recording_finished: () => void
7275
}
7376

7477
export const Main: React.FC<Props> = (props) => {
@@ -849,6 +852,9 @@ export const Main: React.FC<Props> = (props) => {
849852
on_paste_document={props.on_paste_document}
850853
on_open_document={props.on_open_document}
851854
on_paste_url={props.on_paste_url}
855+
is_recording={props.is_recording}
856+
on_recording_started={props.on_recording_started}
857+
on_recording_finished={props.on_recording_finished}
852858
/>
853859
)
854860
}

‎apps/editor/src/views/panel/frontend/Main/MainView/MainView.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ type Props = {
110110
on_paste_document: (text: string) => void
111111
on_open_document: (hash: string) => void
112112
on_paste_url: (url: string) => void
113+
is_recording: boolean
114+
on_recording_started: () => void
115+
on_recording_finished: () => void
113116
}
114117

115118
export const MainView: React.FC<Props> = (props) => {
@@ -276,6 +279,9 @@ export const MainView: React.FC<Props> = (props) => {
276279
on_paste_document={props.on_paste_document}
277280
on_open_document={props.on_open_document}
278281
on_paste_url={props.on_paste_url}
282+
is_recording={props.is_recording}
283+
on_recording_started={props.on_recording_started}
284+
on_recording_finished={props.on_recording_finished}
279285
/>
280286
<UiContextUtilisation
281287
current_context_size={props.token_count}

‎apps/editor/src/views/panel/frontend/Panel.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ export const Panel = () => {
105105
handle_open_image,
106106
handle_paste_long_document,
107107
handle_open_document,
108-
handle_paste_url
108+
handle_paste_url,
109+
is_recording,
110+
handle_set_recording_state
109111
} = use_panel(vscode)
110112

111113
const [checkpoint_to_edit, set_checkpoint_to_edit] = useState<
@@ -314,6 +316,9 @@ export const Panel = () => {
314316
on_paste_document={handle_paste_long_document}
315317
on_open_document={handle_open_document}
316318
on_paste_url={handle_paste_url}
319+
is_recording={is_recording}
320+
on_recording_started={() => handle_set_recording_state(true)}
321+
on_recording_finished={() => handle_set_recording_state(false)}
317322
/>
318323
</div>
319324
<div

‎apps/editor/src/views/panel/frontend/hooks/use-panel.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,13 @@ export const use_panel = (vscode: any) => {
108108
configurations_collapsed_by_api_mode,
109109
set_configurations_collapsed_by_api_mode
110110
] = useState<{ [mode in ApiPromptType]?: boolean }>({})
111-
112111
const [tasks, set_tasks] = useState<Record<string, Task[]>>({})
113112
const [
114113
prune_context_instructions_prefix,
115114
set_prune_context_instructions_prefix
116115
] = useState<string>('')
117116
const [fix_all_automatically, set_fix_all_automatically] = useState(false)
117+
const [is_recording, set_is_recording] = useState(false)
118118

119119
const handle_instructions_change = (
120120
value: string,
@@ -218,6 +218,13 @@ export const use_panel = (vscode: any) => {
218218
})
219219
}
220220

221+
const handle_set_recording_state = (is_recording: boolean) => {
222+
post_message(vscode, {
223+
command: 'SET_RECORDING_STATE',
224+
is_recording
225+
})
226+
}
227+
221228
useEffect(() => {
222229
const handle_message = (event: MessageEvent<BackendMessage>) => {
223230
const message = event.data
@@ -384,6 +391,8 @@ export const use_panel = (vscode: any) => {
384391
set_tasks(message.tasks)
385392
} else if (message.command == 'PRUNE_CONTEXT_INSTRUCTIONS_PREFIX') {
386393
set_prune_context_instructions_prefix(message.prefix)
394+
} else if (message.command == 'RECORDING_STATE') {
395+
set_is_recording(message.is_recording)
387396
}
388397
}
389398
window.addEventListener('message', handle_message)
@@ -618,6 +627,8 @@ export const use_panel = (vscode: any) => {
618627
handle_open_image,
619628
handle_paste_long_document,
620629
handle_open_document,
621-
handle_paste_url
630+
handle_paste_url,
631+
is_recording,
632+
handle_set_recording_state
622633
}
623634
}

‎apps/editor/src/views/panel/types/messages.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,11 @@ export interface PasteUrlMessage extends BaseMessage {
507507
url: string
508508
}
509509

510+
export interface SetRecordingStateMessage extends BaseMessage {
511+
command: 'SET_RECORDING_STATE'
512+
is_recording: boolean
513+
}
514+
510515
export type FrontendMessage =
511516
| GetInstructionsMessage
512517
| SaveInstructionsMessage
@@ -595,6 +600,7 @@ export type FrontendMessage =
595600
| SavePromptDocumentMessage
596601
| OpenPromptDocumentMessage
597602
| PasteUrlMessage
603+
| SetRecordingStateMessage
598604

599605
// === FROM BACKEND TO FRONTEND ===
600606
export interface InstructionsMessage extends BaseMessage {
@@ -834,6 +840,11 @@ export interface PruneContextInstructionsPrefixMessage extends BaseMessage {
834840
prefix: string
835841
}
836842

843+
export interface RecordingStateMessage extends BaseMessage {
844+
command: 'RECORDING_STATE'
845+
is_recording: boolean
846+
}
847+
837848
export type BackendMessage =
838849
| InstructionsMessage
839850
| FocusPromptFieldMessage
@@ -878,3 +889,4 @@ export type BackendMessage =
878889
| TasksMessage
879890
| UpdateFileProgressMessage
880891
| PruneContextInstructionsPrefixMessage
892+
| RecordingStateMessage

0 commit comments

Comments
 (0)