refactor(frontend): isolate audio meters and apply settings on save

This commit is contained in:
2026-10-07 09:29:20 -03:00
parent 09bf6ece00
commit 0c57f7c76d
7 changed files with 240 additions and 105 deletions
+3
View File
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
### Fixed ### Fixed
- Isolate microphone meter subscriptions from the application tree. Apply draft settings only
on Save, preserve explicitly live noise calibration, and persist/send only relevant changes.
- Keep the voice UI speaking until buffered playback finishes, flush short final audio chunks, - Keep the voice UI speaking until buffered playback finishes, flush short final audio chunks,
and exclude response processing from the user inactivity timeout. and exclude response processing from the user inactivity timeout.
+15 -1
View File
@@ -62,8 +62,9 @@ describe("React application", () => {
await user.click(await screen.findByRole("button", { name: "Configuración" })); await user.click(await screen.findByRole("button", { name: "Configuración" }));
expect(document.documentElement.dataset.theme).toBe("white"); expect(document.documentElement.dataset.theme).toBe("white");
await user.selectOptions(screen.getByRole("combobox"), "Serena"); await user.selectOptions(screen.getByRole("combobox"), "Serena");
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena"); expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
await user.click(screen.getByRole("button", { name: "Guardar" })); await user.click(screen.getByRole("button", { name: "Guardar" }));
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
view.unmount(); view.unmount();
render( render(
@@ -74,6 +75,19 @@ describe("React application", () => {
await user.click(await screen.findByRole("button", { name: "Configuración" })); await user.click(await screen.findByRole("button", { name: "Configuración" }));
expect(screen.getByRole("combobox")).toHaveValue("Serena"); expect(screen.getByRole("combobox")).toHaveValue("Serena");
}); });
it("discards unsaved settings on cancel while keeping live noise-gate calibration", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Configuración" }));
await user.selectOptions(screen.getByRole("combobox"), "Serena");
fireEvent.change(screen.getByRole("slider"), { target: { value: "-35" } });
await user.click(screen.getByRole("button", { name: "Cancelar" }));
await user.click(screen.getByRole("button", { name: "Configuración" }));
expect(screen.getByRole("combobox")).toHaveValue("Aiden");
expect(screen.getByRole("slider")).toHaveValue("-35");
expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
expect(localStorage.getItem("s2s.ws.noiseGate")).toBe("-35");
});
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => { it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useSyncExternalStore } from "react";
import { initials, profileName } from "../lib/context"; import { initials, profileName } from "../lib/context";
import { profileRows } from "../lib/profile"; import { profileRows } from "../lib/profile";
@@ -13,15 +13,17 @@ interface Props {
} }
export function SettingsDialog({ state, controller, onClose }: Props) { export function SettingsDialog({ state, controller, onClose }: Props) {
const { config, settings } = state; const { config, settings } = state;
const meter = Math.max( const [draft, setDraft] = useState(() => ({
0, directUrl: settings.directUrl,
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)), voice: settings.voice,
); instructions: settings.instructions,
}));
return ( return (
<Dialog title="Configuración" onClose={onClose}> <Dialog title="Configuración" onClose={onClose}>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
controller.changeSettings(draft);
onClose(); onClose();
}} }}
> >
@@ -35,10 +37,10 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
placeholder="http://localhost:8080" placeholder="http://localhost:8080"
value={config.s2sUrl || settings.directUrl} value={config.s2sUrl || draft.directUrl}
readOnly={!!config.s2sUrl} readOnly={!!config.s2sUrl}
onChange={(event) => { onChange={(event) => {
controller.changeSettings({ directUrl: event.target.value }); setDraft({ ...draft, directUrl: event.target.value });
}} }}
/> />
<small> <small>
@@ -52,9 +54,9 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
<label className="field"> <label className="field">
<span>Voz</span> <span>Voz</span>
<select <select
value={settings.voice} value={draft.voice}
onChange={(event) => { onChange={(event) => {
controller.changeSettings({ voice: event.target.value }); setDraft({ ...draft, voice: event.target.value });
}} }}
> >
{VOICES.map((voice) => ( {VOICES.map((voice) => (
@@ -72,7 +74,7 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</label> </label>
<div className="gate"> <div className="gate">
<div className="gate-track"> <div className="gate-track">
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" /> <GateMeter controller={controller} />
<input <input
id="noise-gate" id="noise-gate"
aria-label="Umbral de la puerta de ruido" aria-label="Umbral de la puerta de ruido"
@@ -92,18 +94,18 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</div> </div>
</div> </div>
<small> <small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
desactivarla. para desactivarla.
</small> </small>
</div> </div>
<label className="field"> <label className="field">
<span>Instrucciones</span> <span>Instrucciones</span>
<textarea <textarea
rows={5} rows={5}
value={config.primaryPrompt || settings.instructions} value={config.primaryPrompt || draft.instructions}
readOnly={!!config.primaryPrompt} readOnly={!!config.primaryPrompt}
onChange={(event) => { onChange={(event) => {
controller.changeSettings({ instructions: event.target.value }); setDraft({ ...draft, instructions: event.target.value });
}} }}
/> />
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>} {config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
@@ -114,17 +116,24 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
className="btn primary wide" className="btn primary wide"
disabled={!LIVE_STATES.has(state.status)} disabled={!LIVE_STATES.has(state.status)}
onClick={() => { onClick={() => {
controller.changeSettings(draft);
onClose(); onClose();
void controller.restart(); void controller.restart();
}} }}
> >
Reiniciar conversación con esta configuración Reiniciar conversación con esta configuración
</button> </button>
<small>La voz, las instrucciones y la puerta de ruido se actualizan en la sesión actual.</small> <small>
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
una nueva conversación.
</small>
</div> </div>
</div> </div>
</div> </div>
<footer className="modal-footer"> <footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit"> <button className="btn primary" type="submit">
Guardar Guardar
</button> </button>
@@ -134,6 +143,15 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
); );
} }
function GateMeter({ controller }: { controller: VoiceController }) {
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
const meter = Math.max(
0,
Math.min(100, ((20 * Math.log10(level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
);
return <div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />;
}
export function RagDialog({ state, controller, onClose }: Props) { export function RagDialog({ state, controller, onClose }: Props) {
const [text, setText] = useState(state.rag); const [text, setText] = useState(state.rag);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@@ -193,69 +211,89 @@ export function RagDialog({ state, controller, onClose }: Props) {
} }
export function ToolsDialog({ state, controller, onClose }: Props) { export function ToolsDialog({ state, controller, onClose }: Props) {
const [draft, setDraft] = useState(() => ({
webSearch: state.settings.webSearch,
searchKey: state.settings.searchKey,
}));
return ( return (
<Dialog title="Herramientas" onClose={onClose}> <Dialog title="Herramientas" onClose={onClose}>
<p className="tools-intro"> <p className="tools-intro">
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato. Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
</p> </p>
<div className="tool-list"> <form
<div className="tool-row"> onSubmit={(event) => {
<div className="tool-info"> event.preventDefault();
<span className="tool-name">Búsqueda web</span> controller.changeSettings(draft);
<span className="tool-desc">Busca información en Google mediante Serper.</span> onClose();
}}
>
<div className="tool-list">
<div className="tool-row">
<div className="tool-info">
<span className="tool-name">Búsqueda web</span>
<span className="tool-desc">Busca información en Google mediante Serper.</span>
</div>
<label className="switch">
<input
type="checkbox"
aria-label="Búsqueda web"
checked={draft.webSearch}
onChange={(event) => {
setDraft({ ...draft, webSearch: event.target.checked });
}}
/>
<span className="switch-track" aria-hidden="true" />
</label>
</div> </div>
<label className="switch"> <label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input <input
type="checkbox" type="password"
aria-label="Búsqueda web" autoComplete="off"
checked={state.settings.webSearch} value={draft.searchKey}
disabled={state.config.search}
onChange={(event) => { onChange={(event) => {
controller.changeSettings({ webSearch: event.target.checked }); setDraft({ ...draft, searchKey: event.target.value });
}} }}
/> />
<span className="switch-track" aria-hidden="true" /> <small>
{state.config.search
? "La búsqueda está configurada en el servidor."
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
</small>
</label> </label>
</div> <div className="tool-row tool-row-sep">
<label className="field tools-key"> <div className="tool-info">
<span>Clave de la API de búsqueda</span> <span className="tool-name">Herramientas MCP cargadas</span>
<input <span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
type="password" </div>
autoComplete="off" </div>
value={state.settings.searchKey} <p className="tool-hint" role="status">
disabled={state.config.search} {state.toolsLoading
onChange={(event) => { ? "Cargando herramientas…"
controller.changeSettings({ searchKey: event.target.value }); : state.toolsError ||
}} (state.tools.length
/> ? `${String(state.tools.length)} herramientas disponibles.`
<small> : "No hay herramientas remotas disponibles.")}
{state.config.search </p>
? "La búsqueda está configurada en el servidor." <div className="agent-tools-list">
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."} {state.tools.map((tool) => (
</small> <article key={tool.name} className="agent-tool-item">
</label> <strong className="block text-sm">{tool.name}</strong>
<div className="tool-row tool-row-sep"> <p className="mt-1 text-sm opacity-75">{tool.description}</p>
<div className="tool-info"> </article>
<span className="tool-name">Herramientas MCP cargadas</span> ))}
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div> </div>
</div> </div>
<p className="tool-hint" role="status"> <footer className="modal-footer">
{state.toolsLoading <button className="btn secondary" type="button" onClick={onClose}>
? "Cargando herramientas…" Cancelar
: state.toolsError || </button>
(state.tools.length <button className="btn primary" type="submit">
? `${String(state.tools.length)} herramientas disponibles.` Guardar
: "No hay herramientas remotas disponibles.")} </button>
</p> </footer>
<div className="agent-tools-list"> </form>
{state.tools.map((tool) => (
<article key={tool.name} className="agent-tool-item">
<strong className="block text-sm">{tool.name}</strong>
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
</article>
))}
</div>
</div>
</Dialog> </Dialog>
); );
} }
+46 -27
View File
@@ -1,5 +1,5 @@
import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react"; import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react";
import { useEffect, useState, type CSSProperties, type PointerEvent } from "react"; import { useEffect, useState, useSyncExternalStore, type CSSProperties, type PointerEvent } from "react";
import type { ConnectionStatus } from "../lib/contracts"; import type { ConnectionStatus } from "../lib/contracts";
import { GATE_MAX, GATE_OFF } from "../lib/settings"; import { GATE_MAX, GATE_OFF } from "../lib/settings";
@@ -98,6 +98,45 @@ function Waveform({ side }: { side: "left" | "right" }) {
</div> </div>
); );
} }
function MicrophoneGate({
controller,
threshold,
muted,
live,
}: {
controller: VoiceController;
threshold: number;
muted: boolean;
live: boolean;
}) {
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
return (
<div className={`mic-gate ${20 * Math.log10(level || 0.00001) >= threshold ? "gate-open" : ""}`}>
{live && (
<GateArc
threshold={threshold}
level={level}
onChange={(noiseGate) => {
controller.changeSettings({ noiseGate });
}}
/>
)}
<button
className={`side-btn ${muted ? "muted" : ""}`}
type="button"
disabled={!live}
tabIndex={live ? 0 : -1}
aria-label={muted ? "Activar micrófono" : "Silenciar micrófono"}
aria-pressed={muted}
onClick={() => {
controller.toggleMuted();
}}
>
{muted ? <MicOff /> : <Mic />}
</button>
</div>
);
}
function JoinCountdown({ deadline }: { deadline: number }) { function JoinCountdown({ deadline }: { deadline: number }) {
const [now, setNow] = useState(Date.now); const [now, setNow] = useState(Date.now);
useEffect(() => { useEffect(() => {
@@ -195,32 +234,12 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
) : ( ) : (
<> <>
<div className={`orb-wrap ${active ? "live" : ""}`}> <div className={`orb-wrap ${active ? "live" : ""}`}>
<div <MicrophoneGate
className={`mic-gate ${20 * Math.log10(state.level || 0.00001) >= state.settings.noiseGate ? "gate-open" : ""}`} controller={controller}
> threshold={state.settings.noiseGate}
{live && ( muted={state.muted}
<GateArc live={live}
threshold={state.settings.noiseGate} />
level={state.level}
onChange={(noiseGate) => {
controller.changeSettings({ noiseGate });
}}
/>
)}
<button
className={`side-btn ${state.muted ? "muted" : ""}`}
type="button"
disabled={!live}
tabIndex={live ? 0 : -1}
aria-label={muteLabel}
aria-pressed={state.muted}
onClick={() => {
controller.toggleMuted();
}}
>
{state.muted ? <MicOff /> : <Mic />}
</button>
</div>
<button <button
id="main-circle" id="main-circle"
className={`circle state-${status}`} className={`circle state-${status}`}
+7 -3
View File
@@ -40,12 +40,16 @@ export function loadSettings(): Settings {
searchKey: read("searchKey") ?? "", searchKey: read("searchKey") ?? "",
}; };
} }
export function saveSettings(settings: Settings) { export function saveSettings(settings: Partial<Settings>) {
try { try {
for (const key of ["directUrl", "voice", "instructions", "noiseGate", "searchKey"] as const) { for (const key of ["directUrl", "voice", "instructions", "noiseGate", "searchKey"] as const) {
localStorage.setItem(`s2s.ws.${key}`, String(settings[key])); if (settings[key] !== undefined) {
localStorage.setItem(`s2s.ws.${key}`, String(settings[key]));
}
}
if (settings.webSearch !== undefined) {
localStorage.setItem("s2s.ws.tools", JSON.stringify({ web_search: settings.webSearch }));
} }
localStorage.setItem("s2s.ws.tools", JSON.stringify({ web_search: settings.webSearch }));
} catch { } catch {
/* The current session still works when browser storage is disabled. */ /* The current session still works when browser storage is disabled. */
} }
@@ -87,6 +87,32 @@ async function start() {
return client; return client;
} }
describe("voice application lifecycle", () => { describe("voice application lifecycle", () => {
it("notifies only meter subscribers when microphone level changes", async () => {
const client = await start();
const app = vi.fn();
const meter = vi.fn();
controller.subscribe(app);
controller.subscribeLevel(meter);
const snapshot = controller.getSnapshot();
client.emit("level", 0.2);
expect(controller.getLevel()).toBe(0.2);
expect(meter).toHaveBeenCalledOnce();
expect(app).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toBe(snapshot);
});
it("saves only changed preferences and keeps noise calibration out of session updates", async () => {
const client = await start();
const save = vi.spyOn(Storage.prototype, "setItem");
client.updateSession.mockClear();
controller.changeSettings({ noiseGate: -35 });
expect(save).toHaveBeenCalledExactlyOnceWith("s2s.ws.noiseGate", "-35");
expect(client.setGate).toHaveBeenCalledExactlyOnceWith({ enabled: true, thresholdDb: -35 });
expect(client.updateSession).not.toHaveBeenCalled();
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
expect(client.updateSession).toHaveBeenCalledOnce();
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
expect(client.updateSession).toHaveBeenCalledOnce();
});
it("renders configuration and starts without waiting for optional reports", async () => { it("renders configuration and starts without waiting for optional reports", async () => {
const original = fetchMock.getMockImplementation(); const original = fetchMock.getMockImplementation();
if (!original) { if (!original) {
+39 -8
View File
@@ -53,7 +53,6 @@ export interface AppState {
notice: string; notice: string;
messages: Message[]; messages: Message[];
muted: boolean; muted: boolean;
level: number;
rag: string; rag: string;
queuePosition: number; queuePosition: number;
joinDeadline: number; joinDeadline: number;
@@ -65,6 +64,8 @@ export class VoiceController {
readonly caller: Caller; readonly caller: Caller;
private state: AppState; private state: AppState;
private readonly listeners = new Set<() => void>(); private readonly listeners = new Set<() => void>();
private readonly levelListeners = new Set<() => void>();
private level = 0;
private client: RealtimeClient | null = null; private client: RealtimeClient | null = null;
private subscriptions: (() => void)[] = []; private subscriptions: (() => void)[] = [];
private requests = new AbortController(); private requests = new AbortController();
@@ -98,7 +99,6 @@ export class VoiceController {
notice: "", notice: "",
messages: [], messages: [],
muted: false, muted: false,
level: 0,
rag: "", rag: "",
queuePosition: 0, queuePosition: 0,
joinDeadline: 0, joinDeadline: 0,
@@ -106,6 +106,22 @@ export class VoiceController {
}; };
} }
getSnapshot = () => this.state; getSnapshot = () => this.state;
getLevel = () => this.level;
subscribeLevel = (listener: () => void) => {
this.levelListeners.add(listener);
return () => {
this.levelListeners.delete(listener);
};
};
private updateLevel(level: number) {
if (this.level === level) {
return;
}
this.level = level;
this.levelListeners.forEach((listener) => {
listener();
});
}
subscribe = (listener: () => void) => { subscribe = (listener: () => void) => {
this.listeners.add(listener); this.listeners.add(listener);
return () => { return () => {
@@ -215,13 +231,28 @@ export class VoiceController {
this.state.settings.voice, this.state.settings.voice,
this.tools(), this.tools(),
); );
this.client?.setGate(this.gate());
} }
changeSettings(patch: Partial<Settings>) { changeSettings(patch: Partial<Settings>) {
const settings = { ...this.state.settings, ...patch }; const changed = Object.fromEntries(
Object.entries(patch).filter(([key, value]) => this.state.settings[key as keyof Settings] !== value),
) as Partial<Settings>;
if (!Object.keys(changed).length) {
return;
}
const settings = { ...this.state.settings, ...changed };
this.update({ settings }); this.update({ settings });
saveSettings(settings); saveSettings(changed);
this.pushSettings(); if (changed.noiseGate !== undefined) {
this.client?.setGate(this.gate());
}
if (
changed.voice !== undefined ||
changed.instructions !== undefined ||
changed.webSearch !== undefined ||
changed.searchKey !== undefined
) {
this.pushSettings();
}
} }
setRag(rag: string) { setRag(rag: string) {
this.update({ rag: rag.trim() }); this.update({ rag: rag.trim() });
@@ -338,7 +369,7 @@ export class VoiceController {
} }
if (Date.now() - this.lastMeter > 150) { if (Date.now() - this.lastMeter > 150) {
this.lastMeter = Date.now(); this.lastMeter = Date.now();
this.update({ level }); this.updateLevel(level);
} }
}), }),
client.on("queue", ({ position }) => { client.on("queue", ({ position }) => {
@@ -574,10 +605,10 @@ export class VoiceController {
this.requests.abort(); this.requests.abort();
this.cancelReasoning(); this.cancelReasoning();
window.speechSynthesis?.cancel(); window.speechSynthesis?.cancel();
this.updateLevel(0);
this.update({ this.update({
status, status,
muted: false, muted: false,
level: 0,
joinDeadline: 0, joinDeadline: 0,
unlock: false, unlock: false,
messages: this.state.messages.map((message) => messages: this.state.messages.map((message) =>