Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<a href="https://www.npmjs.com/package/opencode-gpt-live"><img alt="npm" src="https://img.shields.io/npm/v/opencode-gpt-live?color=8b5cf6&label=npm"></a>
<a href="https://github.com/malhashemi/opencode-gpt-live/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/malhashemi/opencode-gpt-live/actions/workflows/ci.yml/badge.svg"></a>
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-22d3ee"></a>
<img alt="OpenCode 2.0.14 or newer" src="https://img.shields.io/badge/OpenCode-%E2%89%A5%202.0.14-4ade80">
<img alt="OpenCode 2.0.20 or newer" src="https://img.shields.io/badge/OpenCode-%E2%89%A5%202.0.20-4ade80">
<img alt="macOS, Linux, Windows" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Linux%20%C2%B7%20Windows-64748b">
</p>

Expand Down Expand Up @@ -51,7 +51,7 @@ throughout the call.

## Requirements

- [OpenCode](https://opencode.ai) 2.0.14 or newer.
- [OpenCode](https://opencode.ai) 2.0.20 or newer.
- A ChatGPT account signed in to OpenCode: run `/connect`, choose OpenAI, then "ChatGPT Pro/Plus". Voice availability
and usage limits depend on your ChatGPT plan.
- A microphone and speakers (or headphones).
Expand Down
252 changes: 41 additions & 211 deletions bun.lock

Large diffs are not rendered by default.

67 changes: 63 additions & 4 deletions native/src/duck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,29 @@ mod imp {
set: u64,
}

// PipeWire's ALSA bridge can omit application.process.id. In that case the
// helper's stable stream names are safer than treating it as another app.
fn is_call_stream(stream: &serde_json::Value, own: &str) -> bool {
let pid = stream
.pointer("/properties/application.process.id")
.and_then(|pid| pid.as_str());
if let Some(pid) = pid {
return pid == own;
}
[
("application.name", "PipeWire ALSA [gpt-live-host]"),
("node.name", "alsa_playback.gpt-live-host"),
]
.iter()
.any(|(key, expected)| {
stream
.get("properties")
.and_then(|p| p.get(key))
.and_then(|v| v.as_str())
== Some(*expected)
})
}

/// Other processes' playback streams as (index, volume in PulseAudio units).
fn streams() -> anyhow::Result<Vec<(u64, u64)>> {
let output = Command::new("pactl")
Expand All @@ -395,10 +418,7 @@ mod imp {
.into_iter()
.flatten()
.filter_map(|stream| {
let pid = stream
.pointer("/properties/application.process.id")
.and_then(|pid| pid.as_str());
if pid == Some(own.as_str()) {
if is_call_stream(stream, &own) {
return None;
}
let index = stream.get("index")?.as_u64()?;
Expand Down Expand Up @@ -453,6 +473,45 @@ mod imp {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::is_call_stream;
use serde_json::json;

#[test]
fn excludes_own_pid() {
assert!(is_call_stream(
&json!({"properties": {"application.process.id": "42"}}),
"42"
));
}

#[test]
fn excludes_pipewire_alsa_without_pid() {
for properties in [
json!({"application.name": "PipeWire ALSA [gpt-live-host]"}),
json!({"node.name": "alsa_playback.gpt-live-host"}),
] {
assert!(is_call_stream(&json!({"properties": properties}), "42"));
}
}

#[test]
fn keeps_other_apps_and_prefers_known_pid() {
assert!(!is_call_stream(
&json!({"properties": {"application.name": "Music"}}),
"42"
));
assert!(!is_call_stream(
&json!({"properties": {
"application.process.id": "99",
"application.name": "PipeWire ALSA [gpt-live-host]"
}}),
"42"
));
assert!(!is_call_stream(&json!({"properties": {}}), "42"));
}
}
}

#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@
"e2e": "bun scripts/e2e-call.ts"
},
"dependencies": {
"@opencode/plugin": "^2.0.14",
"@opencode/plugin": "^2.0.20",
"zod": "4.1.8"
},
"devDependencies": {
"@opencode/client": "2.0.14",
"@opencode/client": "2.0.20",
"@opencode/theme": "2.0.14",
"@opentui/core": "0.5.12",
"@types/bun": "latest",
Expand All @@ -68,6 +68,6 @@
"@opentui/core": ">=0.5.10"
},
"engines": {
"opencode": ">=2.0.14"
"opencode": ">=2.0.20"
}
}
51 changes: 41 additions & 10 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Plugin } from "@opencode/plugin/tui"

import { GptLive, type TaskStatus, type Voice } from "../shared/rpc"
import { HelperProcess, ensureHelper, type HelperEvent } from "./helper"
import { syncCallModel, type CallModel } from "./model"
import { arrivalsFor, type Phase } from "./visuals"

type Context = Plugin.Context
Expand Down Expand Up @@ -71,7 +72,9 @@ export class VoiceController {
private readonly listeners = new Set<() => void>()
private helper: HelperProcess | undefined
private readonly unsubscribe: Array<() => void> = []
private starting = false
private starting: number | undefined
private generation = 0
private disposed = false

constructor(
private readonly context: Context,
Expand Down Expand Up @@ -109,7 +112,8 @@ export class VoiceController {

private subscribe() {
const rpc = this.rpc()
const mine = (event: { data: { callID: string } }) => event.data.callID === this.state.callID
const mine = (event: { data: { callID: string } }) =>
!this.disposed && this.active && this.state.phase !== "closing" && event.data.callID === this.state.callID
this.unsubscribe.push(
rpc.events.on("state", (event) => {
if (!mine(event)) return
Expand Down Expand Up @@ -152,9 +156,11 @@ export class VoiceController {
)
}

async start(sessionID: string, voice?: Voice, fresh = false) {
if (this.active || this.starting) return
this.starting = true
async start(sessionID: string, model: CallModel | undefined, voice?: Voice, fresh = false) {
if (this.disposed || this.active || this.starting !== undefined) return
const generation = ++this.generation
this.starting = generation
const current = () => !this.disposed && this.generation === generation
const session = this.context.data.session.get(sessionID) as { location?: Location } | undefined
const location = session?.location ?? this.context.location ?? this.context.data.location.default()
Object.assign(this.state, initial(), {
Expand All @@ -166,11 +172,18 @@ export class VoiceController {
})
this.notice("Connecting to GPT-Live…", "info")
try {
const binary = await ensureHelper((message) => this.context.ui.toast.show({ message, variant: "info" }))
await syncCallModel(this.context, sessionID, model)
if (!current()) return
const binary = await ensureHelper((message) => {
if (current()) this.context.ui.toast.show({ message, variant: "info" })
})
if (!current()) return
const helper = new HelperProcess(binary, {
onEvent: (event) => this.onHelperEvent(event),
onEvent: (event) => {
if (current() && this.helper === helper) this.onHelperEvent(event)
},
onExit: (code, stderr) => {
if (this.helper !== helper) return
if (!current() || this.helper !== helper) return
this.helper = undefined
if (this.active)
void this.stop(code ? `Audio stopped unexpectedly: ${stderr.trim().split("\n").pop() ?? code}` : undefined)
Expand All @@ -186,10 +199,18 @@ export class VoiceController {
output: output ? (output === "none" ? "none" : { file: output }) : undefined,
duck: this.options.duck,
})
if (!current()) return
const call = await this.rpc().start(
{ sessionID, sdp: offer, voice: voice ?? this.options.voice, fresh },
{ location },
)
if (!current()) {
// stop() could not know the call ID while this request was pending.
await this.rpc()
.stop({ callID: call.callID }, { location })
.catch(() => undefined)
return
}
const past = call.previous.map((turn): Entry => ({
id: nextID(),
kind: turn.role,
Expand All @@ -211,15 +232,17 @@ export class VoiceController {
for (const notice of call.notices ?? []) this.notice(notice, "info")
this.heartbeat(call.callID)
await helper.answer(call.sdp)
if (!current()) return
if (this.state.phase === "connecting") this.markLive()
} catch (error) {
await this.teardown(describeError(error))
if (current()) await this.teardown(describeError(error))
} finally {
this.starting = false
if (this.starting === generation) this.starting = undefined
}
}

async stop(failure?: string) {
this.invalidateStart()
if (!this.active) return
this.set({ phase: "closing" })
const { callID, location } = this.state
Expand All @@ -232,8 +255,15 @@ export class VoiceController {

private tearingDown = false

private invalidateStart() {
const attempt = this.generation
this.generation++
if (this.starting === attempt) this.starting = undefined
}

private async teardown(failure?: string, reason?: string) {
if (this.state.phase === "idle" || this.state.phase === "error" || this.tearingDown) return
this.invalidateStart()
this.tearingDown = true
try {
await this.finishTeardown(failure, reason)
Expand Down Expand Up @@ -382,6 +412,7 @@ export class VoiceController {
}

async dispose() {
this.disposed = true
for (const stop of this.unsubscribe.splice(0)) stop()
await this.stop()
this.listeners.clear()
Expand Down
51 changes: 41 additions & 10 deletions src/tui/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Plugin as PluginNamespace } from "@opencode/plugin/tui"

import { VOICES, type Voice } from "../shared/rpc"
import { VoiceController } from "./controller"
import { VoiceController, describeError } from "./controller"
import { callModel, type CallModel } from "./model"
import { Frames, footerBadge, transcriptPanel, useCore, voiceAura, voiceStrip, type Core, type View } from "./ui"

const PANEL = "gptlive.transcript"
Expand Down Expand Up @@ -66,28 +67,37 @@ export function createTuiPlugin(module: Core): PluginNamespace.Definition {
})
const frames = new Frames(voice)
const autoPanel = options.panel !== false
let preparing: number | undefined
let generation = 0
let disposed = false

const currentSession = () => {
const route = context.ui.router.current()
return route.type === "session" ? route.sessionID : undefined
}

// With no session open (e.g. on the home screen), a call starts in a new session.
const openNewSession = async () => {
const openNewSession = async (model: CallModel | undefined, attempt: number) => {
try {
const location = context.location ?? context.data.location.default()
const created = await context.client.session.create({ location })
const created = await context.client.session.create({
location,
...(model ? { model } : {}),
})
if (attempt !== generation) return undefined
const previous = context.renderer.currentFocusedEditor
context.ui.router.navigate({ type: "session", sessionID: created.id })
// Wait for the new session's prompt to mount and take focus, so opening the panel
// hands focus back to it rather than to the home screen's (now gone) prompt.
const ready = () => {
if (attempt !== generation) return true
const editor = context.renderer.currentFocusedEditor
return currentSession() === created.id && editor && editor !== previous && !editor.isDestroyed
}
await waitFor(ready, 2_000)
return created.id
} catch (error) {
if (attempt !== generation) return undefined
context.ui.toast.show({
title: "GPT-Live",
message: `Could not start a new session: ${error instanceof Error ? error.message : String(error)}`,
Expand All @@ -98,10 +108,28 @@ export function createTuiPlugin(module: Core): PluginNamespace.Definition {
}

const start = async (chosen?: Voice, fresh = false) => {
const sessionID = currentSession() ?? (await openNewSession())
if (!sessionID) return
if (autoPanel) openPanelKeepingFocus()
await voice.start(sessionID, chosen, fresh)
if (disposed || preparing !== undefined || voice.active) return
const attempt = ++generation
preparing = attempt
try {
const model = callModel(context)
const sessionID = currentSession() ?? (await openNewSession(model, attempt))
if (attempt !== generation || !sessionID || currentSession() !== sessionID) return
if (autoPanel) openPanelKeepingFocus()
await voice.start(sessionID, model, chosen, fresh)
} catch (error) {
if (attempt === generation)
context.ui.toast.show({ title: "GPT-Live", message: describeError(error), variant: "error" })
} finally {
if (preparing === attempt) preparing = undefined
}
}

const stop = () => {
const attempt = preparing
generation++
if (preparing === attempt) preparing = undefined
return voice.stop()
}

// Close the transcript panel when a call ends normally; keep it open after a failure
Expand All @@ -127,10 +155,10 @@ export function createTuiPlugin(module: Core): PluginNamespace.Definition {
return opened
}

const toggle = () => (voice.active ? voice.stop() : start())
const toggle = () => (voice.active || preparing !== undefined ? stop() : start())

const restartWith = async (name: Voice, fresh = false) => {
if (voice.active) await voice.stop()
if (voice.active || preparing !== undefined) await stop()
await start(name, fresh)
}

Expand Down Expand Up @@ -178,7 +206,7 @@ export function createTuiPlugin(module: Core): PluginNamespace.Definition {
bind: key("stop"),
palette: true,
slash: { name: "voice-stop", aliases: ["hangup"] },
run: () => voice.stop(),
run: () => stop(),
},
{
id: "gptlive.new",
Expand Down Expand Up @@ -275,6 +303,9 @@ export function createTuiPlugin(module: Core): PluginNamespace.Definition {
}

return async () => {
disposed = true
generation++
preparing = undefined
if (autostart) clearInterval(autostart)
stopWatchingCall()
for (const dispose of disposers) dispose()
Expand Down
24 changes: 24 additions & 0 deletions src/tui/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Plugin } from "@opencode/plugin/tui"

type Context = Plugin.Context
export type CallModel = Parameters<Context["client"]["session"]["switchModel"]>[0]["model"]

/** Copy the selection before navigating or waiting for a session to be created. */
export function callModel(context: Pick<Context, "ui">): CallModel | undefined {
const selected = context.ui.model.current()
if (!selected) return undefined
return {
providerID: selected.providerID,
id: selected.modelID,
...(selected.variant ? { variant: selected.variant } : {}),
}
}

/** Persist the captured selection before voice can enqueue a prompt without a model. */
export async function syncCallModel(context: Pick<Context, "client">, sessionID: string, model: CallModel | undefined) {
if (!model) return
await context.client.session.switchModel({
sessionID,
model,
})
}
Loading
Loading