diff --git a/ChangeLog.md b/ChangeLog.md index 3b45bb60..3acde1ac 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,9 @@ # Changelog: +* **05.04.21** + - Fixed the mute but for the webclient + - Fixed that "always active" microphone filter now works reliably + - Improved the recorder API + * **29.03.21** - Accquiering the default input recorder when opening the settings - Adding new modal Input Processing Properties for the native client diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index b1ad4dc7..e8f21d65 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -151,6 +151,7 @@ export class ConnectionHandler { sound: SoundManager; serverFeatures: ServerFeatures; + log: ServerEventLog; private sideBar: SideBarManager; private playlistManager: PlaylistManager; @@ -171,7 +172,7 @@ export class ConnectionHandler { private pluginCmdRegistry: PluginCmdRegistry; - private client_status: LocalClientStatus = { + private handlerState: LocalClientStatus = { input_muted: false, output_muted: false, @@ -186,8 +187,6 @@ export class ConnectionHandler { private inputHardwareState: InputHardwareState = InputHardwareState.MISSING; private listenerRecorderInputDeviceChanged: (() => void); - log: ServerEventLog; - constructor() { this.handlerId = guid(); this.events_ = new Registry(); @@ -196,7 +195,13 @@ export class ConnectionHandler { this.settings = new ServerSettings(); this.serverConnection = getServerConnectionFactory().create(this); - this.serverConnection.events.on("notify_connection_state_changed", event => this.on_connection_state_changed(event.oldState, event.newState)); + this.serverConnection.events.on("notify_connection_state_changed", event => { + logTrace(LogCategory.CLIENT, tr("From %s to %s"), ConnectionState[event.oldState], ConnectionState[event.newState]); + this.events_.fire("notify_connection_state_changed", { + oldState: event.oldState, + newState: event.newState + }); + }); this.serverConnection.getVoiceConnection().events.on("notify_recorder_changed", event => { this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING); @@ -245,14 +250,25 @@ export class ConnectionHandler { this.events_.fire("notify_handler_initialized"); } - initialize_client_state(source?: ConnectionHandler) { - this.client_status.input_muted = source ? source.client_status.input_muted : settings.getValue(Settings.KEY_CLIENT_STATE_MICROPHONE_MUTED); - this.client_status.output_muted = source ? source.client_status.output_muted : settings.getValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED); - this.update_voice_status(); + initializeHandlerState(source?: ConnectionHandler) { + if(source) { + this.handlerState.input_muted = source.handlerState.input_muted; + this.handlerState.output_muted = source.handlerState.output_muted; + this.update_voice_status(); - this.setSubscribeToAllChannels(source ? source.client_status.channel_subscribe_all : settings.getValue(Settings.KEY_CLIENT_STATE_SUBSCRIBE_ALL_CHANNELS)); - this.doSetAway(source ? source.client_status.away : (settings.getValue(Settings.KEY_CLIENT_STATE_AWAY) ? settings.getValue(Settings.KEY_CLIENT_AWAY_MESSAGE) : false), false); - this.setQueriesShown(source ? source.client_status.queries_visible : settings.getValue(Settings.KEY_CLIENT_STATE_QUERY_SHOWN)); + this.setAway(source.handlerState.away); + this.setQueriesShown(source.handlerState.queries_visible); + this.setSubscribeToAllChannels(source.handlerState.channel_subscribe_all); + /* Ignore lastChannelCodecWarned */ + } else { + this.handlerState.input_muted = settings.getValue(Settings.KEY_CLIENT_STATE_MICROPHONE_MUTED); + this.handlerState.output_muted = settings.getValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED); + this.update_voice_status(); + + this.setSubscribeToAllChannels(settings.getValue(Settings.KEY_CLIENT_STATE_SUBSCRIBE_ALL_CHANNELS)); + this.doSetAway(settings.getValue(Settings.KEY_CLIENT_STATE_AWAY) ? settings.getValue(Settings.KEY_CLIENT_AWAY_MESSAGE) : false, false); + this.setQueriesShown(settings.getValue(Settings.KEY_CLIENT_STATE_QUERY_SHOWN)); + } } events() : Registry { @@ -386,7 +402,9 @@ export class ConnectionHandler { async disconnectFromServer(reason?: string) { this.cancelAutoReconnect(true); - if(!this.connected) return; + if(!this.connected) { + return; + } this.handleDisconnect(DisconnectReason.REQUESTED); try { @@ -458,12 +476,12 @@ export class ConnectionHandler { this.settings.setServer(this.channelTree.server.properties.virtualserver_unique_identifier); /* apply the server settings */ - if(this.client_status.channel_subscribe_all) { + if(this.handlerState.channel_subscribe_all) { this.channelTree.subscribe_all_channels(); } else { this.channelTree.unsubscribe_all_channels(); } - this.channelTree.toggle_server_queries(this.client_status.queries_visible); + this.channelTree.toggle_server_queries(this.handlerState.queries_visible); this.sync_status_with_server(); this.channelTree.server.updateProperties(); @@ -740,7 +758,7 @@ export class ConnectionHandler { this.serverConnection.disconnect(); } - this.client_status.lastChannelCodecWarned = 0; + this.handlerState.lastChannelCodecWarned = 0; if(autoReconnect) { if(!this.serverConnection) { @@ -776,14 +794,6 @@ export class ConnectionHandler { } } - private on_connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) { - logTrace(LogCategory.CLIENT, tr("From %s to %s"), ConnectionState[old_state], ConnectionState[new_state]); - this.events_.fire("notify_connection_state_changed", { - oldState: old_state, - newState: new_state - }); - } - private updateVoiceStatus() { if(!this.localClient) { /* we've been destroyed */ @@ -816,8 +826,8 @@ export class ConnectionHandler { localClientUpdates.client_input_hardware = codecSupportEncode; localClientUpdates.client_output_hardware = codecSupportDecode; - if(this.client_status.lastChannelCodecWarned !== currentChannel.getChannelId()) { - this.client_status.lastChannelCodecWarned = currentChannel.getChannelId(); + if(this.handlerState.lastChannelCodecWarned !== currentChannel.getChannelId()) { + this.handlerState.lastChannelCodecWarned = currentChannel.getChannelId(); if(!codecSupportEncode || !codecSupportDecode) { let message; @@ -837,8 +847,8 @@ export class ConnectionHandler { } localClientUpdates.client_input_hardware = localClientUpdates.client_input_hardware && this.inputHardwareState === InputHardwareState.VALID; - localClientUpdates.client_output_muted = this.client_status.output_muted; - localClientUpdates.client_input_muted = this.client_status.input_muted; + localClientUpdates.client_output_muted = this.handlerState.output_muted; + localClientUpdates.client_input_muted = this.handlerState.input_muted; if(localClientUpdates.client_input_muted || localClientUpdates.client_output_muted) { shouldRecord = false; } @@ -863,11 +873,13 @@ export class ConnectionHandler { this.getClient().updateVariables(...updates); this.clientStatusSync = true; - this.serverConnection.send_command("clientupdate", localClientUpdates).catch(error => { - logWarn(LogCategory.GENERAL, tr("Failed to update client audio hardware properties. Error: %o"), error); - this.log.log("error.custom", { message: tr("Failed to update audio hardware properties.") }); - this.clientStatusSync = false; - }); + if(this.connected) { + this.serverConnection.send_command("clientupdate", localClientUpdates).catch(error => { + logWarn(LogCategory.GENERAL, tr("Failed to update client audio hardware properties. Error: %o"), error); + this.log.log("error.custom", { message: tr("Failed to update audio hardware properties.") }); + this.clientStatusSync = false; + }); + } } } } else { @@ -902,10 +914,10 @@ export class ConnectionHandler { sync_status_with_server() { if(this.serverConnection.connected()) this.serverConnection.send_command("clientupdate", { - client_input_muted: this.client_status.input_muted, - client_output_muted: this.client_status.output_muted, - client_away: typeof(this.client_status.away) === "string" || this.client_status.away, - client_away_message: typeof(this.client_status.away) === "string" ? this.client_status.away : "", + client_input_muted: this.handlerState.input_muted, + client_output_muted: this.handlerState.output_muted, + client_away: typeof(this.handlerState.away) === "string" || this.handlerState.away, + client_away_message: typeof(this.handlerState.away) === "string" ? this.handlerState.away : "", /* TODO: Somehow store this? */ //client_input_hardware: this.client_status.sound_record_supported && this.getInputHardwareState() === InputHardwareState.VALID, //client_output_hardware: this.client_status.sound_playback_supported @@ -917,11 +929,8 @@ export class ConnectionHandler { /* can be called as much as you want, does nothing if nothing changed */ async acquireInputHardware() { - /* if we're having multiple recorders, try to get the right one */ - let recorder: RecorderProfile = defaultRecorder; - try { - await this.serverConnection.getVoiceConnection().acquireVoiceRecorder(recorder); + await this.serverConnection.getVoiceConnection().acquireVoiceRecorder(defaultRecorder); } catch (error) { logError(LogCategory.AUDIO, tr("Failed to acquire recorder: %o"), error); createErrorModal(tr("Failed to acquire recorder"), tr("Failed to acquire recorder.\nLookup the console for more details.")).open(); @@ -983,7 +992,7 @@ export class ConnectionHandler { } } - getVoiceRecorder() : RecorderProfile | undefined { return this.serverConnection.getVoiceConnection().voiceRecorder(); } + getVoiceRecorder() : RecorderProfile | undefined { return this.serverConnection?.getVoiceConnection().voiceRecorder(); } reconnect_properties(profile?: ConnectionProfile) : ConnectParametersOld { @@ -1098,11 +1107,11 @@ export class ConnectionHandler { /* state changing methods */ setMicrophoneMuted(muted: boolean, dontPlaySound?: boolean) { - if(this.client_status.input_muted === muted) { + if(this.handlerState.input_muted === muted) { return; } - this.client_status.input_muted = muted; + this.handlerState.input_muted = muted; if(!dontPlaySound) { this.sound.play(muted ? Sound.MICROPHONE_MUTED : Sound.MICROPHONE_ACTIVATED); } @@ -1111,21 +1120,30 @@ export class ConnectionHandler { } toggleMicrophone() { this.setMicrophoneMuted(!this.isMicrophoneMuted()); } - isMicrophoneMuted() { return this.client_status.input_muted; } + isMicrophoneMuted() { return this.handlerState.input_muted; } isMicrophoneDisabled() { return this.inputHardwareState !== InputHardwareState.VALID; } setSpeakerMuted(muted: boolean, dontPlaySound?: boolean) { - if(this.client_status.output_muted === muted) return; - if(muted && !dontPlaySound) this.sound.play(Sound.SOUND_MUTED); /* play the sound *before* we're setting the muted state */ - this.client_status.output_muted = muted; + if(this.handlerState.output_muted === muted) { + return; + } + + if(muted && !dontPlaySound) { + /* play the sound *before* we're setting the muted state */ + this.sound.play(Sound.SOUND_MUTED); + } + this.handlerState.output_muted = muted; this.events_.fire("notify_state_updated", { state: "speaker" }); - if(!muted && !dontPlaySound) this.sound.play(Sound.SOUND_ACTIVATED); /* play the sound *after* we're setting we've unmuted the sound */ + if(!muted && !dontPlaySound) { + /* play the sound *after* we're setting we've unmuted the sound */ + this.sound.play(Sound.SOUND_ACTIVATED); + } this.update_voice_status(); this.serverConnection.getVoiceConnection().stopAllVoiceReplays(); } toggleSpeakerMuted() { this.setSpeakerMuted(!this.isSpeakerMuted()); } - isSpeakerMuted() { return this.client_status.output_muted; } + isSpeakerMuted() { return this.handlerState.output_muted; } /* * Returns whatever the client is able to playback sound (voice). Reasons for returning true could be: @@ -1136,8 +1154,11 @@ export class ConnectionHandler { isSpeakerDisabled() : boolean { return false; } setSubscribeToAllChannels(flag: boolean) { - if(this.client_status.channel_subscribe_all === flag) return; - this.client_status.channel_subscribe_all = flag; + if(this.handlerState.channel_subscribe_all === flag) { + return; + } + + this.handlerState.channel_subscribe_all = flag; if(flag) { this.channelTree.subscribe_all_channels(); } else { @@ -1146,25 +1167,27 @@ export class ConnectionHandler { this.events_.fire("notify_state_updated", { state: "subscribe" }); } - isSubscribeToAllChannels() : boolean { return this.client_status.channel_subscribe_all; } + isSubscribeToAllChannels() : boolean { return this.handlerState.channel_subscribe_all; } setAway(state: boolean | string) { this.doSetAway(state, true); } - private doSetAway(state: boolean | string, play_sound: boolean) { - if(this.client_status.away === state) + private doSetAway(state: boolean | string, playSound: boolean) { + if(this.handlerState.away === state) { return; + } - const was_away = this.isAway(); - const will_away = typeof state === "boolean" ? state : true; - if(was_away != will_away && play_sound) - this.sound.play(will_away ? Sound.AWAY_ACTIVATED : Sound.AWAY_DEACTIVATED); + const wasAway = this.isAway(); + const willAway = typeof state === "boolean" ? state : true; + if(wasAway != willAway && playSound) { + this.sound.play(willAway ? Sound.AWAY_ACTIVATED : Sound.AWAY_DEACTIVATED); + } - this.client_status.away = state; + this.handlerState.away = state; this.serverConnection.send_command("clientupdate", { - client_away: typeof(this.client_status.away) === "string" || this.client_status.away, - client_away_message: typeof(this.client_status.away) === "string" ? this.client_status.away : "", + client_away: typeof(this.handlerState.away) === "string" || this.handlerState.away, + client_away_message: typeof(this.handlerState.away) === "string" ? this.handlerState.away : "", }).catch(error => { logWarn(LogCategory.GENERAL, tr("Failed to update away status. Error: %o"), error); this.log.log("error.custom", {message: tr("Failed to update away status.")}); @@ -1175,11 +1198,13 @@ export class ConnectionHandler { }); } toggleAway() { this.setAway(!this.isAway()); } - isAway() : boolean { return typeof this.client_status.away !== "boolean" || this.client_status.away; } + isAway() : boolean { return typeof this.handlerState.away !== "boolean" || this.handlerState.away; } setQueriesShown(flag: boolean) { - if(this.client_status.queries_visible === flag) return; - this.client_status.queries_visible = flag; + if(this.handlerState.queries_visible === flag) { + return; + } + this.handlerState.queries_visible = flag; this.channelTree.toggle_server_queries(flag); this.events_.fire("notify_state_updated", { @@ -1188,7 +1213,7 @@ export class ConnectionHandler { } areQueriesShown() : boolean { - return this.client_status.queries_visible; + return this.handlerState.queries_visible; } getInputHardwareState() : InputHardwareState { return this.inputHardwareState; } diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index b2db395d..b22dc53d 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -49,7 +49,7 @@ export class ConnectionManager { spawnConnectionHandler() : ConnectionHandler { const handler = new ConnectionHandler(); - handler.initialize_client_state(this.activeConnectionHandler); + handler.initializeHandlerState(this.activeConnectionHandler); this.connectionHandlers.push(handler); this.events_.fire("notify_handler_created", { handler: handler, handlerId: handler.handlerId }); diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 042e8c46..34589d99 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -667,7 +667,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { entry.getVoiceClient()?.abortReplay(); } } else { - client.speaking = false; + client.getVoiceClient()?.abortReplay(); } const own_channel = this.connection.client.getClient().currentChannel(); diff --git a/shared/js/connection/DummyVoiceConnection.ts b/shared/js/connection/DummyVoiceConnection.ts index 8d68a916..71ae0cb9 100644 --- a/shared/js/connection/DummyVoiceConnection.ts +++ b/shared/js/connection/DummyVoiceConnection.ts @@ -2,13 +2,15 @@ import { AbstractVoiceConnection, VoiceConnectionStatus, WhisperSessionInitializer } from "../connection/VoiceConnection"; -import {RecorderProfile} from "../voice/RecorderProfile"; +import {RecorderProfile, RecorderProfileOwner} from "../voice/RecorderProfile"; import {AbstractServerConnection, ConnectionStatistics} from "../connection/ConnectionBase"; import {VoiceClient} from "../voice/VoiceClient"; import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "../voice/VoicePlayer"; import {WhisperSession, WhisperTarget} from "../voice/VoiceWhisper"; import {Registry} from "../events"; import { tr } from "tc-shared/i18n/localize"; +import {AbstractInput} from "tc-shared/voice/RecorderBase"; +import {crashOnThrow} from "tc-shared/proto"; class DummyVoiceClient implements VoiceClient { readonly events: Registry; @@ -54,9 +56,11 @@ class DummyVoiceClient implements VoiceClient { export class DummyVoiceConnection extends AbstractVoiceConnection { private recorder: RecorderProfile; private voiceClients: DummyVoiceClient[] = []; + private triggerUnmountEvent: boolean; constructor(connection: AbstractServerConnection) { super(connection); + this.triggerUnmountEvent = true; } async acquireVoiceRecorder(recorder: RecorderProfile | undefined): Promise { @@ -64,21 +68,29 @@ export class DummyVoiceConnection extends AbstractVoiceConnection { return; } - if(this.recorder) { - this.recorder.callback_unmount = undefined; - await this.recorder.unmount(); - } - - await recorder?.unmount(); const oldRecorder = this.recorder; - this.recorder = recorder; + await crashOnThrow(async () => { + this.triggerUnmountEvent = false; + await this.recorder?.ownRecorder(undefined); + this.triggerUnmountEvent = true; - if(this.recorder) { - this.recorder.callback_unmount = () => { - this.recorder = undefined; - this.events.fire("notify_recorder_changed"); - } - } + this.recorder = recorder; + + const voiceConnection = this; + await this.recorder?.ownRecorder(new class extends RecorderProfileOwner { + protected handleRecorderInput(input: AbstractInput) { } + + protected handleUnmount() { + if(!voiceConnection.triggerUnmountEvent) { + return; + } + + const oldRecorder = voiceConnection.recorder; + voiceConnection.recorder = undefined; + voiceConnection.events.fire("notify_recorder_changed", { oldRecorder: oldRecorder, newRecorder: undefined }) + } + }); + }); this.events.fire("notify_recorder_changed", { oldRecorder, diff --git a/shared/js/connection/PluginCmdHandler.ts b/shared/js/connection/PluginCmdHandler.ts index ec753605..a77371ae 100644 --- a/shared/js/connection/PluginCmdHandler.ts +++ b/shared/js/connection/PluginCmdHandler.ts @@ -25,18 +25,38 @@ export abstract class PluginCmdHandler { abstract handlePluginCommand(data: string, invoker: PluginCommandInvoker); - protected sendPluginCommand(data: string, mode: "server" | "view" | "channel" | "private", clientId?: number) : Promise { - if(!this.currentServerConnection) + protected sendPluginCommand(data: string, mode: "server" | "view" | "channel" | "private", clientOrChannelId?: number) : Promise { + if(!this.currentServerConnection) { throw "plugin command handler not registered"; + } + + let targetMode: number; + switch (mode) { + case "channel": + targetMode = 0; + break; + + case "server": + targetMode = 1; + break; + + case "private": + targetMode = 2; + break; + + case "view": + targetMode = 3; + break; + + default: + throw tr("invalid plugin message target"); + } return this.currentServerConnection.send_command("plugincmd", { data: data, name: this.channel, - targetmode: mode === "server" ? 1 : - mode === "view" ? 3 : - mode === "channel" ? 0 : - 2, - target: clientId + targetmode: targetMode, + target: clientOrChannelId }); } } diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 42680307..478ea804 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -661,6 +661,25 @@ export class RTCConnection { return oldTrack; } + async clearTrackSources(types: RTCSourceTrackType[]) : Promise { + const result = []; + + for(const type of types) { + if(this.currentTracks[type]) { + result.push(this.currentTracks[type]); + this.currentTracks[type] = null; + } else { + result.push(undefined); + } + } + + if(result.find(entry => typeof entry !== "undefined") !== -1) { + await this.updateTracks(); + } + + return result; + } + public async startVideoBroadcast(type: VideoBroadcastType, config: VideoBroadcastConfig) { let track: RTCBroadcastableTrackType; let broadcastType: number; diff --git a/shared/js/proto.ts b/shared/js/proto.ts index 6af27fc2..2d7fcb9d 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -1,7 +1,7 @@ /* setup jsrenderer */ import "jsrender"; import {tr} from "./i18n/localize"; -import {LogCategory, logTrace} from "tc-shared/log"; +import {LogCategory, logError, logTrace} from "tc-shared/log"; if(__build.target === "web") { (window as any).$ = require("jquery"); @@ -300,4 +300,153 @@ if(typeof ($) !== "undefined") { if(!Object.values) { Object.values = object => Object.keys(object).map(e => object[e]); -} \ No newline at end of file +} + +export function crashOnThrow(promise: Promise | (() => Promise)) : Promise { + if(typeof promise === "function") { + try { + promise = promise(); + } catch (error) { + promise = Promise.reject(error); + } + } + + return promise.catch(error => { + /* TODO: Crash screen of the app? */ + logError(LogCategory.GENERAL, tr("Critical app error: %o"), error); + + /* Lets make this promise stuck for ever */ + return new Promise(() => {}); + }); +} + +export function ignorePromise(_promise: Promise) {} + +export function NoThrow(target: any, methodName: string, descriptor: PropertyDescriptor) { + const crashApp = error => { + /* TODO: Crash screen of the app? */ + logError(LogCategory.GENERAL, tr("Critical app error: %o"), error); + }; + + const promiseAccepted = { value: false }; + + const originalMethod: Function = descriptor.value; + descriptor.value = function () { + try { + const result = originalMethod.apply(this, arguments); + if(result instanceof Promise) { + promiseAccepted.value = true; + return result.catch(error => { + crashApp(error); + + /* Lets make this promise stuck for ever since we're in a not well defined state */ + return new Promise(() => {}); + }); + } + + return result; + } catch (error) { + crashApp(error); + + if(!promiseAccepted.value) { + throw error; + } else { + /* + * We don't know if we can return a promise or if just the object is expected. + * Since we don't know that, we're just rethrowing the error for now. + */ + return new Promise(() => {}); + } + } + }; +} + +const kCallOnceSymbol = Symbol("call-once-data"); +export function CallOnce(target: any, methodName: string, descriptor: PropertyDescriptor) { + const callOnceData = target[kCallOnceSymbol] || (target[kCallOnceSymbol] = {}); + + const originalMethod: Function = descriptor.value; + descriptor.value = function () { + if(callOnceData[methodName]) { + debugger; + throw "method " + methodName + " has already been called"; + } + + return originalMethod.apply(this, arguments); + }; +} + +const kNonNullSymbol = Symbol("non-null-data"); +export function NonNull(target: any, methodName: string, parameterIndex: number) { + const nonNullInfo = target[kNonNullSymbol] || (target[kNonNullSymbol] = {}); + const methodInfo = nonNullInfo[methodName] || (nonNullInfo[methodName] = {}); + if(!Array.isArray(methodInfo.indexes)) { + /* Initialize method info */ + methodInfo.overloaded = false; + methodInfo.indexes = []; + } + + methodInfo.indexes.push(parameterIndex); + setImmediate(() => { + if(methodInfo.overloaded || methodInfo.missingWarned) { + return; + } + + methodInfo.missingWarned = true; + logError(LogCategory.GENERAL, "Method %s has been constrained but the @Constrained decoration is missing."); + debugger; + }); +} + +/** + * The class or method has been constrained + */ +export function ParameterConstrained(target: any, methodName: string, descriptor: PropertyDescriptor) { + const nonNullInfo = target[kNonNullSymbol]; + if(!nonNullInfo) { + return; + } + + const methodInfo = nonNullInfo[methodName] || (nonNullInfo[methodName] = {}); + if(!methodInfo) { + return; + } + + methodInfo.overloaded = true; + const originalMethod: Function = descriptor.value; + descriptor.value = function () { + for(let index = 0; index < methodInfo.indexes.length; index++) { + const argument = arguments[methodInfo.indexes[index]]; + if(typeof argument === undefined || typeof argument === null) { + throw "parameter " + methodInfo.indexes[index] + " should not be null or undefined"; + } + } + + return originalMethod.apply(this, arguments); + }; +} + +class TestClass { + @NoThrow + noThrow0() { } + + @NoThrow + async noThrow1() { + await new Promise(resolve => setTimeout(resolve, 1)); + } + + @NoThrow + noThrow2() { throw "expected"; } + + @NoThrow + async noThrow3() { + await new Promise(resolve => setTimeout(resolve, 1)); + throw "expected"; + } + + @ParameterConstrained + nonNull0(@NonNull value: number) { + + } +} +(window as any).TestClass = TestClass; \ No newline at end of file diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index c7b2233f..45278e5d 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -260,12 +260,13 @@ export class ClientEntry extends Cha switch (event.newState) { case VoicePlayerState.PLAYING: case VoicePlayerState.STOPPING: - this.speaking = true; + this.setSpeaking(true); break; case VoicePlayerState.STOPPED: case VoicePlayerState.INITIALIZING: - this.speaking = false; + default: + this.setSpeaking(false); break; } } @@ -698,14 +699,22 @@ export class ClientEntry extends Cha return ClientEntry.chatTag(this.clientId(), this.clientNickName(), this.clientUid(), braces); } - set speaking(flag) { - if(flag === this._speaking) return; - this._speaking = flag; - this.events.fire("notify_speak_state_change", { speaking: flag }); + /** @deprecated Don't use this any more! */ + set speaking(flag: boolean) { + this.setSpeaking(!!flag); } isSpeaking() { return this._speaking; } + protected setSpeaking(flag: boolean) { + if(this._speaking === flag) { + return; + } + + this._speaking = flag; + this.events.fire("notify_speak_state_change", { speaking: flag }); + } + updateVariables(...variables: {key: string, value: string}[]) { let reorder_channel = false; @@ -914,6 +923,10 @@ export class LocalClientEntry extends ClientEntry { this.handle = handle; } + setSpeaking(flag: boolean) { + super.setSpeaking(flag); + } + showContextMenu(x: number, y: number, on_close: () => void = undefined): void { contextmenu.spawn_context_menu(x, y, ...this.contextmenu_info(), { diff --git a/shared/js/ui/AppRenderer.tsx b/shared/js/ui/AppRenderer.tsx index 13dfec4d..ae97c0cc 100644 --- a/shared/js/ui/AppRenderer.tsx +++ b/shared/js/ui/AppRenderer.tsx @@ -20,6 +20,7 @@ import {ChannelTreeRenderer} from "tc-shared/ui/tree/Renderer"; import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions"; import {ImagePreviewHook} from "tc-shared/ui/frames/ImagePreview"; import {InternalModalHook} from "tc-shared/ui/react-elements/modal/internal"; +import {TooltipHook} from "tc-shared/ui/react-elements/Tooltip"; const cssStyle = require("./AppRenderer.scss"); const VideoFrame = React.memo((props: { events: Registry }) => { @@ -110,6 +111,10 @@ export const TeaAppMainView = (props: { + + + + ); } \ No newline at end of file diff --git a/shared/js/ui/modal/settings/Microphone.tsx b/shared/js/ui/modal/settings/Microphone.tsx index 49626181..79f39336 100644 --- a/shared/js/ui/modal/settings/Microphone.tsx +++ b/shared/js/ui/modal/settings/Microphone.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {Registry} from "tc-shared/events"; 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 {ConnectionRecorderProfileOwner, defaultRecorder, RecorderProfileOwner} from "tc-shared/voice/RecorderProfile"; import {getRecorderBackend, InputDevice} from "tc-shared/audio/Recorder"; import {Settings, settings} from "tc-shared/settings"; import {getBackend} from "tc-shared/backend"; @@ -16,6 +16,7 @@ import { 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"; +import {ignorePromise} from "tc-shared/proto"; export function initialize_audio_microphone_controller(events: Registry) { const recorderBackend = getRecorderBackend(); @@ -438,25 +439,26 @@ export function initialize_audio_microphone_controller(events: Registry { - const originalHandlerId = defaultRecorder.current_handler?.handlerId; - defaultRecorder.unmount().then(() => { - defaultRecorder.input.start().catch(error => { + const oldOwner = defaultRecorder.getOwner(); + let originalHandlerId = oldOwner instanceof ConnectionRecorderProfileOwner ? oldOwner.getConnection().handlerId : undefined; + + ignorePromise(defaultRecorder.ownRecorder(new class extends RecorderProfileOwner { + protected handleRecorderInput(input: AbstractInput) { + input.start().catch(error => { logError(LogCategory.AUDIO, tr("Failed to start default input: %o"), error); }); - }); + } - events.on("notify_destroy", () => { - server_connections.findConnection(originalHandlerId)?.acquireInputHardware().catch(error => { - logError(LogCategory.GENERAL, tr("Failed to acquire microphone after settings detach: %o"), error); - }); - }); - }; + protected handleUnmount() { + /* We've been passed to somewhere else */ + originalHandlerId = undefined; + } + })); - if(defaultRecorder.input) { - ownDefaultRecorder(); - } else { - events.on("notify_destroy", defaultRecorder.events.one("notify_input_initialized", () => ownDefaultRecorder())); - } + events.on("notify_destroy", () => { + server_connections.findConnection(originalHandlerId)?.acquireInputHardware().catch(error => { + logError(LogCategory.GENERAL, tr("Failed to acquire microphone after settings detach: %o"), error); + }); + }); } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/Tooltip.tsx b/shared/js/ui/react-elements/Tooltip.tsx index 13535a0c..70a97278 100644 --- a/shared/js/ui/react-elements/Tooltip.tsx +++ b/shared/js/ui/react-elements/Tooltip.tsx @@ -11,6 +11,7 @@ interface GlobalTooltipState { tooltipId: string; } +const globalTooltipRef = React.createRef(); class GlobalTooltip extends React.Component<{}, GlobalTooltipState> { private currentTooltip_: Tooltip; private isUnmount: boolean; @@ -160,7 +161,4 @@ export const IconTooltip = (props: { children?: React.ReactElement | React.React ); -const globalTooltipRef = React.createRef(); -const tooltipContainer = document.createElement("div"); -document.body.appendChild(tooltipContainer); -ReactDOM.render(, tooltipContainer); \ No newline at end of file +export const TooltipHook = React.memo(() => ); \ No newline at end of file diff --git a/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.tsx b/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.tsx index ace9c369..32b4be77 100644 --- a/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.tsx +++ b/shared/js/ui/react-elements/modal/external/renderer/ModalRenderer.tsx @@ -8,12 +8,22 @@ import { } from "tc-shared/ui/react-elements/modal/Renderer"; import "./ModalRenderer.scss"; +import {TooltipHook} from "tc-shared/ui/react-elements/Tooltip"; +import {ImagePreviewHook} from "tc-shared/ui/frames/ImagePreview"; export interface ModalControlFunctions { close(); minimize(); } +const GlobalHooks = React.memo((props: { children }) => ( + + + + {props.children} + +)); + export class ModalRenderer { private readonly functionController: ModalControlFunctions; private readonly container: HTMLDivElement; @@ -33,30 +43,34 @@ export class ModalRenderer { if(__build.target === "client") { ReactDOM.render( - - + + this.functionController.close()} - onMinimize={() => this.functionController.minimize()} - /> - - , + onClose={() => this.functionController.close()} + onMinimize={() => this.functionController.minimize()} + /> + + + , this.container ); } else { ReactDOM.render( - - + + this.functionController.close()} - onMinimize={() => this.functionController.minimize()} - /> - - , + onClose={() => this.functionController.close()} + onMinimize={() => this.functionController.minimize()} + /> + + + , this.container ); } diff --git a/shared/js/voice/RecorderProfile.ts b/shared/js/voice/RecorderProfile.ts index f99e08b6..91e84af3 100644 --- a/shared/js/voice/RecorderProfile.ts +++ b/shared/js/voice/RecorderProfile.ts @@ -5,9 +5,11 @@ import {Settings, settings} from "../settings"; import {ConnectionHandler} from "../ConnectionHandler"; import {getRecorderBackend, InputDevice} from "../audio/Recorder"; import {FilterType, StateFilter, ThresholdFilter} from "../voice/Filter"; -import { tr } from "tc-shared/i18n/localize"; +import {tr} from "tc-shared/i18n/localize"; import {Registry} from "tc-shared/events"; import {getAudioBackend} from "tc-shared/audio/Player"; +import {Mutex} from "tc-shared/Mutex"; +import {NoThrow} from "tc-shared/proto"; export type VadType = "threshold" | "push_to_talk" | "active"; export interface RecorderProfileConfig { @@ -57,23 +59,42 @@ export interface RecorderProfileEvents { notify_input_initialized: { }, } +export abstract class RecorderProfileOwner { + /** + * This method will be called from the recorder profile. + */ + protected abstract handleUnmount(); + + /** + * This callback will be called when the recorder audio input has + * been initialized. + * Note: This method might be called within ownRecorder(). + * If this method has been called, handleUnmount will be called. + * + * @param input The target input. + */ + protected abstract handleRecorderInput(input: AbstractInput); +} + +export abstract class ConnectionRecorderProfileOwner extends RecorderProfileOwner { + abstract getConnection() : ConnectionHandler; +} + export class RecorderProfile { readonly events: Registry; readonly name; readonly volatile; /* not saving profile */ config: RecorderProfileConfig; - input: AbstractInput; + /* TODO! */ + /* private */input: AbstractInput; + private currentOwner: RecorderProfileOwner; + private currentOwnerMutex: Mutex; + + /* FIXME: Remove this! */ current_handler: ConnectionHandler; - /* attention: this callback will only be called when the audio input hasn't been initialized! */ - callback_input_initialized: (input: AbstractInput) => void; - callback_start: () => any; - callback_stop: () => any; - - callback_unmount: () => any; /* called if somebody else takes the ownership */ - private readonly pptHook: KeyHook; private pptTimeout: number; private pptHookRegistered: boolean; @@ -87,6 +108,7 @@ export class RecorderProfile { this.events = new Registry(); this.name = name; this.volatile = typeof(volatile) === "boolean" ? volatile : false; + this.currentOwnerMutex = new Mutex(void 0); this.pptHook = { callbackRelease: () => { @@ -161,18 +183,12 @@ export class RecorderProfile { this.input = getRecorderBackend().createInput(); this.input.events.on("notify_voice_start", () => { - logDebug(LogCategory.VOICE, "Voice start"); - if(this.callback_start) { - this.callback_start(); - } + logDebug(LogCategory.VOICE, tr("Voice recorder %s: Voice started."), this.name); this.events.fire("notify_voice_start"); }); this.input.events.on("notify_voice_end", () => { - logDebug(LogCategory.VOICE, "Voice end"); - if(this.callback_stop) { - this.callback_stop(); - } + logDebug(LogCategory.VOICE, tr("Voice recorder %s: Voice stopped."), this.name); this.events.fire("notify_voice_end"); }); @@ -183,12 +199,8 @@ export class RecorderProfile { this.registeredFilter["threshold"] = this.input.createFilter(FilterType.THRESHOLD, 100); this.registeredFilter["threshold"].setEnabled(false); - if(this.callback_input_initialized) { - this.callback_input_initialized(this.input); - } this.events.fire("notify_input_initialized"); - /* apply initial config values */ this.input.setVolume(this.config.volume / 100); if(this.config.device_id) { @@ -267,26 +279,47 @@ export class RecorderProfile { this.input.setFilterMode(FilterMode.Filter); } - async unmount() : Promise { - if(this.callback_unmount) { - this.callback_unmount(); - } - - if(this.input) { - try { - await this.input.setConsumer(undefined); - } catch(error) { - logWarn(LogCategory.VOICE, tr("Failed to unmount input consumer for profile (%o)"), error); + /** + * Own the recorder. + */ + @NoThrow + async ownRecorder(target: RecorderProfileOwner | undefined) { + await this.currentOwnerMutex.execute(async () => { + if(this.currentOwner) { + try { + this.currentOwner["handleUnmount"](); + } catch (error) { + logError(LogCategory.AUDIO, tr("Failed to invoke unmount method on the current owner: %o"), error); + } + this.currentOwner = undefined; } - /* this.input.setFilterMode(FilterMode.Block); */ - } + this.currentOwner = target; + if(this.input) { + await this.input.setConsumer(undefined); + } - this.callback_input_initialized = undefined; - this.callback_start = undefined; - this.callback_stop = undefined; - this.callback_unmount = undefined; - this.current_handler = undefined; + if(this.currentOwner && this.input) { + try { + this.currentOwner["handleRecorderInput"](this.input); + } catch (error) { + logError(LogCategory.AUDIO, tr("Failed to call handleRecorderInput on the current owner: %o"), error); + } + } + }); + } + + getOwner() : RecorderProfileOwner | undefined { + return this.currentOwner; + } + + isInputActive() : boolean { + return typeof this.input !== "undefined" && !this.input.isFiltered(); + } + + /** @deprecated use `ownRecorder(undefined)` */ + async unmount() : Promise { + await this.ownRecorder(undefined); } getVadType() { return this.config.vad_type; } @@ -343,8 +376,9 @@ export class RecorderProfile { getPushToTalkDelay() { return this.config.vad_push_to_talk.delay; } setPushToTalkDelay(value: number) { - if(this.config.vad_push_to_talk.delay === value) + if(this.config.vad_push_to_talk.delay === value) { return; + } this.config.vad_push_to_talk.delay = value; this.save(); @@ -371,8 +405,9 @@ export class RecorderProfile { getVolume() : number { return this.input ? (this.input.getVolume() * 100) : this.config.volume; } setVolume(volume: number) { - if(this.config.volume === volume) + if(this.config.volume === volume) { return; + } this.config.volume = volume; this.input?.setVolume(volume / 100); diff --git a/web/app/voice/Connection.ts b/web/app/voice/Connection.ts index 8bf31ea6..fc8cd349 100644 --- a/web/app/voice/Connection.ts +++ b/web/app/voice/Connection.ts @@ -3,7 +3,7 @@ import { VoiceConnectionStatus, WhisperSessionInitializer } from "tc-shared/connection/VoiceConnection"; -import {RecorderProfile} from "tc-shared/voice/RecorderProfile"; +import {ConnectionRecorderProfileOwner, RecorderProfile} from "tc-shared/voice/RecorderProfile"; import {VoiceClient} from "tc-shared/voice/VoiceClient"; import { kUnknownWhisperClientUniqueId, @@ -16,19 +16,18 @@ import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connecti import {VoicePlayerState} from "tc-shared/voice/VoicePlayer"; import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log"; import {tr} from "tc-shared/i18n/localize"; -import {InputConsumerType} from "tc-shared/voice/RecorderBase"; +import {AbstractInput, InputConsumerType} from "tc-shared/voice/RecorderBase"; import {getAudioBackend} from "tc-shared/audio/Player"; import {RtpVoiceClient} from "./VoiceClient"; import {RtpWhisperSession} from "./WhisperClient"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {CallOnce, crashOnThrow, ignorePromise} from "tc-shared/proto"; type CancelableWhisperTarget = WhisperTarget & { canceled: boolean }; export class RtpVoiceConnection extends AbstractVoiceConnection { private readonly rtcConnection: RTCConnection; - private readonly listenerRtcAudioAssignment; - private readonly listenerRtcStateChanged; - private listenerClientMoved; - private listenerSpeakerStateChanged; + private listenerCallbacks: (() => void)[]; private connectionState: VoiceConnectionStatus; private localFailedReason: string; @@ -36,9 +35,11 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { private localAudioDestination: MediaStreamAudioDestinationNode; private currentAudioSourceNode: AudioNode; private currentAudioSource: RecorderProfile; + private ignoreRecorderUnmount: boolean; + private currentAudioListener: (() => void)[]; private speakerMuted: boolean; - private voiceClients: RtpVoiceClient[] = []; + private voiceClients: {[T: number]: RtpVoiceClient} = {}; private whisperSessionInitializer: WhisperSessionInitializer | undefined; private whisperSessions: RtpWhisperSession[] = []; @@ -56,32 +57,39 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { this.voiceClientStateChangedEventListener = this.handleVoiceClientStateChange.bind(this); this.whisperSessionStateChangedEventListener = this.handleWhisperSessionStateChange.bind(this); - this.rtcConnection.getEvents().on("notify_audio_assignment_changed", - this.listenerRtcAudioAssignment = event => this.handleAudioAssignmentChanged(event)); + this.listenerCallbacks = []; + this.listenerCallbacks.push( + this.rtcConnection.getEvents().on("notify_audio_assignment_changed", event => this.handleAudioAssignmentChanged(event)) + ); - this.rtcConnection.getEvents().on("notify_state_changed", - this.listenerRtcStateChanged = event => this.handleRtcConnectionStateChanged(event)); + this.listenerCallbacks.push( + this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event)) + ); - this.listenerSpeakerStateChanged = connection.client.events().on("notify_state_updated", event => { - if(event.state === "speaker") { - this.updateSpeakerState(); - } - }); - - this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => { - const localClientId = this.rtcConnection.getConnection().client.getClientId(); - for(const data of event.arguments) { - if(parseInt(data["clid"]) === localClientId) { - this.rtcConnection.startAudioBroadcast().catch(error => { - logError(LogCategory.VOICE, tr("Failed to start voice audio broadcasting after channel switch: %o"), error); - this.localFailedReason = tr("Failed to start audio broadcasting"); - this.setConnectionState(VoiceConnectionStatus.Failed); - }).catch(() => { - this.setConnectionState(VoiceConnectionStatus.Connected); - }); + this.listenerCallbacks.push( + connection.client.events().on("notify_state_updated", event => { + if(event.state === "speaker") { + this.updateSpeakerState(); } - } - }); + }) + ); + + this.listenerCallbacks.push( + this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => { + const localClientId = this.rtcConnection.getConnection().client.getClientId(); + for(const data of event.arguments) { + if(parseInt(data["clid"]) === localClientId) { + this.rtcConnection.startAudioBroadcast().catch(error => { + logError(LogCategory.VOICE, tr("Failed to start voice audio broadcasting after channel switch: %o"), error); + this.localFailedReason = tr("Failed to start audio broadcasting"); + this.setConnectionState(VoiceConnectionStatus.Failed); + }).catch(() => { + this.setConnectionState(VoiceConnectionStatus.Connected); + }); + } + } + }) + ); this.speakerMuted = connection.client.isSpeakerMuted() || connection.client.isSpeakerDisabled(); @@ -96,37 +104,32 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { this.setWhisperSessionInitializer(undefined); } + @CallOnce destroy() { - if(this.listenerClientMoved) { - this.listenerClientMoved(); - this.listenerClientMoved = undefined; - } + this.listenerCallbacks?.forEach(callback => callback()); + this.listenerCallbacks = undefined; - if(this.listenerSpeakerStateChanged) { - this.listenerSpeakerStateChanged(); - this.listenerSpeakerStateChanged = undefined; - } - - this.rtcConnection.getEvents().off("notify_audio_assignment_changed", this.listenerRtcAudioAssignment); - this.rtcConnection.getEvents().off("notify_state_changed", this.listenerRtcStateChanged); - - this.acquireVoiceRecorder(undefined, true).catch(error => { + this.ignoreRecorderUnmount = true; + this.acquireVoiceRecorder(undefined).catch(error => { logWarn(LogCategory.VOICE, tr("Failed to release voice recorder: %o"), error); - }).then(() => { - for(const client of Object.values(this.voiceClients)) { - client.abortReplay(); - } - this.voiceClients = undefined; - this.currentAudioSource = undefined; }); - if(Object.keys(this.voiceClients).length !== 0) { - logWarn(LogCategory.AUDIO, tr("Voice connection will be destroyed, but some voice clients are still left (%d)."), Object.keys(this.voiceClients).length); + + for(const key of Object.keys(this.voiceClients)) { + const client = this.voiceClients[key]; + delete this.voiceClients[key]; + + client.abortReplay(); + client.destroy(); } - /* - const whisperSessions = Object.keys(this.whisperSessions); - whisperSessions.forEach(session => this.whisperSessions[session].destroy()); - this.whisperSessions = {}; - */ + + this.currentAudioSource = undefined; + + for(const client of this.whisperSessions) { + client.getVoicePlayer()?.abortReplay(); + client.destroy(); + } + this.whisperSessions = []; + this.events.destroy(); } @@ -147,52 +150,73 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { return; } + this.currentAudioListener?.forEach(callback => callback()); + this.currentAudioListener = undefined; + if(this.currentAudioSource) { this.currentAudioSourceNode?.disconnect(this.localAudioDestination); this.currentAudioSourceNode = undefined; - this.currentAudioSource.callback_unmount = undefined; - await this.currentAudioSource.unmount(); + this.ignoreRecorderUnmount = true; + await this.currentAudioSource.ownRecorder(undefined); + this.ignoreRecorderUnmount = false; } - /* unmount our target recorder */ - await recorder?.unmount(); - - this.handleRecorderStop(); const oldRecorder = recorder; this.currentAudioSource = recorder; if(recorder) { - recorder.current_handler = this.connection.client; + const rtpConnection = this; + await recorder.ownRecorder(new class extends ConnectionRecorderProfileOwner { + getConnection(): ConnectionHandler { + return rtpConnection.connection.client; + } - recorder.callback_unmount = this.handleRecorderUnmount.bind(this); - recorder.callback_start = this.handleRecorderStart.bind(this); - recorder.callback_stop = this.handleRecorderStop.bind(this); + protected handleRecorderInput(input: AbstractInput) { + input.setConsumer({ + type: InputConsumerType.NODE, + callbackDisconnect: node => { + if(rtpConnection.currentAudioSourceNode !== node) { + /* We're not connected */ + return; + } - recorder.callback_input_initialized = async input => { - await input.setConsumer({ - type: InputConsumerType.NODE, - callbackDisconnect: node => { - this.currentAudioSourceNode = undefined; - node.disconnect(this.localAudioDestination); - }, - callbackNode: node => { - this.currentAudioSourceNode = node; - if(this.localAudioDestination) { - node.connect(this.localAudioDestination); + rtpConnection.currentAudioSourceNode = undefined; + if(rtpConnection.localAudioDestination) { + node.disconnect(rtpConnection.localAudioDestination); + } + }, + callbackNode: node => { + if(rtpConnection.currentAudioSourceNode === node) { + return; + } + + if(rtpConnection.localAudioDestination) { + rtpConnection.currentAudioSourceNode?.disconnect(rtpConnection.localAudioDestination); + } + + rtpConnection.currentAudioSourceNode = node; + if(rtpConnection.localAudioDestination) { + node.connect(rtpConnection.localAudioDestination); + } } - } - }); - }; - if(recorder.input) { - recorder.callback_input_initialized(recorder.input); - } + }); + } - if(!recorder.input || recorder.input.isFiltered()) { - this.handleRecorderStop(); - } else { - this.handleRecorderStart(); - } + protected handleUnmount() { + rtpConnection.handleRecorderUnmount(); + } + }); + + this.currentAudioListener = []; + this.currentAudioListener.push(recorder.events.on("notify_voice_start", () => this.handleRecorderStart())); + this.currentAudioListener.push(recorder.events.on("notify_voice_end", () => this.handleRecorderStop(tr("recorder event")))); + } + + if(this.currentAudioSource?.isInputActive()) { + this.handleRecorderStart(); + } else { + this.handleRecorderStop(tr("recorder change")); } this.events.fire("notify_recorder_changed", { @@ -201,27 +225,13 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { }); } - private handleRecorderStop() { + private handleRecorderStop(reason: string) { const chandler = this.connection.client; - const ch = chandler.getClient(); - if(ch) ch.speaking = false; + chandler.getClient()?.setSpeaking(false); - if(!chandler.connected) { - return false; - } - - if(chandler.isMicrophoneMuted()) { - return false; - } - - logInfo(LogCategory.VOICE, tr("Local voice ended")); - - this.rtcConnection.setTrackSource("audio", null).catch(error => { - logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); - }); - - this.rtcConnection.setTrackSource("audio-whisper", null).catch(error => { - logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); + logInfo(LogCategory.VOICE, tr("Received local voice end signal (%s)"), reason); + this.rtcConnection.clearTrackSources(["audio", "audio-whisper"]).catch(error => { + logError(LogCategory.AUDIO, tr("Failed to stop/remove audio RTC tracks: %o"), error); }); } @@ -234,19 +244,19 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { logInfo(LogCategory.VOICE, tr("Local voice started")); - const ch = chandler.getClient(); - if(ch) { ch.speaking = true; } + chandler.getClient()?.setSpeaking(true); - this.rtcConnection.setTrackSource(this.whisperTarget ? "audio-whisper" : "audio", this.localAudioDestination.stream.getAudioTracks()[0]) - .catch(error => { - logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); - }); + const audioTrack = this.localAudioDestination.stream.getAudioTracks()[0]; + const audioTarget = this.whisperTarget ? "audio-whisper" : "audio"; + this.rtcConnection.setTrackSource(audioTarget, audioTrack).catch(error => { + logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); + }); } private handleRecorderUnmount() { logInfo(LogCategory.VOICE, "Lost recorder!"); this.currentAudioSource = undefined; - this.acquireVoiceRecorder(undefined, true); /* we can ignore the promise because we should finish this directly */ + ignorePromise(crashOnThrow(this.acquireVoiceRecorder(undefined, true))); } isReplayingVoice(): boolean { @@ -359,7 +369,7 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { return; } - this.handleRecorderStop(); + this.handleRecorderStop(tr("whisper start")); if(this.currentAudioSource?.input && !this.currentAudioSource.input.isFiltered()) { this.handleRecorderStart(); } @@ -379,7 +389,7 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { }); } - this.handleRecorderStop(); + this.handleRecorderStop(tr("whisper stop")); if(this.currentAudioSource?.input && !this.currentAudioSource.input.isFiltered()) { this.handleRecorderStart(); } @@ -538,7 +548,7 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { if(this.speakerMuted === newState) { return; } this.speakerMuted = newState; - this.voiceClients.forEach(client => client.setGloballyMuted(this.speakerMuted)); + Object.values(this.voiceClients).forEach(client => client.setGloballyMuted(this.speakerMuted)); this.whisperSessions.forEach(session => session.setGloballyMuted(this.speakerMuted)); }