mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-08-14 00:58:04 +00:00
Add opt-in office sound cues
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
co-authored by
Luke The Dev
parent
f2a2fbe57c
commit
727a7e8a9e
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user