Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/stores/chatStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,8 @@ export class ChatSession {
() => ({
sound: getData().runSoundEnabled,
notification: getData().runNotificationEnabled,
successSoundPath: getData().runSuccessSoundPath,
failureSoundPath: getData().runFailureSoundPath,
}),
cueName,
);
Expand Down
18 changes: 18 additions & 0 deletions src/stores/dataStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ export const DEFAULT_SETTINGS: PluginData = {
sessionSidebarWidth: 240,
runSoundEnabled: true,
runNotificationEnabled: true,
runSuccessSoundPath: "",
runFailureSoundPath: "",
sessionFlags: {},
onboardingComplete: false,
onboardingSplashSeen: false,
Expand Down Expand Up @@ -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 {
Expand Down
131 changes: 121 additions & 10 deletions src/stores/runCue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,46 @@ 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
* *which* chat settled. Null when the session has no name yet. */
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.
*
Expand All @@ -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,
};
Expand All @@ -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<string, AudioBuffer>();
/** 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<string, Promise<AudioBuffer | null>>();

/**
* 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 {
Expand All @@ -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<AudioBuffer | null> {
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.
}
}

Expand Down Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SessionFlags>;
/** Whether the user has completed (or dismissed) the first-run onboarding flow. */
Expand Down
25 changes: 25 additions & 0 deletions src/views/settings/AgentsSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -290,6 +291,30 @@ function getAgentSkillsSummary(agentId: string): { icons: string[]; overflow: nu
<!-- Obsidian mobile has no status bar (addStatusBarItem is a no-op there), so the
toggle would control nothing. Hide it rather than offering a dead switch. -->
{#if !isMobileUI()}
<SettingItem
name="Success sound file"
desc="Absolute path to a sound file to play when a run succeeds. Leave empty for the built-in tone."
>
<Text
placeholder={"C:/Windows/Media/tada.wav"}
inputType="text"
value={pluginData.runSuccessSoundPath}
onblur={(v) => (pluginData.runSuccessSoundPath = v)}
/>
</SettingItem>

<SettingItem
name="Failure sound file"
desc="Absolute path to a sound file to play when a run fails. Leave empty for the built-in tone."
>
<Text
placeholder={"C:/Windows/Media/Windows Error.wav"}
inputType="text"
value={pluginData.runFailureSoundPath}
onblur={(v) => (pluginData.runFailureSoundPath = v)}
/>
</SettingItem>

<SettingItem
name="Show active agents in status bar"
desc="Display a clickable indicator in the status bar for each chat with a running agent."
Expand Down
73 changes: 65 additions & 8 deletions test/stores/runCue.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { AssistantState } from "../../src/stores/chatTimeline";
import { CUE_DONE_HZ, CUE_FAIL_HZ, cueForOutcome, outcomeForAssistantState } from "../../src/stores/runCue";
import {
CUE_DONE_HZ,
CUE_FAIL_HZ,
cueForOutcome,
outcomeForAssistantState,
} from "../../src/stores/runCue";

describe("cueForOutcome", () => {
const on = { sound: true, notification: true };
Expand All @@ -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,
});
Expand All @@ -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();
});
});
Expand Down
Loading