Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
1 change: 0 additions & 1 deletion apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
14 changes: 14 additions & 0 deletions apps/web/components/GuestAudioProfileSelector.module.css
Original file line number Diff line number Diff line change
@@ -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; } }
23 changes: 14 additions & 9 deletions apps/web/components/GuestAudioProfileSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>
<label>{t("Режим микрофона")} <select aria-label={t("Режим микрофона")} value={profile} disabled={disabled || busy} onChange={event => onChange(guestAudioProfile(event.target.value))}>
<option value="speech">{t("Обычная речь")}</option>
<option value="raw">{t("Оригинальный звук")}</option>
</select></label>
{profile === "raw" && <p className="muted">{t("Без шумоподавления и автоматического усиления. Используйте наушники, чтобы избежать эха.")}</p>}
{busy && <small role="status">{t("Переключение микрофона…")}</small>}
const { t } = useI18n();
const id = useId();
const enabled = guestAudioProfile(value) === "speech";
return <div className={styles.root} aria-busy={busy}>
<label className={styles.control}>
<span className={styles.copy}>
<span id={`${id}-label`} className={styles.title}>{t("Шумоподавление и эхо")}</span>
<span id={`${id}-hint`} className={styles.hint}>{enabled ? t("Включено — подавляет фоновый шум и эхо.") : t("Выключено — оригинальный звук. Используйте наушники.")}</span>
</span>
<input className={styles.switch} type="checkbox" role="switch" aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} checked={enabled} disabled={disabled || busy} onChange={event => onChange(event.target.checked ? "speech" : "raw")} />
</label>
{busy && <small className={styles.hint} role="status">{t("Переключение микрофона…")}</small>}
</div>;
}
39 changes: 39 additions & 0 deletions apps/web/components/GuestMiniStats.tsx
Original file line number Diff line number Diff line change
@@ -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 <dl className={styles.miniStats} aria-label={t("Статистика соединения")}>
<div title={t("Измеренный приём камеры этой студией; без аудио")}><dt>{t("Приём видео")}</dt><dd>{bitrate(guest.receiveVideoKbps, guest.camera)}</dd></div>
<div title={t("Измеренный приём микрофона этой студией; без видео")}><dt>{t("Приём аудио")}</dt><dd>{bitrate(guest.receiveAudioKbps, guest.microphone)}</dd></div>
<div title={detail}><dt>{t("Сеть")}</dt><dd className={styles.network} data-quality={quality}><i aria-hidden="true" />{t(measured ? guest.stability : "Нет данных")}</dd></div>
</dl>;
}
7 changes: 3 additions & 4 deletions apps/web/components/GuestNoiseSuppressionControl.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,9 +21,7 @@ export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabl
: speech ? "Шумоподавление и защита от эха включены."
: "Оригинальный звук. Используйте наушники, чтобы избежать эха.";
return <div className="guest-noise-control" aria-busy={busy}>
{asButton ? <button type="button" className="button-secondary guest-audio-processing-toggle" role="switch" aria-checked={speech} disabled={disabled || busy} onClick={() => onChange(speech ? "raw" : "speech")}><span>{t("Шумоподавление и защита от эха")}</span><strong>{t(speech ? "Включено" : "Выключено")}</strong></button> : <label><input type="checkbox" role="switch" checked={speech} disabled={disabled || busy} onChange={event => onChange(event.target.checked ? "speech" : "raw")} />
<span>{t("Шумоподавление и защита от эха")}</span>
</label>}
<GuestAudioProfileSelector value={profile} disabled={disabled || busy} onChange={onChange} />
<p className="muted" role="status">{t(status)}</p>
</div>;
}
2 changes: 1 addition & 1 deletion apps/web/components/GuestPrivateTalkback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { guestAudioCaptureOptions, setGuestMicrophoneEnabled, type GuestAudioPro
type PrivateSession = { id: string; room: Room; dispose: () => void; track?: LocalAudioTrack; reply: ReturnType<typeof createPrivateReplyController<LocalAudioTrack>>; 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<number>; disabled?: boolean;
}) {
const { t } = useI18n();
Expand Down
33 changes: 33 additions & 0 deletions apps/web/components/GuestVideoCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components/GuestVideoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,6 +109,7 @@ export function GuestVideoCard({
aria-label={`${t(copied ? "Скопировано" : "Копировать URL")}: ${guest.name}`} title={`${t(copied ? "Скопировано" : "Копировать URL")}: ${guest.name}`} data-copied={copied || undefined}>
<StudioConsoleIcon kind={copied ? "check" : "copy"} width={16} height={16}/><span className={styles.copyStatus} role="status">{copied ? t("Скопировано") : ""}</span>
</button>}</div>
<GuestMiniStats guest={guest} />
<div className={styles.feedback}>{feedback}</div>
<div ref={popup} id={menuId} className={styles.advancedSurface} popover={compact ? "auto" : undefined} role={compact ? "dialog" : undefined} aria-labelledby={compact ? `${menuId}-title` : undefined} onToggle={event => {
if (event.target !== event.currentTarget) return;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/StudioGuestMediaControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { GuestAudioProfileSelector } from "./GuestAudioProfileSelector";
import { StudioGuestDeviceControls } from "./StudioGuestDeviceControls";
import { GuestCameraCaptureControls } from "./GuestCameraCaptureControls";
import { useI18n } from "@/components/I18nProvider";
Expand Down Expand Up @@ -71,13 +72,12 @@ export function StudioGuestMediaControls({ studioId, roomName, identity, name, c
{gainControl("micGain", t("Уровень микрофона гостя"), 200)}
{gainControl("listenGain", t("Громкость звука у гостя"), 100)}
<label>{t("Видеокодек гостя")} <select aria-label={t("Видеокодек {name}", { name })} value={view?.state?.videoCodec || "vp8"} disabled={disabled || !view?.state?.videoCodec} onChange={event => void change({ videoCodec: event.target.value as StudioVideoCodec })}>{(["vp9", "h264", "h265", "av1", ...(view?.state?.videoCodec === "vp8" ? ["vp8"] as const : [])] as const).map(codec => <option key={codec} value={codec} disabled={!view?.state?.supportedVideoCodecs?.includes(codec)}>{codec === "h265" ? view?.state?.supportedVideoCodecs?.includes(codec) ? "H.265 (HEVC)" : t("H.265 (HEVC) — недоступен") : codec.toUpperCase()}</option>)}</select></label>
<label>{t("Звук гостя")} <select aria-label={t("Режим микрофона {name}", { name: name })} value={view?.state?.audioProfile || "speech"} disabled={disabled} onChange={event => void change({ audioProfile: event.target.value as "speech" | "raw" })}><option value="speech">{t("Шумоподавление")}</option><option value="raw">{t("Оригинальный звук")}</option></select></label>
<GuestAudioProfileSelector value={view?.state?.audioProfile} disabled={disabled} busy={pending && !!view?.command?.patch.audioProfile} onChange={audioProfile => void change({ audioProfile })} />
<label>{t("Маршрут гостя")} <select aria-label={t("Маршрут подключения {name}", { name: name })} value={view?.state?.connectionRoute || "auto"} disabled={disabled} onChange={event => void change({ connectionRoute: event.target.value as "auto" | "normal" | "relay" })}><option value="auto">{t("Автоматически")}</option><option value="normal">{t("Обычный")}</option><option value="relay" disabled={!view?.connectionRoutes?.relay.configured}>{t("Запасной TURN")}</option></select></label>
<small role="status">{pending ? connected ? t("Ожидаем подтверждение гостя…") : t("Переподключение…") : !connected || view?.offline ? t("Гость отключён. Показаны последние подтверждённые настройки.") : view?.state ? t("Настройки подтверждены гостем.") : t("Ожидаем состояние гостя…")}</small>
{view?.state?.noiseSuppressionState === "pending" && <small role="status">{t("Обработка применится при включении микрофона.")}</small>}
{view?.state && !["on", "off", "pending"].includes(view.state.noiseSuppressionState || "") && <small role="status">{t("Браузер гостя не подтвердил состояние шумоподавления.")}</small>}
{view?.state?.videoCodec && (view.state.videoEnabled === false || view.state.actualVideoCodec !== view.state.videoCodec) && <small role="status">{view.state.videoEnabled === false ? t("Кодек применится при включении камеры.") : t("Кодек камеры не подтверждён.")}</small>}
{view?.state?.audioProfile === "raw" && <small>{t("Для оригинального звука гостю нужны наушники.")}</small>}
</fieldset>
{allowDeviceControl && <fieldset className="guest-media-group"><legend>{t("Устройства гостя")}</legend><StudioGuestDeviceControls studioId={studioId} roomName={roomName} identity={identity} name={name} connected={connected} /></fieldset>}
<details className="guest-media-help"><summary>{t("Настройки")}</summary>
Expand Down
Loading
Loading