diff --git a/vocero-api/CHANGELOG.md b/vocero-api/CHANGELOG.md index 2c9809f..bcf6f1f 100644 --- a/vocero-api/CHANGELOG.md +++ b/vocero-api/CHANGELOG.md @@ -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. diff --git a/vocero-frontend/src/App.test.tsx b/vocero-frontend/src/App.test.tsx index 36b94d1..68df72c 100644 --- a/vocero-frontend/src/App.test.tsx +++ b/vocero-frontend/src/App.test.tsx @@ -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(); + 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(); diff --git a/vocero-frontend/src/components/SettingsDialogs.tsx b/vocero-frontend/src/components/SettingsDialogs.tsx index 964ca4f..bf66569 100644 --- a/vocero-frontend/src/components/SettingsDialogs.tsx +++ b/vocero-frontend/src/components/SettingsDialogs.tsx @@ -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 (
{ 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 }); }} /> @@ -52,9 +54,9 @@ export function SettingsDialog({ state, controller, onClose }: Props) {