From 1a134e3a509374441a6d121945f4304ed29cf530 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 1 Jul 2026 11:46:18 +0100 Subject: [PATCH 1/2] feat(mobile): scale completion sound speed with task length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the desktop feature from #3026 to the mobile app: the task-completion sound now plays at a variable rate based on how long the turn ran — quick tasks ring fast (higher pitch), long tasks drag slow (lower pitch). A new "Scale sound speed with task length" setting gates the behavior, defaulting off. - Add `playbackRateForTaskDuration` (verbatim from desktop) mapping duration to a playback rate, and thread an optional `playbackRate` through `playCompletionSound`, setting `rate`/`shouldCorrectPitch: false` on expo-av. - Add the `scaleSoundWithTaskLength` preference (persisted, default off). - Track prompt start time on the session and compute the rate at the ping sites. - Add the settings toggle, shown only when a completion sound is enabled. Generated-By: PostHog Code Task-Id: dc58b11c-b3fc-4275-8102-9bffed29d638 --- apps/mobile/src/app/settings/index.tsx | 18 ++++++++- .../stores/preferencesStore.test.ts | 26 +++++++++++++ .../preferences/stores/preferencesStore.ts | 6 +++ .../tasks/stores/taskSessionStore.test.ts | 3 +- .../features/tasks/stores/taskSessionStore.ts | 33 ++++++++++++++-- .../features/tasks/utils/playbackRate.test.ts | 39 +++++++++++++++++++ .../src/features/tasks/utils/playbackRate.ts | 28 +++++++++++++ .../mobile/src/features/tasks/utils/sounds.ts | 9 ++--- 8 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/features/tasks/utils/playbackRate.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/playbackRate.ts diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index c4601b4216..fa11f065d9 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -175,6 +175,12 @@ export default function SettingsScreen() { const setCompletionSound = usePreferencesStore((s) => s.setCompletionSound); const completionVolume = usePreferencesStore((s) => s.completionVolume); const setCompletionVolume = usePreferencesStore((s) => s.setCompletionVolume); + const scaleSoundWithTaskLength = usePreferencesStore( + (s) => s.scaleSoundWithTaskLength, + ); + const setScaleSoundWithTaskLength = usePreferencesStore( + (s) => s.setScaleSoundWithTaskLength, + ); const defaultInitialTaskMode = usePreferencesStore( (s) => s.defaultInitialTaskMode, ); @@ -348,7 +354,6 @@ export default function SettingsScreen() { label="Sound volume" description="How loud the completion sound plays" onPress={() => setVolumeSheetOpen(true)} - showDivider={false} rightSlot={ <> @@ -358,6 +363,17 @@ export default function SettingsScreen() { } /> + + } + /> ) : null} diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts index cf15fdf835..263daeb2c6 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts @@ -55,6 +55,32 @@ describe("preferencesStore reasoning effort", () => { }); }); +describe("preferencesStore scale sound with task length", () => { + it("defaults to false", () => { + expect(usePreferencesStore.getState().scaleSoundWithTaskLength).toBe(false); + }); + + it.each([true, false])("updates to %s via setter", (enabled) => { + usePreferencesStore.getState().setScaleSoundWithTaskLength(enabled); + expect(usePreferencesStore.getState().scaleSoundWithTaskLength).toBe( + enabled, + ); + }); + + it("persists the value to storage", async () => { + const AsyncStorage = ( + await import("@react-native-async-storage/async-storage") + ).default; + usePreferencesStore.getState().setScaleSoundWithTaskLength(true); + await Promise.resolve(); + const persisted = await AsyncStorage.getItem("posthog-preferences"); + expect(persisted).not.toBeNull(); + expect(JSON.parse(persisted as string).state.scaleSoundWithTaskLength).toBe( + true, + ); + }); +}); + describe("preferencesStore font size", () => { it("defaults to a known preference with a 'default' scale of 1", () => { const { fontSize } = usePreferencesStore.getState(); diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index 8065640c97..cc8b5475fc 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -64,6 +64,8 @@ interface PreferencesState { setCompletionSound: (sound: CompletionSound) => void; completionVolume: number; setCompletionVolume: (volume: number) => void; + scaleSoundWithTaskLength: boolean; + setScaleSoundWithTaskLength: (enabled: boolean) => void; defaultInitialTaskMode: InitialTaskMode; setDefaultInitialTaskMode: (mode: InitialTaskMode) => void; @@ -102,6 +104,9 @@ export const usePreferencesStore = create()( set({ completionVolume: Math.max(0, Math.min(100, Math.round(volume))), }), + scaleSoundWithTaskLength: false, + setScaleSoundWithTaskLength: (enabled) => + set({ scaleSoundWithTaskLength: enabled }), defaultInitialTaskMode: "plan", setDefaultInitialTaskMode: (mode) => @@ -126,6 +131,7 @@ export const usePreferencesStore = create()( fontSize: state.fontSize, completionSound: state.completionSound, completionVolume: state.completionVolume, + scaleSoundWithTaskLength: state.scaleSoundWithTaskLength, defaultInitialTaskMode: state.defaultInitialTaskMode, lastNewTaskMode: state.lastNewTaskMode, defaultReasoningEffort: state.defaultReasoningEffort, diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index 8b3fdd4773..36c2338ddb 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -11,7 +11,8 @@ vi.mock("../composer/attachments/buildCloudPrompt", () => ({ buildCloudPromptBlocks: vi.fn(() => Promise.resolve([])), })); vi.mock("../utils/sounds", () => ({ - playMeepSound: vi.fn(() => Promise.resolve()), + playCompletionSound: vi.fn(() => Promise.resolve()), + playbackRateForTaskDuration: vi.fn(() => 1), })); vi.mock("@/features/notifications/lib/notifications", () => ({ presentLocalNotification: vi.fn(() => Promise.resolve()), diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index fd71bd23e1..67792e41ff 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -28,7 +28,8 @@ import { type Task, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; -import { playMeepSound } from "../utils/sounds"; +import { playbackRateForTaskDuration } from "../utils/playbackRate"; +import { playCompletionSound } from "../utils/sounds"; import { useAttachmentEchoStore } from "./attachmentEchoStore"; import { combineQueuedMessages, @@ -38,6 +39,16 @@ import { useTaskStore } from "./taskStore"; const log = logger.scope("task-session-store"); +function completionPlaybackRate(promptStartedAt?: number): number { + if ( + !usePreferencesStore.getState().scaleSoundWithTaskLength || + promptStartedAt == null + ) { + return 1; + } + return playbackRateForTaskDuration(Date.now() - promptStartedAt); +} + // Match historical `user_message_chunk` events (text-only, as the cloud // stores them) against locally-cached attachment echoes by position+text. // Echoes are written in send-order; we walk user messages in receive-order @@ -289,6 +300,9 @@ export interface TaskSession { // we should play a sound when control returns. False when reconnecting // to an already-running task to avoid spurious pings. awaitingPing?: boolean; + // Timestamp when the current prompt started on this device. Used to scale + // the completion sound's playback rate by how long the turn ran. + promptStartedAt?: number; // True after a user prompt is sent, cleared when the first piece of // agent output (tool call, message, etc.) arrives. awaitingAgentOutput?: boolean; @@ -425,6 +439,7 @@ export const useTaskSessionStore = create((set, get) => ({ // us otherwise — the SSE watcher will refine these fields. isPromptPending: true, awaitingPing, + promptStartedAt: awaitingPing ? Date.now() : undefined, awaitingAgentOutput: true, }, }, @@ -513,6 +528,7 @@ export const useTaskSessionStore = create((set, get) => ({ localUserEchoes: nextLocalEchoes, isPromptPending: true, awaitingPing: true, + promptStartedAt: ts, awaitingAgentOutput: true, }, }, @@ -624,6 +640,7 @@ export const useTaskSessionStore = create((set, get) => ({ localUserEchoes: nextLocalEchoes, isPromptPending: true, awaitingPing: true, + promptStartedAt: ts, awaitingAgentOutput: true, }, }, @@ -775,6 +792,7 @@ export const useTaskSessionStore = create((set, get) => ({ ...state.sessions[session.taskRunId], isPromptPending: false, awaitingPing: false, + promptStartedAt: undefined, awaitingAgentOutput: false, }, }, @@ -1037,7 +1055,11 @@ export const useTaskSessionStore = create((set, get) => ({ shouldPingForTurnComplete || shouldPingForTurnFailed; if (shouldPingNow && usePreferencesStore.getState().pingsEnabled) { - playMeepSound().catch(() => {}); + playCompletionSound( + undefined, + undefined, + completionPlaybackRate(existing?.promptStartedAt), + ).catch(() => {}); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } if (shouldPingForAwaitingInput) { @@ -1100,7 +1122,11 @@ export const useTaskSessionStore = create((set, get) => ({ }; }); if (shouldPing && usePreferencesStore.getState().pingsEnabled) { - playMeepSound().catch(() => {}); + playCompletionSound( + undefined, + undefined, + completionPlaybackRate(preState?.promptStartedAt), + ).catch(() => {}); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } if (shouldPing) { @@ -1161,6 +1187,7 @@ export const useTaskSessionStore = create((set, get) => ({ status: "connecting", isPromptPending: true, awaitingPing: true, + promptStartedAt: Date.now(), awaitingAgentOutput: true, }, }, diff --git a/apps/mobile/src/features/tasks/utils/playbackRate.test.ts b/apps/mobile/src/features/tasks/utils/playbackRate.test.ts new file mode 100644 index 0000000000..8390f395e2 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/playbackRate.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { playbackRateForTaskDuration } from "./playbackRate"; + +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); + }); + + it("decreases monotonically as duration grows", () => { + const durations = [ + 10 * 1000, + 45 * 1000, + 90 * 1000, + 2 * 60 * 1000, + 4 * 60 * 1000, + 10 * 60 * 1000, + 30 * 60 * 1000, + ]; + const rates = durations.map(playbackRateForTaskDuration); + for (let i = 1; i < rates.length; i++) { + expect(rates[i]).toBeLessThanOrEqual(rates[i - 1]); + } + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/playbackRate.ts b/apps/mobile/src/features/tasks/utils/playbackRate.ts new file mode 100644 index 0000000000..d6517d4405 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/playbackRate.ts @@ -0,0 +1,28 @@ +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; +} diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts index 0817354bb0..4ed1f10454 100644 --- a/apps/mobile/src/features/tasks/utils/sounds.ts +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -43,6 +43,7 @@ async function ensureAudioMode(): Promise { export async function playCompletionSound( sound?: CompletionSound, volume?: number, + playbackRate = 1, ): Promise { const prefs = usePreferencesStore.getState(); const which = sound ?? prefs.completionSound; @@ -51,6 +52,8 @@ export async function playCompletionSound( const { sound: player } = await Audio.Sound.createAsync(SOUND_ASSETS[which], { shouldPlay: true, volume: Math.max(0, Math.min(1, vol)), + rate: playbackRate, + shouldCorrectPitch: false, }); player.setOnPlaybackStatusUpdate((status) => { if (status.isLoaded && status.didJustFinish) { @@ -58,9 +61,3 @@ export async function playCompletionSound( } }); } - -// Kept as an alias so existing call sites continue to work; routes through -// the user's selected completion sound. -export function playMeepSound(): Promise { - return playCompletionSound(); -} From 07559b288f26535ac707b2fb5916c0fd39318790 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 1 Jul 2026 12:10:38 +0100 Subject: [PATCH 2/2] test(mobile): drop dead playbackRateForTaskDuration stub from sounds mock The store imports playbackRateForTaskDuration from ../utils/playbackRate, not ../utils/sounds, so stubbing it in the sounds mock had no effect. The function is pure and dependency-free, so let the real implementation run rather than add a redundant mock. Generated-By: PostHog Code Task-Id: dc58b11c-b3fc-4275-8102-9bffed29d638 --- apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index 36c2338ddb..77e5b515c4 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -12,7 +12,6 @@ vi.mock("../composer/attachments/buildCloudPrompt", () => ({ })); vi.mock("../utils/sounds", () => ({ playCompletionSound: vi.fn(() => Promise.resolve()), - playbackRateForTaskDuration: vi.fn(() => 1), })); vi.mock("@/features/notifications/lib/notifications", () => ({ presentLocalNotification: vi.fn(() => Promise.resolve()),