diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 428343b752..866030e4cc 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -202,6 +202,7 @@ container dockBounceNotifications: s.dockBounceNotifications, completionSound: s.completionSound, completionVolume: s.completionVolume, + scaleSoundWithTaskLength: s.scaleSoundWithTaskLength, customSounds: s.customSounds, }; }, diff --git a/packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts b/packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts index e6a2d299cb..2f1d05797c 100644 --- a/packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts +++ b/packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts @@ -178,6 +178,7 @@ describe("cloud task update notifications", () => { "Cloud Task", "end_turn", TASK_ID, + undefined, ); expect(harness.markActivity).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 9ac67c6f4b..e4860b85a8 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -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); @@ -1671,6 +1674,9 @@ export class SessionService { session.taskTitle, stopReason, session.taskId, + session.promptStartedAt + ? acpMsg.ts - session.promptStartedAt + : undefined, ); } diff --git a/packages/ui/src/features/notifications/identifiers.ts b/packages/ui/src/features/notifications/identifiers.ts index fa254bf0b1..de7b7d6258 100644 --- a/packages/ui/src/features/notifications/identifiers.ts +++ b/packages/ui/src/features/notifications/identifiers.ts @@ -10,6 +10,7 @@ export interface NotificationSettings { dockBounceNotifications: boolean; completionSound: CompletionSound; completionVolume: number; + scaleSoundWithTaskLength: boolean; customSounds: CustomSound[]; } diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index a9819c7625..4e99b64eb2 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -51,6 +51,7 @@ function makeBus(overrides?: { dockBounceNotifications: true, completionSound: "meep", completionVolume: 80, + scaleSoundWithTaskLength: false, customSounds: [], ...overrides?.settings, }; @@ -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); + }); }); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index cb3e3bf642..03393830f9 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -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, @@ -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 @@ -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") { @@ -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, }); } diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index bae8f81297..5e56b1627e 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -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, diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx index c79e8b5b33..f5891427d9 100644 --- a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx @@ -40,12 +40,14 @@ export function NotificationsSettings() { dockBounceNotifications, completionSound, completionVolume, + scaleSoundWithTaskLength, customSounds, setDesktopNotifications, setDockBadgeNotifications, setDockBounceNotifications, setCompletionSound, setCompletionVolume, + setScaleSoundWithTaskLength, removeCustomSound, renameCustomSound, } = useSettingsStore(); @@ -117,12 +119,25 @@ 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, @@ -130,6 +145,7 @@ export function NotificationsSettings() { setDockBounceNotifications, setCompletionSound, setCompletionVolume, + setScaleSoundWithTaskLength, ]); return ( @@ -270,7 +286,7 @@ export function NotificationsSettings() { /> {completionSound !== "none" && ( - + )} + {completionSound !== "none" && ( + + + + )} + 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; @@ -187,6 +189,7 @@ export const NOTIFICATION_DEFAULTS = { dockBounceNotifications: false, completionSound: "none" as CompletionSound, completionVolume: 80, + scaleSoundWithTaskLength: false, }; export const useSettingsStore = create()( @@ -253,6 +256,8 @@ export const useSettingsStore = create()( 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) => @@ -381,6 +386,7 @@ export const useSettingsStore = create()( dockBounceNotifications: state.dockBounceNotifications, completionSound: state.completionSound, completionVolume: state.completionVolume, + scaleSoundWithTaskLength: state.scaleSoundWithTaskLength, customSounds: state.customSounds, // Composer / chat diff --git a/packages/ui/src/utils/sounds.test.ts b/packages/ui/src/utils/sounds.test.ts index 41ec8b8d0c..c1dd0e4acb 100644 --- a/packages/ui/src/utils/sounds.test.ts +++ b/packages/ui/src/utils/sounds.test.ts @@ -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[] = [ { @@ -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); + }); +}); diff --git a/packages/ui/src/utils/sounds.ts b/packages/ui/src/utils/sounds.ts index dac8b49031..4673352f8e 100644 --- a/packages/ui/src/utils/sounds.ts +++ b/packages/ui/src/utils/sounds.ts @@ -37,6 +37,35 @@ const SOUND_URLS: Record, 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 @@ -59,6 +88,7 @@ export function playCompletionSound( sound: CompletionSound, volume = 80, customSounds: CustomSound[] = [], + playbackRate = 1, ): void { const url = resolveSoundUrl(sound, customSounds); if (!url) return; @@ -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