Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/mobile/src/app/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -348,7 +354,6 @@ export default function SettingsScreen() {
label="Sound volume"
description="How loud the completion sound plays"
onPress={() => setVolumeSheetOpen(true)}
showDivider={false}
rightSlot={
<>
<Text className="text-[14px] text-gray-11">
Expand All @@ -358,6 +363,17 @@ export default function SettingsScreen() {
</>
}
/>
<SettingsRow
label="Scale sound speed with task length"
description="Play the sound faster for quick tasks and slower for long ones"
showDivider={false}
rightSlot={
<Switch
value={scaleSoundWithTaskLength}
onValueChange={setScaleSoundWithTaskLength}
/>
}
/>
</SettingsSection>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,6 +104,9 @@ export const usePreferencesStore = create<PreferencesState>()(
set({
completionVolume: Math.max(0, Math.min(100, Math.round(volume))),
}),
scaleSoundWithTaskLength: false,
setScaleSoundWithTaskLength: (enabled) =>
set({ scaleSoundWithTaskLength: enabled }),

defaultInitialTaskMode: "plan",
setDefaultInitialTaskMode: (mode) =>
Expand All @@ -126,6 +131,7 @@ export const usePreferencesStore = create<PreferencesState>()(
fontSize: state.fontSize,
completionSound: state.completionSound,
completionVolume: state.completionVolume,
scaleSoundWithTaskLength: state.scaleSoundWithTaskLength,
defaultInitialTaskMode: state.defaultInitialTaskMode,
lastNewTaskMode: state.lastNewTaskMode,
defaultReasoningEffort: state.defaultReasoningEffort,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}));
Comment thread
Gilbert09 marked this conversation as resolved.
vi.mock("@/features/notifications/lib/notifications", () => ({
presentLocalNotification: vi.fn(() => Promise.resolve()),
Expand Down
33 changes: 30 additions & 3 deletions apps/mobile/src/features/tasks/stores/taskSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -425,6 +439,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
// us otherwise — the SSE watcher will refine these fields.
isPromptPending: true,
awaitingPing,
promptStartedAt: awaitingPing ? Date.now() : undefined,
awaitingAgentOutput: true,
},
},
Expand Down Expand Up @@ -513,6 +528,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
localUserEchoes: nextLocalEchoes,
isPromptPending: true,
awaitingPing: true,
promptStartedAt: ts,
awaitingAgentOutput: true,
},
},
Expand Down Expand Up @@ -624,6 +640,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
localUserEchoes: nextLocalEchoes,
isPromptPending: true,
awaitingPing: true,
promptStartedAt: ts,
awaitingAgentOutput: true,
},
},
Expand Down Expand Up @@ -775,6 +792,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
...state.sessions[session.taskRunId],
isPromptPending: false,
awaitingPing: false,
promptStartedAt: undefined,
awaitingAgentOutput: false,
},
},
Expand Down Expand Up @@ -1037,7 +1055,11 @@ export const useTaskSessionStore = create<TaskSessionStore>((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) {
Expand Down Expand Up @@ -1100,7 +1122,11 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
};
});
if (shouldPing && usePreferencesStore.getState().pingsEnabled) {
playMeepSound().catch(() => {});
playCompletionSound(
undefined,
undefined,
completionPlaybackRate(preState?.promptStartedAt),
).catch(() => {});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
if (shouldPing) {
Expand Down Expand Up @@ -1161,6 +1187,7 @@ export const useTaskSessionStore = create<TaskSessionStore>((set, get) => ({
status: "connecting",
isPromptPending: true,
awaitingPing: true,
promptStartedAt: Date.now(),
awaitingAgentOutput: true,
},
},
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/features/tasks/utils/playbackRate.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
});
});
28 changes: 28 additions & 0 deletions apps/mobile/src/features/tasks/utils/playbackRate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 3 additions & 6 deletions apps/mobile/src/features/tasks/utils/sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async function ensureAudioMode(): Promise<void> {
export async function playCompletionSound(
sound?: CompletionSound,
volume?: number,
playbackRate = 1,
): Promise<void> {
const prefs = usePreferencesStore.getState();
const which = sound ?? prefs.completionSound;
Expand All @@ -51,16 +52,12 @@ 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) {
player.unloadAsync();
}
});
}

// Kept as an alias so existing call sites continue to work; routes through
// the user's selected completion sound.
export function playMeepSound(): Promise<void> {
return playCompletionSound();
}
Loading