From ce3dbc7ceafd292149d30faace243df034463600 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Tue, 30 Jun 2026 14:49:22 +0100 Subject: [PATCH 1/4] feat(notifications): scale completion sound speed with task length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in "Scale sound speed with task length" toggle to Settings → Notifications. When enabled, the completion sound's playback rate reflects how long the just-completed turn took: a quick task (<30s) plays up to 3x faster (higher pitch), the 2-4 min "normal" band plays at normal speed, and a long task (>=30min) plays up to 3x slower (lower pitch), with smooth log-interpolation across the ramps. Off by default, so existing behavior is unchanged. Generated-By: PostHog Code Task-Id: e2191f70-e37c-4f84-b59f-54d458e496fd --- apps/code/src/renderer/desktop-services.ts | 1 + packages/core/src/sessions/sessionService.ts | 6 ++++ .../src/features/notifications/identifiers.ts | 1 + .../notifications/notifications.test.ts | 29 +++++++++++++++++ .../features/notifications/notifications.ts | 17 +++++++++- .../features/sessions/sessionServiceHost.ts | 3 +- .../sections/NotificationsSettings.tsx | 32 ++++++++++++++++++- .../ui/src/features/settings/settingsStore.ts | 6 ++++ packages/ui/src/utils/sounds.test.ts | 25 ++++++++++++++- packages/ui/src/utils/sounds.ts | 31 ++++++++++++++++++ 10 files changed, 147 insertions(+), 4 deletions(-) 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/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..66b1562407 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,32 @@ describe("sound", () => { bus.notifyPromptComplete("My task", "end_turn", TASK_ID); expect(play).toHaveBeenCalledTimes(1); }); + + it("plays at normal speed when scaling is off", () => { + const { bus, play } = makeBus({ + hasFocus: false, + settings: { scaleSoundWithTaskLength: false }, + }); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID, 10 * 60 * 1000); + expect(play).toHaveBeenCalledWith("meep", 80, [], 1); + }); + + it("scales playback rate by duration when scaling is on", () => { + const { bus, play } = makeBus({ + hasFocus: false, + settings: { scaleSoundWithTaskLength: true }, + }); + // A quick (<30s) task plays at the max 3x rate. + bus.notifyPromptComplete("My task", "end_turn", TASK_ID, 10 * 1000); + expect(play).toHaveBeenCalledWith("meep", 80, [], 3); + }); + + it("plays at normal speed when scaling is on but no duration is given", () => { + const { bus, play } = makeBus({ + hasFocus: false, + settings: { scaleSoundWithTaskLength: true }, + }); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(play).toHaveBeenCalledWith("meep", 80, [], 1); + }); }); 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..6fc34f47f7 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,26 @@ 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], + ])("%s → %f", (_label, durationMs, expected) => { + expect(playbackRateForTaskDuration(durationMs)).toBeCloseTo(expected, 5); + }); + + it("falls back to the fast rate for non-finite input", () => { + expect(playbackRateForTaskDuration(Number.NaN)).toBe(3); + }); +}); 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 From dcae7cebcf02a74037c3ee09f113935bacdf250a Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Tue, 30 Jun 2026 15:03:14 +0100 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../notifications/notifications.test.ts | 42 +++++++------------ packages/ui/src/utils/sounds.test.ts | 5 +-- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 66b1562407..d96132c12d 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -185,31 +185,19 @@ describe("sound", () => { expect(play).toHaveBeenCalledTimes(1); }); - it("plays at normal speed when scaling is off", () => { - const { bus, play } = makeBus({ - hasFocus: false, - settings: { scaleSoundWithTaskLength: false }, - }); - bus.notifyPromptComplete("My task", "end_turn", TASK_ID, 10 * 60 * 1000); - expect(play).toHaveBeenCalledWith("meep", 80, [], 1); - }); - - it("scales playback rate by duration when scaling is on", () => { - const { bus, play } = makeBus({ - hasFocus: false, - settings: { scaleSoundWithTaskLength: true }, - }); - // A quick (<30s) task plays at the max 3x rate. - bus.notifyPromptComplete("My task", "end_turn", TASK_ID, 10 * 1000); - expect(play).toHaveBeenCalledWith("meep", 80, [], 3); - }); - - it("plays at normal speed when scaling is on but no duration is given", () => { - const { bus, play } = makeBus({ - hasFocus: false, - settings: { scaleSoundWithTaskLength: true }, - }); - bus.notifyPromptComplete("My task", "end_turn", TASK_ID); - expect(play).toHaveBeenCalledWith("meep", 80, [], 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/utils/sounds.test.ts b/packages/ui/src/utils/sounds.test.ts index 6fc34f47f7..c1dd0e4acb 100644 --- a/packages/ui/src/utils/sounds.test.ts +++ b/packages/ui/src/utils/sounds.test.ts @@ -55,11 +55,8 @@ describe("playbackRateForTaskDuration", () => { ], ["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); }); - - it("falls back to the fast rate for non-finite input", () => { - expect(playbackRateForTaskDuration(Number.NaN)).toBe(3); - }); }); From 42ebbcd7b0100cca6844c1869137f666af189a66 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Tue, 30 Jun 2026 15:11:55 +0100 Subject: [PATCH 3/4] style: apply biome formatting to notifications test Format the it.each block added by the code-review suggestions to satisfy biome ci (collapse the callback onto the matcher line, wrap the long tuple element). Generated-By: PostHog Code Task-Id: e2191f70-e37c-4f84-b59f-54d458e496fd --- .../notifications/notifications.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index d96132c12d..4e99b64eb2 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -186,18 +186,20 @@ describe("sound", () => { }); it.each([ - ["scaling off, with duration", false, 10 * 60 * 1000 as number | undefined, 1], + [ + "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); - }, - ); + ])("%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); + }); }); From ef0259458cb468c8730a197c3f987d13ad970fb4 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Tue, 30 Jun 2026 15:23:18 +0100 Subject: [PATCH 4/4] test: assert the duration arg passed to notifyPromptComplete The cloud turn_complete path now forwards a per-turn durationMs (undefined when no prompt-start was recorded, as in this harness). Update the toHaveBeenCalledWith assertion to match the new arity. Generated-By: PostHog Code Task-Id: e2191f70-e37c-4f84-b59f-54d458e496fd --- packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts | 1 + 1 file changed, 1 insertion(+) 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); });