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..77e5b515c4 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -11,7 +11,7 @@ vi.mock("../composer/attachments/buildCloudPrompt", () => ({ buildCloudPromptBlocks: vi.fn(() => Promise.resolve([])), })); vi.mock("../utils/sounds", () => ({ - playMeepSound: vi.fn(() => Promise.resolve()), + playCompletionSound: vi.fn(() => Promise.resolve()), })); 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(); -}