soundpacks

This commit is contained in:
Sebastian Herrlinger
2026-05-12 02:03:52 +02:00
parent 9fdc885e1f
commit 5d763d9ee7
16 changed files with 475 additions and 41 deletions
@@ -95,20 +95,39 @@ class FakeAudio {
engine = new FakeAudioEngine()
createCalls = 0
bytesCalls = 0
bytesPaths: string[] = []
rejectBytes = false
rejectPaths = new Set<string>()
create() {
this.createCalls += 1
return this.engine
}
async bytes() {
async bytes(path: string) {
this.bytesCalls += 1
if (this.rejectBytes) throw new Error("decode failed")
this.bytesPaths.push(path)
if (this.rejectBytes || this.rejectPaths.has(path)) throw new Error("decode failed")
return new Uint8Array([1, 2, 3])
}
}
class FakeKV {
store: Record<string, unknown> = {}
get ready() {
return true
}
get<Value = unknown>(key: string, fallback?: Value) {
return (this.store[key] ?? fallback) as Value
}
set(key: string, value: unknown) {
this.store[key] = value
}
}
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
return {
attention: {
@@ -116,6 +135,8 @@ function config(attention: Partial<AttentionConfig["attention"]> = {}): Attentio
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
...attention,
},
}
@@ -335,6 +356,84 @@ describe("createTuiAttention", () => {
expect(startedAudio.engine.playCalls).toBe(2)
})
test("plays named sounds from the active sound pack", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
const dispose = attention.soundboard.registerPack({
id: "acme.soft",
name: "Soft Alerts",
sounds: {
question: "/tmp/question.mp3",
},
})
expect(attention.soundboard.activate("acme.soft")).toBe(true)
expect(attention.soundboard.current()).toBe("acme.soft")
expect(attention.soundboard.list()).toContainEqual({
id: "acme.soft",
name: "Soft Alerts",
active: true,
builtin: false,
})
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.bytesPaths).toEqual(["/tmp/question.mp3"])
dispose()
expect(attention.soundboard.current()).toBe("opencode.default")
})
test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
audio.rejectPaths.add("/tmp/bad-question.mp3")
const attention = createTuiAttention({
renderer,
config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
audio,
})
renderer.emit("blur")
attention.soundboard.registerPack({
id: "acme.soft",
sounds: {
question: "/tmp/good-question.mp3",
},
})
attention.soundboard.activate("acme.soft")
expect(await attention.notify({ message: "question", sound: "question" })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.bytesPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
})
test("persists activated sound pack in KV", () => {
const kv = new FakeKV()
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), kv })
attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
expect(kv.store.attention_sound_pack).toBeUndefined()
expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
expect(kv.store.attention_sound_pack).toBe("acme.soft")
const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
expect(next.soundboard.current()).toBe("acme.soft")
})
test("does not throw for notification or sound failures", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
@@ -98,6 +98,8 @@ function config(input?: {
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
},
diff_style: input?.diff_style,
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
@@ -854,6 +854,75 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
}
})
test("auto-disposes plugin attention sound packs and resolves relative paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "attention-soundpack-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.attention.soundpack",
tui: async (api) => {
api.attention.soundboard.registerPack({
id: "demo.pack",
sounds: { question: "sounds/question.mp3" },
})
},
}
`,
)
return { spec }
},
})
const packs: Array<{ id: string; sounds: Record<string, string> }> = []
let dropped = 0
const attention = {
async notify() {
return { ok: false, notification: false, sound: false }
},
soundboard: {
registerPack(pack: { id: string; sounds: Record<string, string> }) {
packs.push(pack)
return () => {
dropped += 1
}
},
activate: () => false,
current: () => "opencode.default",
list: () => [],
},
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["attention"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({ attention }),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
expect(packs).toEqual([
{
id: "demo.pack",
sounds: { question: path.join(tmp.path, "sounds", "question.mp3") },
},
])
expect(dropped).toBe(0)
} finally {
await TuiPluginRuntime.dispose()
expect(dropped).toBe(1)
cwd.mockRestore()
wait.mockRestore()
}
})
test("auto-disposes plugin keymap transformers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {