Skip to content

Commit a35ad5f

Browse files
committed
singleton audio
1 parent 0d1ba6a commit a35ad5f

5 files changed

Lines changed: 105 additions & 137 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
22
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
33
import * as Clipboard from "@tui/util/clipboard"
44
import * as Selection from "@tui/util/selection"
5+
import * as TuiAudio from "@tui/util/audio"
56
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
67
import { RouteProvider, useRoute } from "@tui/context/route"
78
import {
@@ -180,6 +181,7 @@ export function tui(input: {
180181
const onBeforeExit = async () => {
181182
offKeymap()
182183
await TuiPluginRuntime.dispose()
184+
TuiAudio.dispose()
183185
}
184186

185187
const renderer = await createCliRenderer(rendererConfig(input.config))

packages/opencode/src/cli/cmd/tui/attention.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Audio, type AudioErrorContext, type AudioSound } from "@opentui/core"
1+
import type { AudioSound } from "@opentui/core"
22
import type {
33
TuiAttention,
44
TuiAttentionNotifyInput,
@@ -12,6 +12,7 @@ import type {
1212
} from "@opencode-ai/plugin/tui"
1313
import stripAnsi from "strip-ansi"
1414
import type { TuiConfig } from "./config/tui"
15+
import * as TuiAudio from "@tui/util/audio"
1516
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
1617
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
1718
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
@@ -28,19 +29,6 @@ type AttentionRenderer = {
2829
triggerNotification(message: string, title?: string): boolean
2930
}
3031

31-
type AttentionAudioEngine = {
32-
on(event: "error", listener: (error: Error, context: AudioErrorContext) => void): unknown
33-
isStarted(): boolean
34-
start(): boolean
35-
loadSoundFile(file: string): Promise<AudioSound | null>
36-
play(sound: AudioSound, options?: { volume?: number }): unknown | null
37-
dispose(): void
38-
}
39-
40-
type AttentionAudio = {
41-
create(): AttentionAudioEngine
42-
}
43-
4432
type RegisteredSoundPack = TuiAttentionSoundPack & {
4533
builtin: boolean
4634
}
@@ -159,25 +147,14 @@ export function createTuiAttention(input: {
159147
renderer: AttentionRenderer
160148
config: Pick<TuiConfig.Resolved, "attention">
161149
kv?: TuiKV
162-
audio?: AttentionAudio
150+
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
163151
}): TuiAttentionHost {
164152
let focus: FocusState = "unknown"
165153
let disposed = false
166-
let audio: AttentionAudioEngine | undefined
167154
let activePackID: string | undefined
168155
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
169156
const sounds = new Map<string, Promise<AudioSound | null>>()
170-
171-
const audioInput: AttentionAudio =
172-
input.audio ?? {
173-
create: () => {
174-
const engine = Audio.create({ autoStart: false })
175-
engine.on("error", (error, context) => {
176-
log.debug("attention audio error", { error, context })
177-
})
178-
return engine
179-
},
180-
}
157+
const audio = input.audio ?? TuiAudio
181158

182159
const onFocus = () => {
183160
focus = "focused"
@@ -205,7 +182,6 @@ export function createTuiAttention(input: {
205182
}
206183

207184
async function loadSound(file: string) {
208-
if (!audio) return null
209185
const cached = sounds.get(file)
210186
if (cached) return cached
211187
const task = audio.loadSoundFile(file).catch((error) => {
@@ -218,10 +194,9 @@ export function createTuiAttention(input: {
218194

219195
async function playSound(name: TuiAttentionSoundName, volume: number) {
220196
try {
221-
audio ??= audioInput.create()
222-
if (!audio.isStarted() && !audio.start()) return false
223197
for (const file of soundCandidates(name)) {
224198
const current = await loadSound(file)
199+
if (disposed) return false
225200
if (current == null) continue
226201
if (audio.play(current, { volume }) != null) return true
227202
}
@@ -316,8 +291,6 @@ export function createTuiAttention(input: {
316291
disposed = true
317292
input.renderer.off("focus", onFocus)
318293
input.renderer.off("blur", onBlur)
319-
audio?.dispose()
320-
audio = undefined
321294
sounds.clear()
322295
},
323296
}

packages/opencode/src/cli/cmd/tui/component/logo.tsx

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import {
2-
Audio,
32
BoxRenderable,
43
MouseButton,
54
MouseEvent,
65
RGBA,
76
TextAttributes,
8-
type AudioSound,
97
type AudioVoice,
108
} from "@opentui/core"
119
import { useRenderer } from "@opentui/solid"
1210
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
1311
import { useTheme, tint } from "@tui/context/theme"
12+
import * as TuiAudio from "@tui/util/audio"
1413
import { go, logo } from "@/cli/logo"
1514
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
1615
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
@@ -102,41 +101,18 @@ const GLOW_OUT = 1600
102101
const PEAK = RGBA.fromInts(255, 255, 255)
103102
const PULSE_SOUNDS = [pulseA, pulseB, pulseC]
104103

105-
let logoAudio: Audio | undefined
106-
let logoAudioSounds: Promise<{ charge: AudioSound | null; pulse: (AudioSound | null)[] }> | undefined
107104
let logoAudioVoice: AudioVoice | undefined
108105
let logoAudioTail: ReturnType<typeof setTimeout> | undefined
109106
let logoAudioSeq = 0
110107
let logoAudioShot = 0
111108

112-
function createLogoAudio() {
113-
if (logoAudio) return logoAudio
114-
try {
115-
logoAudio = Audio.create({ autoStart: false })
116-
logoAudio.on("error", () => undefined)
117-
return logoAudio
118-
} catch {
119-
return
120-
}
121-
}
122-
123-
function loadLogoSounds(audio: Audio) {
124-
logoAudioSounds ??= Promise.all([
125-
audio.loadSoundFile(charge).catch(() => null),
126-
Promise.all(PULSE_SOUNDS.map((file) => audio.loadSoundFile(file).catch(() => null))),
127-
]).then(([charge, pulse]) => ({ charge, pulse }))
128-
return logoAudioSounds
129-
}
130-
131109
function startLogoSound() {
132110
stopLogoSound()
133111
const id = ++logoAudioSeq
134-
const audio = createLogoAudio()
135-
if (!audio || (!audio.isStarted() && !audio.start())) return
136-
void loadLogoSounds(audio)
137-
.then((sounds) => {
138-
if (id !== logoAudioSeq || sounds.charge == null) return
139-
const voice = audio.play(sounds.charge, { volume: 0.24, loop: true })
112+
void TuiAudio.loadSoundFile(charge)
113+
.then((sound) => {
114+
if (id !== logoAudioSeq || sound == null) return
115+
const voice = TuiAudio.play(sound, { volume: 0.24, loop: true })
140116
if (voice == null) return
141117
logoAudioVoice = voice
142118
})
@@ -156,37 +132,32 @@ function stopLogoSound(delay = 0) {
156132
const voice = logoAudioVoice
157133
if (delay <= 0) {
158134
logoAudioVoice = undefined
159-
logoAudio?.stopVoice(voice)
135+
TuiAudio.stopVoice(voice)
160136
return
161137
}
162138
logoAudioTail = setTimeout(() => {
163139
logoAudioTail = undefined
164140
if (logoAudioVoice !== voice) return
165141
logoAudioVoice = undefined
166-
logoAudio?.stopVoice(voice)
142+
TuiAudio.stopVoice(voice)
167143
}, delay)
168144
}
169145

170146
function pulseLogoSound(scale = 1) {
171147
stopLogoSound(140)
172148
const id = logoAudioSeq
173-
const audio = createLogoAudio()
174-
if (!audio || (!audio.isStarted() && !audio.start())) return
175-
void loadLogoSounds(audio)
176-
.then((sounds) => {
149+
const file = PULSE_SOUNDS[logoAudioShot++ % PULSE_SOUNDS.length]
150+
void TuiAudio.loadSoundFile(file)
151+
.then((sound) => {
177152
if (id !== logoAudioSeq) return
178-
const sound = sounds.pulse[logoAudioShot++ % PULSE_SOUNDS.length]
179153
if (sound == null) return
180-
audio.play(sound, { volume: 0.26 + 0.14 * scale })
154+
TuiAudio.play(sound, { volume: 0.26 + 0.14 * scale })
181155
})
182156
.catch(() => undefined)
183157
}
184158

185159
function disposeLogoSound() {
186160
stopLogoSound()
187-
logoAudio?.dispose()
188-
logoAudio = undefined
189-
logoAudioSounds = undefined
190161
}
191162

192163
type Ring = {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
2+
import * as Log from "@opencode-ai/core/util/log"
3+
4+
const log = Log.create({ service: "tui.audio" })
5+
6+
let audio: Audio | null | undefined
7+
const sounds = new Map<string, Promise<AudioSound | null>>()
8+
9+
function getAudio() {
10+
if (audio !== undefined) return audio
11+
try {
12+
const next = Audio.create({ autoStart: false })
13+
next.on("error", (error: Error, context: AudioErrorContext) => {
14+
log.debug("tui audio error", { error, context })
15+
})
16+
audio = next
17+
return next
18+
} catch (error) {
19+
log.debug("failed to create tui audio", { error })
20+
audio = null
21+
return null
22+
}
23+
}
24+
25+
export function loadSoundFile(file: string) {
26+
const current = getAudio()
27+
if (!current) return Promise.resolve(null)
28+
const cached = sounds.get(file)
29+
if (cached) return cached
30+
const task = current.loadSoundFile(file).catch((error) => {
31+
log.debug("failed to load tui sound", { file, error })
32+
return null
33+
})
34+
sounds.set(file, task)
35+
return task
36+
}
37+
38+
export function play(sound: AudioSound, options?: AudioPlayOptions) {
39+
const current = getAudio()
40+
if (!current) return null
41+
if (!current.isStarted() && !current.start()) return null
42+
return current.play(sound, options)
43+
}
44+
45+
export function stopVoice(voice: AudioVoice) {
46+
return audio?.stopVoice(voice) ?? false
47+
}
48+
49+
export function dispose() {
50+
audio?.dispose()
51+
audio = undefined
52+
sounds.clear()
53+
}
54+
55+
export * as TuiAudio from "./audio"

0 commit comments

Comments
 (0)