Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-speech-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Wait for microphone capture to start before showing voice input as recording.
6 changes: 6 additions & 0 deletions packages/kilo-vscode/src/services/input-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { KiloConnectionService } from "./cli-backend/connection-service"
import { routeAutocompleteMessage } from "./autocomplete/settings"
import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler"
import { prewarmSpeechCapture } from "../speech-to-text/capture"

type Msg = {
type: string
Expand All @@ -18,6 +19,11 @@ type Ctx = {
export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise<boolean> {
if (await routeAutocompleteMessage(message, ctx.post)) return true

if (message.type === "speechToTextPrewarm") {
void prewarmSpeechCapture().catch((err) => console.warn("[Kilo New] Speech capture prewarm failed:", err))
return true
}

if (message.type === "speechToTextStart") {
if (!message.requestId) return true
handleSpeechToTextStart(
Expand Down
24 changes: 23 additions & 1 deletion packages/kilo-vscode/src/speech-to-text/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ type Args = {

let active: Recording | undefined
let starting: string | undefined
let ffmpeg: Promise<string> | undefined

export async function prewarmSpeechCapture(): Promise<void> {
await resolveFFmpeg()
}

export async function startSpeechCapture(input: Input): Promise<boolean> {
if (active || starting) throw new Error("Speech recording is already in progress")

starting = input.requestId
try {
const bin = await findFFmpeg()
const bin = await resolveFFmpeg()
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
return !state.stopped
Expand Down Expand Up @@ -229,6 +234,23 @@ function requireActive(requestId: string): Recording {
return active
}

async function resolveFFmpeg(): Promise<string> {
if (ffmpeg) {
const bin = await ffmpeg
if (!path.isAbsolute(bin) || existsSync(bin)) return bin
ffmpeg = undefined
}

const task = findFFmpeg()
ffmpeg = task
try {
return await task
} catch (err) {
if (ffmpeg === task) ffmpeg = undefined
throw err
}
}

async function findFFmpeg(): Promise<string> {
const paths = [
process.env.KILO_FFMPEG_PATH,
Expand Down
73 changes: 73 additions & 0 deletions packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "bun:test"
import path from "node:path"

const ROOT = path.resolve(import.meta.dir, "../..")

describe("speech-to-text prewarm", () => {
it("starts only after Kilo speech access becomes available", () => {
const result = Bun.spawnSync(
[
"bun",
"--conditions=browser",
"-e",
`
import { Window } from "happy-dom"

const window = new Window()
globalThis.window = window
globalThis.document = window.document
globalThis.Node = window.Node

const sent = []
globalThis.acquireVsCodeApi = () => ({
postMessage: (message) => sent.push(message),
getState: () => undefined,
setState: () => {},
})

const { createComponent, createSignal } = await import("solid-js")
const { render } = await import("solid-js/web")
const { ConfigContext } = await import("./webview-ui/src/context/config.tsx")
const { ProviderContext } = await import("./webview-ui/src/context/provider.tsx")
const { SpeechToTextPrewarm } = await import(
"./webview-ui/src/components/speech-to-text/SpeechToTextPrewarm.tsx"
)

const [config, setConfig] = createSignal({ disabled_providers: ["kilo"] })
const [auth, setAuth] = createSignal({})
const root = document.createElement("div")
const dispose = render(
() =>
createComponent(ProviderContext.Provider, {
value: { authStates: auth },
get children() {
return createComponent(ConfigContext.Provider, {
value: { config },
get children() {
return createComponent(SpeechToTextPrewarm, {})
},
})
},
}),
root,
)

if (sent.length !== 0) throw new Error("prewarmed without Kilo access")
setAuth({ kilo: "api" })
if (sent.length !== 0) throw new Error("prewarmed while Kilo was disabled")
setConfig({})
if (sent.length !== 1 || sent[0]?.type !== "speechToTextPrewarm") {
throw new Error("did not prewarm after Kilo access became available")
}
setAuth({ kilo: "oauth" })
if (sent.length !== 1) throw new Error("prewarmed more than once")
dispose()
`,
],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
)

const output = result.stdout.toString() + result.stderr.toString()
expect(result.exitCode, output).toBe(0)

Check failure on line 71 in packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts

View workflow job for this annotation

GitHub Actions / unit tests

error: error: Cannot find module 'react/jsx-dev-runtime' from '/home/runner/_work/kilocode/kilocode/packages/kilo-vscode/webview-ui/src/context/config.tsx'

Bun v1.3.14 (Linux x64 baseline) Expected: 0 Received: 1 at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts:71:37)
})
})
56 changes: 56 additions & 0 deletions packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,59 @@ function setup() {
}

describe("useSpeechToText", () => {
it("waits for microphone readiness before reporting recording", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

expect(ctx.speech.state()).toBe("starting")
expect(ctx.speech.active()).toBe(true)

ctx.fire({ type: "speechToTextStarted", requestId: "another-request" })
expect(ctx.speech.state()).toBe("starting")

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("recording")
ctx.dispose()
})

it("does not stop or start another recording while the microphone is starting", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

ctx.speech.stop()
ctx.speech.start({ model: "other", insert: () => {} })
expect(ctx.speech.state()).toBe("starting")
expect(ctx.sent).toEqual([start])

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
ctx.speech.stop()
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.dispose()
})

it("cancels a pending microphone startup and ignores its late acknowledgement", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

ctx.speech.cancel()
expect(ctx.sent[1]).toEqual({ type: "speechToTextCancel", requestId: start.requestId })
expect(ctx.speech.state()).toBe("idle")

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("idle")
ctx.dispose()
})

it("offers sign-in when stored credentials stop authenticating", () => {
const ctx = setup()

Expand Down Expand Up @@ -70,6 +123,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++ })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" })
Expand All @@ -87,6 +141,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++ })
ctx.speech.cancel()
Expand All @@ -105,6 +160,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++, ready: () => false })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { FeedbackProvider } from "../src/context/feedback"
import { SessionProvider, useSession } from "../src/context/session"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { DataBridge, MermaidDownloadBridge } from "../src/App"
Expand Down Expand Up @@ -3131,6 +3132,7 @@ export const AgentManagerApp: Component = () => {
<FileComponentProvider component={File}>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DisplayProvider>
<IndexingProvider>
<KiloEmbeddingModelsProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/dif
import type { DiffViewerNotice } from "../src/types/messages/extension-messages"
import { DiffPickerHeader } from "./DiffPickerHeader"
import { BaseBranchPicker } from "./BaseBranchPicker"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"

const NOTICE_KEYS: Record<DiffViewerNotice, string> = {
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
Expand Down Expand Up @@ -305,6 +306,7 @@ export const DiffViewerApp: Component = () => {
<ServerProvider>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DiffViewerShell />
</ConfigProvider>
</ProviderProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ChatView } from "./components/chat"
import { SidebarEmptyState } from "./components/chat/SidebarEmptyState"
import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded"
import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides"
import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm"

// Override the upstream "task" tool renderer with the fully-expanded version
// that shows child session parts inline in the VS Code sidebar.
Expand Down Expand Up @@ -378,6 +379,7 @@ const App: Component = () => {
<FileComponentProvider component={File}>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DisplayProvider>
<WorkStyleProvider>
<IndexingProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button } from "@kilocode/kilo-ui/button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import type { Component } from "solid-js"
import { onCleanup, type Component } from "solid-js"
import type { SpeechToText } from "./useSpeechToText"

type Props = {
Expand All @@ -12,15 +12,18 @@ type Props = {
}

export const SpeechToTextButton: Component<Props> = (props) => {
const disabled = () => !!props.disabled
const unavailable = () => !!props.disabled && props.speech.state() === "idle"
const busy = () => props.speech.state() === "starting" || props.speech.state() === "transcribing"
const label = () => {
if (props.speech.state() === "starting") return props.label("speechToText.tooltip.starting")
if (props.speech.state() === "recording") return props.label("speechToText.tooltip.stop")
if (props.speech.state() === "transcribing") return props.label("speechToText.tooltip.transcribing")
if (props.speech.state() === "error") return props.speech.error() || props.label("speechToText.tooltip.error")
return props.label("speechToText.tooltip.start")
}

const click = () => {
if (props.speech.state() === "starting") return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Starting state cannot be cancelled from the button

The new state machine and tests explicitly support cancelling a pending microphone startup, but this early return swallows the only user action while the spinner is shown. If opening the capture device hangs for a few seconds or the permission flow stalls, the user has to wait for timeout or for the recording to become ready before they can recover. Letting the second click call speech.cancel() would preserve the new readiness indicator without removing the escape hatch.

Suggested change
if (props.speech.state() === "starting") return
if (props.speech.state() === "starting") return props.speech.cancel()

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if (props.speech.state() === "recording") {
props.speech.stop()
return
Expand All @@ -33,22 +36,28 @@ export const SpeechToTextButton: Component<Props> = (props) => {
props.speech.clear()
return
}
if (disabled()) return
if (unavailable()) return
props.start()
}

onCleanup(() => {
if (props.speech.active()) props.speech.cancel()
})

return (
<Tooltip value={label()} placement="top">
<Button
variant="ghost"
size="small"
onClick={click}
disabled={disabled()}
disabled={unavailable()}
aria-label={label()}
aria-disabled={unavailable() || props.speech.state() === "starting"}
aria-busy={busy()}
aria-pressed={props.speech.state() === "recording"}
class={`prompt-speech-button prompt-speech-button--${props.speech.state()}`}
>
{props.speech.state() === "transcribing" ? (
{busy() ? (
<Spinner style={{ width: "16px", height: "16px" }} />
) : (
<svg class="prompt-speech-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createEffect, type Component } from "solid-js"
import { useConfig } from "../../context/config"
import { useProvider } from "../../context/provider"
import { getVSCodeAPI } from "../../context/vscode"
import { canUseSpeechToText } from "./availability"

export const SpeechToTextPrewarm: Component = () => {
const vscode = getVSCodeAPI()
const provider = useProvider()
const { config } = useConfig()
let prepared = false

createEffect(() => {
if (prepared || !canUseSpeechToText(config(), provider.authStates())) return
prepared = true
vscode.postMessage({ type: "speechToTextPrewarm" })
})

return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Lang = {
t: (key: string) => string
}

export type SpeechState = "idle" | "recording" | "transcribing" | "error"
export type SpeechState = "idle" | "starting" | "recording" | "transcribing" | "error"

export type InsertTranscript = (text: string) => void

Expand All @@ -43,7 +43,7 @@ export type SpeechToText = {
export function useSpeechToText(vscode: VSCode, server: Server, lang: Lang): SpeechToText {
const [state, setState] = createSignal<SpeechState>("idle")
const [error, setError] = createSignal<string | undefined>()
const active = () => state() === "recording" || state() === "transcribing"
const active = () => state() === "starting" || state() === "recording" || state() === "transcribing"
const prefix = globalThis.crypto?.randomUUID?.() ?? `stt-${Math.random().toString(36).slice(2)}`

let request = ""
Expand All @@ -57,6 +57,7 @@ export function useSpeechToText(vscode: VSCode, server: Server, lang: Lang): Spe
if (msg.requestId !== request) return

if (msg.type === "speechToTextStarted") {
if (state() !== "starting") return
setState("recording")
return
}
Expand Down Expand Up @@ -103,7 +104,7 @@ export function useSpeechToText(vscode: VSCode, server: Server, lang: Lang): Spe

counter++
request = `${prefix}-${counter}`
setState("recording")
setState("starting")
vscode.postMessage({
type: "speechToTextStart",
requestId: request,
Expand Down
Loading
Loading