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
1 change: 1 addition & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ container
dockBounceNotifications: s.dockBounceNotifications,
completionSound: s.completionSound,
completionVolume: s.completionVolume,
scaleSoundWithTaskLength: s.scaleSoundWithTaskLength,
customSounds: s.customSounds,
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ describe("cloud task update notifications", () => {
"Cloud Task",
"end_turn",
TASK_ID,
undefined,
);
expect(harness.markActivity).toHaveBeenCalledTimes(1);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,9 @@ export class SessionService {
session.taskTitle,
"end_turn",
session.taskId,
session.promptStartedAt
? acpMsg.ts - session.promptStartedAt
: undefined,
);
}
this.d.taskViewedApi.markActivity(session.taskId);
Expand Down Expand Up @@ -1671,6 +1674,9 @@ export class SessionService {
session.taskTitle,
stopReason,
session.taskId,
session.promptStartedAt
? acpMsg.ts - session.promptStartedAt
: undefined,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/notifications/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface NotificationSettings {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
scaleSoundWithTaskLength: boolean;
customSounds: CustomSound[];
}

Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/features/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function makeBus(overrides?: {
dockBounceNotifications: true,
completionSound: "meep",
completionVolume: 80,
scaleSoundWithTaskLength: false,
customSounds: [],
...overrides?.settings,
};
Expand Down Expand Up @@ -183,4 +184,22 @@ describe("sound", () => {
bus.notifyPromptComplete("My task", "end_turn", TASK_ID);
expect(play).toHaveBeenCalledTimes(1);
});

it.each([
[
"scaling off, with duration",
false,
(10 * 60 * 1000) as number | undefined,
1,
],
["scaling on, quick task (<30s) → 3×", true, 10 * 1000, 3],
["scaling on, no duration → 1×", true, undefined, 1],
])("%s", (_label, scaleSoundWithTaskLength, durationMs, expectedRate) => {
const { bus, play } = makeBus({
hasFocus: false,
settings: { scaleSoundWithTaskLength },
});
bus.notifyPromptComplete("My task", "end_turn", TASK_ID, durationMs);
expect(play).toHaveBeenCalledWith("meep", 80, [], expectedRate);
});
});
17 changes: 16 additions & 1 deletion packages/ui/src/features/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
} from "@posthog/platform/notifications";
import { toast } from "@posthog/ui/primitives/toast";
import { openNotificationTarget } from "@posthog/ui/router/navigationBridge";
import { playCompletionSound, resolveSoundUrl } from "@posthog/ui/utils/sounds";
import {
playbackRateForTaskDuration,
playCompletionSound,
resolveSoundUrl,
} from "@posthog/ui/utils/sounds";
import { inject, injectable } from "inversify";
import {
ACTIVE_VIEW_PROVIDER,
Expand Down Expand Up @@ -34,6 +38,9 @@ export interface NotificationDescriptor {
duration?: number;
};
silent?: boolean;
// How long the task took, in ms. When the user enables sound scaling, this
// drives the completion sound's playback rate (fast task -> faster/higher).
soundDurationMs?: number;
}

// The single channel every app notification flows through. Reads focus + the
Expand Down Expand Up @@ -61,12 +68,18 @@ export class NotificationBus {
if (channel === "suppress") return;

const settings = this.settings.get();
const playbackRate =
settings.scaleSoundWithTaskLength &&
descriptor.soundDurationMs !== undefined
? playbackRateForTaskDuration(descriptor.soundDurationMs)
: 1;
// Sound fires on both delivered tiers (toast + native), not on suppress —
// matching the pre-bus behavior where any non-suppressed notification rang.
playCompletionSound(
settings.completionSound,
settings.completionVolume,
settings.customSounds,
playbackRate,
);

if (channel === "toast") {
Expand Down Expand Up @@ -100,12 +113,14 @@ export class NotificationBus {
taskTitle: string,
stopReason: string,
taskId?: string,
durationMs?: number,
): void {
if (stopReason !== "end_turn") return;
this.notify({
body: `"${this.truncateTitle(taskTitle)}" finished`,
target: taskId ? { kind: "task", taskId } : undefined,
toast: { level: "success" },
soundDurationMs: durationMs,
});
}

Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/features/sessions/sessionServiceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ function buildSessionServiceDeps(): SessionServiceDeps {
taskTitle,
taskId,
),
notifyPromptComplete: (taskTitle, stopReason, taskId) =>
notifyPromptComplete: (taskTitle, stopReason, taskId, durationMs) =>
resolveService(NotificationBus).notifyPromptComplete(
taskTitle,
stopReason,
taskId,
durationMs,
),
getIsOnline,
fetchAuthState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export function NotificationsSettings() {
dockBounceNotifications,
completionSound,
completionVolume,
scaleSoundWithTaskLength,
customSounds,
setDesktopNotifications,
setDockBadgeNotifications,
setDockBounceNotifications,
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
removeCustomSound,
renameCustomSound,
} = useSettingsStore();
Expand Down Expand Up @@ -117,19 +119,33 @@ export function NotificationsSettings() {
[completionSound, setCompletionSound],
);

const handleScaleSoundChange = useCallback(
(checked: boolean) => {
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
setting_name: "scale_sound_with_task_length",
new_value: checked,
old_value: scaleSoundWithTaskLength,
});
setScaleSoundWithTaskLength(checked);
},
[scaleSoundWithTaskLength, setScaleSoundWithTaskLength],
);

const resetToDefaults = useCallback(() => {
setDesktopNotifications(NOTIFICATION_DEFAULTS.desktopNotifications);
setDockBadgeNotifications(NOTIFICATION_DEFAULTS.dockBadgeNotifications);
setDockBounceNotifications(NOTIFICATION_DEFAULTS.dockBounceNotifications);
setCompletionSound(NOTIFICATION_DEFAULTS.completionSound);
setCompletionVolume(NOTIFICATION_DEFAULTS.completionVolume);
setScaleSoundWithTaskLength(NOTIFICATION_DEFAULTS.scaleSoundWithTaskLength);
toast.success("Notification settings reset to defaults");
}, [
setDesktopNotifications,
setDockBadgeNotifications,
setDockBounceNotifications,
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
]);

return (
Expand Down Expand Up @@ -270,7 +286,7 @@ export function NotificationsSettings() {
/>

{completionSound !== "none" && (
<SettingRow label="Sound volume" noBorder>
<SettingRow label="Sound volume">
<Flex align="center" gap="3">
<Slider
value={[completionVolume]}
Expand All @@ -288,6 +304,20 @@ export function NotificationsSettings() {
</SettingRow>
)}

{completionSound !== "none" && (
<SettingRow
label="Scale sound speed with task length"
description="Play the sound faster for quick tasks and slower for long ones"
noBorder
>
<Switch
checked={scaleSoundWithTaskLength}
onCheckedChange={handleScaleSoundChange}
size="1"
/>
</SettingRow>
)}

<NotificationTestHarness
bus={bus}
notifications={notifications}
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,14 @@ interface SettingsStore {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
scaleSoundWithTaskLength: boolean;
customSounds: CustomSound[];
setDesktopNotifications: (enabled: boolean) => void;
setDockBadgeNotifications: (enabled: boolean) => void;
setDockBounceNotifications: (enabled: boolean) => void;
setCompletionSound: (sound: CompletionSound) => void;
setCompletionVolume: (volume: number) => void;
setScaleSoundWithTaskLength: (enabled: boolean) => void;
addCustomSound: (sound: CustomSound) => void;
removeCustomSound: (id: string) => void;
renameCustomSound: (id: string, name: string) => void;
Expand Down Expand Up @@ -187,6 +189,7 @@ export const NOTIFICATION_DEFAULTS = {
dockBounceNotifications: false,
completionSound: "none" as CompletionSound,
completionVolume: 80,
scaleSoundWithTaskLength: false,
};

export const useSettingsStore = create<SettingsStore>()(
Expand Down Expand Up @@ -253,6 +256,8 @@ export const useSettingsStore = create<SettingsStore>()(
set({ dockBounceNotifications: enabled }),
setCompletionSound: (sound) => set({ completionSound: sound }),
setCompletionVolume: (volume) => set({ completionVolume: volume }),
setScaleSoundWithTaskLength: (enabled) =>
set({ scaleSoundWithTaskLength: enabled }),
addCustomSound: (sound) =>
set((state) => ({ customSounds: [...state.customSounds, sound] })),
removeCustomSound: (id) =>
Expand Down Expand Up @@ -381,6 +386,7 @@ export const useSettingsStore = create<SettingsStore>()(
dockBounceNotifications: state.dockBounceNotifications,
completionSound: state.completionSound,
completionVolume: state.completionVolume,
scaleSoundWithTaskLength: state.scaleSoundWithTaskLength,
customSounds: state.customSounds,

// Composer / chat
Expand Down
22 changes: 21 additions & 1 deletion packages/ui/src/utils/sounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
CustomSound,
} from "@posthog/ui/features/settings/settingsStore";
import { describe, expect, it } from "vitest";
import { resolveSoundUrl } from "./sounds";
import { playbackRateForTaskDuration, resolveSoundUrl } from "./sounds";

const customs: CustomSound[] = [
{
Expand Down Expand Up @@ -40,3 +40,23 @@ describe("resolveSoundUrl", () => {
expect(resolveSoundUrl("custom:gone", customs)).toBeNull();
});
});

describe("playbackRateForTaskDuration", () => {
it.each([
["below the fast floor (10s)", 10 * 1000, 3],
["at the fast floor (30s)", 30 * 1000, 3],
["geometric mid of the fast ramp (60s)", 60 * 1000, Math.sqrt(3)],
["normal band start (2min)", 2 * 60 * 1000, 1],
["normal band end (4min)", 4 * 60 * 1000, 1],
[
"geometric mid of the slow ramp",
Math.sqrt(4 * 60 * 1000 * (30 * 60 * 1000)),
Math.sqrt(1 / 3),
],
["at the slow ceiling (30min)", 30 * 60 * 1000, 1 / 3],
["beyond the slow ceiling (2h)", 2 * 60 * 60 * 1000, 1 / 3],
["NaN (non-finite) → fast rate", Number.NaN, 3],
])("%s → %f", (_label, durationMs, expected) => {
expect(playbackRateForTaskDuration(durationMs)).toBeCloseTo(expected, 5);
});
});
31 changes: 31 additions & 0 deletions packages/ui/src/utils/sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ const SOUND_URLS: Record<Exclude<BuiltInCompletionSound, "none">, string> = {
icq: icqUrl,
};

const MIN_RATE = 1 / 3;
const MAX_RATE = 3;
const FAST_MS = 30 * 1000;
const NORMAL_START_MS = 2 * 60 * 1000;
const NORMAL_END_MS = 4 * 60 * 1000;
const SLOW_MS = 30 * 60 * 1000;

// Maps a task's duration to an audio playback rate so a quick task rings fast
// (and high-pitched) while a long one drags slow (and low). Anchored at: <=30s
// -> 3x, the 2-4min "normal" band -> 1x, >=30min -> 1/3x, with smooth
// log-interpolation across the two ramps so the rate doesn't jump at the edges.
export function playbackRateForTaskDuration(durationMs: number): number {
if (!Number.isFinite(durationMs) || durationMs <= FAST_MS) return MAX_RATE;
if (durationMs >= SLOW_MS) return MIN_RATE;
if (durationMs >= NORMAL_START_MS && durationMs <= NORMAL_END_MS) return 1;

if (durationMs < NORMAL_START_MS) {
const frac =
(Math.log(durationMs) - Math.log(FAST_MS)) /
(Math.log(NORMAL_START_MS) - Math.log(FAST_MS));
return MAX_RATE ** (1 - frac);
}

const frac =
(Math.log(durationMs) - Math.log(NORMAL_END_MS)) /
(Math.log(SLOW_MS) - Math.log(NORMAL_END_MS));
return MIN_RATE ** frac;
}

let currentAudio: HTMLAudioElement | null = null;

// Resolves the playable URL for a completion sound: a bundled asset URL for the
Expand All @@ -59,6 +88,7 @@ export function playCompletionSound(
sound: CompletionSound,
volume = 80,
customSounds: CustomSound[] = [],
playbackRate = 1,
): void {
const url = resolveSoundUrl(sound, customSounds);
if (!url) return;
Expand All @@ -70,6 +100,7 @@ export function playCompletionSound(

const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(100, volume)) / 100;
audio.playbackRate = playbackRate;
currentAudio = audio;
audio.play().catch(() => {
// Audio play can fail if user hasn't interacted with the page yet
Expand Down
Loading