TeaWeb/shared/js/voice/VoiceHandler.ts

465 lines
18 KiB
TypeScript

/// <reference path="../client.ts" />
/// <reference path="../codec/Codec.ts" />
/// <reference path="VoiceRecorder.ts" />
class CodecPoolEntry {
instance: BasicCodec;
owner: number;
last_access: number;
}
class CodecPool {
handle: VoiceConnection;
codecIndex: number;
name: string;
type: CodecType;
entries: CodecPoolEntry[] = [];
maxInstances: number = 2;
private _supported: boolean = true;
initialize(cached: number) {
for(let i = 0; i < cached; i++)
this.ownCodec(i + 1).then(codec => {
console.log(tr("Release again! (%o)"), codec);
this.releaseCodec(i + 1);
}).catch(error => {
console.warn(tr("Disabling codec support for "), this.name);
if(this._supported) {
createErrorModal(tr("Could not load codec driver"), tr("Could not load or initialize codec ") + this.name + "<br>" +
"Error: <code>" + JSON.stringify(error) + "</code>").open();
}
this._supported = false;
console.error(error);
});
}
supported() { return this._supported; }
ownCodec?(clientId: number, create: boolean = true) : Promise<BasicCodec | undefined> {
return new Promise<BasicCodec>((resolve, reject) => {
if(!this._supported) {
reject(tr("unsupported codec!"));
return;
}
let freeSlot = 0;
for(let index = 0; index < this.entries.length; index++) {
if(this.entries[index].owner == clientId) {
this.entries[index].last_access = new Date().getTime();
if(this.entries[index].instance.initialized()) resolve(this.entries[index].instance);
else {
this.entries[index].instance.initialise().then((flag) => {
//TODO test success flag
this.ownCodec(clientId, false).then(resolve).catch(reject);
}).catch(error => {
console.error(tr("Could not initialize codec!\nError: %o"), error);
reject(tr("Could not initialize codec!"));
});
}
return;
} else if(freeSlot == 0 && this.entries[index].owner == 0) {
freeSlot = index;
}
}
if(!create) {
resolve(undefined);
return;
}
if(freeSlot == 0){
freeSlot = this.entries.length;
let entry = new CodecPoolEntry();
entry.instance = audio.codec.new_instance(this.type);
entry.instance.on_encoded_data = buffer => this.handle.handleEncodedVoicePacket(buffer, this.codecIndex);
this.entries.push(entry);
}
this.entries[freeSlot].owner = clientId;
this.entries[freeSlot].last_access = new Date().getTime();
if(this.entries[freeSlot].instance.initialized())
this.entries[freeSlot].instance.reset();
else {
this.ownCodec(clientId, false).then(resolve).catch(reject);
return;
}
resolve(this.entries[freeSlot].instance);
});
}
releaseCodec(clientId: number) {
for(let index = 0; index < this.entries.length; index++)
if(this.entries[index].owner == clientId) this.entries[index].owner = 0;
}
constructor(handle: VoiceConnection, index: number, name: string, type: CodecType){
this.handle = handle;
this.codecIndex = index;
this.name = name;
this.type = type;
this._supported = this.type !== undefined && audio.codec.supported(this.type);
}
}
enum VoiceConnectionType {
JS_ENCODE,
NATIVE_ENCODE
}
/* funny fact that typescript dosn't find this */
interface RTCPeerConnection {
addStream(stream: MediaStream): void;
getLocalStreams(): MediaStream[];
getStreamById(streamId: string): MediaStream | null;
removeStream(stream: MediaStream): void;
createOffer(successCallback?: RTCSessionDescriptionCallback, failureCallback?: RTCPeerConnectionErrorCallback, options?: RTCOfferOptions): Promise<RTCSessionDescription>;
}
class VoiceConnection {
client: TSClient;
rtcPeerConnection: RTCPeerConnection;
dataChannel: RTCDataChannel;
voiceRecorder: VoiceRecorder;
private _type: VoiceConnectionType = VoiceConnectionType.NATIVE_ENCODE;
local_audio_stream: any;
private codec_pool: CodecPool[] = [
new CodecPool(this,0,tr("Speex Narrowband"), CodecType.SPEEX_NARROWBAND),
new CodecPool(this,1,tr("Speex Wideband"), CodecType.SPEEX_WIDEBAND),
new CodecPool(this,2,tr("Speex Ultra Wideband"), CodecType.SPEEX_ULTRA_WIDEBAND),
new CodecPool(this,3,tr("CELT Mono"), CodecType.CELT_MONO),
new CodecPool(this,4,tr("Opus Voice"), CodecType.OPUS_VOICE),
new CodecPool(this,5,tr("Opus Music"), CodecType.OPUS_MUSIC)
];
private vpacketId: number = 0;
private chunkVPacketId: number = 0;
private send_task: NodeJS.Timer;
constructor(client) {
this.client = client;
this._type = settings.static_global("voice_connection_type", this._type);
this.voiceRecorder = new VoiceRecorder(this);
this.voiceRecorder.on_end = this.handleVoiceEnded.bind(this);
this.voiceRecorder.on_start = this.handleVoiceStarted.bind(this);
this.voiceRecorder.reinitialiseVAD();
audio.player.on_ready(() => {
log.info(LogCategory.VOICE, tr("Initializing voice handler after AudioController has been initialized!"));
if(native_client) {
this.codec_pool[0].initialize(2);
this.codec_pool[1].initialize(2);
this.codec_pool[2].initialize(2);
this.codec_pool[3].initialize(2);
}
this.codec_pool[4].initialize(2);
this.codec_pool[5].initialize(2);
if(this.type == VoiceConnectionType.NATIVE_ENCODE)
this.setup_native();
else
this.setup_js();
});
this.send_task = setInterval(this.sendNextVoicePacket.bind(this), 20);
}
native_encoding_supported() : boolean {
if(!(window.webkitAudioContext || window.AudioContext || {prototype: {}} as typeof AudioContext).prototype.createMediaStreamDestination) return false; //Required, but not available within edge
return true;
}
javascript_encoding_supported() : boolean {
if(!(window.RTCPeerConnection || {prototype: {}} as typeof RTCPeerConnection).prototype.createDataChannel) return false;
return true;
}
current_encoding_supported() : boolean {
switch (this._type) {
case VoiceConnectionType.JS_ENCODE:
return this.javascript_encoding_supported();
case VoiceConnectionType.NATIVE_ENCODE:
return this.native_encoding_supported();
}
return false;
}
private setup_native() {
log.info(LogCategory.VOICE, tr("Setting up native voice stream!"));
if(!this.native_encoding_supported()) {
log.warn(LogCategory.VOICE, tr("Native codec isnt supported!"));
return;
}
this.voiceRecorder.on_data = undefined;
let stream = this.voiceRecorder.get_output_stream();
stream.disconnect();
if(!this.local_audio_stream)
this.local_audio_stream = audio.player.context().createMediaStreamDestination();
stream.connect(this.local_audio_stream);
}
private setup_js() {
if(!this.javascript_encoding_supported()) return;
this.voiceRecorder.on_data = this.handleVoiceData.bind(this);
}
get type() : VoiceConnectionType { return this._type; }
set type(target: VoiceConnectionType) {
if(target == this.type) return;
this._type = target;
if(this.type == VoiceConnectionType.NATIVE_ENCODE)
this.setup_native();
else
this.setup_js();
this.createSession();
}
codecSupported(type: number) : boolean {
return this.codec_pool.length > type && this.codec_pool[type].supported();
}
voice_playback_support() : boolean {
return this.dataChannel && this.dataChannel.readyState == "open";
}
voice_send_support() : boolean {
if(this.type == VoiceConnectionType.NATIVE_ENCODE)
return this.native_encoding_supported() && this.rtcPeerConnection.getLocalStreams().length > 0;
else
return this.voice_playback_support();
}
private voice_send_queue: {data: Uint8Array, codec: number}[] = [];
handleEncodedVoicePacket(data: Uint8Array, codec: number){
this.voice_send_queue.push({data: data, codec: codec});
}
private sendNextVoicePacket() {
let buffer = this.voice_send_queue.pop_front();
if(!buffer) return;
this.sendVoicePacket(buffer.data, buffer.codec);
}
sendVoicePacket(data: Uint8Array, codec: number) {
if(this.dataChannel) {
this.vpacketId++;
if(this.vpacketId > 65535) this.vpacketId = 0;
let packet = new Uint8Array(data.byteLength + 2 + 3);
packet[0] = this.chunkVPacketId++ < 5 ? 1 : 0; //Flag header
packet[1] = 0; //Flag fragmented
packet[2] = (this.vpacketId >> 8) & 0xFF; //HIGHT (voiceID)
packet[3] = (this.vpacketId >> 0) & 0xFF; //LOW (voiceID)
packet[4] = codec; //Codec
packet.set(data, 5);
try {
this.dataChannel.send(packet);
} catch (e) {
//TODO may handle error?
}
} else {
console.warn(tr("Could not transfer audio (not connected)"));
}
}
createSession() {
if(!this.current_encoding_supported()) return false;
if(this.rtcPeerConnection) {
this.dropSession();
}
this._ice_use_cache = true;
let config: RTCConfiguration = {};
config.iceServers = [];
config.iceServers.push({ urls: 'stun:stun.l.google.com:19302' });
this.rtcPeerConnection = new RTCPeerConnection(config);
const dataChannelConfig = { ordered: true, maxRetransmits: 0 };
this.dataChannel = this.rtcPeerConnection.createDataChannel('main', dataChannelConfig);
this.dataChannel.onmessage = this.onDataChannelMessage.bind(this);
this.dataChannel.onopen = this.onDataChannelOpen.bind(this);
this.dataChannel.binaryType = "arraybuffer";
let sdpConstraints : RTCOfferOptions = {};
sdpConstraints.offerToReceiveAudio = this._type == VoiceConnectionType.NATIVE_ENCODE;
sdpConstraints.offerToReceiveVideo = false;
this.rtcPeerConnection.onicecandidate = this.onIceCandidate.bind(this);
if(this.local_audio_stream) { //May a typecheck?
this.rtcPeerConnection.addStream(this.local_audio_stream.stream);
console.log(tr("Adding stream (%o)!"), this.local_audio_stream.stream);
}
this.rtcPeerConnection.createOffer(this.onOfferCreated.bind(this), () => {
console.error(tr("Could not create ice offer!"));
}, sdpConstraints);
}
dropSession() {
if(this.dataChannel) this.dataChannel.close();
if(this.rtcPeerConnection) this.rtcPeerConnection.close();
//TODO here!
}
_ice_use_cache: boolean = true;
_ice_cache: any[] = [];
handleControlPacket(json) {
if(json["request"] === "answer") {
console.log(tr("Set remote sdp! (%o)"), json["msg"]);
this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(json["msg"])).catch(error => {
console.log(tr("Failed to apply remote description: %o"), error); //FIXME error handling!
});
this._ice_use_cache = false;
for(let msg of this._ice_cache) {
this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(msg)).catch(error => {
console.log(tr("Failed to add remote cached ice candidate %s: %o"), msg, error);
});
}
} else if(json["request"] === "ice") {
if(!this._ice_use_cache) {
console.log(tr("Add remote ice! (%s | %o)"), json["msg"], json);
this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(json["msg"])).catch(error => {
console.log(tr("Failed to add remote ice candidate %s: %o"), json["msg"], error);
});
} else {
console.log(tr("Cache remote ice! (%s | %o)"), json["msg"], json);
this._ice_cache.push(json["msg"]);
}
} else if(json["request"] == "status") {
if(json["state"] == "failed") {
chat.serverChat().appendError(tr("Failed to setup voice bridge ({}). Allow reconnect: {}"), json["reason"], json["allow_reconnect"]);
log.error(LogCategory.NETWORKING, tr("Failed to setup voice bridge (%s). Allow reconnect: %s"), json["reason"], json["allow_reconnect"]);
if(json["allow_reconnect"] == true) {
this.createSession();
}
//TODO handle fail specially when its not allowed to reconnect
}
}
}
//Listeners
onIceCandidate(event) {
console.log(tr("Got ice candidate! Event:"));
console.log(event);
if (event) {
if(event.candidate)
this.client.serverConnection.sendData(JSON.stringify({
type: 'WebRTC',
request: "ice",
msg: event.candidate,
}));
else {
this.client.serverConnection.sendData(JSON.stringify({
type: 'WebRTC',
request: "ice_finish"
}));
}
}
}
onOfferCreated(localSession) {
console.log(tr("Offer created and accepted"));
this.rtcPeerConnection.setLocalDescription(localSession).catch(error => {
console.log(tr("Failed to apply local description: %o"), error);
//FIXME error handling
});
console.log(tr("Send offer: %o"), localSession);
this.client.serverConnection.sendData(JSON.stringify({type: 'WebRTC', request: "create", msg: localSession}));
}
onDataChannelOpen(channel) {
console.log(tr("Got new data channel! (%s)"), this.dataChannel.readyState);
this.client.controlBar.updateVoice();
}
onDataChannelMessage(message) {
if(this.client.controlBar.muteOutput) return;
let bin = new Uint8Array(message.data);
let clientId = bin[2] << 8 | bin[3];
let packetId = bin[0] << 8 | bin[1];
let codec = bin[4];
//console.log("Client id " + clientId + " PacketID " + packetId + " Codec: " + codec);
let client = this.client.channelTree.findClient(clientId);
if(!client) {
console.error(tr("Having voice from unknown client? (ClientID: %o)"), clientId);
return;
}
let codecPool = this.codec_pool[codec];
if(!codecPool) {
console.error(tr("Could not playback codec %o"), codec);
return;
}
let encodedData;
if(message.data.subarray)
encodedData = message.data.subarray(5);
else encodedData = new Uint8Array(message.data, 5);
if(encodedData.length == 0) {
client.getAudioController().stopAudio();
codecPool.releaseCodec(clientId);
} else {
codecPool.ownCodec(clientId)
.then(decoder => decoder.decodeSamples(client.getAudioController().codecCache(codec), encodedData))
.then(buffer => client.getAudioController().playBuffer(buffer)).catch(error => {
console.error(tr("Could not playback client's (%o) audio (%o)"), clientId, error);
if(error instanceof Error)
console.error(error.stack);
});
}
}
private current_channel_codec() : number {
return (this.client.getClient().currentChannel() || {properties: { channel_codec: 4}}).properties.channel_codec;
}
private handleVoiceData(data: AudioBuffer, head: boolean) {
if(!this.voiceRecorder) return;
if(!this.client.connected) return false;
if(this.client.controlBar.muteInput) return;
if(head) {
this.chunkVPacketId = 0;
this.client.getClient().speaking = true;
}
//TODO Use channel codec!
const codec = this.current_channel_codec();
this.codec_pool[codec].ownCodec(this.client.getClientId())
.then(encoder => encoder.encodeSamples(this.client.getClient().getAudioController().codecCache(codec), data));
}
private handleVoiceEnded() {
if(this.client && this.client.getClient())
this.client.getClient().speaking = false;
if(!this.voiceRecorder) return;
if(!this.client.connected) return;
console.log(tr("Local voice ended"));
if(this.dataChannel)
this.sendVoicePacket(new Uint8Array(0), this.current_channel_codec()); //TODO Use channel codec!
}
private handleVoiceStarted() {
console.log(tr("Local voice started"));
if(this.client && this.client.getClient())
this.client.getClient().speaking = true;
}
}