enum Sound { SOUND_TEST = "sound.test", SOUND_EGG = "sound.egg", AWAY_ACTIVATED = "away_activated", AWAY_DEACTIVATED = "away_deactivated", MICROPHONE_MUTED = "microphone.muted", MICROPHONE_ACTIVATED = "microphone.activated", SOUND_MUTED = "sound.muted", SOUND_ACTIVATED = "sound.activated", CONNECTION_CONNECTED = "connection.connected", CONNECTION_DISCONNECTED = "connection.disconnected", CONNECTION_BANNED = "connection.banned", CONNECTION_DISCONNECTED_TIMEOUT = "connection.disconnected.timeout", CONNECTION_REFUSED = "connection.refused", SERVER_EDITED = "server.edited", SERVER_EDITED_SELF = "server.edited.self", SERVER_KICKED = "server.kicked", CHANNEL_CREATED = "channel.created", CHANNEL_MOVED = "channel.moved", CHANNEL_EDITED = "channel.edited", CHANNEL_EDITED_SELF = "channel.edited.self", CHANNEL_DELETED = "channel.deleted", CHANNEL_JOINED = "channel.joined", CHANNEL_KICKED = "channel.kicked", //You got kicked from the channel USER_MOVED = "user.moved", //User moved USER_MOVED_SELF = "user.moved.self", //You were moved USER_POKED_SELF = "user.poked.self", //Hey wakeup USER_BANNED = "user.banned", USER_ENTERED = "user.joined", //User joined your channel USER_ENTERED_MOVED = "user.joined.moved", //User was moved to your channel USER_ENTERED_KICKED = "user.joined.kicked", //User was kicked to your channel USER_ENTERED_CONNECT = "user.joined.connect", USER_LEFT = "user.left", //User left your channel USER_LEFT_MOVED = "user.left.moved", //User was move out of your channel USER_LEFT_KICKED_CHANNEL = "user.left.kicked.server", //User was kicked out of your channel USER_LEFT_KICKED_SERVER = "user.left.kicked.channel", //User is your channel was kicked from the server USER_LEFT_DISCONNECT = "user.left.disconnect", USER_LEFT_BANNED = "user.left.banned", USER_LEFT_TIMEOUT = "user.left.timeout", ERROR_INSUFFICIENT_PERMISSIONS = "error.insufficient_permissions", MESSAGE_SEND = "message.send", MESSAGE_RECEIVED = "message.received", GROUP_SERVER_ASSIGNED = "group.server.assigned", GROUP_SERVER_REVOKED = "group.server.revoked", GROUP_CHANNEL_CHANGED = "group.channel.changed", GROUP_SERVER_ASSIGNED_SELF = "group.server.assigned.self", GROUP_SERVER_REVOKED_SELF = "group.server.revoked.self", GROUP_CHANNEL_CHANGED_SELF = "group.channel.changed.self" } namespace sound { export interface SoundHandle { key: string; filename: string; not_supported?: boolean; not_supported_timeout?: number; cached?: AudioBuffer; node?: HTMLAudioElement; replaying: boolean; } let warned = false; let speech_mapping: {[key: string]:SoundHandle} = {}; let volume_require_save = false; let speech_volume: {[key: string]:number} = {}; let master_volume: number; let overlap_sounds: boolean; let ignore_muted: boolean; let master_mixed: GainNode; function register_sound(key: string, file: string) { speech_mapping[key] = {key: key, filename: file} as SoundHandle; } export function get_sound_volume(sound: Sound, default_volume?: number) : number { let result = speech_volume[sound]; if(typeof(result) === "undefined") { if(typeof(default_volume) !== "undefined") result = default_volume; else result = 1; } return result; } export function set_sound_volume(sound: Sound, volume: number) { volume_require_save = volume_require_save || speech_volume[sound] != volume; speech_volume[sound] = volume == 1 ? undefined : volume; } export function get_master_volume() : number { return master_volume; } export function set_master_volume(volume: number) { volume_require_save = volume_require_save || master_volume != volume; master_volume = volume; if(master_mixed) { if(master_mixed.gain.setValueAtTime) master_mixed.gain.setValueAtTime(volume, 0); else master_mixed.gain.value = volume; } } export function overlap_activated() : boolean { return overlap_sounds; } export function set_overlap_activated(flag: boolean) { volume_require_save = volume_require_save || overlap_sounds != flag; overlap_sounds = flag; } export function ignore_output_muted() : boolean { return ignore_muted; } export function set_ignore_output_muted(flag: boolean) { volume_require_save = volume_require_save || ignore_muted != flag; ignore_muted = flag; } export function reinitialisize_audio() { const context = audio.player.context(); const destination = audio.player.destination(); if(master_mixed) master_mixed.disconnect(); master_mixed = context.createGain(); if(master_mixed.gain.setValueAtTime) master_mixed.gain.setValueAtTime(master_volume, 0); else master_mixed.gain.value = master_volume; master_mixed.connect(destination); } export function save() { if(volume_require_save) { volume_require_save = false; const data: any = {}; data.version = 1; for(const key in Sound) { if(typeof(speech_volume[Sound[key]]) !== "undefined") data[Sound[key]] = speech_volume[Sound[key]]; } data.master = master_volume; data.overlap = overlap_sounds; data.ignore_muted = ignore_muted; settings.changeGlobal("sound_volume", JSON.stringify(data)); } } export function initialize() : Promise { $.ajaxSetup({ beforeSend: function(jqXHR,settings){ if (settings.dataType === 'binary') { settings.xhr().responseType = 'arraybuffer'; settings.processData = false; } } }); /* volumes */ { const data = JSON.parse(settings.static_global("sound_volume", "{}")); for(const sound_key in Sound) { if(typeof(data[Sound[sound_key]]) !== "undefined") speech_volume[Sound[sound_key]] = data[Sound[sound_key]]; } master_volume = typeof(data.master) === "number" ? data.master : 1; overlap_sounds = typeof(data.overlap) === "boolean" ? data.overlap : true; ignore_muted = typeof(data.ignore_muted) === "boolean" ? data.ignore_muted : false; } register_sound("message.received", "effects/message_received.wav"); register_sound("message.send", "effects/message_send.wav"); manager = new SoundManager(undefined); audio.player.on_ready(reinitialisize_audio); return new Promise((resolve, reject) => { $.ajax({ url: "audio/speech/mapping.json", success: response => { if(typeof(response) === "string") response = JSON.parse(response); for(const entry of response) register_sound(entry.key, "speech/" + entry.file); resolve(); }, error: error => { log.error(LogCategory.AUDIO, "error: %o", error); reject(); }, timeout: 5000, async: true, type: 'GET' }); }); } export interface PlaybackOptions { ignore_muted?: boolean; ignore_overlap?: boolean; default_volume?: number; callback?: (flag: boolean) => any; } export async function resolve_sound(sound: Sound) : Promise { const file: SoundHandle = speech_mapping[sound]; if(!file) throw tr("Missing sound handle"); if(file.not_supported) { if(!file.not_supported_timeout || Date.now() < file.not_supported_timeout) //Test if the not supported flag has been expired return file; file.not_supported = false; file.not_supported_timeout = undefined; } const context = audio.player.context(); if(!context) return file; const path = "audio/" + file.filename; if(context.decodeAudioData) { if(!file.cached) { const decode_data = buffer => { try { log.info(LogCategory.AUDIO, tr("Decoding data")); context.decodeAudioData(buffer, result => { file.cached = result; }, error => { log.error(LogCategory.AUDIO, tr("Failed to decode audio data for %o: %o"), sound, error); file.not_supported = true; file.not_supported_timeout = Date.now() + 1000 * 60 * 2; //Try in 2min again! }) } catch (error) { log.error(LogCategory.AUDIO, error); file.not_supported = true; file.not_supported_timeout = Date.now() + 1000 * 60 * 2; //Try in 2min again! } }; const xhr = new XMLHttpRequest(); xhr.open('GET', path, true); xhr.responseType = 'arraybuffer'; try { const result = new Promise((resolve, reject) => { xhr.onload = resolve; xhr.onerror = reject; }); xhr.send(); await result; if (xhr.status != 200) throw "invalid response code (" + xhr.status + ")"; log.debug(LogCategory.AUDIO, tr("Decoding data")); try { file.cached = await context.decodeAudioData(xhr.response); } catch(error) { log.error(LogCategory.AUDIO, error); throw "failed to decode audio data"; } } catch(error) { log.error(LogCategory.AUDIO, tr("Failed to load audio file %s. Error: %o"), sound, error); file.not_supported = true; file.not_supported_timeout = Date.now() + 1000 * 60 * 2; //Try in 2min again! } } } else { if(!file.node) { if(!warned) { warned = true; log.warn(LogCategory.AUDIO, tr("Your browser does not support decodeAudioData! Using a node to playback! This bypasses the audio output and volume regulation!")); } const container = $("#sounds"); const node = $.spawn("audio").attr("src", path); node.appendTo(container); file.node = node[0]; } } return file; } export let manager: SoundManager; export class SoundManager { private _handle: ConnectionHandler; private _playing_sounds: {[key: string]:number} = {}; constructor(handle: ConnectionHandler) { this._handle = handle; } play(_sound: Sound, options?: PlaybackOptions) { options = options || {}; const volume = get_sound_volume(_sound, options.default_volume); log.info(LogCategory.AUDIO, tr("Replaying sound %s (Sound volume: %o | Master volume %o)"), _sound, volume, master_volume); if(volume == 0 || master_volume == 0) return; if(this._handle && !options.ignore_muted && !sound.ignore_output_muted() && this._handle.client_status.output_muted) return; const context = audio.player.context(); if(!context) { log.warn(LogCategory.AUDIO, tr("Tried to replay a sound without an audio context (Sound: %o). Dropping playback"), _sound); return; } sound.resolve_sound(_sound).then(handle => { if(!handle) return; if(!options.ignore_overlap && (this._playing_sounds[_sound] > 0) && !sound.overlap_activated()) { log.info(LogCategory.AUDIO, tr("Dropping requested playback for sound %s because it would overlap."), _sound); return; } if(handle.cached) { this._playing_sounds[_sound] = Date.now(); const player = context.createBufferSource(); player.buffer = handle.cached; player.start(0); handle.replaying = true; player.onended = event => { if(options.callback) options.callback(true); delete this._playing_sounds[_sound]; }; if(volume != 1 && context.createGain) { const gain = context.createGain(); if(gain.gain.setValueAtTime) gain.gain.setValueAtTime(volume, 0); else gain.gain.value = volume; player.connect(gain); gain.connect(master_mixed); } else { player.connect(master_mixed); } } else if(handle.node) { handle.node.currentTime = 0; handle.node.play().then(() => { if(options.callback) options.callback(true); }).catch(error => { log.warn(LogCategory.AUDIO, tr("Sound playback for sound %o resulted in an error: %o"), sound, error); if(options.callback) options.callback(false); }); } else { log.warn(LogCategory.AUDIO, tr("Failed to replay sound %o because of missing handles."), sound); if(options.callback) options.callback(false); return; } }).catch(error => { log.warn(LogCategory.AUDIO, tr("Failed to replay sound %o because it could not be resolved: %o"), sound, error); if(options.callback) options.callback(false); }); } } }