Improved the Recorder API

master
WolverinDEV 2021-04-05 23:05:44 +02:00
parent ae39685a40
commit d4179db329
15 changed files with 599 additions and 292 deletions

View File

@ -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

View File

@ -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<ConnectionEvents>();
@ -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<ConnectionEvents> {
@ -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; }

View File

@ -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 });

View File

@ -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();

View File

@ -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<VoicePlayerEvents>;
@ -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<void> {
@ -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,

View File

@ -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<CommandResult> {
if(!this.currentServerConnection)
protected sendPluginCommand(data: string, mode: "server" | "view" | "channel" | "private", clientOrChannelId?: number) : Promise<CommandResult> {
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
});
}
}

View File

@ -661,6 +661,25 @@ export class RTCConnection {
return oldTrack;
}
async clearTrackSources(types: RTCSourceTrackType[]) : Promise<MediaStreamTrack[]> {
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;

View File

@ -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]);
}
}
export function crashOnThrow<T>(promise: Promise<T> | (() => Promise<T>)) : Promise<T> {
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<T>(_promise: Promise<T>) {}
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;

View File

@ -260,12 +260,13 @@ export class ClientEntry<Events extends ClientEvents = ClientEvents> 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<Events extends ClientEvents = ClientEvents> 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(), {

View File

@ -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<AppUiEvents> }) => {
@ -110,6 +111,10 @@ export const TeaAppMainView = (props: {
<ErrorBoundary>
<InternalModalHook />
</ErrorBoundary>
<ErrorBoundary>
<TooltipHook />
</ErrorBoundary>
</div>
);
}

View File

@ -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<MicrophoneSettingsEvents>) {
const recorderBackend = getRecorderBackend();
@ -438,25 +439,26 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
/* TODO: Only do this on user request? */
{
const ownDefaultRecorder = () => {
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);
});
});
}
}

View File

@ -11,6 +11,7 @@ interface GlobalTooltipState {
tooltipId: string;
}
const globalTooltipRef = React.createRef<GlobalTooltip>();
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
</Tooltip>
);
const globalTooltipRef = React.createRef<GlobalTooltip>();
const tooltipContainer = document.createElement("div");
document.body.appendChild(tooltipContainer);
ReactDOM.render(<GlobalTooltip ref={globalTooltipRef} />, tooltipContainer);
export const TooltipHook = React.memo(() => <GlobalTooltip ref={globalTooltipRef} />);

View File

@ -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 }) => (
<React.Fragment>
<ImagePreviewHook />
<TooltipHook />
<React.Fragment>{props.children}</React.Fragment>
</React.Fragment>
));
export class ModalRenderer {
private readonly functionController: ModalControlFunctions;
private readonly container: HTMLDivElement;
@ -33,30 +43,34 @@ export class ModalRenderer {
if(__build.target === "client") {
ReactDOM.render(
<ModalFrameRenderer windowed={true}>
<ModalFrameTopRenderer
replacePageTitle={true}
modalInstance={modal}
<GlobalHooks>
<ModalFrameRenderer windowed={true}>
<ModalFrameTopRenderer
replacePageTitle={true}
modalInstance={modal}
onClose={() => this.functionController.close()}
onMinimize={() => this.functionController.minimize()}
/>
<ModalBodyRenderer modalInstance={modal} />
</ModalFrameRenderer>,
onClose={() => this.functionController.close()}
onMinimize={() => this.functionController.minimize()}
/>
<ModalBodyRenderer modalInstance={modal} />
</ModalFrameRenderer>
</GlobalHooks>,
this.container
);
} else {
ReactDOM.render(
<WindowModalRenderer>
<ModalFrameTopRenderer
replacePageTitle={true}
modalInstance={modal}
<GlobalHooks>
<WindowModalRenderer>
<ModalFrameTopRenderer
replacePageTitle={true}
modalInstance={modal}
onClose={() => this.functionController.close()}
onMinimize={() => this.functionController.minimize()}
/>
<ModalBodyRenderer modalInstance={modal} />
</WindowModalRenderer>,
onClose={() => this.functionController.close()}
onMinimize={() => this.functionController.minimize()}
/>
<ModalBodyRenderer modalInstance={modal} />
</WindowModalRenderer>
</GlobalHooks>,
this.container
);
}

View File

@ -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<RecorderProfileEvents>;
readonly name;
readonly volatile; /* not saving profile */
config: RecorderProfileConfig;
input: AbstractInput;
/* TODO! */
/* private */input: AbstractInput;
private currentOwner: RecorderProfileOwner;
private currentOwnerMutex: Mutex<void>;
/* 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<RecorderProfileEvents>();
this.name = name;
this.volatile = typeof(volatile) === "boolean" ? volatile : false;
this.currentOwnerMutex = new Mutex<void>(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<void> {
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<void> {
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);

View File

@ -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));
}