From 1dfa10b09bfc0278ac605709775c6bd747c77d13 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 30 Mar 2021 11:20:27 +0200 Subject: [PATCH] Adding an UI for the new client audio processing unit --- ChangeLog.md | 5 + shared/css/load-css.tsx | 1 + shared/css/static/color-variables.scss | 45 ++ shared/js/ConnectionHandler.ts | 8 +- shared/js/audio/Recorder.ts | 20 +- shared/js/ui/frames/HostBannerRenderer.scss | 5 +- shared/js/ui/modal/ModalNewcomer.tsx | 3 +- shared/js/ui/modal/ModalSettings.tsx | 3 +- .../js/ui/modal/input-processor/Controller.ts | 140 ++++++ .../js/ui/modal/input-processor/Definitios.ts | 18 + .../js/ui/modal/input-processor/Renderer.scss | 286 ++++++++++++ .../js/ui/modal/input-processor/Renderer.tsx | 415 ++++++++++++++++++ .../js/ui/modal/permission/ModalRenderer.scss | 43 -- shared/js/ui/modal/settings/Microphone.scss | 16 +- shared/js/ui/modal/settings/Microphone.tsx | 205 ++++----- .../modal/settings/MicrophoneDefinitions.ts | 96 ++++ .../ui/modal/settings/MicrophoneRenderer.tsx | 369 ++++++++++------ shared/js/ui/react-elements/InputField.scss | 1 + shared/js/ui/react-elements/InputField.tsx | 2 +- .../js/ui/react-elements/modal/Definitions.ts | 5 + shared/js/ui/react-elements/modal/Registry.ts | 6 +- .../modal/external/renderer/EntryPoint.ts | 12 +- .../external/renderer/ModalRenderer.scss | 1 + shared/js/ui/utils/Variable.ts | 2 +- shared/js/voice/RecorderBase.ts | 199 +++++++++ shared/js/voice/RecorderProfile.ts | 21 +- web/app/WebWindowManager.ts | 16 +- web/app/audio/Recorder.ts | 38 +- webpack.config.ts | 4 +- 29 files changed, 1631 insertions(+), 354 deletions(-) create mode 100644 shared/css/static/color-variables.scss create mode 100644 shared/js/ui/modal/input-processor/Controller.ts create mode 100644 shared/js/ui/modal/input-processor/Definitios.ts create mode 100644 shared/js/ui/modal/input-processor/Renderer.scss create mode 100644 shared/js/ui/modal/input-processor/Renderer.tsx create mode 100644 shared/js/ui/modal/settings/MicrophoneDefinitions.ts diff --git a/ChangeLog.md b/ChangeLog.md index 5c4751cc..3b45bb60 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,9 @@ # Changelog: +* **29.03.21** + - Accquiering the default input recorder when opening the settings + - Adding new modal Input Processing Properties for the native client + - Fixed that you can't finish off the name editing by pressing enter + * **25.03.21** - Allowing to directly select the speaker output device - Saving the speaker output device diff --git a/shared/css/load-css.tsx b/shared/css/load-css.tsx index fa2c909e..173bcfa7 100644 --- a/shared/css/load-css.tsx +++ b/shared/css/load-css.tsx @@ -25,6 +25,7 @@ import "./static/modal-serverinfobandwidth.scss" import "./static/modal-serverinfo.scss" import "./static/modal-settings.scss" import "./static/overlay-image-preview.scss" +import "./static/color-variables.scss" import "./static/ts/tab.scss" import "./static/ts/country.scss" \ No newline at end of file diff --git a/shared/css/static/color-variables.scss b/shared/css/static/color-variables.scss new file mode 100644 index 00000000..a64a0d9a --- /dev/null +++ b/shared/css/static/color-variables.scss @@ -0,0 +1,45 @@ +html:root { + /* Permission editor modal */ + --modal-permissions-header-text: #e1e1e1; + --modal-permissions-header-background: #19191b; + --modal-permissions-header-hover: #4e4e4e; + --modal-permissions-header-selected: #0073d4; + + --modal-permission-right: #303036; + --modal-permission-left: #222226; + + --modal-permissions-entry-hover: #28282c; + --modal-permissions-entry-selected: #111111; + --modal-permissions-current-group: #101012; + + --modal-permissions-buttons-background: #0f0f0f; + --modal-permissions-buttons-hover: #262626; + --modal-permissions-buttons-disabled: #1b1b1b; + + --modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */ + --modal-permissions-container-seperator: #222224; /* the seperator between left and right */ + + --modal-permissions-icon-select: #121213; + --modal-permissions-icon-select-border: #0d0d0d; + --modal-permissions-icon-select-hover: #17171a; + --modal-permissions-icon-select-hover-border: #333333; + + --modal-permission-no-permnissions: #18171c; + --modal-permissions-table-border: #1e2025; + + --modal-permissions-table-header: #303036; + --modal-permissions-table-row-odd: #303036; + --modal-permissions-table-row-even: #25252a; + --modal-permissions-table-row-hover: #343a47; + + --modal-permissions-table-header-text: #e1e1e1; + --modal-permissions-table-row-text: #535455; + --modal-permissions-table-entry-active-text: #e1e1e1; + --modal-permissions-table-entry-group-text: #e1e1e1; + + --modal-permissions-table-input: #e1e1e1; + --modal-permissions-table-input-focus: #3f7dbf; + + /* The host banner */ + --hostbanner-background: #2e2e2e; +} \ No newline at end of file diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 965c3acf..9d5da2ae 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -5,21 +5,17 @@ import {ServerSettings, Settings, settings, StaticSettings} from "./settings"; import {Sound, SoundManager} from "./audio/Sounds"; import {ConnectionProfile} from "./profiles/ConnectionProfile"; import {LogCategory, logError, logInfo, logTrace, logWarn} from "./log"; -import {createErrorModal, createInfoModal, createInputModal, Modal} from "./ui/elements/Modal"; +import {createErrorModal, createInputModal, Modal} from "./ui/elements/Modal"; import {hashPassword} from "./utils/helpers"; import {HandshakeHandler} from "./connection/HandshakeHandler"; import * as htmltags from "./ui/htmltags"; import {FilterMode, InputStartError, InputState} from "./voice/RecorderBase"; -import {CommandResult} from "./connection/ServerConnectionDeclaration"; import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile"; import {Regex} from "./ui/modal/ModalConnect"; import {formatMessage} from "./ui/frames/chat"; -import {spawnAvatarUpload} from "./ui/modal/ModalAvatar"; import {EventHandler, Registry} from "./events"; import {FileManager} from "./file/FileManager"; -import {FileTransferState, TransferProvider} from "./file/Transfer"; -import {tr, traj} from "./i18n/localize"; -import {md5} from "./crypto/md5"; +import {tr} from "./i18n/localize"; import {guid} from "./crypto/uid"; import {PluginCmdRegistry} from "./connection/PluginCmdHandler"; import {VoiceConnectionStatus, WhisperSessionInitializeData} from "./connection/VoiceConnection"; diff --git a/shared/js/audio/Recorder.ts b/shared/js/audio/Recorder.ts index 28e4085e..4cae3d66 100644 --- a/shared/js/audio/Recorder.ts +++ b/shared/js/audio/Recorder.ts @@ -1,17 +1,11 @@ -import * as loader from "tc-loader"; -import {Stage} from "tc-loader"; import {AbstractInput, LevelMeter} from "../voice/RecorderBase"; import {Registry} from "../events"; -import {Settings, settings} from "tc-shared/settings"; export interface AudioRecorderBacked { createInput() : AbstractInput; createLevelMeter(device: InputDevice) : Promise; getDeviceList() : DeviceList; - - isRnNoiseSupported() : boolean; - toggleRnNoise(target: boolean); } export interface DeviceListEvents { @@ -165,16 +159,4 @@ export function setRecorderBackend(instance: AudioRecorderBacked) { } recorderBackend = instance; -} - -loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { - name: "audio filter init", - priority: 10, - function: async () => { - const backend = getRecorderBackend(); - if(backend.isRnNoiseSupported()) { - getRecorderBackend().toggleRnNoise(settings.getValue(Settings.KEY_RNNOISE_FILTER)); - settings.globalChangeListener(Settings.KEY_RNNOISE_FILTER, value => getRecorderBackend().toggleRnNoise(value)); - } - } -}) \ No newline at end of file +} \ No newline at end of file diff --git a/shared/js/ui/frames/HostBannerRenderer.scss b/shared/js/ui/frames/HostBannerRenderer.scss index 8dd124ec..36cc859e 100644 --- a/shared/js/ui/frames/HostBannerRenderer.scss +++ b/shared/js/ui/frames/HostBannerRenderer.scss @@ -1,10 +1,6 @@ @import "../../../css/static/properties"; @import "../../../css/static/mixin"; -html:root { - --hostbanner-background: #2e2e2e; -} - .container { position: relative; overflow: hidden; @@ -14,6 +10,7 @@ html:root { justify-content: stretch; flex-shrink: 0; + user-select: none; .withBackground { background-color: var(--hostbanner-background); diff --git a/shared/js/ui/modal/ModalNewcomer.tsx b/shared/js/ui/modal/ModalNewcomer.tsx index ca625b47..0cf2e6e8 100644 --- a/shared/js/ui/modal/ModalNewcomer.tsx +++ b/shared/js/ui/modal/ModalNewcomer.tsx @@ -3,10 +3,11 @@ import {tra} from "tc-shared/i18n/localize"; import {Registry} from "tc-shared/events"; import {modal_settings, SettingProfileEvents} from "tc-shared/ui/modal/ModalSettings"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; -import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; +import {initialize_audio_microphone_controller} from "tc-shared/ui/modal/settings/Microphone"; import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; import * as React from "react"; import * as ReactDOM from "react-dom"; +import {MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/MicrophoneDefinitions"; export interface EventModalNewcomer { "show_step": { diff --git a/shared/js/ui/modal/ModalSettings.tsx b/shared/js/ui/modal/ModalSettings.tsx index c87b8517..4cb7b5aa 100644 --- a/shared/js/ui/modal/ModalSettings.tsx +++ b/shared/js/ui/modal/ModalSettings.tsx @@ -23,9 +23,10 @@ import {KeyMapSettings} from "tc-shared/ui/modal/settings/Keymap"; import * as React from "react"; import * as ReactDOM from "react-dom"; import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications"; -import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; +import {initialize_audio_microphone_controller} from "tc-shared/ui/modal/settings/Microphone"; import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; import {getBackend} from "tc-shared/backend"; +import {MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/MicrophoneDefinitions"; type ProfileInfoEvent = { id: string, diff --git a/shared/js/ui/modal/input-processor/Controller.ts b/shared/js/ui/modal/input-processor/Controller.ts new file mode 100644 index 00000000..a18cb5fe --- /dev/null +++ b/shared/js/ui/modal/input-processor/Controller.ts @@ -0,0 +1,140 @@ +import { + InputProcessor, + kInputProcessorConfigRNNoiseKeys, + kInputProcessorConfigWebRTCKeys +} from "tc-shared/voice/RecorderBase"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; +import {Registry} from "tc-events"; +import {ModalInputProcessorEvents, ModalInputProcessorVariables} from "tc-shared/ui/modal/input-processor/Definitios"; +import {createIpcUiVariableProvider, IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable"; +import _ from "lodash"; +import {LogCategory, logError, logTrace} from "tc-shared/log"; + +class Controller { + readonly events: Registry; + readonly variables: IpcUiVariableProvider; + + private readonly processor: InputProcessor; + private statisticsTask: number; + + private currentConfigRNNoise; + private currentConfigWebRTC; + private currentStatistics; + + private filter: string; + + constructor(processor: InputProcessor) { + this.processor = processor; + + this.events = new Registry(); + this.variables = createIpcUiVariableProvider(); + + this.filter = ""; + + for(const key of kInputProcessorConfigRNNoiseKeys) { + this.variables.setVariableProvider(key, () => this.currentConfigRNNoise[key]); + this.variables.setVariableEditor(key, newValue => { + if(this.currentConfigRNNoise[key] === newValue) { + return true; + } + + try { + const update = {}; + update[key] = newValue; + this.processor.applyProcessorConfig("rnnoise", update as any); + } catch (error) { + logTrace(LogCategory.AUDIO, tr("Tried to apply rnnoise: %o"), this.currentConfigRNNoise); + this.sendApplyError(error); + return false; + } + + this.currentConfigRNNoise[key] = newValue; + return true; + }); + } + + for(const key of kInputProcessorConfigWebRTCKeys) { + this.variables.setVariableProvider(key, () => this.currentConfigWebRTC[key]); + this.variables.setVariableEditor(key, newValue => { + if(this.currentConfigWebRTC[key] === newValue) { + return true; + } + + try { + const update = {}; + update[key] = newValue; + this.processor.applyProcessorConfig("webrtc-processing", update as any); + } catch (error) { + logTrace(LogCategory.AUDIO, tr("Tried to apply webrtc-processing: %o"), this.currentConfigWebRTC); + this.sendApplyError(error); + return false; + } + + this.currentConfigWebRTC[key] = newValue; + return true; + }); + } + + this.variables.setVariableProvider("propertyFilter", () => this.filter); + this.variables.setVariableEditor("propertyFilter", newValue => { + this.filter = newValue; + }); + + this.currentConfigRNNoise = this.processor.getProcessorConfig("rnnoise"); + this.currentConfigWebRTC = this.processor.getProcessorConfig("webrtc-processing"); + + this.events.on("query_statistics", () => this.sendStatistics(true)); + + this.statisticsTask = setInterval(() => { + this.sendStatistics(false); + }, 250); + } + + destroy() { + clearInterval(this.statisticsTask); + this.events.destroy(); + this.variables.destroy(); + } + + sendStatistics(force: boolean) { + const statistics = this.processor.getStatistics(); + if(!force && _.isEqual(this.currentStatistics, statistics)) { + return; + } + + this.currentStatistics = statistics; + this.events.fire_react("notify_statistics", { statistics: statistics }); + } + + sendApplyError(error: any) { + if(error instanceof Error) { + error = error.message; + } else if(typeof error !== "string") { + logError(LogCategory.AUDIO, tr("Failed to apply new processor config: %o"), error); + error = tr("lookup the console"); + } + + this.events.fire("notify_apply_error", { message: error }); + } +} + +export function spawnInputProcessorModal(processor: InputProcessor) { + if(__build.target !== "client") { + throw tr("only the native client supports such modal"); + } + + const controller = new Controller(processor); + + const modal = spawnModal("modal-input-processor", [ + controller.events.generateIpcDescription(), + controller.variables.generateConsumerDescription() + ], { + popoutable: true, + popedOut: true + }); + + modal.getEvents().on("destroy", () => { + controller.destroy(); + }); + modal.show().then(undefined); +} \ No newline at end of file diff --git a/shared/js/ui/modal/input-processor/Definitios.ts b/shared/js/ui/modal/input-processor/Definitios.ts new file mode 100644 index 00000000..61efd0f5 --- /dev/null +++ b/shared/js/ui/modal/input-processor/Definitios.ts @@ -0,0 +1,18 @@ +import { + InputProcessorConfigRNNoise, + InputProcessorConfigWebRTC, + InputProcessorStatistics +} from "tc-shared/voice/RecorderBase"; + +export type ModalInputProcessorVariables = { + propertyFilter: string +} & InputProcessorConfigRNNoise & InputProcessorConfigWebRTC; +export interface ModalInputProcessorEvents { + query_statistics: {}, + notify_statistics: { + statistics: InputProcessorStatistics, + }, + notify_apply_error: { + message: string, + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/input-processor/Renderer.scss b/shared/js/ui/modal/input-processor/Renderer.scss new file mode 100644 index 00000000..cc969f6c --- /dev/null +++ b/shared/js/ui/modal/input-processor/Renderer.scss @@ -0,0 +1,286 @@ +@import "../../../../css/static/mixin.scss"; +@import "../../../../css/static/properties.scss"; + +.container { + padding: .5em; + + min-height: 25em; + min-width: 35em; + + display: flex; + flex-direction: column; + justify-content: stretch; + + user-select: none; + + .title { + color: #557edc; + text-transform: uppercase; + } + + &.windowed { + height: 100%; + width: 100%; + } +} + +.containerStatistics { + margin-top: 1em; + + .statistics { + display: flex; + flex-direction: row; + justify-content: flex-start; + flex-wrap: wrap; + + .statistic { + width: 50%; + + display: flex; + flex-direction: row; + justify-content: space-between; + + .key { + flex-shrink: 1; + min-width: 4em; + + @include text-dotdotdot(); + } + + .value { + flex-shrink: 1; + min-width: 2em; + + @include text-dotdotdot(); + + .unset { + color: #666; + } + } + + &:nth-child(2n + 1) { + padding-right: .5em; + } + + &:nth-child(2n) { + padding-left: .5em; + } + } + } +} + +.containerProperties { + flex-grow: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 5em; + + .title { + flex-shrink: 0; + flex-grow: 0; + } + + .table { + flex-shrink: 1; + flex-grow: 1; + + min-height: 4em; + + display: flex; + flex-direction: column; + } + + .note { + flex-shrink: 0; + } + + .containerFilter { + margin-top: 1em; + } +} + +.tableBody { + flex-shrink: 1; + + height: 100%; + min-height: 2em; + + overflow-x: hidden; + overflow-y: scroll; + + position: relative; + + @include chat-scrollbar-vertical(); + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + z-index: 1; + background-color: var(--modal-permission-right); + + padding-top: 2em; + + a { + text-align: center; + font-size: 1.6em; + + color: var(--modal-permission-loading); + } + + &.hidden { + opacity: 0; + pointer-events: none; + } + + &.error { + a { + color: var(--modal-permission-error); + } + } + } + + .tableEntry { + &:hover { + background-color: var(--modal-permissions-table-row-hover); + } + } +} + +.tableHeader { + flex-grow: 0; + flex-shrink: 0; + + margin-right: .5em; /* scroll bar width */ + + .header { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .column { + color: var(--modal-permissions-table-header-text); + font-weight: bold; + } + + .tooltip { + display: flex; + margin-left: .5em; + + width: 1.1em; + height: 1.1em; + + img { + height: 100%; + width: 100%; + } + } +} + +$border-color: #070708; +.tableEntry { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + line-height: 1.8em; + height: 2em; + + color: var(--modal-permissions-table-entry-active-text); + + .column { + display: flex; + flex-direction: row; + justify-content: flex-start; + + padding-left: 1em; + + border: none; + border-right: 1px solid $border-color; + + &:last-of-type { + border-right: none; + } + + &.name { + flex-grow: 1; + flex-shrink: 1; + + min-width: 5em; + + display: flex; + flex-direction: column; + justify-content: center; + + .text { + width: 100%; + + display: block; + align-self: flex-start; + + @include text-dotdotdot(); + } + } + + &.value { + flex-grow: 0; + flex-shrink: 0; + + justify-content: center; + + width: 15em; + + padding: .25em; + + .containerInput { + flex-shrink: 1; + flex-grow: 1; + + min-width: 5em; + + height: 1.5em; + width: 100%; + + font-size: .9em; + + input { + text-align: right; + } + + select { + direction: rtl; + } + } + } + + > * { + align-self: center; + } + } + + &:nth-of-type(2n) { + background-color: var(--modal-permissions-table-row-even); + } + + border-bottom: 1px solid $border-color; + + &:last-of-type { + border-bottom: none; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/input-processor/Renderer.tsx b/shared/js/ui/modal/input-processor/Renderer.tsx new file mode 100644 index 00000000..c8d8bb8b --- /dev/null +++ b/shared/js/ui/modal/input-processor/Renderer.tsx @@ -0,0 +1,415 @@ +import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; +import React, {useContext, useState} from "react"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {IpcRegistryDescription, Registry} from "tc-events"; +import {ModalInputProcessorEvents, ModalInputProcessorVariables} from "tc-shared/ui/modal/input-processor/Definitios"; +import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable"; +import {UiVariableConsumer} from "tc-shared/ui/utils/Variable"; +import {InputProcessorStatistics} from "tc-shared/voice/RecorderBase"; +import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; +import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; +import {ControlledBoxedInputField, ControlledSelect} from "tc-shared/ui/react-elements/InputField"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {traj} from "tc-shared/i18n/localize"; + +const cssStyle = require("./Renderer.scss"); +const EventContext = React.createContext>(undefined); +const VariableContext = React.createContext>(undefined); +const StatisticsContext = React.createContext(undefined); + +const TablePropertiesHead = React.memo(() => ( +
+
+
+ Property +
+
+
+
+ Value +
+
+
+)); + +type PropertyType = { + type: "integer" | "float" | "boolean" +} | { + type: "select", + values: string[] +}; + +const PropertyValueBooleanRenderer = React.memo((props: { property: keyof ModalInputProcessorVariables }) => { + const variables = useContext(VariableContext); + const value = variables.useVariable(props.property); + + if(value.status === "loading") { + return null; + } else { + return ( + value.setValue(newValue)} + /> + ); + } +}); + +const PropertyValueNumberRenderer = React.memo((props: { property: keyof ModalInputProcessorVariables }) => { + const variables = useContext(VariableContext); + const value = variables.useVariable(props.property); + + if(value.status === "loading") { + return null; + } else { + return ( + value.setValue(newValue as any, true)} + onBlur={() => { + const targetValue = parseFloat(value.localValue as any); + if(isNaN(targetValue)) { + value.setValue(value.remoteValue, true); + } else { + value.setValue(targetValue, false); + } + }} + finishOnEnter={true} + /> + ); + } +}); + +const PropertyValueSelectRenderer = React.memo((props: { property: keyof ModalInputProcessorVariables, options: string[] }) => { + const variables = useContext(VariableContext); + const value = variables.useVariable(props.property); + + if(value.status === "loading") { + return null; + } else { + const index = props.options.indexOf(value.localValue as any); + return ( + { + const targetIndex = parseInt(event.target.value); + if(isNaN(targetIndex)) { + return; + } + + value.setValue(props.options[targetIndex]); + }} + > + + {props.options.map((option, index) => ( + + )) as any} + + ); + } +}); + +const TablePropertyValue = React.memo((props: { property: keyof ModalInputProcessorVariables, type: PropertyType }) => { + const variables = useContext(VariableContext); + const filter = variables.useReadOnly("propertyFilter", undefined, undefined); + if(typeof filter === "string" && filter.length > 0) { + const key = props.property as string; + if(key.toLowerCase().indexOf(filter) === -1) { + return null; + } + } + + let value; + switch (props.type.type) { + case "integer": + case "float": + value = ; + break; + + case "boolean": + value = ; + break; + + case "select": + value = ; + break; + } + + return ( +
+
+
{props.property}
+
+
+ {value} +
+
+ ); +}); + +const PropertyFilter = React.memo(() => { + const variables = useContext(VariableContext); + const filter = variables.useVariable("propertyFilter"); + + return ( +
+ filter.setValue(newValue)} + value={filter.localValue} + placeholder={useTr("Filter")} + /> +
+ ) +}); + +const Properties = React.memo(() => { + return ( +
+
+ Properties +
+
+ Note: All changes are temporary and will be reset on the next restart. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ ) +}) + +const StatisticValue = React.memo((props: { statisticKey: keyof InputProcessorStatistics }) => { + const statistics = useContext(StatisticsContext); + + let value; + switch(statistics ? typeof statistics[props.statisticKey] : "undefined") { + case "number": + value = statistics[props.statisticKey].toPrecision(4); + break; + + case "boolean": + value = statistics[props.statisticKey] ? ( + true + ) : ( + false + ); + break; + + case "string": + value = statistics[props.statisticKey]; + break; + + default: + value = ( +
+ unset +
+ ); + break; + } + + return value; +}); + +const Statistic = React.memo((props: { statisticKey: keyof InputProcessorStatistics, children }) => ( +
+
+ {props.children}: +
+
+ +
+
+)); + +const StatisticsProvider = React.memo((props: { children }) => { + const events = useContext(EventContext); + const [ statistics, setStatistics ] = useState(() => { + events.fire("query_statistics"); + return undefined; + }); + + events.reactUse("notify_statistics", event => setStatistics(event.statistics), undefined, []); + + return ( + + {props.children} + + ); +}); + +const Statistics = React.memo(() => ( + +
+
+ Statistics +
+
+ + Output RMS (dbfs) + + + + Voice detected + + + + Echo return loss + + + + Echo return loss enchancement + + + + Delay median (ms) + + + + Delay (ms) + + + + Delay standard deviation (ms) + + + + Divergent filter fraction + + + + Residual echo likelihood + + + + Residual echo likelihood (max) + + + + RNNoise volume + +
+
+
+)); + +class Modal extends AbstractModal { + private readonly events: Registry; + private readonly variables: UiVariableConsumer; + + constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor) { + super(); + + this.events = Registry.fromIpcDescription(events); + this.variables = createIpcUiVariableConsumer(variables); + + this.events.on("notify_apply_error", event => { + createErrorModal(tr("Failed to apply changes"), traj("Failed to apply changes:{:br:}{}", event.message)).open(); + }) + } + + protected onDestroy() { + super.onDestroy(); + + this.events.destroy(); + this.variables.destroy(); + } + + renderBody(): React.ReactElement { + return ( + + +
+ + +
+
+
+ ); + } + + renderTitle(): string | React.ReactElement { + return Input processor properties; + } + +} + +export default Modal; \ No newline at end of file diff --git a/shared/js/ui/modal/permission/ModalRenderer.scss b/shared/js/ui/modal/permission/ModalRenderer.scss index 3318f8f5..e136d654 100644 --- a/shared/js/ui/modal/permission/ModalRenderer.scss +++ b/shared/js/ui/modal/permission/ModalRenderer.scss @@ -1,49 +1,6 @@ @import "../../../../css/static/mixin"; @import "../../../../css/static/properties"; -html:root { - --modal-permissions-header-text: #e1e1e1; - --modal-permissions-header-background: #19191b; - --modal-permissions-header-hover: #4e4e4e; - --modal-permissions-header-selected: #0073d4; - - --modal-permission-right: #303036; - --modal-permission-left: #222226; - - --modal-permissions-entry-hover: #28282c; - --modal-permissions-entry-selected: #111111; - --modal-permissions-current-group: #101012; - - --modal-permissions-buttons-background: #0f0f0f; - --modal-permissions-buttons-hover: #262626; - --modal-permissions-buttons-disabled: #1b1b1b; - - --modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */ - --modal-permissions-container-seperator: #222224; /* the seperator between left and right */ - - --modal-permissions-icon-select: #121213; - --modal-permissions-icon-select-border: #0d0d0d; - --modal-permissions-icon-select-hover: #17171a; - --modal-permissions-icon-select-hover-border: #333333; - - --modal-permission-no-permnissions: #18171c; - --modal-permissions-table-border: #1e2025; - - --modal-permissions-table-header: #303036; - --modal-permissions-table-row-odd: #303036; - --modal-permissions-table-row-even: #25252a; - --modal-permissions-table-row-hover: #343a47; - - --modal-permissions-table-header-text: #e1e1e1; - --modal-permissions-table-row-text: #535455; - --modal-permissions-table-entry-active-text: #e1e1e1; - --modal-permissions-table-entry-group-text: #e1e1e1; - - --modal-permissions-table-input: #e1e1e1; - --modal-permissions-table-input-focus: #3f7dbf; -} - - .container { @include user-select(none); diff --git a/shared/js/ui/modal/settings/Microphone.scss b/shared/js/ui/modal/settings/Microphone.scss index 9721b0e2..bba246b9 100644 --- a/shared/js/ui/modal/settings/Microphone.scss +++ b/shared/js/ui/modal/settings/Microphone.scss @@ -260,7 +260,7 @@ padding-bottom: .5em; - a { + .text { flex-grow: 1; flex-shrink: 1; @@ -284,6 +284,20 @@ margin-left: 1em; min-width: 8em; } + + .icon { + flex-shrink: 0; + flex-grow: 0; + + align-self: flex-end; + + width: 1.2em; + height: 1.2em; + } +} + +.tooltipContainer { + min-width: 14em; } .containerDevices { diff --git a/shared/js/ui/modal/settings/Microphone.tsx b/shared/js/ui/modal/settings/Microphone.tsx index 64a83a91..49626181 100644 --- a/shared/js/ui/modal/settings/Microphone.tsx +++ b/shared/js/ui/modal/settings/Microphone.tsx @@ -1,92 +1,21 @@ import * as React from "react"; import {Registry} from "tc-shared/events"; -import {LevelMeter} from "tc-shared/voice/RecorderBase"; -import {LogCategory, logTrace, logWarn} from "tc-shared/log"; +import {AbstractInput, FilterMode, LevelMeter} from "tc-shared/voice/RecorderBase"; +import {LogCategory, logError, logTrace, logWarn} from "tc-shared/log"; import {defaultRecorder} from "tc-shared/voice/RecorderProfile"; -import {DeviceListState, getRecorderBackend, InputDevice} from "tc-shared/audio/Recorder"; +import {getRecorderBackend, InputDevice} from "tc-shared/audio/Recorder"; import {Settings, settings} from "tc-shared/settings"; import {getBackend} from "tc-shared/backend"; import * as _ from "lodash"; import {getAudioBackend} from "tc-shared/audio/Player"; - -export type MicrophoneSetting = - "volume" - | "vad-type" - | "ppt-key" - | "ppt-release-delay" - | "ppt-release-delay-active" - | "threshold-threshold" - | "rnnoise"; - -export type MicrophoneDevice = { - id: string, - name: string, - driver: string, - default: boolean -}; - -export type SelectedMicrophone = { type: "default" } | { type: "none" } | { type: "device", deviceId: string }; -export type MicrophoneDevices = { - status: "error", - error: string -} | { - status: "audio-not-initialized" -} | { - status: "no-permissions", - shouldAsk: boolean -} | { - status: "success", - devices: MicrophoneDevice[] - selectedDevice: SelectedMicrophone; -}; -export interface MicrophoneSettingsEvents { - "query_devices": { refresh_list: boolean }, - "query_help": {}, - "query_setting": { - setting: MicrophoneSetting - }, - - "action_help_click": {}, - "action_request_permissions": {}, - "action_set_selected_device": { target: SelectedMicrophone }, - "action_set_selected_device_result": { - status: "error", - reason: string - }, - - "action_set_setting": { - setting: MicrophoneSetting; - value: any; - }, - - notify_setting: { - setting: MicrophoneSetting; - value: any; - } - - notify_devices: MicrophoneDevices, - notify_device_selected: { device: SelectedMicrophone }, - - notify_device_level: { - level: { - [key: string]: { - deviceId: string, - status: "success" | "error", - - level?: number, - error?: string - } - }, - - status: Exclude - }, - - notify_highlight: { - field: "hs-0" | "hs-1" | "hs-2" | undefined - } - - notify_destroy: {} -} +import { + InputDeviceLevel, + MicrophoneSettingsEvents, + SelectedMicrophone +} from "tc-shared/ui/modal/settings/MicrophoneDefinitions"; +import {spawnInputProcessorModal} from "tc-shared/ui/modal/input-processor/Controller"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {server_connections} from "tc-shared/ConnectionManager"; export function initialize_audio_microphone_controller(events: Registry) { const recorderBackend = getRecorderBackend(); @@ -219,6 +148,47 @@ export function initialize_audio_microphone_controller(events: Registry { + try { + levelMeter?.destroy(); + + levelMeter = input.createLevelMeter(); + levelMeter.setObserver(value => { + currentLevel = { status: "success", level: value }; + events.fire_react("notify_input_level", { level: currentLevel }); + }); + + currentLevel = { status: "success", level: 0 }; + } catch (error) { + if(typeof error !== "string") { + logError(LogCategory.GENERAL, tr("Failed to create input device level meter: %o"), error); + error = tr("lookup the console"); + } + + currentLevel = { status: "error", message: error }; + } + events.fire_react("notify_input_level", { level: currentLevel }); + } + + events.on("notify_destroy", () => { + levelMeter?.setObserver(undefined); + levelMeter?.destroy(); + }); + + events.on("query_input_level", () => events.fire_react("notify_input_level", { level: currentLevel })); + + if(defaultRecorder.input) { + initializeInput(defaultRecorder.input); + } else { + events.on("notify_destroy", defaultRecorder.events.one("notify_input_initialized", () => initializeInput(defaultRecorder.input))); + } + } + /* device list */ { const currentSelectedDevice = (): SelectedMicrophone => { @@ -444,6 +414,16 @@ export function initialize_audio_microphone_controller(events: Registry { + const processor = defaultRecorder.input?.getInputProcessor(); + if(!processor) { + createErrorModal(tr("Missing input processor"), tr("Missing default recorders input processor.")).open(); + return; + } + + spawnInputProcessorModal(processor); + }); + events.on("notify_destroy", recorderBackend.getDeviceList().getEvents().on("notify_list_updated", () => { events.fire("query_devices"); })); @@ -455,45 +435,28 @@ export function initialize_audio_microphone_controller(events: Registry events.fire_react("query_devices")); } -} -/* -import * as loader from "tc-loader"; -import {Stage} from "tc-loader"; -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; -import {Translatable} from "tc-shared/ui/react-elements/i18n"; -import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; - -loader.register_task(Stage.LOADED, { - name: "test", - function: async () => { - aplayer.on_ready(() => { - const modal = spawnReactModal(class extends InternalModal { - settings = new Registry(); - constructor() { - super(); - - initialize_audio_microphone_controller(this.settings); - } - - renderBody(): React.ReactElement { - return
- -
; - } - - title(): string | React.ReactElement { - return "test"; - } + /* TODO: Only do this on user request? */ + { + const ownDefaultRecorder = () => { + const originalHandlerId = defaultRecorder.current_handler?.handlerId; + defaultRecorder.unmount().then(() => { + defaultRecorder.input.start().catch(error => { + logError(LogCategory.AUDIO, tr("Failed to start default input: %o"), error); + }); }); - modal.show(); - }); - }, - priority: -2 -}); -*/ \ No newline at end of file + events.on("notify_destroy", () => { + server_connections.findConnection(originalHandlerId)?.acquireInputHardware().catch(error => { + logError(LogCategory.GENERAL, tr("Failed to acquire microphone after settings detach: %o"), error); + }); + }); + }; + + if(defaultRecorder.input) { + ownDefaultRecorder(); + } else { + events.on("notify_destroy", defaultRecorder.events.one("notify_input_initialized", () => ownDefaultRecorder())); + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/settings/MicrophoneDefinitions.ts b/shared/js/ui/modal/settings/MicrophoneDefinitions.ts new file mode 100644 index 00000000..e683c187 --- /dev/null +++ b/shared/js/ui/modal/settings/MicrophoneDefinitions.ts @@ -0,0 +1,96 @@ +import {DeviceListState} from "tc-shared/audio/Recorder"; + +export type MicrophoneSetting = + "volume" + | "vad-type" + | "ppt-key" + | "ppt-release-delay" + | "ppt-release-delay-active" + | "threshold-threshold" + | "rnnoise"; + +export type MicrophoneDevice = { + id: string, + name: string, + driver: string, + default: boolean +}; + +export type SelectedMicrophone = { type: "default" } | { type: "none" } | { type: "device", deviceId: string }; +export type MicrophoneDevices = { + status: "error", + error: string +} | { + status: "audio-not-initialized" +} | { + status: "no-permissions", + shouldAsk: boolean +} | { + status: "success", + devices: MicrophoneDevice[] + selectedDevice: SelectedMicrophone; +}; + +export type InputDeviceLevel = { + status: "success", + level: number +} | { + status: "uninitialized" +} | { + status: "error", + message: string +} + +export interface MicrophoneSettingsEvents { + "query_devices": { refresh_list: boolean }, + "query_help": {}, + "query_setting": { + setting: MicrophoneSetting + }, + "query_input_level": {} + + "action_help_click": {}, + "action_request_permissions": {}, + "action_set_selected_device": { target: SelectedMicrophone }, + "action_set_selected_device_result": { + status: "error", + reason: string + }, + "action_open_processor_properties": {}, + + "action_set_setting": { + setting: MicrophoneSetting; + value: any; + }, + + notify_setting: { + setting: MicrophoneSetting; + value: any; + } + + notify_devices: MicrophoneDevices, + notify_device_selected: { device: SelectedMicrophone }, + + notify_device_level: { + level: { + [key: string]: { + deviceId: string, + status: "success" | "error", + + level?: number, + error?: string + } + }, + + status: Exclude + }, + notify_input_level: { + level: InputDeviceLevel + }, + + notify_highlight: { + field: "hs-0" | "hs-1" | "hs-2" | undefined + } + + notify_destroy: {} +} \ No newline at end of file diff --git a/shared/js/ui/modal/settings/MicrophoneRenderer.tsx b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx index de07fce2..3d370c46 100644 --- a/shared/js/ui/modal/settings/MicrophoneRenderer.tsx +++ b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx @@ -1,9 +1,9 @@ import * as React from "react"; -import {useEffect, useRef, useState} from "react"; +import {useContext, useEffect, useRef, useState} from "react"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {Button} from "tc-shared/ui/react-elements/Button"; import {Registry} from "tc-shared/events"; -import {MicrophoneDevice, MicrophoneSettingsEvents, SelectedMicrophone} from "tc-shared/ui/modal/settings/Microphone"; +import {MicrophoneDevice, MicrophoneSettingsEvents, SelectedMicrophone} from "tc-shared/ui/modal/settings/MicrophoneDefinitions"; import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; @@ -17,8 +17,12 @@ import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; import {HighlightContainer, HighlightRegion, HighlightText} from "./Heighlight"; import {InputDevice} from "tc-shared/audio/Recorder"; +import {joinClassList} from "tc-shared/ui/react-elements/Helper"; +import {IconTooltip} from "tc-shared/ui/react-elements/Tooltip"; +import _ from "lodash"; const cssStyle = require("./Microphone.scss"); +const EventContext = React.createContext>(undefined); type MicrophoneSelectedState = "selected" | "applying" | "unselected"; const MicrophoneStatus = (props: { state: MicrophoneSelectedState }) => { @@ -34,89 +38,134 @@ const MicrophoneStatus = (props: { state: MicrophoneSelectedState }) => { return null; case "selected": - return ; + return ( + + ); } } type ActivityBarStatus = - { mode: "success" } + { mode: "success", level: number } | { mode: "error", message: string } | { mode: "loading" } | { mode: "uninitialized" }; -const ActivityBar = (props: { events: Registry, deviceId: string | "none", disabled?: boolean }) => { - const refHider = useRef(); - const [status, setStatus] = useState({ mode: "loading" }); - if(typeof props.deviceId === "undefined") { - throw "invalid device id"; +const ActivityBarStatusContext = React.createContext({ mode: "loading" }); + +const DeviceActivityBarStatusProvider = React.memo((props: { deviceId: string, children }) => { + const events = useContext(EventContext); + const [ status, setStatus ] = useState({ mode: "loading" }); + + const updateState = (newState: ActivityBarStatus) => { + if(!_.isEqual(newState, status)) { + setStatus(newState); + } } - props.events.reactUse("notify_device_level", event => { - refHider.current.style.width = "100%"; - if (event.status === "uninitialized") { - if (status.mode === "uninitialized") { - return; - } - - setStatus({mode: "uninitialized"}); - } else if (event.status === "no-permissions") { - const noPermissionsMessage = tr("no permissions"); - if (status.mode === "error" && status.message === noPermissionsMessage) { - return; - } - - setStatus({mode: "error", message: noPermissionsMessage}); - } else { + events.reactUse("notify_device_level", event => { + if(event.status === "uninitialized") { + updateState({ mode: "uninitialized" }); + } else if(event.status === "no-permissions") { + updateState({ mode: "error", message: tr("no permissions") }); + } else if(event.status === "healthy") { const device = event.level[props.deviceId]; if (!device) { - if (status.mode === "loading") { - return; - } - - setStatus({mode: "loading"}); - } else if (device.status === "success") { - if (status.mode !== "success") { - setStatus({mode: "success"}); - } - - refHider.current.style.width = (100 - device.level) + "%"; + updateState({ mode: "loading" }); + } else if(device.status === "success") { + updateState({ mode: "success", level: device.level }); } else { - if (status.mode === "error" && status.message === device.error) { - return; - } - - setStatus({mode: "error", message: device.error + ""}); + updateState({ mode: "error", message: device.error }); } } - }, true, [status]); + }, undefined, []); + return ( + + {props.children} + + ); +}); - let error; +const InputActivityBarStatusProvider = React.memo((props: { children }) => { + const events = useContext(EventContext); + const [ status, setStatus ] = useState(() => { + events.fire("query_input_level"); + return { mode: "loading" }; + }); + + events.reactUse("notify_input_level", event => { + switch (event.level.status) { + case "success": + setStatus({ mode: "success", level: event.level.level }); + break; + + case "error": + setStatus({ mode: "error", message: event.level.message }); + break; + + case "uninitialized": + setStatus({ mode: "uninitialized" }); + break; + + default: + setStatus({ mode: "error", message: tr("unknown status") }); + break; + } + }, undefined, []); + + return ( + + {props.children} + + ); +}); + +const ActivityBar = (props: { disabled?: boolean }) => { + const status = useContext(ActivityBarStatusContext); + + let error = undefined; + let hiderWidth = "100%"; switch (status.mode) { case "error": - error =
{status.message}
; + error = ( +
+ {status.message} +
+ ); break; case "loading": - error = -
Loading  -
; + error = ( +
+ Loading  +
+ ); break; case "success": - error = undefined; + hiderWidth = (100 - status.level) + "%"; break; } + return (
-
+
{error}
- ) + ); }; -const Microphone = (props: { events: Registry, device: MicrophoneDevice, state: MicrophoneSelectedState, onClick: () => void }) => { +const Microphone = React.memo((props: { device: MicrophoneDevice, state: MicrophoneSelectedState, onClick: () => void }) => { + let activityBar; + if(props.device.id !== InputDevice.NoDeviceId) { + activityBar = ( + + + + ); + } + return (
@@ -128,13 +177,11 @@ const Microphone = (props: { events: Registry, device:
{props.device.name}
- {props.device.id === InputDevice.NoDeviceId ? undefined : - - } + {activityBar}
); -}; +}); type MicrophoneListState = { type: "normal" | "loading" | "audio-not-initialized" @@ -284,7 +331,6 @@ const MicrophoneList = (props: { events: Registry }) = name: tr("No device"), default: false }} - events={props.events} state={deviceSelectState("none")} onClick={() => { if (state.type !== "normal" || selectedDevice?.selectingDevice) { @@ -295,23 +341,24 @@ const MicrophoneList = (props: { events: Registry }) = }} /> - {deviceList.map(device => { - if (state.type !== "normal" || selectedDevice?.selectingDevice) { - return; - } + {deviceList.map(device => ( + { + if (state.type !== "normal" || selectedDevice?.selectingDevice) { + return; + } - if(device.default) { - props.events.fire("action_set_selected_device", { target: { type: "default" } }); - } else { - props.events.fire("action_set_selected_device", { target: { type: "device", deviceId: device.id } }); - } - }} - />)} + if(device.default) { + props.events.fire("action_set_selected_device", { target: { type: "default" } }); + } else { + props.events.fire("action_set_selected_device", { target: { type: "device", deviceId: device.id } }); + } + }} + /> + ))}
) } @@ -405,33 +452,36 @@ const PPTKeyButton = React.memo((props: { events: Registry }) => { +const PPTDelaySettings = React.memo(() => { + const events = useContext(EventContext); + const [delayActive, setDelayActive] = useState<"loading" | boolean>(() => { - props.events.fire("query_setting", {setting: "ppt-release-delay"}); + events.fire("query_setting", {setting: "ppt-release-delay"}); return "loading"; }); const [delay, setDelay] = useState<"loading" | number>(() => { - props.events.fire("query_setting", {setting: "ppt-release-delay-active"}); + events.fire("query_setting", {setting: "ppt-release-delay-active"}); return "loading"; }); const [isActive, setActive] = useState(false); - props.events.reactUse("notify_setting", event => { - if (event.setting === "vad-type") + events.reactUse("notify_setting", event => { + if (event.setting === "vad-type") { setActive(event.value === "push_to_talk"); - else if (event.setting === "ppt-release-delay") + } else if (event.setting === "ppt-release-delay") { setDelay(event.value); - else if (event.setting === "ppt-release-delay-active") + } else if (event.setting === "ppt-release-delay-active") { setDelayActive(event.value); + } }); return (
{ - props.events.fire("action_set_setting", {setting: "ppt-release-delay-active", value: value}) + events.fire("action_set_setting", {setting: "ppt-release-delay-active", value: value}) }} disabled={!isActive} value={delayActive === true} @@ -458,45 +508,48 @@ const PPTDelaySettings = (props: { events: Registry }) const newValue = event.target.valueAsNumber; - if (isNaN(newValue)) + if (isNaN(newValue)) { return; + } - if (newValue < 0 || newValue > 4000) + if (newValue < 0 || newValue > 4000) { return; + } - props.events.fire("action_set_setting", {setting: "ppt-release-delay", value: newValue}); + events.fire("action_set_setting", {setting: "ppt-release-delay", value: newValue}); }} />} />
); -} +}); -const RNNoiseLabel = () => ( - +const RNNoiseLabel = React.memo(() => ( + more info -) +)); -const RNNoiseSettings = (props: { events: Registry }) => { +const RNNoiseSettings = React.memo(() => { if(__build.target === "web") { return null; } + const events = useContext(EventContext); const [ enabled, setEnabled ] = useState(() => { - props.events.fire("query_setting", { setting: "rnnoise" }); + events.fire("query_setting", { setting: "rnnoise" }); return "loading"; }); - props.events.reactUse("notify_setting", event => event.setting === "rnnoise" && setEnabled(event.value)); + events.reactUse("notify_setting", event => event.setting === "rnnoise" && setEnabled(event.value)); return ( } disabled={enabled === "loading"} value={enabled === true} - onChange={value => props.events.fire("action_set_setting", { setting: "rnnoise", value: value })} + onChange={value => events.fire("action_set_setting", { setting: "rnnoise", value: value })} /> ) -} +}); const VadSelector = (props: { events: Registry }) => { const [selectedType, setSelectedType] = useState(() => { @@ -558,10 +611,11 @@ const VadSelector = (props: { events: Registry }) => { ); } -const ThresholdSelector = (props: { events: Registry }) => { +const ThresholdSelector = React.memo(() => { + const events = useContext(EventContext); const refSlider = useRef(); const [value, setValue] = useState<"loading" | number>(() => { - props.events.fire("query_setting", {setting: "threshold-threshold"}); + events.fire("query_setting", {setting: "threshold-threshold"}); return "loading"; }); @@ -592,7 +646,7 @@ const ThresholdSelector = (props: { events: Registry } } } - props.events.reactUse("notify_setting", event => { + events.reactUse("notify_setting", event => { if (event.setting === "threshold-threshold") { refSlider.current?.setState({value: event.value}); setValue(event.value); @@ -601,7 +655,7 @@ const ThresholdSelector = (props: { events: Registry } } }); - props.events.reactUse("notify_devices", event => { + events.reactUse("notify_devices", event => { if(event.status === "success") { const defaultDevice = event.devices.find(device => device.default); defaultDeviceId.current = defaultDevice?.id; @@ -612,13 +666,15 @@ const ThresholdSelector = (props: { events: Registry } } }); - props.events.reactUse("notify_device_selected", event => changeCurrentDevice(event.device)); + events.reactUse("notify_device_selected", event => changeCurrentDevice(event.device)); let isActive = isVadActive && currentDevice.type === "device"; return (
- + + +
} disabled={value === "loading" || !isActive} onChange={value => { - props.events.fire("action_set_setting", {setting: "threshold-threshold", value: value}) + events.fire("action_set_setting", {setting: "threshold-threshold", value: value}) }} />
) -}; +}); const HelpText0 = () => ( @@ -700,6 +756,24 @@ const HelpText2 = () => ( ); +const InputProcessorButton = React.memo(() => { + const events = useContext(EventContext); + + if(__build.target !== "client") { + return; + } + + return ( + + ); +}); + export const MicrophoneSettings = (props: { events: Registry }) => { const [highlighted, setHighlighted] = useState(() => { props.events.fire("query_help"); @@ -709,44 +783,63 @@ export const MicrophoneSettings = (props: { events: Registry setHighlighted(event.field)); return ( - props.events.fire("action_help_click")} - classList={cssStyle.highlightContainer}> -
- - - - - - - - - -
- - -
- -
- -
- -
-
- - + + props.events.fire("action_help_click")} + classList={cssStyle.highlightContainer} + > +
+ + +
+
+ Select your Microphone Device +
+
-
- -
- + + + + + +
+
+ Microphone Settings +
+
+
+ + +
+
+
+ Sensitivity Settings +
+ +
+ The volume meter will show the processed audio volume as others would hear you. +
+
+
+
+ +
+
+
+ Advanced Settings +
+
+
+
+ + + +
+
+
+
+ + ); } \ No newline at end of file diff --git a/shared/js/ui/react-elements/InputField.scss b/shared/js/ui/react-elements/InputField.scss index 62587c96..cff6fb5a 100644 --- a/shared/js/ui/react-elements/InputField.scss +++ b/shared/js/ui/react-elements/InputField.scss @@ -98,6 +98,7 @@ html:root { flex-shrink: 1; background: transparent; + align-self: center; border: none; outline: none; diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx index 6d4b0b8d..052d6aaf 100644 --- a/shared/js/ui/react-elements/InputField.tsx +++ b/shared/js/ui/react-elements/InputField.tsx @@ -64,7 +64,7 @@ export const ControlledBoxedInputField = (props: { ref={props.refInput} - value={props.value || ""} + value={typeof props.value !== "undefined" ? props.value : ""} placeholder={props.placeholder} readOnly={typeof props.editable === "boolean" ? !props.editable : false} diff --git a/shared/js/ui/react-elements/modal/Definitions.ts b/shared/js/ui/react-elements/modal/Definitions.ts index 1c988b4b..f8ec60d6 100644 --- a/shared/js/ui/react-elements/modal/Definitions.ts +++ b/shared/js/ui/react-elements/modal/Definitions.ts @@ -20,6 +20,7 @@ import {PermissionModalEvents} from "tc-shared/ui/modal/permission/ModalDefiniti import {PermissionEditorEvents} from "tc-shared/ui/modal/permission/EditorDefinitions"; import {PermissionEditorServerInfo} from "tc-shared/ui/modal/permission/ModalRenderer"; import {ModalAvatarUploadEvents, ModalAvatarUploadVariables} from "tc-shared/ui/modal/avatar-upload/Definitions"; +import {ModalInputProcessorEvents, ModalInputProcessorVariables} from "tc-shared/ui/modal/input-processor/Definitios"; export type ModalType = "error" | "warning" | "info" | "none"; export type ModalRenderType = "page" | "dialog"; @@ -210,5 +211,9 @@ export interface ModalConstructorArguments { /* events */ IpcRegistryDescription, /* variables */ IpcVariableDescriptor, /* serverUniqueId */ string + ], + "modal-input-processor": [ + /* events */ IpcRegistryDescription, + /* variables */ IpcVariableDescriptor, ] } \ No newline at end of file diff --git a/shared/js/ui/react-elements/modal/Registry.ts b/shared/js/ui/react-elements/modal/Registry.ts index 3f04218f..96fda81e 100644 --- a/shared/js/ui/react-elements/modal/Registry.ts +++ b/shared/js/ui/react-elements/modal/Registry.ts @@ -109,4 +109,8 @@ registerModal({ popoutSupported: true }); - +registerModal({ + modalId: "modal-input-processor", + classLoader: async () => await import("tc-shared/ui/modal/input-processor/Renderer"), + popoutSupported: true +}); \ No newline at end of file diff --git a/shared/js/ui/react-elements/modal/external/renderer/EntryPoint.ts b/shared/js/ui/react-elements/modal/external/renderer/EntryPoint.ts index 2334293c..7b9ec12d 100644 --- a/shared/js/ui/react-elements/modal/external/renderer/EntryPoint.ts +++ b/shared/js/ui/react-elements/modal/external/renderer/EntryPoint.ts @@ -3,7 +3,7 @@ import {setupIpcHandler} from "tc-shared/ipc/BrowserIPC"; import {initializeI18N} from "tc-shared/i18n/localize"; import {Stage} from "tc-loader"; import {AbstractModal, constructAbstractModalClass} from "tc-shared/ui/react-elements/modal/Definitions"; -import {AppParameters} from "tc-shared/settings"; +import {AppParameters, Settings, settings} from "tc-shared/settings"; import {setupJSRender} from "tc-shared/ui/jsrender"; import {findRegisteredModal} from "tc-shared/ui/react-elements/modal/Registry"; import {ModalWindowControllerInstance} from "./Controller"; @@ -26,8 +26,16 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { await import("tc-shared/proto"); await initializeI18N(); setupIpcHandler(); - setupJSRender(); + + { + const font = settings.getValue(Settings.KEY_FONT_SIZE); + + document.body.style.fontSize = font + "px"; + settings.globalChangeListener(Settings.KEY_FONT_SIZE, value => { + document.body.style.fontSize = value + "px"; + }); + } } }); diff --git a/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.scss b/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.scss index e428ab65..21e0d675 100644 --- a/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.scss +++ b/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.scss @@ -1,4 +1,5 @@ @import "../../../../../../css/static/mixin"; +@import "../../../../../../css/static/color-variables"; /* FIXME: Remove this wired import */ @import "../../../../../../css/static/general"; diff --git a/shared/js/ui/utils/Variable.ts b/shared/js/ui/utils/Variable.ts index beaa36bb..38674b41 100644 --- a/shared/js/ui/utils/Variable.ts +++ b/shared/js/ui/utils/Variable.ts @@ -404,7 +404,7 @@ export abstract class UiVariableConsumer { }, [ variable, customData ]); if(arguments.length >= 3) { - return cacheEntry.status === "loaded" ? cacheEntry.currentValue : defaultValue; + return cacheEntry.status === "loaded" || cacheEntry.status === "applying" ? cacheEntry.currentValue : defaultValue; } else { return { status: cacheEntry.status, diff --git a/shared/js/voice/RecorderBase.ts b/shared/js/voice/RecorderBase.ts index 25e3d6b0..40d3c97f 100644 --- a/shared/js/voice/RecorderBase.ts +++ b/shared/js/voice/RecorderBase.ts @@ -78,6 +78,196 @@ export enum FilterMode { Block } +/** + * All available options for input processing. + * Since input processing is only available on the native client these are the options + * the native client (especially WebRTC audio processing) have. + */ +export interface InputProcessorConfigWebRTC { + "pipeline.maximum_internal_processing_rate": number, + "pipeline.multi_channel_render": boolean, + "pipeline.multi_channel_capture": boolean, + + "pre_amplifier.enabled": boolean, + "pre_amplifier.fixed_gain_factor": number, + + "high_pass_filter.enabled": boolean, + "high_pass_filter.apply_in_full_band": boolean, + + "echo_canceller.enabled": boolean, + "echo_canceller.mobile_mode": boolean, + "echo_canceller.export_linear_aec_output": boolean, + "echo_canceller.enforce_high_pass_filtering": boolean, + + "noise_suppression.enabled": boolean, + "noise_suppression.level": "low" | "moderate" | "high" | "very-high", + "noise_suppression.analyze_linear_aec_output_when_available": boolean, + + "transient_suppression.enabled": boolean, + + "voice_detection.enabled": boolean, + + "gain_controller1.enabled": boolean, + "gain_controller1.mode": "adaptive-analog" | "adaptive-digital" | "fixed-digital", + "gain_controller1.target_level_dbfs": number, + "gain_controller1.compression_gain_db": number, + "gain_controller1.enable_limiter": boolean, + "gain_controller1.analog_level_minimum": number, + "gain_controller1.analog_level_maximum": number, + + "gain_controller1.analog_gain_controller.enabled": boolean, + "gain_controller1.analog_gain_controller.startup_min_volume": number, + "gain_controller1.analog_gain_controller.clipped_level_min": number, + "gain_controller1.analog_gain_controller.enable_agc2_level_estimator": boolean, + "gain_controller1.analog_gain_controller.enable_digital_adaptive": boolean, + + "gain_controller2.enabled": boolean, + + "gain_controller2.fixed_digital.gain_db": number, + + "gain_controller2.adaptive_digital.enabled": boolean, + "gain_controller2.adaptive_digital.vad_probability_attack": number, + "gain_controller2.adaptive_digital.level_estimator": "rms" | "peak", + "gain_controller2.adaptive_digital.level_estimator_adjacent_speech_frames_threshold": number, + "gain_controller2.adaptive_digital.use_saturation_protector": boolean, + "gain_controller2.adaptive_digital.initial_saturation_margin_db": number, + "gain_controller2.adaptive_digital.extra_saturation_margin_db": number, + "gain_controller2.adaptive_digital.gain_applier_adjacent_speech_frames_threshold": number, + "gain_controller2.adaptive_digital.max_gain_change_db_per_second": number, + "gain_controller2.adaptive_digital.max_output_noise_level_dbfs": number, + + "residual_echo_detector.enabled": boolean, + "level_estimation.enabled": boolean, +} + +/** + * Attention: + * These keys **MUST** be equal to all keys of `InputProcessorConfigWebRTC`. + * All keys not registered in here will not be consideration. + */ +export const kInputProcessorConfigWebRTCKeys: (keyof InputProcessorConfigWebRTC)[] = [ + "pipeline.maximum_internal_processing_rate", + "pipeline.multi_channel_render", + "pipeline.multi_channel_capture", + + "pre_amplifier.enabled", + "pre_amplifier.fixed_gain_factor", + + "high_pass_filter.enabled", + "high_pass_filter.apply_in_full_band", + + "echo_canceller.enabled", + "echo_canceller.mobile_mode", + "echo_canceller.export_linear_aec_output", + "echo_canceller.enforce_high_pass_filtering", + + "noise_suppression.enabled", + "noise_suppression.level", + "noise_suppression.analyze_linear_aec_output_when_available", + + "transient_suppression.enabled", + + "voice_detection.enabled", + + "gain_controller1.enabled", + "gain_controller1.mode", + "gain_controller1.target_level_dbfs", + "gain_controller1.compression_gain_db", + "gain_controller1.enable_limiter", + "gain_controller1.analog_level_minimum", + "gain_controller1.analog_level_maximum", + + "gain_controller1.analog_gain_controller.enabled", + "gain_controller1.analog_gain_controller.startup_min_volume", + "gain_controller1.analog_gain_controller.clipped_level_min", + "gain_controller1.analog_gain_controller.enable_agc2_level_estimator", + "gain_controller1.analog_gain_controller.enable_digital_adaptive", + + "gain_controller2.enabled", + + "gain_controller2.fixed_digital.gain_db", + + "gain_controller2.adaptive_digital.enabled", + "gain_controller2.adaptive_digital.vad_probability_attack", + "gain_controller2.adaptive_digital.level_estimator", + "gain_controller2.adaptive_digital.level_estimator_adjacent_speech_frames_threshold", + "gain_controller2.adaptive_digital.use_saturation_protector", + "gain_controller2.adaptive_digital.initial_saturation_margin_db", + "gain_controller2.adaptive_digital.extra_saturation_margin_db", + "gain_controller2.adaptive_digital.gain_applier_adjacent_speech_frames_threshold", + "gain_controller2.adaptive_digital.max_gain_change_db_per_second", + "gain_controller2.adaptive_digital.max_output_noise_level_dbfs", + + "residual_echo_detector.enabled", + "level_estimation.enabled" +]; + + +export interface InputProcessorConfigRNNoise { + "rnnoise.enabled": boolean +} + +/** + * Attention: + * These keys **MUST** be equal to all keys of `InputProcessorConfigWebRTC`. + * All keys not registered in here will not be consideration. + */ +export const kInputProcessorConfigRNNoiseKeys: (keyof InputProcessorConfigRNNoise)[] = [ + "rnnoise.enabled" +]; + +export interface InputProcessorConfigMapping { + "webrtc-processing": InputProcessorConfigWebRTC, + "rnnoise": InputProcessorConfigRNNoise +} + +export type InputProcessorType = keyof InputProcessorConfigMapping; + +export interface InputProcessorStatistics { + /* WebRTC processor statistics */ + output_rms_dbfs: number | undefined, + voice_detected: number | undefined, + echo_return_loss: number | undefined, + echo_return_loss_enhancement: number | undefined, + divergent_filter_fraction: number | undefined, + delay_median_ms: number | undefined, + delay_standard_deviation_ms: number | undefined, + residual_echo_likelihood: number | undefined, + residual_echo_likelihood_recent_max: number | undefined, + delay_ms: number | undefined, + + /* RNNoise processor statistics */ + rnnoise_volume: number | undefined +} + +export interface InputProcessor { + /** + * @param processor Target processor type + * @returns `true` if the target processor type is supported and available + */ + hasProcessor(processor: InputProcessorType): boolean; + + /** + * Get the processor config of the target type. + * This method will throw when the target processor isn't supported. + * @param processor Target processor type. + * @returns The processor config. + */ + getProcessorConfig(processor: T) : InputProcessorConfigMapping[T]; + + /** + * Apply the target config. + * @param processor + * @param config + */ + applyProcessorConfig(processor: T, config: InputProcessorConfigMapping[T]); + + /** + * Get the current processor statistics. + */ + getStatistics() : InputProcessorStatistics; +} + export interface AbstractInput { readonly events: Registry; @@ -116,6 +306,15 @@ export interface AbstractInput { getVolume() : number; setVolume(volume: number); + + getInputProcessor() : InputProcessor; + + /** + * Create a new level meter for this audio input. + * This level meter will be indicate the audio level after all processing. + * Note: Changing the input device or stopping the input will result in no activity. + */ + createLevelMeter() : LevelMeter; } export interface LevelMeter { diff --git a/shared/js/voice/RecorderProfile.ts b/shared/js/voice/RecorderProfile.ts index 9d657bd5..f99e08b6 100644 --- a/shared/js/voice/RecorderProfile.ts +++ b/shared/js/voice/RecorderProfile.ts @@ -49,6 +49,12 @@ export function setDefaultRecorder(recorder: RecorderProfile) { export interface RecorderProfileEvents { notify_device_changed: { }, + + notify_voice_start: { }, + notify_voice_end: { }, + + /* attention: this notify will only be called when the audio input hasn't been initialized! */ + notify_input_initialized: { }, } export class RecorderProfile { @@ -84,8 +90,9 @@ export class RecorderProfile { this.pptHook = { callbackRelease: () => { - if(this.pptTimeout) + if(this.pptTimeout) { clearTimeout(this.pptTimeout); + } this.pptTimeout = setTimeout(() => { this.registeredFilter["ppt-gate"]?.setState(true); @@ -93,8 +100,9 @@ export class RecorderProfile { }, callbackPress: () => { - if(this.pptTimeout) + if(this.pptTimeout) { clearTimeout(this.pptTimeout); + } this.registeredFilter["ppt-gate"]?.setState(false); }, @@ -154,14 +162,18 @@ export class RecorderProfile { this.input.events.on("notify_voice_start", () => { logDebug(LogCategory.VOICE, "Voice start"); - if(this.callback_start) + if(this.callback_start) { this.callback_start(); + } + this.events.fire("notify_voice_start"); }); this.input.events.on("notify_voice_end", () => { logDebug(LogCategory.VOICE, "Voice end"); - if(this.callback_stop) + if(this.callback_stop) { this.callback_stop(); + } + this.events.fire("notify_voice_end"); }); this.input.setFilterMode(FilterMode.Block); @@ -174,6 +186,7 @@ export class RecorderProfile { if(this.callback_input_initialized) { this.callback_input_initialized(this.input); } + this.events.fire("notify_input_initialized"); /* apply initial config values */ diff --git a/web/app/WebWindowManager.ts b/web/app/WebWindowManager.ts index db3064ed..a2a0de57 100644 --- a/web/app/WebWindowManager.ts +++ b/web/app/WebWindowManager.ts @@ -8,10 +8,10 @@ import { import {assertMainApplication} from "tc-shared/ui/utils"; import {Registry} from "tc-events"; import {getIpcInstance} from "tc-shared/ipc/BrowserIPC"; -import _ from "lodash"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {tr, tra} from "tc-shared/i18n/localize"; import {guid} from "tc-shared/crypto/uid"; +import _ from "lodash"; assertMainApplication(); @@ -27,17 +27,29 @@ type WindowHandle = { export class WebWindowManager implements WindowManager { private readonly events: Registry; + private readonly listenerUnload; private registeredWindows: { [key: string]: WindowHandle } = {}; constructor() { this.events = new Registry(); - /* TODO: Close all active windows on page unload */ + + this.listenerUnload = () => this.destroyAllWindows(); + window.addEventListener("unload", this.listenerUnload); } getEvents(): Registry { return this.events; } + destroy() { + window.removeEventListener("unload", this.listenerUnload); + this.destroyAllWindows(); + } + + private destroyAllWindows() { + Object.values(this.registeredWindows).forEach(window => window.destroy()); + } + async createWindow(options: WindowSpawnOptions): Promise { /* Multiple application instance may want to open the same windows */ const windowUniqueId = getIpcInstance().getApplicationChannelId() + "-" + options.uniqueId; diff --git a/web/app/audio/Recorder.ts b/web/app/audio/Recorder.ts index 2884c50b..9f0f2fa2 100644 --- a/web/app/audio/Recorder.ts +++ b/web/app/audio/Recorder.ts @@ -5,7 +5,7 @@ import { FilterMode, InputConsumer, InputConsumerType, - InputEvents, + InputEvents, InputProcessor, InputProcessorConfigMapping, InputProcessorStatistics, InputProcessorType, InputStartError, InputState, LevelMeter, @@ -43,12 +43,6 @@ export class WebAudioRecorder implements AudioRecorderBacked { getDeviceList(): DeviceList { return inputDeviceList; } - - isRnNoiseSupported() { - return false; - } - - toggleRnNoise(target: boolean) { throw "not supported"; } } class JavascriptInput implements AbstractInput { @@ -454,8 +448,10 @@ class JavascriptInput implements AbstractInput { } setVolume(volume: number) { - if(volume === this.volumeModifier) + if(volume === this.volumeModifier) { return; + } + this.volumeModifier = volume; this.audioNodeVolume.gain.value = volume; } @@ -482,6 +478,32 @@ class JavascriptInput implements AbstractInput { newMode: mode }); } + + getInputProcessor(): InputProcessor { + return new JavaScriptInputProcessor(); + } + + createLevelMeter(): LevelMeter { + throw tr("implement me!"); + } +} + +class JavaScriptInputProcessor implements InputProcessor { + applyProcessorConfig(processor: T, config: InputProcessorConfigMapping[T]) { + throw tr("target processor is not supported"); + } + + getProcessorConfig(processor: T): InputProcessorConfigMapping[T] { + throw tr("target processor is not supported"); + } + + getStatistics(): InputProcessorStatistics { + return {} as any; + } + + hasProcessor(processor: InputProcessorType): boolean { + return false; + } } class JavascriptLevelMeter implements LevelMeter { diff --git a/webpack.config.ts b/webpack.config.ts index b72eab7b..314a6e1d 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -370,7 +370,9 @@ export const config = async (env: any, target: "web" | "client"): Promise