refactor(frontend): isolate audio meters and apply settings on save
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +211,22 @@ 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>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
controller.changeSettings(draft);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="tool-list">
|
<div className="tool-list">
|
||||||
<div className="tool-row">
|
<div className="tool-row">
|
||||||
<div className="tool-info">
|
<div className="tool-info">
|
||||||
@@ -208,9 +237,9 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Búsqueda web"
|
aria-label="Búsqueda web"
|
||||||
checked={state.settings.webSearch}
|
checked={draft.webSearch}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
controller.changeSettings({ webSearch: event.target.checked });
|
setDraft({ ...draft, webSearch: event.target.checked });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="switch-track" aria-hidden="true" />
|
<span className="switch-track" aria-hidden="true" />
|
||||||
@@ -221,10 +250,10 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={state.settings.searchKey}
|
value={draft.searchKey}
|
||||||
disabled={state.config.search}
|
disabled={state.config.search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
controller.changeSettings({ searchKey: event.target.value });
|
setDraft({ ...draft, searchKey: event.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
@@ -256,6 +285,15 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
|
||||||
{live && (
|
|
||||||
<GateArc
|
|
||||||
threshold={state.settings.noiseGate}
|
threshold={state.settings.noiseGate}
|
||||||
level={state.level}
|
muted={state.muted}
|
||||||
onChange={(noiseGate) => {
|
live={live}
|
||||||
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}`}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (settings[key] !== undefined) {
|
||||||
localStorage.setItem(`s2s.ws.${key}`, String(settings[key]));
|
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) {
|
||||||
|
|||||||
@@ -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,14 +231,29 @@ 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);
|
||||||
|
if (changed.noiseGate !== undefined) {
|
||||||
|
this.client?.setGate(this.gate());
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
changed.voice !== undefined ||
|
||||||
|
changed.instructions !== undefined ||
|
||||||
|
changed.webSearch !== undefined ||
|
||||||
|
changed.searchKey !== undefined
|
||||||
|
) {
|
||||||
this.pushSettings();
|
this.pushSettings();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setRag(rag: string) {
|
setRag(rag: string) {
|
||||||
this.update({ rag: rag.trim() });
|
this.update({ rag: rag.trim() });
|
||||||
this.pushSettings();
|
this.pushSettings();
|
||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user