diff --git a/src/stores/chatStore.svelte.ts b/src/stores/chatStore.svelte.ts index 66486a56..55281728 100644 --- a/src/stores/chatStore.svelte.ts +++ b/src/stores/chatStore.svelte.ts @@ -817,6 +817,8 @@ export class ChatSession { () => ({ sound: getData().runSoundEnabled, notification: getData().runNotificationEnabled, + successSoundPath: getData().runSuccessSoundPath, + failureSoundPath: getData().runFailureSoundPath, }), cueName, ); diff --git a/src/stores/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index c9ba1837..533343c9 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -238,6 +238,8 @@ export const DEFAULT_SETTINGS: PluginData = { sessionSidebarWidth: 240, runSoundEnabled: true, runNotificationEnabled: true, + runSuccessSoundPath: "", + runFailureSoundPath: "", sessionFlags: {}, onboardingComplete: false, onboardingSplashSeen: false, @@ -1621,6 +1623,22 @@ export class PluginDataStore { void this.saveSettings(); } + get runSuccessSoundPath(): string { + return this.#data.runSuccessSoundPath ?? ""; + } + set runSuccessSoundPath(val: string) { + this.#data.runSuccessSoundPath = val; + void this.saveSettings(); + } + + get runFailureSoundPath(): string { + return this.#data.runFailureSoundPath ?? ""; + } + set runFailureSoundPath(val: string) { + this.#data.runFailureSoundPath = val; + void this.saveSettings(); + } + // --- Session Flags (pin/archive) --- getSessionFlags(path: string): SessionFlags { diff --git a/src/stores/runCue.ts b/src/stores/runCue.ts index ea12e861..d3117c74 100644 --- a/src/stores/runCue.ts +++ b/src/stores/runCue.ts @@ -6,17 +6,27 @@ import { AssistantState } from "./chatTimeline"; export type RunOutcome = "success" | "error" | "cancelled"; export interface RunCueSettings { - /** Play a short tone when a run settles. */ + /** Play a sound when a run settles. */ sound: boolean; /** Raise a native notification when a run settles. */ notification: boolean; + /** Absolute path to a sound file to play on success. Blank uses the + * built-in tone. Desktop-only: ignored where there is no filesystem. */ + successSoundPath?: string; + /** Absolute path to a sound file to play on failure. Blank uses the + * built-in tone. Desktop-only: ignored where there is no filesystem. */ + failureSoundPath?: string; } +/** What a settled run should play. `default` is the built-in synthesised cue; + * `file` is a user-supplied audio file read at play time. */ +export type CueSound = { kind: "default"; frequency: number } | { kind: "file"; path: string }; + export interface RunCue { /** false for outcomes that must stay silent. */ play: boolean; - /** Tone frequency in Hz, or null for no sound. */ - frequency: number | null; + /** The sound to play, or null for silence. */ + sound: CueSound | null; /** Notification body, or null for no notification. */ body: string | null; /** Display title for the notification — the session name, so a cue can say @@ -24,12 +34,18 @@ export interface RunCue { name: string | null; } -/** How long the tone is held. A cue, not a jingle. */ +/** How long the built-in tone is held. A cue, not a jingle. */ export const CUE_DURATION_MS = 140; /** Distinct by ear: a failure must not sound like a completion. */ export const CUE_DONE_HZ = 880; export const CUE_FAIL_HZ = 320; +/** A path that is only whitespace is not a setting. */ +function trimmedOrNull(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + /** * Decide what a settled run should emit. * @@ -45,12 +61,20 @@ export function cueForOutcome( // The session name rides alongside the body rather than inside it: several // chats can be in flight, so "Chat run finished" on its own does not tell // the user which one it was. - const name = sessionName?.trim() ? sessionName.trim() : null; - if (outcome === "cancelled") return { play: false, frequency: null, body: null, name: null }; + const name = trimmedOrNull(sessionName); + if (outcome === "cancelled") return { play: false, sound: null, body: null, name: null }; const failed = outcome === "error"; + // The user's own file wins over the built-in tone, but only when sound is + // on at all — the toggle stays the master switch. + const customPath = trimmedOrNull(failed ? settings.failureSoundPath : settings.successSoundPath); + const sound: CueSound | null = settings.sound + ? customPath + ? { kind: "file", path: customPath } + : { kind: "default", frequency: failed ? CUE_FAIL_HZ : CUE_DONE_HZ } + : null; return { play: true, - frequency: settings.sound ? (failed ? CUE_FAIL_HZ : CUE_DONE_HZ) : null, + sound, body: settings.notification ? (failed ? "Chat run failed" : "Chat run finished") : null, name, }; @@ -75,10 +99,17 @@ export function outcomeForAssistantState(state: AssistantState | undefined): Run let audioCtx: AudioContext | null = null; +/** Decoded audio, keyed by file path, so a settle does not re-read and + * re-decode the same file from disk on every run. */ +const decodedSounds = new Map(); +/** Decodes already in flight, keyed the same way, so two settles in quick + * succession do not both hit the disk for the same file. */ +const pendingDecodes = new Map>(); + /** * Called from a user gesture (send / submit). Chromium gates audio until a * gesture has happened, so this is the only reliable moment to open a context - * that can actually be heard later. Best-effort: failure just means no tone. + * that can actually be heard later. Best-effort: failure just means no sound. */ export function primeAudio(): void { try { @@ -102,7 +133,84 @@ function playTone(frequency: number): void { osc.start(now); osc.stop(now + CUE_DURATION_MS / 1000); } catch { - // A blocked or failed cue must never break the run\u2019s bookkeeping. + // A blocked or failed cue must never break the run's bookkeeping. + } +} + +/** + * Read a file into an ArrayBuffer using Electron's real require. + * + * This is exactly the path a plugin bundle's Node `require` does NOT provide: + * Obsidian injects a stub that returns null for builtins, while the renderer's + * own `require` (window.require / globalThis.require) is Electron's real one. + * Returns null wherever there is no filesystem, i.e. mobile. + */ +function readFileBytes(path: string): ArrayBuffer | null { + try { + const scope = globalThis as { require?: (id: string) => unknown }; + const req = + scope.require ?? (window as unknown as { require?: (id: string) => unknown }).require; + if (typeof req !== "function") return null; + const fs = req("fs") as { readFileSync?: (p: string) => Uint8Array }; + if (typeof fs?.readFileSync !== "function") return null; + const bytes = fs.readFileSync(path); + // Slice out exactly this view: Buffer may be a window onto a shared pool. + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } catch { + // Missing file, permissions, unreadable format — all mean "no sound". + return null; + } +} + +function playBuffer(buffer: AudioBuffer): void { + if (!audioCtx || audioCtx.state !== "running") return; + const source = audioCtx.createBufferSource(); + const gain = audioCtx.createGain(); + gain.gain.value = 1; // the file is already mastered; only the synth is attenuated + source.buffer = buffer; + source.connect(gain).connect(audioCtx.destination); + source.start(); +} + +async function decodeSound(path: string): Promise { + try { + if (!audioCtx) return null; + const bytes = readFileBytes(path); + if (!bytes) return null; + const decoded = await audioCtx.decodeAudioData(bytes); + decodedSounds.set(path, decoded); + return decoded; + } catch { + return null; + } finally { + pendingDecodes.delete(path); + } +} + +/** + * Play a user-supplied file. Falls back to the built-in tone for that outcome + * when the file cannot be read or decoded — silence would hide the very event + * the cue exists to announce. + */ +function playFile(path: string, fallbackHz: number): void { + try { + const cached = decodedSounds.get(path); + if (cached) { + playBuffer(cached); + return; + } + if (!audioCtx || audioCtx.state !== "running") return; + let pending = pendingDecodes.get(path); + if (!pending) { + pending = decodeSound(path); + pendingDecodes.set(path, pending); + } + void pending.then((buffer) => { + if (buffer) playBuffer(buffer); + else playTone(fallbackHz); + }); + } catch { + // Never throw into a settled run. } } @@ -144,6 +252,9 @@ export function emitRunCue( } const cue = cueForOutcome(outcome, settings, sessionName); if (!cue.play) return; - if (cue.frequency !== null) playTone(cue.frequency); + if (cue.sound) { + if (cue.sound.kind === "default") playTone(cue.sound.frequency); + else playFile(cue.sound.path, outcome === "error" ? CUE_FAIL_HZ : CUE_DONE_HZ); + } if (cue.body) notify(cue.body, cue.name); } diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 0b7324b7..dffd4a91 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -587,6 +587,10 @@ export interface PluginData { runSoundEnabled: boolean; /** Raise a notification when a chat run settles (success or failure). */ runNotificationEnabled: boolean; + /** Absolute path to a sound file played when a run succeeds. Blank uses the built-in tone. */ + runSuccessSoundPath: string; + /** Absolute path to a sound file played when a run fails. Blank uses the built-in tone. */ + runFailureSoundPath: string; /** Pin/archive flags per chat session, keyed by thread file path. */ sessionFlags: Record; /** Whether the user has completed (or dismissed) the first-run onboarding flow. */ diff --git a/src/views/settings/AgentsSettings.svelte b/src/views/settings/AgentsSettings.svelte index 0f00c209..39f52574 100644 --- a/src/views/settings/AgentsSettings.svelte +++ b/src/views/settings/AgentsSettings.svelte @@ -11,6 +11,7 @@ import Button from "../../components/ui/Button.svelte"; import { confirmDelete } from "../../components/modal/ConfirmModal"; import Dropdown from "../../components/ui/Dropdown.svelte"; import Icon from "../../components/ui/Icon.svelte"; +import Text from "../../components/ui/Text.svelte"; import Toggle from "../../components/ui/Toggle.svelte"; import { getProviderDefinition } from "../../providers/index"; import { getData } from "../../stores/dataStore.svelte"; @@ -290,6 +291,30 @@ function getAgentSkillsSummary(agentId: string): { icons: string[]; overflow: nu {#if !isMobileUI()} + + (pluginData.runSuccessSoundPath = v)} + /> + + + + (pluginData.runFailureSoundPath = v)} + /> + + { const on = { sound: true, notification: true }; @@ -9,7 +14,7 @@ describe("cueForOutcome", () => { it("stays silent for a run the user cancelled", () => { expect(cueForOutcome("cancelled", on)).toEqual({ play: false, - frequency: null, + sound: null, body: null, name: null, }); @@ -26,27 +31,79 @@ describe("cueForOutcome", () => { expect(cueForOutcome("success", on, " ").name).toBeNull(); }); - it("gives failure and completion distinguishable tones", () => { - expect(cueForOutcome("error", on).frequency).toBe(CUE_FAIL_HZ); - expect(cueForOutcome("success", on).frequency).toBe(CUE_DONE_HZ); + it("gives failure and completion distinguishable default tones", () => { + expect(cueForOutcome("error", on).sound).toEqual({ kind: "default", frequency: CUE_FAIL_HZ }); + expect(cueForOutcome("success", on).sound).toEqual({ + kind: "default", + frequency: CUE_DONE_HZ, + }); expect(CUE_FAIL_HZ).not.toBe(CUE_DONE_HZ); }); + it("plays a configured file in place of the default tone", () => { + const cue = cueForOutcome("success", { + ...on, + successSoundPath: "C:/Windows/Media/tada.wav", + }); + expect(cue.sound).toEqual({ kind: "file", path: "C:/Windows/Media/tada.wav" }); + }); + + it("picks each outcome's own file, so failure cannot sound like success", () => { + const settings = { + ...on, + successSoundPath: "/sounds/tada.wav", + failureSoundPath: "/sounds/error.wav", + }; + expect(cueForOutcome("success", settings).sound).toEqual({ + kind: "file", + path: "/sounds/tada.wav", + }); + expect(cueForOutcome("error", settings).sound).toEqual({ + kind: "file", + path: "/sounds/error.wav", + }); + }); + + it("falls back to the default tone when an override is blank or whitespace", () => { + for (const blank of ["", " ", undefined]) { + expect(cueForOutcome("success", { ...on, successSoundPath: blank }).sound).toEqual({ + kind: "default", + frequency: CUE_DONE_HZ, + }); + } + }); + + it("does not leak a success path into the failure cue", () => { + expect(cueForOutcome("error", { ...on, successSoundPath: "/sounds/tada.wav" }).sound).toEqual({ + kind: "default", + frequency: CUE_FAIL_HZ, + }); + }); + it("keeps sound and notification independently toggleable", () => { expect(cueForOutcome("success", { sound: true, notification: false })).toMatchObject({ - frequency: CUE_DONE_HZ, + sound: { kind: "default", frequency: CUE_DONE_HZ }, body: null, }); expect(cueForOutcome("success", { sound: false, notification: true })).toMatchObject({ - frequency: null, + sound: null, body: "Chat run finished", }); }); + it("mutes a configured file too when sound is off", () => { + const cue = cueForOutcome("success", { + sound: false, + notification: false, + successSoundPath: "C:/Windows/Media/tada.wav", + }); + expect(cue.sound).toBeNull(); + }); + it("still settles with both cues off, emitting nothing", () => { const cue = cueForOutcome("success", off); expect(cue.play).toBe(true); - expect(cue.frequency).toBeNull(); + expect(cue.sound).toBeNull(); expect(cue.body).toBeNull(); }); });