Improved the Recorder API
parent
ae39685a40
commit
d4179db329
|
@ -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
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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(), {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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} />);
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue