fix(voice): run reactive opentui and harden shutdown
This commit is contained in:
parent
5f0487fa87
commit
e5ac4ca560
5 changed files with 87 additions and 51 deletions
4
packages/voice/bunfig.toml
Normal file
4
packages/voice/bunfig.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"description": "Prototype voice control for OpenCode via the OpenAI Realtime API",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"spike": "bun src/spike.ts",
|
||||
"spike": "bun run --no-orphans --conditions=browser src/spike.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -19,6 +19,6 @@
|
|||
"@opencode-ai/client": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"solid-js": "1.9.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const truncate = (text: string, max = 2000) => (text.length > max ? text.slice(0
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createConsoleUI, createVoiceTUI } = await import("./ui")
|
||||
const tuiActive = !args.text && process.stdout.isTTY
|
||||
const tuiActive = !args.text
|
||||
const ui = tuiActive
|
||||
? await createVoiceTUI({
|
||||
onInterrupt: () => interrupt(),
|
||||
|
|
@ -327,10 +327,12 @@ const fullDuplex = aecBinary !== undefined || args.duplex
|
|||
// session is untouched).
|
||||
const voices = ["marin", "cedar", "coral", "sage", "ash", "ballad", "alloy", "verse"]
|
||||
let currentVoice = args.voice ?? "marin"
|
||||
let ws!: WebSocket
|
||||
let ws: WebSocket | undefined
|
||||
let reconnecting = false
|
||||
|
||||
const send = (event: Record<string, unknown>) => ws.send(JSON.stringify(event))
|
||||
const send = (event: Record<string, unknown>) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event))
|
||||
}
|
||||
|
||||
function setVoice(voice: string) {
|
||||
currentVoice = voice
|
||||
|
|
@ -338,7 +340,7 @@ function setVoice(voice: string) {
|
|||
ui.meta(`[voice] switching to ${voice}…`)
|
||||
reconnecting = true
|
||||
flushPlayback()
|
||||
ws.close(1000)
|
||||
ws?.close(1000)
|
||||
connectRealtime()
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +380,7 @@ async function startMicrophone() {
|
|||
ui.setStatus({ audio: args.speakers ? "duplex+aec" : "duplex" })
|
||||
ui.meta("mic live — talk any time, even over the assistant")
|
||||
for await (const chunk of audio.stdout as ReadableStream<Uint8Array>) {
|
||||
if (ws.readyState === WebSocket.OPEN)
|
||||
if (ws?.readyState === WebSocket.OPEN)
|
||||
send({ type: "input_audio_buffer.append", audio: Buffer.from(chunk).toString("base64") })
|
||||
}
|
||||
return
|
||||
|
|
@ -388,7 +390,7 @@ async function startMicrophone() {
|
|||
ui.meta("mic live — start talking")
|
||||
if (!args.duplex) ui.meta("mic mutes while the assistant speaks; press any key to interrupt")
|
||||
for await (const chunk of recorder.stdout as ReadableStream<Uint8Array>) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue
|
||||
if (ws?.readyState !== WebSocket.OPEN) continue
|
||||
// Half-duplex: drop mic audio while the assistant is audible (plus a
|
||||
// short tail) so speaker echo can't barge-in against itself.
|
||||
if (!args.duplex && Date.now() < playbackEndsAt + 300) continue
|
||||
|
|
@ -570,12 +572,16 @@ function onClose(event: CloseEvent) {
|
|||
shutdown()
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
function shutdown() {
|
||||
ui.close()
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
recorder?.kill()
|
||||
player?.kill()
|
||||
audio?.kill()
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close()
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.close()
|
||||
ui.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
// moment the audio buffer commits (before the assistant starts replying) and
|
||||
// its transcript is filled in when Whisper finishes.
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { render, useKeyboard } from "@opentui/solid"
|
||||
import { For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, For } from "solid-js"
|
||||
|
||||
export type VoiceStatus = {
|
||||
server?: string
|
||||
|
|
@ -151,39 +150,53 @@ export async function createVoiceTUI(options: {
|
|||
onExit(): void
|
||||
onCycleVoice(): void
|
||||
}): Promise<VoiceUI> {
|
||||
const [state, setState] = createStore({
|
||||
const [state, setState] = createSignal({
|
||||
messages: [] as Message[],
|
||||
status: {} as VoiceStatus,
|
||||
})
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
useMouse: true,
|
||||
exitOnCtrlC: false,
|
||||
// Handle Ctrl-C in OpenTUI's native input parser, before Solid handlers
|
||||
// and rendering work. onDestroy performs process/audio cleanup below.
|
||||
exitOnCtrlC: true,
|
||||
exitSignals: [],
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
screenMode: "alternate-screen",
|
||||
externalOutputMode: "passthrough",
|
||||
consoleMode: "disabled",
|
||||
onDestroy: options.onExit,
|
||||
})
|
||||
|
||||
function App() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") return options.onExit()
|
||||
if (evt.ctrl && evt.name === "c") return
|
||||
if (evt.name === "v") return options.onCycleVoice()
|
||||
options.onInterrupt()
|
||||
})
|
||||
const statusLine = () =>
|
||||
[
|
||||
state.status.audio ?? "connecting…",
|
||||
state.status.voice,
|
||||
state.status.session ?? "no session",
|
||||
state.status.server,
|
||||
state().status.audio ?? "connecting…",
|
||||
state().status.voice,
|
||||
state().status.session ?? "no session",
|
||||
state().status.server,
|
||||
"v: voice · any key interrupts · ctrl+c quits",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%" paddingLeft={1} paddingRight={1}>
|
||||
<box
|
||||
flexDirection="column"
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
|
||||
<box flexDirection="column" flexShrink={0}>
|
||||
<For each={state.messages}>{(message) => <MessageRow message={message} />}</For>
|
||||
<For each={state().messages}>{(message) => <MessageRow message={message} />}</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<box height={1} marginTop={1}>
|
||||
|
|
@ -195,50 +208,57 @@ export async function createVoiceTUI(options: {
|
|||
|
||||
void render(() => <App />, renderer)
|
||||
|
||||
const push = (message: Message) => setState("messages", state.messages.length, message)
|
||||
const redraw = () => renderer.requestRender()
|
||||
const push = (message: Message) => {
|
||||
setState((current) => ({ ...current, messages: [...current.messages, message] }))
|
||||
redraw()
|
||||
}
|
||||
|
||||
return {
|
||||
meta: (text) => push({ kind: "meta", text }),
|
||||
userCommitted: (itemID) => push({ kind: "user", itemID }),
|
||||
userTranscript: (itemID, text) => {
|
||||
const index = state.messages.findIndex((m) => m.kind === "user" && m.itemID === itemID)
|
||||
const index = state().messages.findIndex((m) => m.kind === "user" && m.itemID === itemID)
|
||||
if (index === -1) return push({ kind: "user", itemID, text })
|
||||
setState(
|
||||
"messages",
|
||||
index,
|
||||
produce((m) => {
|
||||
if (m.kind === "user") m.text = text
|
||||
}),
|
||||
)
|
||||
setState((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message, i) =>
|
||||
i === index && message.kind === "user" ? { ...message, text } : message,
|
||||
),
|
||||
}))
|
||||
redraw()
|
||||
},
|
||||
assistantDelta: (text) => {
|
||||
const index = state.messages.length - 1
|
||||
const last = state.messages[index]
|
||||
const index = state().messages.length - 1
|
||||
const last = state().messages[index]
|
||||
if (last?.kind === "assistant" && last.streaming) {
|
||||
setState(
|
||||
"messages",
|
||||
index,
|
||||
produce((m) => {
|
||||
if (m.kind === "assistant") m.text += text
|
||||
}),
|
||||
)
|
||||
setState((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message, i) =>
|
||||
i === index && message.kind === "assistant" ? { ...message, text: message.text + text } : message,
|
||||
),
|
||||
}))
|
||||
redraw()
|
||||
return
|
||||
}
|
||||
push({ kind: "assistant", text, streaming: true })
|
||||
},
|
||||
assistantDone: () => {
|
||||
const index = state.messages.length - 1
|
||||
if (state.messages[index]?.kind !== "assistant") return
|
||||
setState(
|
||||
"messages",
|
||||
index,
|
||||
produce((m) => {
|
||||
if (m.kind === "assistant") m.streaming = false
|
||||
}),
|
||||
)
|
||||
const index = state().messages.length - 1
|
||||
if (state().messages[index]?.kind !== "assistant") return
|
||||
setState((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message, i) =>
|
||||
i === index && message.kind === "assistant" ? { ...message, streaming: false } : message,
|
||||
),
|
||||
}))
|
||||
redraw()
|
||||
},
|
||||
tool: (name, output) => push({ kind: "tool", name, json: compactJson(output) }),
|
||||
setStatus: (patch) => setState("status", patch),
|
||||
setStatus: (patch) => {
|
||||
setState((current) => ({ ...current, status: { ...current.status, ...patch } }))
|
||||
redraw()
|
||||
},
|
||||
close: () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue