TeaWeb/web/app/legacy/voice/VoicePlayer.ts

293 lines
9.7 KiB
TypeScript

import {
VoicePlayer,
VoicePlayerEvents,
VoicePlayerLatencySettings,
VoicePlayerState
} from "tc-shared/voice/VoicePlayer";
import {AudioClient} from "../audio-lib/AudioClient";
import {AudioResampler} from "./AudioResampler";
import {Registry} from "tc-shared/events";
import * as aplayer from "tc-backend/web/audio/player";
import {getAudioLibrary} from "../audio-lib";
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
import { tr } from "tc-shared/i18n/localize";
const kDefaultLatencySettings = {
minBufferTime: 60,
maxBufferTime: 400
} as VoicePlayerLatencySettings;
export class WebVoicePlayer implements VoicePlayer {
public readonly events: Registry<VoicePlayerEvents>;
private speakerContext: AudioContext;
private gainNode: GainNode;
private playerState = VoicePlayerState.STOPPED;
private currentPlaybackTime: number = 0;
private bufferTimeout: number;
private bufferQueueTime: number = 0;
private bufferQueue: AudioBuffer[] = [];
private playingNodes: AudioBufferSourceNode[] = [];
private currentVolume: number = 1;
private latencySettings: VoicePlayerLatencySettings;
private audioInitializePromise: Promise<void>;
private audioClient: AudioClient;
private resampler: AudioResampler;
constructor() {
this.events = new Registry<VoicePlayerEvents>();
this.resampler = new AudioResampler(48000);
aplayer.on_ready(() => {
this.speakerContext = aplayer.context();
this.gainNode = aplayer.context().createGain();
this.gainNode.connect(this.speakerContext.destination);
this.gainNode.gain.value = this.currentVolume;
this.initializeAudio();
});
this.resetLatencySettings();
this.setPlayerState(VoicePlayerState.STOPPED);
}
abortReplay() {
this.stopAudio(true);
}
flushBuffer() {
this.bufferQueue = [];
this.bufferQueueTime = 0;
for(const entry of this.playingNodes) {
entry.stop(0);
}
this.playingNodes = [];
}
getState(): VoicePlayerState {
return this.playerState;
}
getVolume(): number {
return this.currentVolume;
}
setVolume(volume: number) {
if(this.currentVolume == volume) {
return;
}
this.currentVolume = volume;
if(this.gainNode) {
this.gainNode.gain.value = volume;
}
}
getLatencySettings(): Readonly<VoicePlayerLatencySettings> {
return this.latencySettings;
}
setLatencySettings(settings: VoicePlayerLatencySettings) {
this.latencySettings = settings
}
resetLatencySettings() {
this.latencySettings = kDefaultLatencySettings;
}
enqueueAudioPacket(packetId: number, codec: number, head: boolean, buffer: Uint8Array) {
if(!this.audioClient) {
return;
} else {
this.initializeAudio().then(() => {
if(!this.audioClient) {
/* we've already been destroyed */
return;
}
this.audioClient.enqueueBuffer(buffer, packetId, codec, head);
});
}
}
destroy() {
this.audioClient?.destroy();
this.audioClient = undefined;
this.events.destroy();
}
private initializeAudio() : Promise<void> {
if(this.audioInitializePromise) {
return this.audioInitializePromise;
}
this.audioInitializePromise = (async () => {
this.audioClient = await getAudioLibrary().createClient();
this.audioClient.callback_decoded = buffer => {
this.resampler.resample(buffer).then(buffer => {
this.playbackAudioBuffer(buffer);
});
}
this.audioClient.callback_ended = () => {
this.stopAudio(false);
};
})();
return this.audioInitializePromise;
}
playbackAudioBuffer(buffer: AudioBuffer) {
if(!buffer) {
logWarn(LogCategory.VOICE, tr("[AudioController] Got empty or undefined buffer! Dropping it"));
return;
}
if(!this.speakerContext) {
logWarn(LogCategory.VOICE, tr("[AudioController] Failed to replay audio. Global audio context not initialized yet!"));
return;
}
if (buffer.sampleRate != this.speakerContext.sampleRate) {
logWarn(LogCategory.VOICE, tr("[AudioController] Source sample rate isn't equal to playback sample rate! (%o | %o)"), buffer.sampleRate, this.speakerContext.sampleRate);
}
if(this.playerState == VoicePlayerState.STOPPED || this.playerState == VoicePlayerState.STOPPING) {
logDebug(LogCategory.VOICE, tr("[Audio] Starting new playback"));
this.setPlayerState(VoicePlayerState.PREBUFFERING);
}
if(this.playerState === VoicePlayerState.PREBUFFERING || this.playerState === VoicePlayerState.BUFFERING) {
this.resetBufferTimeout(true);
this.bufferQueue.push(buffer);
this.bufferQueueTime += buffer.duration;
if(this.bufferQueueTime <= this.latencySettings.minBufferTime / 1000) {
return;
}
/* finished buffering */
if(this.playerState == VoicePlayerState.PREBUFFERING) {
logDebug(LogCategory.VOICE, tr("[Audio] Prebuffering succeeded (Replaying now)"));
} else {
logDebug(LogCategory.VOICE, tr("[Audio] Buffering succeeded (Replaying now)"));
}
this.gainNode.gain.value = 0;
this.gainNode.gain.linearRampToValueAtTime(this.currentVolume, this.speakerContext.currentTime + .1);
this.replayBufferQueue();
this.setPlayerState(VoicePlayerState.PLAYING);
} else if(this.playerState === VoicePlayerState.PLAYING) {
const latency = this.getCurrentPlaybackLatency();
if(latency > (this.latencySettings.maxBufferTime / 1000)) {
logWarn(LogCategory.VOICE, tr("Dropping replay buffer because of too high replay latency. (Current: %f, Max: %f)"),
latency.toFixed(3), (this.latencySettings.maxBufferTime / 1000).toFixed(3));
return;
}
this.enqueueBufferForPayback(buffer);
} else {
logError(LogCategory.AUDIO, tr("This block should be unreachable!"));
return;
}
}
getCurrentPlaybackLatency() {
return Math.max(this.currentPlaybackTime - this.speakerContext.currentTime, 0);
}
stopAudio(abortPlayback: boolean) {
if(abortPlayback) {
this.setPlayerState(VoicePlayerState.STOPPED);
this.flushBuffer();
} else {
this.setPlayerState(VoicePlayerState.STOPPING);
/* replay all pending buffers */
this.replayBufferQueue();
/* test if there are any buffers which are currently played, if not the state will change to stopped */
this.testReplayState();
}
}
private replayBufferQueue() {
for(const buffer of this.bufferQueue)
this.enqueueBufferForPayback(buffer);
this.bufferQueue = [];
this.bufferQueueTime = 0;
}
private enqueueBufferForPayback(buffer: AudioBuffer) {
/* advance the playback time index, we seem to be behind a bit */
if(this.currentPlaybackTime < this.speakerContext.currentTime)
this.currentPlaybackTime = this.speakerContext.currentTime;
const player = this.speakerContext.createBufferSource();
player.buffer = buffer;
player.onended = () => this.handleBufferPlaybackEnded(player);
this.playingNodes.push(player);
player.connect(this.gainNode);
player.start(this.currentPlaybackTime);
this.currentPlaybackTime += buffer.duration;
}
private handleBufferPlaybackEnded(node: AudioBufferSourceNode) {
this.playingNodes.remove(node);
this.testReplayState();
}
private testReplayState() {
if(this.bufferQueue.length > 0 || this.playingNodes.length > 0) {
return;
}
if(this.playerState === VoicePlayerState.STOPPING) {
/* All buffers have been replayed successfully */
this.setPlayerState(VoicePlayerState.STOPPED);
} else if(this.playerState === VoicePlayerState.PLAYING) {
logDebug(LogCategory.VOICE, tr("Voice player has a buffer underflow. Changing state to buffering."));
this.setPlayerState(VoicePlayerState.BUFFERING);
this.resetBufferTimeout(true);
}
}
/***
* Schedule a new buffer timeout.
* The buffer timeout is used to playback even small amounts of audio, which are less than the min. buffer size.
* @param scheduleNewTimeout
* @private
*/
private resetBufferTimeout(scheduleNewTimeout: boolean) {
clearTimeout(this.bufferTimeout);
if(scheduleNewTimeout) {
this.bufferTimeout = setTimeout(() => {
if(this.playerState == VoicePlayerState.PREBUFFERING || this.playerState == VoicePlayerState.BUFFERING) {
logWarn(LogCategory.VOICE, tr("[Audio] Buffering exceeded timeout. Flushing and stopping replay."));
this.stopAudio(false);
}
this.bufferTimeout = undefined;
}, 1000);
}
}
private setPlayerState(state: VoicePlayerState) {
if(this.playerState === state) {
return;
}
const oldState = this.playerState;
this.playerState = state;
this.events.fire("notify_state_changed", {
oldState: oldState,
newState: state
});
}
}