feat(voice): add audio-reactive TUI animations
This commit is contained in:
parent
113a117cef
commit
1d772f4896
4 changed files with 237 additions and 86 deletions
1
bun.lock
1
bun.lock
|
|
@ -993,6 +993,7 @@
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
|
"opentui-spinner": "catalog:",
|
||||||
"solid-js": "1.9.12",
|
"solid-js": "1.9.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
|
"opentui-spinner": "catalog:",
|
||||||
"solid-js": "1.9.12"
|
"solid-js": "1.9.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ if (!args.server) {
|
||||||
const apiKey = process.env["OPENAI_API_KEY"]
|
const apiKey = process.env["OPENAI_API_KEY"]
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error("OPENAI_API_KEY is required. Run via:")
|
console.error("OPENAI_API_KEY is required. Run via:")
|
||||||
console.error(` 2password run --env "OPENAI_API_KEY=op://Personal/OpenAI API Key/credential" -- bun src/spike.ts ...`)
|
console.error(
|
||||||
|
` 2password run --env "OPENAI_API_KEY=op://Personal/OpenAI API Key/credential" -- bun src/spike.ts ...`,
|
||||||
|
)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,7 +228,6 @@ const toolHandlers: Record<string, (input: Record<string, unknown>) => Promise<u
|
||||||
const sessionID = await requireSession()
|
const sessionID = await requireSession()
|
||||||
lastPromptAt = Date.now()
|
lastPromptAt = Date.now()
|
||||||
await client.session.prompt({ sessionID, text })
|
await client.session.prompt({ sessionID, text })
|
||||||
ui.meta(`[prompt] ${truncate(text, 120)}`)
|
|
||||||
if (input["delivery"] === "background")
|
if (input["delivery"] === "background")
|
||||||
return { status: "started", sessionID, hint: "Use check_session only when the user asks for status." }
|
return { status: "started", sessionID, hint: "Use check_session only when the user asks for status." }
|
||||||
await client.session.wait({ sessionID })
|
await client.session.wait({ sessionID })
|
||||||
|
|
@ -461,8 +462,7 @@ const aecBinary = await (async () => {
|
||||||
if (args.text || process.platform !== "darwin") return undefined
|
if (args.text || process.platform !== "darwin") return undefined
|
||||||
const source = Bun.fileURLToPath(new URL("./duplex-audio.swift", import.meta.url))
|
const source = Bun.fileURLToPath(new URL("./duplex-audio.swift", import.meta.url))
|
||||||
const binary = Bun.fileURLToPath(new URL("../.build/duplex-audio", import.meta.url))
|
const binary = Bun.fileURLToPath(new URL("../.build/duplex-audio", import.meta.url))
|
||||||
if ((await Bun.file(binary).exists()) && Bun.file(binary).lastModified > Bun.file(source).lastModified)
|
if ((await Bun.file(binary).exists()) && Bun.file(binary).lastModified > Bun.file(source).lastModified) return binary
|
||||||
return binary
|
|
||||||
ui.meta("compiling echo-cancellation helper (first run only)...")
|
ui.meta("compiling echo-cancellation helper (first run only)...")
|
||||||
const { mkdir } = await import("node:fs/promises")
|
const { mkdir } = await import("node:fs/promises")
|
||||||
await mkdir(Bun.fileURLToPath(new URL("../.build", import.meta.url)), { recursive: true })
|
await mkdir(Bun.fileURLToPath(new URL("../.build", import.meta.url)), { recursive: true })
|
||||||
|
|
@ -519,6 +519,24 @@ const soxFormat = ["-q", "-t", "raw", "-r", "24000", "-e", "signed-integer", "-b
|
||||||
// PCM16 mono at 24kHz is 48 bytes per millisecond.
|
// PCM16 mono at 24kHz is 48 bytes per millisecond.
|
||||||
let playbackEndsAt = 0
|
let playbackEndsAt = 0
|
||||||
const assistantSpeaking = () => Date.now() < playbackEndsAt
|
const assistantSpeaking = () => Date.now() < playbackEndsAt
|
||||||
|
let playbackDoneTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const PLAYBACK_RELEASE_MS = 180
|
||||||
|
|
||||||
|
function pcmLevel(bytes: Buffer) {
|
||||||
|
const samples = Math.floor(bytes.length / 2)
|
||||||
|
if (samples === 0) return 0
|
||||||
|
const stride = Math.max(1, Math.floor(samples / 1200))
|
||||||
|
let energy = 0
|
||||||
|
let count = 0
|
||||||
|
for (let index = 0; index < samples; index += stride) {
|
||||||
|
const sample = bytes.readInt16LE(index * 2) / 32768
|
||||||
|
energy += sample * sample
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(energy / count)
|
||||||
|
if (rms < 0.008) return 0
|
||||||
|
return Math.min(1, Math.sqrt((rms - 0.008) / 0.18))
|
||||||
|
}
|
||||||
|
|
||||||
let micStarted = false
|
let micStarted = false
|
||||||
|
|
||||||
|
|
@ -535,8 +553,10 @@ async function startMicrophone() {
|
||||||
ui.setStatus({ audio: args.speakers ? "duplex+aec" : "duplex" })
|
ui.setStatus({ audio: args.speakers ? "duplex+aec" : "duplex" })
|
||||||
ui.meta("mic live — talk any time, even over the assistant")
|
ui.meta("mic live — talk any time, even over the assistant")
|
||||||
for await (const chunk of audio.stdout as ReadableStream<Uint8Array>) {
|
for await (const chunk of audio.stdout as ReadableStream<Uint8Array>) {
|
||||||
if (ws?.readyState === WebSocket.OPEN)
|
if (ws?.readyState !== WebSocket.OPEN) continue
|
||||||
send({ type: "input_audio_buffer.append", audio: Buffer.from(chunk).toString("base64") })
|
const bytes = Buffer.from(chunk)
|
||||||
|
ui.userAudioLevel(pcmLevel(bytes))
|
||||||
|
send({ type: "input_audio_buffer.append", audio: bytes.toString("base64") })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -549,7 +569,9 @@ async function startMicrophone() {
|
||||||
// Half-duplex: drop mic audio while the assistant is audible (plus a
|
// Half-duplex: drop mic audio while the assistant is audible (plus a
|
||||||
// short tail) so speaker echo can't barge-in against itself.
|
// short tail) so speaker echo can't barge-in against itself.
|
||||||
if (!args.duplex && Date.now() < playbackEndsAt + 300) continue
|
if (!args.duplex && Date.now() < playbackEndsAt + 300) continue
|
||||||
send({ type: "input_audio_buffer.append", audio: Buffer.from(chunk).toString("base64") })
|
const bytes = Buffer.from(chunk)
|
||||||
|
ui.userAudioLevel(pcmLevel(bytes))
|
||||||
|
send({ type: "input_audio_buffer.append", audio: bytes.toString("base64") })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,23 +585,40 @@ async function forwardHelperLogs(stream: ReadableStream<Uint8Array>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAudio(base64: string) {
|
function playAudio(base64: string) {
|
||||||
|
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
|
||||||
|
playbackDoneTimer = undefined
|
||||||
if (!audio) {
|
if (!audio) {
|
||||||
player ??= Bun.spawn(["play", ...soxFormat, "-"], { stdin: "pipe", stderr: "ignore" })
|
player ??= Bun.spawn(["play", ...soxFormat, "-"], { stdin: "pipe", stderr: "ignore" })
|
||||||
if (args.debug) void player.exited.then((code) => ui.meta(`[debug] play exited (${code})`))
|
if (args.debug) void player.exited.then((code) => ui.meta(`[debug] play exited (${code})`))
|
||||||
}
|
}
|
||||||
const stdin = (audio ?? player)!.stdin as import("bun").FileSink
|
const stdin = (audio ?? player)!.stdin as import("bun").FileSink
|
||||||
const bytes = Buffer.from(base64, "base64")
|
const bytes = Buffer.from(base64, "base64")
|
||||||
|
ui.assistantAudio(pcmLevel(bytes), bytes.length / 48)
|
||||||
stdin.write(bytes)
|
stdin.write(bytes)
|
||||||
stdin.flush()
|
stdin.flush()
|
||||||
playbackEndsAt = Math.max(playbackEndsAt, Date.now()) + bytes.length / 48
|
playbackEndsAt = Math.max(playbackEndsAt, Date.now()) + bytes.length / 48
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishPlayback() {
|
||||||
|
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
|
||||||
|
playbackDoneTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
playbackDoneTimer = undefined
|
||||||
|
ui.assistantDone()
|
||||||
|
},
|
||||||
|
Math.max(0, playbackEndsAt - Date.now()) + PLAYBACK_RELEASE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function flushPlayback() {
|
function flushPlayback() {
|
||||||
// The AEC helper flushes its queued speaker audio on SIGUSR1 and keeps running.
|
// The AEC helper flushes its queued speaker audio on SIGUSR1 and keeps running.
|
||||||
if (audio) process.kill(audio.pid, "SIGUSR1")
|
if (audio) process.kill(audio.pid, "SIGUSR1")
|
||||||
player?.kill()
|
player?.kill()
|
||||||
player = undefined
|
player = undefined
|
||||||
playbackEndsAt = 0
|
playbackEndsAt = 0
|
||||||
|
if (playbackDoneTimer) clearTimeout(playbackDoneTimer)
|
||||||
|
playbackDoneTimer = undefined
|
||||||
|
ui.assistantDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
const createResponse = () =>
|
const createResponse = () =>
|
||||||
|
|
@ -687,17 +726,22 @@ function onMessage(event: MessageEvent) {
|
||||||
ui.assistantDelta(data.delta ?? "")
|
ui.assistantDelta(data.delta ?? "")
|
||||||
break
|
break
|
||||||
case "response.done": {
|
case "response.done": {
|
||||||
ui.assistantDone()
|
if (args.text) ui.assistantDone()
|
||||||
|
else finishPlayback()
|
||||||
const calledFunction = data.response?.output?.some((item) => item.type === "function_call") ?? false
|
const calledFunction = data.response?.output?.some((item) => item.type === "function_call") ?? false
|
||||||
if (args.text && !calledFunction && inflightTools === 0) shutdown()
|
if (args.text && !calledFunction && inflightTools === 0) shutdown()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "input_audio_buffer.speech_started":
|
case "input_audio_buffer.speech_started":
|
||||||
|
ui.userSpeaking(true)
|
||||||
// Voice barge-in needs full duplex (AEC helper or headphones). In
|
// Voice barge-in needs full duplex (AEC helper or headphones). In
|
||||||
// half-duplex, speech that slips past the mic gate must not kill
|
// half-duplex, speech that slips past the mic gate must not kill
|
||||||
// playback; keypress is the interrupt.
|
// playback; keypress is the interrupt.
|
||||||
if (fullDuplex) flushPlayback()
|
if (fullDuplex) flushPlayback()
|
||||||
break
|
break
|
||||||
|
case "input_audio_buffer.speech_stopped":
|
||||||
|
ui.userSpeaking(false)
|
||||||
|
break
|
||||||
case "input_audio_buffer.committed":
|
case "input_audio_buffer.committed":
|
||||||
// Reserve the user's slot in the conversation now; the transcript
|
// Reserve the user's slot in the conversation now; the transcript
|
||||||
// arrives later and must not print after the assistant's reply.
|
// arrives later and must not print after the assistant's reply.
|
||||||
|
|
@ -712,9 +756,6 @@ function onMessage(event: MessageEvent) {
|
||||||
case "response.output_audio_transcript.delta":
|
case "response.output_audio_transcript.delta":
|
||||||
ui.assistantDelta(data.delta ?? "")
|
ui.assistantDelta(data.delta ?? "")
|
||||||
break
|
break
|
||||||
case "response.output_audio_transcript.done":
|
|
||||||
ui.assistantDone()
|
|
||||||
break
|
|
||||||
case "response.output_item.done":
|
case "response.output_item.done":
|
||||||
if (data.item?.type === "function_call") void handleFunctionCall(data.item)
|
if (data.item?.type === "function_call") void handleFunctionCall(data.item)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
// its transcript is filled in when Whisper finishes.
|
// its transcript is filled in when Whisper finishes.
|
||||||
import { createCliRenderer, RGBA } from "@opentui/core"
|
import { createCliRenderer, RGBA } from "@opentui/core"
|
||||||
import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { registerSpinner } from "opentui-spinner/solid"
|
||||||
import { createSignal, For } from "solid-js"
|
import { createSignal, For } from "solid-js"
|
||||||
|
|
||||||
|
registerSpinner()
|
||||||
|
|
||||||
export type VoiceStatus = {
|
export type VoiceStatus = {
|
||||||
server?: string
|
server?: string
|
||||||
session?: string
|
session?: string
|
||||||
|
|
@ -18,8 +21,11 @@ export type VoiceStatus = {
|
||||||
|
|
||||||
export type VoiceUI = {
|
export type VoiceUI = {
|
||||||
meta(text: string): void
|
meta(text: string): void
|
||||||
|
userSpeaking(active: boolean): void
|
||||||
|
userAudioLevel(level?: number): void
|
||||||
userCommitted(itemID: string): void
|
userCommitted(itemID: string): void
|
||||||
userTranscript(itemID: string, text: string): void
|
userTranscript(itemID: string, text: string): void
|
||||||
|
assistantAudio(level: number, durationMs: number): void
|
||||||
assistantDelta(text: string): void
|
assistantDelta(text: string): void
|
||||||
assistantDone(): void
|
assistantDone(): void
|
||||||
toolStart(callID: string, name: string, input: unknown): void
|
toolStart(callID: string, name: string, input: unknown): void
|
||||||
|
|
@ -30,7 +36,7 @@ export type VoiceUI = {
|
||||||
|
|
||||||
type Message =
|
type Message =
|
||||||
| { kind: "user"; itemID: string; text?: string }
|
| { kind: "user"; itemID: string; text?: string }
|
||||||
| { kind: "assistant"; text: string; streaming: boolean; reveals: Array<{ offset: number; at: number }> }
|
| { kind: "assistant"; text: string; streaming: boolean }
|
||||||
| { kind: "tool"; callID: string; name: string; input: unknown; output?: unknown }
|
| { kind: "tool"; callID: string; name: string; input: unknown; output?: unknown }
|
||||||
| { kind: "meta"; text: string }
|
| { kind: "meta"; text: string }
|
||||||
|
|
||||||
|
|
@ -85,8 +91,11 @@ export function createConsoleUI(): VoiceUI {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: (text) => line(dim(` ${text}`)),
|
meta: (text) => line(dim(` ${text}`)),
|
||||||
|
userSpeaking: () => {},
|
||||||
|
userAudioLevel: () => {},
|
||||||
userCommitted: () => {},
|
userCommitted: () => {},
|
||||||
userTranscript: (_, text) => line(cyan("● you ") + text),
|
userTranscript: (_, text) => line(cyan("● you ") + text),
|
||||||
|
assistantAudio: () => {},
|
||||||
assistantDelta: (text) => {
|
assistantDelta: (text) => {
|
||||||
if (!streaming) {
|
if (!streaming) {
|
||||||
process.stdout.write(green("● assistant "))
|
process.stdout.write(green("● assistant "))
|
||||||
|
|
@ -127,28 +136,27 @@ const theme = {
|
||||||
literal: "#bb9af7",
|
literal: "#bb9af7",
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantText = RGBA.fromHex(theme.text)
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
const REVEAL_DURATION_MS = 320
|
const colors = {
|
||||||
const REVEAL_STAGGER_MS = 32
|
text: RGBA.fromHex(theme.text),
|
||||||
const REVEAL_MAX_QUEUE_MS = 160
|
you: RGBA.fromHex(theme.you),
|
||||||
const REVEAL_WORD_LIMIT = 24
|
assistant: RGBA.fromHex(theme.assistant),
|
||||||
|
|
||||||
function springOpacity(now: number, start: number) {
|
|
||||||
const progress = Math.max(0, Math.min(1, (now - start) / REVEAL_DURATION_MS))
|
|
||||||
if (progress === 1) return 1
|
|
||||||
const time = progress * 8
|
|
||||||
return 1 - (1 + time) * Math.exp(-time)
|
|
||||||
}
|
}
|
||||||
|
const AUDIO_FRAME_MS = 33
|
||||||
|
const AUDIO_LEVEL_STALE_MS = 250
|
||||||
|
const meterGlyphs = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||||
|
|
||||||
function fade(color: RGBA, opacity: number) {
|
function fade(color: RGBA, opacity: number) {
|
||||||
return RGBA.fromValues(color.r, color.g, color.b, color.a * opacity)
|
return RGBA.fromValues(color.r, color.g, color.b, color.a * opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealOffsets(previous: string, delta: string) {
|
function meter(level: number, phase: number) {
|
||||||
const text = previous + delta
|
return [0, 1, 2, 3]
|
||||||
return Array.from(delta.matchAll(/\S+/g))
|
.map((index) => {
|
||||||
.map((match) => previous.length + match.index)
|
const value = Math.max(0, Math.min(1, level * (0.72 + Math.sin(phase + index * 1.4) * 0.28)))
|
||||||
.filter((offset) => offset === 0 || /\s/.test(text[offset - 1] ?? ""))
|
return meterGlyphs[Math.round(value * (meterGlyphs.length - 1))]
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiny JSON tokenizer for syntax-highlighted tool results.
|
// Tiny JSON tokenizer for syntax-highlighted tool results.
|
||||||
|
|
@ -176,44 +184,67 @@ function jsonTokens(json: string) {
|
||||||
|
|
||||||
// `kind` never changes after creation, so branching once here is safe; the
|
// `kind` never changes after creation, so branching once here is safe; the
|
||||||
// property reads inside JSX stay reactive through the store proxy.
|
// property reads inside JSX stay reactive through the store proxy.
|
||||||
function MessageRow(props: { message: Message; details: boolean; now: () => number }) {
|
function MessageRow(props: { message: Message; details: boolean; assistantLevel: () => number; animate: boolean }) {
|
||||||
const message = props.message
|
const message = props.message
|
||||||
if (message.kind === "user")
|
if (message.kind === "user")
|
||||||
return (
|
return (
|
||||||
<text fg={theme.you} marginTop={1}>
|
<box
|
||||||
<b>● you</b> <span style={{ fg: theme.text }}>{message.text ?? "…"}</span>
|
flexDirection="column"
|
||||||
</text>
|
flexShrink={0}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={fade(colors.you, 0.38)}
|
||||||
|
backgroundColor={fade(colors.you, 0.035)}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
marginTop={1}
|
||||||
|
>
|
||||||
|
<text fg={fade(colors.you, 0.72)}>
|
||||||
|
<b>you</b>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: fade(colors.text, 0.74) }}>{message.text ?? "transcribing"}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
)
|
)
|
||||||
if (message.kind === "assistant")
|
if (message.kind === "assistant") {
|
||||||
|
const pulse = () => (!props.animate || !message.streaming ? 0.5 : props.assistantLevel())
|
||||||
return (
|
return (
|
||||||
<text fg={theme.assistant} marginTop={1}>
|
<box
|
||||||
<b>● assistant</b>{" "}
|
flexDirection="column"
|
||||||
{message.reveals.length === 0 ? (
|
flexShrink={0}
|
||||||
<span style={{ fg: theme.text }}>{message.text}</span>
|
border={["left"]}
|
||||||
) : (
|
borderColor={fade(colors.assistant, message.streaming ? 0.58 + pulse() * 0.42 : 0.3)}
|
||||||
<>
|
backgroundColor={fade(colors.assistant, message.streaming ? 0.055 + pulse() * 0.11 : 0.025)}
|
||||||
<span style={{ fg: theme.text }}>{message.text.slice(0, message.reveals[0]!.offset)}</span>
|
paddingLeft={1}
|
||||||
<For each={message.reveals}>
|
paddingRight={1}
|
||||||
{(reveal, index) => (
|
marginTop={1}
|
||||||
<span style={{ fg: fade(assistantText, springOpacity(props.now(), reveal.at)) }}>
|
>
|
||||||
{message.text.slice(reveal.offset, message.reveals[index() + 1]?.offset ?? message.text.length)}
|
<text fg={fade(colors.assistant, message.streaming ? 0.82 + pulse() * 0.18 : 0.58)}>
|
||||||
</span>
|
<b>assistant</b>
|
||||||
)}
|
{" "}
|
||||||
</For>
|
<span style={{ fg: fade(colors.text, message.streaming ? 1 : 0.7) }}>{message.text}</span>
|
||||||
</>
|
</text>
|
||||||
)}
|
</box>
|
||||||
</text>
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (message.kind === "tool")
|
if (message.kind === "tool")
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" paddingLeft={2}>
|
<box flexDirection="column" paddingLeft={2}>
|
||||||
<text fg={theme.muted}>
|
<box flexDirection="row" gap={1}>
|
||||||
<span style={{ fg: message.output === undefined ? theme.number : theme.assistant }}>
|
{message.output === undefined ? (
|
||||||
{message.output === undefined ? "◌" : "✓"}
|
props.animate ? (
|
||||||
</span>{" "}
|
<spinner frames={SPINNER_FRAMES} interval={80} color={theme.number} />
|
||||||
<span style={{ fg: theme.key }}>{message.name}</span>{" "}
|
) : (
|
||||||
{toolSummary(message.name, message.output)}
|
<text fg={theme.number}>⋯</text>
|
||||||
</text>
|
)
|
||||||
|
) : (
|
||||||
|
<text fg={theme.assistant}>✓</text>
|
||||||
|
)}
|
||||||
|
<text fg={theme.muted}>
|
||||||
|
<span style={{ fg: theme.key }}>{message.name}</span>
|
||||||
|
{" "}
|
||||||
|
{toolSummary(message.name, message.output)}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
{props.details ? (
|
{props.details ? (
|
||||||
<text fg={theme.muted} paddingLeft={2}>
|
<text fg={theme.muted} paddingLeft={2}>
|
||||||
<For each={jsonTokens(displayJson({ input: message.input, output: message.output }))}>
|
<For each={jsonTokens(displayJson({ input: message.input, output: message.output }))}>
|
||||||
|
|
@ -230,6 +261,32 @@ function MessageRow(props: { message: Message; details: boolean; now: () => numb
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserSpeakingBubble(props: { level: () => number | undefined; now: () => number; animate: boolean }) {
|
||||||
|
const pulse = () => (!props.animate ? 0.5 : (props.level() ?? 0.65))
|
||||||
|
const activity = () => {
|
||||||
|
if (!props.animate) return "voice"
|
||||||
|
return meter(Math.max(0.18, props.level() ?? 0.65), props.now() / (props.level() === undefined ? 220 : 320))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexShrink={0}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={fade(colors.you, 0.58 + pulse() * 0.42)}
|
||||||
|
backgroundColor={fade(colors.you, 0.055 + pulse() * 0.11)}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
marginTop={1}
|
||||||
|
>
|
||||||
|
<text fg={fade(colors.you, 0.82 + pulse() * 0.18)}>
|
||||||
|
<b>you</b>
|
||||||
|
{" "}
|
||||||
|
<span style={{ fg: fade(colors.text, 0.8) }}>{activity()}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function createVoiceTUI(options: {
|
export async function createVoiceTUI(options: {
|
||||||
onInterrupt(): void
|
onInterrupt(): void
|
||||||
onExit(): void
|
onExit(): void
|
||||||
|
|
@ -242,9 +299,14 @@ export async function createVoiceTUI(options: {
|
||||||
details: false,
|
details: false,
|
||||||
})
|
})
|
||||||
const [animationFrame, setAnimationFrame] = createSignal(performance.now())
|
const [animationFrame, setAnimationFrame] = createSignal(performance.now())
|
||||||
|
const [userActive, setUserActive] = createSignal(false)
|
||||||
let animationTimer: ReturnType<typeof setInterval> | undefined
|
let animationTimer: ReturnType<typeof setInterval> | undefined
|
||||||
let animationEndsAt = 0
|
let assistantSegments: Array<{ start: number; end: number; level: number }> = []
|
||||||
let revealAt = 0
|
let assistantScheduledUntil = 0
|
||||||
|
let assistantLevel = 0
|
||||||
|
let userTargetLevel = 0
|
||||||
|
let userLevel = 0
|
||||||
|
let userLevelAt = 0
|
||||||
|
|
||||||
const renderer = await createCliRenderer({
|
const renderer = await createCliRenderer({
|
||||||
useMouse: true,
|
useMouse: true,
|
||||||
|
|
@ -263,29 +325,33 @@ export async function createVoiceTUI(options: {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const animateThrough = (end: number) => {
|
const startAnimation = () => {
|
||||||
animationEndsAt = Math.max(animationEndsAt, end)
|
if (options.reducedMotion) return renderer.requestRender()
|
||||||
if (animationTimer) return
|
if (animationTimer) return
|
||||||
animationTimer = setInterval(() => {
|
animationTimer = setInterval(() => {
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
|
assistantSegments = assistantSegments.filter((segment) => segment.end > now)
|
||||||
|
const output = assistantSegments.find((segment) => segment.start <= now)
|
||||||
|
const assistantTarget = output?.level ?? 0
|
||||||
|
const userTarget = now - userLevelAt < AUDIO_LEVEL_STALE_MS ? userTargetLevel : 0
|
||||||
|
assistantLevel += (assistantTarget - assistantLevel) * (assistantTarget > assistantLevel ? 0.5 : 0.16)
|
||||||
|
userLevel += (userTarget - userLevel) * (userTarget > userLevel ? 0.5 : 0.18)
|
||||||
setAnimationFrame(now)
|
setAnimationFrame(now)
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
if (now < animationEndsAt) return
|
if (userActive() || assistantSegments.length > 0 || assistantLevel > 0.01 || userLevel > 0.01) return
|
||||||
clearInterval(animationTimer)
|
clearInterval(animationTimer)
|
||||||
animationTimer = undefined
|
animationTimer = undefined
|
||||||
}, 16)
|
}, AUDIO_FRAME_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleReveal = (previous: string, delta: string) => {
|
const currentAssistantLevel = () => {
|
||||||
if (options.reducedMotion) return []
|
animationFrame()
|
||||||
const now = performance.now()
|
return assistantLevel
|
||||||
const offsets = revealOffsets(previous, delta).slice(-REVEAL_WORD_LIMIT)
|
}
|
||||||
const start = Math.min(Math.max(revealAt, now), now + REVEAL_MAX_QUEUE_MS)
|
const currentUserLevel = () => {
|
||||||
const reveals = offsets.map((offset, word) => ({ offset, at: start + word * REVEAL_STAGGER_MS }))
|
const now = animationFrame()
|
||||||
if (reveals.length === 0) return reveals
|
if (now - userLevelAt >= AUDIO_LEVEL_STALE_MS) return undefined
|
||||||
revealAt = reveals.at(-1)!.at + REVEAL_STAGGER_MS
|
return userLevel
|
||||||
animateThrough(reveals.at(-1)!.at + REVEAL_DURATION_MS)
|
|
||||||
return reveals
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -323,8 +389,18 @@ export async function createVoiceTUI(options: {
|
||||||
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
|
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
|
||||||
<box flexDirection="column" flexShrink={0}>
|
<box flexDirection="column" flexShrink={0}>
|
||||||
<For each={state().messages}>
|
<For each={state().messages}>
|
||||||
{(message) => <MessageRow message={message} details={state().details} now={animationFrame} />}
|
{(message) => (
|
||||||
|
<MessageRow
|
||||||
|
message={message}
|
||||||
|
details={state().details}
|
||||||
|
assistantLevel={currentAssistantLevel}
|
||||||
|
animate={!options.reducedMotion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
{userActive() ? (
|
||||||
|
<UserSpeakingBubble level={currentUserLevel} now={animationFrame} animate={!options.reducedMotion} />
|
||||||
|
) : null}
|
||||||
</box>
|
</box>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box height={1} marginTop={1}>
|
<box height={1} marginTop={1}>
|
||||||
|
|
@ -344,7 +420,25 @@ export async function createVoiceTUI(options: {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: (text) => push({ kind: "meta", text }),
|
meta: (text) => push({ kind: "meta", text }),
|
||||||
userCommitted: (itemID) => push({ kind: "user", itemID }),
|
userSpeaking: (active) => {
|
||||||
|
setUserActive(active)
|
||||||
|
if (active) startAnimation()
|
||||||
|
if (!active) userTargetLevel = 0
|
||||||
|
redraw()
|
||||||
|
},
|
||||||
|
userAudioLevel: (level) => {
|
||||||
|
if (level === undefined) {
|
||||||
|
userLevelAt = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userTargetLevel = Math.max(0, Math.min(1, level))
|
||||||
|
userLevelAt = performance.now()
|
||||||
|
if (userActive()) startAnimation()
|
||||||
|
},
|
||||||
|
userCommitted: (itemID) => {
|
||||||
|
setUserActive(false)
|
||||||
|
push({ kind: "user", itemID })
|
||||||
|
},
|
||||||
userTranscript: (itemID, text) => {
|
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 })
|
if (index === -1) return push({ kind: "user", itemID, text })
|
||||||
|
|
@ -356,31 +450,45 @@ export async function createVoiceTUI(options: {
|
||||||
}))
|
}))
|
||||||
redraw()
|
redraw()
|
||||||
},
|
},
|
||||||
|
assistantAudio: (level, durationMs) => {
|
||||||
|
if (options.reducedMotion) return redraw()
|
||||||
|
const now = performance.now()
|
||||||
|
const start = Math.max(now, assistantScheduledUntil)
|
||||||
|
const end = start + durationMs
|
||||||
|
assistantSegments.push({ start, end, level: Math.max(0, Math.min(1, level)) })
|
||||||
|
assistantScheduledUntil = end
|
||||||
|
startAnimation()
|
||||||
|
},
|
||||||
assistantDelta: (text) => {
|
assistantDelta: (text) => {
|
||||||
const index = state().messages.length - 1
|
const index = state().messages.length - 1
|
||||||
const last = state().messages[index]
|
const last = state().messages[index]
|
||||||
if (last?.kind === "assistant" && last.streaming) {
|
if (last?.kind === "assistant" && last.streaming) {
|
||||||
const reveals = scheduleReveal(last.text, text)
|
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
messages: current.messages.map((message, i) =>
|
messages: current.messages.map((message, i) =>
|
||||||
i === index && message.kind === "assistant"
|
i === index && message.kind === "assistant" ? { ...message, text: message.text + text } : message,
|
||||||
? {
|
|
||||||
...message,
|
|
||||||
text: message.text + text,
|
|
||||||
reveals: [...message.reveals, ...reveals].slice(-REVEAL_WORD_LIMIT),
|
|
||||||
}
|
|
||||||
: message,
|
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
redraw()
|
redraw()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
push({ kind: "assistant", text, streaming: true, reveals: scheduleReveal("", text) })
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
messages: [
|
||||||
|
...current.messages.map((message) =>
|
||||||
|
message.kind === "assistant" && message.streaming ? { ...message, streaming: false } : message,
|
||||||
|
),
|
||||||
|
{ kind: "assistant", text, streaming: true },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
redraw()
|
||||||
},
|
},
|
||||||
assistantDone: () => {
|
assistantDone: () => {
|
||||||
const index = state().messages.length - 1
|
assistantSegments = []
|
||||||
if (state().messages[index]?.kind !== "assistant") return
|
assistantScheduledUntil = 0
|
||||||
|
assistantLevel = 0
|
||||||
|
const index = state().messages.findLastIndex((message) => message.kind === "assistant" && message.streaming)
|
||||||
|
if (index === -1) return
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
messages: current.messages.map((message, i) =>
|
messages: current.messages.map((message, i) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue