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
- 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,
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" }));
expect(document.documentElement.dataset.theme).toBe("white");
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" }));
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
view.unmount();
render(
@@ -74,6 +75,19 @@ describe("React application", () => {
await user.click(await screen.findByRole("button", { name: "Configuración" }));
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 () => {
const user = userEvent.setup();
render(<App />);
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useSyncExternalStore } from "react";
import { initials, profileName } from "../lib/context";
import { profileRows } from "../lib/profile";
@@ -13,15 +13,17 @@ interface Props {
}
export function SettingsDialog({ state, controller, onClose }: Props) {
const { config, settings } = state;
const meter = Math.max(
0,
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
);
const [draft, setDraft] = useState(() => ({
directUrl: settings.directUrl,
voice: settings.voice,
instructions: settings.instructions,
}));
return (
<Dialog title="Configuración" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
controller.changeSettings(draft);
onClose();
}}
>
@@ -35,10 +37,10 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
autoComplete="off"
spellCheck={false}
placeholder="http://localhost:8080"
value={config.s2sUrl || settings.directUrl}
value={config.s2sUrl || draft.directUrl}
readOnly={!!config.s2sUrl}
onChange={(event) => {
controller.changeSettings({ directUrl: event.target.value });
setDraft({ ...draft, directUrl: event.target.value });
}}
/>
<small>
@@ -52,9 +54,9 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
<label className="field">
<span>Voz</span>
<select
value={settings.voice}
value={draft.voice}
onChange={(event) => {
controller.changeSettings({ voice: event.target.value });
setDraft({ ...draft, voice: event.target.value });
}}
>
{VOICES.map((voice) => (
@@ -72,7 +74,7 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</label>
<div className="gate">
<div className="gate-track">
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
<GateMeter controller={controller} />
<input
id="noise-gate"
aria-label="Umbral de la puerta de ruido"
@@ -92,18 +94,18 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</div>
</div>
<small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
desactivarla.
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
para desactivarla.
</small>
</div>
<label className="field">
<span>Instrucciones</span>
<textarea
rows={5}
value={config.primaryPrompt || settings.instructions}
value={config.primaryPrompt || draft.instructions}
readOnly={!!config.primaryPrompt}
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>}
@@ -114,17 +116,24 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
className="btn primary wide"
disabled={!LIVE_STATES.has(state.status)}
onClick={() => {
controller.changeSettings(draft);
onClose();
void controller.restart();
}}
>
Reiniciar conversación con esta configuración
</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>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
Guardar
</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) {
const [text, setText] = useState(state.rag);
const [saved, setSaved] = useState(false);
@@ -193,69 +211,89 @@ export function RagDialog({ state, controller, onClose }: Props) {
}
export function ToolsDialog({ state, controller, onClose }: Props) {
const [draft, setDraft] = useState(() => ({
webSearch: state.settings.webSearch,
searchKey: state.settings.searchKey,
}));
return (
<Dialog title="Herramientas" onClose={onClose}>
<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>
<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>
<form
onSubmit={(event) => {
event.preventDefault();
controller.changeSettings(draft);
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>
<label className="switch">
<label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input
type="checkbox"
aria-label="Búsqueda web"
checked={state.settings.webSearch}
type="password"
autoComplete="off"
value={draft.searchKey}
disabled={state.config.search}
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>
</div>
<label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input
type="password"
autoComplete="off"
value={state.settings.searchKey}
disabled={state.config.search}
onChange={(event) => {
controller.changeSettings({ searchKey: event.target.value });
}}
/>
<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>
<div className="tool-row tool-row-sep">
<div className="tool-info">
<span className="tool-name">Herramientas MCP cargadas</span>
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
<div className="tool-row tool-row-sep">
<div className="tool-info">
<span className="tool-name">Herramientas MCP cargadas</span>
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div>
</div>
<p className="tool-hint" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
(state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
</p>
<div className="agent-tools-list">
{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>
<p className="tool-hint" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
(state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
</p>
<div className="agent-tools-list">
{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>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
Guardar
</button>
</footer>
</form>
</Dialog>
);
}
+46 -27
View File
@@ -1,5 +1,5 @@
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 { GATE_MAX, GATE_OFF } from "../lib/settings";
@@ -98,6 +98,45 @@ function Waveform({ side }: { side: "left" | "right" }) {
</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 }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
@@ -195,32 +234,12 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
) : (
<>
<div className={`orb-wrap ${active ? "live" : ""}`}>
<div
className={`mic-gate ${20 * Math.log10(state.level || 0.00001) >= state.settings.noiseGate ? "gate-open" : ""}`}
>
{live && (
<GateArc
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>
<MicrophoneGate
controller={controller}
threshold={state.settings.noiseGate}
muted={state.muted}
live={live}
/>
<button
id="main-circle"
className={`circle state-${status}`}
+7 -3
View File
@@ -40,12 +40,16 @@ export function loadSettings(): Settings {
searchKey: read("searchKey") ?? "",
};
}
export function saveSettings(settings: Settings) {
export function saveSettings(settings: Partial<Settings>) {
try {
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 {
/* The current session still works when browser storage is disabled. */
}
@@ -87,6 +87,32 @@ async function start() {
return client;
}
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 () => {
const original = fetchMock.getMockImplementation();
if (!original) {
+39 -8
View File
@@ -53,7 +53,6 @@ export interface AppState {
notice: string;
messages: Message[];
muted: boolean;
level: number;
rag: string;
queuePosition: number;
joinDeadline: number;
@@ -65,6 +64,8 @@ export class VoiceController {
readonly caller: Caller;
private state: AppState;
private readonly listeners = new Set<() => void>();
private readonly levelListeners = new Set<() => void>();
private level = 0;
private client: RealtimeClient | null = null;
private subscriptions: (() => void)[] = [];
private requests = new AbortController();
@@ -98,7 +99,6 @@ export class VoiceController {
notice: "",
messages: [],
muted: false,
level: 0,
rag: "",
queuePosition: 0,
joinDeadline: 0,
@@ -106,6 +106,22 @@ export class VoiceController {
};
}
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) => {
this.listeners.add(listener);
return () => {
@@ -215,13 +231,28 @@ export class VoiceController {
this.state.settings.voice,
this.tools(),
);
this.client?.setGate(this.gate());
}
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 });
saveSettings(settings);
this.pushSettings();
saveSettings(changed);
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) {
this.update({ rag: rag.trim() });
@@ -338,7 +369,7 @@ export class VoiceController {
}
if (Date.now() - this.lastMeter > 150) {
this.lastMeter = Date.now();
this.update({ level });
this.updateLevel(level);
}
}),
client.on("queue", ({ position }) => {
@@ -574,10 +605,10 @@ export class VoiceController {
this.requests.abort();
this.cancelReasoning();
window.speechSynthesis?.cancel();
this.updateLevel(0);
this.update({
status,
muted: false,
level: 0,
joinDeadline: 0,
unlock: false,
messages: this.state.messages.map((message) =>