diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b4a43b8..8a0546e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 1.0.3 — обработка микрофона
+
+- Шумоподавление и эхоподавление по умолчанию выключены. Ранее сохранённый явный выбор сохраняется.
+- В меню настроек микрофона — единый тумблер «Шумоподавление и эхо» с текущим состоянием.
+- Основная студия может включать и выключать обработку в карточке кастера без его ручного согласия. Приложение автоматически сообщает результат применения.
+- Переключение сохраняет выключенное состояние микрофона, не меняет камеру и не прерывает приватное обращение.
+- Сохранена действующая мини-статистика приёма видео, аудио и состояния сети в карточках кастеров.
+- Обновляются web и серверные пакеты Windows Server Setup/Linux; отдельная пересборка Windows/macOS Client не нужна для этого изменения web-интерфейса. После обновления сервера перезагрузите CastLink вне эфира.
+
## 1.0.2 — серверный выпуск / Server release / 服务器发行版
[Русский](distribution/RELEASE_NOTES_1_0_2.md) · [English](distribution/docs/en/RELEASE_NOTES_1_0_2.md) · [简体中文](distribution/docs/zh-CN/RELEASE_NOTES_1_0_2.md)
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index d28be5ec..d2aee1e1 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1306,7 +1306,6 @@ html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content select { width: 100%; }
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border); }
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .guest-noise-control > label { display: flex; align-items: center; gap: 10px; min-height: 44px; font-size: 13px; font-weight: 600; line-height: 1.5; cursor: pointer; }
-html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control input[type="checkbox"] { flex: 0 0 18px; width: 18px; height: 18px; min-height: 18px; margin: 0; padding: 0; accent-color: var(--media-accent); }
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control p { margin: 4px 0 0; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel h2 { margin: 0 0 8px; font-size: 14px; }
html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel p { margin: 0 0 12px; font-size: 12px; line-height: 1.6; }
diff --git a/apps/web/components/GuestAudioProfileSelector.module.css b/apps/web/components/GuestAudioProfileSelector.module.css
new file mode 100644
index 00000000..b09c2370
--- /dev/null
+++ b/apps/web/components/GuestAudioProfileSelector.module.css
@@ -0,0 +1,14 @@
+.root { min-width: 0; width: 100%; }
+.root .control { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 44px; margin: 0; cursor: pointer; }
+.copy { display: grid; gap: 4px; min-width: 0; }
+.title { color: var(--text); font-size: 13px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
+.hint { color: var(--muted); font-size: 12px; font-weight: 400; line-height: 1.5; }
+.root .switch { appearance: none; flex: none; box-sizing: border-box; width: 44px; height: 26px; min-height: 0; margin: 0; padding: 3px; border: 1px solid var(--field-border); border-radius: 999px; background: var(--secondary-bg); cursor: pointer; transition: background-color 160ms ease-out, border-color 160ms ease-out; }
+.switch::before { content: ""; display: block; width: 18px; height: 18px; border-radius: 50%; background: var(--muted); transition: transform 160ms ease-out, background-color 160ms ease-out; }
+.root .switch:checked { background: var(--primary); border-color: var(--primary); }
+.switch:checked::before { transform: translateX(18px); background: var(--page-bg); }
+.root .switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; }
+.control:hover .switch:not(:disabled) { border-color: var(--text); }
+.control:has(.switch:disabled) { cursor: default; opacity: .6; }
+.root .switch:disabled { cursor: default; }
+@media (prefers-reduced-motion: reduce) { .root .switch, .switch::before { transition: none; } }
diff --git a/apps/web/components/GuestAudioProfileSelector.tsx b/apps/web/components/GuestAudioProfileSelector.tsx
index ef1644e5..25e46618 100644
--- a/apps/web/components/GuestAudioProfileSelector.tsx
+++ b/apps/web/components/GuestAudioProfileSelector.tsx
@@ -1,16 +1,21 @@
"use client";
+import { useId } from "react";
import { useI18n } from "@/components/I18nProvider";
import { guestAudioProfile, type GuestAudioProfile } from "@/lib/guest-audio";
+import styles from "./GuestAudioProfileSelector.module.css";
export function GuestAudioProfileSelector({ value, onChange, disabled = false, busy = false }: { value?: GuestAudioProfile; onChange: (profile: GuestAudioProfile) => void; disabled?: boolean; busy?: boolean }) {
- const { t, locale } = useI18n();
- const profile = guestAudioProfile(value);
- return
-
- {profile === "raw" &&
{t("Без шумоподавления и автоматического усиления. Используйте наушники, чтобы избежать эха.")}
}
- {busy &&
{t("Переключение микрофона…")}}
+ const { t } = useI18n();
+ const id = useId();
+ const enabled = guestAudioProfile(value) === "speech";
+ return
+
+ {busy && {t("Переключение микрофона…")}}
;
}
diff --git a/apps/web/components/GuestMiniStats.tsx b/apps/web/components/GuestMiniStats.tsx
new file mode 100644
index 00000000..a60b979d
--- /dev/null
+++ b/apps/web/components/GuestMiniStats.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useI18n } from "./I18nProvider";
+import styles from "./GuestVideoCard.module.css";
+
+export type GuestMiniStatsData = {
+ receiveVideoKbps?: number | null;
+ receiveAudioKbps?: number | null;
+ networkMeasured?: boolean;
+ statsSampledAt?: number;
+ statsSampledAtRef?: { current: number };
+ stability: "Хорошая" | "Средняя" | "Плохая";
+ packetLoss: number;
+ jitterMs: number;
+ rttMs: number;
+ camera: boolean;
+ microphone: boolean;
+};
+
+export function GuestMiniStats({ guest }: { guest: GuestMiniStatsData }) {
+ const { t, locale } = useI18n();
+ const [now, setNow] = useState(Date.now);
+ useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 2000); return () => clearInterval(timer); }, []);
+ const sampledAt = guest.statsSampledAtRef?.current ?? guest.statsSampledAt;
+ const fresh = sampledAt != null && sampledAt > 0 && now - sampledAt < 8000;
+ const measured = fresh && guest.networkMeasured;
+ const quality = !measured ? "unknown" : guest.stability === "Хорошая" ? "good" : guest.stability === "Средняя" ? "mid" : "bad";
+ const bitrate = (value: number | null | undefined, enabled: boolean) => fresh && enabled && value != null && Number.isFinite(value) && value >= 0
+ ? `${value.toLocaleString(locale)} ${t("кбит/с")}` : "—";
+ const detail = measured ? t("Потери: {loss} пак. за всё время трека; джиттер: {jitter} мс; RTT: {rtt}", {
+ loss: guest.packetLoss, jitter: guest.jitterMs, rtt: guest.rttMs > 0 ? `${guest.rttMs} ${t("мс")}` : "—",
+ }) : t("Нет свежего замера соединения");
+ return
+ - {t("Приём видео")}
- {bitrate(guest.receiveVideoKbps, guest.camera)}
+ - {t("Приём аудио")}
- {bitrate(guest.receiveAudioKbps, guest.microphone)}
+ - {t("Сеть")}
- {t(measured ? guest.stability : "Нет данных")}
+
;
+}
diff --git a/apps/web/components/GuestNoiseSuppressionControl.tsx b/apps/web/components/GuestNoiseSuppressionControl.tsx
index ab41ebcc..84aacb95 100644
--- a/apps/web/components/GuestNoiseSuppressionControl.tsx
+++ b/apps/web/components/GuestNoiseSuppressionControl.tsx
@@ -1,9 +1,10 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
+import { GuestAudioProfileSelector } from "./GuestAudioProfileSelector";
import type { GuestAudioProfile, GuestNoiseSuppressionState } from "@/lib/guest-audio";
-export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabled, busy, onChange, asButton = false }: {
+export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabled, busy, onChange }: {
asButton?: boolean;
profile: GuestAudioProfile;
state: GuestNoiseSuppressionState;
@@ -20,9 +21,7 @@ export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabl
: speech ? "Шумоподавление и защита от эха включены."
: "Оригинальный звук. Используйте наушники, чтобы избежать эха.";
return
- {asButton ?
:
}
+
{t(status)}
;
}
diff --git a/apps/web/components/GuestPrivateTalkback.tsx b/apps/web/components/GuestPrivateTalkback.tsx
index b9407216..2074923f 100644
--- a/apps/web/components/GuestPrivateTalkback.tsx
+++ b/apps/web/components/GuestPrivateTalkback.tsx
@@ -14,7 +14,7 @@ import { guestAudioCaptureOptions, setGuestMicrophoneEnabled, type GuestAudioPro
type PrivateSession = { id: string; room: Room; dispose: () => void; track?: LocalAudioTrack; reply: ReturnType
>; restoreEpoch: number };
/** Only the held guest reply gates its microphone; the rest of the program is untouched. */
-export function GuestPrivateTalkback({ room, roomName, studioId, token, microphoneId, onReplyBusyChange, listenGain = 100, audioProfile = "speech", micGain = 100, microphoneMuteEpochRef, disabled = false }: {
+export function GuestPrivateTalkback({ room, roomName, studioId, token, microphoneId, onReplyBusyChange, listenGain = 100, audioProfile, micGain = 100, microphoneMuteEpochRef, disabled = false }: {
room: Room; roomName: string; studioId: string; token: string; microphoneId: string; onReplyBusyChange?: (active: boolean) => void; listenGain?: number; audioProfile?: GuestAudioProfile; micGain?: number; microphoneMuteEpochRef?: React.RefObject; disabled?: boolean;
}) {
const { t } = useI18n();
diff --git a/apps/web/components/GuestVideoCard.module.css b/apps/web/components/GuestVideoCard.module.css
index d60acd92..846c9da3 100644
--- a/apps/web/components/GuestVideoCard.module.css
+++ b/apps/web/components/GuestVideoCard.module.css
@@ -30,6 +30,39 @@
.actual > span { color: var(--muted); }
+.miniStats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px 8px;
+ margin: 0;
+ padding: 7px;
+ border-top: 1px solid var(--panel-border);
+ font-size: 10px;
+ line-height: 1.3;
+ min-width: 0;
+}
+.miniStats > div { min-width: 0; }
+.miniStats > div:last-child { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 6px; }
+.miniStats dt { color: var(--muted); }
+.miniStats dd { margin: 2px 0 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.network { display: flex; align-items: center; gap: 5px; }
+.network i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; flex: none; }
+.network[data-quality="good"] { color: #a3d4b0; }
+.network[data-quality="mid"] { color: #e9bc72; }
+.network[data-quality="bad"] { color: #f3a0a6; }
+.network[data-quality="unknown"] { color: var(--muted); }
+:global([data-theme="light"]) .network[data-quality="good"] { color: #245d43; }
+:global([data-theme="light"]) .network[data-quality="mid"] { color: #885609; }
+:global([data-theme="light"]) .network[data-quality="bad"] { color: #ad2738; }
+
+@container (max-width: 165px) {
+ .miniStats { display: flex; flex-direction: column; gap: 0; padding: 0 5px; line-height: 11px; }
+ .miniStats > div { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
+ .miniStats > div:last-child { align-items: center; }
+ .miniStats dt { font-size: 9px; line-height: 11px; white-space: nowrap; }
+ .miniStats dd { margin: 0; font-size: 10px; line-height: 11px; }
+}
+
.actual output {
display: flex;
align-items: center;
diff --git a/apps/web/components/GuestVideoCard.tsx b/apps/web/components/GuestVideoCard.tsx
index a4adf049..ddd4278e 100644
--- a/apps/web/components/GuestVideoCard.tsx
+++ b/apps/web/components/GuestVideoCard.tsx
@@ -10,8 +10,9 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { StudioConsoleIcon } from "./StudioConsoleIcon";
import styles from "./GuestVideoCard.module.css";
import { positionStudioConsolePopover } from "./studio-console-popover-position";
+import { GuestMiniStats, type GuestMiniStatsData } from "./GuestMiniStats";
-export type GuestVideoCardData = {
+export type GuestVideoCardData = GuestMiniStatsData & {
identity: string;
name: string;
resolution: string;
@@ -108,6 +109,7 @@ export function GuestVideoCard({
aria-label={`${t(copied ? "Скопировано" : "Копировать URL")}: ${guest.name}`} title={`${t(copied ? "Скопировано" : "Копировать URL")}: ${guest.name}`} data-copied={copied || undefined}>
{copied ? t("Скопировано") : ""}
}
+