diff --git a/client-api/api.php b/client-api/api.php index bf7a7c2e..140111f2 100644 --- a/client-api/api.php +++ b/client-api/api.php @@ -7,6 +7,7 @@ */ $UI_BASE_PATH = "ui-files/"; + $UI_RAW_BASE_PATH = "ui-files/raw/"; $CLIENT_BASE_PATH = "files/"; if(!isset($_SERVER['REQUEST_METHOD'])) { @@ -46,10 +47,7 @@ include $name; return; } - $file = fopen($name, "r") or die(json_encode([ - "success" => false, - "error" => "missing file (" . $name . ")" - ])); + $file = fopen($name, "r") or error_exit("missing file \"" . $name . "\"."); echo (fread($file, filesize($name))); fclose($file); @@ -64,7 +62,7 @@ } function handle_develop_web_request() { - global $UI_BASE_PATH; + global $UI_RAW_BASE_PATH; if(isset($_GET) && isset($_GET["type"])) { if($_GET["type"] === "files") { @@ -73,7 +71,7 @@ /* header("mode: develop"); */ echo ("type\thash\tpath\tname\n"); - foreach (list_dir($UI_BASE_PATH) as $file) { + foreach (list_dir($UI_RAW_BASE_PATH) as $file) { $type_idx = strrpos($file, "."); $type = substr($file, $type_idx + 1); if($type == "php") $type = "html"; @@ -85,13 +83,13 @@ $name_idx = strrpos($name, "."); $name = substr($name, 0, $name_idx); - echo $type . "\t" . sha1_file($UI_BASE_PATH . $file) . "\t" . $path . "\t" . $name . "." . $type . "\n"; + echo $type . "\t" . sha1_file($UI_RAW_BASE_PATH . $file) . "\t" . $path . "\t" . $name . "." . $type . "\n"; } die; } else if($_GET["type"] === "file") { header("Content-Type: text/plain"); - $path = realpath($UI_BASE_PATH . $_GET["path"]); + $path = realpath($UI_RAW_BASE_PATH . $_GET["path"]); $name = $_GET["name"]; if($path === False || strpos($path, realpath(".")) === False || strpos($name, "/") !== False) error_exit("Invalid file"); @@ -132,10 +130,64 @@ header("Content-Transfer-Encoding: Binary"); header("Content-Length:".filesize($path . $platform->update)); header("Content-Disposition: attachment; filename=update.tar.gz"); + header("info-version: 1"); readfile($path . $platform->update); die(); } error_exit("Missing platform, arch or file"); + } else if ($_GET["type"] == "ui-info") { + global $UI_BASE_PATH; + + $version_info = file_get_contents($UI_BASE_PATH . "info.json"); + if($version_info === false) $version_info = array(); + else $version_info = json_decode($version_info, true); + + $info = array(); + $info["success"] = true; + $info["versions"] = array(); + + foreach($version_info as $channel => $data) { + if(!isset($data["latest"])) continue; + + $channel_info = [ + "timestamp" => $data["latest"]["timestamp"], + "version" => $data["latest"]["version"], + "git-ref" => $data["latest"]["git-ref"], + "channel" => $channel + ]; + array_push($info["versions"], $channel_info); + } + + die(json_encode($info)); + } else if ($_GET["type"] == "ui-download") { + global $UI_BASE_PATH; + + if(!isset($_GET["git-ref"]) || !isset($_GET["channel"]) || !isset($_GET["version"])) + error_exit("missing required parameters"); + + $version_info = file_get_contents($UI_BASE_PATH . "info.json"); + if($version_info === false) $version_info = array(); + else $version_info = json_decode($version_info, true); + + if(!isset($version_info[$_GET["channel"]])) + error_exit("missing channel"); + + foreach ($version_info[$_GET["channel"]]["history"] as $entry) { + if($entry["version"] == $_GET["version"] && $entry["git-ref"] == $_GET["git-ref"]) { + header("Cache-Control: public"); // needed for internet explorer + header("Content-Type: application/binary"); + header("Content-Transfer-Encoding: Binary"); + header("Content-Disposition: attachment; filename=ui.tar.gz"); + header("info-version: 1"); + $read = readfile($entry["file"]); + header("Content-Length:" . $read); + + if($read === false) error_exit("internal error: Failed to read file!"); + die(); + } + } + + error_exit("missing version"); } } else if($_POST["type"] == "deploy-build") { global $CLIENT_BASE_PATH; @@ -158,7 +210,7 @@ if($_FILES["installer"]["error"] !== UPLOAD_ERR_OK) error_exit("Upload for installer failed!"); $json_version = json_decode($_POST["version"], true); - $version = $json_version["major"] . "." . $json_version["minor"] . "." . $json_version["patch"] . ($json_version["build"] > 0 ? $json_version["build"] : ""); + $version = $json_version["major"] . "." . $json_version["minor"] . "." . $json_version["patch"] . ($json_version["build"] > 0 ? "-" . $json_version["build"] : ""); $path = $CLIENT_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"] . DIRECTORY_SEPARATOR . $version . DIRECTORY_SEPARATOR; exec("mkdir -p " . $path); //mkdir($path, 777, true); @@ -216,6 +268,55 @@ move_uploaded_file($_FILES["installer"]["tmp_name"],$path . $filename_install); move_uploaded_file($_FILES["update"]["tmp_name"],$path . $filename_update); + die(json_encode([ + "success" => true + ])); + } else if($_POST["type"] == "deploy-ui-build") { + global $UI_BASE_PATH; + + if(!isset($_POST["secret"]) || !isset($_POST["channel"]) || !isset($_POST["version"]) || !isset($_POST["git_ref"])) + error_exit("Missing required information!"); + + $path = $UI_BASE_PATH . DIRECTORY_SEPARATOR; + $channeled_path = $UI_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"]; + $filename = "TeaClientUI-" . $_POST["version"] . "_" . $_POST["git_ref"] . ".tar.gz"; + exec("mkdir -p " . $path); + exec("mkdir -p " . $channeled_path); + + { + $require_secret = file_get_contents(".deploy_secret"); + if($require_secret === false || strlen($require_secret) == 0) error_exit("Server missing secret!"); + + error_log($_POST["secret"]); + error_log(trim($require_secret)); + if(!is_string($_POST["secret"])) error_exit("Invalid secret!"); + if(strcmp(trim($require_secret), trim($_POST["secret"])) !== 0) + error_exit("Secret does not match!"); + } + { + $info = file_get_contents($path . "info.json"); + if($info === false) $info = array(); + else $info = json_decode($info, true); + + $channel_info = &$info[$_POST["channel"]]; + if(!$channel_info) $channel_info = array(); + + $entry = [ + "timestamp" => time(), + "file" => $channeled_path . DIRECTORY_SEPARATOR . $filename, + "version" => $_POST["version"], + "git-ref" => $_POST["git_ref"] + ]; + + $channel_info["latest"] = $entry; + if(!$channel_info["history"]) $channel_info["history"] = array(); + array_push($channel_info["history"], $entry); + + file_put_contents($path . "info.json", json_encode($info)); + } + + + move_uploaded_file($_FILES["file"]["tmp_name"],$channeled_path . DIRECTORY_SEPARATOR . $filename); die(json_encode([ "success" => true ])); diff --git a/client/app-definitions/index.d.ts b/client/app-definitions/index.d.ts deleted file mode 100644 index 7087fd6e..00000000 --- a/client/app-definitions/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* native functions declaration */ - -import * as updater from "updater/updater"; -declare function displayCriticalError(message: string); \ No newline at end of file diff --git a/client/app-definitions/native_api.d.ts b/client/app-definitions/native_api.d.ts new file mode 100644 index 00000000..059d6c30 --- /dev/null +++ b/client/app-definitions/native_api.d.ts @@ -0,0 +1,26 @@ +declare namespace native { + function client_version(): Promise; +} +declare namespace forum { + interface UserData { + session_id: string; + username: string; + application_data: string; + application_data_sign: string; + } +} +declare namespace audio.player { + interface Device { + device_id: string; + name: string; + } + function initialized(): boolean; + function context(): AudioContext; + function destination(): AudioNode; + function on_ready(cb: () => any): void; + function initialize(): boolean; + function available_devices(): Promise; + function set_device(device_id?: string): Promise; + function current_device(device_id?: string): Device; +} +declare function getUserMediaFunction(): (settings: any, success: any, fail: any) => void; diff --git a/client/app-definitions/teaforo/manager.d.ts b/client/app-definitions/teaforo/manager.d.ts deleted file mode 100644 index fd1a8d9f..00000000 --- a/client/app-definitions/teaforo/manager.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface UserData { - session_id: string; - username: string; - application_data: string; - application_data_sign: string; -} -export declare function open_login(enforce?: boolean): Promise; -export declare function current_data(): UserData | undefined; -export declare function logout(): void; diff --git a/client/js/teaforo.ts b/client/js/teaforo.ts index 295a560a..449f10b5 100644 --- a/client/js/teaforo.ts +++ b/client/js/teaforo.ts @@ -1,9 +1,9 @@ -import {UserData} from "../app-definitions/teaforo/manager"; +/// const ipc = require("electron").ipcRenderer; let callback_listener: (() => any)[] = []; -ipc.on('teaforo-update', (event, data: UserData) => { +ipc.on('teaforo-update', (event, data: forum.UserData) => { console.log("Got data update: %o", data); forumIdentity = data ? new TeaForumIdentity(data.application_data, data.application_data_sign) : undefined; try { diff --git a/files.php b/files.php index 7d7bd60e..9598d58e 100644 --- a/files.php +++ b/files.php @@ -97,6 +97,15 @@ ], /* web specs */ + [ + "web-only" => true, + "type" => "js", + "search-pattern" => "/.*\.js$/", + "build-target" => "dev", + + "path" => "js/", + "local-path" => "./web/js/" + ], [ "web-only" => true, "type" => "css", @@ -250,7 +259,7 @@ $environment = "web/dev-environment"; } else if ($_SERVER["argv"][2] == "client") { $flagset = 0b10; - $environment = "client-api/environment/ui-files"; + $environment = "client-api/environment/ui-files/raw"; } else { error_log("Invalid type!"); goto help; @@ -262,7 +271,7 @@ $environment = "web/rel-environment"; } else if ($_SERVER["argv"][2] == "client") { $flagset = 0b10; - $environment = "client-api/environment/ui-files"; + $environment = "client-api/environment/ui-files/raw"; } else { error_log("Invalid type!"); goto help; @@ -318,7 +327,7 @@ if(!is_dir("versions/stable")) exec($command = "mkdir -p versions/beta", $output, $state); if($state) goto handle_error; - exec($command = "ln -s ../api.php ./", $output, $state); + exec($command = "ln -s ../api.php ./", $output, $state); $state = 0; //Dont handle an error here! if($state) goto handle_error; } diff --git a/index.php b/index.php index 5c021e41..044e694e 100644 --- a/index.php +++ b/index.php @@ -50,6 +50,7 @@ + diff --git a/package.json b/package.json index 2eb108e6..fd67ad3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "1.0.0", + "version": "1.0.1", "description": "Welcome here! This repository is created with two reasons:\n 1. People can bring their own ideas and follow their implementation\n 2. People can see TeaSpeak Web client progress and avoid creating repetitive issues all the time.", "main": "main.js", "directories": {}, diff --git a/scripts/deploy_ui_files.sh b/scripts/deploy_ui_files.sh new file mode 100755 index 00000000..b4befecb --- /dev/null +++ b/scripts/deploy_ui_files.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +TMP_FILE_NAME="TeaSpeakUI.tar.gz" +TMP_DIR_NAME="tmp" + +BASEDIR=$(dirname "$0") +cd "$BASEDIR/../" + +if [ "$#" -ne 2 ]; then + echo "Illegal number of parameters" + exit 1 +fi + +if [ ! -d client-api/environment/ui-files/ ]; then + echo "Missing UI Files" + exit 1 +fi + +if [ "${teaclient_deploy_secret}" == "" ]; then + echo "Missing deploy secret!" + exit 1 +fi + +if [ -e "${TMP_FILE_NAME}" ]; then + echo "Temp file already exists!" + echo "Deleting it!" + rm ${TMP_FILE_NAME} + if [ $? -ne 0 ]; then + echo "Failed to delete file" + exit 1 + fi +fi + +GIT_HASH=$(git rev-parse --verify --short HEAD) +APPLICATION_VERSION=$(cat package.json | python -c "import sys, json; print(json.load(sys.stdin)['version'])") +echo "Git hash ${GIT_HASH} on version ${APPLICATION_VERSION} on channel $2" + +#Packaging the app +cd client-api/environment/ui-files/ +if [ -e ${TMP_DIR_NAME} ]; then + rm -r ${TMP_DIR_NAME} + if [ $? -ne 0 ]; then + echo "Failed to remove temporary directory!" + exit 1 + fi +fi +cp -rL raw ${TMP_DIR_NAME} + +for file in $(find ${TMP_DIR_NAME} -name '*.php'); do + echo "Evaluating php file $file" + RESULT=$(php "${file}" 2> /dev/null) + CODE=$? + if [ ${CODE} -ne 0 ]; then + echo "Failed to evaluate php file $file!" + echo "Return code $CODE" + exit 1 + fi + + echo "${RESULT}" > "${file::-4}.html" +done + +cd ${TMP_DIR_NAME} +tar chvzf ${TMP_FILE_NAME} * +if [ $? -ne 0 ]; then + echo "Failed to pack file" + exit 1 +fi +mv ${TMP_FILE_NAME} ../../../../ +cd ../ +rm -r ${TMP_DIR_NAME} +cd ../../../ + +RESP=$(curl \ + -k \ + -X POST \ + -F "type=deploy-ui-build" \ + -F "channel=$2" \ + -F "version=$APPLICATION_VERSION" \ + -F "git_ref=$GIT_HASH" \ + -F "secret=${teaclient_deploy_secret}" \ + -F "file=@`pwd`/TeaSpeakUI.tar.gz" \ + $1 +) +echo "$RESP" +SUCCESS=$(echo ${RESP} | python -c "import sys, json; print(json.load(sys.stdin)['success'])") + +if [ ! "${SUCCESS}" == "True" ]; then + ERROR=$(echo ${RESP} | python -c "import sys, json; print(json.load(sys.stdin)['error'])" 2>/dev/null) + if [ $? -ne 0 ]; then + ERROR=$(echo ${RESP} | python -c "import sys, json; print(json.load(sys.stdin)['msg'])" 2>/dev/null) + fi + echo "Failed to deploy build!" + echo "${ERROR}" + + rm ${TMP_FILE_NAME} + exit 1 +fi + +echo "Build deployed!" \ No newline at end of file diff --git a/shared/css/modal-settings.scss b/shared/css/modal-settings.scss new file mode 100644 index 00000000..f4b1df9b --- /dev/null +++ b/shared/css/modal-settings.scss @@ -0,0 +1,100 @@ +.modal .settings_audio { + display: flex; + flex-direction: column; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + margin: 3px; + > div { + margin: 2px; + } + + a { + align-self: center; + } + + .group_box { + display: flex; + flex-direction: column; + } + + .settings-device { + display: flex; + flex-direction: column; + width: 100%; + + a { + flex-grow: 0; + } + + .settings-device-error { + display: none; + + width: 100%; + margin-bottom: 3px; + padding: 2px; + + align-self: center; + text-align: center; + + vertical-align: center; + border: darkred 2px solid; + border-radius: 4px; + background: #be00006b; + } + + .settings-device-select { + display: flex; + flex-direction: row; + justify-content: stretch; + + > div { + flex-grow: 1; + flex-shrink: 1; + } + + select { + flex-grow: 1; + margin-left: 5px; + width: 100%; + } + } + } + + .settings-vad-container { + display: flex; + flex-direction: row; + margin-top: 5px; + + > div { + width: 50%; + } + + fieldset { + input { + vertical-align: text-bottom; + } + } + + .settings-vad { + display: flex; + flex-direction: column; + } + + .settings-vad-impl { + display: flex; + justify-content: space-around; + padding: 5px; + + > div { + align-self: center; + } + + .settings-vad-impl-entry { + display: none; + } + } + } +} \ No newline at end of file diff --git a/shared/external/defaults.d.ts b/shared/external/defaults.d.ts new file mode 100644 index 00000000..f0130c30 --- /dev/null +++ b/shared/external/defaults.d.ts @@ -0,0 +1,5 @@ +interface Window { + displayCriticalError: typeof displayCriticalError; +} + +declare function displayCriticalError(message: string); \ No newline at end of file diff --git a/shared/js/codec/BasicCodec.ts b/shared/js/codec/BasicCodec.ts index cb00e45b..e6b806c9 100644 --- a/shared/js/codec/BasicCodec.ts +++ b/shared/js/codec/BasicCodec.ts @@ -29,12 +29,12 @@ abstract class BasicCodec implements Codec { channelCount: number = 1; samplesPerUnit: number = 960; - constructor(codecSampleRate: number) { + protected constructor(codecSampleRate: number) { this.channelCount = 1; this.samplesPerUnit = 960; - this._audioContext = new (window.webkitOfflineAudioContext || window.OfflineAudioContext)(AudioController.globalContext.destination.channelCount, 1024,AudioController.globalContext.sampleRate ); + this._audioContext = new (window.webkitOfflineAudioContext || window.OfflineAudioContext)(audio.player.destination().channelCount, 1024, audio.player.context().sampleRate); this._codecSampleRate = codecSampleRate; - this._decodeResampler = new AudioResampler(AudioController.globalContext.sampleRate); + this._decodeResampler = new AudioResampler(audio.player.context().sampleRate); this._encodeResampler = new AudioResampler(codecSampleRate); } diff --git a/shared/js/codec/CodecWrapper.ts b/shared/js/codec/CodecWrapper.ts index a1824e7c..8df7a1d5 100644 --- a/shared/js/codec/CodecWrapper.ts +++ b/shared/js/codec/CodecWrapper.ts @@ -34,7 +34,6 @@ class CodecWrapper extends BasicCodec { this._workerListener.push({ token: token, resolve: data => { - console.log("Init result: %o", data); this._initialized = data["success"] == true; if(data["success"] == true) resolve(); @@ -144,7 +143,7 @@ class CodecWrapper extends BasicCodec { private sendWorkerMessage(message: any, transfare?: any[]) { message["timestamp"] = Date.now(); - this._worker.postMessage(message, transfare); + this._worker.postMessage(message, transfare as any); } private onWorkerMessage(message: any) { diff --git a/shared/js/connection.ts b/shared/js/connection.ts index 6799aeb1..5b8b6b2b 100644 --- a/shared/js/connection.ts +++ b/shared/js/connection.ts @@ -329,11 +329,17 @@ class HandshakeHandler { }).then(() => this.handshake_finished()); //TODO handle error } - private async handshake_finished(version?: string) { - if(window.require && !version) { - version = "?.?.?"; //FIXME findout version! + private handshake_finished(version?: string) { + if(native_client && window["native"] && native.client_version && !version) { + native.client_version() + .then( this.handshake_finished.bind(this)) + .catch(error => { + console.error("Failed to get version:"); + console.error(error); + this.handshake_finished("?.?.?"); + }); + return; } - let data = { //TODO variables! client_nickname: this.name ? this.name : this.identity.name(), @@ -344,7 +350,7 @@ class HandshakeHandler { client_browser_engine: navigator.product }; - if(window.require) { + if(version) { data.client_version = "TeaClient "; data.client_version += " " + version; diff --git a/shared/js/load.ts b/shared/js/load.ts index 4bc4b3c3..5c11c826 100644 --- a/shared/js/load.ts +++ b/shared/js/load.ts @@ -52,21 +52,21 @@ namespace app { } } -function loadScripts(paths: (string | string[])[]) : {path: string, promise: Promise}[] { +function load_scripts(paths: (string | string[])[]) : {path: string, promise: Promise}[] { let result = []; for(let path of paths) - result.push({path: path, promise: loadScript(path)}); + result.push({path: path, promise: load_script(path)}); return result; } -function loadScript(path: string | string[]) : Promise { +function load_script(path: string | string[]) : Promise { if(Array.isArray(path)) { //Having fallbacks return new Promise((resolve, reject) => { - loadScript(path[0]).then(resolve).catch(error => { + load_script(path[0]).then(resolve).catch(error => { if(path.length >= 2) { - loadScript(path.slice(1)).then(resolve).catch(() => reject("could not load file " + formatPath(path))); + load_script(path.slice(1)).then(resolve).catch(() => reject("could not load file " + formatPath(path))); } else { - reject("could not load file (event fallback's)"); + reject("could not load file"); } }); }); @@ -99,7 +99,7 @@ function formatPath(path: string | string[]) { function loadRelease() { app.type = app.Type.RELEASE; console.log("Load for release!"); - awaitLoad(loadScripts([ + awaitLoad(load_scripts([ //Load general API's ["wasm/TeaWeb-Identity.js"], ["js/client.min.js", "js/client.js"] @@ -110,11 +110,19 @@ function loadRelease() { console.error("Could not load " + error.path); }); } + /** Only possible for developers! **/ function loadDebug() { app.type = app.Type.DEBUG; console.log("Load for debug!"); + let custom_scripts: string[] | string[][] = []; + + if(!window.require) { + console.log("Adding browser audio player"); + custom_scripts.push(["js/audio/AudioPlayer.js"]); + } + load_wait_scripts([ ["wasm/TeaWeb-Identity.js"], @@ -171,7 +179,9 @@ function loadDebug() { "js/FileManager.js", "js/client.js", "js/chat.js", - "js/Identity.js" + "js/Identity.js", + + ...custom_scripts ]).then(() => load_wait_scripts([ "js/codec/CodecWrapper.js" ])).then(() => load_wait_scripts([ @@ -208,7 +218,7 @@ function awaitLoad(promises: {path: string, promise: Promise}[]) : Prom } function load_wait_scripts(paths: (string | string[])[]) : Promise { - return awaitLoad(loadScripts(paths)); + return awaitLoad(load_scripts(paths)); } @@ -293,7 +303,7 @@ function loadSide() { ["https://webrtc.github.io/adapter/adapter-latest.js"] ])).then(() => { //Load the teaweb scripts - loadScript("js/proto.js").then(loadDebug).catch(loadRelease); + load_script("js/proto.js").then(loadDebug).catch(loadRelease); //Load the teaweb templates loadTemplates(); }); @@ -345,7 +355,7 @@ if(typeof Module === "undefined") app.initialize(); app.loadedListener.push(fadeoutLoader); -if(!window.displayCriticalError) { +if(!window.displayCriticalError) { /* Declare this function here only because its required before load */ window.displayCriticalError = function(message: string) { if(typeof(createErrorModal) !== 'undefined') { createErrorModal("A critical error occurred while loading the page!", message, {closeable: false}).open(); diff --git a/shared/js/main.ts b/shared/js/main.ts index 731e3dc8..11a3eb49 100644 --- a/shared/js/main.ts +++ b/shared/js/main.ts @@ -138,7 +138,7 @@ function main() { $("#music-test").replaceWith(tag); - //Modals.spawnSettingsModal(); + Modals.spawnSettingsModal(); /* Modals.spawnYesNo("Are your sure?", "Do you really want to exit?", flag => { console.log("Response: " + flag); @@ -154,13 +154,17 @@ function main() { app.loadedListener.push(() => { try { main(); - if(!AudioController.initialized()) { + if(!audio.player.initialized()) { log.info(LogCategory.VOICE, "Initialize audio controller later!"); - $(document).one('click', event => AudioController.initializeFromGesture()); + if(!audio.player.initializeFromGesture) { + console.error("Missing audio.player.initializeFromGesture"); + } else + $(document).one('click', event => audio.player.initializeFromGesture()); } } catch (ex) { + console.error(ex.stack); if(ex instanceof ReferenceError || ex instanceof TypeError) - ex = ex.message + ":
" + ex.stack; + ex = ex.name + ": " + ex.message; displayCriticalError("Failed to invoke main function:
" + ex); } }); diff --git a/shared/js/ui/modal/ModalSettings.ts b/shared/js/ui/modal/ModalSettings.ts index ccbabd85..a1c423f7 100644 --- a/shared/js/ui/modal/ModalSettings.ts +++ b/shared/js/ui/modal/ModalSettings.ts @@ -4,6 +4,8 @@ /// namespace Modals { + import set = Reflect.set; + export function spawnSettingsModal() { let modal; modal = createModal({ @@ -35,87 +37,190 @@ namespace Modals { function initialiseSettingListeners(modal: Modal, tag: JQuery) { //Voice - initialiseVoiceListeners(modal, tag.find(".settings_voice")); + initialiseVoiceListeners(modal, tag.find(".settings_audio")); } function initialiseVoiceListeners(modal: Modal, tag: JQuery) { - let currentVAD = settings.global("vad_type"); + let currentVAD = settings.global("vad_type", "ppt"); - tag.find("input[type=radio][name=\"vad_type\"]").change(function (this: HTMLButtonElement) { - tag.find(".vad_settings .vad_type").text($(this).attr("display")); - tag.find(".vad_settings .vad_type_settings").hide(); - tag.find(".vad_settings .vad_type_" + this.value).show(); - settings.changeGlobal("vad_type", this.value); - globalClient.voiceConnection.voiceRecorder.reinitialiseVAD(); + { //Initialized voice activation detection + const vad_tag = tag.find(".settings-vad-container"); - switch (this.value) { - case "ppt": - let keyCode: number = parseInt(settings.global("vad_ppt_key", JQuery.Key.T.toString())); - tag.find(".vat_ppt_key").text(String.fromCharCode(keyCode)); - break; - case "vad": - let slider = tag.find(".vad_vad_slider"); - let vad: VoiceActivityDetectorVAD = globalClient.voiceConnection.voiceRecorder.getVADHandler() as VoiceActivityDetectorVAD; - slider.val(vad.percentageThreshold); - slider.trigger("change"); - globalClient.voiceConnection.voiceRecorder.update(true); - vad.percentage_listener = per => { - tag.find(".vad_vad_bar_filler") - .css("width", per + "%"); - }; - break; + vad_tag.find('input[type=radio]').on('change', event => { + const select = event.currentTarget as HTMLSelectElement; + { + vad_tag.find(".settings-vad-impl-entry").hide(); + vad_tag.find(".setting-vad-" + select.value).show(); + } + { + settings.changeGlobal("vad_type", select.value); + globalClient.voiceConnection.voiceRecorder.reinitialiseVAD(); + } + + switch (select.value) { + case "ppt": + let keyCode: number = parseInt(settings.global("vad_ppt_key", JQuery.Key.T.toString())); + vad_tag.find(".vat_ppt_key").text(String.fromCharCode(keyCode)); + break; + case "vad": + let slider = vad_tag.find(".vad_vad_slider"); + let vad: VoiceActivityDetectorVAD = globalClient.voiceConnection.voiceRecorder.getVADHandler() as VoiceActivityDetectorVAD; + slider.val(vad.percentageThreshold); + slider.trigger("change"); + globalClient.voiceConnection.voiceRecorder.update(true); + vad.percentage_listener = per => { + vad_tag.find(".vad_vad_bar_filler") + .css("width", per + "%"); + }; + break; + } + }); + + { //Initialized push to talk + vad_tag.find(".vat_ppt_key").click(function () { + let modal = createModal({ + body: "", + header: () => { + let head = $.spawn("div"); + head.text("Type the key you wish"); + head.css("background-color", "blue"); + return head; + }, + footer: "" + }); + $(document).one("keypress", function (e) { + console.log("Got key " + e.keyCode); + modal.close(); + settings.changeGlobal("vad_ppt_key", e.keyCode.toString()); + globalClient.voiceConnection.voiceRecorder.reinitialiseVAD(); + vad_tag.find(".vat_ppt_key").text(String.fromCharCode(e.keyCode)); + }); + modal.open(); + }); } - }); - if(!currentVAD) - currentVAD = "ppt"; - let elm = tag.find("input[type=radio][name=\"vad_type\"][value=\"" + currentVAD + "\"]"); - elm.attr("checked", "true"); + { //Initialized voice activation detection + let slider = vad_tag.find(".vad_vad_slider"); + slider.on("input change", () => { + settings.changeGlobal("vad_threshold", slider.val().toString()); + let vad = globalClient.voiceConnection.voiceRecorder.getVADHandler(); + if(vad instanceof VoiceActivityDetectorVAD) + vad.percentageThreshold = slider.val() as number; + vad_tag.find(".vad_vad_slider_value").text(slider.val().toString()); + }); + modal.properties.registerCloseListener(() => { + let vad = globalClient.voiceConnection.voiceRecorder.getVADHandler(); + if(vad instanceof VoiceActivityDetectorVAD) + vad.percentage_listener = undefined; + }); + } - tag.find(".vat_ppt_key").click(function () { - let modal = createModal({ - body: "", - header: () => { - let head = $.spawn("div"); - head.text("Type the key you wish"); - head.css("background-color", "blue"); - return head; - }, - footer: "" + let target_tag = vad_tag.find('input[type=radio][name="vad_type"][value="' + currentVAD + '"]'); + if(target_tag.length == 0) { + console.warn("Failed to find tag for " + currentVAD + ". Using latest tag!"); + target_tag = vad_tag.find('input[type=radio][name="vad_type"]').last(); + } + target_tag.prop("checked", true); + setTimeout(() => target_tag.trigger('change'), 0); + } + + { //Initialize microphone + + const setting_tag = tag.find(".settings-microphone"); + const tag_select = setting_tag.find(".audio-select-microphone"); + console.log(setting_tag); + console.log(setting_tag.find(".settings-device-error")); + console.log(setting_tag.find(".settings-device-error").html()); + + { //List devices + $.spawn("option") + .attr("device-id", "") + .attr("device-group", "") + .text("No device") + .appendTo(tag_select); + + navigator.mediaDevices.enumerateDevices().then(devices => { + const active_device = globalClient.voiceConnection.voiceRecorder.device_id(); + + for(const device of devices) { + console.debug("Got device %s (%s): %s", device.deviceId, device.kind, device.label); + if(device.kind !== 'audioinput') continue; + + $.spawn("option") + .attr("device-id", device.deviceId) + .attr("device-group", device.groupId) + .text(device.label) + .prop("selected", device.deviceId == active_device) + .appendTo(tag_select); + } + }).catch(error => { + console.error("Could not enumerate over devices!"); + console.error(error); + setting_tag.find(".settings-device-error") + .text("Could not get device list!") + .css("display", "block"); + }); + + if(tag_select.find("option:selected").length == 0) + tag_select.find("option").prop("selected", true); + + } + + { + tag_select.on('change', event => { + let selected_tag = tag_select.find("option:selected"); + let deviceId = selected_tag.attr("device-id"); + let groupId = selected_tag.attr("device-group"); + console.log("Selected microphone device: id: %o group: %o", deviceId, groupId); + globalClient.voiceConnection.voiceRecorder.change_device(deviceId, groupId); + }); + } + } + + { //Initialize speaker + const setting_tag = tag.find(".settings-speaker"); + const tag_select = setting_tag.find(".audio-select-speaker"); + const active_device = audio.player.current_device(); + + audio.player.available_devices().then(devices => { + for(const device of devices) { + $.spawn("option") + .attr("device-id", device.device_id) + .text(device.name) + .prop("selected", device.device_id == active_device.device_id) + .appendTo(tag_select); + } + }).catch(error => { + console.error("Could not enumerate over devices!"); + console.error(error); + setting_tag.find(".settings-device-error") + .text("Could not get device list!") + .css("display", "block"); }); - $(document).one("keypress", function (e) { - console.log("Got key " + e.keyCode); - modal.close(); - settings.changeGlobal("vad_ppt_key", e.keyCode.toString()); - globalClient.voiceConnection.voiceRecorder.reinitialiseVAD(); - tag.find(".vat_ppt_key").text(String.fromCharCode(e.keyCode)); - }); - modal.open(); - }); - //VAD VAD - let slider = tag.find(".vad_vad_slider"); - slider.on("input change", () => { - settings.changeGlobal("vad_threshold", slider.val().toString()); - let vad = globalClient.voiceConnection.voiceRecorder.getVADHandler(); - if(vad instanceof VoiceActivityDetectorVAD) - vad.percentageThreshold = slider.val() as number; - tag.find(".vad_vad_slider_value").text(slider.val().toString()); - }); - modal.properties.registerCloseListener(() => { - let vad = globalClient.voiceConnection.voiceRecorder.getVADHandler(); - if(vad instanceof VoiceActivityDetectorVAD) - vad.percentage_listener = undefined; + if(tag_select.find("option:selected").length == 0) + tag_select.find("option").prop("selected", true); - }); - - - //Trigger radio button select for VAD setting setup - elm.trigger("change"); + { + const error_tag = setting_tag.find(".settings-device-error"); + tag_select.on('change', event => { + let selected_tag = tag_select.find("option:selected"); + let deviceId = selected_tag.attr("device-id"); + console.log("Selected speaker device: id: %o", deviceId); + audio.player.set_device(deviceId).then(() => error_tag.css("display", "none")).catch(error => { + console.error(error); + error_tag + .text("Failed to change device!") + .css("display", "block"); + }); + }); + } + } //Initialise microphones + /* let select_microphone = tag.find(".voice_microphone_select"); let select_error = tag.find(".voice_microphone_select_error"); @@ -148,6 +253,7 @@ namespace Modals { console.log("Selected microphone device: id: %o group: %o", deviceId, groupId); globalClient.voiceConnection.voiceRecorder.change_device(deviceId, groupId); }); + */ //Initialise speakers } diff --git a/shared/js/voice/AudioController.ts b/shared/js/voice/AudioController.ts index 8dddfe24..26f04b30 100644 --- a/shared/js/voice/AudioController.ts +++ b/shared/js/voice/AudioController.ts @@ -1,3 +1,5 @@ +/// + enum PlayerState { PREBUFFERING, PLAYING, @@ -6,73 +8,15 @@ enum PlayerState { STOPPED } -interface Navigator { - mozGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void; - webkitGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void; -} - class AudioController { - private static getUserMediaFunction() { - if(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) - return (settings, success, fail) => { navigator.mediaDevices.getUserMedia(settings).then(success).catch(fail); }; - return navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; - } - public static userMedia = AudioController.getUserMediaFunction(); - private static _globalContext: AudioContext; - private static _globalContextPromise: Promise; private static _audioInstances: AudioController[] = []; - private static _initialized_listener: (() => any)[] = []; private static _globalReplayScheduler: NodeJS.Timer; private static _timeIndex: number = 0; private static _audioDestinationStream: MediaStream; - static get globalContext() : AudioContext { - if(this._globalContext && this._globalContext.state != "suspended") return this._globalContext; - - if(!this._globalContext) - this._globalContext = new (window.webkitAudioContext || window.AudioContext)(); - if(this._globalContext.state == "suspended") { - if(!this._globalContextPromise) { - (this._globalContextPromise = this._globalContext.resume()).then(() => { - this.fire_initialized(); - }).catch(error => { - displayCriticalError("Failed to initialize global audio context! (" + error + ")"); - }); - } - this._globalContext.resume(); //We already have our listener - return undefined; - } - - if(this._globalContext.state == "running") { - this.fire_initialized(); - return this._globalContext; - } - return undefined; - } - - - private static fire_initialized() { - while(this._initialized_listener.length > 0) - this._initialized_listener.pop_front()(); - } - - static initialized() : boolean { - return (this.globalContext || {state: ""}).state === "running"; - } - - static on_initialized(callback: () => any) { - if(this.globalContext) - callback(); - else - this._initialized_listener.push(callback); - } - - static initializeFromGesture() { - AudioController.globalContext; - } - static initializeAudioController() { - AudioController.globalContext; //Just test here + if(!audio.player.initialize()) + console.warn("Failed to initialize audio controller!"); //this._globalReplayScheduler = setInterval(() => { AudioController.invokeNextReplay(); }, 20); //Fix me } @@ -142,7 +86,7 @@ class AudioController { onSilence: () => void; constructor() { - AudioController.on_initialized(() => this.speakerContext = AudioController.globalContext); + audio.player.on_ready(() => this.speakerContext = audio.player.context()); this.onSpeaking = function () { }; this.onSilence = function () { }; @@ -204,6 +148,10 @@ class AudioController { private playQueue() { let buffer: AudioBuffer; while(buffer = this.audioCache.pop_front()) { + if(this.playingAudioCache.length >= this._latencyBufferLength * 1.5 + 3) { + console.log("Dropping buffer because playing queue grows to much"); + continue; /* drop the data (we're behind) */ + } if(this._timeIndex < this.speakerContext.currentTime) this._timeIndex = this.speakerContext.currentTime; let player = this.speakerContext.createBufferSource(); @@ -212,7 +160,7 @@ class AudioController { player.onended = () => this.removeNode(player); this.playingAudioCache.push(player); - player.connect(AudioController.globalContext.destination); + player.connect(audio.player.destination()); player.start(this._timeIndex); this._timeIndex += buffer.duration; } @@ -274,4 +222,10 @@ class AudioController { this._codecCache.push(new CodecClientCache()); return this._codecCache[codec]; } +} + +function getUserMediaFunction() { + if(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) + return (settings, success, fail) => { navigator.mediaDevices.getUserMedia(settings).then(success).catch(fail); }; + return navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; } \ No newline at end of file diff --git a/shared/js/voice/AudioResampler.ts b/shared/js/voice/AudioResampler.ts index 09f45598..63445c8d 100644 --- a/shared/js/voice/AudioResampler.ts +++ b/shared/js/voice/AudioResampler.ts @@ -2,7 +2,7 @@ class AudioResampler { targetSampleRate: number; private _use_promise: boolean; - constructor(targetSampleRate: number = 44100){ + constructor(targetSampleRate: number){ this.targetSampleRate = targetSampleRate; if(this.targetSampleRate < 3000 || this.targetSampleRate > 384000) throw "The target sample rate is outside the range [3000, 384000]."; } @@ -10,11 +10,11 @@ class AudioResampler { resample(buffer: AudioBuffer) : Promise { if(!buffer) { console.warn("Received empty buffer as input! Returning empty output!"); - return new Promise(resolve => resolve(undefined)); + return Promise.resolve(buffer); } //console.log("Encode from %i to %i", buffer.sampleRate, this.targetSampleRate); if(buffer.sampleRate == this.targetSampleRate) - return new Promise(resolve => resolve(buffer)); + return Promise.resolve(buffer); let context; context = new (window.webkitOfflineAudioContext || window.OfflineAudioContext)(buffer.numberOfChannels, Math.ceil(buffer.length * this.targetSampleRate / buffer.sampleRate), this.targetSampleRate); diff --git a/shared/js/voice/VoiceHandler.ts b/shared/js/voice/VoiceHandler.ts index 405fc523..330ee8f5 100644 --- a/shared/js/voice/VoiceHandler.ts +++ b/shared/js/voice/VoiceHandler.ts @@ -146,7 +146,7 @@ class VoiceConnection { this.voiceRecorder.on_start = this.handleVoiceStarted.bind(this); this.voiceRecorder.reinitialiseVAD(); - AudioController.on_initialized(() => { + audio.player.on_ready(() => { log.info(LogCategory.VOICE, "Initializing voice handler after AudioController has been initialized!"); this.codec_pool[4].initialize(2); this.codec_pool[5].initialize(2); @@ -193,7 +193,7 @@ class VoiceConnection { stream.disconnect(); if(!this.local_audio_stream) - this.local_audio_stream = AudioController.globalContext.createMediaStreamDestination(); + this.local_audio_stream = audio.player.context().createMediaStreamDestination(); stream.connect(this.local_audio_stream); } diff --git a/shared/js/voice/VoiceRecorder.ts b/shared/js/voice/VoiceRecorder.ts index bf28d8d8..35f8f6a3 100644 --- a/shared/js/voice/VoiceRecorder.ts +++ b/shared/js/voice/VoiceRecorder.ts @@ -65,8 +65,8 @@ class VoiceRecorder { this._deviceId = settings.global("microphone_device_id", "default"); this._deviceGroup = settings.global("microphone_device_group", "default"); - AudioController.on_initialized(() => { - this.audioContext = AudioController.globalContext; + audio.player.on_ready(() => { + this.audioContext = audio.player.context(); this.processor = this.audioContext.createScriptProcessor(VoiceRecorder.BUFFER_SIZE, VoiceRecorder.CHANNELS, VoiceRecorder.CHANNELS); const empty_buffer = this.audioContext.createBuffer(VoiceRecorder.CHANNELS, VoiceRecorder.BUFFER_SIZE, 48000); @@ -102,7 +102,7 @@ class VoiceRecorder { } available() : boolean { - return !!AudioController.userMedia; + return !!getUserMediaFunction() && !!getUserMediaFunction(); } recording() : boolean { @@ -184,7 +184,8 @@ class VoiceRecorder { console.log("[VoiceRecorder] Start recording! (Device: %o | Group: %o)", device, groupId); this._recording = true; - AudioController.userMedia({ + //FIXME Implement that here for thew client as well + getUserMediaFunction()({ audio: { deviceId: device, groupId: groupId @@ -257,7 +258,7 @@ class VoiceActivityDetectorVAD extends VoiceActivityDetector { percentage_listener: (per: number) => void = ($) => {}; initialise() { - this.analyzer = AudioController.globalContext.createAnalyser(); + this.analyzer = audio.player.context().createAnalyser(); this.analyzer.smoothingTimeConstant = 1; //TODO test this.buffer = new Uint8Array(this.analyzer.fftSize); return super.initialise(); diff --git a/shared/js/workers/codec/OpusCodec.ts b/shared/js/workers/codec/OpusCodec.ts index 4af2c042..9ca65348 100644 --- a/shared/js/workers/codec/OpusCodec.ts +++ b/shared/js/workers/codec/OpusCodec.ts @@ -95,7 +95,6 @@ class OpusWorker implements CodecWorker { if (result < 0) { return "invalid result on decode (" + result + ")"; } - console.log("Result: %o | Channel count %o", result, this.channelCount); return Module.HEAPF32.slice(this.decodeBuffer.byteOffset / 4, (this.decodeBuffer.byteOffset / 4) + (result * this.channelCount)); } diff --git a/templates.html b/templates.html index 3f6bd3f8..4314f935 100644 --- a/templates.html +++ b/templates.html @@ -564,47 +564,65 @@ Didnt setuped yet! - Voice + Audio -
-
- Microphone: - -
-
-
- Voice Activity Detection -
-
-
Always active
-
Voice activity detection
-
Push to talk
-
-
-
-
-
-
-
Type[Unknown] settings
-
There are no setting entries for an always online voice detection.
-
- Push to talk key: - +
+
+
Microphone
+
+
+
+
+ Device: +
-
-
Voice activity threshold (20%)
-
-
-
-
+
+
+
+
Voice Activity Detection
+
+
+
Always active
+
Voice activity detection
+
Push to talk
+
+
+
+
+
+ There are no setting entries for an always online voice detection. +
+
+ Push to talk key: + +
+
+
Voice activity threshold (20%)
+
+
+
+
+
+
-
+
+
Speaker
+
+
+
+
+ Device: +
+
+
+
+
diff --git a/tsconfig.json b/tsconfig.json index 303a8d80..27307ea0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "es6", - "module": "none", + "module": "commonjs", "sourceMap": true }, "exclude": [ diff --git a/web/js/audio/AudioPlayer.ts b/web/js/audio/AudioPlayer.ts new file mode 100644 index 00000000..971a6d31 --- /dev/null +++ b/web/js/audio/AudioPlayer.ts @@ -0,0 +1,84 @@ +interface Navigator { + mozGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void; + webkitGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void; +} + +namespace audio.player { + let _globalContext: AudioContext; + let _globalContextPromise: Promise; + let _initialized_listener: (() => any)[] = []; + + export interface Device { + device_id: string; + name: string; + } + + export function initialize() : boolean { + context(); + return true; + } + + export function initialized() : boolean { + return !!_globalContext && _globalContext.state === 'running'; + } + + function fire_initialized() { + console.log("Fire initialized: %o", _initialized_listener); + while(_initialized_listener.length > 0) + _initialized_listener.pop_front()(); + } + + + export function context() : AudioContext { + if(_globalContext && _globalContext.state != "suspended") return _globalContext; + + if(!_globalContext) + _globalContext = new (window.webkitAudioContext || window.AudioContext)(); + if(_globalContext.state == "suspended") { + if(!_globalContextPromise) { + (_globalContextPromise = _globalContext.resume()).then(() => { + fire_initialized(); + }).catch(error => { + displayCriticalError("Failed to initialize global audio context! (" + error + ")"); + }); + } + _globalContext.resume(); //We already have our listener + return undefined; + } + + if(_globalContext.state == "running") { + fire_initialized(); + return _globalContext; + } + return undefined; + } + + export function destination() : AudioNode { + return context().destination; + } + + export function on_ready(cb: () => any) { + if(initialized()) + cb(); + else + _initialized_listener.push(cb); + } + + export const WEB_DEVICE: Device = {device_id: "", name: "default playback"}; + + export function available_devices() : Promise { + return Promise.resolve([WEB_DEVICE]) + } + + export function set_device(device_id: string) : Promise { + return Promise.resolve(); + } + + export function current_device() : Device { + return WEB_DEVICE; + } + + export function initializeFromGesture() { + context(); + } +} diff --git a/web/tsdeclaration.json b/web/tsdeclaration.json new file mode 100644 index 00000000..e3ddbcf5 --- /dev/null +++ b/web/tsdeclaration.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "listFiles": true, + "module": "system", + "target": "es6", + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": false, + "checkJs": false, + + "outFile": "declarations/web_api" + }, + "include": [ + "js/**/*.ts" + ] +} \ No newline at end of file