Add opt-in office sound cues

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-27 02:23:59 +00:00
co-authored by Luke The Dev
parent f2a2fbe57c
commit 727a7e8a9e
9 changed files with 519 additions and 0 deletions
@@ -39,10 +39,15 @@ type SettingsPanelProps = {
voiceRepliesVoiceId: string | null;
voiceRepliesSpeed: number;
voiceRepliesLoaded: boolean;
officeSoundEnabled: boolean;
officeSoundVolume: number;
officeSoundLoaded: boolean;
onVoiceRepliesToggle: (enabled: boolean) => void;
onVoiceRepliesVoiceChange: (voiceId: string | null) => void;
onVoiceRepliesSpeedChange: (speed: number) => void;
onVoiceRepliesPreview: (voiceId: string | null, voiceName: string) => void;
onOfficeSoundToggle: (enabled: boolean) => void;
onOfficeSoundVolumeChange: (volume: number) => void;
};
export function SettingsPanel({
@@ -78,10 +83,15 @@ export function SettingsPanel({
voiceRepliesVoiceId,
voiceRepliesSpeed,
voiceRepliesLoaded,
officeSoundEnabled,
officeSoundVolume,
officeSoundLoaded,
onVoiceRepliesToggle,
onVoiceRepliesVoiceChange,
onVoiceRepliesSpeedChange,
onVoiceRepliesPreview,
onOfficeSoundToggle,
onOfficeSoundVolumeChange,
}: SettingsPanelProps) {
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
const normalizedGatewayToken = gatewayToken ?? "";
@@ -236,6 +246,59 @@ export function SettingsPanel({
mappings={stateAnimationMappings}
onChange={onStateAnimationMappingsChange}
/>
<div className="ui-settings-row mt-3 flex min-h-[72px] items-center justify-between gap-6 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-label="Office sound cues"
aria-checked={officeSoundEnabled}
className={`ui-switch self-center ${officeSoundEnabled ? "ui-switch--on" : ""}`}
onClick={() => onOfficeSoundToggle(!officeSoundEnabled)}
disabled={!officeSoundLoaded}
>
<span className="ui-switch-thumb" />
</button>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-white">Office sound cues</span>
<span className="text-[10px] text-white/80">
Play subtle cues for runs, errors, and external events.
</span>
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{officeSoundLoaded ? (officeSoundEnabled ? "On" : "Off") : "Loading"}
</span>
</div>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-medium text-white">Sound volume</div>
<div className="mt-1 text-[10px] text-white/75">
Browser audio starts after the first user gesture.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{Math.round(officeSoundVolume * 100)}%
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={officeSoundVolume}
disabled={!officeSoundLoaded}
onChange={(event) =>
onOfficeSoundVolumeChange(Number.parseFloat(event.target.value))
}
className="mt-3 h-2 w-full cursor-pointer appearance-none rounded-full bg-cyan-500/15 accent-cyan-400"
/>
<div className="mt-1 flex items-center justify-between text-[10px] text-white/45">
<span>Muted</span>
<span>Louder</span>
</div>
</div>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import type { OfficeExternalEvent } from "@/lib/office/externalEventsStore";
import type { RunRecord } from "@/features/office/hooks/useRunLog";
import {
playOfficeSoundCue,
resolveOfficeSoundCueForExternalEvent,
} from "@/features/office/sound/officeSound";
type UseOfficeSoundEffectsParams = {
enabled: boolean;
volume: number;
latestExternalEvent: OfficeExternalEvent | null;
runLog: RunRecord[];
playCue?: typeof playOfficeSoundCue;
};
export const useOfficeSoundEffects = ({
enabled,
volume,
latestExternalEvent,
runLog,
playCue = playOfficeSoundCue,
}: UseOfficeSoundEffectsParams) => {
const lastExternalEventIdRef = useRef<string | null>(null);
const runSignature = useMemo(
() =>
runLog
.slice(0, 8)
.map((run) => `${run.runId}:${run.endedAt ?? "running"}:${run.outcome ?? "pending"}`)
.join("|"),
[runLog],
);
const lastRunSignatureRef = useRef(runSignature);
useEffect(() => {
if (!enabled || !latestExternalEvent) return;
if (lastExternalEventIdRef.current === latestExternalEvent.id) return;
lastExternalEventIdRef.current = latestExternalEvent.id;
const cueId = resolveOfficeSoundCueForExternalEvent({
effect: latestExternalEvent.effect,
});
if (!cueId) return;
void playCue({ cue: cueId, volume });
}, [enabled, latestExternalEvent, playCue, volume]);
useEffect(() => {
if (!enabled) {
lastRunSignatureRef.current = runSignature;
return;
}
if (lastRunSignatureRef.current === runSignature) return;
const previous = lastRunSignatureRef.current;
lastRunSignatureRef.current = runSignature;
if (!previous) return;
const latestRun = runLog[0] ?? null;
if (!latestRun) return;
if (latestRun.endedAt === null) {
void playCue({ cue: "task-start", volume });
return;
}
void playCue({
cue: latestRun.outcome === "error" ? "alarm" : "task-complete",
volume,
});
}, [enabled, playCue, runLog, runSignature, volume]);
};
@@ -185,6 +185,7 @@ import {
} from "@/features/onboarding";
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
import { useStudioOfficePreference } from "@/hooks/useStudioOfficePreference";
import { useStudioOfficeSoundPreference } from "@/hooks/useStudioOfficeSoundPreference";
import { isRemoteOfficeAgentId } from "@/features/retro-office/core/district";
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
import {
@@ -192,6 +193,7 @@ import {
type VoiceSendPayload,
} from "@/hooks/useVoiceRecorder";
import { useVoiceReplyPlayback } from "@/hooks/useVoiceReplyPlayback";
import { useOfficeSoundEffects } from "@/features/office/hooks/useOfficeSoundEffects";
import {
buildOfficeAnimationState,
clearOfficeAnimationTriggerHold,
@@ -1377,6 +1379,16 @@ export function OfficeScreen({
gatewayUrl,
settingsCoordinator,
});
const {
loaded: officeSoundLoaded,
enabled: officeSoundEnabled,
volume: officeSoundVolume,
setEnabled: setOfficeSoundEnabled,
setVolume: setOfficeSoundVolume,
} = useStudioOfficeSoundPreference({
gatewayUrl,
settingsCoordinator,
});
const {
enqueue: enqueueVoiceReply,
preview: previewVoiceReply,
@@ -3138,7 +3150,14 @@ export function OfficeScreen({
const {
events: externalOfficeEvents,
feedEvents: externalOfficeFeedEvents,
latestNewEvent: latestExternalOfficeEvent,
} = useOfficeExternalEvents();
useOfficeSoundEffects({
enabled: officeSoundEnabled,
volume: officeSoundVolume,
runLog,
latestExternalEvent: latestExternalOfficeEvent,
});
const operationsFeedEvents = useMemo(
() => [...externalOfficeFeedEvents, ...feedEvents],
[externalOfficeFeedEvents, feedEvents],
@@ -4903,12 +4922,17 @@ export function OfficeScreen({
remoteLayoutSnapshot={remoteOfficeLayoutSnapshot}
remoteOfficeTokenConfigured={remoteOfficeTokenConfigured}
stateAnimationMappings={stateAnimationMappings}
officeSoundEnabled={officeSoundEnabled}
officeSoundLoaded={officeSoundLoaded}
officeSoundVolume={officeSoundVolume}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
voiceRepliesLoaded={voiceRepliesLoaded}
onOfficeTitleChange={setOfficeTitle}
onStateAnimationMappingsChange={setStateAnimationMappings}
onOfficeSoundToggle={setOfficeSoundEnabled}
onOfficeSoundVolumeChange={setOfficeSoundVolume}
onRemoteOfficeEnabledChange={setRemoteOfficeEnabled}
onRemoteOfficeSourceKindChange={setRemoteOfficeSourceKind}
onRemoteOfficeLabelChange={setRemoteOfficeLabel}
+106
View File
@@ -0,0 +1,106 @@
"use client";
export type OfficeSoundCue =
| "task-start"
| "task-complete"
| "alarm"
| "doorbell"
| "chime";
type CueTone = {
frequency: number;
durationMs: number;
delayMs?: number;
type?: OscillatorType;
gain?: number;
};
const CUE_TONES: Record<OfficeSoundCue, CueTone[]> = {
"task-start": [
{ frequency: 660, durationMs: 70, type: "triangle", gain: 0.035 },
{ frequency: 880, durationMs: 90, delayMs: 80, type: "triangle", gain: 0.032 },
],
"task-complete": [
{ frequency: 784, durationMs: 80, type: "sine", gain: 0.032 },
{ frequency: 1046, durationMs: 120, delayMs: 90, type: "sine", gain: 0.03 },
],
alarm: [
{ frequency: 220, durationMs: 140, type: "sawtooth", gain: 0.035 },
{ frequency: 196, durationMs: 140, delayMs: 160, type: "sawtooth", gain: 0.035 },
],
doorbell: [
{ frequency: 523, durationMs: 130, type: "sine", gain: 0.035 },
{ frequency: 659, durationMs: 170, delayMs: 145, type: "sine", gain: 0.032 },
],
chime: [
{ frequency: 880, durationMs: 100, type: "triangle", gain: 0.026 },
{ frequency: 1175, durationMs: 140, delayMs: 110, type: "triangle", gain: 0.024 },
],
};
export const resolveOfficeSoundCueForExternalEvent = (params: {
effect: string | null | undefined;
soundCueId?: string | null;
}): OfficeSoundCue | null => {
const cue = params.soundCueId?.trim() ?? "";
if (cue === "alarm" || cue === "doorbell" || cue === "chime") return cue;
if (params.effect === "alarm") return "alarm";
if (params.effect === "doorbell") return "doorbell";
if (params.effect === "confetti") return "chime";
return null;
};
export const resolveOfficeSoundCueForRunTransition = (params: {
wasRunning: boolean;
isRunning: boolean;
isError: boolean;
}): OfficeSoundCue | null => {
if (params.isError) return "alarm";
if (!params.wasRunning && params.isRunning) return "task-start";
if (params.wasRunning && !params.isRunning) return "task-complete";
return null;
};
export const playOfficeSoundCue = async (params: {
cue: OfficeSoundCue;
volume: number;
audioContextRef?: React.MutableRefObject<AudioContext | null>;
}) => {
if (typeof window === "undefined") return false;
const AudioContextCtor =
window.AudioContext ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).webkitAudioContext as typeof AudioContext | undefined);
if (!AudioContextCtor) return false;
if (!params.audioContextRef) return false;
if (!params.audioContextRef.current) {
params.audioContextRef.current = new AudioContextCtor();
}
const audioContext = params.audioContextRef.current;
if (audioContext.state === "suspended") {
try {
await audioContext.resume();
} catch {
return false;
}
}
const volume = Math.max(0, Math.min(1, params.volume));
const tones = CUE_TONES[params.cue] ?? [];
const now = audioContext.currentTime;
for (const tone of tones) {
const start = now + (tone.delayMs ?? 0) / 1000;
const duration = tone.durationMs / 1000;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = tone.type ?? "sine";
oscillator.frequency.setValueAtTime(tone.frequency, start);
gainNode.gain.setValueAtTime(0.0001, start);
gainNode.gain.exponentialRampToValueAtTime((tone.gain ?? 0.03) * volume, start + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
return true;
};
@@ -2278,6 +2278,9 @@ export function RetroOffice3D({
remoteLayoutSnapshot = null,
remoteOfficeTokenConfigured = false,
stateAnimationMappings = [],
officeSoundEnabled = false,
officeSoundVolume = 0.45,
officeSoundLoaded = false,
voiceRepliesEnabled = false,
voiceRepliesVoiceId = null,
voiceRepliesSpeed = 1,
@@ -2290,6 +2293,8 @@ export function RetroOffice3D({
onRemoteOfficeGatewayUrlChange,
onRemoteOfficeTokenChange,
onStateAnimationMappingsChange,
onOfficeSoundToggle,
onOfficeSoundVolumeChange,
onVoiceRepliesToggle,
onVoiceRepliesVoiceChange,
onVoiceRepliesSpeedChange,
@@ -2394,6 +2399,9 @@ export function RetroOffice3D({
remoteLayoutSnapshot?: OfficeLayoutSnapshot | null;
remoteOfficeTokenConfigured?: boolean;
stateAnimationMappings?: OfficeStateAnimationMapping[];
officeSoundEnabled?: boolean;
officeSoundVolume?: number;
officeSoundLoaded?: boolean;
voiceRepliesEnabled?: boolean;
voiceRepliesVoiceId?: string | null;
voiceRepliesSpeed?: number;
@@ -2408,6 +2416,8 @@ export function RetroOffice3D({
onRemoteOfficeGatewayUrlChange?: (url: string) => void;
onRemoteOfficeTokenChange?: (token: string) => void;
onStateAnimationMappingsChange?: (mappings: OfficeStateAnimationMapping[]) => void;
onOfficeSoundToggle?: (enabled: boolean) => void;
onOfficeSoundVolumeChange?: (volume: number) => void;
onVoiceRepliesToggle?: (enabled: boolean) => void;
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
onVoiceRepliesSpeedChange?: (speed: number) => void;
@@ -7141,6 +7151,13 @@ export function RetroOffice3D({
onStateAnimationMappingsChange={(mappings) =>
onStateAnimationMappingsChange?.(mappings)
}
officeSoundEnabled={officeSoundEnabled}
officeSoundVolume={officeSoundVolume}
officeSoundLoaded={officeSoundLoaded}
onOfficeSoundToggle={(enabled) => onOfficeSoundToggle?.(enabled)}
onOfficeSoundVolumeChange={(volume) =>
onOfficeSoundVolumeChange?.(volume)
}
voiceRepliesEnabled={voiceRepliesEnabled}
voiceRepliesVoiceId={voiceRepliesVoiceId}
voiceRepliesSpeed={voiceRepliesSpeed}
@@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
import {
defaultStudioOfficeSoundPreference,
resolveOfficeSoundPreference,
type StudioOfficeSoundPreference,
} from "@/lib/studio/settings";
type UseStudioOfficeSoundPreferenceParams = {
gatewayUrl: string;
settingsCoordinator: StudioSettingsCoordinator;
};
export const useStudioOfficeSoundPreference = ({
gatewayUrl,
settingsCoordinator,
}: UseStudioOfficeSoundPreferenceParams) => {
const [preference, setPreference] = useState<StudioOfficeSoundPreference>(
defaultStudioOfficeSoundPreference(),
);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const gatewayKey = gatewayUrl.trim();
if (!gatewayKey) {
setPreference(defaultStudioOfficeSoundPreference());
setLoaded(true);
return;
}
setLoaded(false);
const loadPreference = async () => {
try {
const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 });
if (cancelled) return;
setPreference(
settings
? resolveOfficeSoundPreference(settings, gatewayKey)
: defaultStudioOfficeSoundPreference(),
);
} catch (error) {
if (!cancelled) {
console.error("Failed to load office sound preference.", error);
setPreference(defaultStudioOfficeSoundPreference());
}
} finally {
if (!cancelled) setLoaded(true);
}
};
void loadPreference();
return () => {
cancelled = true;
};
}, [gatewayUrl, settingsCoordinator]);
const setEnabled = useCallback(
(enabled: boolean) => {
const gatewayKey = gatewayUrl.trim();
setPreference((current) => ({ ...current, enabled }));
if (!gatewayKey) return;
settingsCoordinator.schedulePatch(
{ officeSound: { [gatewayKey]: { enabled } } },
0,
);
},
[gatewayUrl, settingsCoordinator],
);
const setVolume = useCallback(
(volume: number) => {
const gatewayKey = gatewayUrl.trim();
setPreference((current) => ({ ...current, volume }));
if (!gatewayKey) return;
settingsCoordinator.schedulePatch(
{ officeSound: { [gatewayKey]: { volume } } },
0,
);
},
[gatewayUrl, settingsCoordinator],
);
return {
loaded,
preference,
enabled: preference.enabled,
volume: preference.volume,
setEnabled,
setVolume,
};
};
+81
View File
@@ -148,6 +148,16 @@ export type StudioVoiceRepliesPreferencePatch = {
speed?: number;
};
export type StudioOfficeSoundPreference = {
enabled: boolean;
volume: number;
};
export type StudioOfficeSoundPreferencePatch = {
enabled?: boolean;
volume?: number;
};
export type StudioOfficePreference = {
title: string;
stateAnimationMappings: OfficeStateAnimationMapping[];
@@ -262,6 +272,7 @@ export type StudioSettings = {
deskAssignments: Record<string, StudioDeskAssignments>;
analytics: Record<string, StudioAnalyticsPreference>;
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
officeSound: Record<string, StudioOfficeSoundPreference>;
office: Record<string, StudioOfficePreference>;
standup?: Record<string, StudioStandupPreference>;
taskBoard?: Record<string, StudioTaskBoardPreference>;
@@ -270,6 +281,7 @@ export type StudioSettings = {
export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "office" | "standup"> & {
gateway: StudioGatewaySettingsPublic | null;
office: Record<string, StudioOfficePreferencePublic>;
officeSound: Record<string, StudioOfficeSoundPreference>;
standup?: Record<string, StudioStandupPreferencePublic>;
taskBoard?: Record<string, StudioTaskBoardPreferencePublic>;
};
@@ -283,6 +295,7 @@ export type StudioSettingsPatch = {
deskAssignments?: Record<string, Record<string, string | null> | null>;
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
officeSound?: Record<string, StudioOfficeSoundPreferencePatch | null>;
office?: Record<string, StudioOfficePreferencePatch | null>;
standup?: Record<string, StudioStandupPreferencePatch | null>;
taskBoard?: Record<string, StudioTaskBoardPreferencePatch | null>;
@@ -420,6 +433,12 @@ export const defaultStudioVoiceRepliesPreference =
speed: 1,
});
export const defaultStudioOfficeSoundPreference =
(): StudioOfficeSoundPreference => ({
enabled: false,
volume: 0.45,
});
export const defaultStudioStandupScheduleConfig = (): StandupScheduleConfig => ({
enabled: false,
cronExpr: "0 9 * * 1-5",
@@ -476,6 +495,11 @@ const normalizeVoiceReplySpeed = (value: unknown, fallback: number = 1): number
return Math.min(1.2, Math.max(0.7, value));
};
const normalizeOfficeSoundVolume = (value: unknown, fallback: number = 0.45): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(1, Math.max(0, value));
};
const normalizeOptionalIsoString = (
value: unknown,
fallback: string | null = null
@@ -1175,6 +1199,30 @@ const normalizeVoiceReplies = (
return voiceReplies;
};
const normalizeOfficeSoundPreference = (
value: unknown,
fallback: StudioOfficeSoundPreference = defaultStudioOfficeSoundPreference()
): StudioOfficeSoundPreference => {
if (!isRecord(value)) return fallback;
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : fallback.enabled,
volume: normalizeOfficeSoundVolume(value.volume, fallback.volume),
};
};
const normalizeOfficeSound = (
value: unknown
): Record<string, StudioOfficeSoundPreference> => {
if (!isRecord(value)) return {};
const officeSound: Record<string, StudioOfficeSoundPreference> = {};
for (const [gatewayKeyRaw, soundRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
officeSound[gatewayKey] = normalizeOfficeSoundPreference(soundRaw);
}
return officeSound;
};
const normalizeOfficePreference = (
value: unknown,
fallback: StudioOfficePreference = defaultStudioOfficePreference()
@@ -1289,6 +1337,7 @@ export const defaultStudioSettings = (): StudioSettings => ({
deskAssignments: {},
analytics: {},
voiceReplies: {},
officeSound: {},
office: {},
standup: {},
taskBoard: {},
@@ -1380,6 +1429,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
const analytics = normalizeAnalytics(raw.analytics);
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
const officeSound = normalizeOfficeSound(raw.officeSound);
const office = normalizeOffice(raw.office);
const standup = normalizeStandup(raw.standup);
const taskBoard = normalizeTaskBoard(raw.taskBoard);
@@ -1393,6 +1443,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
deskAssignments,
analytics,
voiceReplies,
officeSound,
office,
standup,
taskBoard,
@@ -1415,6 +1466,7 @@ export const mergeStudioSettings = (
const nextDeskAssignments = { ...current.deskAssignments };
const nextAnalytics = { ...current.analytics };
const nextVoiceReplies = { ...current.voiceReplies };
const nextOfficeSound = { ...current.officeSound };
const nextOffice = { ...current.office };
const nextStandup = { ...(current.standup ?? {}) };
const nextTaskBoard = { ...(current.taskBoard ?? {}) };
@@ -1541,6 +1593,25 @@ export const mergeStudioSettings = (
);
}
}
if (patch.officeSound) {
for (const [gatewayKeyRaw, officeSoundPatch] of Object.entries(patch.officeSound)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (officeSoundPatch === null) {
delete nextOfficeSound[gatewayKey];
continue;
}
const fallback =
nextOfficeSound[gatewayKey] ?? defaultStudioOfficeSoundPreference();
nextOfficeSound[gatewayKey] = normalizeOfficeSoundPreference(
{
...fallback,
...officeSoundPatch,
},
fallback,
);
}
}
if (patch.office) {
for (const [gatewayKeyRaw, officePatch] of Object.entries(patch.office)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
@@ -1639,6 +1710,7 @@ export const mergeStudioSettings = (
deskAssignments: nextDeskAssignments,
analytics: nextAnalytics,
voiceReplies: nextVoiceReplies,
officeSound: nextOfficeSound,
office: nextOffice,
standup: nextStandup,
taskBoard: nextTaskBoard,
@@ -1711,6 +1783,15 @@ export const resolveVoiceRepliesPreference = (
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
};
export const resolveOfficeSoundPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioOfficeSoundPreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioOfficeSoundPreference();
return settings.officeSound[gatewayKey] ?? defaultStudioOfficeSoundPreference();
};
export const resolveOfficePreference = (
settings: StudioSettings,
gatewayUrl: string
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
resolveOfficeSoundCueForExternalEvent,
resolveOfficeSoundCueForRunTransition,
} from "@/features/office/sound/officeSound";
describe("office sound cues", () => {
it("maps external event effects to cue ids", () => {
expect(resolveOfficeSoundCueForExternalEvent({ effect: "confetti" })).toBe("chime");
expect(resolveOfficeSoundCueForExternalEvent({ effect: "alarm" })).toBe("alarm");
expect(resolveOfficeSoundCueForExternalEvent({ effect: "doorbell" })).toBe("doorbell");
expect(resolveOfficeSoundCueForExternalEvent({ effect: null })).toBeNull();
});
it("maps run transitions to cue ids", () => {
expect(
resolveOfficeSoundCueForRunTransition({
wasRunning: false,
isRunning: true,
isError: false,
}),
).toBe("task-start");
expect(
resolveOfficeSoundCueForRunTransition({
wasRunning: true,
isRunning: false,
isError: false,
}),
).toBe("task-complete");
expect(
resolveOfficeSoundCueForRunTransition({
wasRunning: true,
isRunning: false,
isError: true,
}),
).toBe("alarm");
});
});
+29
View File
@@ -212,6 +212,35 @@ describe("studio settings normalization", () => {
]);
});
it("normalizes and merges office sound preferences per gateway", () => {
const normalized = normalizeStudioSettings({
officeSound: {
" [REDACTED] ": {
enabled: true,
volume: 2,
},
},
});
expect(normalized.officeSound["[REDACTED]"]).toEqual({
enabled: true,
volume: 1,
});
const merged = mergeStudioSettings(normalized, {
officeSound: {
"[REDACTED]": {
volume: 0.25,
},
},
});
expect(merged.officeSound["[REDACTED]"]).toEqual({
enabled: true,
volume: 0.25,
});
});
it("merges office title patches", () => {
const current = normalizeStudioSettings({
office: {