Skip to content

Commit fbca40f

Browse files
authored
feat(mobile): scale completion sound speed with task length (#3050)
1 parent 72496ff commit fbca40f

8 files changed

Lines changed: 150 additions & 11 deletions

File tree

apps/mobile/src/app/settings/index.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ export default function SettingsScreen() {
175175
const setCompletionSound = usePreferencesStore((s) => s.setCompletionSound);
176176
const completionVolume = usePreferencesStore((s) => s.completionVolume);
177177
const setCompletionVolume = usePreferencesStore((s) => s.setCompletionVolume);
178+
const scaleSoundWithTaskLength = usePreferencesStore(
179+
(s) => s.scaleSoundWithTaskLength,
180+
);
181+
const setScaleSoundWithTaskLength = usePreferencesStore(
182+
(s) => s.setScaleSoundWithTaskLength,
183+
);
178184
const defaultInitialTaskMode = usePreferencesStore(
179185
(s) => s.defaultInitialTaskMode,
180186
);
@@ -348,7 +354,6 @@ export default function SettingsScreen() {
348354
label="Sound volume"
349355
description="How loud the completion sound plays"
350356
onPress={() => setVolumeSheetOpen(true)}
351-
showDivider={false}
352357
rightSlot={
353358
<>
354359
<Text className="text-[14px] text-gray-11">
@@ -358,6 +363,17 @@ export default function SettingsScreen() {
358363
</>
359364
}
360365
/>
366+
<SettingsRow
367+
label="Scale sound speed with task length"
368+
description="Play the sound faster for quick tasks and slower for long ones"
369+
showDivider={false}
370+
rightSlot={
371+
<Switch
372+
value={scaleSoundWithTaskLength}
373+
onValueChange={setScaleSoundWithTaskLength}
374+
/>
375+
}
376+
/>
361377
</SettingsSection>
362378
) : null}
363379

apps/mobile/src/features/preferences/stores/preferencesStore.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ describe("preferencesStore reasoning effort", () => {
5555
});
5656
});
5757

58+
describe("preferencesStore scale sound with task length", () => {
59+
it("defaults to false", () => {
60+
expect(usePreferencesStore.getState().scaleSoundWithTaskLength).toBe(false);
61+
});
62+
63+
it.each([true, false])("updates to %s via setter", (enabled) => {
64+
usePreferencesStore.getState().setScaleSoundWithTaskLength(enabled);
65+
expect(usePreferencesStore.getState().scaleSoundWithTaskLength).toBe(
66+
enabled,
67+
);
68+
});
69+
70+
it("persists the value to storage", async () => {
71+
const AsyncStorage = (
72+
await import("@react-native-async-storage/async-storage")
73+
).default;
74+
usePreferencesStore.getState().setScaleSoundWithTaskLength(true);
75+
await Promise.resolve();
76+
const persisted = await AsyncStorage.getItem("posthog-preferences");
77+
expect(persisted).not.toBeNull();
78+
expect(JSON.parse(persisted as string).state.scaleSoundWithTaskLength).toBe(
79+
true,
80+
);
81+
});
82+
});
83+
5884
describe("preferencesStore font size", () => {
5985
it("defaults to a known preference with a 'default' scale of 1", () => {
6086
const { fontSize } = usePreferencesStore.getState();

apps/mobile/src/features/preferences/stores/preferencesStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ interface PreferencesState {
6464
setCompletionSound: (sound: CompletionSound) => void;
6565
completionVolume: number;
6666
setCompletionVolume: (volume: number) => void;
67+
scaleSoundWithTaskLength: boolean;
68+
setScaleSoundWithTaskLength: (enabled: boolean) => void;
6769

6870
defaultInitialTaskMode: InitialTaskMode;
6971
setDefaultInitialTaskMode: (mode: InitialTaskMode) => void;
@@ -102,6 +104,9 @@ export const usePreferencesStore = create<PreferencesState>()(
102104
set({
103105
completionVolume: Math.max(0, Math.min(100, Math.round(volume))),
104106
}),
107+
scaleSoundWithTaskLength: false,
108+
setScaleSoundWithTaskLength: (enabled) =>
109+
set({ scaleSoundWithTaskLength: enabled }),
105110

106111
defaultInitialTaskMode: "plan",
107112
setDefaultInitialTaskMode: (mode) =>
@@ -126,6 +131,7 @@ export const usePreferencesStore = create<PreferencesState>()(
126131
fontSize: state.fontSize,
127132
completionSound: state.completionSound,
128133
completionVolume: state.completionVolume,
134+
scaleSoundWithTaskLength: state.scaleSoundWithTaskLength,
129135
defaultInitialTaskMode: state.defaultInitialTaskMode,
130136
lastNewTaskMode: state.lastNewTaskMode,
131137
defaultReasoningEffort: state.defaultReasoningEffort,

apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ vi.mock("../composer/attachments/buildCloudPrompt", () => ({
1111
buildCloudPromptBlocks: vi.fn(() => Promise.resolve([])),
1212
}));
1313
vi.mock("../utils/sounds", () => ({
14-
playMeepSound: vi.fn(() => Promise.resolve()),
14+
playCompletionSound: vi.fn(() => Promise.resolve()),
1515
}));
1616
vi.mock("@/features/notifications/lib/notifications", () => ({
1717
presentLocalNotification: vi.fn(() => Promise.resolve()),

apps/mobile/src/features/tasks/stores/taskSessionStore.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
type Task,
2929
} from "../types";
3030
import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs";
31-
import { playMeepSound } from "../utils/sounds";
31+
import { playbackRateForTaskDuration } from "../utils/playbackRate";
32+
import { playCompletionSound } from "../utils/sounds";
3233
import { useAttachmentEchoStore } from "./attachmentEchoStore";
3334
import {
3435
combineQueuedMessages,
@@ -38,6 +39,16 @@ import { useTaskStore } from "./taskStore";
3839

3940
const log = logger.scope("task-session-store");
4041

42+
function completionPlaybackRate(promptStartedAt?: number): number {
43+
if (
44+
!usePreferencesStore.getState().scaleSoundWithTaskLength ||
45+
promptStartedAt == null
46+
) {
47+
return 1;
48+
}
49+
return playbackRateForTaskDuration(Date.now() - promptStartedAt);
50+
}
51+
4152
// Match historical `user_message_chunk` events (text-only, as the cloud
4253
// stores them) against locally-cached attachment echoes by position+text.
4354
// Echoes are written in send-order; we walk user messages in receive-order
@@ -289,6 +300,9 @@ export interface TaskSession {
289300
// we should play a sound when control returns. False when reconnecting
290301
// to an already-running task to avoid spurious pings.
291302
awaitingPing?: boolean;
303+
// Timestamp when the current prompt started on this device. Used to scale
304+
// the completion sound's playback rate by how long the turn ran.
305+
promptStartedAt?: number;
292306
// True after a user prompt is sent, cleared when the first piece of
293307
// agent output (tool call, message, etc.) arrives.
294308
awaitingAgentOutput?: boolean;
@@ -425,6 +439,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
425439
// us otherwise — the SSE watcher will refine these fields.
426440
isPromptPending: true,
427441
awaitingPing,
442+
promptStartedAt: awaitingPing ? Date.now() : undefined,
428443
awaitingAgentOutput: true,
429444
},
430445
},
@@ -513,6 +528,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
513528
localUserEchoes: nextLocalEchoes,
514529
isPromptPending: true,
515530
awaitingPing: true,
531+
promptStartedAt: ts,
516532
awaitingAgentOutput: true,
517533
},
518534
},
@@ -624,6 +640,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
624640
localUserEchoes: nextLocalEchoes,
625641
isPromptPending: true,
626642
awaitingPing: true,
643+
promptStartedAt: ts,
627644
awaitingAgentOutput: true,
628645
},
629646
},
@@ -775,6 +792,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
775792
...state.sessions[session.taskRunId],
776793
isPromptPending: false,
777794
awaitingPing: false,
795+
promptStartedAt: undefined,
778796
awaitingAgentOutput: false,
779797
},
780798
},
@@ -1037,7 +1055,11 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
10371055
shouldPingForTurnComplete ||
10381056
shouldPingForTurnFailed;
10391057
if (shouldPingNow && usePreferencesStore.getState().pingsEnabled) {
1040-
playMeepSound().catch(() => {});
1058+
playCompletionSound(
1059+
undefined,
1060+
undefined,
1061+
completionPlaybackRate(existing?.promptStartedAt),
1062+
).catch(() => {});
10411063
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
10421064
}
10431065
if (shouldPingForAwaitingInput) {
@@ -1100,7 +1122,11 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
11001122
};
11011123
});
11021124
if (shouldPing && usePreferencesStore.getState().pingsEnabled) {
1103-
playMeepSound().catch(() => {});
1125+
playCompletionSound(
1126+
undefined,
1127+
undefined,
1128+
completionPlaybackRate(preState?.promptStartedAt),
1129+
).catch(() => {});
11041130
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
11051131
}
11061132
if (shouldPing) {
@@ -1161,6 +1187,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
11611187
status: "connecting",
11621188
isPromptPending: true,
11631189
awaitingPing: true,
1190+
promptStartedAt: Date.now(),
11641191
awaitingAgentOutput: true,
11651192
},
11661193
},
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { playbackRateForTaskDuration } from "./playbackRate";
4+
5+
describe("playbackRateForTaskDuration", () => {
6+
it.each([
7+
["below the fast floor (10s)", 10 * 1000, 3],
8+
["at the fast floor (30s)", 30 * 1000, 3],
9+
["geometric mid of the fast ramp (60s)", 60 * 1000, Math.sqrt(3)],
10+
["normal band start (2min)", 2 * 60 * 1000, 1],
11+
["normal band end (4min)", 4 * 60 * 1000, 1],
12+
[
13+
"geometric mid of the slow ramp",
14+
Math.sqrt(4 * 60 * 1000 * (30 * 60 * 1000)),
15+
Math.sqrt(1 / 3),
16+
],
17+
["at the slow ceiling (30min)", 30 * 60 * 1000, 1 / 3],
18+
["beyond the slow ceiling (2h)", 2 * 60 * 60 * 1000, 1 / 3],
19+
["NaN (non-finite) → fast rate", Number.NaN, 3],
20+
])("%s → %f", (_label, durationMs, expected) => {
21+
expect(playbackRateForTaskDuration(durationMs)).toBeCloseTo(expected, 5);
22+
});
23+
24+
it("decreases monotonically as duration grows", () => {
25+
const durations = [
26+
10 * 1000,
27+
45 * 1000,
28+
90 * 1000,
29+
2 * 60 * 1000,
30+
4 * 60 * 1000,
31+
10 * 60 * 1000,
32+
30 * 60 * 1000,
33+
];
34+
const rates = durations.map(playbackRateForTaskDuration);
35+
for (let i = 1; i < rates.length; i++) {
36+
expect(rates[i]).toBeLessThanOrEqual(rates[i - 1]);
37+
}
38+
});
39+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const MIN_RATE = 1 / 3;
2+
const MAX_RATE = 3;
3+
const FAST_MS = 30 * 1000;
4+
const NORMAL_START_MS = 2 * 60 * 1000;
5+
const NORMAL_END_MS = 4 * 60 * 1000;
6+
const SLOW_MS = 30 * 60 * 1000;
7+
8+
// Maps a task's duration to an audio playback rate so a quick task rings fast
9+
// (and high-pitched) while a long one drags slow (and low). Anchored at: <=30s
10+
// -> 3x, the 2-4min "normal" band -> 1x, >=30min -> 1/3x, with smooth
11+
// log-interpolation across the two ramps so the rate doesn't jump at the edges.
12+
export function playbackRateForTaskDuration(durationMs: number): number {
13+
if (!Number.isFinite(durationMs) || durationMs <= FAST_MS) return MAX_RATE;
14+
if (durationMs >= SLOW_MS) return MIN_RATE;
15+
if (durationMs >= NORMAL_START_MS && durationMs <= NORMAL_END_MS) return 1;
16+
17+
if (durationMs < NORMAL_START_MS) {
18+
const frac =
19+
(Math.log(durationMs) - Math.log(FAST_MS)) /
20+
(Math.log(NORMAL_START_MS) - Math.log(FAST_MS));
21+
return MAX_RATE ** (1 - frac);
22+
}
23+
24+
const frac =
25+
(Math.log(durationMs) - Math.log(NORMAL_END_MS)) /
26+
(Math.log(SLOW_MS) - Math.log(NORMAL_END_MS));
27+
return MIN_RATE ** frac;
28+
}

apps/mobile/src/features/tasks/utils/sounds.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async function ensureAudioMode(): Promise<void> {
4343
export async function playCompletionSound(
4444
sound?: CompletionSound,
4545
volume?: number,
46+
playbackRate = 1,
4647
): Promise<void> {
4748
const prefs = usePreferencesStore.getState();
4849
const which = sound ?? prefs.completionSound;
@@ -51,16 +52,12 @@ export async function playCompletionSound(
5152
const { sound: player } = await Audio.Sound.createAsync(SOUND_ASSETS[which], {
5253
shouldPlay: true,
5354
volume: Math.max(0, Math.min(1, vol)),
55+
rate: playbackRate,
56+
shouldCorrectPitch: false,
5457
});
5558
player.setOnPlaybackStatusUpdate((status) => {
5659
if (status.isLoaded && status.didJustFinish) {
5760
player.unloadAsync();
5861
}
5962
});
6063
}
61-
62-
// Kept as an alias so existing call sites continue to work; routes through
63-
// the user's selected completion sound.
64-
export function playMeepSound(): Promise<void> {
65-
return playCompletionSound();
66-
}

0 commit comments

Comments
 (0)