From 7de784ce27d6c9d41e3847e74caf778d90b71c87 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Wed, 10 Oct 2018 12:32:55 -0700 Subject: [PATCH 01/34] First steps (definitely not working) --- assignment-client/src/Agent.cpp | 2 +- interface/src/Application.cpp | 14 ++- interface/src/assets/ATPAssetMigrator.cpp | 4 +- .../scripting/SpeechScriptingInterface.cpp | 96 +++++++++++++++++++ .../src/scripting/SpeechScriptingInterface.h | 76 +++++++++++++++ .../src/RenderableWebEntityItem.cpp | 2 +- libraries/entities/src/EntityEditFilters.cpp | 2 +- .../src/model-networking/TextureCache.cpp | 4 +- libraries/networking/src/AddressManager.cpp | 10 +- libraries/networking/src/DomainHandler.cpp | 2 +- .../networking/src/NetworkingConstants.h | 10 +- libraries/networking/src/ResourceCache.cpp | 2 +- libraries/networking/src/ResourceManager.cpp | 18 ++-- 13 files changed, 209 insertions(+), 33 deletions(-) create mode 100644 interface/src/scripting/SpeechScriptingInterface.cpp create mode 100644 interface/src/scripting/SpeechScriptingInterface.h diff --git a/assignment-client/src/Agent.cpp b/assignment-client/src/Agent.cpp index 639e9f924b..ee21fff8c0 100644 --- a/assignment-client/src/Agent.cpp +++ b/assignment-client/src/Agent.cpp @@ -216,7 +216,7 @@ void Agent::requestScript() { } // make sure this is not a script request for the file scheme - if (scriptURL.scheme() == URL_SCHEME_FILE) { + if (scriptURL.scheme() == HIFI_URL_SCHEME_FILE) { qWarning() << "Cannot load script for Agent from local filesystem."; scriptRequestFinished(); return; diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index aa2b382c58..74532ef53a 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -182,6 +182,7 @@ #include "scripting/RatesScriptingInterface.h" #include "scripting/SelectionScriptingInterface.h" #include "scripting/WalletScriptingInterface.h" +#include "scripting/SpeechScriptingInterface.h" #if defined(Q_OS_MAC) || defined(Q_OS_WIN) #include "SpeechRecognizer.h" #endif @@ -528,11 +529,11 @@ bool isDomainURL(QUrl url) { if (url.scheme() == URL_SCHEME_HIFI) { return true; } - if (url.scheme() != URL_SCHEME_FILE) { + if (url.scheme() != HIFI_URL_SCHEME_FILE) { // TODO -- once Octree::readFromURL no-longer takes over the main event-loop, serverless-domain urls can // be loaded over http(s) - // && url.scheme() != URL_SCHEME_HTTP && - // url.scheme() != URL_SCHEME_HTTPS + // && url.scheme() != HIFI_URL_SCHEME_HTTP && + // url.scheme() != HIFI_URL_SCHEME_HTTPS return false; } if (url.path().endsWith(".json", Qt::CaseInsensitive) || @@ -943,6 +944,7 @@ bool setupEssentials(int& argc, char** argv, bool runningMarkerExisted) { DependencyManager::set(); DependencyManager::set(); DependencyManager::set(); + DependencyManager::set(); DependencyManager::set(); @@ -1024,8 +1026,8 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo // If the URL scheme is http(s) or ftp, then use as is, else - treat it as a local file // This is done so as not break previous command line scripts - if (testScriptPath.left(URL_SCHEME_HTTP.length()) == URL_SCHEME_HTTP || - testScriptPath.left(URL_SCHEME_FTP.length()) == URL_SCHEME_FTP) { + if (testScriptPath.left(HIFI_URL_SCHEME_HTTP.length()) == HIFI_URL_SCHEME_HTTP || + testScriptPath.left(HIFI_URL_SCHEME_FTP.length()) == HIFI_URL_SCHEME_FTP) { setProperty(hifi::properties::TEST, QUrl::fromUserInput(testScriptPath)); } else if (QFileInfo(testScriptPath).exists()) { @@ -3127,6 +3129,7 @@ void Application::onDesktopRootContextCreated(QQmlContext* surfaceContext) { surfaceContext->setContextProperty("ContextOverlay", DependencyManager::get().data()); surfaceContext->setContextProperty("Wallet", DependencyManager::get().data()); surfaceContext->setContextProperty("HiFiAbout", AboutUtil::getInstance()); + surfaceContext->setContextProperty("Speech", DependencyManager::get().data()); if (auto steamClient = PluginManager::getInstance()->getSteamClientPlugin()) { surfaceContext->setContextProperty("Steam", new SteamScriptingInterface(engine, steamClient.get())); @@ -6797,6 +6800,7 @@ void Application::registerScriptEngineWithApplicationServices(ScriptEnginePointe scriptEngine->registerGlobalObject("Wallet", DependencyManager::get().data()); scriptEngine->registerGlobalObject("AddressManager", DependencyManager::get().data()); scriptEngine->registerGlobalObject("HifiAbout", AboutUtil::getInstance()); + scriptEngine->registerGlobalObject("Speech", DependencyManager::get().data()); qScriptRegisterMetaType(scriptEngine.data(), OverlayIDtoScriptValue, OverlayIDfromScriptValue); diff --git a/interface/src/assets/ATPAssetMigrator.cpp b/interface/src/assets/ATPAssetMigrator.cpp index 45ac80b054..423a4b8509 100644 --- a/interface/src/assets/ATPAssetMigrator.cpp +++ b/interface/src/assets/ATPAssetMigrator.cpp @@ -121,8 +121,8 @@ void ATPAssetMigrator::loadEntityServerFile() { QUrl migrationURL = QUrl(migrationURLString); if (!_ignoredUrls.contains(migrationURL) - && (migrationURL.scheme() == URL_SCHEME_HTTP || migrationURL.scheme() == URL_SCHEME_HTTPS - || migrationURL.scheme() == URL_SCHEME_FILE || migrationURL.scheme() == URL_SCHEME_FTP)) { + && (migrationURL.scheme() == HIFI_URL_SCHEME_HTTP || migrationURL.scheme() == HIFI_URL_SCHEME_HTTPS + || migrationURL.scheme() == HIFI_URL_SCHEME_FILE || migrationURL.scheme() == HIFI_URL_SCHEME_FTP)) { if (_pendingReplacements.contains(migrationURL)) { // we already have a request out for this asset, just store the QJsonValueRef diff --git a/interface/src/scripting/SpeechScriptingInterface.cpp b/interface/src/scripting/SpeechScriptingInterface.cpp new file mode 100644 index 0000000000..a38e1aa824 --- /dev/null +++ b/interface/src/scripting/SpeechScriptingInterface.cpp @@ -0,0 +1,96 @@ +// +// SpeechScriptingInterface.cpp +// interface/src/scripting +// +// Created by Zach Fox on 2018-10-10. +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "SpeechScriptingInterface.h" +#include "avatar/AvatarManager.h" +#include + +SpeechScriptingInterface::SpeechScriptingInterface() { + // + // Create text to speech engine + // + HRESULT hr = m_tts.CoCreateInstance(CLSID_SpVoice); + if (FAILED(hr)) { + ATLTRACE(TEXT("Text-to-speech creation failed.\n")); + AtlThrow(hr); + } + + // + // Get token corresponding to default voice + // + hr = SpGetDefaultTokenFromCategoryId(SPCAT_VOICES, &m_voiceToken, FALSE); + if (FAILED(hr)) { + ATLTRACE(TEXT("Can't get default voice token.\n")); + AtlThrow(hr); + } + + // + // Set default voice + // + hr = m_tts->SetVoice(m_voiceToken); + if (FAILED(hr)) { + ATLTRACE(TEXT("Can't set default voice.\n")); + AtlThrow(hr); + } + + WAVEFORMATEX fmt; + fmt.wFormatTag = WAVE_FORMAT_PCM; + fmt.nSamplesPerSec = 48000; + fmt.wBitsPerSample = 16; + fmt.nChannels = 1; + fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; + fmt.nAvgBytesPerSec = fmt.nSamplesPerSec * fmt.nBlockAlign; + fmt.cbSize = 0; + + BYTE* pcontent = new BYTE[1024 * 1000]; + + cpIStream = SHCreateMemStream(NULL, 0); + hr = outputStream->SetBaseStream(cpIStream, SPDFID_WaveFormatEx, &fmt); + + hr = m_tts->SetOutput(outputStream, true); + if (FAILED(hr)) { + ATLTRACE(TEXT("Can't set output stream.\n")); + AtlThrow(hr); + } +} + +SpeechScriptingInterface::~SpeechScriptingInterface() { + +} + +void SpeechScriptingInterface::speakText(const QString& textToSpeak) { + ULONG streamNumber; + HRESULT hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), + SPF_IS_NOT_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, + &streamNumber); + if (FAILED(hr)) { + ATLTRACE(TEXT("Speak failed.\n")); + AtlThrow(hr); + } + + m_tts->WaitUntilDone(-1); + + outputStream->GetBaseStream(&cpIStream); + ULARGE_INTEGER StreamSize; + StreamSize.LowPart = 0; + hr = IStream_Size(cpIStream, &StreamSize); + + DWORD dwSize = StreamSize.QuadPart; + char* buf1 = new char[dwSize + 1]; + hr = IStream_Read(cpIStream, buf1, dwSize); + + QByteArray byteArray = QByteArray::QByteArray(buf1, (int)dwSize); + AudioInjectorOptions options; + + options.position = DependencyManager::get()->getMyAvatarPosition(); + + AudioInjector::playSound(byteArray, options); +} diff --git a/interface/src/scripting/SpeechScriptingInterface.h b/interface/src/scripting/SpeechScriptingInterface.h new file mode 100644 index 0000000000..311bd80605 --- /dev/null +++ b/interface/src/scripting/SpeechScriptingInterface.h @@ -0,0 +1,76 @@ +// SpeechScriptingInterface.h +// interface/src/scripting +// +// Created by Zach Fox on 2018-10-10. +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_SpeechScriptingInterface_h +#define hifi_SpeechScriptingInterface_h + +#include +#include + +#include // SAPI +#include // SAPI Helper + +class SpeechScriptingInterface : public QObject, public Dependency { + Q_OBJECT + +public: + SpeechScriptingInterface(); + ~SpeechScriptingInterface(); + + Q_INVOKABLE void speakText(const QString& textToSpeak); + +private: + + class CComAutoInit { + public: + // Initializes COM using CoInitialize. + // On failure, signals error using AtlThrow. + CComAutoInit() { + HRESULT hr = ::CoInitialize(NULL); + if (FAILED(hr)) { + ATLTRACE(TEXT("CoInitialize() failed in CComAutoInit constructor (hr=0x%08X).\n"), hr); + AtlThrow(hr); + } + } + + // Initializes COM using CoInitializeEx. + // On failure, signals error using AtlThrow. + explicit CComAutoInit(__in DWORD dwCoInit) { + HRESULT hr = ::CoInitializeEx(NULL, dwCoInit); + if (FAILED(hr)) { + ATLTRACE(TEXT("CoInitializeEx() failed in CComAutoInit constructor (hr=0x%08X).\n"), hr); + AtlThrow(hr); + } + } + + // Uninitializes COM using CoUninitialize. + ~CComAutoInit() { ::CoUninitialize(); } + + // + // Ban copy + // + private: + CComAutoInit(const CComAutoInit&); + }; + + // COM initialization and cleanup (must precede other COM related data members) + CComAutoInit m_comInit; + + // Text to speech engine + CComPtr m_tts; + + // Default voice token + CComPtr m_voiceToken; + + CComPtr outputStream; + CComPtr cpIStream; +}; + +#endif // hifi_SpeechScriptingInterface_h diff --git a/libraries/entities-renderer/src/RenderableWebEntityItem.cpp b/libraries/entities-renderer/src/RenderableWebEntityItem.cpp index bc9ac84c91..ac5e43e558 100644 --- a/libraries/entities-renderer/src/RenderableWebEntityItem.cpp +++ b/libraries/entities-renderer/src/RenderableWebEntityItem.cpp @@ -54,7 +54,7 @@ WebEntityRenderer::ContentType WebEntityRenderer::getContentType(const QString& const QUrl url(urlString); auto scheme = url.scheme(); - if (scheme == URL_SCHEME_ABOUT || scheme == URL_SCHEME_HTTP || scheme == URL_SCHEME_HTTPS || + if (scheme == HIFI_URL_SCHEME_ABOUT || scheme == HIFI_URL_SCHEME_HTTP || scheme == HIFI_URL_SCHEME_HTTPS || urlString.toLower().endsWith(".htm") || urlString.toLower().endsWith(".html")) { return ContentType::HtmlContent; } diff --git a/libraries/entities/src/EntityEditFilters.cpp b/libraries/entities/src/EntityEditFilters.cpp index 94df7eb465..9a3f056e04 100644 --- a/libraries/entities/src/EntityEditFilters.cpp +++ b/libraries/entities/src/EntityEditFilters.cpp @@ -183,7 +183,7 @@ void EntityEditFilters::addFilter(EntityItemID entityID, QString filterURL) { } // The following should be abstracted out for use in Agent.cpp (and maybe later AvatarMixer.cpp) - if (scriptURL.scheme().isEmpty() || (scriptURL.scheme() == URL_SCHEME_FILE)) { + if (scriptURL.scheme().isEmpty() || (scriptURL.scheme() == HIFI_URL_SCHEME_FILE)) { qWarning() << "Cannot load script from local filesystem, because assignment may be on a different computer."; scriptRequestFinished(entityID); return; diff --git a/libraries/model-networking/src/model-networking/TextureCache.cpp b/libraries/model-networking/src/model-networking/TextureCache.cpp index e8aec5e60e..11a5b2f167 100644 --- a/libraries/model-networking/src/model-networking/TextureCache.cpp +++ b/libraries/model-networking/src/model-networking/TextureCache.cpp @@ -329,7 +329,7 @@ _maxNumPixels(100) static bool isLocalUrl(const QUrl& url) { auto scheme = url.scheme(); - return (scheme == URL_SCHEME_FILE || scheme == URL_SCHEME_QRC || scheme == RESOURCE_SCHEME); + return (scheme == HIFI_URL_SCHEME_FILE || scheme == URL_SCHEME_QRC || scheme == RESOURCE_SCHEME); } NetworkTexture::NetworkTexture(const QUrl& url, image::TextureUsage::Type type, const QByteArray& content, int maxNumPixels) : @@ -502,7 +502,7 @@ void NetworkTexture::handleLocalRequestCompleted() { void NetworkTexture::makeLocalRequest() { const QString scheme = _activeUrl.scheme(); QString path; - if (scheme == URL_SCHEME_FILE) { + if (scheme == HIFI_URL_SCHEME_FILE) { path = PathUtils::expandToLocalDataAbsolutePath(_activeUrl).toLocalFile(); } else { path = ":" + _activeUrl.path(); diff --git a/libraries/networking/src/AddressManager.cpp b/libraries/networking/src/AddressManager.cpp index f8ab8ceaec..e6957728e8 100644 --- a/libraries/networking/src/AddressManager.cpp +++ b/libraries/networking/src/AddressManager.cpp @@ -155,12 +155,12 @@ void AddressManager::goForward() { void AddressManager::storeCurrentAddress() { auto url = currentAddress(); - if (url.scheme() == URL_SCHEME_FILE || + if (url.scheme() == HIFI_URL_SCHEME_FILE || (url.scheme() == URL_SCHEME_HIFI && !url.host().isEmpty())) { // TODO -- once Octree::readFromURL no-longer takes over the main event-loop, serverless-domain urls can // be loaded over http(s) - // url.scheme() == URL_SCHEME_HTTP || - // url.scheme() == URL_SCHEME_HTTPS || + // url.scheme() == HIFI_URL_SCHEME_HTTP || + // url.scheme() == HIFI_URL_SCHEME_HTTPS || bool isInErrorState = DependencyManager::get()->getDomainHandler().isInErrorState(); if (isConnected()) { if (isInErrorState) { @@ -331,11 +331,11 @@ bool AddressManager::handleUrl(const QUrl& lookupUrl, LookupTrigger trigger) { emit lookupResultsFinished(); return true; - } else if (lookupUrl.scheme() == URL_SCHEME_FILE) { + } else if (lookupUrl.scheme() == HIFI_URL_SCHEME_FILE) { // TODO -- once Octree::readFromURL no-longer takes over the main event-loop, serverless-domain urls can // be loaded over http(s) // lookupUrl.scheme() == URL_SCHEME_HTTP || - // lookupUrl.scheme() == URL_SCHEME_HTTPS || + // lookupUrl.scheme() == HIFI_URL_SCHEME_HTTPS || // TODO once a file can return a connection refusal if there were to be some kind of load error, we'd // need to store the previous domain tried in _lastVisitedURL. For now , do not store it. diff --git a/libraries/networking/src/DomainHandler.cpp b/libraries/networking/src/DomainHandler.cpp index 615546b410..3dda182989 100644 --- a/libraries/networking/src/DomainHandler.cpp +++ b/libraries/networking/src/DomainHandler.cpp @@ -194,7 +194,7 @@ void DomainHandler::setURLAndID(QUrl domainURL, QUuid domainID) { _sockAddr.clear(); // if this is a file URL we need to see if it has a ~ for us to expand - if (domainURL.scheme() == URL_SCHEME_FILE) { + if (domainURL.scheme() == HIFI_URL_SCHEME_FILE) { domainURL = PathUtils::expandToLocalDataAbsolutePath(domainURL); } } diff --git a/libraries/networking/src/NetworkingConstants.h b/libraries/networking/src/NetworkingConstants.h index 839e269fd4..302e0efa02 100644 --- a/libraries/networking/src/NetworkingConstants.h +++ b/libraries/networking/src/NetworkingConstants.h @@ -30,14 +30,14 @@ namespace NetworkingConstants { QUrl METAVERSE_SERVER_URL(); } -const QString URL_SCHEME_ABOUT = "about"; +const QString HIFI_URL_SCHEME_ABOUT = "about"; const QString URL_SCHEME_HIFI = "hifi"; const QString URL_SCHEME_HIFIAPP = "hifiapp"; const QString URL_SCHEME_QRC = "qrc"; -const QString URL_SCHEME_FILE = "file"; -const QString URL_SCHEME_HTTP = "http"; -const QString URL_SCHEME_HTTPS = "https"; -const QString URL_SCHEME_FTP = "ftp"; +const QString HIFI_URL_SCHEME_FILE = "file"; +const QString HIFI_URL_SCHEME_HTTP = "http"; +const QString HIFI_URL_SCHEME_HTTPS = "https"; +const QString HIFI_URL_SCHEME_FTP = "ftp"; const QString URL_SCHEME_ATP = "atp"; #endif // hifi_NetworkingConstants_h diff --git a/libraries/networking/src/ResourceCache.cpp b/libraries/networking/src/ResourceCache.cpp index aed9f3b0e5..1328606be4 100644 --- a/libraries/networking/src/ResourceCache.cpp +++ b/libraries/networking/src/ResourceCache.cpp @@ -114,7 +114,7 @@ QSharedPointer ResourceCacheSharedItems::getHighestPendingRequest() { // Check load priority float priority = resource->getLoadPriority(); - bool isFile = resource->getURL().scheme() == URL_SCHEME_FILE; + bool isFile = resource->getURL().scheme() == HIFI_URL_SCHEME_FILE; if (priority >= highestPriority && (isFile || !currentHighestIsFile)) { highestPriority = priority; highestIndex = i; diff --git a/libraries/networking/src/ResourceManager.cpp b/libraries/networking/src/ResourceManager.cpp index 553f0d0a61..40d6570f48 100644 --- a/libraries/networking/src/ResourceManager.cpp +++ b/libraries/networking/src/ResourceManager.cpp @@ -82,10 +82,10 @@ const QSet& getKnownUrls() { static std::once_flag once; std::call_once(once, [] { knownUrls.insert(URL_SCHEME_QRC); - knownUrls.insert(URL_SCHEME_FILE); - knownUrls.insert(URL_SCHEME_HTTP); - knownUrls.insert(URL_SCHEME_HTTPS); - knownUrls.insert(URL_SCHEME_FTP); + knownUrls.insert(HIFI_URL_SCHEME_FILE); + knownUrls.insert(HIFI_URL_SCHEME_HTTP); + knownUrls.insert(HIFI_URL_SCHEME_HTTPS); + knownUrls.insert(HIFI_URL_SCHEME_FTP); knownUrls.insert(URL_SCHEME_ATP); }); return knownUrls; @@ -97,7 +97,7 @@ QUrl ResourceManager::normalizeURL(const QUrl& originalUrl) { if (!getKnownUrls().contains(scheme)) { // check the degenerative file case: on windows we can often have urls of the form c:/filename // this checks for and works around that case. - QUrl urlWithFileScheme{ URL_SCHEME_FILE + ":///" + url.toString() }; + QUrl urlWithFileScheme{ HIFI_URL_SCHEME_FILE + ":///" + url.toString() }; if (!urlWithFileScheme.toLocalFile().isEmpty()) { return urlWithFileScheme; } @@ -118,9 +118,9 @@ ResourceRequest* ResourceManager::createResourceRequest(QObject* parent, const Q ResourceRequest* request = nullptr; - if (scheme == URL_SCHEME_FILE || scheme == URL_SCHEME_QRC) { + if (scheme == HIFI_URL_SCHEME_FILE || scheme == URL_SCHEME_QRC) { request = new FileResourceRequest(normalizedURL); - } else if (scheme == URL_SCHEME_HTTP || scheme == URL_SCHEME_HTTPS || scheme == URL_SCHEME_FTP) { + } else if (scheme == HIFI_URL_SCHEME_HTTP || scheme == HIFI_URL_SCHEME_HTTPS || scheme == HIFI_URL_SCHEME_FTP) { request = new HTTPResourceRequest(normalizedURL); } else if (scheme == URL_SCHEME_ATP) { if (!_atpSupportEnabled) { @@ -143,10 +143,10 @@ ResourceRequest* ResourceManager::createResourceRequest(QObject* parent, const Q bool ResourceManager::resourceExists(const QUrl& url) { auto scheme = url.scheme(); - if (scheme == URL_SCHEME_FILE) { + if (scheme == HIFI_URL_SCHEME_FILE) { QFileInfo file{ url.toString() }; return file.exists(); - } else if (scheme == URL_SCHEME_HTTP || scheme == URL_SCHEME_HTTPS || scheme == URL_SCHEME_FTP) { + } else if (scheme == HIFI_URL_SCHEME_HTTP || scheme == HIFI_URL_SCHEME_HTTPS || scheme == HIFI_URL_SCHEME_FTP) { auto& networkAccessManager = NetworkAccessManager::getInstance(); QNetworkRequest request{ url }; From f1446532d02eca2ee160e83bb5d2b122a1b48d38 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Wed, 10 Oct 2018 16:13:23 -0700 Subject: [PATCH 02/34] Almost working --- .../scripting/SpeechScriptingInterface.cpp | 106 ++++++++++++------ .../src/scripting/SpeechScriptingInterface.h | 7 +- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/interface/src/scripting/SpeechScriptingInterface.cpp b/interface/src/scripting/SpeechScriptingInterface.cpp index a38e1aa824..b9c7718075 100644 --- a/interface/src/scripting/SpeechScriptingInterface.cpp +++ b/interface/src/scripting/SpeechScriptingInterface.cpp @@ -19,8 +19,7 @@ SpeechScriptingInterface::SpeechScriptingInterface() { // HRESULT hr = m_tts.CoCreateInstance(CLSID_SpVoice); if (FAILED(hr)) { - ATLTRACE(TEXT("Text-to-speech creation failed.\n")); - AtlThrow(hr); + qDebug() << "Text-to-speech engine creation failed."; } // @@ -28,8 +27,7 @@ SpeechScriptingInterface::SpeechScriptingInterface() { // hr = SpGetDefaultTokenFromCategoryId(SPCAT_VOICES, &m_voiceToken, FALSE); if (FAILED(hr)) { - ATLTRACE(TEXT("Can't get default voice token.\n")); - AtlThrow(hr); + qDebug() << "Can't get default voice token."; } // @@ -37,28 +35,7 @@ SpeechScriptingInterface::SpeechScriptingInterface() { // hr = m_tts->SetVoice(m_voiceToken); if (FAILED(hr)) { - ATLTRACE(TEXT("Can't set default voice.\n")); - AtlThrow(hr); - } - - WAVEFORMATEX fmt; - fmt.wFormatTag = WAVE_FORMAT_PCM; - fmt.nSamplesPerSec = 48000; - fmt.wBitsPerSample = 16; - fmt.nChannels = 1; - fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; - fmt.nAvgBytesPerSec = fmt.nSamplesPerSec * fmt.nBlockAlign; - fmt.cbSize = 0; - - BYTE* pcontent = new BYTE[1024 * 1000]; - - cpIStream = SHCreateMemStream(NULL, 0); - hr = outputStream->SetBaseStream(cpIStream, SPDFID_WaveFormatEx, &fmt); - - hr = m_tts->SetOutput(outputStream, true); - if (FAILED(hr)) { - ATLTRACE(TEXT("Can't set output stream.\n")); - AtlThrow(hr); + qDebug() << "Can't set default voice."; } } @@ -66,30 +43,91 @@ SpeechScriptingInterface::~SpeechScriptingInterface() { } +class ReleaseOnExit { +public: + ReleaseOnExit(IUnknown* p) : m_p(p) {} + ~ReleaseOnExit() { + if (m_p) { + m_p->Release(); + } + } + +private: + IUnknown* m_p; +}; + void SpeechScriptingInterface::speakText(const QString& textToSpeak) { + WAVEFORMATEX fmt; + fmt.wFormatTag = WAVE_FORMAT_PCM; + fmt.nSamplesPerSec = 44100; + fmt.wBitsPerSample = 16; + fmt.nChannels = 1; + fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; + fmt.nAvgBytesPerSec = fmt.nSamplesPerSec * fmt.nBlockAlign; + fmt.cbSize = 0; + + IStream* pStream = NULL; + + ISpStream* pSpStream = nullptr; + HRESULT hr = CoCreateInstance(CLSID_SpStream, nullptr, CLSCTX_ALL, __uuidof(ISpStream), (void**)&pSpStream); + if (FAILED(hr)) { + qDebug() << "CoCreateInstance failed."; + } + ReleaseOnExit rSpStream(pSpStream); + + pStream = SHCreateMemStream(NULL, 0); + if (nullptr == pStream) { + qDebug() << "SHCreateMemStream failed."; + } + + hr = pSpStream->SetBaseStream(pStream, SPDFID_WaveFormatEx, &fmt); + if (FAILED(hr)) { + qDebug() << "Can't set base stream."; + } + + hr = m_tts->SetOutput(pSpStream, true); + if (FAILED(hr)) { + qDebug() << "Can't set output stream."; + } + + ReleaseOnExit rStream(pStream); + ULONG streamNumber; - HRESULT hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), + hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), SPF_IS_NOT_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, &streamNumber); if (FAILED(hr)) { - ATLTRACE(TEXT("Speak failed.\n")); - AtlThrow(hr); + qDebug() << "Speak failed."; } m_tts->WaitUntilDone(-1); - outputStream->GetBaseStream(&cpIStream); + hr = pSpStream->GetBaseStream(&pStream); + if (FAILED(hr)) { + qDebug() << "Couldn't get base stream."; + } + + hr = IStream_Reset(pStream); + if (FAILED(hr)) { + qDebug() << "Couldn't reset stream."; + } + ULARGE_INTEGER StreamSize; StreamSize.LowPart = 0; - hr = IStream_Size(cpIStream, &StreamSize); + hr = IStream_Size(pStream, &StreamSize); DWORD dwSize = StreamSize.QuadPart; char* buf1 = new char[dwSize + 1]; - hr = IStream_Read(cpIStream, buf1, dwSize); + memset(buf1, 0, dwSize + 1); + + hr = IStream_Read(pStream, buf1, dwSize); + if (FAILED(hr)) { + qDebug() << "Couldn't read from stream."; + } + + QByteArray byteArray = QByteArray::QByteArray(buf1, dwSize); - QByteArray byteArray = QByteArray::QByteArray(buf1, (int)dwSize); AudioInjectorOptions options; - options.position = DependencyManager::get()->getMyAvatarPosition(); AudioInjector::playSound(byteArray, options); diff --git a/interface/src/scripting/SpeechScriptingInterface.h b/interface/src/scripting/SpeechScriptingInterface.h index 311bd80605..ad6777e339 100644 --- a/interface/src/scripting/SpeechScriptingInterface.h +++ b/interface/src/scripting/SpeechScriptingInterface.h @@ -13,7 +13,9 @@ #include #include - +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif #include // SAPI #include // SAPI Helper @@ -68,9 +70,6 @@ private: // Default voice token CComPtr m_voiceToken; - - CComPtr outputStream; - CComPtr cpIStream; }; #endif // hifi_SpeechScriptingInterface_h From d8c9712dd2cfb404878eb830f7060015fbfc37c6 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Wed, 10 Oct 2018 16:19:11 -0700 Subject: [PATCH 03/34] It's working! --- interface/src/scripting/SpeechScriptingInterface.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/scripting/SpeechScriptingInterface.cpp b/interface/src/scripting/SpeechScriptingInterface.cpp index b9c7718075..3b3ecf728d 100644 --- a/interface/src/scripting/SpeechScriptingInterface.cpp +++ b/interface/src/scripting/SpeechScriptingInterface.cpp @@ -59,7 +59,7 @@ private: void SpeechScriptingInterface::speakText(const QString& textToSpeak) { WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; - fmt.nSamplesPerSec = 44100; + fmt.nSamplesPerSec = 24000; fmt.wBitsPerSample = 16; fmt.nChannels = 1; fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; From 5d4de3d3b0130aa7c6b00c5aa83fe12af5d808af Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Wed, 10 Oct 2018 17:36:38 -0700 Subject: [PATCH 04/34] I love it. --- interface/src/scripting/SpeechScriptingInterface.cpp | 9 ++++++--- interface/src/scripting/SpeechScriptingInterface.h | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/interface/src/scripting/SpeechScriptingInterface.cpp b/interface/src/scripting/SpeechScriptingInterface.cpp index 3b3ecf728d..b8e0f5c3e8 100644 --- a/interface/src/scripting/SpeechScriptingInterface.cpp +++ b/interface/src/scripting/SpeechScriptingInterface.cpp @@ -11,7 +11,6 @@ #include "SpeechScriptingInterface.h" #include "avatar/AvatarManager.h" -#include SpeechScriptingInterface::SpeechScriptingInterface() { // @@ -94,7 +93,7 @@ void SpeechScriptingInterface::speakText(const QString& textToSpeak) { ULONG streamNumber; hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), - SPF_IS_NOT_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, + SPF_IS_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, &streamNumber); if (FAILED(hr)) { qDebug() << "Speak failed."; @@ -130,5 +129,9 @@ void SpeechScriptingInterface::speakText(const QString& textToSpeak) { AudioInjectorOptions options; options.position = DependencyManager::get()->getMyAvatarPosition(); - AudioInjector::playSound(byteArray, options); + lastSound = AudioInjector::playSound(byteArray, options); +} + +void SpeechScriptingInterface::stopLastSpeech() { + lastSound->stop(); } diff --git a/interface/src/scripting/SpeechScriptingInterface.h b/interface/src/scripting/SpeechScriptingInterface.h index ad6777e339..c683a1a3c6 100644 --- a/interface/src/scripting/SpeechScriptingInterface.h +++ b/interface/src/scripting/SpeechScriptingInterface.h @@ -18,6 +18,7 @@ #endif #include // SAPI #include // SAPI Helper +#include class SpeechScriptingInterface : public QObject, public Dependency { Q_OBJECT @@ -27,9 +28,9 @@ public: ~SpeechScriptingInterface(); Q_INVOKABLE void speakText(const QString& textToSpeak); + Q_INVOKABLE void stopLastSpeech(); private: - class CComAutoInit { public: // Initializes COM using CoInitialize. @@ -70,6 +71,8 @@ private: // Default voice token CComPtr m_voiceToken; + + AudioInjectorPointer lastSound; }; #endif // hifi_SpeechScriptingInterface_h From daeedc6ef1ee64472d66ef52decf4d6380c210f9 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Thu, 11 Oct 2018 17:10:14 -0700 Subject: [PATCH 05/34] Lots of progress today --- interface/src/Application.cpp | 11 ++- ...nterface.cpp => TTSScriptingInterface.cpp} | 58 ++++++++---- ...ingInterface.h => TTSScriptingInterface.h} | 19 ++-- libraries/audio-client/src/AudioClient.cpp | 88 ++++++++++--------- libraries/audio-client/src/AudioClient.h | 3 + 5 files changed, 111 insertions(+), 68 deletions(-) rename interface/src/scripting/{SpeechScriptingInterface.cpp => TTSScriptingInterface.cpp} (64%) rename interface/src/scripting/{SpeechScriptingInterface.h => TTSScriptingInterface.h} (79%) diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 74532ef53a..728fea8c10 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -182,7 +182,7 @@ #include "scripting/RatesScriptingInterface.h" #include "scripting/SelectionScriptingInterface.h" #include "scripting/WalletScriptingInterface.h" -#include "scripting/SpeechScriptingInterface.h" +#include "scripting/TTSScriptingInterface.h" #if defined(Q_OS_MAC) || defined(Q_OS_WIN) #include "SpeechRecognizer.h" #endif @@ -944,7 +944,7 @@ bool setupEssentials(int& argc, char** argv, bool runningMarkerExisted) { DependencyManager::set(); DependencyManager::set(); DependencyManager::set(); - DependencyManager::set(); + DependencyManager::set(); DependencyManager::set(); @@ -1179,6 +1179,9 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo recording::Frame::registerFrameHandler(AudioConstants::getAudioFrameName(), [&audioIO](recording::Frame::ConstPointer frame) { audioIO->handleRecordedAudioInput(frame->data); }); + + auto TTS = DependencyManager::get().data(); + connect(TTS, &TTSScriptingInterface::ttsSampleCreated, audioIO, &AudioClient::handleTTSAudioInput); connect(audioIO, &AudioClient::inputReceived, [](const QByteArray& audio) { static auto recorder = DependencyManager::get(); @@ -3129,7 +3132,7 @@ void Application::onDesktopRootContextCreated(QQmlContext* surfaceContext) { surfaceContext->setContextProperty("ContextOverlay", DependencyManager::get().data()); surfaceContext->setContextProperty("Wallet", DependencyManager::get().data()); surfaceContext->setContextProperty("HiFiAbout", AboutUtil::getInstance()); - surfaceContext->setContextProperty("Speech", DependencyManager::get().data()); + surfaceContext->setContextProperty("TextToSpeech", DependencyManager::get().data()); if (auto steamClient = PluginManager::getInstance()->getSteamClientPlugin()) { surfaceContext->setContextProperty("Steam", new SteamScriptingInterface(engine, steamClient.get())); @@ -6800,7 +6803,7 @@ void Application::registerScriptEngineWithApplicationServices(ScriptEnginePointe scriptEngine->registerGlobalObject("Wallet", DependencyManager::get().data()); scriptEngine->registerGlobalObject("AddressManager", DependencyManager::get().data()); scriptEngine->registerGlobalObject("HifiAbout", AboutUtil::getInstance()); - scriptEngine->registerGlobalObject("Speech", DependencyManager::get().data()); + scriptEngine->registerGlobalObject("TextToSpeech", DependencyManager::get().data()); qScriptRegisterMetaType(scriptEngine.data(), OverlayIDtoScriptValue, OverlayIDfromScriptValue); diff --git a/interface/src/scripting/SpeechScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp similarity index 64% rename from interface/src/scripting/SpeechScriptingInterface.cpp rename to interface/src/scripting/TTSScriptingInterface.cpp index b8e0f5c3e8..fdbb37e586 100644 --- a/interface/src/scripting/SpeechScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -1,6 +1,6 @@ // -// SpeechScriptingInterface.cpp -// interface/src/scripting +// TTSScriptingInterface.cpp +// libraries/audio-client/src/scripting // // Created by Zach Fox on 2018-10-10. // Copyright 2018 High Fidelity, Inc. @@ -9,10 +9,10 @@ // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // -#include "SpeechScriptingInterface.h" +#include "TTSScriptingInterface.h" #include "avatar/AvatarManager.h" -SpeechScriptingInterface::SpeechScriptingInterface() { +TTSScriptingInterface::TTSScriptingInterface() { // // Create text to speech engine // @@ -38,8 +38,7 @@ SpeechScriptingInterface::SpeechScriptingInterface() { } } -SpeechScriptingInterface::~SpeechScriptingInterface() { - +TTSScriptingInterface::~TTSScriptingInterface() { } class ReleaseOnExit { @@ -55,7 +54,28 @@ private: IUnknown* m_p; }; -void SpeechScriptingInterface::speakText(const QString& textToSpeak) { +void TTSScriptingInterface::testTone(const bool& alsoInject) { + QByteArray byteArray(480000, 0); + _lastSoundByteArray.resize(0); + _lastSoundByteArray.resize(480000); + + int32_t a = 0; + int16_t* samples = reinterpret_cast(byteArray.data()); + for (a = 0; a < 240000; a++) { + int16_t temp = (glm::sin(glm::radians((float)a))) * 32768; + samples[a] = temp; + } + emit ttsSampleCreated(_lastSoundByteArray); + + if (alsoInject) { + AudioInjectorOptions options; + options.position = DependencyManager::get()->getMyAvatarPosition(); + + _lastSoundAudioInjector = AudioInjector::playSound(_lastSoundByteArray, options); + } +} + +void TTSScriptingInterface::speakText(const QString& textToSpeak, const bool& alsoInject) { WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; fmt.nSamplesPerSec = 24000; @@ -92,9 +112,8 @@ void SpeechScriptingInterface::speakText(const QString& textToSpeak) { ReleaseOnExit rStream(pStream); ULONG streamNumber; - hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), - SPF_IS_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, - &streamNumber); + hr = m_tts->Speak(reinterpret_cast(textToSpeak.utf16()), SPF_IS_XML | SPF_ASYNC | SPF_PURGEBEFORESPEAK, + &streamNumber); if (FAILED(hr)) { qDebug() << "Speak failed."; } @@ -124,14 +143,21 @@ void SpeechScriptingInterface::speakText(const QString& textToSpeak) { qDebug() << "Couldn't read from stream."; } - QByteArray byteArray = QByteArray::QByteArray(buf1, dwSize); + _lastSoundByteArray.resize(0); + _lastSoundByteArray.append(buf1, dwSize); - AudioInjectorOptions options; - options.position = DependencyManager::get()->getMyAvatarPosition(); + emit ttsSampleCreated(_lastSoundByteArray); - lastSound = AudioInjector::playSound(byteArray, options); + if (alsoInject) { + AudioInjectorOptions options; + options.position = DependencyManager::get()->getMyAvatarPosition(); + + _lastSoundAudioInjector = AudioInjector::playSound(_lastSoundByteArray, options); + } } -void SpeechScriptingInterface::stopLastSpeech() { - lastSound->stop(); +void TTSScriptingInterface::stopLastSpeech() { + if (_lastSoundAudioInjector) { + _lastSoundAudioInjector->stop(); + } } diff --git a/interface/src/scripting/SpeechScriptingInterface.h b/interface/src/scripting/TTSScriptingInterface.h similarity index 79% rename from interface/src/scripting/SpeechScriptingInterface.h rename to interface/src/scripting/TTSScriptingInterface.h index c683a1a3c6..cb9c6c8c3e 100644 --- a/interface/src/scripting/SpeechScriptingInterface.h +++ b/interface/src/scripting/TTSScriptingInterface.h @@ -1,5 +1,5 @@ -// SpeechScriptingInterface.h -// interface/src/scripting +// TTSScriptingInterface.h +// libraries/audio-client/src/scripting // // Created by Zach Fox on 2018-10-10. // Copyright 2018 High Fidelity, Inc. @@ -20,16 +20,20 @@ #include // SAPI Helper #include -class SpeechScriptingInterface : public QObject, public Dependency { +class TTSScriptingInterface : public QObject, public Dependency { Q_OBJECT public: - SpeechScriptingInterface(); - ~SpeechScriptingInterface(); + TTSScriptingInterface(); + ~TTSScriptingInterface(); - Q_INVOKABLE void speakText(const QString& textToSpeak); + Q_INVOKABLE void testTone(const bool& alsoInject = false); + Q_INVOKABLE void speakText(const QString& textToSpeak, const bool& alsoInject = false); Q_INVOKABLE void stopLastSpeech(); +signals: + void ttsSampleCreated(QByteArray outputArray); + private: class CComAutoInit { public: @@ -72,7 +76,8 @@ private: // Default voice token CComPtr m_voiceToken; - AudioInjectorPointer lastSound; + QByteArray _lastSoundByteArray; + AudioInjectorPointer _lastSoundByteArray; }; #endif // hifi_SpeechScriptingInterface_h diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index d00bc29054..96f1c97878 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -1135,6 +1135,46 @@ void AudioClient::handleAudioInput(QByteArray& audioBuffer) { } } +void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, const uchar& channelCount, const qint32& bytesForDuration) { + // input samples required to produce exactly NETWORK_FRAME_SAMPLES of output + const int inputSamplesRequired = + (_inputToNetworkResampler ? _inputToNetworkResampler->getMinInput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) + : AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * + channelCount; + + const auto inputAudioSamples = std::unique_ptr(new int16_t[inputSamplesRequired]); + + handleLocalEchoAndReverb(inputByteArray); + + _inputRingBuffer.writeData(inputByteArray.data(), inputByteArray.size()); + + float audioInputMsecsRead = inputByteArray.size() / (float)(bytesForDuration); + _stats.updateInputMsRead(audioInputMsecsRead); + + const int numNetworkBytes = + _isStereoInput ? AudioConstants::NETWORK_FRAME_BYTES_STEREO : AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; + const int numNetworkSamples = + _isStereoInput ? AudioConstants::NETWORK_FRAME_SAMPLES_STEREO : AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL; + + static int16_t networkAudioSamples[AudioConstants::NETWORK_FRAME_SAMPLES_STEREO]; + + while (_inputRingBuffer.samplesAvailable() >= inputSamplesRequired) { + if (_muted) { + _inputRingBuffer.shiftReadPosition(inputSamplesRequired); + } else { + _inputRingBuffer.readSamples(inputAudioSamples.get(), inputSamplesRequired); + possibleResampling(_inputToNetworkResampler, inputAudioSamples.get(), networkAudioSamples, inputSamplesRequired, + numNetworkSamples, channelCount, _desiredInputFormat.channelCount()); + } + int bytesInInputRingBuffer = _inputRingBuffer.samplesAvailable() * AudioConstants::SAMPLE_SIZE; + float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); + _stats.updateInputMsUnplayed(msecsInInputRingBuffer); + + QByteArray audioBuffer(reinterpret_cast(networkAudioSamples), numNetworkBytes); + handleAudioInput(audioBuffer); + } +} + void AudioClient::handleMicAudioInput() { if (!_inputDevice || _isPlayingBackRecording) { return; @@ -1144,47 +1184,8 @@ void AudioClient::handleMicAudioInput() { _inputReadsSinceLastCheck++; #endif - // input samples required to produce exactly NETWORK_FRAME_SAMPLES of output - const int inputSamplesRequired = (_inputToNetworkResampler ? - _inputToNetworkResampler->getMinInput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) : - AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * _inputFormat.channelCount(); - - const auto inputAudioSamples = std::unique_ptr(new int16_t[inputSamplesRequired]); - QByteArray inputByteArray = _inputDevice->readAll(); - - handleLocalEchoAndReverb(inputByteArray); - - _inputRingBuffer.writeData(inputByteArray.data(), inputByteArray.size()); - - float audioInputMsecsRead = inputByteArray.size() / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); - _stats.updateInputMsRead(audioInputMsecsRead); - - const int numNetworkBytes = _isStereoInput - ? AudioConstants::NETWORK_FRAME_BYTES_STEREO - : AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; - const int numNetworkSamples = _isStereoInput - ? AudioConstants::NETWORK_FRAME_SAMPLES_STEREO - : AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL; - - static int16_t networkAudioSamples[AudioConstants::NETWORK_FRAME_SAMPLES_STEREO]; - - while (_inputRingBuffer.samplesAvailable() >= inputSamplesRequired) { - if (_muted) { - _inputRingBuffer.shiftReadPosition(inputSamplesRequired); - } else { - _inputRingBuffer.readSamples(inputAudioSamples.get(), inputSamplesRequired); - possibleResampling(_inputToNetworkResampler, - inputAudioSamples.get(), networkAudioSamples, - inputSamplesRequired, numNetworkSamples, - _inputFormat.channelCount(), _desiredInputFormat.channelCount()); - } - int bytesInInputRingBuffer = _inputRingBuffer.samplesAvailable() * AudioConstants::SAMPLE_SIZE; - float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); - _stats.updateInputMsUnplayed(msecsInInputRingBuffer); - - QByteArray audioBuffer(reinterpret_cast(networkAudioSamples), numNetworkBytes); - handleAudioInput(audioBuffer); - } + processAudioAndAddToRingBuffer(_inputDevice->readAll(), _inputFormat.channelCount(), + _inputFormat.bytesForDuration(USECS_PER_MSEC)); } void AudioClient::handleDummyAudioInput() { @@ -1201,6 +1202,11 @@ void AudioClient::handleRecordedAudioInput(const QByteArray& audio) { handleAudioInput(audioBuffer); } +void AudioClient::handleTTSAudioInput(const QByteArray& audio) { + QByteArray audioBuffer(audio); + processAudioAndAddToRingBuffer(audioBuffer, 1, 48); +} + void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLock) { bool doSynchronously = localAudioLock.operator bool(); if (!localAudioLock) { diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 5e7f1fb8a0..170a355abe 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -197,6 +197,7 @@ public slots: void checkInputTimeout(); void handleDummyAudioInput(); void handleRecordedAudioInput(const QByteArray& audio); + void handleTTSAudioInput(const QByteArray& audio); void reset(); void audioMixerKilled(); @@ -289,6 +290,8 @@ private: float azimuthForSource(const glm::vec3& relativePosition); float gainForSource(float distance, float volume); + void processAudioAndAddToRingBuffer(QByteArray& inputByteArray, const uchar& channelCount, const qint32& bytesForDuration); + #ifdef Q_OS_ANDROID QTimer _checkInputTimer; long _inputReadsSinceLastCheck = 0l; From 53226e7924d109be6a1a763da0793d721bbe32be Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Fri, 12 Oct 2018 11:19:52 -0700 Subject: [PATCH 06/34] Prevent overflows; still not working --- interface/src/scripting/TTSScriptingInterface.h | 2 +- libraries/audio-client/src/AudioClient.cpp | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/interface/src/scripting/TTSScriptingInterface.h b/interface/src/scripting/TTSScriptingInterface.h index cb9c6c8c3e..c1fffe67d1 100644 --- a/interface/src/scripting/TTSScriptingInterface.h +++ b/interface/src/scripting/TTSScriptingInterface.h @@ -77,7 +77,7 @@ private: CComPtr m_voiceToken; QByteArray _lastSoundByteArray; - AudioInjectorPointer _lastSoundByteArray; + AudioInjectorPointer _lastSoundAudioInjector; }; #endif // hifi_SpeechScriptingInterface_h diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index 96f1c97878..12da7ea3be 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -1167,7 +1167,7 @@ void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, con numNetworkSamples, channelCount, _desiredInputFormat.channelCount()); } int bytesInInputRingBuffer = _inputRingBuffer.samplesAvailable() * AudioConstants::SAMPLE_SIZE; - float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); + float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(bytesForDuration); _stats.updateInputMsUnplayed(msecsInInputRingBuffer); QByteArray audioBuffer(reinterpret_cast(networkAudioSamples), numNetworkBytes); @@ -1204,7 +1204,12 @@ void AudioClient::handleRecordedAudioInput(const QByteArray& audio) { void AudioClient::handleTTSAudioInput(const QByteArray& audio) { QByteArray audioBuffer(audio); - processAudioAndAddToRingBuffer(audioBuffer, 1, 48); + while (audioBuffer.size() > 0) { + QByteArray part; + part.append(audioBuffer.data(), AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + audioBuffer.remove(0, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + processAudioAndAddToRingBuffer(part, 1, 48); + } } void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLock) { From 34befd4a52e085bbf548a200e507cc34afe07b3c Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Fri, 12 Oct 2018 12:14:51 -0700 Subject: [PATCH 07/34] Just to make sure, writes data back to a WAV file --- libraries/audio-client/src/AudioClient.cpp | 378 ++++++++++----------- 1 file changed, 188 insertions(+), 190 deletions(-) diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index 12da7ea3be..858f6e738c 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include @@ -67,7 +68,7 @@ static const int CHECK_INPUT_READS_MSECS = 2000; static const int MIN_READS_TO_CONSIDER_INPUT_ALIVE = 10; #endif -static const auto DEFAULT_POSITION_GETTER = []{ return Vectors::ZERO; }; +static const auto DEFAULT_POSITION_GETTER = [] { return Vectors::ZERO; }; static const auto DEFAULT_ORIENTATION_GETTER = [] { return Quaternions::IDENTITY; }; static const int DEFAULT_BUFFER_FRAMES = 1; @@ -78,12 +79,11 @@ static const int OUTPUT_CHANNEL_COUNT = 2; static const bool DEFAULT_STARVE_DETECTION_ENABLED = true; static const int STARVE_DETECTION_THRESHOLD = 3; -static const int STARVE_DETECTION_PERIOD = 10 * 1000; // 10 Seconds +static const int STARVE_DETECTION_PERIOD = 10 * 1000; // 10 Seconds Setting::Handle dynamicJitterBufferEnabled("dynamicJitterBuffersEnabled", - InboundAudioStream::DEFAULT_DYNAMIC_JITTER_BUFFER_ENABLED); -Setting::Handle staticJitterBufferFrames("staticJitterBufferFrames", - InboundAudioStream::DEFAULT_STATIC_JITTER_FRAMES); + InboundAudioStream::DEFAULT_DYNAMIC_JITTER_BUFFER_ENABLED); +Setting::Handle staticJitterBufferFrames("staticJitterBufferFrames", InboundAudioStream::DEFAULT_STATIC_JITTER_FRAMES); // protect the Qt internal device list using Mutex = std::mutex; @@ -127,7 +127,7 @@ QAudioDeviceInfo AudioClient::getActiveAudioDevice(QAudio::Mode mode) const { if (mode == QAudio::AudioInput) { return _inputDeviceInfo; - } else { // if (mode == QAudio::AudioOutput) + } else { // if (mode == QAudio::AudioOutput) return _outputDeviceInfo; } } @@ -137,14 +137,13 @@ QList AudioClient::getAudioDevices(QAudio::Mode mode) const { if (mode == QAudio::AudioInput) { return _inputDevices; - } else { // if (mode == QAudio::AudioOutput) + } else { // if (mode == QAudio::AudioOutput) return _outputDevices; } } static void channelUpmix(int16_t* source, int16_t* dest, int numSamples, int numExtraChannels) { - for (int i = 0; i < numSamples/2; i++) { - + for (int i = 0; i < numSamples / 2; i++) { // read 2 samples int16_t left = *source++; int16_t right = *source++; @@ -159,8 +158,7 @@ static void channelUpmix(int16_t* source, int16_t* dest, int numSamples, int num } static void channelDownmix(int16_t* source, int16_t* dest, int numSamples) { - for (int i = 0; i < numSamples/2; i++) { - + for (int i = 0; i < numSamples / 2; i++) { // read 2 samples int16_t left = *source++; int16_t right = *source++; @@ -175,48 +173,22 @@ static inline float convertToFloat(int16_t sample) { } AudioClient::AudioClient() : - AbstractAudioInterface(), - _gate(this), - _audioInput(NULL), - _dummyAudioInput(NULL), - _desiredInputFormat(), - _inputFormat(), - _numInputCallbackBytes(0), - _audioOutput(NULL), - _desiredOutputFormat(), - _outputFormat(), - _outputFrameSize(0), - _numOutputCallbackBytes(0), - _loopbackAudioOutput(NULL), - _loopbackOutputDevice(NULL), - _inputRingBuffer(0), - _localInjectorsStream(0, 1), - _receivedAudioStream(RECEIVED_AUDIO_STREAM_CAPACITY_FRAMES), - _isStereoInput(false), - _outputStarveDetectionStartTimeMsec(0), - _outputStarveDetectionCount(0), + AbstractAudioInterface(), _gate(this), _audioInput(NULL), _dummyAudioInput(NULL), _desiredInputFormat(), _inputFormat(), + _numInputCallbackBytes(0), _audioOutput(NULL), _desiredOutputFormat(), _outputFormat(), _outputFrameSize(0), + _numOutputCallbackBytes(0), _loopbackAudioOutput(NULL), _loopbackOutputDevice(NULL), _inputRingBuffer(0), + _localInjectorsStream(0, 1), _receivedAudioStream(RECEIVED_AUDIO_STREAM_CAPACITY_FRAMES), _isStereoInput(false), + _outputStarveDetectionStartTimeMsec(0), _outputStarveDetectionCount(0), _outputBufferSizeFrames("audioOutputBufferFrames", DEFAULT_BUFFER_FRAMES), _sessionOutputBufferSizeFrames(_outputBufferSizeFrames.get()), _outputStarveDetectionEnabled("audioOutputStarveDetectionEnabled", DEFAULT_STARVE_DETECTION_ENABLED), - _lastInputLoudness(0.0f), - _timeSinceLastClip(-1.0f), - _muted(false), - _shouldEchoLocally(false), - _shouldEchoToServer(false), - _isNoiseGateEnabled(true), - _reverb(false), - _reverbOptions(&_scriptReverbOptions), - _inputToNetworkResampler(NULL), - _networkToOutputResampler(NULL), - _localToOutputResampler(NULL), - _audioLimiter(AudioConstants::SAMPLE_RATE, OUTPUT_CHANNEL_COUNT), - _outgoingAvatarAudioSequenceNumber(0), - _audioOutputIODevice(_localInjectorsStream, _receivedAudioStream, this), - _stats(&_receivedAudioStream), + _lastInputLoudness(0.0f), _timeSinceLastClip(-1.0f), _muted(false), _shouldEchoLocally(false), _shouldEchoToServer(false), + _isNoiseGateEnabled(true), _reverb(false), _reverbOptions(&_scriptReverbOptions), _inputToNetworkResampler(NULL), + _networkToOutputResampler(NULL), _localToOutputResampler(NULL), + _audioLimiter(AudioConstants::SAMPLE_RATE, OUTPUT_CHANNEL_COUNT), _outgoingAvatarAudioSequenceNumber(0), + _audioOutputIODevice(_localInjectorsStream, _receivedAudioStream, this), _stats(&_receivedAudioStream), _positionGetter(DEFAULT_POSITION_GETTER), #if defined(Q_OS_ANDROID) - _checkInputTimer(this), - _isHeadsetPluggedIn(false), + _checkInputTimer(this), _isHeadsetPluggedIn(false), #endif _orientationGetter(DEFAULT_ORIENTATION_GETTER) { // avoid putting a lock in the device callback @@ -226,16 +198,20 @@ AudioClient::AudioClient() : { Setting::Handle::Deprecated("maxFramesOverDesired", InboundAudioStream::MAX_FRAMES_OVER_DESIRED); Setting::Handle::Deprecated("windowStarveThreshold", InboundAudioStream::WINDOW_STARVE_THRESHOLD); - Setting::Handle::Deprecated("windowSecondsForDesiredCalcOnTooManyStarves", InboundAudioStream::WINDOW_SECONDS_FOR_DESIRED_CALC_ON_TOO_MANY_STARVES); - Setting::Handle::Deprecated("windowSecondsForDesiredReduction", InboundAudioStream::WINDOW_SECONDS_FOR_DESIRED_REDUCTION); + Setting::Handle::Deprecated("windowSecondsForDesiredCalcOnTooManyStarves", + InboundAudioStream::WINDOW_SECONDS_FOR_DESIRED_CALC_ON_TOO_MANY_STARVES); + Setting::Handle::Deprecated("windowSecondsForDesiredReduction", + InboundAudioStream::WINDOW_SECONDS_FOR_DESIRED_REDUCTION); Setting::Handle::Deprecated("useStDevForJitterCalc", InboundAudioStream::USE_STDEV_FOR_JITTER); Setting::Handle::Deprecated("repetitionWithFade", InboundAudioStream::REPETITION_WITH_FADE); } - connect(&_receivedAudioStream, &MixedProcessedAudioStream::processSamples, - this, &AudioClient::processReceivedSamples, Qt::DirectConnection); + connect(&_receivedAudioStream, &MixedProcessedAudioStream::processSamples, this, &AudioClient::processReceivedSamples, + Qt::DirectConnection); connect(this, &AudioClient::changeDevice, this, [=](const QAudioDeviceInfo& outputDeviceInfo) { - qCDebug(audioclient) << "got AudioClient::changeDevice signal, about to call switchOutputToAudioDevice() outputDeviceInfo: [" << outputDeviceInfo.deviceName() << "]"; + qCDebug(audioclient) + << "got AudioClient::changeDevice signal, about to call switchOutputToAudioDevice() outputDeviceInfo: [" + << outputDeviceInfo.deviceName() << "]"; switchOutputToAudioDevice(outputDeviceInfo); }); @@ -244,20 +220,18 @@ AudioClient::AudioClient() : // initialize wasapi; if getAvailableDevices is called from the CheckDevicesThread before this, it will crash getAvailableDevices(QAudio::AudioInput); getAvailableDevices(QAudio::AudioOutput); - + // start a thread to detect any device changes _checkDevicesTimer = new QTimer(this); - connect(_checkDevicesTimer, &QTimer::timeout, this, [this] { - QtConcurrent::run(QThreadPool::globalInstance(), [this] { checkDevices(); }); - }); + connect(_checkDevicesTimer, &QTimer::timeout, this, + [this] { QtConcurrent::run(QThreadPool::globalInstance(), [this] { checkDevices(); }); }); const unsigned long DEVICE_CHECK_INTERVAL_MSECS = 2 * 1000; _checkDevicesTimer->start(DEVICE_CHECK_INTERVAL_MSECS); // start a thread to detect peak value changes _checkPeakValuesTimer = new QTimer(this); - connect(_checkPeakValuesTimer, &QTimer::timeout, this, [this] { - QtConcurrent::run(QThreadPool::globalInstance(), [this] { checkPeakValues(); }); - }); + connect(_checkPeakValuesTimer, &QTimer::timeout, this, + [this] { QtConcurrent::run(QThreadPool::globalInstance(), [this] { checkPeakValues(); }); }); const unsigned long PEAK_VALUES_CHECK_INTERVAL_MSECS = 50; _checkPeakValuesTimer->start(PEAK_VALUES_CHECK_INTERVAL_MSECS); @@ -289,11 +263,11 @@ void AudioClient::customDeleter() { } void AudioClient::handleMismatchAudioFormat(SharedNodePointer node, const QString& currentCodec, const QString& recievedCodec) { - qCDebug(audioclient) << __FUNCTION__ << "sendingNode:" << *node << "currentCodec:" << currentCodec << "recievedCodec:" << recievedCodec; + qCDebug(audioclient) << __FUNCTION__ << "sendingNode:" << *node << "currentCodec:" << currentCodec + << "recievedCodec:" << recievedCodec; selectAudioFormat(recievedCodec); } - void AudioClient::reset() { _receivedAudioStream.reset(); _stats.reset(); @@ -321,7 +295,7 @@ void AudioClient::setAudioPaused(bool pause) { QAudioDeviceInfo getNamedAudioDeviceForMode(QAudio::Mode mode, const QString& deviceName) { QAudioDeviceInfo result; - foreach(QAudioDeviceInfo audioDevice, getAvailableDevices(mode)) { + foreach (QAudioDeviceInfo audioDevice, getAvailableDevices(mode)) { if (audioDevice.deviceName().trimmed() == deviceName.trimmed()) { result = audioDevice; break; @@ -356,7 +330,8 @@ QString AudioClient::getWinDeviceName(wchar_t* guid) { HRESULT hr = S_OK; CoInitialize(nullptr); IMMDeviceEnumerator* pMMDeviceEnumerator = nullptr; - CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pMMDeviceEnumerator); + CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), + (void**)&pMMDeviceEnumerator); IMMDevice* pEndpoint; hr = pMMDeviceEnumerator->GetDevice(guid, &pEndpoint); if (hr == E_NOTFOUND) { @@ -380,34 +355,26 @@ QAudioDeviceInfo defaultAudioDeviceForMode(QAudio::Mode mode) { if (getAvailableDevices(mode).size() > 1) { AudioDeviceID defaultDeviceID = 0; uint32_t propertySize = sizeof(AudioDeviceID); - AudioObjectPropertyAddress propertyAddress = { - kAudioHardwarePropertyDefaultInputDevice, - kAudioObjectPropertyScopeGlobal, - kAudioObjectPropertyElementMaster - }; + AudioObjectPropertyAddress propertyAddress = { kAudioHardwarePropertyDefaultInputDevice, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMaster }; if (mode == QAudio::AudioOutput) { propertyAddress.mSelector = kAudioHardwarePropertyDefaultOutputDevice; } - - OSStatus getPropertyError = AudioObjectGetPropertyData(kAudioObjectSystemObject, - &propertyAddress, - 0, - NULL, - &propertySize, - &defaultDeviceID); + OSStatus getPropertyError = + AudioObjectGetPropertyData(kAudioObjectSystemObject, &propertyAddress, 0, NULL, &propertySize, &defaultDeviceID); if (!getPropertyError && propertySize) { CFStringRef deviceName = NULL; propertySize = sizeof(deviceName); propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString; - getPropertyError = AudioObjectGetPropertyData(defaultDeviceID, &propertyAddress, 0, - NULL, &propertySize, &deviceName); + getPropertyError = + AudioObjectGetPropertyData(defaultDeviceID, &propertyAddress, 0, NULL, &propertySize, &deviceName); if (!getPropertyError && propertySize) { // find a device in the list that matches the name we have and return it - foreach(QAudioDeviceInfo audioDevice, getAvailableDevices(mode)) { + foreach (QAudioDeviceInfo audioDevice, getAvailableDevices(mode)) { if (audioDevice.deviceName() == CFStringGetCStringPtr(deviceName, kCFStringEncodingMacRoman)) { return audioDevice; } @@ -419,7 +386,7 @@ QAudioDeviceInfo defaultAudioDeviceForMode(QAudio::Mode mode) { #ifdef WIN32 QString deviceName; //Check for Windows Vista or higher, IMMDeviceEnumerator doesn't work below that. - if (!IsWindowsVistaOrGreater()) { // lower then vista + if (!IsWindowsVistaOrGreater()) { // lower then vista if (mode == QAudio::AudioInput) { WAVEINCAPS wic; // first use WAVE_MAPPER to get the default devices manufacturer ID @@ -441,9 +408,11 @@ QAudioDeviceInfo defaultAudioDeviceForMode(QAudio::Mode mode) { HRESULT hr = S_OK; CoInitialize(NULL); IMMDeviceEnumerator* pMMDeviceEnumerator = NULL; - CoCreateInstance(__uuidof(MMDeviceEnumerator), NULL, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pMMDeviceEnumerator); + CoCreateInstance(__uuidof(MMDeviceEnumerator), NULL, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), + (void**)&pMMDeviceEnumerator); IMMDevice* pEndpoint; - hr = pMMDeviceEnumerator->GetDefaultAudioEndpoint(mode == QAudio::AudioOutput ? eRender : eCapture, eMultimedia, &pEndpoint); + hr = pMMDeviceEnumerator->GetDefaultAudioEndpoint(mode == QAudio::AudioOutput ? eRender : eCapture, eMultimedia, + &pEndpoint); if (hr == E_NOTFOUND) { printf("Audio Error: device not found\n"); deviceName = QString("NONE"); @@ -457,22 +426,22 @@ QAudioDeviceInfo defaultAudioDeviceForMode(QAudio::Mode mode) { CoUninitialize(); } - qCDebug(audioclient) << "defaultAudioDeviceForMode mode: " << (mode == QAudio::AudioOutput ? "Output" : "Input") - << " [" << deviceName << "] [" << getNamedAudioDeviceForMode(mode, deviceName).deviceName() << "]"; + qCDebug(audioclient) << "defaultAudioDeviceForMode mode: " << (mode == QAudio::AudioOutput ? "Output" : "Input") << " [" + << deviceName << "] [" << getNamedAudioDeviceForMode(mode, deviceName).deviceName() << "]"; return getNamedAudioDeviceForMode(mode, deviceName); #endif -#if defined (Q_OS_ANDROID) +#if defined(Q_OS_ANDROID) if (mode == QAudio::AudioInput) { Setting::Handle enableAEC(SETTING_AEC_KEY, false); bool aecEnabled = enableAEC.get(); auto audioClient = DependencyManager::get(); - bool headsetOn = audioClient? audioClient->isHeadsetPluggedIn() : false; + bool headsetOn = audioClient ? audioClient->isHeadsetPluggedIn() : false; auto inputDevices = QAudioDeviceInfo::availableDevices(QAudio::AudioInput); for (auto inputDevice : inputDevices) { if (((headsetOn || !aecEnabled) && inputDevice.deviceName() == VOICE_RECOGNITION) || - ((!headsetOn && aecEnabled) && inputDevice.deviceName() == VOICE_COMMUNICATION)) { + ((!headsetOn && aecEnabled) && inputDevice.deviceName() == VOICE_COMMUNICATION)) { return inputDevice; } } @@ -486,11 +455,8 @@ bool AudioClient::getNamedAudioDeviceForModeExists(QAudio::Mode mode, const QStr return (getNamedAudioDeviceForMode(mode, deviceName).deviceName() == deviceName); } - // attempt to use the native sample rate and channel count -bool nativeFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, - QAudioFormat& audioFormat) { - +bool nativeFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, QAudioFormat& audioFormat) { audioFormat = audioDevice.preferredFormat(); audioFormat.setCodec("audio/pcm"); @@ -513,7 +479,6 @@ bool nativeFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, bool adjustedFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, const QAudioFormat& desiredAudioFormat, QAudioFormat& adjustedAudioFormat) { - qCDebug(audioclient) << "The desired format for audio I/O is" << desiredAudioFormat; #if defined(Q_OS_ANDROID) || defined(Q_OS_OSX) @@ -539,12 +504,11 @@ bool adjustedFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, // Attempt the device sample rate and channel count in decreasing order of preference. // const int sampleRates[] = { 48000, 44100, 32000, 24000, 16000, 96000, 192000, 88200, 176400 }; - const int inputChannels[] = { 1, 2, 4, 6, 8 }; // prefer mono - const int outputChannels[] = { 2, 4, 6, 8, 1 }; // prefer stereo, downmix as last resort + const int inputChannels[] = { 1, 2, 4, 6, 8 }; // prefer mono + const int outputChannels[] = { 2, 4, 6, 8, 1 }; // prefer stereo, downmix as last resort for (int channelCount : (desiredAudioFormat.channelCount() == 1 ? inputChannels : outputChannels)) { for (int sampleRate : sampleRates) { - adjustedAudioFormat.setChannelCount(channelCount); adjustedAudioFormat.setSampleRate(sampleRate); @@ -554,11 +518,14 @@ bool adjustedFormatForAudioDevice(const QAudioDeviceInfo& audioDevice, } } - return false; // a supported format could not be found + return false; // a supported format could not be found } -bool sampleChannelConversion(const int16_t* sourceSamples, int16_t* destinationSamples, unsigned int numSourceSamples, - const int sourceChannelCount, const int destinationChannelCount) { +bool sampleChannelConversion(const int16_t* sourceSamples, + int16_t* destinationSamples, + unsigned int numSourceSamples, + const int sourceChannelCount, + const int destinationChannelCount) { if (sourceChannelCount == 2 && destinationChannelCount == 1) { // loop through the stereo input audio samples and average every two samples for (uint i = 0; i < numSourceSamples; i += 2) { @@ -567,7 +534,6 @@ bool sampleChannelConversion(const int16_t* sourceSamples, int16_t* destinationS return true; } else if (sourceChannelCount == 1 && destinationChannelCount == 2) { - // loop through the mono input audio and repeat each sample twice for (uint i = 0; i < numSourceSamples; ++i) { destinationSamples[i * 2] = destinationSamples[(i * 2) + 1] = sourceSamples[i]; @@ -580,32 +546,31 @@ bool sampleChannelConversion(const int16_t* sourceSamples, int16_t* destinationS } void possibleResampling(AudioSRC* resampler, - const int16_t* sourceSamples, int16_t* destinationSamples, - unsigned int numSourceSamples, unsigned int numDestinationSamples, - const int sourceChannelCount, const int destinationChannelCount) { - + const int16_t* sourceSamples, + int16_t* destinationSamples, + unsigned int numSourceSamples, + unsigned int numDestinationSamples, + const int sourceChannelCount, + const int destinationChannelCount) { if (numSourceSamples > 0) { if (!resampler) { - if (!sampleChannelConversion(sourceSamples, destinationSamples, numSourceSamples, - sourceChannelCount, destinationChannelCount)) { + if (!sampleChannelConversion(sourceSamples, destinationSamples, numSourceSamples, sourceChannelCount, + destinationChannelCount)) { // no conversion, we can copy the samples directly across memcpy(destinationSamples, sourceSamples, numSourceSamples * AudioConstants::SAMPLE_SIZE); } } else { - if (sourceChannelCount != destinationChannelCount) { - int numChannelCoversionSamples = (numSourceSamples * destinationChannelCount) / sourceChannelCount; int16_t* channelConversionSamples = new int16_t[numChannelCoversionSamples]; - sampleChannelConversion(sourceSamples, channelConversionSamples, numSourceSamples, - sourceChannelCount, destinationChannelCount); + sampleChannelConversion(sourceSamples, channelConversionSamples, numSourceSamples, sourceChannelCount, + destinationChannelCount); resampler->render(channelConversionSamples, destinationSamples, numChannelCoversionSamples); delete[] channelConversionSamples; } else { - unsigned int numAdjustedSourceSamples = numSourceSamples; unsigned int numAdjustedDestinationSamples = numDestinationSamples; @@ -621,7 +586,6 @@ void possibleResampling(AudioSRC* resampler, } void AudioClient::start() { - // set up the desired audio format _desiredInputFormat.setSampleRate(AudioConstants::SAMPLE_RATE); _desiredInputFormat.setSampleSize(16); @@ -710,7 +674,6 @@ void AudioClient::handleAudioDataPacket(QSharedPointer message) nodeList->flagTimeForConnectionStep(LimitedNodeList::ConnectionStep::ReceiveFirstAudioPacket); if (_audioOutput) { - if (!_hasReceivedFirstPacket) { _hasReceivedFirstPacket = true; @@ -727,8 +690,8 @@ void AudioClient::handleAudioDataPacket(QSharedPointer message) } } -AudioClient::Gate::Gate(AudioClient* audioClient) : - _audioClient(audioClient) {} +AudioClient::Gate::Gate(AudioClient* audioClient) : _audioClient(audioClient) { +} void AudioClient::Gate::setIsSimulatingJitter(bool enable) { std::lock_guard lock(_mutex); @@ -781,7 +744,6 @@ void AudioClient::Gate::flush() { _index = 0; } - void AudioClient::handleNoisyMutePacket(QSharedPointer message) { if (!_muted) { setMuted(true); @@ -827,7 +789,6 @@ void AudioClient::handleSelectedAudioFormat(QSharedPointer mess } void AudioClient::selectAudioFormat(const QString& selectedCodecName) { - _selectedCodecName = selectedCodecName; qCDebug(audioclient) << "Selected Codec:" << _selectedCodecName << "isStereoInput:" << _isStereoInput; @@ -845,12 +806,12 @@ void AudioClient::selectAudioFormat(const QString& selectedCodecName) { if (_selectedCodecName == plugin->getName()) { _codec = plugin; _receivedAudioStream.setupCodec(plugin, _selectedCodecName, AudioConstants::STEREO); - _encoder = plugin->createEncoder(AudioConstants::SAMPLE_RATE, _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); + _encoder = plugin->createEncoder(AudioConstants::SAMPLE_RATE, + _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); qCDebug(audioclient) << "Selected Codec Plugin:" << _codec.get(); break; } } - } bool AudioClient::switchAudioDevice(QAudio::Mode mode, const QAudioDeviceInfo& deviceInfo) { @@ -862,7 +823,7 @@ bool AudioClient::switchAudioDevice(QAudio::Mode mode, const QAudioDeviceInfo& d if (mode == QAudio::AudioInput) { return switchInputToAudioDevice(device); - } else { // if (mode == QAudio::AudioOutput) + } else { // if (mode == QAudio::AudioOutput) return switchOutputToAudioDevice(device); } } @@ -904,8 +865,8 @@ void AudioClient::configureReverb() { p.sampleRate = _outputFormat.sampleRate(); p.wetDryMix = 100.0f; p.preDelay = 0.0f; - p.earlyGain = -96.0f; // disable ER - p.lateGain += _reverbOptions->getWetDryMix() * (24.0f/100.0f) - 24.0f; // -0dB to -24dB, based on wetDryMix + p.earlyGain = -96.0f; // disable ER + p.lateGain += _reverbOptions->getWetDryMix() * (24.0f / 100.0f) - 24.0f; // -0dB to -24dB, based on wetDryMix p.lateMixLeft = 0.0f; p.lateMixRight = 0.0f; @@ -915,7 +876,6 @@ void AudioClient::configureReverb() { void AudioClient::updateReverbOptions() { bool reverbChanged = false; if (_receivedAudioStream.hasReverb()) { - if (_zoneReverbOptions.getReverbTime() != _receivedAudioStream.getRevebTime()) { _zoneReverbOptions.setReverbTime(_receivedAudioStream.getRevebTime()); reverbChanged = true; @@ -1020,7 +980,8 @@ void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { int16_t* loopbackSamples = reinterpret_cast(loopBackByteArray.data()); // upmix mono to stereo - if (!sampleChannelConversion(inputSamples, loopbackSamples, numInputSamples, _inputFormat.channelCount(), OUTPUT_CHANNEL_COUNT)) { + if (!sampleChannelConversion(inputSamples, loopbackSamples, numInputSamples, _inputFormat.channelCount(), + OUTPUT_CHANNEL_COUNT)) { // no conversion, just copy the samples memcpy(loopbackSamples, inputSamples, numInputSamples * AudioConstants::SAMPLE_SIZE); } @@ -1028,17 +989,15 @@ void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { // apply stereo reverb at the source, to the loopback audio if (!_shouldEchoLocally && hasReverb) { updateReverbOptions(); - _sourceReverb.render(loopbackSamples, loopbackSamples, numLoopbackSamples/2); + _sourceReverb.render(loopbackSamples, loopbackSamples, numLoopbackSamples / 2); } // if required, upmix or downmix to deviceChannelCount int deviceChannelCount = _outputFormat.channelCount(); if (deviceChannelCount == OUTPUT_CHANNEL_COUNT) { - _loopbackOutputDevice->write(loopBackByteArray); } else { - static QByteArray deviceByteArray; int numDeviceSamples = (numLoopbackSamples * deviceChannelCount) / OUTPUT_CHANNEL_COUNT; @@ -1074,7 +1033,7 @@ void AudioClient::handleAudioInput(QByteArray& audioBuffer) { } int32_t loudness = 0; - assert(numSamples < 65536); // int32_t loudness cannot overflow + assert(numSamples < 65536); // int32_t loudness cannot overflow bool didClip = false; for (int i = 0; i < numSamples; ++i) { const int32_t CLIPPING_THRESHOLD = (int32_t)(AudioConstants::MAX_SAMPLE_VALUE * 0.9f); @@ -1129,13 +1088,14 @@ void AudioClient::handleAudioInput(QByteArray& audioBuffer) { } emitAudioPacket(encodedBuffer.data(), encodedBuffer.size(), _outgoingAvatarAudioSequenceNumber, _isStereoInput, - audioTransform, avatarBoundingBoxCorner, avatarBoundingBoxScale, - packetType, _selectedCodecName); + audioTransform, avatarBoundingBoxCorner, avatarBoundingBoxScale, packetType, _selectedCodecName); _stats.sentPacket(); } } -void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, const uchar& channelCount, const qint32& bytesForDuration) { +void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, + const uchar& channelCount, + const qint32& bytesForDuration) { // input samples required to produce exactly NETWORK_FRAME_SAMPLES of output const int inputSamplesRequired = (_inputToNetworkResampler ? _inputToNetworkResampler->getMinInput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) @@ -1189,11 +1149,10 @@ void AudioClient::handleMicAudioInput() { } void AudioClient::handleDummyAudioInput() { - const int numNetworkBytes = _isStereoInput - ? AudioConstants::NETWORK_FRAME_BYTES_STEREO - : AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; + const int numNetworkBytes = + _isStereoInput ? AudioConstants::NETWORK_FRAME_BYTES_STEREO : AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; - QByteArray audioBuffer(numNetworkBytes, 0); // silent + QByteArray audioBuffer(numNetworkBytes, 0); // silent handleAudioInput(audioBuffer); } @@ -1202,13 +1161,59 @@ void AudioClient::handleRecordedAudioInput(const QByteArray& audio) { handleAudioInput(audioBuffer); } + int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long frequency) { + long chunksize = 0x10; + + struct { + unsigned short wFormatTag; + unsigned short wChannels; + unsigned long dwSamplesPerSec; + unsigned long dwAvgBytesPerSec; + unsigned short wBlockAlign; + unsigned short wBitsPerSample; + } fmt; + + long samplecount = rawLength / 2; + long riffsize = samplecount * 2 + 0x24; + long datasize = samplecount * 2; + + FILE* wav = fopen(wavfn, "wb"); + if (!wav) { + return -3; + } + + fwrite("RIFF", 1, 4, wav); + fwrite(&riffsize, 4, 1, wav); + fwrite("WAVEfmt ", 1, 8, wav); + fwrite(&chunksize, 4, 1, wav); + + fmt.wFormatTag = 1; // PCM + fmt.wChannels = 1; // MONO + fmt.dwSamplesPerSec = frequency * 1; + fmt.dwAvgBytesPerSec = frequency * 1 * 2; // 16 bit + fmt.wBlockAlign = 2; + fmt.wBitsPerSample = 16; + + fwrite(&fmt, sizeof(fmt), 1, wav); + fwrite("data", 1, 4, wav); + fwrite(&datasize, 4, 1, wav); + fwrite(rawData, 1, rawLength, wav); + fclose(wav); +} + void AudioClient::handleTTSAudioInput(const QByteArray& audio) { QByteArray audioBuffer(audio); + QVector audioBufferReal; + + QString filename = QString::number(usecTimestampNow()); + QString path = PathUtils::getAppDataPath() + "Audio/" + filename + ".wav"; + rawToWav(audioBuffer.data(), audioBuffer.size(), path.toLocal8Bit(), 24000); + while (audioBuffer.size() > 0) { QByteArray part; part.append(audioBuffer.data(), AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); audioBuffer.remove(0, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - processAudioAndAddToRingBuffer(part, 1, 48); + processAudioAndAddToRingBuffer(part, 1, 48); } } @@ -1234,9 +1239,8 @@ void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLoc int bufferCapacity = _localInjectorsStream.getSampleCapacity(); int maxOutputSamples = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * AudioConstants::STEREO; if (_localToOutputResampler) { - maxOutputSamples = - _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * - AudioConstants::STEREO; + maxOutputSamples = _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * + AudioConstants::STEREO; } samplesNeeded = bufferCapacity - _localSamplesAvailable.load(std::memory_order_relaxed); @@ -1259,7 +1263,7 @@ void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLoc if (_localToOutputResampler) { // resample to output sample rate int frames = _localToOutputResampler->render(_localMixBuffer, _localOutputMixBuffer, - AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); // write to local injectors' ring buffer samples = frames * AudioConstants::STEREO; @@ -1268,8 +1272,7 @@ void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLoc } else { // write to local injectors' ring buffer samples = AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; - _localInjectorsStream.writeSamples(_localMixBuffer, - AudioConstants::NETWORK_FRAME_SAMPLES_STEREO); + _localInjectorsStream.writeSamples(_localMixBuffer, AudioConstants::NETWORK_FRAME_SAMPLES_STEREO); } _localSamplesAvailable.fetch_add(samples, std::memory_order_release); @@ -1294,18 +1297,16 @@ bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { // the lock guarantees that injectorBuffer, if found, is invariant AudioInjectorLocalBuffer* injectorBuffer = injector->getLocalBuffer(); if (injectorBuffer) { - static const int HRTF_DATASET_INDEX = 1; - int numChannels = injector->isAmbisonic() ? AudioConstants::AMBISONIC : (injector->isStereo() ? AudioConstants::STEREO : AudioConstants::MONO); + int numChannels = injector->isAmbisonic() ? AudioConstants::AMBISONIC + : (injector->isStereo() ? AudioConstants::STEREO : AudioConstants::MONO); size_t bytesToRead = numChannels * AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; // get one frame from the injector memset(_localScratchBuffer, 0, bytesToRead); if (0 < injectorBuffer->readData((char*)_localScratchBuffer, bytesToRead)) { - if (injector->isAmbisonic()) { - // no distance attenuation float gain = injector->getVolume(); @@ -1322,11 +1323,10 @@ bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { float qz = relativeOrientation.y; // Ambisonic gets spatialized into mixBuffer - injector->getLocalFOA().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, - qw, qx, qy, qz, gain, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + injector->getLocalFOA().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, qw, qx, qy, qz, gain, + AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); } else if (injector->isStereo()) { - // stereo gets directly mixed into mixBuffer float gain = injector->getVolume(); for (int i = 0; i < AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; i++) { @@ -1334,7 +1334,6 @@ bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { } } else { - // calculate distance, gain and azimuth for hrtf glm::vec3 relativePosition = injector->getPosition() - _positionGetter(); float distance = glm::max(glm::length(relativePosition), EPSILON); @@ -1342,19 +1341,17 @@ bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { float azimuth = azimuthForSource(relativePosition); // mono gets spatialized into mixBuffer - injector->getLocalHRTF().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, - azimuth, distance, gain, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + injector->getLocalHRTF().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, azimuth, distance, gain, + AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); } } else { - qCDebug(audioclient) << "injector has no more data, marking finished for removal"; injector->finishLocalInjection(); injectorsToRemove.append(injector); } } else { - qCDebug(audioclient) << "injector has no local buffer, marking as finished for removal"; injector->finishLocalInjection(); injectorsToRemove.append(injector); @@ -1373,7 +1370,6 @@ bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { } void AudioClient::processReceivedSamples(const QByteArray& decodedBuffer, QByteArray& outputBuffer) { - const int16_t* decodedSamples = reinterpret_cast(decodedBuffer.data()); assert(decodedBuffer.size() == AudioConstants::NETWORK_FRAME_BYTES_STEREO); @@ -1442,7 +1438,6 @@ void AudioClient::setNoiseReduction(bool enable, bool emitSignal) { } } - bool AudioClient::setIsStereoInput(bool isStereoInput) { bool stereoInputChanged = false; if (isStereoInput != _isStereoInput && _inputDeviceInfo.supportedChannelCounts().contains(2)) { @@ -1460,7 +1455,8 @@ bool AudioClient::setIsStereoInput(bool isStereoInput) { if (_encoder) { _codec->releaseEncoder(_encoder); } - _encoder = _codec->createEncoder(AudioConstants::SAMPLE_RATE, _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); + _encoder = _codec->createEncoder(AudioConstants::SAMPLE_RATE, + _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); } qCDebug(audioclient) << "Reset Codec:" << _selectedCodecName << "isStereoInput:" << _isStereoInput; @@ -1500,7 +1496,7 @@ bool AudioClient::outputLocalInjector(const AudioInjectorPointer& injector) { void AudioClient::outputFormatChanged() { _outputFrameSize = (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * OUTPUT_CHANNEL_COUNT * _outputFormat.sampleRate()) / - _desiredOutputFormat.sampleRate(); + _desiredOutputFormat.sampleRate(); _receivedAudioStream.outputFormatChanged(_outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); } @@ -1514,7 +1510,7 @@ bool AudioClient::switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInf Lock lock(_deviceMutex); #if defined(Q_OS_ANDROID) - _shouldRestartInputSetup = false; // avoid a double call to _audioInput->start() from audioInputStateChanged + _shouldRestartInputSetup = false; // avoid a double call to _audioInput->start() from audioInputStateChanged #endif // cleanup any previously initialized device @@ -1565,15 +1561,15 @@ bool AudioClient::switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInf // we've got the best we can get for input // if required, setup a resampler for this input to our desired network format - if (_inputFormat != _desiredInputFormat - && _inputFormat.sampleRate() != _desiredInputFormat.sampleRate()) { + if (_inputFormat != _desiredInputFormat && _inputFormat.sampleRate() != _desiredInputFormat.sampleRate()) { qCDebug(audioclient) << "Attemping to create a resampler for input format to network format."; assert(_inputFormat.sampleSize() == 16); assert(_desiredInputFormat.sampleSize() == 16); int channelCount = (_inputFormat.channelCount() == 2 && _desiredInputFormat.channelCount() == 2) ? 2 : 1; - _inputToNetworkResampler = new AudioSRC(_inputFormat.sampleRate(), _desiredInputFormat.sampleRate(), channelCount); + _inputToNetworkResampler = + new AudioSRC(_inputFormat.sampleRate(), _desiredInputFormat.sampleRate(), channelCount); } else { qCDebug(audioclient) << "No resampling required for audio input to match desired network format."; @@ -1607,7 +1603,7 @@ bool AudioClient::switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInf connect(_inputDevice, SIGNAL(readyRead()), this, SLOT(handleMicAudioInput())); supportedFormat = true; } else { - qCDebug(audioclient) << "Error starting audio input -" << _audioInput->error(); + qCDebug(audioclient) << "Error starting audio input -" << _audioInput->error(); _audioInput->deleteLater(); _audioInput = NULL; } @@ -1677,7 +1673,7 @@ void AudioClient::checkInputTimeout() { void AudioClient::setHeadsetPluggedIn(bool pluggedIn) { #if defined(Q_OS_ANDROID) if (pluggedIn == !_isHeadsetPluggedIn && !_inputDeviceInfo.isNull()) { - QAndroidJniObject brand = QAndroidJniObject::getStaticObjectField("android/os/Build", "BRAND"); + QAndroidJniObject brand = QAndroidJniObject::getStaticObjectField("android/os/Build", "BRAND"); // some samsung phones needs more time to shutdown the previous input device if (brand.toString().contains("samsung", Qt::CaseInsensitive)) { switchInputToAudioDevice(QAudioDeviceInfo(), true); @@ -1715,8 +1711,8 @@ void AudioClient::outputNotify() { int newOutputBufferSizeFrames = setOutputBufferSize(oldOutputBufferSizeFrames + 1, false); if (newOutputBufferSizeFrames > oldOutputBufferSizeFrames) { - qCDebug(audioclient, - "Starve threshold surpassed (%d starves in %d ms)", _outputStarveDetectionCount, dt); + qCDebug(audioclient, "Starve threshold surpassed (%d starves in %d ms)", _outputStarveDetectionCount, + dt); } _outputStarveDetectionStartTimeMsec = now; @@ -1730,7 +1726,8 @@ void AudioClient::outputNotify() { bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceInfo, bool isShutdownRequest) { Q_ASSERT_X(QThread::currentThread() == thread(), Q_FUNC_INFO, "Function invoked on wrong thread"); - qCDebug(audioclient) << "AudioClient::switchOutputToAudioDevice() outputDeviceInfo: [" << outputDeviceInfo.deviceName() << "]"; + qCDebug(audioclient) << "AudioClient::switchOutputToAudioDevice() outputDeviceInfo: [" << outputDeviceInfo.deviceName() + << "]"; bool supportedFormat = false; // NOTE: device start() uses the Qt internal device list @@ -1789,15 +1786,16 @@ bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceI // we've got the best we can get for input // if required, setup a resampler for this input to our desired network format - if (_desiredOutputFormat != _outputFormat - && _desiredOutputFormat.sampleRate() != _outputFormat.sampleRate()) { + if (_desiredOutputFormat != _outputFormat && _desiredOutputFormat.sampleRate() != _outputFormat.sampleRate()) { qCDebug(audioclient) << "Attemping to create a resampler for network format to output format."; assert(_desiredOutputFormat.sampleSize() == 16); assert(_outputFormat.sampleSize() == 16); - _networkToOutputResampler = new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); - _localToOutputResampler = new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); + _networkToOutputResampler = + new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); + _localToOutputResampler = + new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); } else { qCDebug(audioclient) << "No resampling required for network output to match actual output format."; @@ -1809,7 +1807,9 @@ bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceI _audioOutput = new QAudioOutput(outputDeviceInfo, _outputFormat, this); int deviceChannelCount = _outputFormat.channelCount(); - int frameSize = (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * deviceChannelCount * _outputFormat.sampleRate()) / _desiredOutputFormat.sampleRate(); + int frameSize = + (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * deviceChannelCount * _outputFormat.sampleRate()) / + _desiredOutputFormat.sampleRate(); int requestedSize = _sessionOutputBufferSizeFrames * frameSize * AudioConstants::SAMPLE_SIZE; _audioOutput->setBufferSize(requestedSize); @@ -1825,7 +1825,10 @@ bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceI _outputScratchBuffer = new int16_t[_outputPeriod]; // size local output mix buffer based on resampled network frame size - int networkPeriod = _localToOutputResampler ? _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_STEREO) : AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; + int networkPeriod = + _localToOutputResampler + ? _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_STEREO) + : AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; _localOutputMixBuffer = new float[networkPeriod]; // local period should be at least twice the output period, @@ -1875,7 +1878,8 @@ int AudioClient::setOutputBufferSize(int numFrames, bool persist) { qCDebug(audioclient) << __FUNCTION__ << "numFrames:" << numFrames << "persist:" << persist; numFrames = std::min(std::max(numFrames, MIN_BUFFER_FRAMES), MAX_BUFFER_FRAMES); - qCDebug(audioclient) << __FUNCTION__ << "clamped numFrames:" << numFrames << "_sessionOutputBufferSizeFrames:" << _sessionOutputBufferSizeFrames; + qCDebug(audioclient) << __FUNCTION__ << "clamped numFrames:" << numFrames + << "_sessionOutputBufferSizeFrames:" << _sessionOutputBufferSizeFrames; if (numFrames != _sessionOutputBufferSizeFrames) { qCInfo(audioclient, "Audio output buffer set to %d frames", numFrames); @@ -1906,10 +1910,10 @@ const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 2.0f; #endif int AudioClient::calculateNumberOfInputCallbackBytes(const QAudioFormat& format) const { - int numInputCallbackBytes = (int)(((AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL - * format.channelCount() - * ((float) format.sampleRate() / AudioConstants::SAMPLE_RATE)) - / CALLBACK_ACCELERATOR_RATIO) + 0.5f); + int numInputCallbackBytes = (int)(((AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL * format.channelCount() * + ((float)format.sampleRate() / AudioConstants::SAMPLE_RATE)) / + CALLBACK_ACCELERATOR_RATIO) + + 0.5f); return numInputCallbackBytes; } @@ -1931,10 +1935,9 @@ float AudioClient::azimuthForSource(const glm::vec3& relativePosition) { float rotatedSourcePositionLength2 = glm::length2(rotatedSourcePosition); if (rotatedSourcePositionLength2 > SOURCE_DISTANCE_THRESHOLD) { - // produce an oriented angle about the y-axis glm::vec3 direction = rotatedSourcePosition * (1.0f / fastSqrtf(rotatedSourcePositionLength2)); - float angle = fastAcosf(glm::clamp(-direction.z, -1.0f, 1.0f)); // UNIT_NEG_Z is "forward" + float angle = fastAcosf(glm::clamp(-direction.z, -1.0f, 1.0f)); // UNIT_NEG_Z is "forward" return (direction.x < 0.0f) ? -angle : angle; } else { @@ -1944,7 +1947,6 @@ float AudioClient::azimuthForSource(const glm::vec3& relativePosition) { } float AudioClient::gainForSource(float distance, float volume) { - // attenuation = -6dB * log2(distance) // reference attenuation of 0dB at distance = 1.0m float gain = volume / std::max(distance, HRTF_NEARFIELD_MIN); @@ -1952,8 +1954,7 @@ float AudioClient::gainForSource(float distance, float volume) { return gain; } -qint64 AudioClient::AudioOutputIODevice::readData(char * data, qint64 maxSize) { - +qint64 AudioClient::AudioOutputIODevice::readData(char* data, qint64 maxSize) { // samples requested from OUTPUT_CHANNEL_COUNT int deviceChannelCount = _audio->_outputFormat.channelCount(); int samplesRequested = (int)(maxSize / AudioConstants::SAMPLE_SIZE) * OUTPUT_CHANNEL_COUNT / deviceChannelCount; @@ -1965,7 +1966,8 @@ qint64 AudioClient::AudioOutputIODevice::readData(char * data, qint64 maxSize) { int networkSamplesPopped; if ((networkSamplesPopped = _receivedAudioStream.popSamples(samplesRequested, false)) > 0) { - qCDebug(audiostream, "Read %d samples from buffer (%d available, %d requested)", networkSamplesPopped, _receivedAudioStream.getSamplesAvailable(), samplesRequested); + qCDebug(audiostream, "Read %d samples from buffer (%d available, %d requested)", networkSamplesPopped, + _receivedAudioStream.getSamplesAvailable(), samplesRequested); AudioRingBuffer::ConstIterator lastPopOutput = _receivedAudioStream.getLastPopOutput(); lastPopOutput.readSamples(scratchBuffer, networkSamplesPopped); for (int i = 0; i < networkSamplesPopped; i++) { @@ -1997,14 +1999,13 @@ qint64 AudioClient::AudioOutputIODevice::readData(char * data, qint64 maxSize) { samplesRequested = std::min(samplesRequested, samplesAvailable); if ((injectorSamplesPopped = _localInjectorsStream.appendSamples(mixBuffer, samplesRequested, append)) > 0) { _audio->_localSamplesAvailable.fetch_sub(injectorSamplesPopped, std::memory_order_release); - qCDebug(audiostream, "Read %d samples from injectors (%d available, %d requested)", injectorSamplesPopped, _localInjectorsStream.samplesAvailable(), samplesRequested); + qCDebug(audiostream, "Read %d samples from injectors (%d available, %d requested)", injectorSamplesPopped, + _localInjectorsStream.samplesAvailable(), samplesRequested); } } // prepare injectors for the next callback - QtConcurrent::run(QThreadPool::globalInstance(), [this] { - _audio->prepareLocalAudioInjectors(); - }); + QtConcurrent::run(QThreadPool::globalInstance(), [this] { _audio->prepareLocalAudioInjectors(); }); int samplesPopped = std::max(networkSamplesPopped, injectorSamplesPopped); int framesPopped = samplesPopped / AudioConstants::STEREO; @@ -2038,7 +2039,6 @@ qint64 AudioClient::AudioOutputIODevice::readData(char * data, qint64 maxSize) { _audio->_audioFileWav.addRawAudioChunk(reinterpret_cast(scratchBuffer), bytesWritten); } - int bytesAudioOutputUnplayed = _audio->_audioOutput->bufferSize() - _audio->_audioOutput->bytesFree(); float msecsAudioOutputUnplayed = bytesAudioOutputUnplayed / (float)_audio->_outputFormat.bytesForDuration(USECS_PER_MSEC); _audio->_stats.updateOutputMsUnplayed(msecsAudioOutputUnplayed); @@ -2075,7 +2075,6 @@ void AudioClient::loadSettings() { for (auto& plugin : codecPlugins) { qCDebug(audioclient) << "Codec available:" << plugin->getName(); } - } void AudioClient::saveSettings() { @@ -2088,7 +2087,6 @@ void AudioClient::setAvatarBoundingBoxParameters(glm::vec3 corner, glm::vec3 sca avatarBoundingBoxScale = scale; } - void AudioClient::startThread() { moveToNewNamedThread(this, "Audio Thread", [this] { start(); }, QThread::TimeCriticalPriority); } From d9873d363322e5b15cb0e2c8e976e8595f3505ea Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Mon, 15 Oct 2018 11:33:42 -0700 Subject: [PATCH 08/34] Adding some debug stuff... --- libraries/audio-client/src/AudioClient.cpp | 48 ++++++++++++++-------- libraries/audio-client/src/AudioClient.h | 5 ++- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index 858f6e738c..c2b066b716 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -1095,7 +1095,8 @@ void AudioClient::handleAudioInput(QByteArray& audioBuffer) { void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, const uchar& channelCount, - const qint32& bytesForDuration) { + const qint32& bytesForDuration, + QByteArray& rollingBuffer) { // input samples required to produce exactly NETWORK_FRAME_SAMPLES of output const int inputSamplesRequired = (_inputToNetworkResampler ? _inputToNetworkResampler->getMinInput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) @@ -1131,6 +1132,7 @@ void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, _stats.updateInputMsUnplayed(msecsInInputRingBuffer); QByteArray audioBuffer(reinterpret_cast(networkAudioSamples), numNetworkBytes); + rollingBuffer.append(audioBuffer); handleAudioInput(audioBuffer); } } @@ -1144,8 +1146,10 @@ void AudioClient::handleMicAudioInput() { _inputReadsSinceLastCheck++; #endif + QByteArray temp; + processAudioAndAddToRingBuffer(_inputDevice->readAll(), _inputFormat.channelCount(), - _inputFormat.bytesForDuration(USECS_PER_MSEC)); + _inputFormat.bytesForDuration(USECS_PER_MSEC), temp); } void AudioClient::handleDummyAudioInput() { @@ -1161,9 +1165,7 @@ void AudioClient::handleRecordedAudioInput(const QByteArray& audio) { handleAudioInput(audioBuffer); } - int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long frequency) { - long chunksize = 0x10; - +int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long frequency, unsigned short channels) { struct { unsigned short wFormatTag; unsigned short wChannels; @@ -1174,47 +1176,59 @@ void AudioClient::handleRecordedAudioInput(const QByteArray& audio) { } fmt; long samplecount = rawLength / 2; - long riffsize = samplecount * 2 + 0x24; - long datasize = samplecount * 2; FILE* wav = fopen(wavfn, "wb"); if (!wav) { - return -3; + return -1; } fwrite("RIFF", 1, 4, wav); + + long riffsize = samplecount * 2 + 0x24; fwrite(&riffsize, 4, 1, wav); + fwrite("WAVEfmt ", 1, 8, wav); + + long chunksize = 0x10; fwrite(&chunksize, 4, 1, wav); - fmt.wFormatTag = 1; // PCM - fmt.wChannels = 1; // MONO + fmt.wFormatTag = 1; // WAVE_FORMAT_PCM + fmt.wChannels = channels; fmt.dwSamplesPerSec = frequency * 1; - fmt.dwAvgBytesPerSec = frequency * 1 * 2; // 16 bit - fmt.wBlockAlign = 2; fmt.wBitsPerSample = 16; - + fmt.wBlockAlign = fmt.wChannels * fmt.wBitsPerSample / 8; + fmt.dwAvgBytesPerSec = fmt.dwSamplesPerSec * fmt.wBlockAlign; fwrite(&fmt, sizeof(fmt), 1, wav); + fwrite("data", 1, 4, wav); + long datasize = samplecount * 2; fwrite(&datasize, 4, 1, wav); fwrite(rawData, 1, rawLength, wav); + fclose(wav); + + return 0; } void AudioClient::handleTTSAudioInput(const QByteArray& audio) { QByteArray audioBuffer(audio); - QVector audioBufferReal; QString filename = QString::number(usecTimestampNow()); - QString path = PathUtils::getAppDataPath() + "Audio/" + filename + ".wav"; - rawToWav(audioBuffer.data(), audioBuffer.size(), path.toLocal8Bit(), 24000); + QString path = PathUtils::getAppDataPath() + "Audio/" + filename + "-before.wav"; + rawToWav(audioBuffer.data(), audioBuffer.size(), path.toLocal8Bit(), 24000, 1); + + QByteArray temp; while (audioBuffer.size() > 0) { QByteArray part; part.append(audioBuffer.data(), AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); audioBuffer.remove(0, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - processAudioAndAddToRingBuffer(part, 1, 48); + processAudioAndAddToRingBuffer(part, 1, 48, temp); } + + filename = QString::number(usecTimestampNow()); + path = PathUtils::getAppDataPath() + "Audio/" + filename + "-after.wav"; + rawToWav(temp.data(), temp.size(), path.toLocal8Bit(), 12000, 1); } void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLock) { diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 170a355abe..1ca7cac6ca 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -290,7 +290,10 @@ private: float azimuthForSource(const glm::vec3& relativePosition); float gainForSource(float distance, float volume); - void processAudioAndAddToRingBuffer(QByteArray& inputByteArray, const uchar& channelCount, const qint32& bytesForDuration); + void processAudioAndAddToRingBuffer(QByteArray& inputByteArray, + const uchar& channelCount, + const qint32& bytesForDuration, + QByteArray& rollingBuffer); #ifdef Q_OS_ANDROID QTimer _checkInputTimer; From 26e388b139bb040ae2260042e7c8ff327ca18e1f Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 16 Oct 2018 17:34:48 -0700 Subject: [PATCH 09/34] Some experimentation yields promising results... --- interface/src/Application.cpp | 1 + .../src/scripting/TTSScriptingInterface.cpp | 17 +- .../src/scripting/TTSScriptingInterface.h | 11 +- libraries/audio-client/src/AudioClient.cpp | 1607 +++++++++-------- libraries/audio-client/src/AudioClient.h | 19 +- 5 files changed, 844 insertions(+), 811 deletions(-) diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 728fea8c10..2991fab5f7 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -1182,6 +1182,7 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo auto TTS = DependencyManager::get().data(); connect(TTS, &TTSScriptingInterface::ttsSampleCreated, audioIO, &AudioClient::handleTTSAudioInput); + connect(TTS, &TTSScriptingInterface::clearTTSBuffer, audioIO, &AudioClient::clearTTSBuffer); connect(audioIO, &AudioClient::inputReceived, [](const QByteArray& audio) { static auto recorder = DependencyManager::get(); diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index fdbb37e586..5fb47a73c3 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -65,7 +65,7 @@ void TTSScriptingInterface::testTone(const bool& alsoInject) { int16_t temp = (glm::sin(glm::radians((float)a))) * 32768; samples[a] = temp; } - emit ttsSampleCreated(_lastSoundByteArray); + emit ttsSampleCreated(_lastSoundByteArray, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50, 96); if (alsoInject) { AudioInjectorOptions options; @@ -75,11 +75,16 @@ void TTSScriptingInterface::testTone(const bool& alsoInject) { } } -void TTSScriptingInterface::speakText(const QString& textToSpeak, const bool& alsoInject) { +void TTSScriptingInterface::speakText(const QString& textToSpeak, + const int& newChunkSize, + const int& timerInterval, + const int& sampleRate, + const int& bitsPerSample, + const bool& alsoInject) { WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; - fmt.nSamplesPerSec = 24000; - fmt.wBitsPerSample = 16; + fmt.nSamplesPerSec = sampleRate; + fmt.wBitsPerSample = bitsPerSample; fmt.nChannels = 1; fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; fmt.nAvgBytesPerSec = fmt.nSamplesPerSec * fmt.nBlockAlign; @@ -146,7 +151,7 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, const bool& al _lastSoundByteArray.resize(0); _lastSoundByteArray.append(buf1, dwSize); - emit ttsSampleCreated(_lastSoundByteArray); + emit ttsSampleCreated(_lastSoundByteArray, newChunkSize, timerInterval); if (alsoInject) { AudioInjectorOptions options; @@ -160,4 +165,6 @@ void TTSScriptingInterface::stopLastSpeech() { if (_lastSoundAudioInjector) { _lastSoundAudioInjector->stop(); } + + emit clearTTSBuffer(); } diff --git a/interface/src/scripting/TTSScriptingInterface.h b/interface/src/scripting/TTSScriptingInterface.h index c1fffe67d1..f6eca081ab 100644 --- a/interface/src/scripting/TTSScriptingInterface.h +++ b/interface/src/scripting/TTSScriptingInterface.h @@ -19,6 +19,7 @@ #include // SAPI #include // SAPI Helper #include +#include class TTSScriptingInterface : public QObject, public Dependency { Q_OBJECT @@ -28,11 +29,17 @@ public: ~TTSScriptingInterface(); Q_INVOKABLE void testTone(const bool& alsoInject = false); - Q_INVOKABLE void speakText(const QString& textToSpeak, const bool& alsoInject = false); + Q_INVOKABLE void speakText(const QString& textToSpeak, + const int& newChunkSize = (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50), + const int& timerInterval = 96, + const int& sampleRate = 24000, + const int& bitsPerSample = 16, + const bool& alsoInject = false); Q_INVOKABLE void stopLastSpeech(); signals: - void ttsSampleCreated(QByteArray outputArray); + void ttsSampleCreated(QByteArray outputArray, const int& newChunkSize, const int& timerInterval); + void clearTTSBuffer(); private: class CComAutoInit { diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index c2b066b716..606763e4ab 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -186,7 +186,7 @@ AudioClient::AudioClient() : _networkToOutputResampler(NULL), _localToOutputResampler(NULL), _audioLimiter(AudioConstants::SAMPLE_RATE, OUTPUT_CHANNEL_COUNT), _outgoingAvatarAudioSequenceNumber(0), _audioOutputIODevice(_localInjectorsStream, _receivedAudioStream, this), _stats(&_receivedAudioStream), - _positionGetter(DEFAULT_POSITION_GETTER), + _positionGetter(DEFAULT_POSITION_GETTER), _TTSTimer(this), #if defined(Q_OS_ANDROID) _checkInputTimer(this), _isHeadsetPluggedIn(false), #endif @@ -245,6 +245,8 @@ AudioClient::AudioClient() : packetReceiver.registerListener(PacketType::NoisyMute, this, "handleNoisyMutePacket"); packetReceiver.registerListener(PacketType::MuteEnvironment, this, "handleMuteEnvironmentPacket"); packetReceiver.registerListener(PacketType::SelectedAudioFormat, this, "handleSelectedAudioFormat"); + + connect(&_TTSTimer, &QTimer::timeout, this, &AudioClient::processTTSBuffer); } AudioClient::~AudioClient() { @@ -939,7 +941,7 @@ void AudioClient::setReverbOptions(const AudioEffectOptions* options) { } } -void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { +void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray, const int& sampleRate, const int& channelCount) { // If there is server echo, reverb will be applied to the recieved audio stream so no need to have it here. bool hasReverb = _reverb || _receivedAudioStream.hasReverb(); if (_muted || !_audioOutput || (!_shouldEchoLocally && !hasReverb)) { @@ -949,7 +951,7 @@ void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { // NOTE: we assume the inputFormat and the outputFormat are the same, since on any modern // multimedia OS they should be. If there is a device that this is not true for, we can // add back support to do resampling. - if (_inputFormat.sampleRate() != _outputFormat.sampleRate()) { + if (sampleRate != _outputFormat.sampleRate()) { return; } @@ -972,7 +974,7 @@ void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { static QByteArray loopBackByteArray; int numInputSamples = inputByteArray.size() / AudioConstants::SAMPLE_SIZE; - int numLoopbackSamples = (numInputSamples * OUTPUT_CHANNEL_COUNT) / _inputFormat.channelCount(); + int numLoopbackSamples = (numInputSamples * OUTPUT_CHANNEL_COUNT) / channelCount; loopBackByteArray.resize(numLoopbackSamples * AudioConstants::SAMPLE_SIZE); @@ -980,7 +982,7 @@ void AudioClient::handleLocalEchoAndReverb(QByteArray& inputByteArray) { int16_t* loopbackSamples = reinterpret_cast(loopBackByteArray.data()); // upmix mono to stereo - if (!sampleChannelConversion(inputSamples, loopbackSamples, numInputSamples, _inputFormat.channelCount(), + if (!sampleChannelConversion(inputSamples, loopbackSamples, numInputSamples, channelCount, OUTPUT_CHANNEL_COUNT)) { // no conversion, just copy the samples memcpy(loopbackSamples, inputSamples, numInputSamples * AudioConstants::SAMPLE_SIZE); @@ -1093,23 +1095,29 @@ void AudioClient::handleAudioInput(QByteArray& audioBuffer) { } } -void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, - const uchar& channelCount, - const qint32& bytesForDuration, - QByteArray& rollingBuffer) { +void AudioClient::handleMicAudioInput() { + if (!_inputDevice || _isPlayingBackRecording) { + return; + } + +#if defined(Q_OS_ANDROID) + _inputReadsSinceLastCheck++; +#endif + // input samples required to produce exactly NETWORK_FRAME_SAMPLES of output const int inputSamplesRequired = (_inputToNetworkResampler ? _inputToNetworkResampler->getMinInput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) : AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * - channelCount; + _inputFormat.channelCount(); const auto inputAudioSamples = std::unique_ptr(new int16_t[inputSamplesRequired]); + QByteArray inputByteArray = _inputDevice->readAll(); - handleLocalEchoAndReverb(inputByteArray); + handleLocalEchoAndReverb(inputByteArray, _inputFormat.sampleRate(), _inputFormat.channelCount()); _inputRingBuffer.writeData(inputByteArray.data(), inputByteArray.size()); - float audioInputMsecsRead = inputByteArray.size() / (float)(bytesForDuration); + float audioInputMsecsRead = inputByteArray.size() / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); _stats.updateInputMsRead(audioInputMsecsRead); const int numNetworkBytes = @@ -1125,33 +1133,17 @@ void AudioClient::processAudioAndAddToRingBuffer(QByteArray& inputByteArray, } else { _inputRingBuffer.readSamples(inputAudioSamples.get(), inputSamplesRequired); possibleResampling(_inputToNetworkResampler, inputAudioSamples.get(), networkAudioSamples, inputSamplesRequired, - numNetworkSamples, channelCount, _desiredInputFormat.channelCount()); + numNetworkSamples, _inputFormat.channelCount(), _desiredInputFormat.channelCount()); } int bytesInInputRingBuffer = _inputRingBuffer.samplesAvailable() * AudioConstants::SAMPLE_SIZE; - float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(bytesForDuration); + float msecsInInputRingBuffer = bytesInInputRingBuffer / (float)(_inputFormat.bytesForDuration(USECS_PER_MSEC)); _stats.updateInputMsUnplayed(msecsInInputRingBuffer); QByteArray audioBuffer(reinterpret_cast(networkAudioSamples), numNetworkBytes); - rollingBuffer.append(audioBuffer); handleAudioInput(audioBuffer); } } -void AudioClient::handleMicAudioInput() { - if (!_inputDevice || _isPlayingBackRecording) { - return; - } - -#if defined(Q_OS_ANDROID) - _inputReadsSinceLastCheck++; -#endif - - QByteArray temp; - - processAudioAndAddToRingBuffer(_inputDevice->readAll(), _inputFormat.channelCount(), - _inputFormat.bytesForDuration(USECS_PER_MSEC), temp); -} - void AudioClient::handleDummyAudioInput() { const int numNetworkBytes = _isStereoInput ? AudioConstants::NETWORK_FRAME_BYTES_STEREO : AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; @@ -1192,7 +1184,7 @@ int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long long chunksize = 0x10; fwrite(&chunksize, 4, 1, wav); - fmt.wFormatTag = 1; // WAVE_FORMAT_PCM + fmt.wFormatTag = 1; // WAVE_FORMAT_PCM fmt.wChannels = channels; fmt.dwSamplesPerSec = frequency * 1; fmt.wBitsPerSample = 16; @@ -1210,906 +1202,927 @@ int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long return 0; } -void AudioClient::handleTTSAudioInput(const QByteArray& audio) { - QByteArray audioBuffer(audio); - - QString filename = QString::number(usecTimestampNow()); - QString path = PathUtils::getAppDataPath() + "Audio/" + filename + "-before.wav"; - rawToWav(audioBuffer.data(), audioBuffer.size(), path.toLocal8Bit(), 24000, 1); - - QByteArray temp; - - while (audioBuffer.size() > 0) { +void AudioClient::processTTSBuffer() { + Lock lock(_TTSMutex); + if (_TTSAudioBuffer.size() > 0) { QByteArray part; - part.append(audioBuffer.data(), AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - audioBuffer.remove(0, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - processAudioAndAddToRingBuffer(part, 1, 48, temp); + part.append(_TTSAudioBuffer.data(), _TTSChunkSize); + _TTSAudioBuffer.remove(0, _TTSChunkSize); + handleAudioInput(part); + } else { + _isProcessingTTS = false; + _TTSTimer.stop(); } +} - filename = QString::number(usecTimestampNow()); - path = PathUtils::getAppDataPath() + "Audio/" + filename + "-after.wav"; - rawToWav(temp.data(), temp.size(), path.toLocal8Bit(), 12000, 1); +void AudioClient::handleTTSAudioInput(const QByteArray& audio, const int& newChunkSize, const int& timerInterval) { + _TTSChunkSize = newChunkSize; + _TTSAudioBuffer.append(audio); + + handleLocalEchoAndReverb(_TTSAudioBuffer, 48000, 1); + + //QString filename = QString::number(usecTimestampNow()); + //QString path = PathUtils::getAppDataPath() + "Audio/" + filename + "-before.wav"; + //rawToWav(_TTSAudioBuffer.data(), _TTSAudioBuffer.size(), path.toLocal8Bit(), 24000, 1); + + //QByteArray temp; + + _isProcessingTTS = true; + _TTSTimer.start(timerInterval); + + //filename = QString::number(usecTimestampNow()); + //path = PathUtils::getAppDataPath() + "Audio/" + filename + "-after.wav"; + //rawToWav(temp.data(), temp.size(), path.toLocal8Bit(), 12000, 1); +} + +void AudioClient::clearTTSBuffer() { + _TTSAudioBuffer.resize(0); + _isProcessingTTS = false; + _TTSTimer.stop(); } void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLock) { - bool doSynchronously = localAudioLock.operator bool(); - if (!localAudioLock) { - localAudioLock.reset(new Lock(_localAudioMutex)); + bool doSynchronously = localAudioLock.operator bool(); + if (!localAudioLock) { + localAudioLock.reset(new Lock(_localAudioMutex)); + } + + int samplesNeeded = std::numeric_limits::max(); + while (samplesNeeded > 0) { + if (!doSynchronously) { + // unlock between every write to allow device switching + localAudioLock->unlock(); + localAudioLock->lock(); + } + + // in case of a device switch, consider bufferCapacity volatile across iterations + if (_outputPeriod == 0) { + return; + } + + int bufferCapacity = _localInjectorsStream.getSampleCapacity(); + int maxOutputSamples = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * AudioConstants::STEREO; + if (_localToOutputResampler) { + maxOutputSamples = _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * + AudioConstants::STEREO; + } + + samplesNeeded = bufferCapacity - _localSamplesAvailable.load(std::memory_order_relaxed); + if (samplesNeeded < maxOutputSamples) { + // avoid overwriting the buffer to prevent losing frames + break; + } + + // get a network frame of local injectors' audio + if (!mixLocalAudioInjectors(_localMixBuffer)) { + break; + } + + // reverb + if (_reverb) { + _localReverb.render(_localMixBuffer, _localMixBuffer, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + } + + int samples; + if (_localToOutputResampler) { + // resample to output sample rate + int frames = _localToOutputResampler->render(_localMixBuffer, _localOutputMixBuffer, + AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + + // write to local injectors' ring buffer + samples = frames * AudioConstants::STEREO; + _localInjectorsStream.writeSamples(_localOutputMixBuffer, samples); + + } else { + // write to local injectors' ring buffer + samples = AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; + _localInjectorsStream.writeSamples(_localMixBuffer, AudioConstants::NETWORK_FRAME_SAMPLES_STEREO); + } + + _localSamplesAvailable.fetch_add(samples, std::memory_order_release); + samplesNeeded -= samples; + } } - int samplesNeeded = std::numeric_limits::max(); - while (samplesNeeded > 0) { - if (!doSynchronously) { - // unlock between every write to allow device switching - localAudioLock->unlock(); - localAudioLock->lock(); + bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { + // check the flag for injectors before attempting to lock + if (!_localInjectorsAvailable.load(std::memory_order_acquire)) { + return false; } - // in case of a device switch, consider bufferCapacity volatile across iterations - if (_outputPeriod == 0) { - return; - } + // lock the injectors + Lock lock(_injectorsMutex); - int bufferCapacity = _localInjectorsStream.getSampleCapacity(); - int maxOutputSamples = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * AudioConstants::STEREO; - if (_localToOutputResampler) { - maxOutputSamples = _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL) * - AudioConstants::STEREO; - } + QVector injectorsToRemove; - samplesNeeded = bufferCapacity - _localSamplesAvailable.load(std::memory_order_relaxed); - if (samplesNeeded < maxOutputSamples) { - // avoid overwriting the buffer to prevent losing frames - break; - } + memset(mixBuffer, 0, AudioConstants::NETWORK_FRAME_SAMPLES_STEREO * sizeof(float)); - // get a network frame of local injectors' audio - if (!mixLocalAudioInjectors(_localMixBuffer)) { - break; - } + for (const AudioInjectorPointer& injector : _activeLocalAudioInjectors) { + // the lock guarantees that injectorBuffer, if found, is invariant + AudioInjectorLocalBuffer* injectorBuffer = injector->getLocalBuffer(); + if (injectorBuffer) { + static const int HRTF_DATASET_INDEX = 1; - // reverb - if (_reverb) { - _localReverb.render(_localMixBuffer, _localMixBuffer, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - } + int numChannels = injector->isAmbisonic() + ? AudioConstants::AMBISONIC + : (injector->isStereo() ? AudioConstants::STEREO : AudioConstants::MONO); + size_t bytesToRead = numChannels * AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; - int samples; - if (_localToOutputResampler) { - // resample to output sample rate - int frames = _localToOutputResampler->render(_localMixBuffer, _localOutputMixBuffer, - AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + // get one frame from the injector + memset(_localScratchBuffer, 0, bytesToRead); + if (0 < injectorBuffer->readData((char*)_localScratchBuffer, bytesToRead)) { + if (injector->isAmbisonic()) { + // no distance attenuation + float gain = injector->getVolume(); - // write to local injectors' ring buffer - samples = frames * AudioConstants::STEREO; - _localInjectorsStream.writeSamples(_localOutputMixBuffer, samples); + // + // Calculate the soundfield orientation relative to the listener. + // Injector orientation can be used to align a recording to our world coordinates. + // + glm::quat relativeOrientation = injector->getOrientation() * glm::inverse(_orientationGetter()); - } else { - // write to local injectors' ring buffer - samples = AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; - _localInjectorsStream.writeSamples(_localMixBuffer, AudioConstants::NETWORK_FRAME_SAMPLES_STEREO); - } + // convert from Y-up (OpenGL) to Z-up (Ambisonic) coordinate system + float qw = relativeOrientation.w; + float qx = -relativeOrientation.z; + float qy = -relativeOrientation.x; + float qz = relativeOrientation.y; - _localSamplesAvailable.fetch_add(samples, std::memory_order_release); - samplesNeeded -= samples; - } -} + // Ambisonic gets spatialized into mixBuffer + injector->getLocalFOA().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, qw, qx, qy, qz, gain, + AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); -bool AudioClient::mixLocalAudioInjectors(float* mixBuffer) { - // check the flag for injectors before attempting to lock - if (!_localInjectorsAvailable.load(std::memory_order_acquire)) { - return false; - } + } else if (injector->isStereo()) { + // stereo gets directly mixed into mixBuffer + float gain = injector->getVolume(); + for (int i = 0; i < AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; i++) { + mixBuffer[i] += convertToFloat(_localScratchBuffer[i]) * gain; + } - // lock the injectors - Lock lock(_injectorsMutex); + } else { + // calculate distance, gain and azimuth for hrtf + glm::vec3 relativePosition = injector->getPosition() - _positionGetter(); + float distance = glm::max(glm::length(relativePosition), EPSILON); + float gain = gainForSource(distance, injector->getVolume()); + float azimuth = azimuthForSource(relativePosition); - QVector injectorsToRemove; - - memset(mixBuffer, 0, AudioConstants::NETWORK_FRAME_SAMPLES_STEREO * sizeof(float)); - - for (const AudioInjectorPointer& injector : _activeLocalAudioInjectors) { - // the lock guarantees that injectorBuffer, if found, is invariant - AudioInjectorLocalBuffer* injectorBuffer = injector->getLocalBuffer(); - if (injectorBuffer) { - static const int HRTF_DATASET_INDEX = 1; - - int numChannels = injector->isAmbisonic() ? AudioConstants::AMBISONIC - : (injector->isStereo() ? AudioConstants::STEREO : AudioConstants::MONO); - size_t bytesToRead = numChannels * AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL; - - // get one frame from the injector - memset(_localScratchBuffer, 0, bytesToRead); - if (0 < injectorBuffer->readData((char*)_localScratchBuffer, bytesToRead)) { - if (injector->isAmbisonic()) { - // no distance attenuation - float gain = injector->getVolume(); - - // - // Calculate the soundfield orientation relative to the listener. - // Injector orientation can be used to align a recording to our world coordinates. - // - glm::quat relativeOrientation = injector->getOrientation() * glm::inverse(_orientationGetter()); - - // convert from Y-up (OpenGL) to Z-up (Ambisonic) coordinate system - float qw = relativeOrientation.w; - float qx = -relativeOrientation.z; - float qy = -relativeOrientation.x; - float qz = relativeOrientation.y; - - // Ambisonic gets spatialized into mixBuffer - injector->getLocalFOA().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, qw, qx, qy, qz, gain, - AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - - } else if (injector->isStereo()) { - // stereo gets directly mixed into mixBuffer - float gain = injector->getVolume(); - for (int i = 0; i < AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; i++) { - mixBuffer[i] += convertToFloat(_localScratchBuffer[i]) * gain; + // mono gets spatialized into mixBuffer + injector->getLocalHRTF().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, azimuth, distance, + gain, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); } } else { - // calculate distance, gain and azimuth for hrtf - glm::vec3 relativePosition = injector->getPosition() - _positionGetter(); - float distance = glm::max(glm::length(relativePosition), EPSILON); - float gain = gainForSource(distance, injector->getVolume()); - float azimuth = azimuthForSource(relativePosition); - - // mono gets spatialized into mixBuffer - injector->getLocalHRTF().render(_localScratchBuffer, mixBuffer, HRTF_DATASET_INDEX, azimuth, distance, gain, - AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + qCDebug(audioclient) << "injector has no more data, marking finished for removal"; + injector->finishLocalInjection(); + injectorsToRemove.append(injector); } } else { - qCDebug(audioclient) << "injector has no more data, marking finished for removal"; + qCDebug(audioclient) << "injector has no local buffer, marking as finished for removal"; injector->finishLocalInjection(); injectorsToRemove.append(injector); } - - } else { - qCDebug(audioclient) << "injector has no local buffer, marking as finished for removal"; - injector->finishLocalInjection(); - injectorsToRemove.append(injector); - } - } - - for (const AudioInjectorPointer& injector : injectorsToRemove) { - qCDebug(audioclient) << "removing injector"; - _activeLocalAudioInjectors.removeOne(injector); - } - - // update the flag - _localInjectorsAvailable.exchange(!_activeLocalAudioInjectors.empty(), std::memory_order_release); - - return true; -} - -void AudioClient::processReceivedSamples(const QByteArray& decodedBuffer, QByteArray& outputBuffer) { - const int16_t* decodedSamples = reinterpret_cast(decodedBuffer.data()); - assert(decodedBuffer.size() == AudioConstants::NETWORK_FRAME_BYTES_STEREO); - - outputBuffer.resize(_outputFrameSize * AudioConstants::SAMPLE_SIZE); - int16_t* outputSamples = reinterpret_cast(outputBuffer.data()); - - bool hasReverb = _reverb || _receivedAudioStream.hasReverb(); - - // apply stereo reverb - if (hasReverb) { - updateReverbOptions(); - int16_t* reverbSamples = _networkToOutputResampler ? _networkScratchBuffer : outputSamples; - _listenerReverb.render(decodedSamples, reverbSamples, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - } - - // resample to output sample rate - if (_networkToOutputResampler) { - const int16_t* inputSamples = hasReverb ? _networkScratchBuffer : decodedSamples; - _networkToOutputResampler->render(inputSamples, outputSamples, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); - } - - // if no transformations were applied, we still need to copy the buffer - if (!hasReverb && !_networkToOutputResampler) { - memcpy(outputSamples, decodedSamples, decodedBuffer.size()); - } -} - -void AudioClient::sendMuteEnvironmentPacket() { - auto nodeList = DependencyManager::get(); - - int dataSize = sizeof(glm::vec3) + sizeof(float); - - auto mutePacket = NLPacket::create(PacketType::MuteEnvironment, dataSize); - - const float MUTE_RADIUS = 50; - - glm::vec3 currentSourcePosition = _positionGetter(); - - mutePacket->writePrimitive(currentSourcePosition); - mutePacket->writePrimitive(MUTE_RADIUS); - - // grab our audio mixer from the NodeList, if it exists - SharedNodePointer audioMixer = nodeList->soloNodeOfType(NodeType::AudioMixer); - - if (audioMixer) { - // send off this mute packet - nodeList->sendPacket(std::move(mutePacket), *audioMixer); - } -} - -void AudioClient::setMuted(bool muted, bool emitSignal) { - if (_muted != muted) { - _muted = muted; - if (emitSignal) { - emit muteToggled(_muted); - } - } -} - -void AudioClient::setNoiseReduction(bool enable, bool emitSignal) { - if (_isNoiseGateEnabled != enable) { - _isNoiseGateEnabled = enable; - if (emitSignal) { - emit noiseReductionChanged(_isNoiseGateEnabled); - } - } -} - -bool AudioClient::setIsStereoInput(bool isStereoInput) { - bool stereoInputChanged = false; - if (isStereoInput != _isStereoInput && _inputDeviceInfo.supportedChannelCounts().contains(2)) { - _isStereoInput = isStereoInput; - stereoInputChanged = true; - - if (_isStereoInput) { - _desiredInputFormat.setChannelCount(2); - } else { - _desiredInputFormat.setChannelCount(1); } - // restart the codec - if (_codec) { - if (_encoder) { - _codec->releaseEncoder(_encoder); - } - _encoder = _codec->createEncoder(AudioConstants::SAMPLE_RATE, - _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); + for (const AudioInjectorPointer& injector : injectorsToRemove) { + qCDebug(audioclient) << "removing injector"; + _activeLocalAudioInjectors.removeOne(injector); } - qCDebug(audioclient) << "Reset Codec:" << _selectedCodecName << "isStereoInput:" << _isStereoInput; - // restart the input device - switchInputToAudioDevice(_inputDeviceInfo); - - emit isStereoInputChanged(_isStereoInput); - } - - return stereoInputChanged; -} - -bool AudioClient::outputLocalInjector(const AudioInjectorPointer& injector) { - AudioInjectorLocalBuffer* injectorBuffer = injector->getLocalBuffer(); - if (injectorBuffer) { - // local injectors are on the AudioInjectorsThread, so we must guard access - Lock lock(_injectorsMutex); - if (!_activeLocalAudioInjectors.contains(injector)) { - qCDebug(audioclient) << "adding new injector"; - _activeLocalAudioInjectors.append(injector); - // move local buffer to the LocalAudioThread to avoid dataraces with AudioInjector (like stop()) - injectorBuffer->setParent(nullptr); - - // update the flag - _localInjectorsAvailable.exchange(true, std::memory_order_release); - } else { - qCDebug(audioclient) << "injector exists in active list already"; - } + // update the flag + _localInjectorsAvailable.exchange(!_activeLocalAudioInjectors.empty(), std::memory_order_release); return true; - - } else { - // no local buffer - return false; } -} -void AudioClient::outputFormatChanged() { - _outputFrameSize = (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * OUTPUT_CHANNEL_COUNT * _outputFormat.sampleRate()) / - _desiredOutputFormat.sampleRate(); - _receivedAudioStream.outputFormatChanged(_outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); -} + void AudioClient::processReceivedSamples(const QByteArray& decodedBuffer, QByteArray& outputBuffer) { + const int16_t* decodedSamples = reinterpret_cast(decodedBuffer.data()); + assert(decodedBuffer.size() == AudioConstants::NETWORK_FRAME_BYTES_STEREO); -bool AudioClient::switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInfo, bool isShutdownRequest) { - Q_ASSERT_X(QThread::currentThread() == thread(), Q_FUNC_INFO, "Function invoked on wrong thread"); + outputBuffer.resize(_outputFrameSize * AudioConstants::SAMPLE_SIZE); + int16_t* outputSamples = reinterpret_cast(outputBuffer.data()); - qCDebug(audioclient) << __FUNCTION__ << "inputDeviceInfo: [" << inputDeviceInfo.deviceName() << "]"; - bool supportedFormat = false; + bool hasReverb = _reverb || _receivedAudioStream.hasReverb(); - // NOTE: device start() uses the Qt internal device list - Lock lock(_deviceMutex); + // apply stereo reverb + if (hasReverb) { + updateReverbOptions(); + int16_t* reverbSamples = _networkToOutputResampler ? _networkScratchBuffer : outputSamples; + _listenerReverb.render(decodedSamples, reverbSamples, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + } + + // resample to output sample rate + if (_networkToOutputResampler) { + const int16_t* inputSamples = hasReverb ? _networkScratchBuffer : decodedSamples; + _networkToOutputResampler->render(inputSamples, outputSamples, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL); + } + + // if no transformations were applied, we still need to copy the buffer + if (!hasReverb && !_networkToOutputResampler) { + memcpy(outputSamples, decodedSamples, decodedBuffer.size()); + } + } + + void AudioClient::sendMuteEnvironmentPacket() { + auto nodeList = DependencyManager::get(); + + int dataSize = sizeof(glm::vec3) + sizeof(float); + + auto mutePacket = NLPacket::create(PacketType::MuteEnvironment, dataSize); + + const float MUTE_RADIUS = 50; + + glm::vec3 currentSourcePosition = _positionGetter(); + + mutePacket->writePrimitive(currentSourcePosition); + mutePacket->writePrimitive(MUTE_RADIUS); + + // grab our audio mixer from the NodeList, if it exists + SharedNodePointer audioMixer = nodeList->soloNodeOfType(NodeType::AudioMixer); + + if (audioMixer) { + // send off this mute packet + nodeList->sendPacket(std::move(mutePacket), *audioMixer); + } + } + + void AudioClient::setMuted(bool muted, bool emitSignal) { + if (_muted != muted) { + _muted = muted; + if (emitSignal) { + emit muteToggled(_muted); + } + } + } + + void AudioClient::setNoiseReduction(bool enable, bool emitSignal) { + if (_isNoiseGateEnabled != enable) { + _isNoiseGateEnabled = enable; + if (emitSignal) { + emit noiseReductionChanged(_isNoiseGateEnabled); + } + } + } + + bool AudioClient::setIsStereoInput(bool isStereoInput) { + bool stereoInputChanged = false; + if (isStereoInput != _isStereoInput && _inputDeviceInfo.supportedChannelCounts().contains(2)) { + _isStereoInput = isStereoInput; + stereoInputChanged = true; + + if (_isStereoInput) { + _desiredInputFormat.setChannelCount(2); + } else { + _desiredInputFormat.setChannelCount(1); + } + + // restart the codec + if (_codec) { + if (_encoder) { + _codec->releaseEncoder(_encoder); + } + _encoder = _codec->createEncoder(AudioConstants::SAMPLE_RATE, + _isStereoInput ? AudioConstants::STEREO : AudioConstants::MONO); + } + qCDebug(audioclient) << "Reset Codec:" << _selectedCodecName << "isStereoInput:" << _isStereoInput; + + // restart the input device + switchInputToAudioDevice(_inputDeviceInfo); + + emit isStereoInputChanged(_isStereoInput); + } + + return stereoInputChanged; + } + + bool AudioClient::outputLocalInjector(const AudioInjectorPointer& injector) { + AudioInjectorLocalBuffer* injectorBuffer = injector->getLocalBuffer(); + if (injectorBuffer) { + // local injectors are on the AudioInjectorsThread, so we must guard access + Lock lock(_injectorsMutex); + if (!_activeLocalAudioInjectors.contains(injector)) { + qCDebug(audioclient) << "adding new injector"; + _activeLocalAudioInjectors.append(injector); + // move local buffer to the LocalAudioThread to avoid dataraces with AudioInjector (like stop()) + injectorBuffer->setParent(nullptr); + + // update the flag + _localInjectorsAvailable.exchange(true, std::memory_order_release); + } else { + qCDebug(audioclient) << "injector exists in active list already"; + } + + return true; + + } else { + // no local buffer + return false; + } + } + + void AudioClient::outputFormatChanged() { + _outputFrameSize = + (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * OUTPUT_CHANNEL_COUNT * _outputFormat.sampleRate()) / + _desiredOutputFormat.sampleRate(); + _receivedAudioStream.outputFormatChanged(_outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); + } + + bool AudioClient::switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInfo, bool isShutdownRequest) { + Q_ASSERT_X(QThread::currentThread() == thread(), Q_FUNC_INFO, "Function invoked on wrong thread"); + + qCDebug(audioclient) << __FUNCTION__ << "inputDeviceInfo: [" << inputDeviceInfo.deviceName() << "]"; + bool supportedFormat = false; + + // NOTE: device start() uses the Qt internal device list + Lock lock(_deviceMutex); #if defined(Q_OS_ANDROID) - _shouldRestartInputSetup = false; // avoid a double call to _audioInput->start() from audioInputStateChanged + _shouldRestartInputSetup = false; // avoid a double call to _audioInput->start() from audioInputStateChanged #endif - // cleanup any previously initialized device - if (_audioInput) { - // The call to stop() causes _inputDevice to be destructed. - // That in turn causes it to be disconnected (see for example - // http://stackoverflow.com/questions/9264750/qt-signals-and-slots-object-disconnect). - _audioInput->stop(); - _inputDevice = NULL; + // cleanup any previously initialized device + if (_audioInput) { + // The call to stop() causes _inputDevice to be destructed. + // That in turn causes it to be disconnected (see for example + // http://stackoverflow.com/questions/9264750/qt-signals-and-slots-object-disconnect). + _audioInput->stop(); + _inputDevice = NULL; - _audioInput->deleteLater(); - _audioInput = NULL; - _numInputCallbackBytes = 0; + _audioInput->deleteLater(); + _audioInput = NULL; + _numInputCallbackBytes = 0; - _inputDeviceInfo = QAudioDeviceInfo(); - } + _inputDeviceInfo = QAudioDeviceInfo(); + } - if (_dummyAudioInput) { - _dummyAudioInput->stop(); + if (_dummyAudioInput) { + _dummyAudioInput->stop(); - _dummyAudioInput->deleteLater(); - _dummyAudioInput = NULL; - } + _dummyAudioInput->deleteLater(); + _dummyAudioInput = NULL; + } - if (_inputToNetworkResampler) { - // if we were using an input to network resampler, delete it here - delete _inputToNetworkResampler; - _inputToNetworkResampler = NULL; - } + if (_inputToNetworkResampler) { + // if we were using an input to network resampler, delete it here + delete _inputToNetworkResampler; + _inputToNetworkResampler = NULL; + } - if (_audioGate) { - delete _audioGate; - _audioGate = nullptr; - } + if (_audioGate) { + delete _audioGate; + _audioGate = nullptr; + } - if (isShutdownRequest) { - qCDebug(audioclient) << "The audio input device has shut down."; - return true; - } + if (isShutdownRequest) { + qCDebug(audioclient) << "The audio input device has shut down."; + return true; + } - if (!inputDeviceInfo.isNull()) { - qCDebug(audioclient) << "The audio input device " << inputDeviceInfo.deviceName() << "is available."; - _inputDeviceInfo = inputDeviceInfo; - emit deviceChanged(QAudio::AudioInput, inputDeviceInfo); + if (!inputDeviceInfo.isNull()) { + qCDebug(audioclient) << "The audio input device " << inputDeviceInfo.deviceName() << "is available."; + _inputDeviceInfo = inputDeviceInfo; + emit deviceChanged(QAudio::AudioInput, inputDeviceInfo); - if (adjustedFormatForAudioDevice(inputDeviceInfo, _desiredInputFormat, _inputFormat)) { - qCDebug(audioclient) << "The format to be used for audio input is" << _inputFormat; + if (adjustedFormatForAudioDevice(inputDeviceInfo, _desiredInputFormat, _inputFormat)) { + qCDebug(audioclient) << "The format to be used for audio input is" << _inputFormat; - // we've got the best we can get for input - // if required, setup a resampler for this input to our desired network format - if (_inputFormat != _desiredInputFormat && _inputFormat.sampleRate() != _desiredInputFormat.sampleRate()) { - qCDebug(audioclient) << "Attemping to create a resampler for input format to network format."; + // we've got the best we can get for input + // if required, setup a resampler for this input to our desired network format + if (_inputFormat != _desiredInputFormat && _inputFormat.sampleRate() != _desiredInputFormat.sampleRate()) { + qCDebug(audioclient) << "Attemping to create a resampler for input format to network format."; - assert(_inputFormat.sampleSize() == 16); - assert(_desiredInputFormat.sampleSize() == 16); - int channelCount = (_inputFormat.channelCount() == 2 && _desiredInputFormat.channelCount() == 2) ? 2 : 1; + assert(_inputFormat.sampleSize() == 16); + assert(_desiredInputFormat.sampleSize() == 16); + int channelCount = (_inputFormat.channelCount() == 2 && _desiredInputFormat.channelCount() == 2) ? 2 : 1; - _inputToNetworkResampler = - new AudioSRC(_inputFormat.sampleRate(), _desiredInputFormat.sampleRate(), channelCount); + _inputToNetworkResampler = + new AudioSRC(_inputFormat.sampleRate(), _desiredInputFormat.sampleRate(), channelCount); - } else { - qCDebug(audioclient) << "No resampling required for audio input to match desired network format."; + } else { + qCDebug(audioclient) << "No resampling required for audio input to match desired network format."; + } + + // the audio gate runs after the resampler + _audioGate = new AudioGate(_desiredInputFormat.sampleRate(), _desiredInputFormat.channelCount()); + qCDebug(audioclient) << "Noise gate created with" << _desiredInputFormat.channelCount() << "channels."; + + // if the user wants stereo but this device can't provide then bail + if (!_isStereoInput || _inputFormat.channelCount() == 2) { + _audioInput = new QAudioInput(inputDeviceInfo, _inputFormat, this); + _numInputCallbackBytes = calculateNumberOfInputCallbackBytes(_inputFormat); + _audioInput->setBufferSize(_numInputCallbackBytes); + // different audio input devices may have different volumes + emit inputVolumeChanged(_audioInput->volume()); + + // how do we want to handle input working, but output not working? + int numFrameSamples = calculateNumberOfFrameSamples(_numInputCallbackBytes); + _inputRingBuffer.resizeForFrameSize(numFrameSamples); + +#if defined(Q_OS_ANDROID) + if (_audioInput) { + _shouldRestartInputSetup = true; + connect(_audioInput, &QAudioInput::stateChanged, this, &AudioClient::audioInputStateChanged); + } +#endif + _inputDevice = _audioInput->start(); + + if (_inputDevice) { + connect(_inputDevice, SIGNAL(readyRead()), this, SLOT(handleMicAudioInput())); + supportedFormat = true; + } else { + qCDebug(audioclient) << "Error starting audio input -" << _audioInput->error(); + _audioInput->deleteLater(); + _audioInput = NULL; + } + } } + } + + // If there is no working input device, use the dummy input device. + // It generates audio callbacks on a timer to simulate a mic stream of silent packets. + // This enables clients without a mic to still receive an audio stream from the mixer. + if (!_audioInput) { + qCDebug(audioclient) << "Audio input device is not available, using dummy input."; + _inputDeviceInfo = QAudioDeviceInfo(); + emit deviceChanged(QAudio::AudioInput, _inputDeviceInfo); + + _inputFormat = _desiredInputFormat; + qCDebug(audioclient) << "The format to be used for audio input is" << _inputFormat; + qCDebug(audioclient) << "No resampling required for audio input to match desired network format."; - // the audio gate runs after the resampler _audioGate = new AudioGate(_desiredInputFormat.sampleRate(), _desiredInputFormat.channelCount()); qCDebug(audioclient) << "Noise gate created with" << _desiredInputFormat.channelCount() << "channels."; - // if the user wants stereo but this device can't provide then bail - if (!_isStereoInput || _inputFormat.channelCount() == 2) { - _audioInput = new QAudioInput(inputDeviceInfo, _inputFormat, this); - _numInputCallbackBytes = calculateNumberOfInputCallbackBytes(_inputFormat); - _audioInput->setBufferSize(_numInputCallbackBytes); - // different audio input devices may have different volumes - emit inputVolumeChanged(_audioInput->volume()); - - // how do we want to handle input working, but output not working? - int numFrameSamples = calculateNumberOfFrameSamples(_numInputCallbackBytes); - _inputRingBuffer.resizeForFrameSize(numFrameSamples); - -#if defined(Q_OS_ANDROID) - if (_audioInput) { - _shouldRestartInputSetup = true; - connect(_audioInput, &QAudioInput::stateChanged, this, &AudioClient::audioInputStateChanged); - } -#endif - _inputDevice = _audioInput->start(); - - if (_inputDevice) { - connect(_inputDevice, SIGNAL(readyRead()), this, SLOT(handleMicAudioInput())); - supportedFormat = true; - } else { - qCDebug(audioclient) << "Error starting audio input -" << _audioInput->error(); - _audioInput->deleteLater(); - _audioInput = NULL; - } - } - } - } - - // If there is no working input device, use the dummy input device. - // It generates audio callbacks on a timer to simulate a mic stream of silent packets. - // This enables clients without a mic to still receive an audio stream from the mixer. - if (!_audioInput) { - qCDebug(audioclient) << "Audio input device is not available, using dummy input."; - _inputDeviceInfo = QAudioDeviceInfo(); - emit deviceChanged(QAudio::AudioInput, _inputDeviceInfo); - - _inputFormat = _desiredInputFormat; - qCDebug(audioclient) << "The format to be used for audio input is" << _inputFormat; - qCDebug(audioclient) << "No resampling required for audio input to match desired network format."; - - _audioGate = new AudioGate(_desiredInputFormat.sampleRate(), _desiredInputFormat.channelCount()); - qCDebug(audioclient) << "Noise gate created with" << _desiredInputFormat.channelCount() << "channels."; - - // generate audio callbacks at the network sample rate - _dummyAudioInput = new QTimer(this); - connect(_dummyAudioInput, SIGNAL(timeout()), this, SLOT(handleDummyAudioInput())); - _dummyAudioInput->start((int)(AudioConstants::NETWORK_FRAME_MSECS + 0.5f)); - } - - return supportedFormat; -} - -void AudioClient::audioInputStateChanged(QAudio::State state) { -#if defined(Q_OS_ANDROID) - switch (state) { - case QAudio::StoppedState: - if (!_audioInput) { - break; - } - // Stopped on purpose - if (_shouldRestartInputSetup) { - Lock lock(_deviceMutex); - _inputDevice = _audioInput->start(); - lock.unlock(); - if (_inputDevice) { - connect(_inputDevice, SIGNAL(readyRead()), this, SLOT(handleMicAudioInput())); - } - } - break; - case QAudio::ActiveState: - break; - default: - break; - } -#endif -} - -void AudioClient::checkInputTimeout() { -#if defined(Q_OS_ANDROID) - if (_audioInput && _inputReadsSinceLastCheck < MIN_READS_TO_CONSIDER_INPUT_ALIVE) { - _audioInput->stop(); - } else { - _inputReadsSinceLastCheck = 0; - } -#endif -} - -void AudioClient::setHeadsetPluggedIn(bool pluggedIn) { -#if defined(Q_OS_ANDROID) - if (pluggedIn == !_isHeadsetPluggedIn && !_inputDeviceInfo.isNull()) { - QAndroidJniObject brand = QAndroidJniObject::getStaticObjectField("android/os/Build", "BRAND"); - // some samsung phones needs more time to shutdown the previous input device - if (brand.toString().contains("samsung", Qt::CaseInsensitive)) { - switchInputToAudioDevice(QAudioDeviceInfo(), true); - QThread::msleep(200); + // generate audio callbacks at the network sample rate + _dummyAudioInput = new QTimer(this); + connect(_dummyAudioInput, SIGNAL(timeout()), this, SLOT(handleDummyAudioInput())); + _dummyAudioInput->start((int)(AudioConstants::NETWORK_FRAME_MSECS + 0.5f)); } - Setting::Handle enableAEC(SETTING_AEC_KEY, false); - bool aecEnabled = enableAEC.get(); - - if ((pluggedIn || !aecEnabled) && _inputDeviceInfo.deviceName() != VOICE_RECOGNITION) { - switchAudioDevice(QAudio::AudioInput, VOICE_RECOGNITION); - } else if (!pluggedIn && aecEnabled && _inputDeviceInfo.deviceName() != VOICE_COMMUNICATION) { - switchAudioDevice(QAudio::AudioInput, VOICE_COMMUNICATION); - } + return supportedFormat; } - _isHeadsetPluggedIn = pluggedIn; -#endif -} -void AudioClient::outputNotify() { - int recentUnfulfilled = _audioOutputIODevice.getRecentUnfulfilledReads(); - if (recentUnfulfilled > 0) { - qCDebug(audioclient, "Starve detected, %d new unfulfilled reads", recentUnfulfilled); - - if (_outputStarveDetectionEnabled.get()) { - quint64 now = usecTimestampNow() / 1000; - int dt = (int)(now - _outputStarveDetectionStartTimeMsec); - if (dt > STARVE_DETECTION_PERIOD) { - _outputStarveDetectionStartTimeMsec = now; - _outputStarveDetectionCount = 0; - } else { - _outputStarveDetectionCount += recentUnfulfilled; - if (_outputStarveDetectionCount > STARVE_DETECTION_THRESHOLD) { - int oldOutputBufferSizeFrames = _sessionOutputBufferSizeFrames; - int newOutputBufferSizeFrames = setOutputBufferSize(oldOutputBufferSizeFrames + 1, false); - - if (newOutputBufferSizeFrames > oldOutputBufferSizeFrames) { - qCDebug(audioclient, "Starve threshold surpassed (%d starves in %d ms)", _outputStarveDetectionCount, - dt); + void AudioClient::audioInputStateChanged(QAudio::State state) { +#if defined(Q_OS_ANDROID) + switch (state) { + case QAudio::StoppedState: + if (!_audioInput) { + break; + } + // Stopped on purpose + if (_shouldRestartInputSetup) { + Lock lock(_deviceMutex); + _inputDevice = _audioInput->start(); + lock.unlock(); + if (_inputDevice) { + connect(_inputDevice, SIGNAL(readyRead()), this, SLOT(handleMicAudioInput())); } + } + break; + case QAudio::ActiveState: + break; + default: + break; + } +#endif + } + void AudioClient::checkInputTimeout() { +#if defined(Q_OS_ANDROID) + if (_audioInput && _inputReadsSinceLastCheck < MIN_READS_TO_CONSIDER_INPUT_ALIVE) { + _audioInput->stop(); + } else { + _inputReadsSinceLastCheck = 0; + } +#endif + } + + void AudioClient::setHeadsetPluggedIn(bool pluggedIn) { +#if defined(Q_OS_ANDROID) + if (pluggedIn == !_isHeadsetPluggedIn && !_inputDeviceInfo.isNull()) { + QAndroidJniObject brand = QAndroidJniObject::getStaticObjectField("android/os/Build", "BRAND"); + // some samsung phones needs more time to shutdown the previous input device + if (brand.toString().contains("samsung", Qt::CaseInsensitive)) { + switchInputToAudioDevice(QAudioDeviceInfo(), true); + QThread::msleep(200); + } + + Setting::Handle enableAEC(SETTING_AEC_KEY, false); + bool aecEnabled = enableAEC.get(); + + if ((pluggedIn || !aecEnabled) && _inputDeviceInfo.deviceName() != VOICE_RECOGNITION) { + switchAudioDevice(QAudio::AudioInput, VOICE_RECOGNITION); + } else if (!pluggedIn && aecEnabled && _inputDeviceInfo.deviceName() != VOICE_COMMUNICATION) { + switchAudioDevice(QAudio::AudioInput, VOICE_COMMUNICATION); + } + } + _isHeadsetPluggedIn = pluggedIn; +#endif + } + + void AudioClient::outputNotify() { + int recentUnfulfilled = _audioOutputIODevice.getRecentUnfulfilledReads(); + if (recentUnfulfilled > 0) { + qCDebug(audioclient, "Starve detected, %d new unfulfilled reads", recentUnfulfilled); + + if (_outputStarveDetectionEnabled.get()) { + quint64 now = usecTimestampNow() / 1000; + int dt = (int)(now - _outputStarveDetectionStartTimeMsec); + if (dt > STARVE_DETECTION_PERIOD) { _outputStarveDetectionStartTimeMsec = now; _outputStarveDetectionCount = 0; + } else { + _outputStarveDetectionCount += recentUnfulfilled; + if (_outputStarveDetectionCount > STARVE_DETECTION_THRESHOLD) { + int oldOutputBufferSizeFrames = _sessionOutputBufferSizeFrames; + int newOutputBufferSizeFrames = setOutputBufferSize(oldOutputBufferSizeFrames + 1, false); + + if (newOutputBufferSizeFrames > oldOutputBufferSizeFrames) { + qCDebug(audioclient, "Starve threshold surpassed (%d starves in %d ms)", + _outputStarveDetectionCount, dt); + } + + _outputStarveDetectionStartTimeMsec = now; + _outputStarveDetectionCount = 0; + } } } } } -} -bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceInfo, bool isShutdownRequest) { - Q_ASSERT_X(QThread::currentThread() == thread(), Q_FUNC_INFO, "Function invoked on wrong thread"); + bool AudioClient::switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceInfo, bool isShutdownRequest) { + Q_ASSERT_X(QThread::currentThread() == thread(), Q_FUNC_INFO, "Function invoked on wrong thread"); - qCDebug(audioclient) << "AudioClient::switchOutputToAudioDevice() outputDeviceInfo: [" << outputDeviceInfo.deviceName() - << "]"; - bool supportedFormat = false; + qCDebug(audioclient) << "AudioClient::switchOutputToAudioDevice() outputDeviceInfo: [" << outputDeviceInfo.deviceName() + << "]"; + bool supportedFormat = false; - // NOTE: device start() uses the Qt internal device list - Lock lock(_deviceMutex); + // NOTE: device start() uses the Qt internal device list + Lock lock(_deviceMutex); - Lock localAudioLock(_localAudioMutex); - _localSamplesAvailable.exchange(0, std::memory_order_release); + Lock localAudioLock(_localAudioMutex); + _localSamplesAvailable.exchange(0, std::memory_order_release); - // cleanup any previously initialized device - if (_audioOutput) { - _audioOutputIODevice.close(); - _audioOutput->stop(); + // cleanup any previously initialized device + if (_audioOutput) { + _audioOutputIODevice.close(); + _audioOutput->stop(); - //must be deleted in next eventloop cycle when its called from notify() - _audioOutput->deleteLater(); - _audioOutput = NULL; + //must be deleted in next eventloop cycle when its called from notify() + _audioOutput->deleteLater(); + _audioOutput = NULL; - _loopbackOutputDevice = NULL; - //must be deleted in next eventloop cycle when its called from notify() - _loopbackAudioOutput->deleteLater(); - _loopbackAudioOutput = NULL; + _loopbackOutputDevice = NULL; + //must be deleted in next eventloop cycle when its called from notify() + _loopbackAudioOutput->deleteLater(); + _loopbackAudioOutput = NULL; - delete[] _outputMixBuffer; - _outputMixBuffer = NULL; + delete[] _outputMixBuffer; + _outputMixBuffer = NULL; - delete[] _outputScratchBuffer; - _outputScratchBuffer = NULL; + delete[] _outputScratchBuffer; + _outputScratchBuffer = NULL; - delete[] _localOutputMixBuffer; - _localOutputMixBuffer = NULL; + delete[] _localOutputMixBuffer; + _localOutputMixBuffer = NULL; - _outputDeviceInfo = QAudioDeviceInfo(); - } + _outputDeviceInfo = QAudioDeviceInfo(); + } - if (_networkToOutputResampler) { - // if we were using an input to network resampler, delete it here - delete _networkToOutputResampler; - _networkToOutputResampler = NULL; + if (_networkToOutputResampler) { + // if we were using an input to network resampler, delete it here + delete _networkToOutputResampler; + _networkToOutputResampler = NULL; - delete _localToOutputResampler; - _localToOutputResampler = NULL; - } + delete _localToOutputResampler; + _localToOutputResampler = NULL; + } - if (isShutdownRequest) { - qCDebug(audioclient) << "The audio output device has shut down."; - return true; - } + if (isShutdownRequest) { + qCDebug(audioclient) << "The audio output device has shut down."; + return true; + } - if (!outputDeviceInfo.isNull()) { - qCDebug(audioclient) << "The audio output device " << outputDeviceInfo.deviceName() << "is available."; - _outputDeviceInfo = outputDeviceInfo; - emit deviceChanged(QAudio::AudioOutput, outputDeviceInfo); + if (!outputDeviceInfo.isNull()) { + qCDebug(audioclient) << "The audio output device " << outputDeviceInfo.deviceName() << "is available."; + _outputDeviceInfo = outputDeviceInfo; + emit deviceChanged(QAudio::AudioOutput, outputDeviceInfo); - if (adjustedFormatForAudioDevice(outputDeviceInfo, _desiredOutputFormat, _outputFormat)) { - qCDebug(audioclient) << "The format to be used for audio output is" << _outputFormat; + if (adjustedFormatForAudioDevice(outputDeviceInfo, _desiredOutputFormat, _outputFormat)) { + qCDebug(audioclient) << "The format to be used for audio output is" << _outputFormat; - // we've got the best we can get for input - // if required, setup a resampler for this input to our desired network format - if (_desiredOutputFormat != _outputFormat && _desiredOutputFormat.sampleRate() != _outputFormat.sampleRate()) { - qCDebug(audioclient) << "Attemping to create a resampler for network format to output format."; + // we've got the best we can get for input + // if required, setup a resampler for this input to our desired network format + if (_desiredOutputFormat != _outputFormat && _desiredOutputFormat.sampleRate() != _outputFormat.sampleRate()) { + qCDebug(audioclient) << "Attemping to create a resampler for network format to output format."; - assert(_desiredOutputFormat.sampleSize() == 16); - assert(_outputFormat.sampleSize() == 16); + assert(_desiredOutputFormat.sampleSize() == 16); + assert(_outputFormat.sampleSize() == 16); - _networkToOutputResampler = - new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); - _localToOutputResampler = - new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); + _networkToOutputResampler = + new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); + _localToOutputResampler = + new AudioSRC(_desiredOutputFormat.sampleRate(), _outputFormat.sampleRate(), OUTPUT_CHANNEL_COUNT); - } else { - qCDebug(audioclient) << "No resampling required for network output to match actual output format."; - } - - outputFormatChanged(); - - // setup our general output device for audio-mixer audio - _audioOutput = new QAudioOutput(outputDeviceInfo, _outputFormat, this); - - int deviceChannelCount = _outputFormat.channelCount(); - int frameSize = - (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * deviceChannelCount * _outputFormat.sampleRate()) / - _desiredOutputFormat.sampleRate(); - int requestedSize = _sessionOutputBufferSizeFrames * frameSize * AudioConstants::SAMPLE_SIZE; - _audioOutput->setBufferSize(requestedSize); - - // initialize mix buffers on the _audioOutput thread to avoid races - connect(_audioOutput, &QAudioOutput::stateChanged, [&, frameSize, requestedSize](QAudio::State state) { - if (state == QAudio::ActiveState) { - // restrict device callback to _outputPeriod samples - _outputPeriod = _audioOutput->periodSize() / AudioConstants::SAMPLE_SIZE; - // device callback may exceed reported period, so double it to avoid stutter - _outputPeriod *= 2; - - _outputMixBuffer = new float[_outputPeriod]; - _outputScratchBuffer = new int16_t[_outputPeriod]; - - // size local output mix buffer based on resampled network frame size - int networkPeriod = - _localToOutputResampler - ? _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_STEREO) - : AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; - _localOutputMixBuffer = new float[networkPeriod]; - - // local period should be at least twice the output period, - // in case two device reads happen before more data can be read (worst case) - int localPeriod = _outputPeriod * 2; - // round up to an exact multiple of networkPeriod - localPeriod = ((localPeriod + networkPeriod - 1) / networkPeriod) * networkPeriod; - // this ensures lowest latency without stutter from underrun - _localInjectorsStream.resizeForFrameSize(localPeriod); - - int bufferSize = _audioOutput->bufferSize(); - int bufferSamples = bufferSize / AudioConstants::SAMPLE_SIZE; - int bufferFrames = bufferSamples / (float)frameSize; - qCDebug(audioclient) << "frame (samples):" << frameSize; - qCDebug(audioclient) << "buffer (frames):" << bufferFrames; - qCDebug(audioclient) << "buffer (samples):" << bufferSamples; - qCDebug(audioclient) << "buffer (bytes):" << bufferSize; - qCDebug(audioclient) << "requested (bytes):" << requestedSize; - qCDebug(audioclient) << "period (samples):" << _outputPeriod; - qCDebug(audioclient) << "local buffer (samples):" << localPeriod; - - disconnect(_audioOutput, &QAudioOutput::stateChanged, 0, 0); - - // unlock to avoid a deadlock with the device callback (which always succeeds this initialization) - localAudioLock.unlock(); + } else { + qCDebug(audioclient) << "No resampling required for network output to match actual output format."; } - }); - connect(_audioOutput, &QAudioOutput::notify, this, &AudioClient::outputNotify); - _audioOutputIODevice.start(); + outputFormatChanged(); - _audioOutput->start(&_audioOutputIODevice); + // setup our general output device for audio-mixer audio + _audioOutput = new QAudioOutput(outputDeviceInfo, _outputFormat, this); - // setup a loopback audio output device - _loopbackAudioOutput = new QAudioOutput(outputDeviceInfo, _outputFormat, this); + int deviceChannelCount = _outputFormat.channelCount(); + int frameSize = + (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * deviceChannelCount * _outputFormat.sampleRate()) / + _desiredOutputFormat.sampleRate(); + int requestedSize = _sessionOutputBufferSizeFrames * frameSize * AudioConstants::SAMPLE_SIZE; + _audioOutput->setBufferSize(requestedSize); - _timeSinceLastReceived.start(); + // initialize mix buffers on the _audioOutput thread to avoid races + connect(_audioOutput, &QAudioOutput::stateChanged, [&, frameSize, requestedSize](QAudio::State state) { + if (state == QAudio::ActiveState) { + // restrict device callback to _outputPeriod samples + _outputPeriod = _audioOutput->periodSize() / AudioConstants::SAMPLE_SIZE; + // device callback may exceed reported period, so double it to avoid stutter + _outputPeriod *= 2; - supportedFormat = true; + _outputMixBuffer = new float[_outputPeriod]; + _outputScratchBuffer = new int16_t[_outputPeriod]; + + // size local output mix buffer based on resampled network frame size + int networkPeriod = + _localToOutputResampler + ? _localToOutputResampler->getMaxOutput(AudioConstants::NETWORK_FRAME_SAMPLES_STEREO) + : AudioConstants::NETWORK_FRAME_SAMPLES_STEREO; + _localOutputMixBuffer = new float[networkPeriod]; + + // local period should be at least twice the output period, + // in case two device reads happen before more data can be read (worst case) + int localPeriod = _outputPeriod * 2; + // round up to an exact multiple of networkPeriod + localPeriod = ((localPeriod + networkPeriod - 1) / networkPeriod) * networkPeriod; + // this ensures lowest latency without stutter from underrun + _localInjectorsStream.resizeForFrameSize(localPeriod); + + int bufferSize = _audioOutput->bufferSize(); + int bufferSamples = bufferSize / AudioConstants::SAMPLE_SIZE; + int bufferFrames = bufferSamples / (float)frameSize; + qCDebug(audioclient) << "frame (samples):" << frameSize; + qCDebug(audioclient) << "buffer (frames):" << bufferFrames; + qCDebug(audioclient) << "buffer (samples):" << bufferSamples; + qCDebug(audioclient) << "buffer (bytes):" << bufferSize; + qCDebug(audioclient) << "requested (bytes):" << requestedSize; + qCDebug(audioclient) << "period (samples):" << _outputPeriod; + qCDebug(audioclient) << "local buffer (samples):" << localPeriod; + + disconnect(_audioOutput, &QAudioOutput::stateChanged, 0, 0); + + // unlock to avoid a deadlock with the device callback (which always succeeds this initialization) + localAudioLock.unlock(); + } + }); + connect(_audioOutput, &QAudioOutput::notify, this, &AudioClient::outputNotify); + + _audioOutputIODevice.start(); + + _audioOutput->start(&_audioOutputIODevice); + + // setup a loopback audio output device + _loopbackAudioOutput = new QAudioOutput(outputDeviceInfo, _outputFormat, this); + + _timeSinceLastReceived.start(); + + supportedFormat = true; + } } + + return supportedFormat; } - return supportedFormat; -} + int AudioClient::setOutputBufferSize(int numFrames, bool persist) { + qCDebug(audioclient) << __FUNCTION__ << "numFrames:" << numFrames << "persist:" << persist; -int AudioClient::setOutputBufferSize(int numFrames, bool persist) { - qCDebug(audioclient) << __FUNCTION__ << "numFrames:" << numFrames << "persist:" << persist; + numFrames = std::min(std::max(numFrames, MIN_BUFFER_FRAMES), MAX_BUFFER_FRAMES); + qCDebug(audioclient) << __FUNCTION__ << "clamped numFrames:" << numFrames + << "_sessionOutputBufferSizeFrames:" << _sessionOutputBufferSizeFrames; - numFrames = std::min(std::max(numFrames, MIN_BUFFER_FRAMES), MAX_BUFFER_FRAMES); - qCDebug(audioclient) << __FUNCTION__ << "clamped numFrames:" << numFrames - << "_sessionOutputBufferSizeFrames:" << _sessionOutputBufferSizeFrames; - - if (numFrames != _sessionOutputBufferSizeFrames) { - qCInfo(audioclient, "Audio output buffer set to %d frames", numFrames); - _sessionOutputBufferSizeFrames = numFrames; - if (persist) { - _outputBufferSizeFrames.set(numFrames); + if (numFrames != _sessionOutputBufferSizeFrames) { + qCInfo(audioclient, "Audio output buffer set to %d frames", numFrames); + _sessionOutputBufferSizeFrames = numFrames; + if (persist) { + _outputBufferSizeFrames.set(numFrames); + } } + return numFrames; } - return numFrames; -} -// The following constant is operating system dependent due to differences in -// the way input audio is handled. The audio input buffer size is inversely -// proportional to the accelerator ratio. + // The following constant is operating system dependent due to differences in + // the way input audio is handled. The audio input buffer size is inversely + // proportional to the accelerator ratio. #ifdef Q_OS_WIN -const float AudioClient::CALLBACK_ACCELERATOR_RATIO = IsWindows8OrGreater() ? 1.0f : 0.25f; + const float AudioClient::CALLBACK_ACCELERATOR_RATIO = IsWindows8OrGreater() ? 1.0f : 0.25f; #endif #ifdef Q_OS_MAC -const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 2.0f; + const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 2.0f; #endif #ifdef Q_OS_ANDROID -const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 0.5f; + const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 0.5f; #elif defined(Q_OS_LINUX) -const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 2.0f; + const float AudioClient::CALLBACK_ACCELERATOR_RATIO = 2.0f; #endif -int AudioClient::calculateNumberOfInputCallbackBytes(const QAudioFormat& format) const { - int numInputCallbackBytes = (int)(((AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL * format.channelCount() * - ((float)format.sampleRate() / AudioConstants::SAMPLE_RATE)) / - CALLBACK_ACCELERATOR_RATIO) + - 0.5f); + int AudioClient::calculateNumberOfInputCallbackBytes(const QAudioFormat& format) const { + int numInputCallbackBytes = (int)(((AudioConstants::NETWORK_FRAME_BYTES_PER_CHANNEL * format.channelCount() * + ((float)format.sampleRate() / AudioConstants::SAMPLE_RATE)) / + CALLBACK_ACCELERATOR_RATIO) + + 0.5f); - return numInputCallbackBytes; -} - -int AudioClient::calculateNumberOfFrameSamples(int numBytes) const { - int frameSamples = (int)(numBytes * CALLBACK_ACCELERATOR_RATIO + 0.5f) / AudioConstants::SAMPLE_SIZE; - return frameSamples; -} - -float AudioClient::azimuthForSource(const glm::vec3& relativePosition) { - glm::quat inverseOrientation = glm::inverse(_orientationGetter()); - - glm::vec3 rotatedSourcePosition = inverseOrientation * relativePosition; - - // project the rotated source position vector onto the XZ plane - rotatedSourcePosition.y = 0.0f; - - static const float SOURCE_DISTANCE_THRESHOLD = 1e-30f; - - float rotatedSourcePositionLength2 = glm::length2(rotatedSourcePosition); - if (rotatedSourcePositionLength2 > SOURCE_DISTANCE_THRESHOLD) { - // produce an oriented angle about the y-axis - glm::vec3 direction = rotatedSourcePosition * (1.0f / fastSqrtf(rotatedSourcePositionLength2)); - float angle = fastAcosf(glm::clamp(-direction.z, -1.0f, 1.0f)); // UNIT_NEG_Z is "forward" - return (direction.x < 0.0f) ? -angle : angle; - - } else { - // no azimuth if they are in same spot - return 0.0f; - } -} - -float AudioClient::gainForSource(float distance, float volume) { - // attenuation = -6dB * log2(distance) - // reference attenuation of 0dB at distance = 1.0m - float gain = volume / std::max(distance, HRTF_NEARFIELD_MIN); - - return gain; -} - -qint64 AudioClient::AudioOutputIODevice::readData(char* data, qint64 maxSize) { - // samples requested from OUTPUT_CHANNEL_COUNT - int deviceChannelCount = _audio->_outputFormat.channelCount(); - int samplesRequested = (int)(maxSize / AudioConstants::SAMPLE_SIZE) * OUTPUT_CHANNEL_COUNT / deviceChannelCount; - // restrict samplesRequested to the size of our mix/scratch buffers - samplesRequested = std::min(samplesRequested, _audio->_outputPeriod); - - int16_t* scratchBuffer = _audio->_outputScratchBuffer; - float* mixBuffer = _audio->_outputMixBuffer; - - int networkSamplesPopped; - if ((networkSamplesPopped = _receivedAudioStream.popSamples(samplesRequested, false)) > 0) { - qCDebug(audiostream, "Read %d samples from buffer (%d available, %d requested)", networkSamplesPopped, - _receivedAudioStream.getSamplesAvailable(), samplesRequested); - AudioRingBuffer::ConstIterator lastPopOutput = _receivedAudioStream.getLastPopOutput(); - lastPopOutput.readSamples(scratchBuffer, networkSamplesPopped); - for (int i = 0; i < networkSamplesPopped; i++) { - mixBuffer[i] = convertToFloat(scratchBuffer[i]); - } - samplesRequested = networkSamplesPopped; + return numInputCallbackBytes; } - int injectorSamplesPopped = 0; - { - bool append = networkSamplesPopped > 0; - // check the samples we have available locklessly; this is possible because only two functions add to the count: - // - prepareLocalAudioInjectors will only increase samples count - // - switchOutputToAudioDevice will zero samples count, - // stop the device - so that readData will exhaust the existing buffer or see a zeroed samples count, - // and start the device - which can then only see a zeroed samples count - int samplesAvailable = _audio->_localSamplesAvailable.load(std::memory_order_acquire); - - // if we do not have enough samples buffered despite having injectors, buffer them synchronously - if (samplesAvailable < samplesRequested && _audio->_localInjectorsAvailable.load(std::memory_order_acquire)) { - // try_to_lock, in case the device is being shut down already - std::unique_ptr localAudioLock(new Lock(_audio->_localAudioMutex, std::try_to_lock)); - if (localAudioLock->owns_lock()) { - _audio->prepareLocalAudioInjectors(std::move(localAudioLock)); - samplesAvailable = _audio->_localSamplesAvailable.load(std::memory_order_acquire); - } - } - - samplesRequested = std::min(samplesRequested, samplesAvailable); - if ((injectorSamplesPopped = _localInjectorsStream.appendSamples(mixBuffer, samplesRequested, append)) > 0) { - _audio->_localSamplesAvailable.fetch_sub(injectorSamplesPopped, std::memory_order_release); - qCDebug(audiostream, "Read %d samples from injectors (%d available, %d requested)", injectorSamplesPopped, - _localInjectorsStream.samplesAvailable(), samplesRequested); - } + int AudioClient::calculateNumberOfFrameSamples(int numBytes) const { + int frameSamples = (int)(numBytes * CALLBACK_ACCELERATOR_RATIO + 0.5f) / AudioConstants::SAMPLE_SIZE; + return frameSamples; } - // prepare injectors for the next callback - QtConcurrent::run(QThreadPool::globalInstance(), [this] { _audio->prepareLocalAudioInjectors(); }); + float AudioClient::azimuthForSource(const glm::vec3& relativePosition) { + glm::quat inverseOrientation = glm::inverse(_orientationGetter()); + + glm::vec3 rotatedSourcePosition = inverseOrientation * relativePosition; + + // project the rotated source position vector onto the XZ plane + rotatedSourcePosition.y = 0.0f; + + static const float SOURCE_DISTANCE_THRESHOLD = 1e-30f; + + float rotatedSourcePositionLength2 = glm::length2(rotatedSourcePosition); + if (rotatedSourcePositionLength2 > SOURCE_DISTANCE_THRESHOLD) { + // produce an oriented angle about the y-axis + glm::vec3 direction = rotatedSourcePosition * (1.0f / fastSqrtf(rotatedSourcePositionLength2)); + float angle = fastAcosf(glm::clamp(-direction.z, -1.0f, 1.0f)); // UNIT_NEG_Z is "forward" + return (direction.x < 0.0f) ? -angle : angle; - int samplesPopped = std::max(networkSamplesPopped, injectorSamplesPopped); - int framesPopped = samplesPopped / AudioConstants::STEREO; - int bytesWritten; - if (samplesPopped > 0) { - if (deviceChannelCount == OUTPUT_CHANNEL_COUNT) { - // limit the audio - _audio->_audioLimiter.render(mixBuffer, (int16_t*)data, framesPopped); } else { - _audio->_audioLimiter.render(mixBuffer, scratchBuffer, framesPopped); + // no azimuth if they are in same spot + return 0.0f; + } + } - // upmix or downmix to deviceChannelCount - if (deviceChannelCount > OUTPUT_CHANNEL_COUNT) { - int extraChannels = deviceChannelCount - OUTPUT_CHANNEL_COUNT; - channelUpmix(scratchBuffer, (int16_t*)data, samplesPopped, extraChannels); - } else { - channelDownmix(scratchBuffer, (int16_t*)data, samplesPopped); + float AudioClient::gainForSource(float distance, float volume) { + // attenuation = -6dB * log2(distance) + // reference attenuation of 0dB at distance = 1.0m + float gain = volume / std::max(distance, HRTF_NEARFIELD_MIN); + + return gain; + } + + qint64 AudioClient::AudioOutputIODevice::readData(char* data, qint64 maxSize) { + // samples requested from OUTPUT_CHANNEL_COUNT + int deviceChannelCount = _audio->_outputFormat.channelCount(); + int samplesRequested = (int)(maxSize / AudioConstants::SAMPLE_SIZE) * OUTPUT_CHANNEL_COUNT / deviceChannelCount; + // restrict samplesRequested to the size of our mix/scratch buffers + samplesRequested = std::min(samplesRequested, _audio->_outputPeriod); + + int16_t* scratchBuffer = _audio->_outputScratchBuffer; + float* mixBuffer = _audio->_outputMixBuffer; + + int networkSamplesPopped; + if ((networkSamplesPopped = _receivedAudioStream.popSamples(samplesRequested, false)) > 0) { + qCDebug(audiostream, "Read %d samples from buffer (%d available, %d requested)", networkSamplesPopped, + _receivedAudioStream.getSamplesAvailable(), samplesRequested); + AudioRingBuffer::ConstIterator lastPopOutput = _receivedAudioStream.getLastPopOutput(); + lastPopOutput.readSamples(scratchBuffer, networkSamplesPopped); + for (int i = 0; i < networkSamplesPopped; i++) { + mixBuffer[i] = convertToFloat(scratchBuffer[i]); + } + samplesRequested = networkSamplesPopped; + } + + int injectorSamplesPopped = 0; + { + bool append = networkSamplesPopped > 0; + // check the samples we have available locklessly; this is possible because only two functions add to the count: + // - prepareLocalAudioInjectors will only increase samples count + // - switchOutputToAudioDevice will zero samples count, + // stop the device - so that readData will exhaust the existing buffer or see a zeroed samples count, + // and start the device - which can then only see a zeroed samples count + int samplesAvailable = _audio->_localSamplesAvailable.load(std::memory_order_acquire); + + // if we do not have enough samples buffered despite having injectors, buffer them synchronously + if (samplesAvailable < samplesRequested && _audio->_localInjectorsAvailable.load(std::memory_order_acquire)) { + // try_to_lock, in case the device is being shut down already + std::unique_ptr localAudioLock(new Lock(_audio->_localAudioMutex, std::try_to_lock)); + if (localAudioLock->owns_lock()) { + _audio->prepareLocalAudioInjectors(std::move(localAudioLock)); + samplesAvailable = _audio->_localSamplesAvailable.load(std::memory_order_acquire); + } + } + + samplesRequested = std::min(samplesRequested, samplesAvailable); + if ((injectorSamplesPopped = _localInjectorsStream.appendSamples(mixBuffer, samplesRequested, append)) > 0) { + _audio->_localSamplesAvailable.fetch_sub(injectorSamplesPopped, std::memory_order_release); + qCDebug(audiostream, "Read %d samples from injectors (%d available, %d requested)", injectorSamplesPopped, + _localInjectorsStream.samplesAvailable(), samplesRequested); } } - bytesWritten = framesPopped * AudioConstants::SAMPLE_SIZE * deviceChannelCount; - } else { - // nothing on network, don't grab anything from injectors, and just return 0s - memset(data, 0, maxSize); - bytesWritten = maxSize; + // prepare injectors for the next callback + QtConcurrent::run(QThreadPool::globalInstance(), [this] { _audio->prepareLocalAudioInjectors(); }); + + int samplesPopped = std::max(networkSamplesPopped, injectorSamplesPopped); + int framesPopped = samplesPopped / AudioConstants::STEREO; + int bytesWritten; + if (samplesPopped > 0) { + if (deviceChannelCount == OUTPUT_CHANNEL_COUNT) { + // limit the audio + _audio->_audioLimiter.render(mixBuffer, (int16_t*)data, framesPopped); + } else { + _audio->_audioLimiter.render(mixBuffer, scratchBuffer, framesPopped); + + // upmix or downmix to deviceChannelCount + if (deviceChannelCount > OUTPUT_CHANNEL_COUNT) { + int extraChannels = deviceChannelCount - OUTPUT_CHANNEL_COUNT; + channelUpmix(scratchBuffer, (int16_t*)data, samplesPopped, extraChannels); + } else { + channelDownmix(scratchBuffer, (int16_t*)data, samplesPopped); + } + } + + bytesWritten = framesPopped * AudioConstants::SAMPLE_SIZE * deviceChannelCount; + } else { + // nothing on network, don't grab anything from injectors, and just return 0s + memset(data, 0, maxSize); + bytesWritten = maxSize; + } + + // send output buffer for recording + if (_audio->_isRecording) { + Lock lock(_recordMutex); + _audio->_audioFileWav.addRawAudioChunk(reinterpret_cast(scratchBuffer), bytesWritten); + } + + int bytesAudioOutputUnplayed = _audio->_audioOutput->bufferSize() - _audio->_audioOutput->bytesFree(); + float msecsAudioOutputUnplayed = + bytesAudioOutputUnplayed / (float)_audio->_outputFormat.bytesForDuration(USECS_PER_MSEC); + _audio->_stats.updateOutputMsUnplayed(msecsAudioOutputUnplayed); + + if (bytesAudioOutputUnplayed == 0) { + _unfulfilledReads++; + } + + return bytesWritten; } - // send output buffer for recording - if (_audio->_isRecording) { - Lock lock(_recordMutex); - _audio->_audioFileWav.addRawAudioChunk(reinterpret_cast(scratchBuffer), bytesWritten); + bool AudioClient::startRecording(const QString& filepath) { + if (!_audioFileWav.create(_outputFormat, filepath)) { + qDebug() << "Error creating audio file: " + filepath; + return false; + } + _isRecording = true; + return true; } - int bytesAudioOutputUnplayed = _audio->_audioOutput->bufferSize() - _audio->_audioOutput->bytesFree(); - float msecsAudioOutputUnplayed = bytesAudioOutputUnplayed / (float)_audio->_outputFormat.bytesForDuration(USECS_PER_MSEC); - _audio->_stats.updateOutputMsUnplayed(msecsAudioOutputUnplayed); - - if (bytesAudioOutputUnplayed == 0) { - _unfulfilledReads++; - } - - return bytesWritten; -} - -bool AudioClient::startRecording(const QString& filepath) { - if (!_audioFileWav.create(_outputFormat, filepath)) { - qDebug() << "Error creating audio file: " + filepath; - return false; - } - _isRecording = true; - return true; -} - -void AudioClient::stopRecording() { - if (_isRecording) { - _isRecording = false; - _audioFileWav.close(); - } -} - -void AudioClient::loadSettings() { - _receivedAudioStream.setDynamicJitterBufferEnabled(dynamicJitterBufferEnabled.get()); - _receivedAudioStream.setStaticJitterBufferFrames(staticJitterBufferFrames.get()); - - qCDebug(audioclient) << "---- Initializing Audio Client ----"; - auto codecPlugins = PluginManager::getInstance()->getCodecPlugins(); - for (auto& plugin : codecPlugins) { - qCDebug(audioclient) << "Codec available:" << plugin->getName(); - } -} - -void AudioClient::saveSettings() { - dynamicJitterBufferEnabled.set(_receivedAudioStream.dynamicJitterBufferEnabled()); - staticJitterBufferFrames.set(_receivedAudioStream.getStaticJitterBufferFrames()); -} - -void AudioClient::setAvatarBoundingBoxParameters(glm::vec3 corner, glm::vec3 scale) { - avatarBoundingBoxCorner = corner; - avatarBoundingBoxScale = scale; -} - -void AudioClient::startThread() { - moveToNewNamedThread(this, "Audio Thread", [this] { start(); }, QThread::TimeCriticalPriority); -} - -void AudioClient::setInputVolume(float volume, bool emitSignal) { - if (_audioInput && volume != (float)_audioInput->volume()) { - _audioInput->setVolume(volume); - if (emitSignal) { - emit inputVolumeChanged(_audioInput->volume()); + void AudioClient::stopRecording() { + if (_isRecording) { + _isRecording = false; + _audioFileWav.close(); + } + } + + void AudioClient::loadSettings() { + _receivedAudioStream.setDynamicJitterBufferEnabled(dynamicJitterBufferEnabled.get()); + _receivedAudioStream.setStaticJitterBufferFrames(staticJitterBufferFrames.get()); + + qCDebug(audioclient) << "---- Initializing Audio Client ----"; + auto codecPlugins = PluginManager::getInstance()->getCodecPlugins(); + for (auto& plugin : codecPlugins) { + qCDebug(audioclient) << "Codec available:" << plugin->getName(); + } + } + + void AudioClient::saveSettings() { + dynamicJitterBufferEnabled.set(_receivedAudioStream.dynamicJitterBufferEnabled()); + staticJitterBufferFrames.set(_receivedAudioStream.getStaticJitterBufferFrames()); + } + + void AudioClient::setAvatarBoundingBoxParameters(glm::vec3 corner, glm::vec3 scale) { + avatarBoundingBoxCorner = corner; + avatarBoundingBoxScale = scale; + } + + void AudioClient::startThread() { + moveToNewNamedThread(this, "Audio Thread", [this] { start(); }, QThread::TimeCriticalPriority); + } + + void AudioClient::setInputVolume(float volume, bool emitSignal) { + if (_audioInput && volume != (float)_audioInput->volume()) { + _audioInput->setVolume(volume); + if (emitSignal) { + emit inputVolumeChanged(_audioInput->volume()); + } } } -} diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 1ca7cac6ca..2e5ef65473 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -197,7 +197,11 @@ public slots: void checkInputTimeout(); void handleDummyAudioInput(); void handleRecordedAudioInput(const QByteArray& audio); - void handleTTSAudioInput(const QByteArray& audio); + void handleTTSAudioInput(const QByteArray& audio, + const int& newChunkSize, + const int& timerInterval); + void clearTTSBuffer(); + void processTTSBuffer(); void reset(); void audioMixerKilled(); @@ -289,11 +293,12 @@ private: bool mixLocalAudioInjectors(float* mixBuffer); float azimuthForSource(const glm::vec3& relativePosition); float gainForSource(float distance, float volume); - - void processAudioAndAddToRingBuffer(QByteArray& inputByteArray, - const uchar& channelCount, - const qint32& bytesForDuration, - QByteArray& rollingBuffer); + + Mutex _TTSMutex; + QTimer _TTSTimer; + bool _isProcessingTTS {false}; + QByteArray _TTSAudioBuffer; + int _TTSChunkSize = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50; #ifdef Q_OS_ANDROID QTimer _checkInputTimer; @@ -401,7 +406,7 @@ private: void configureReverb(); void updateReverbOptions(); - void handleLocalEchoAndReverb(QByteArray& inputByteArray); + void handleLocalEchoAndReverb(QByteArray& inputByteArray, const int& sampleRate, const int& channelCount); bool switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInfo, bool isShutdownRequest = false); bool switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceInfo, bool isShutdownRequest = false); From 12d092609b39ad125c57f7f038501ee1d4c77215 Mon Sep 17 00:00:00 2001 From: NissimHadar Date: Thu, 18 Oct 2018 17:20:26 -0700 Subject: [PATCH 10/34] Do not show login dialog if requested not to on the command line. --- interface/src/Application.cpp | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 1bd6af2eba..ba51ff9cec 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2330,23 +2330,29 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo connect(&AndroidHelper::instance(), &AndroidHelper::enterForeground, this, &Application::enterForeground); AndroidHelper::instance().notifyLoadComplete(); #else - static int CHECK_LOGIN_TIMER = 3000; - QTimer* checkLoginTimer = new QTimer(this); - checkLoginTimer->setInterval(CHECK_LOGIN_TIMER); - checkLoginTimer->setSingleShot(true); - connect(checkLoginTimer, &QTimer::timeout, this, []() { - auto accountManager = DependencyManager::get(); - auto dialogsManager = DependencyManager::get(); - if (!accountManager->isLoggedIn()) { - Setting::Handle{"loginDialogPoppedUp", false}.set(true); - dialogsManager->showLoginDialog(); - QJsonObject loginData = {}; - loginData["action"] = "login dialog shown"; - UserActivityLogger::getInstance().logAction("encourageLoginDialog", loginData); - } - }); - Setting::Handle{"loginDialogPoppedUp", false}.set(false); - checkLoginTimer->start(); + // Do not show login dialog if requested not to on the command line + const QString HIFI_NO_LOGIN_COMMAND_LINE_KEY = "--no-login"; + int index = arguments().indexOf(HIFI_NO_LOGIN_COMMAND_LINE_KEY); + if (index == -1) { + // request not found + static int CHECK_LOGIN_TIMER = 3000; + QTimer* checkLoginTimer = new QTimer(this); + checkLoginTimer->setInterval(CHECK_LOGIN_TIMER); + checkLoginTimer->setSingleShot(true); + connect(checkLoginTimer, &QTimer::timeout, this, []() { + auto accountManager = DependencyManager::get(); + auto dialogsManager = DependencyManager::get(); + if (!accountManager->isLoggedIn()) { + Setting::Handle{ "loginDialogPoppedUp", false }.set(true); + dialogsManager->showLoginDialog(); + QJsonObject loginData = {}; + loginData["action"] = "login dialog shown"; + UserActivityLogger::getInstance().logAction("encourageLoginDialog", loginData); + } + }); + Setting::Handle{ "loginDialogPoppedUp", false }.set(false); + checkLoginTimer->start(); + } #endif } From 092f88e5586bf79b0ef8afb511dd0d6793c87ea7 Mon Sep 17 00:00:00 2001 From: NissimHadar Date: Mon, 22 Oct 2018 10:55:50 -0700 Subject: [PATCH 11/34] Change parameter name from `--no-login` to `--no-login-suggestion` --- interface/src/Application.cpp | 2 +- tools/auto-tester/src/TestRunner.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index ba51ff9cec..f07983fddf 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2331,7 +2331,7 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo AndroidHelper::instance().notifyLoadComplete(); #else // Do not show login dialog if requested not to on the command line - const QString HIFI_NO_LOGIN_COMMAND_LINE_KEY = "--no-login"; + const QString HIFI_NO_LOGIN_COMMAND_LINE_KEY = "--no-login-suggestion"; int index = arguments().indexOf(HIFI_NO_LOGIN_COMMAND_LINE_KEY); if (index == -1) { // request not found diff --git a/tools/auto-tester/src/TestRunner.cpp b/tools/auto-tester/src/TestRunner.cpp index 674cf6f8e8..01ec04f254 100644 --- a/tools/auto-tester/src/TestRunner.cpp +++ b/tools/auto-tester/src/TestRunner.cpp @@ -340,7 +340,7 @@ void TestRunner::runInterfaceWithTestScript() { QString testScript = QString("https://raw.githubusercontent.com/") + _user + "/hifi_tests/" + _branch + "/tests/testRecursive.js"; - QString commandLine = exeFile + " --url " + url + " --no-updater --no-login" + " --testScript " + testScript + + QString commandLine = exeFile + " --url " + url + " --no-updater --no-login-suggestion" + " --testScript " + testScript + " quitWhenFinished --testResultsLocation " + snapshotFolder; interfaceWorker->setCommandLine(commandLine); From 1d8994993c0e640ecaeadd8557fe09ea9a6f5fe2 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Mon, 22 Oct 2018 17:05:31 -0700 Subject: [PATCH 12/34] Whitelist TTS scripting interface to TTS app, which is now a default script --- interface/resources/qml/hifi/tts/TTS.qml | 304 ++++++++++++++++++ interface/src/Application.cpp | 12 +- .../src/scripting/TTSScriptingInterface.cpp | 4 +- scripts/defaultScripts.js | 3 +- scripts/system/tts/TTS.js | 28 ++ scripts/system/tts/tts-a.svg | 9 + scripts/system/tts/tts-i.svg | 9 + 7 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 interface/resources/qml/hifi/tts/TTS.qml create mode 100644 scripts/system/tts/TTS.js create mode 100644 scripts/system/tts/tts-a.svg create mode 100644 scripts/system/tts/tts-i.svg diff --git a/interface/resources/qml/hifi/tts/TTS.qml b/interface/resources/qml/hifi/tts/TTS.qml new file mode 100644 index 0000000000..114efd0cca --- /dev/null +++ b/interface/resources/qml/hifi/tts/TTS.qml @@ -0,0 +1,304 @@ +// +// TTS.qml +// +// TTS App +// +// Created by Zach Fox on 2018-10-10 +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +import Hifi 1.0 as Hifi +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import "qrc:////qml//styles-uit" as HifiStylesUit +import "qrc:////qml//controls-uit" as HifiControlsUit +import "qrc:////qml//controls" as HifiControls + +Rectangle { + HifiStylesUit.HifiConstants { id: hifi; } + + id: root; + // Style + color: hifi.colors.darkGray; + property bool keyboardRaised: false; + + // + // TITLE BAR START + // + Item { + id: titleBarContainer; + // Size + width: root.width; + height: 50; + // Anchors + anchors.left: parent.left; + anchors.top: parent.top; + + // Title bar text + HifiStylesUit.RalewaySemiBold { + id: titleBarText; + text: "Text-to-Speech"; + // Text size + size: hifi.fontSizes.overlayTitle; + // Anchors + anchors.top: parent.top; + anchors.bottom: parent.bottom; + anchors.left: parent.left; + anchors.leftMargin: 16; + width: paintedWidth; + // Style + color: hifi.colors.lightGrayText; + // Alignment + horizontalAlignment: Text.AlignHLeft; + verticalAlignment: Text.AlignVCenter; + } + + // Separator + HifiControlsUit.Separator { + anchors.left: parent.left; + anchors.right: parent.right; + anchors.bottom: parent.bottom; + } + } + // + // TITLE BAR END + // + + + Item { + id: tagButtonContainer; + anchors.top: titleBarContainer.bottom; + anchors.topMargin: 2; + anchors.left: parent.left; + anchors.right: parent.right; + height: 70; + + HifiStylesUit.RalewaySemiBold { + id: tagButtonTitle; + text: "Insert Tag:"; + // Text size + size: 18; + // Anchors + anchors.top: parent.top; + anchors.left: parent.left; + anchors.right: parent.right; + height: 35; + // Style + color: hifi.colors.lightGrayText; + // Alignment + horizontalAlignment: Text.AlignHCenter; + verticalAlignment: Text.AlignVCenter; + } + + HifiControlsUit.Button { + id: pitch10Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: parent.left; + anchors.leftMargin: 3; + width: parent.width/6 - 6; + height: 30; + text: "Pitch 10"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + + HifiControlsUit.Button { + id: pitch0Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: pitch10Button.right; + anchors.leftMargin: 6; + width: parent.width/6 - anchors.leftMargin; + height: 30; + text: "Pitch 0"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + + HifiControlsUit.Button { + id: pitchNeg10Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: pitch0Button.right; + anchors.leftMargin: 6; + width: parent.width/6 - anchors.leftMargin; + height: 30; + text: "Pitch -10"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + + HifiControlsUit.Button { + id: speed5Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: pitchNeg10Button.right; + anchors.leftMargin: 6; + width: parent.width/6 - anchors.leftMargin; + height: 30; + text: "Speed 5"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + + HifiControlsUit.Button { + id: speed0Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: speed5Button.right; + anchors.leftMargin: 6; + width: parent.width/6 - anchors.leftMargin; + height: 30; + text: "Speed 0"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + + HifiControlsUit.Button { + id: speedNeg10Button; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.none; + colorScheme: hifi.colorSchemes.dark; + anchors.top: tagButtonTitle.bottom; + anchors.left: speed0Button.right; + anchors.leftMargin: 6; + width: parent.width/6 - anchors.leftMargin; + height: 30; + text: "Speed -10"; + onClicked: { + messageToSpeak.insert(messageToSpeak.cursorPosition, ""); + } + } + } + + Item { + anchors.top: tagButtonContainer.bottom; + anchors.topMargin: 8; + anchors.bottom: keyboardContainer.top; + anchors.bottomMargin: 16; + anchors.left: parent.left; + anchors.leftMargin: 16; + anchors.right: parent.right; + anchors.rightMargin: 16; + + TextArea { + id: messageToSpeak; + placeholderText: "Message to Speak"; + font.family: "Fira Sans SemiBold"; + font.pixelSize: 20; + // Anchors + anchors.top: parent.top; + anchors.left: parent.left; + anchors.right: parent.right; + anchors.bottom: speakButton.top; + anchors.bottomMargin: 8; + // Style + background: Rectangle { + anchors.fill: parent; + color: parent.activeFocus ? hifi.colors.black : hifi.colors.baseGrayShadow; + border.width: parent.activeFocus ? 1 : 0; + border.color: parent.activeFocus ? hifi.colors.primaryHighlight : hifi.colors.textFieldLightBackground; + } + color: hifi.colors.white; + textFormat: TextEdit.PlainText; + wrapMode: TextEdit.Wrap; + activeFocusOnPress: true; + activeFocusOnTab: true; + Keys.onPressed: { + if (event.key == Qt.Key_Return || event.key == Qt.Key_Enter) { + TextToSpeech.speakText(messageToSpeak.text, 480, 10, 24000, 16, true); + event.accepted = true; + } + } + } + + HifiControlsUit.Button { + id: speakButton; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.blue; + colorScheme: hifi.colorSchemes.dark; + anchors.right: parent.right; + anchors.bottom: parent.bottom; + width: 215; + height: 40; + text: "Speak"; + onClicked: { + TextToSpeech.speakText(messageToSpeak.text, 480, 10, 24000, 16, true); + } + } + + HifiControlsUit.Button { + id: clearButton; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.white; + colorScheme: hifi.colorSchemes.dark; + anchors.right: speakButton.left; + anchors.rightMargin: 16; + anchors.bottom: parent.bottom; + width: 100; + height: 40; + text: "Clear"; + onClicked: { + messageToSpeak.text = ""; + } + } + + HifiControlsUit.Button { + id: stopButton; + focusPolicy: Qt.NoFocus; + color: hifi.buttons.red; + colorScheme: hifi.colorSchemes.dark; + anchors.right: clearButton.left; + anchors.rightMargin: 16; + anchors.bottom: parent.bottom; + width: 100; + height: 40; + text: "Stop Last"; + onClicked: { + TextToSpeech.stopLastSpeech(); + } + } + } + + Item { + id: keyboardContainer; + z: 998; + visible: keyboard.raised; + property bool punctuationMode: false; + anchors { + bottom: parent.bottom; + left: parent.left; + right: parent.right; + } + + HifiControlsUit.Keyboard { + id: keyboard; + raised: HMD.mounted && root.keyboardRaised; + numeric: parent.punctuationMode; + anchors { + bottom: parent.bottom; + left: parent.left; + right: parent.right; + } + } + } +} diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 836e12e60a..9cfdc8a9bb 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2906,7 +2906,7 @@ void Application::initializeUi() { LoginDialog::registerType(); Tooltip::registerType(); UpdateDialog::registerType(); - QmlContextCallback callback = [](QQmlContext* context) { + QmlContextCallback commerceCallback = [](QQmlContext* context) { context->setContextProperty("Commerce", new QmlCommerce()); }; OffscreenQmlSurface::addWhitelistContextHandler({ @@ -2932,7 +2932,13 @@ void Application::initializeUi() { QUrl{ "hifi/commerce/wallet/Wallet.qml" }, QUrl{ "hifi/commerce/wallet/WalletHome.qml" }, QUrl{ "hifi/commerce/wallet/WalletSetup.qml" }, - }, callback); + }, commerceCallback); + QmlContextCallback ttsCallback = [](QQmlContext* context) { + context->setContextProperty("TextToSpeech", DependencyManager::get().data()); + }; + OffscreenQmlSurface::addWhitelistContextHandler({ + QUrl{ "hifi/tts/TTS.qml" } + }, ttsCallback); qmlRegisterType("Hifi", 1, 0, "ResourceImageItem"); qmlRegisterType("Hifi", 1, 0, "Preference"); qmlRegisterType("HifiWeb", 1, 0, "WebBrowserSuggestionsEngine"); @@ -3135,7 +3141,6 @@ void Application::onDesktopRootContextCreated(QQmlContext* surfaceContext) { surfaceContext->setContextProperty("ContextOverlay", DependencyManager::get().data()); surfaceContext->setContextProperty("Wallet", DependencyManager::get().data()); surfaceContext->setContextProperty("HiFiAbout", AboutUtil::getInstance()); - surfaceContext->setContextProperty("TextToSpeech", DependencyManager::get().data()); if (auto steamClient = PluginManager::getInstance()->getSteamClientPlugin()) { surfaceContext->setContextProperty("Steam", new SteamScriptingInterface(engine, steamClient.get())); @@ -6818,7 +6823,6 @@ void Application::registerScriptEngineWithApplicationServices(ScriptEnginePointe scriptEngine->registerGlobalObject("Wallet", DependencyManager::get().data()); scriptEngine->registerGlobalObject("AddressManager", DependencyManager::get().data()); scriptEngine->registerGlobalObject("HifiAbout", AboutUtil::getInstance()); - scriptEngine->registerGlobalObject("TextToSpeech", DependencyManager::get().data()); qScriptRegisterMetaType(scriptEngine.data(), OverlayIDtoScriptValue, OverlayIDfromScriptValue); diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index 5fb47a73c3..0cdb24e15d 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -151,7 +151,9 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, _lastSoundByteArray.resize(0); _lastSoundByteArray.append(buf1, dwSize); - emit ttsSampleCreated(_lastSoundByteArray, newChunkSize, timerInterval); + // Commented out because this doesn't work completely :) + // Obviously, commenting this out isn't fit for production, but it's fine for a test PR + //emit ttsSampleCreated(_lastSoundByteArray, newChunkSize, timerInterval); if (alsoInject) { AudioInjectorOptions options; diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index 9efb040624..2398973dfd 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -32,7 +32,8 @@ var DEFAULT_SCRIPTS_COMBINED = [ "system/firstPersonHMD.js", "system/tablet-ui/tabletUI.js", "system/emote.js", - "system/miniTablet.js" + "system/miniTablet.js", + "system/tts/TTS.js" ]; var DEFAULT_SCRIPTS_SEPARATE = [ "system/controllers/controllerScripts.js", diff --git a/scripts/system/tts/TTS.js b/scripts/system/tts/TTS.js new file mode 100644 index 0000000000..36259cfda0 --- /dev/null +++ b/scripts/system/tts/TTS.js @@ -0,0 +1,28 @@ +"use strict"; +/*jslint vars:true, plusplus:true, forin:true*/ +/*global Tablet, Script, */ +/* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ +// +// TTS.js +// +// Created by Zach Fox on 2018-10-10 +// Copyright 2018 High Fidelity, Inc +// +// Distributed under the Apache License, Version 2.0 +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +(function () { // BEGIN LOCAL_SCOPE +var AppUi = Script.require('appUi'); + +var ui; +function startup() { + ui = new AppUi({ + buttonName: "TTS", + //home: Script.resolvePath("TTS.qml") + home: "hifi/tts/TTS.qml", + graphicsDirectory: Script.resolvePath("./") // speech by Danil Polshin from the Noun Project + }); +} +startup(); +}()); // END LOCAL_SCOPE diff --git a/scripts/system/tts/tts-a.svg b/scripts/system/tts/tts-a.svg new file mode 100644 index 0000000000..9dac3a2d53 --- /dev/null +++ b/scripts/system/tts/tts-a.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/scripts/system/tts/tts-i.svg b/scripts/system/tts/tts-i.svg new file mode 100644 index 0000000000..1c52ec3193 --- /dev/null +++ b/scripts/system/tts/tts-i.svg @@ -0,0 +1,9 @@ + + + + + + + + + From 794767144fe0a8837c361502d25b12cee5f53a3d Mon Sep 17 00:00:00 2001 From: David Rowe Date: Tue, 23 Oct 2018 15:30:33 +1300 Subject: [PATCH 13/34] Fix Interface crash importing JSON with duplicate entity IDs in file --- libraries/entities/src/EntityTree.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/entities/src/EntityTree.cpp b/libraries/entities/src/EntityTree.cpp index 0b3b8abba2..1c201e7b46 100644 --- a/libraries/entities/src/EntityTree.cpp +++ b/libraries/entities/src/EntityTree.cpp @@ -2752,9 +2752,11 @@ bool EntityTree::readFromMap(QVariantMap& map) { success = false; } - const QUuid& cloneOriginID = entity->getCloneOriginID(); - if (!cloneOriginID.isNull()) { - cloneIDs[cloneOriginID].push_back(entity->getEntityItemID()); + if (entity) { + const QUuid& cloneOriginID = entity->getCloneOriginID(); + if (!cloneOriginID.isNull()) { + cloneIDs[cloneOriginID].push_back(entity->getEntityItemID()); + } } } From 947391e49eb28140d58047d3d2194ac3a6701348 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 23 Oct 2018 11:44:16 -0700 Subject: [PATCH 14/34] Fix build errors --- interface/src/scripting/TTSScriptingInterface.cpp | 8 ++++++++ interface/src/scripting/TTSScriptingInterface.h | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index 0cdb24e15d..f51a638471 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -13,6 +13,7 @@ #include "avatar/AvatarManager.h" TTSScriptingInterface::TTSScriptingInterface() { +#ifdef WIN32 // // Create text to speech engine // @@ -36,11 +37,13 @@ TTSScriptingInterface::TTSScriptingInterface() { if (FAILED(hr)) { qDebug() << "Can't set default voice."; } +#endif } TTSScriptingInterface::~TTSScriptingInterface() { } +#ifdef WIN32 class ReleaseOnExit { public: ReleaseOnExit(IUnknown* p) : m_p(p) {} @@ -53,6 +56,7 @@ public: private: IUnknown* m_p; }; +#endif void TTSScriptingInterface::testTone(const bool& alsoInject) { QByteArray byteArray(480000, 0); @@ -81,6 +85,7 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, const int& sampleRate, const int& bitsPerSample, const bool& alsoInject) { +#ifdef WIN32 WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; fmt.nSamplesPerSec = sampleRate; @@ -161,6 +166,9 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, _lastSoundAudioInjector = AudioInjector::playSound(_lastSoundByteArray, options); } +#else + qDebug() << "Text-to-Speech isn't currently supported on non-Windows platforms."; +#endif } void TTSScriptingInterface::stopLastSpeech() { diff --git a/interface/src/scripting/TTSScriptingInterface.h b/interface/src/scripting/TTSScriptingInterface.h index f6eca081ab..7e4f3afa9d 100644 --- a/interface/src/scripting/TTSScriptingInterface.h +++ b/interface/src/scripting/TTSScriptingInterface.h @@ -13,11 +13,14 @@ #include #include +#ifdef WIN32 +#pragma warning(disable : 4996) #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include // SAPI #include // SAPI Helper +#endif #include #include @@ -42,6 +45,7 @@ signals: void clearTTSBuffer(); private: +#ifdef WIN32 class CComAutoInit { public: // Initializes COM using CoInitialize. @@ -82,6 +86,7 @@ private: // Default voice token CComPtr m_voiceToken; +#endif QByteArray _lastSoundByteArray; AudioInjectorPointer _lastSoundAudioInjector; From 83951328300acdb409d6f4c08e74f11f8b1ee880 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 23 Oct 2018 15:11:02 -0700 Subject: [PATCH 15/34] Attempt to resolve final warnings --- libraries/audio-client/src/AudioClient.cpp | 4 ++-- libraries/audio-client/src/AudioClient.h | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index 606763e4ab..1ce6c15951 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -186,11 +186,11 @@ AudioClient::AudioClient() : _networkToOutputResampler(NULL), _localToOutputResampler(NULL), _audioLimiter(AudioConstants::SAMPLE_RATE, OUTPUT_CHANNEL_COUNT), _outgoingAvatarAudioSequenceNumber(0), _audioOutputIODevice(_localInjectorsStream, _receivedAudioStream, this), _stats(&_receivedAudioStream), - _positionGetter(DEFAULT_POSITION_GETTER), _TTSTimer(this), + _positionGetter(DEFAULT_POSITION_GETTER), _orientationGetter(DEFAULT_ORIENTATION_GETTER), #if defined(Q_OS_ANDROID) _checkInputTimer(this), _isHeadsetPluggedIn(false), #endif - _orientationGetter(DEFAULT_ORIENTATION_GETTER) { + _TTSTimer(this) { // avoid putting a lock in the device callback assert(_localSamplesAvailable.is_lock_free()); diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 2e5ef65473..9b50d3eccb 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -293,12 +293,6 @@ private: bool mixLocalAudioInjectors(float* mixBuffer); float azimuthForSource(const glm::vec3& relativePosition); float gainForSource(float distance, float volume); - - Mutex _TTSMutex; - QTimer _TTSTimer; - bool _isProcessingTTS {false}; - QByteArray _TTSAudioBuffer; - int _TTSChunkSize = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50; #ifdef Q_OS_ANDROID QTimer _checkInputTimer; @@ -464,6 +458,12 @@ private: QTimer* _checkPeakValuesTimer { nullptr }; bool _isRecording { false }; + + Mutex _TTSMutex; + bool _isProcessingTTS { false }; + QByteArray _TTSAudioBuffer; + int _TTSChunkSize = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50; + QTimer _TTSTimer; }; From 0279eef3a4958896b403a562913fc9aa7841dedb Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Wed, 31 Oct 2018 10:13:32 -0700 Subject: [PATCH 16/34] improving interstitial loading bar --- scripts/system/interstitialPage.js | 83 ++++++++++++++++-------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/scripts/system/interstitialPage.js b/scripts/system/interstitialPage.js index 670d21c7a7..34eee605ae 100644 --- a/scripts/system/interstitialPage.js +++ b/scripts/system/interstitialPage.js @@ -17,7 +17,8 @@ Script.include("/~/system/libraries/globals.js"); var DEBUG = false; var MIN_LOADING_PROGRESS = 3.6; - var TOTAL_LOADING_PROGRESS = 3.8; + var TOTAL_LOADING_PROGRESS = 3.7; + var FINAL_Y_DIMENSIONS = 2.8; var EPSILON = 0.05; var TEXTURE_EPSILON = 0.01; var isVisible = false; @@ -27,6 +28,7 @@ var MAX_LEFT_MARGIN = 1.9; var INNER_CIRCLE_WIDTH = 4.7; var DEFAULT_Z_OFFSET = 5.45; + var LOADING_IMAGE_WIDTH_PIXELS = 1024; var previousCameraMode = Camera.mode; var renderViewTask = Render.getConfig("RenderMainView"); @@ -182,12 +184,13 @@ parentID: anchorOverlay }); - var loadingBarPlacard = Overlays.addOverlay("image3d", { - name: "Loading-Bar-Placard", - localPosition: { x: 0.0, y: -0.99, z: 0.3 }, - url: Script.resourcesPath() + "images/loadingBar_placard.png", + + var loadingBarProgress = Overlays.addOverlay("image3d", { + name: "Loading-Bar-Progress", + localPosition: { x: 0.0, y: -0.86, z: 0.0 }, + url: Script.resourcesPath() + "images/loadingBar_progress.png", alpha: 1, - dimensions: { x: 4, y: 2.8 }, + dimensions: { x: 3.8, y: 2.8 }, visible: isVisible, emissive: true, ignoreRayIntersection: false, @@ -197,12 +200,12 @@ parentID: anchorOverlay }); - var loadingBarProgress = Overlays.addOverlay("image3d", { - name: "Loading-Bar-Progress", - localPosition: { x: 0.0, y: -0.90, z: 0.0 }, - url: Script.resourcesPath() + "images/loadingBar_progress.png", + var loadingBarPlacard = Overlays.addOverlay("image3d", { + name: "Loading-Bar-Placard", + localPosition: { x: 0.0, y: -0.99, z: 0.4 }, + url: Script.resourcesPath() + "images/loadingBar_placard.png", alpha: 1, - dimensions: { x: 3.8, y: 2.8 }, + dimensions: { x: 4, y: 2.8 }, visible: isVisible, emissive: true, ignoreRayIntersection: false, @@ -245,15 +248,7 @@ } function resetValues() { - var properties = { - localPosition: { x: 1.85, y: -0.935, z: 0.0 }, - dimensions: { - x: 0.1, - y: 2.8 - } - }; - - Overlays.editOverlay(loadingBarProgress, properties); + updateProgressBar(0.0); } function startInterstitialPage() { @@ -382,8 +377,8 @@ function updateOverlays(physicsEnabled) { if (isInterstitialOverlaysVisible !== !physicsEnabled && !physicsEnabled === true) { - // visible changed to true. - isInterstitialOverlaysVisible = !physicsEnabled; + // visible changed to true. + isInterstitialOverlaysVisible = !physicsEnabled; } var properties = { @@ -400,7 +395,7 @@ }; var loadingBarProperties = { - dimensions: { x: 0.0, y: 2.8 }, + dimensions: { x: 2.0, y: 2.8 }, visible: !physicsEnabled }; @@ -434,8 +429,8 @@ } if (isInterstitialOverlaysVisible !== !physicsEnabled && !physicsEnabled === false) { - // visible changed to false. - isInterstitialOverlaysVisible = !physicsEnabled; + // visible changed to false. + isInterstitialOverlaysVisible = !physicsEnabled; } } @@ -459,6 +454,27 @@ } } + function updateProgressBar(progress) { + var progressPercentage = progress / TOTAL_LOADING_PROGRESS; + var subImageWidth = progressPercentage * LOADING_IMAGE_WIDTH_PIXELS; + + var properties = { + localPosition: { x: (TOTAL_LOADING_PROGRESS / 2) - (currentProgress / 2), y: -0.86, z: 0.0 }, + dimensions: { + x: currentProgress, + y: FINAL_Y_DIMENSIONS * (subImageWidth / LOADING_IMAGE_WIDTH_PIXELS) + }, + subImage: { + x: 0.0, + y: 0.0, + width: subImageWidth, + height: 90 + } + }; + + Overlays.editOverlay(loadingBarProgress, properties); + } + var MAX_TEXTURE_STABILITY_COUNT = 30; var INTERVAL_PROGRESS = 0.04; function update() { @@ -503,15 +519,8 @@ } currentProgress = lerp(currentProgress, target, INTERVAL_PROGRESS); - var properties = { - localPosition: { x: (1.85 - (currentProgress / 2) - (-0.029 * (currentProgress / TOTAL_LOADING_PROGRESS))), y: -0.935, z: 0.0 }, - dimensions: { - x: currentProgress, - y: 2.8 - } - }; - Overlays.editOverlay(loadingBarProgress, properties); + updateProgressBar(currentProgress); if (errorConnectingToDomain) { updateOverlays(errorConnectingToDomain); @@ -542,17 +551,11 @@ } var whiteColor = { red: 255, green: 255, blue: 255 }; var greyColor = { red: 125, green: 125, blue: 125 }; + Overlays.mouseReleaseOnOverlay.connect(clickedOnOverlay); Overlays.hoverEnterOverlay.connect(onEnterOverlay); - Overlays.hoverLeaveOverlay.connect(onLeaveOverlay); - location.hostChanged.connect(domainChanged); - location.lookupResultsFinished.connect(function() { - Script.setTimeout(function() { - connectionToDomainFailed = !location.isConnected; - }, 1200); - }); Window.redirectErrorStateChanged.connect(toggleInterstitialPage); MyAvatar.sensorToWorldScaleChanged.connect(scaleInterstitialPage); From 92888e9d3e1ff7533b470ca038ea9126027129be Mon Sep 17 00:00:00 2001 From: Thijs Wenker Date: Wed, 31 Oct 2018 18:26:23 +0100 Subject: [PATCH 17/34] tooltip CR changes --- .../system/assets/data/createAppTooltips.json | 24 ------------------- scripts/system/edit.js | 7 ++++++ scripts/system/html/css/edit-style.css | 7 +++--- scripts/system/html/js/createAppTooltip.js | 10 ++++---- scripts/system/html/js/entityProperties.js | 6 ++++- 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/scripts/system/assets/data/createAppTooltips.json b/scripts/system/assets/data/createAppTooltips.json index 83ddcaa34b..5572779d46 100644 --- a/scripts/system/assets/data/createAppTooltips.json +++ b/scripts/system/assets/data/createAppTooltips.json @@ -196,12 +196,6 @@ "particleRadius": { "tooltip": "The size of each particle." }, - "radiusStart": { - "tooltip": "" - }, - "radiusFinish": { - "tooltip": "" - }, "radiusSpread": { "tooltip": "The spread in size that each particle is given, resulting in a variety of sizes." }, @@ -215,12 +209,6 @@ "alpha": { "tooltip": "The alpha of each particle." }, - "alphaStart": { - "tooltip": "" - }, - "alphaFinish": { - "tooltip": "" - }, "alphaSpread": { "tooltip": "The spread in alpha that each particle is given, resulting in a variety of alphas." }, @@ -233,12 +221,6 @@ "particleSpin": { "tooltip": "The spin of each particle in the system." }, - "spinStart": { - "tooltip": "" - }, - "spinFinish": { - "tooltip": "" - }, "spinSpread": { "tooltip": "The spread in spin that each particle is given, resulting in a variety of spins." }, @@ -248,15 +230,9 @@ "polarStart": { "tooltip": "The angle in deg at which particles are emitted. Starts in the entity's -z direction, and rotates around its y axis." }, - "polarFinish": { - "tooltip": "" - }, "azimuthStart": { "tooltip": "The angle in deg at which particles are emitted. Starts in the entity's -z direction, and rotates around its y axis." }, - "azimuthFinish": { - "tooltip": "" - }, "lightColor": { "tooltip": "The color of the light emitted.", "jsPropertyName": "color" diff --git a/scripts/system/edit.js b/scripts/system/edit.js index 6425806771..17fe9e2d40 100644 --- a/scripts/system/edit.js +++ b/scripts/system/edit.js @@ -2490,6 +2490,13 @@ var PropertiesTool = function (opts) { } }; + HMD.displayModeChanged.connect(function() { + emitScriptEvent({ + type: 'hmdActiveChanged', + hmdActive: HMD.active, + }); + }); + createToolsWindow.webEventReceived.addListener(this, onWebEventReceived); webView.webEventReceived.connect(onWebEventReceived); diff --git a/scripts/system/html/css/edit-style.css b/scripts/system/html/css/edit-style.css index cf7124d9eb..7d2350e1c8 100644 --- a/scripts/system/html/css/edit-style.css +++ b/scripts/system/html/css/edit-style.css @@ -1598,7 +1598,7 @@ input.rename-entity { padding-left: 2px; } -.createAppTooltip { +.create-app-tooltip { position: absolute; background: #6a6a6a; border: 1px solid black; @@ -1607,17 +1607,16 @@ input.rename-entity { padding: 5px; } -.createAppTooltip .createAppTooltipDescription { +.create-app-tooltip .create-app-tooltip-description { font-size: 12px; font-style: italic; color: #ffffff; } -.createAppTooltip .createAppTooltipJSAttribute { +.create-app-tooltip .create-app-tooltip-js-attribute { font-family: Raleway-SemiBold; font-size: 11px; color: #000000; bottom: 0; margin-top: 5px; } - diff --git a/scripts/system/html/js/createAppTooltip.js b/scripts/system/html/js/createAppTooltip.js index a42e5efe05..a5c961a7e2 100644 --- a/scripts/system/html/js/createAppTooltip.js +++ b/scripts/system/html/js/createAppTooltip.js @@ -58,15 +58,15 @@ CreateAppTooltip.prototype = { if (!TOOLTIP_DEBUG) { return; } - tooltipData = {tooltip: 'PLEASE SET THIS TOOLTIP'}; + tooltipData = { tooltip: 'PLEASE SET THIS TOOLTIP' }; } let elementRect = element.getBoundingClientRect(); let elTip = document.createElement("div"); - elTip.className = "createAppTooltip"; + elTip.className = "create-app-tooltip"; let elTipDescription = document.createElement("div"); - elTipDescription.className = "createAppTooltipDescription"; + elTipDescription.className = "create-app-tooltip-description"; elTipDescription.innerText = tooltipData.tooltip; elTip.appendChild(elTipDescription); @@ -77,7 +77,7 @@ CreateAppTooltip.prototype = { if (!tooltipData.skipJSProperty) { let elTipJSAttribute = document.createElement("div"); - elTipJSAttribute.className = "createAppTooltipJSAttribute"; + elTipJSAttribute.className = "create-app-tooltip-js-attribute"; elTipJSAttribute.innerText = `JS Attribute: ${jsAttribute}`; elTip.appendChild(elTipJSAttribute); } @@ -93,7 +93,7 @@ CreateAppTooltip.prototype = { // show above when otherwise out of bounds elTip.style.top = elementTop - CREATE_APP_TOOLTIP_OFFSET - elTip.clientHeight; } else { - // show tooltip on below by default + // show tooltip below by default elTip.style.top = desiredTooltipTop; } if ((window.innerWidth + window.pageXOffset) < (desiredTooltipLeft + elTip.clientWidth)) { diff --git a/scripts/system/html/js/entityProperties.js b/scripts/system/html/js/entityProperties.js index 78de0d075a..c7c75a243b 100644 --- a/scripts/system/html/js/entityProperties.js +++ b/scripts/system/html/js/entityProperties.js @@ -3165,8 +3165,13 @@ function loaded() { } else if (data.type === 'tooltipsReply') { createAppTooltip.setIsEnabled(!data.hmdActive); createAppTooltip.setTooltipData(data.tooltips); + } else if (data.type === 'hmdActiveChanged') { + createAppTooltip.setIsEnabled(!data.hmdActive); } }); + + // Request tooltips as soon as we can process a reply: + EventBridge.emitWebEvent(JSON.stringify({ type: 'tooltipsRequest' })); } // Server Script Status @@ -3397,6 +3402,5 @@ function loaded() { setTimeout(function() { EventBridge.emitWebEvent(JSON.stringify({ type: 'propertiesPageReady' })); - EventBridge.emitWebEvent(JSON.stringify({ type: 'tooltipsRequest' })); }, 1000); } From b3539b101b6a5be02d1ef435c0ed930ffb19ce53 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Wed, 31 Oct 2018 11:04:30 -0700 Subject: [PATCH 18/34] faster loading bar interpolation once physics is enabled --- scripts/system/interstitialPage.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/system/interstitialPage.js b/scripts/system/interstitialPage.js index 34eee605ae..e6e8d3d6d6 100644 --- a/scripts/system/interstitialPage.js +++ b/scripts/system/interstitialPage.js @@ -19,6 +19,7 @@ var MIN_LOADING_PROGRESS = 3.6; var TOTAL_LOADING_PROGRESS = 3.7; var FINAL_Y_DIMENSIONS = 2.8; + var BEGIN_Y_DIMENSIONS = 0.03; var EPSILON = 0.05; var TEXTURE_EPSILON = 0.01; var isVisible = false; @@ -457,12 +458,13 @@ function updateProgressBar(progress) { var progressPercentage = progress / TOTAL_LOADING_PROGRESS; var subImageWidth = progressPercentage * LOADING_IMAGE_WIDTH_PIXELS; + var subImageWidthPercentage = subImageWidth / LOADING_IMAGE_WIDTH_PIXELS; var properties = { localPosition: { x: (TOTAL_LOADING_PROGRESS / 2) - (currentProgress / 2), y: -0.86, z: 0.0 }, dimensions: { x: currentProgress, - y: FINAL_Y_DIMENSIONS * (subImageWidth / LOADING_IMAGE_WIDTH_PIXELS) + y: (subImageWidthPercentage * (FINAL_Y_DIMENSIONS - BEGIN_Y_DIMENSIONS)) + BEGIN_Y_DIMENSIONS }, subImage: { x: 0.0, @@ -477,6 +479,7 @@ var MAX_TEXTURE_STABILITY_COUNT = 30; var INTERVAL_PROGRESS = 0.04; + var INTERVAL_PROGRESS_PHYSICS_ENABLED = 0.2; function update() { var renderStats = Render.getConfig("Stats"); var physicsEnabled = Window.isPhysicsEnabled(); @@ -518,7 +521,7 @@ target = TOTAL_LOADING_PROGRESS; } - currentProgress = lerp(currentProgress, target, INTERVAL_PROGRESS); + currentProgress = lerp(currentProgress, target, (physicsEnabled ? INTERVAL_PROGRESS_PHYSICS_ENABLED : INTERVAL_PROGRESS)); updateProgressBar(currentProgress); From a4b345bb583eca092e625dd0ef93c6cca6660bb3 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Thu, 1 Nov 2018 09:25:46 -0700 Subject: [PATCH 19/34] fix interstitial domain loading due to bad texture url --- libraries/model-networking/src/model-networking/ModelCache.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/model-networking/src/model-networking/ModelCache.cpp b/libraries/model-networking/src/model-networking/ModelCache.cpp index e96815d391..2686f1c9a8 100644 --- a/libraries/model-networking/src/model-networking/ModelCache.cpp +++ b/libraries/model-networking/src/model-networking/ModelCache.cpp @@ -551,7 +551,7 @@ graphics::TextureMapPointer NetworkMaterial::fetchTextureMap(const QUrl& baseUrl graphics::TextureMapPointer NetworkMaterial::fetchTextureMap(const QUrl& url, image::TextureUsage::Type type, MapChannel channel) { auto textureCache = DependencyManager::get(); - if (textureCache) { + if (textureCache && !url.isEmpty()) { auto texture = textureCache->getTexture(url, type); _textures[channel].texture = texture; From 01b29185ca50c7cf2ce6d966193a99c9afcdbf7b Mon Sep 17 00:00:00 2001 From: Ryan Huffman Date: Thu, 1 Nov 2018 09:22:56 -0700 Subject: [PATCH 20/34] Update the default edit.js collidesWith to include myAvatar --- scripts/system/edit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/system/edit.js b/scripts/system/edit.js index 6425806771..c3f8f1f4e7 100644 --- a/scripts/system/edit.js +++ b/scripts/system/edit.js @@ -278,7 +278,7 @@ const DEFAULT_ENTITY_PROPERTIES = { All: { description: "", rotation: { x: 0, y: 0, z: 0, w: 1 }, - collidesWith: "static,dynamic,kinematic,otherAvatar", + collidesWith: "static,dynamic,kinematic,otherAvatar,myAvatar", collisionSoundURL: "", cloneable: false, ignoreIK: true, From 4593b33435ea1a73d7c011e0ac1e33f692cf876f Mon Sep 17 00:00:00 2001 From: Ryan Huffman Date: Thu, 1 Nov 2018 14:23:21 -0700 Subject: [PATCH 21/34] Fix new models not obeying dynamic checkbox --- scripts/system/edit.js | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/scripts/system/edit.js b/scripts/system/edit.js index 6425806771..43b82c8615 100644 --- a/scripts/system/edit.js +++ b/scripts/system/edit.js @@ -484,23 +484,28 @@ var toolBar = (function () { originalProperties[key] = newProperties[key]; } } - function createNewEntity(properties) { - var dimensions = properties.dimensions ? properties.dimensions : DEFAULT_DIMENSIONS; + function createNewEntity(requestedProperties) { + var dimensions = requestedProperties.dimensions ? requestedProperties.dimensions : DEFAULT_DIMENSIONS; var position = getPositionToCreateEntity(); var entityID = null; + var properties = {}; + applyProperties(properties, DEFAULT_ENTITY_PROPERTIES.All); - var type = properties.type; + var type = requestedProperties.type; if (type == "Box" || type == "Sphere") { applyProperties(properties, DEFAULT_ENTITY_PROPERTIES.Shape); } else if (type == "Image") { - properties.type = "Model"; + requestedProperties.type = "Model"; applyProperties(properties, DEFAULT_ENTITY_PROPERTIES.Image); } else { applyProperties(properties, DEFAULT_ENTITY_PROPERTIES[type]); } + // We apply the requested properties first so that they take priority over any default properties. + applyProperties(properties, requestedProperties); + if (position !== null && position !== undefined) { var direction; @@ -845,41 +850,18 @@ var toolBar = (function () { addButton("newCubeButton", function () { createNewEntity({ type: "Box", - dimensions: DEFAULT_DIMENSIONS, - color: { - red: 255, - green: 0, - blue: 0 - } }); }); addButton("newSphereButton", function () { createNewEntity({ type: "Sphere", - dimensions: DEFAULT_DIMENSIONS, - color: { - red: 255, - green: 0, - blue: 0 - } }); }); addButton("newLightButton", function () { createNewEntity({ type: "Light", - isSpotlight: false, - color: { - red: 150, - green: 150, - blue: 150 - }, - constantAttenuation: 1, - linearAttenuation: 0, - quadraticAttenuation: 0, - exponent: 0, - cutoff: 180 // in degrees }); }); From bccb3f1de9ea68e6d722d77924be490854ca04e6 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Thu, 1 Nov 2018 14:53:10 -0700 Subject: [PATCH 22/34] Prepare for further work --- scripts/defaultScripts.js | 3 +-- scripts/system/tts/TTS.js | 28 ---------------------------- scripts/system/tts/tts-a.svg | 9 --------- scripts/system/tts/tts-i.svg | 9 --------- 4 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 scripts/system/tts/TTS.js delete mode 100644 scripts/system/tts/tts-a.svg delete mode 100644 scripts/system/tts/tts-i.svg diff --git a/scripts/defaultScripts.js b/scripts/defaultScripts.js index 5ed74fd833..5df1b3e511 100644 --- a/scripts/defaultScripts.js +++ b/scripts/defaultScripts.js @@ -32,8 +32,7 @@ var DEFAULT_SCRIPTS_COMBINED = [ "system/firstPersonHMD.js", "system/tablet-ui/tabletUI.js", "system/emote.js", - "system/miniTablet.js", - "system/tts/TTS.js" + "system/miniTablet.js" ]; var DEFAULT_SCRIPTS_SEPARATE = [ "system/controllers/controllerScripts.js", diff --git a/scripts/system/tts/TTS.js b/scripts/system/tts/TTS.js deleted file mode 100644 index 36259cfda0..0000000000 --- a/scripts/system/tts/TTS.js +++ /dev/null @@ -1,28 +0,0 @@ -"use strict"; -/*jslint vars:true, plusplus:true, forin:true*/ -/*global Tablet, Script, */ -/* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ -// -// TTS.js -// -// Created by Zach Fox on 2018-10-10 -// Copyright 2018 High Fidelity, Inc -// -// Distributed under the Apache License, Version 2.0 -// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html -// - -(function () { // BEGIN LOCAL_SCOPE -var AppUi = Script.require('appUi'); - -var ui; -function startup() { - ui = new AppUi({ - buttonName: "TTS", - //home: Script.resolvePath("TTS.qml") - home: "hifi/tts/TTS.qml", - graphicsDirectory: Script.resolvePath("./") // speech by Danil Polshin from the Noun Project - }); -} -startup(); -}()); // END LOCAL_SCOPE diff --git a/scripts/system/tts/tts-a.svg b/scripts/system/tts/tts-a.svg deleted file mode 100644 index 9dac3a2d53..0000000000 --- a/scripts/system/tts/tts-a.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/scripts/system/tts/tts-i.svg b/scripts/system/tts/tts-i.svg deleted file mode 100644 index 1c52ec3193..0000000000 --- a/scripts/system/tts/tts-i.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - From 20cd1df22cf1f48bbf3c27a7347518c1022afae7 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Thu, 1 Nov 2018 16:48:08 -0700 Subject: [PATCH 23/34] Lotsa changes --- interface/resources/qml/hifi/tts/TTS.qml | 12 +++- interface/src/Application.cpp | 4 -- .../src/scripting/TTSScriptingInterface.cpp | 55 +++++++------------ .../src/scripting/TTSScriptingInterface.h | 15 ++--- libraries/audio-client/src/AudioClient.cpp | 41 -------------- libraries/audio-client/src/AudioClient.h | 5 -- 6 files changed, 36 insertions(+), 96 deletions(-) diff --git a/interface/resources/qml/hifi/tts/TTS.qml b/interface/resources/qml/hifi/tts/TTS.qml index 114efd0cca..d9507f6084 100644 --- a/interface/resources/qml/hifi/tts/TTS.qml +++ b/interface/resources/qml/hifi/tts/TTS.qml @@ -202,7 +202,6 @@ Rectangle { TextArea { id: messageToSpeak; - placeholderText: "Message to Speak"; font.family: "Fira Sans SemiBold"; font.pixelSize: 20; // Anchors @@ -229,6 +228,17 @@ Rectangle { event.accepted = true; } } + + HifiStylesUit.FiraSansRegular { + text: "Input Text to Speak..."; + size: 20; + anchors.fill: parent; + anchors.topMargin: 4; + anchors.leftMargin: 4; + color: hifi.colors.lightGrayText; + visible: !parent.activeFocus && messageToSpeak.text === ""; + verticalAlignment: Text.AlignTop; + } } HifiControlsUit.Button { diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index dfb4ef1c32..967a31135c 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -1187,10 +1187,6 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo recording::Frame::registerFrameHandler(AudioConstants::getAudioFrameName(), [&audioIO](recording::Frame::ConstPointer frame) { audioIO->handleRecordedAudioInput(frame->data); }); - - auto TTS = DependencyManager::get().data(); - connect(TTS, &TTSScriptingInterface::ttsSampleCreated, audioIO, &AudioClient::handleTTSAudioInput); - connect(TTS, &TTSScriptingInterface::clearTTSBuffer, audioIO, &AudioClient::clearTTSBuffer); connect(audioIO, &AudioClient::inputReceived, [](const QByteArray& audio) { static auto recorder = DependencyManager::get(); diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index f51a638471..6a5b72ea5f 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -37,6 +37,9 @@ TTSScriptingInterface::TTSScriptingInterface() { if (FAILED(hr)) { qDebug() << "Can't set default voice."; } + + _lastSoundAudioInjectorUpdateTimer.setSingleShot(true); + connect(&_lastSoundAudioInjectorUpdateTimer, &QTimer::timeout, this, &TTSScriptingInterface::updateLastSoundAudioInjector); #endif } @@ -58,38 +61,22 @@ private: }; #endif -void TTSScriptingInterface::testTone(const bool& alsoInject) { - QByteArray byteArray(480000, 0); - _lastSoundByteArray.resize(0); - _lastSoundByteArray.resize(480000); - - int32_t a = 0; - int16_t* samples = reinterpret_cast(byteArray.data()); - for (a = 0; a < 240000; a++) { - int16_t temp = (glm::sin(glm::radians((float)a))) * 32768; - samples[a] = temp; - } - emit ttsSampleCreated(_lastSoundByteArray, AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50, 96); - - if (alsoInject) { +const std::chrono::milliseconds INJECTOR_INTERVAL_MS = std::chrono::milliseconds(100); +void TTSScriptingInterface::updateLastSoundAudioInjector() { + if (_lastSoundAudioInjector) { AudioInjectorOptions options; options.position = DependencyManager::get()->getMyAvatarPosition(); - - _lastSoundAudioInjector = AudioInjector::playSound(_lastSoundByteArray, options); + _lastSoundAudioInjector->setOptions(options); + _lastSoundAudioInjectorUpdateTimer.start(INJECTOR_INTERVAL_MS); } } -void TTSScriptingInterface::speakText(const QString& textToSpeak, - const int& newChunkSize, - const int& timerInterval, - const int& sampleRate, - const int& bitsPerSample, - const bool& alsoInject) { +void TTSScriptingInterface::speakText(const QString& textToSpeak) { #ifdef WIN32 WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; - fmt.nSamplesPerSec = sampleRate; - fmt.wBitsPerSample = bitsPerSample; + fmt.nSamplesPerSec = 24000; + fmt.wBitsPerSample = 16; fmt.nChannels = 1; fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; fmt.nAvgBytesPerSec = fmt.nSamplesPerSec * fmt.nBlockAlign; @@ -156,16 +143,17 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, _lastSoundByteArray.resize(0); _lastSoundByteArray.append(buf1, dwSize); - // Commented out because this doesn't work completely :) - // Obviously, commenting this out isn't fit for production, but it's fine for a test PR - //emit ttsSampleCreated(_lastSoundByteArray, newChunkSize, timerInterval); + AudioInjectorOptions options; + options.position = DependencyManager::get()->getMyAvatarPosition(); - if (alsoInject) { - AudioInjectorOptions options; - options.position = DependencyManager::get()->getMyAvatarPosition(); - - _lastSoundAudioInjector = AudioInjector::playSound(_lastSoundByteArray, options); + if (_lastSoundAudioInjector) { + _lastSoundAudioInjector->stop(); + _lastSoundAudioInjectorUpdateTimer.stop(); } + + _lastSoundAudioInjector = AudioInjector::playSoundAndDelete(_lastSoundByteArray, options); + + _lastSoundAudioInjectorUpdateTimer.start(INJECTOR_INTERVAL_MS); #else qDebug() << "Text-to-Speech isn't currently supported on non-Windows platforms."; #endif @@ -174,7 +162,6 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak, void TTSScriptingInterface::stopLastSpeech() { if (_lastSoundAudioInjector) { _lastSoundAudioInjector->stop(); + _lastSoundAudioInjector = NULL; } - - emit clearTTSBuffer(); } diff --git a/interface/src/scripting/TTSScriptingInterface.h b/interface/src/scripting/TTSScriptingInterface.h index 7e4f3afa9d..0f1e723885 100644 --- a/interface/src/scripting/TTSScriptingInterface.h +++ b/interface/src/scripting/TTSScriptingInterface.h @@ -12,6 +12,7 @@ #define hifi_SpeechScriptingInterface_h #include +#include #include #ifdef WIN32 #pragma warning(disable : 4996) @@ -31,19 +32,9 @@ public: TTSScriptingInterface(); ~TTSScriptingInterface(); - Q_INVOKABLE void testTone(const bool& alsoInject = false); - Q_INVOKABLE void speakText(const QString& textToSpeak, - const int& newChunkSize = (AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50), - const int& timerInterval = 96, - const int& sampleRate = 24000, - const int& bitsPerSample = 16, - const bool& alsoInject = false); + Q_INVOKABLE void speakText(const QString& textToSpeak); Q_INVOKABLE void stopLastSpeech(); -signals: - void ttsSampleCreated(QByteArray outputArray, const int& newChunkSize, const int& timerInterval); - void clearTTSBuffer(); - private: #ifdef WIN32 class CComAutoInit { @@ -90,6 +81,8 @@ private: QByteArray _lastSoundByteArray; AudioInjectorPointer _lastSoundAudioInjector; + QTimer _lastSoundAudioInjectorUpdateTimer; + void updateLastSoundAudioInjector(); }; #endif // hifi_SpeechScriptingInterface_h diff --git a/libraries/audio-client/src/AudioClient.cpp b/libraries/audio-client/src/AudioClient.cpp index 1ce6c15951..b7557681a5 100644 --- a/libraries/audio-client/src/AudioClient.cpp +++ b/libraries/audio-client/src/AudioClient.cpp @@ -245,8 +245,6 @@ AudioClient::AudioClient() : packetReceiver.registerListener(PacketType::NoisyMute, this, "handleNoisyMutePacket"); packetReceiver.registerListener(PacketType::MuteEnvironment, this, "handleMuteEnvironmentPacket"); packetReceiver.registerListener(PacketType::SelectedAudioFormat, this, "handleSelectedAudioFormat"); - - connect(&_TTSTimer, &QTimer::timeout, this, &AudioClient::processTTSBuffer); } AudioClient::~AudioClient() { @@ -1202,45 +1200,6 @@ int rawToWav(const char* rawData, const int& rawLength, const char* wavfn, long return 0; } -void AudioClient::processTTSBuffer() { - Lock lock(_TTSMutex); - if (_TTSAudioBuffer.size() > 0) { - QByteArray part; - part.append(_TTSAudioBuffer.data(), _TTSChunkSize); - _TTSAudioBuffer.remove(0, _TTSChunkSize); - handleAudioInput(part); - } else { - _isProcessingTTS = false; - _TTSTimer.stop(); - } -} - -void AudioClient::handleTTSAudioInput(const QByteArray& audio, const int& newChunkSize, const int& timerInterval) { - _TTSChunkSize = newChunkSize; - _TTSAudioBuffer.append(audio); - - handleLocalEchoAndReverb(_TTSAudioBuffer, 48000, 1); - - //QString filename = QString::number(usecTimestampNow()); - //QString path = PathUtils::getAppDataPath() + "Audio/" + filename + "-before.wav"; - //rawToWav(_TTSAudioBuffer.data(), _TTSAudioBuffer.size(), path.toLocal8Bit(), 24000, 1); - - //QByteArray temp; - - _isProcessingTTS = true; - _TTSTimer.start(timerInterval); - - //filename = QString::number(usecTimestampNow()); - //path = PathUtils::getAppDataPath() + "Audio/" + filename + "-after.wav"; - //rawToWav(temp.data(), temp.size(), path.toLocal8Bit(), 12000, 1); -} - -void AudioClient::clearTTSBuffer() { - _TTSAudioBuffer.resize(0); - _isProcessingTTS = false; - _TTSTimer.stop(); -} - void AudioClient::prepareLocalAudioInjectors(std::unique_ptr localAudioLock) { bool doSynchronously = localAudioLock.operator bool(); if (!localAudioLock) { diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 9b50d3eccb..788b764903 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -197,11 +197,6 @@ public slots: void checkInputTimeout(); void handleDummyAudioInput(); void handleRecordedAudioInput(const QByteArray& audio); - void handleTTSAudioInput(const QByteArray& audio, - const int& newChunkSize, - const int& timerInterval); - void clearTTSBuffer(); - void processTTSBuffer(); void reset(); void audioMixerKilled(); From 9ca862ad6bdaa6f6822976362df18b14906ffd60 Mon Sep 17 00:00:00 2001 From: Roxanne Skelly Date: Thu, 1 Nov 2018 17:22:47 -0700 Subject: [PATCH 24/34] Case 19373 - Lowering volume on another user in PAL mutes them --- assignment-client/src/audio/AudioMixerClientData.cpp | 2 +- assignment-client/src/audio/AudioMixerClientData.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assignment-client/src/audio/AudioMixerClientData.cpp b/assignment-client/src/audio/AudioMixerClientData.cpp index 7e1420ef60..7f0838d8a5 100644 --- a/assignment-client/src/audio/AudioMixerClientData.cpp +++ b/assignment-client/src/audio/AudioMixerClientData.cpp @@ -202,7 +202,7 @@ void AudioMixerClientData::parsePerAvatarGainSet(ReceivedMessage& message, const } } -void AudioMixerClientData::setGainForAvatar(QUuid nodeID, uint8_t gain) { +void AudioMixerClientData::setGainForAvatar(QUuid nodeID, float gain) { auto it = std::find_if(_streams.active.cbegin(), _streams.active.cend(), [nodeID](const MixableStream& mixableStream){ return mixableStream.nodeStreamID.nodeID == nodeID && mixableStream.nodeStreamID.streamID.isNull(); }); diff --git a/assignment-client/src/audio/AudioMixerClientData.h b/assignment-client/src/audio/AudioMixerClientData.h index 610b258789..0a66a8164b 100644 --- a/assignment-client/src/audio/AudioMixerClientData.h +++ b/assignment-client/src/audio/AudioMixerClientData.h @@ -172,7 +172,7 @@ private: void optionallyReplicatePacket(ReceivedMessage& packet, const Node& node); - void setGainForAvatar(QUuid nodeID, uint8_t gain); + void setGainForAvatar(QUuid nodeID, float gain); bool containsValidPosition(ReceivedMessage& message) const; From b33934ec467005517562025e3548a195741a7a31 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Thu, 1 Nov 2018 15:43:56 -0700 Subject: [PATCH 25/34] fixing Image3DOverlay scaling --- interface/src/ui/overlays/Image3DOverlay.cpp | 14 ++++++++-- interface/src/ui/overlays/Image3DOverlay.h | 1 + scripts/system/interstitialPage.js | 29 ++++++++++---------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/interface/src/ui/overlays/Image3DOverlay.cpp b/interface/src/ui/overlays/Image3DOverlay.cpp index 18da15f019..e24e3b3ed8 100644 --- a/interface/src/ui/overlays/Image3DOverlay.cpp +++ b/interface/src/ui/overlays/Image3DOverlay.cpp @@ -98,8 +98,8 @@ void Image3DOverlay::render(RenderArgs* args) { } float maxSize = glm::max(fromImage.width(), fromImage.height()); - float x = fromImage.width() / (2.0f * maxSize); - float y = -fromImage.height() / (2.0f * maxSize); + float x = _keepAspectRatio ? fromImage.width() / (2.0f * maxSize) : 0.5f; + float y = _keepAspectRatio ? -fromImage.height() / (2.0f * maxSize) : -0.5f; glm::vec2 topLeft(-x, -y); glm::vec2 bottomRight(x, y); @@ -176,6 +176,11 @@ void Image3DOverlay::setProperties(const QVariantMap& properties) { } } + auto keepAspectRatioValue = properties["keepAspectRatio"]; + if (keepAspectRatioValue.isValid()) { + _keepAspectRatio = keepAspectRatioValue.toBool(); + } + auto emissiveValue = properties["emissive"]; if (emissiveValue.isValid()) { _emissive = emissiveValue.toBool(); @@ -225,6 +230,8 @@ void Image3DOverlay::setProperties(const QVariantMap& properties) { * * @property {Vec2} dimensions=1,1 - The dimensions of the overlay. Synonyms: scale, size. * + * @property {bool} keepAspectRatio=true - overlays will maintain the aspect ratio when the subImage is applied. + * * @property {boolean} isFacingAvatar - If true, the overlay is rotated to face the user's camera about an axis * parallel to the user's avatar's "up" direction. * @@ -246,6 +253,9 @@ QVariant Image3DOverlay::getProperty(const QString& property) { if (property == "emissive") { return _emissive; } + if (property == "keepAspectRatio") { + return _keepAspectRatio; + } return Billboard3DOverlay::getProperty(property); } diff --git a/interface/src/ui/overlays/Image3DOverlay.h b/interface/src/ui/overlays/Image3DOverlay.h index 1ffa062d45..1000401abb 100644 --- a/interface/src/ui/overlays/Image3DOverlay.h +++ b/interface/src/ui/overlays/Image3DOverlay.h @@ -58,6 +58,7 @@ private: bool _textureIsLoaded { false }; bool _alphaTexture { false }; bool _emissive { false }; + bool _keepAspectRatio { true }; QRect _fromImage; // where from in the image to sample int _geometryId { 0 }; diff --git a/scripts/system/interstitialPage.js b/scripts/system/interstitialPage.js index e6e8d3d6d6..e2db032d8c 100644 --- a/scripts/system/interstitialPage.js +++ b/scripts/system/interstitialPage.js @@ -16,10 +16,7 @@ Script.include("/~/system/libraries/Xform.js"); Script.include("/~/system/libraries/globals.js"); var DEBUG = false; - var MIN_LOADING_PROGRESS = 3.6; var TOTAL_LOADING_PROGRESS = 3.7; - var FINAL_Y_DIMENSIONS = 2.8; - var BEGIN_Y_DIMENSIONS = 0.03; var EPSILON = 0.05; var TEXTURE_EPSILON = 0.01; var isVisible = false; @@ -191,14 +188,15 @@ localPosition: { x: 0.0, y: -0.86, z: 0.0 }, url: Script.resourcesPath() + "images/loadingBar_progress.png", alpha: 1, - dimensions: { x: 3.8, y: 2.8 }, + dimensions: { x: TOTAL_LOADING_PROGRESS, y: 0.3}, visible: isVisible, emissive: true, ignoreRayIntersection: false, drawInFront: true, grabbable: false, localOrientation: Quat.fromVec3Degrees({ x: 0.0, y: 180.0, z: 0.0 }), - parentID: anchorOverlay + parentID: anchorOverlay, + keepAspectRatio: false }); var loadingBarPlacard = Overlays.addOverlay("image3d", { @@ -259,10 +257,11 @@ target = 0; textureMemSizeStabilityCount = 0; textureMemSizeAtLastCheck = 0; - currentProgress = 0.1; + currentProgress = 0.0; connectionToDomainFailed = false; previousCameraMode = Camera.mode; Camera.mode = "first person"; + updateProgressBar(0.0); timer = Script.setTimeout(update, 2000); } } @@ -373,7 +372,7 @@ } } - var currentProgress = 0.1; + var currentProgress = 0.0; function updateOverlays(physicsEnabled) { @@ -396,7 +395,6 @@ }; var loadingBarProperties = { - dimensions: { x: 2.0, y: 2.8 }, visible: !physicsEnabled }; @@ -458,19 +456,22 @@ function updateProgressBar(progress) { var progressPercentage = progress / TOTAL_LOADING_PROGRESS; var subImageWidth = progressPercentage * LOADING_IMAGE_WIDTH_PIXELS; - var subImageWidthPercentage = subImageWidth / LOADING_IMAGE_WIDTH_PIXELS; + var start = TOTAL_LOADING_PROGRESS / 2; + var end = 0; + var xLocalPosition = (progressPercentage * (end - start)) + start; var properties = { - localPosition: { x: (TOTAL_LOADING_PROGRESS / 2) - (currentProgress / 2), y: -0.86, z: 0.0 }, + localPosition: { x: xLocalPosition, y: -0.93, z: 0.0 }, dimensions: { - x: currentProgress, - y: (subImageWidthPercentage * (FINAL_Y_DIMENSIONS - BEGIN_Y_DIMENSIONS)) + BEGIN_Y_DIMENSIONS + x: progress, + y: 0.3 }, + localOrientation: Quat.fromVec3Degrees({ x: 0.0, y: 180.0, z: 0.0 }), subImage: { x: 0.0, y: 0.0, width: subImageWidth, - height: 90 + height: 128 } }; @@ -479,7 +480,7 @@ var MAX_TEXTURE_STABILITY_COUNT = 30; var INTERVAL_PROGRESS = 0.04; - var INTERVAL_PROGRESS_PHYSICS_ENABLED = 0.2; + var INTERVAL_PROGRESS_PHYSICS_ENABLED = 0.09; function update() { var renderStats = Render.getConfig("Stats"); var physicsEnabled = Window.isPhysicsEnabled(); From c22df4dd947134fb7bd807cd3d843d935bacbb39 Mon Sep 17 00:00:00 2001 From: Ryan Huffman Date: Fri, 2 Nov 2018 16:20:35 -0700 Subject: [PATCH 26/34] Fix horizontal and vertical props in entity properties being swapped --- scripts/system/html/js/entityProperties.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/system/html/js/entityProperties.js b/scripts/system/html/js/entityProperties.js index 78de0d075a..1ce4626b8f 100644 --- a/scripts/system/html/js/entityProperties.js +++ b/scripts/system/html/js/entityProperties.js @@ -902,13 +902,13 @@ const GROUPS = [ { label: "Horizontal Angle Start", type: "slider", - min: -180, - max: 0, + min: 0, + max: 180, step: 1, decimals: 0, multiplier: DEGREES_TO_RADIANS, unit: "deg", - propertyID: "azimuthStart", + propertyID: "polarStart", }, { label: "Horizontal Angle Finish", @@ -919,18 +919,18 @@ const GROUPS = [ decimals: 0, multiplier: DEGREES_TO_RADIANS, unit: "deg", - propertyID: "azimuthFinish", + propertyID: "polarFinish", }, { label: "Vertical Angle Start", type: "slider", - min: 0, - max: 180, + min: -180, + max: 0, step: 1, decimals: 0, multiplier: DEGREES_TO_RADIANS, unit: "deg", - propertyID: "polarStart", + propertyID: "azimuthStart", }, { label: "Vertical Angle Finish", @@ -941,7 +941,7 @@ const GROUPS = [ decimals: 0, multiplier: DEGREES_TO_RADIANS, unit: "deg", - propertyID: "polarFinish", + propertyID: "azimuthFinish", }, ] }, From 53742e90f57d096f8aa73eec369e108f636e2927 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 6 Nov 2018 10:30:10 -0800 Subject: [PATCH 27/34] Cleanup after merge --- libraries/audio-client/src/AudioClient.h | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/libraries/audio-client/src/AudioClient.h b/libraries/audio-client/src/AudioClient.h index 788b764903..5e7f1fb8a0 100644 --- a/libraries/audio-client/src/AudioClient.h +++ b/libraries/audio-client/src/AudioClient.h @@ -395,7 +395,7 @@ private: void configureReverb(); void updateReverbOptions(); - void handleLocalEchoAndReverb(QByteArray& inputByteArray, const int& sampleRate, const int& channelCount); + void handleLocalEchoAndReverb(QByteArray& inputByteArray); bool switchInputToAudioDevice(const QAudioDeviceInfo inputDeviceInfo, bool isShutdownRequest = false); bool switchOutputToAudioDevice(const QAudioDeviceInfo outputDeviceInfo, bool isShutdownRequest = false); @@ -453,12 +453,6 @@ private: QTimer* _checkPeakValuesTimer { nullptr }; bool _isRecording { false }; - - Mutex _TTSMutex; - bool _isProcessingTTS { false }; - QByteArray _TTSAudioBuffer; - int _TTSChunkSize = AudioConstants::NETWORK_FRAME_SAMPLES_PER_CHANNEL * 50; - QTimer _TTSTimer; }; From c33f9b6ea311e8a88b7ad69e6a9c37da9fe56572 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 6 Nov 2018 11:26:09 -0800 Subject: [PATCH 28/34] Fix build error --- interface/src/Application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 07705c8cd5..bd126048dd 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2955,7 +2955,7 @@ void Application::initializeUi() { QUrl{ "hifi/dialogs/security/SecurityImageChange.qml" }, QUrl{ "hifi/dialogs/security/SecurityImageModel.qml" }, QUrl{ "hifi/dialogs/security/SecurityImageSelection.qml" }, - }, callback); + }, commerceCallback); QmlContextCallback ttsCallback = [](QQmlContext* context) { context->setContextProperty("TextToSpeech", DependencyManager::get().data()); }; From 378bf911d447d02611b1f65dabc621a93b4fd774 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Tue, 6 Nov 2018 12:48:17 -0800 Subject: [PATCH 29/34] Fix another build error --- interface/src/scripting/TTSScriptingInterface.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index 6a5b72ea5f..b41f22759c 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -61,7 +61,7 @@ private: }; #endif -const std::chrono::milliseconds INJECTOR_INTERVAL_MS = std::chrono::milliseconds(100); +const int INJECTOR_INTERVAL_MS = 100; void TTSScriptingInterface::updateLastSoundAudioInjector() { if (_lastSoundAudioInjector) { AudioInjectorOptions options; From d316fe15ce24fb406f449bf10fa4ec3f6eccf909 Mon Sep 17 00:00:00 2001 From: SamGondelman Date: Wed, 7 Nov 2018 15:02:32 -0800 Subject: [PATCH 30/34] fix tablet culling --- interface/src/ui/overlays/Base3DOverlay.cpp | 1 + interface/src/ui/overlays/Base3DOverlay.h | 2 -- interface/src/ui/overlays/ModelOverlay.cpp | 2 ++ interface/src/ui/overlays/Overlay.cpp | 2 +- interface/src/ui/overlays/Volume3DOverlay.cpp | 18 ++++-------------- interface/src/ui/overlays/Volume3DOverlay.h | 3 +-- 6 files changed, 9 insertions(+), 19 deletions(-) diff --git a/interface/src/ui/overlays/Base3DOverlay.cpp b/interface/src/ui/overlays/Base3DOverlay.cpp index 14c1f1a15a..8599e05332 100644 --- a/interface/src/ui/overlays/Base3DOverlay.cpp +++ b/interface/src/ui/overlays/Base3DOverlay.cpp @@ -290,6 +290,7 @@ void Base3DOverlay::locationChanged(bool tellPhysics) { notifyRenderVariableChange(); } +// FIXME: Overlays shouldn't be deleted when their parents are void Base3DOverlay::parentDeleted() { qApp->getOverlays().deleteOverlay(getOverlayID()); } diff --git a/interface/src/ui/overlays/Base3DOverlay.h b/interface/src/ui/overlays/Base3DOverlay.h index 6f6092a42e..6cc5182b56 100644 --- a/interface/src/ui/overlays/Base3DOverlay.h +++ b/interface/src/ui/overlays/Base3DOverlay.h @@ -59,8 +59,6 @@ public: void setIsGrabbable(bool value) { _isGrabbable = value; } virtual void setIsVisibleInSecondaryCamera(bool value) { _isVisibleInSecondaryCamera = value; } - virtual AABox getBounds() const override = 0; - void update(float deltatime) override; void notifyRenderVariableChange() const; diff --git a/interface/src/ui/overlays/ModelOverlay.cpp b/interface/src/ui/overlays/ModelOverlay.cpp index 190d9c3895..fa4ced84a8 100644 --- a/interface/src/ui/overlays/ModelOverlay.cpp +++ b/interface/src/ui/overlays/ModelOverlay.cpp @@ -60,6 +60,8 @@ ModelOverlay::ModelOverlay(const ModelOverlay* modelOverlay) : } void ModelOverlay::update(float deltatime) { + Base3DOverlay::update(deltatime); + if (_updateModel) { _updateModel = false; _model->setSnapModelToCenter(true); diff --git a/interface/src/ui/overlays/Overlay.cpp b/interface/src/ui/overlays/Overlay.cpp index 22cf924727..1bf94adfa0 100644 --- a/interface/src/ui/overlays/Overlay.cpp +++ b/interface/src/ui/overlays/Overlay.cpp @@ -247,7 +247,7 @@ void Overlay::removeMaterial(graphics::MaterialPointer material, const std::stri } render::ItemKey Overlay::getKey() { - auto builder = render::ItemKey::Builder().withTypeShape(); + auto builder = render::ItemKey::Builder().withTypeShape().withTypeMeta(); builder.withViewSpace(); builder.withLayer(render::hifi::LAYER_2D); diff --git a/interface/src/ui/overlays/Volume3DOverlay.cpp b/interface/src/ui/overlays/Volume3DOverlay.cpp index a307d445c0..0cceb44a36 100644 --- a/interface/src/ui/overlays/Volume3DOverlay.cpp +++ b/interface/src/ui/overlays/Volume3DOverlay.cpp @@ -20,11 +20,9 @@ Volume3DOverlay::Volume3DOverlay(const Volume3DOverlay* volume3DOverlay) : } AABox Volume3DOverlay::getBounds() const { - auto extents = Extents{_localBoundingBox}; - extents.rotate(getWorldOrientation()); - extents.shiftBy(getWorldPosition()); - - return AABox(extents); + AABox bounds = _localBoundingBox; + bounds.transform(getTransform()); + return bounds; } void Volume3DOverlay::setDimensions(const glm::vec3& value) { @@ -49,15 +47,7 @@ void Volume3DOverlay::setProperties(const QVariantMap& properties) { glm::vec3 scale = vec3FromVariant(dimensions); // don't allow a zero or negative dimension component to reach the renderTransform const float MIN_DIMENSION = 0.0001f; - if (scale.x < MIN_DIMENSION) { - scale.x = MIN_DIMENSION; - } - if (scale.y < MIN_DIMENSION) { - scale.y = MIN_DIMENSION; - } - if (scale.z < MIN_DIMENSION) { - scale.z = MIN_DIMENSION; - } + scale = glm::max(scale, MIN_DIMENSION); setDimensions(scale); } } diff --git a/interface/src/ui/overlays/Volume3DOverlay.h b/interface/src/ui/overlays/Volume3DOverlay.h index e4060ae335..2083f7344a 100644 --- a/interface/src/ui/overlays/Volume3DOverlay.h +++ b/interface/src/ui/overlays/Volume3DOverlay.h @@ -24,7 +24,6 @@ public: virtual AABox getBounds() const override; const glm::vec3& getDimensions() const { return _localBoundingBox.getDimensions(); } - void setDimensions(float value) { setDimensions(glm::vec3(value)); } void setDimensions(const glm::vec3& value); void setProperties(const QVariantMap& properties) override; @@ -37,7 +36,7 @@ public: protected: // Centered local bounding box - AABox _localBoundingBox{ vec3(0.0f), 1.0f }; + AABox _localBoundingBox { vec3(-0.5), 1.0f }; Transform evalRenderTransform() override; }; From cee1454f6e963598fe6409224bd3c7f070760a57 Mon Sep 17 00:00:00 2001 From: Zach Fox Date: Wed, 7 Nov 2018 15:09:53 -0800 Subject: [PATCH 31/34] CR feedback - thanks Ken! --- interface/src/scripting/TTSScriptingInterface.cpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/interface/src/scripting/TTSScriptingInterface.cpp b/interface/src/scripting/TTSScriptingInterface.cpp index b41f22759c..6b1677aecb 100644 --- a/interface/src/scripting/TTSScriptingInterface.cpp +++ b/interface/src/scripting/TTSScriptingInterface.cpp @@ -75,7 +75,7 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak) { #ifdef WIN32 WAVEFORMATEX fmt; fmt.wFormatTag = WAVE_FORMAT_PCM; - fmt.nSamplesPerSec = 24000; + fmt.nSamplesPerSec = AudioConstants::SAMPLE_RATE; fmt.wBitsPerSample = 16; fmt.nChannels = 1; fmt.nBlockAlign = fmt.nChannels * fmt.wBitsPerSample / 8; @@ -132,17 +132,13 @@ void TTSScriptingInterface::speakText(const QString& textToSpeak) { hr = IStream_Size(pStream, &StreamSize); DWORD dwSize = StreamSize.QuadPart; - char* buf1 = new char[dwSize + 1]; - memset(buf1, 0, dwSize + 1); + _lastSoundByteArray.resize(dwSize); - hr = IStream_Read(pStream, buf1, dwSize); + hr = IStream_Read(pStream, _lastSoundByteArray.data(), dwSize); if (FAILED(hr)) { qDebug() << "Couldn't read from stream."; } - _lastSoundByteArray.resize(0); - _lastSoundByteArray.append(buf1, dwSize); - AudioInjectorOptions options; options.position = DependencyManager::get()->getMyAvatarPosition(); From 501746b156c9c839278b3fb33384e6dc43330858 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Mon, 29 Oct 2018 16:37:10 -0700 Subject: [PATCH 32/34] 3D Keyboard --- interface/resources/config/keyboard.json | 2476 +++++++++++++++++ interface/resources/meshes/drumstick.fbx | Bin 0 -> 63952 bytes .../resources/meshes/keyboard/SM_enter.fbx | Bin 0 -> 33248 bytes .../resources/meshes/keyboard/SM_key.fbx | Bin 0 -> 32448 bytes .../resources/meshes/keyboard/SM_space.fbx | Bin 0 -> 30400 bytes .../resources/meshes/keyboard/keyCap_F.png | Bin 0 -> 1670 bytes .../resources/meshes/keyboard/keyCap_a.png | Bin 0 -> 3081 bytes .../resources/meshes/keyboard/keyCap_b.png | Bin 0 -> 2744 bytes .../resources/meshes/keyboard/keyCap_c.png | Bin 0 -> 3421 bytes .../resources/meshes/keyboard/keyCap_d.png | Bin 0 -> 2648 bytes .../resources/meshes/keyboard/keyCap_e.png | Bin 0 -> 1689 bytes .../resources/meshes/keyboard/keyCap_g.png | Bin 0 -> 3298 bytes .../resources/meshes/keyboard/keyCap_h.png | Bin 0 -> 1616 bytes .../resources/meshes/keyboard/keyCap_i.png | Bin 0 -> 1581 bytes .../resources/meshes/keyboard/keyCap_j.png | Bin 0 -> 2213 bytes .../resources/meshes/keyboard/keyCap_k.png | Bin 0 -> 2800 bytes .../resources/meshes/keyboard/keyCap_l.png | Bin 0 -> 1631 bytes .../resources/meshes/keyboard/keyCap_m.png | Bin 0 -> 2714 bytes .../resources/meshes/keyboard/keyCap_n.png | Bin 0 -> 2230 bytes .../resources/meshes/keyboard/keyCap_o.png | Bin 0 -> 3508 bytes .../resources/meshes/keyboard/keyCap_p.png | Bin 0 -> 2282 bytes .../resources/meshes/keyboard/keyCap_q.png | Bin 0 -> 3681 bytes .../resources/meshes/keyboard/keyCap_r.png | Bin 0 -> 2833 bytes .../resources/meshes/keyboard/keyCap_s.png | Bin 0 -> 3405 bytes .../resources/meshes/keyboard/keyCap_t.png | Bin 0 -> 1641 bytes .../resources/meshes/keyboard/keyCap_u.png | Bin 0 -> 2537 bytes .../resources/meshes/keyboard/keyCap_v.png | Bin 0 -> 3024 bytes .../resources/meshes/keyboard/keyCap_w.png | Bin 0 -> 4149 bytes .../resources/meshes/keyboard/keyCap_x.png | Bin 0 -> 3327 bytes .../resources/meshes/keyboard/keyCap_y.png | Bin 0 -> 2701 bytes .../resources/meshes/keyboard/keyCap_z.png | Bin 0 -> 2394 bytes interface/resources/meshes/keyboard/key_0.png | Bin 0 -> 3132 bytes interface/resources/meshes/keyboard/key_1.png | Bin 0 -> 1909 bytes .../resources/meshes/keyboard/key_123.png | Bin 0 -> 4399 bytes interface/resources/meshes/keyboard/key_2.png | Bin 0 -> 2864 bytes interface/resources/meshes/keyboard/key_3.png | Bin 0 -> 3341 bytes interface/resources/meshes/keyboard/key_4.png | Bin 0 -> 2126 bytes interface/resources/meshes/keyboard/key_5.png | Bin 0 -> 3071 bytes interface/resources/meshes/keyboard/key_6.png | Bin 0 -> 3631 bytes interface/resources/meshes/keyboard/key_7.png | Bin 0 -> 1921 bytes interface/resources/meshes/keyboard/key_8.png | Bin 0 -> 3535 bytes interface/resources/meshes/keyboard/key_9.png | Bin 0 -> 3617 bytes interface/resources/meshes/keyboard/key_a.png | Bin 0 -> 3066 bytes .../resources/meshes/keyboard/key_abc.png | Bin 0 -> 5346 bytes .../meshes/keyboard/key_ampersand.png | Bin 0 -> 3695 bytes .../resources/meshes/keyboard/key_ast.png | Bin 0 -> 2196 bytes .../resources/meshes/keyboard/key_at.png | Bin 0 -> 4722 bytes interface/resources/meshes/keyboard/key_b.png | Bin 0 -> 2839 bytes .../meshes/keyboard/key_backspace.png | Bin 0 -> 1987 bytes interface/resources/meshes/keyboard/key_c.png | Bin 0 -> 2950 bytes .../resources/meshes/keyboard/key_cap.png | Bin 0 -> 2088 bytes .../resources/meshes/keyboard/key_caret.png | Bin 0 -> 2209 bytes .../meshes/keyboard/key_close_paren.png | Bin 0 -> 2621 bytes .../resources/meshes/keyboard/key_colon.png | Bin 0 -> 1640 bytes .../resources/meshes/keyboard/key_comma.png | Bin 0 -> 1785 bytes interface/resources/meshes/keyboard/key_d.png | Bin 0 -> 2962 bytes .../resources/meshes/keyboard/key_dollar.png | Bin 0 -> 3481 bytes .../resources/meshes/keyboard/key_dquote.png | Bin 0 -> 1918 bytes interface/resources/meshes/keyboard/key_e.png | Bin 0 -> 3029 bytes .../resources/meshes/keyboard/key_enter.png | Bin 0 -> 2201 bytes .../resources/meshes/keyboard/key_exclam.png | Bin 0 -> 1633 bytes .../resources/meshes/keyboard/key_exit.png | Bin 0 -> 2466 bytes interface/resources/meshes/keyboard/key_f.png | Bin 0 -> 2112 bytes interface/resources/meshes/keyboard/key_g.png | Bin 0 -> 3480 bytes interface/resources/meshes/keyboard/key_h.png | Bin 0 -> 2186 bytes .../resources/meshes/keyboard/key_hashtag.png | Bin 0 -> 2394 bytes interface/resources/meshes/keyboard/key_i.png | Bin 0 -> 1625 bytes interface/resources/meshes/keyboard/key_j.png | Bin 0 -> 2070 bytes interface/resources/meshes/keyboard/key_k.png | Bin 0 -> 2389 bytes interface/resources/meshes/keyboard/key_l.png | Bin 0 -> 1907 bytes interface/resources/meshes/keyboard/key_m.png | Bin 0 -> 2438 bytes .../resources/meshes/keyboard/key_min.png | Bin 0 -> 1557 bytes interface/resources/meshes/keyboard/key_n.png | Bin 0 -> 2165 bytes interface/resources/meshes/keyboard/key_o.png | Bin 0 -> 3054 bytes .../meshes/keyboard/key_open_paren.png | Bin 0 -> 2656 bytes interface/resources/meshes/keyboard/key_p.png | Bin 0 -> 2821 bytes .../meshes/keyboard/key_percentage.png | Bin 0 -> 4316 bytes .../resources/meshes/keyboard/key_period.png | Bin 0 -> 1591 bytes .../resources/meshes/keyboard/key_plus.png | Bin 0 -> 1635 bytes interface/resources/meshes/keyboard/key_q.png | Bin 0 -> 3012 bytes .../meshes/keyboard/key_question.png | Bin 0 -> 2727 bytes interface/resources/meshes/keyboard/key_r.png | Bin 0 -> 1897 bytes interface/resources/meshes/keyboard/key_s.png | Bin 0 -> 3080 bytes .../resources/meshes/keyboard/key_semi.png | Bin 0 -> 1797 bytes .../resources/meshes/keyboard/key_slash.png | Bin 0 -> 2580 bytes .../resources/meshes/keyboard/key_squote.png | Bin 0 -> 1783 bytes interface/resources/meshes/keyboard/key_t.png | Bin 0 -> 2038 bytes interface/resources/meshes/keyboard/key_u.png | Bin 0 -> 2232 bytes .../resources/meshes/keyboard/key_under.png | Bin 0 -> 1579 bytes interface/resources/meshes/keyboard/key_v.png | Bin 0 -> 2671 bytes interface/resources/meshes/keyboard/key_w.png | Bin 0 -> 3466 bytes interface/resources/meshes/keyboard/key_x.png | Bin 0 -> 2997 bytes interface/resources/meshes/keyboard/key_y.png | Bin 0 -> 3072 bytes interface/resources/meshes/keyboard/key_z.png | Bin 0 -> 2115 bytes .../meshes/keyboard/text_placard.png | Bin 0 -> 1308 bytes interface/resources/meshes/keyboard/white.png | Bin 0 -> 293 bytes .../resources/qml/controlsUit/Keyboard.qml | 20 +- .../qml/dialogs/TabletLoginDialog.qml | 4 + interface/resources/qml/hifi/AvatarApp.qml | 11 +- .../resources/qml/hifi/avatarapp/Settings.qml | 24 + .../qml/hifi/commerce/wallet/Wallet.qml | 4 + .../qml/hifi/dialogs/TabletRunningScripts.qml | 4 + interface/resources/qml/hifi/tablet/Edit.qml | 6 + .../qml/hifi/tablet/TabletAddressDialog.qml | 8 + .../tabletWindows/TabletPreferencesDialog.qml | 4 + interface/resources/sounds/keyboard_key.mp3 | Bin 0 -> 9089 bytes interface/src/Application.cpp | 17 +- interface/src/Menu.cpp | 2 + interface/src/Menu.h | 1 + .../src/raypick/PickScriptingInterface.cpp | 12 +- .../src/raypick/PointerScriptingInterface.cpp | 30 +- interface/src/raypick/RayPick.cpp | 8 +- interface/src/raypick/StylusPick.cpp | 18 +- interface/src/raypick/StylusPick.h | 9 +- interface/src/raypick/StylusPointer.cpp | 34 +- interface/src/raypick/StylusPointer.h | 7 +- .../src/scripting/HMDScriptingInterface.cpp | 7 +- .../src/scripting/HMDScriptingInterface.h | 2 + .../scripting/KeyboardScriptingInterface.cpp | 34 + .../scripting/KeyboardScriptingInterface.h | 43 + interface/src/ui/Keyboard.cpp | 799 ++++++ interface/src/ui/Keyboard.h | 152 + interface/src/ui/overlays/ModelOverlay.cpp | 2 +- interface/src/ui/overlays/Overlays.cpp | 53 +- interface/src/ui/overlays/Web3DOverlay.cpp | 2 + libraries/render-utils/src/Model.cpp | 2 +- .../ui/src/ui/TabletScriptingInterface.cpp | 6 + .../ui/src/ui/TabletScriptingInterface.h | 1 + .../controllerModules/inEditMode.js | 17 + .../nearParentGrabOverlay.js | 6 +- scripts/system/marketplaces/marketplaces.js | 5 + 131 files changed, 3777 insertions(+), 53 deletions(-) create mode 100644 interface/resources/config/keyboard.json create mode 100644 interface/resources/meshes/drumstick.fbx create mode 100644 interface/resources/meshes/keyboard/SM_enter.fbx create mode 100644 interface/resources/meshes/keyboard/SM_key.fbx create mode 100644 interface/resources/meshes/keyboard/SM_space.fbx create mode 100644 interface/resources/meshes/keyboard/keyCap_F.png create mode 100644 interface/resources/meshes/keyboard/keyCap_a.png create mode 100644 interface/resources/meshes/keyboard/keyCap_b.png create mode 100644 interface/resources/meshes/keyboard/keyCap_c.png create mode 100644 interface/resources/meshes/keyboard/keyCap_d.png create mode 100644 interface/resources/meshes/keyboard/keyCap_e.png create mode 100644 interface/resources/meshes/keyboard/keyCap_g.png create mode 100644 interface/resources/meshes/keyboard/keyCap_h.png create mode 100644 interface/resources/meshes/keyboard/keyCap_i.png create mode 100644 interface/resources/meshes/keyboard/keyCap_j.png create mode 100644 interface/resources/meshes/keyboard/keyCap_k.png create mode 100644 interface/resources/meshes/keyboard/keyCap_l.png create mode 100644 interface/resources/meshes/keyboard/keyCap_m.png create mode 100644 interface/resources/meshes/keyboard/keyCap_n.png create mode 100644 interface/resources/meshes/keyboard/keyCap_o.png create mode 100644 interface/resources/meshes/keyboard/keyCap_p.png create mode 100644 interface/resources/meshes/keyboard/keyCap_q.png create mode 100644 interface/resources/meshes/keyboard/keyCap_r.png create mode 100644 interface/resources/meshes/keyboard/keyCap_s.png create mode 100644 interface/resources/meshes/keyboard/keyCap_t.png create mode 100644 interface/resources/meshes/keyboard/keyCap_u.png create mode 100644 interface/resources/meshes/keyboard/keyCap_v.png create mode 100644 interface/resources/meshes/keyboard/keyCap_w.png create mode 100644 interface/resources/meshes/keyboard/keyCap_x.png create mode 100644 interface/resources/meshes/keyboard/keyCap_y.png create mode 100644 interface/resources/meshes/keyboard/keyCap_z.png create mode 100644 interface/resources/meshes/keyboard/key_0.png create mode 100644 interface/resources/meshes/keyboard/key_1.png create mode 100644 interface/resources/meshes/keyboard/key_123.png create mode 100644 interface/resources/meshes/keyboard/key_2.png create mode 100644 interface/resources/meshes/keyboard/key_3.png create mode 100644 interface/resources/meshes/keyboard/key_4.png create mode 100644 interface/resources/meshes/keyboard/key_5.png create mode 100644 interface/resources/meshes/keyboard/key_6.png create mode 100644 interface/resources/meshes/keyboard/key_7.png create mode 100644 interface/resources/meshes/keyboard/key_8.png create mode 100644 interface/resources/meshes/keyboard/key_9.png create mode 100644 interface/resources/meshes/keyboard/key_a.png create mode 100644 interface/resources/meshes/keyboard/key_abc.png create mode 100644 interface/resources/meshes/keyboard/key_ampersand.png create mode 100644 interface/resources/meshes/keyboard/key_ast.png create mode 100644 interface/resources/meshes/keyboard/key_at.png create mode 100644 interface/resources/meshes/keyboard/key_b.png create mode 100644 interface/resources/meshes/keyboard/key_backspace.png create mode 100644 interface/resources/meshes/keyboard/key_c.png create mode 100644 interface/resources/meshes/keyboard/key_cap.png create mode 100644 interface/resources/meshes/keyboard/key_caret.png create mode 100644 interface/resources/meshes/keyboard/key_close_paren.png create mode 100644 interface/resources/meshes/keyboard/key_colon.png create mode 100644 interface/resources/meshes/keyboard/key_comma.png create mode 100644 interface/resources/meshes/keyboard/key_d.png create mode 100644 interface/resources/meshes/keyboard/key_dollar.png create mode 100644 interface/resources/meshes/keyboard/key_dquote.png create mode 100644 interface/resources/meshes/keyboard/key_e.png create mode 100644 interface/resources/meshes/keyboard/key_enter.png create mode 100644 interface/resources/meshes/keyboard/key_exclam.png create mode 100644 interface/resources/meshes/keyboard/key_exit.png create mode 100644 interface/resources/meshes/keyboard/key_f.png create mode 100644 interface/resources/meshes/keyboard/key_g.png create mode 100644 interface/resources/meshes/keyboard/key_h.png create mode 100644 interface/resources/meshes/keyboard/key_hashtag.png create mode 100644 interface/resources/meshes/keyboard/key_i.png create mode 100644 interface/resources/meshes/keyboard/key_j.png create mode 100644 interface/resources/meshes/keyboard/key_k.png create mode 100644 interface/resources/meshes/keyboard/key_l.png create mode 100644 interface/resources/meshes/keyboard/key_m.png create mode 100644 interface/resources/meshes/keyboard/key_min.png create mode 100644 interface/resources/meshes/keyboard/key_n.png create mode 100644 interface/resources/meshes/keyboard/key_o.png create mode 100644 interface/resources/meshes/keyboard/key_open_paren.png create mode 100644 interface/resources/meshes/keyboard/key_p.png create mode 100644 interface/resources/meshes/keyboard/key_percentage.png create mode 100644 interface/resources/meshes/keyboard/key_period.png create mode 100644 interface/resources/meshes/keyboard/key_plus.png create mode 100644 interface/resources/meshes/keyboard/key_q.png create mode 100644 interface/resources/meshes/keyboard/key_question.png create mode 100644 interface/resources/meshes/keyboard/key_r.png create mode 100644 interface/resources/meshes/keyboard/key_s.png create mode 100644 interface/resources/meshes/keyboard/key_semi.png create mode 100644 interface/resources/meshes/keyboard/key_slash.png create mode 100644 interface/resources/meshes/keyboard/key_squote.png create mode 100644 interface/resources/meshes/keyboard/key_t.png create mode 100644 interface/resources/meshes/keyboard/key_u.png create mode 100644 interface/resources/meshes/keyboard/key_under.png create mode 100644 interface/resources/meshes/keyboard/key_v.png create mode 100644 interface/resources/meshes/keyboard/key_w.png create mode 100644 interface/resources/meshes/keyboard/key_x.png create mode 100644 interface/resources/meshes/keyboard/key_y.png create mode 100644 interface/resources/meshes/keyboard/key_z.png create mode 100644 interface/resources/meshes/keyboard/text_placard.png create mode 100644 interface/resources/meshes/keyboard/white.png create mode 100644 interface/resources/sounds/keyboard_key.mp3 create mode 100644 interface/src/scripting/KeyboardScriptingInterface.cpp create mode 100644 interface/src/scripting/KeyboardScriptingInterface.h create mode 100644 interface/src/ui/Keyboard.cpp create mode 100644 interface/src/ui/Keyboard.h diff --git a/interface/resources/config/keyboard.json b/interface/resources/config/keyboard.json new file mode 100644 index 0000000000..ba113da1f5 --- /dev/null +++ b/interface/resources/config/keyboard.json @@ -0,0 +1,2476 @@ +{ + "anchor": { + "dimensions": { + "x": 0.023600000888109207, + "y": 0.022600000724196434, + "z": 0.1274999976158142 + }, + "position": { + "x": 0.006292800903320312, + "y": 0.004300000742077827, + "z": 0.005427663803100586 + }, + "rotation": { + "w": 1.000, + "x": 0.000, + "y": 0.000, + "z": 0.000 + } + }, + "textDisplay": { + "dimensions": { + "x": 0.15, + "y": 0.045, + "z": 0.1 + }, + "localPosition": { + "x": -0.3032040786743164, + "y": 0.059300000742077827, + "z": 0.06454843521118164 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.976, + "z": 0.216 + }, + "leftMargin": 0.0, + "rightMargin": 0.0, + "topMargin": 0.0, + "bottomMargin": 0.0, + "lineHeight": 0.05 + }, + "useResourcesPath": true, + "layers": [ + [ + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5332040786743164, + "y": 0.019300000742077827, + "z": 0.03745675086975098 + }, + "key": "p", + "texture": { + "file9": "meshes/keyboard/key_p.png", + "file10": "meshes/keyboard/key_p.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.42067813873291016, + "y": 0.019300000742077827, + "z": 0.03744244575500488 + }, + "key": "i", + "texture": { + "file9": "meshes/keyboard/key_i.png", + "file10": "meshes/keyboard/key_i.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "o", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.47769832611083984, + "y": 0.019300000742077827, + "z": 0.03745675086975098 + }, + "texture": { + "file9": "meshes/keyboard/key_o.png", + "file10": "meshes/keyboard/key_o.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.49904823303222656, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "l", + "texture": { + "file9": "meshes/keyboard/key_l.png", + "file10": "meshes/keyboard/key_l.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.4439973831176758, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "k", + "texture": { + "file9": "meshes/keyboard/key_k.png", + "file10": "meshes/keyboard/key_k.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "y", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_y.png", + "file10": "meshes/keyboard/key_y.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.30902957916259766, + "y": 0.019300000742077827, + "z": 0.0374448299407959 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "r", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_r.png", + "file10": "meshes/keyboard/key_r.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.19474029541015625, + "y": 0.019300000742077827, + "z": 0.03745102882385254 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "d", + "texture": { + "file9": "meshes/keyboard/key_d.png", + "file10": "meshes/keyboard/key_d.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.16272640228271484, + "y": 0.019300000742077827, + "z": -0.01747274398803711 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "t", + "texture": { + "file9": "meshes/keyboard/key_t.png", + "file10": "meshes/keyboard/key_t.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2517843246459961, + "y": 0.019300000742077827, + "z": 0.03744622692465782 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "f", + "texture": { + "file9": "meshes/keyboard/key_f.png", + "file10": "meshes/keyboard/key_f.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2200756072998047, + "y": 0.019300000742077827, + "z": -0.01746511459350586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "g", + "texture": { + "file9": "meshes/keyboard/key_g.png", + "file10": "meshes/keyboard/key_g.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.27622222900390625, + "y": 0.019300000742077827, + "z": -0.017457084730267525 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "w", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_w.png", + "file10": "meshes/keyboard/key_w.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.08203601837158203, + "y": 0.019300000742077827, + "z": 0.03743100166320801 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "q", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_q.png", + "file10": "meshes/keyboard/key_q.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026292800903320312, + "y": 0.019300000742077827, + "z": 0.037427663803100586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "a", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_a.png", + "file10": "meshes/keyboard/key_a.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.050909996032714844, + "y": 0.019300000742077827, + "z": -0.017487764358520508 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "e", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_e.png", + "file10": "meshes/keyboard/key_e.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.13773441314697266, + "y": 0.019300000742077827, + "z": 0.03745269775390625 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "s", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_s.png", + "file10": "meshes/keyboard/key_s.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.10659980773925781, + "y": 0.019300000742077827, + "z": -0.017481565475463867 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "u", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_u.png", + "file10": "meshes/keyboard/key_u.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.36423587799072266, + "y": 0.019300000742077827, + "z": 0.03743577003479004 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "h", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_h.png", + "file10": "meshes/keyboard/key_h.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.3321561813354492, + "y": 0.019300000742077827, + "z": -0.017461776733398438 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "j", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_j.png", + "file10": "meshes/keyboard/key_j.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38753604888916016, + "y": 0.019300000742077827, + "z": -0.01746058464050293 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "c", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_c.png", + "file10": "meshes/keyboard/key_c.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.22023773193359375, + "y": 0.019300000742077827, + "z": -0.07282757759094238 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "v", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_v.png", + "file10": "meshes/keyboard/key_v.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2767200469970703, + "y": 0.019300000742077827, + "z": -0.07291850447654724 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "b", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_b.png", + "file10": "meshes/keyboard/key_b.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.33254528045654297, + "y": 0.019300000742077827, + "z": -0.07283258438110352 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": " ", + "name": "space", + "dimensions": { + "x": 0.14597539603710175, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.27660465240478516, + "y": 0.019300000742077827, + "z": -0.12705934047698975 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/white.png", + "file10": "meshes/keyboard/white.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "z", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_z.png", + "file10": "meshes/keyboard/key_z.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.10669422149658203, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "n", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_n.png", + "file10": "meshes/keyboard/key_n.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020292000845074654, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38794422149658203, + "y": 0.019300000742077827, + "z": -0.0728309154510498 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "m", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_m.png", + "file10": "meshes/keyboard/key_m.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.01846799999475479, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.44464683532714844, + "y": 0.019300000742077827, + "z": -0.07282185554504395 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "x", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_x.png", + "file10": "meshes/keyboard/key_x.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.1630725860595703, + "y": 0.019300000742077827, + "z": -0.07284045219421387 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "DEL", + "type": "backspace", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.53203323516845703, + "y": 0.019300000742077827, + "z": -0.07286686894893646 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_backspace.png", + "file10": "meshes/keyboard/key_backspace.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Caps", + "type": "caps", + "switchToLayer": 1, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.02603323516845703, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_cap.png", + "file10": "meshes/keyboard/key_cap.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Close", + "type": "close", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.59333323516845703, + "y": 0.019300000742077827, + "z": 0.037454843521118164 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_exit.png", + "file10": "meshes/keyboard/key_exit.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Enter", + "type": "enter", + "dimensions": { + "x": 0.08787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5103323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_enter.png", + "file11": "meshes/keyboard/key_enter.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "numbers", + "type": "layer", + "switchToLayer": 2, + "dimensions": { + "x": 0.07787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_123.png", + "file11": "meshes/keyboard/key_123.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + } + ], + [ + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5332040786743164, + "y": 0.019300000742077827, + "z": 0.037454843521118164 + }, + "key": "p", + "texture": { + "file9": "meshes/keyboard/keyCap_p.png", + "file10": "meshes/keyboard/keyCap_p.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.42067813873291016, + "y": 0.019300000742077827, + "z": 0.03744244575500488 + }, + "key": "i", + "texture": { + "file9": "meshes/keyboard/keyCap_i.png", + "file10": "meshes/keyboard/keyCap_i.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "o", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.47769832611083984, + "y": 0.019300000742077827, + "z": 0.03745675086975098 + }, + "texture": { + "file9": "meshes/keyboard/keyCap_o.png", + "file10": "meshes/keyboard/keyCap_o.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.49904823303222656, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "l", + "texture": { + "file9": "meshes/keyboard/keyCap_l.png", + "file10": "meshes/keyboard/keyCap_l.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.4439973831176758, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "k", + "texture": { + "file9": "meshes/keyboard/keyCap_k.png", + "file10": "meshes/keyboard/keyCap_k.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "y", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_y.png", + "file10": "meshes/keyboard/keyCap_y.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.30902957916259766, + "y": 0.019300000742077827, + "z": 0.0374448299407959 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "r", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_r.png", + "file10": "meshes/keyboard/keyCap_r.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.19474029541015625, + "y": 0.019300000742077827, + "z": 0.03745102882385254 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "d", + "texture": { + "file9": "meshes/keyboard/keyCap_d.png", + "file10": "meshes/keyboard/keyCap_d.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.16272640228271484, + "y": 0.019300000742077827, + "z": -0.01747274398803711 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "t", + "texture": { + "file9": "meshes/keyboard/keyCap_t.png", + "file10": "meshes/keyboard/keyCap_t.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2517843246459961, + "y": 0.019300000742077827, + "z": 0.03744622692465782 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "f", + "texture": { + "file9": "meshes/keyboard/keyCap_F.png", + "file10": "meshes/keyboard/keyCap_F.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2200756072998047, + "y": 0.019300000742077827, + "z": -0.01746511459350586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "g", + "texture": { + "file9": "meshes/keyboard/keyCap_g.png", + "file10": "meshes/keyboard/keyCap_g.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.27622222900390625, + "y": 0.019300000742077827, + "z": -0.017457084730267525 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "w", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_w.png", + "file10": "meshes/keyboard/keyCap_w.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.08203601837158203, + "y": 0.019300000742077827, + "z": 0.03743100166320801 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "q", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_q.png", + "file10": "meshes/keyboard/keyCap_q.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026292800903320312, + "y": 0.019300000742077827, + "z": 0.037427663803100586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "a", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_a.png", + "file10": "meshes/keyboard/keyCap_a.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.050909996032714844, + "y": 0.019300000742077827, + "z": -0.017487764358520508 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "e", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_e.png", + "file10": "meshes/keyboard/keyCap_e.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.13773441314697266, + "y": 0.019300000742077827, + "z": 0.03745269775390625 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "s", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_s.png", + "file10": "meshes/keyboard/keyCap_s.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.10659980773925781, + "y": 0.019300000742077827, + "z": -0.017481565475463867 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "u", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_u.png", + "file10": "meshes/keyboard/keyCap_u.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.36423587799072266, + "y": 0.019300000742077827, + "z": 0.03743577003479004 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "h", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_h.png", + "file10": "meshes/keyboard/keyCap_h.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.3321561813354492, + "y": 0.019300000742077827, + "z": -0.017461776733398438 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "j", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_j.png", + "file10": "meshes/keyboard/keyCap_j.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38753604888916016, + "y": 0.019300000742077827, + "z": -0.01746058464050293 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "c", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_c.png", + "file10": "meshes/keyboard/keyCap_c.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.22023773193359375, + "y": 0.019300000742077827, + "z": -0.07282757759094238 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "v", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_v.png", + "file10": "meshes/keyboard/keyCap_v.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2767200469970703, + "y": 0.019300000742077827, + "z": -0.07291850447654724 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "b", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_b.png", + "file10": "meshes/keyboard/keyCap_b.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.33254528045654297, + "y": 0.019300000742077827, + "z": -0.07283258438110352 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": " ", + "name": "space", + "dimensions": { + "x": 0.14597539603710175, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.27660465240478516, + "y": 0.019300000742077827, + "z": -0.12705934047698975 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/white.png", + "file10": "meshes/keyboard/white.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "z", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_z.png", + "file10": "meshes/keyboard/keyCap_z.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.10669422149658203, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "n", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_n.png", + "file10": "meshes/keyboard/keyCap_n.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020292000845074654, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38794422149658203, + "y": 0.019300000742077827, + "z": -0.0728309154510498 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "m", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_m.png", + "file10": "meshes/keyboard/keyCap_m.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.01846799999475479, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.44464683532714844, + "y": 0.019300000742077827, + "z": -0.07282185554504395 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "x", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/keyCap_x.png", + "file10": "meshes/keyboard/keyCap_x.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.1630725860595703, + "y": 0.019300000742077827, + "z": -0.07284045219421387 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "DEL", + "type": "backspace", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.53203323516845703, + "y": 0.019300000742077827, + "z": -0.07286686894893646 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_backspace.png", + "file10": "meshes/keyboard/key_backspace.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Caps", + "type": "caps", + "switchToLayer": 0, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.02603323516845703, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_cap.png", + "file10": "meshes/keyboard/key_cap.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Close", + "type": "close", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.59333323516845703, + "y": 0.019300000742077827, + "z": 0.037454843521118164 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_exit.png", + "file10": "meshes/keyboard/key_exit.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Enter", + "type": "enter", + "dimensions": { + "x": 0.08787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5103323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_enter.png", + "file11": "meshes/keyboard/key_enter.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "numbers", + "type": "layer", + "switchToLayer": 2, + "dimensions": { + "x": 0.07787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_123.png", + "file11": "meshes/keyboard/key_123.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + } + ], + [ + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5332040786743164, + "y": 0.019300000742077827, + "z": 0.037454843521118164 + }, + "key": "0", + "texture": { + "file9": "meshes/keyboard/key_0.png", + "file10": "meshes/keyboard/key_0.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.42067813873291016, + "y": 0.019300000742077827, + "z": 0.03744244575500488 + }, + "key": "8", + "texture": { + "file9": "meshes/keyboard/key_8.png", + "file10": "meshes/keyboard/key_8.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "9", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.47769832611083984, + "y": 0.019300000742077827, + "z": 0.03745675086975098 + }, + "texture": { + "file9": "meshes/keyboard/key_9.png", + "file10": "meshes/keyboard/key_9.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.47764823303222656, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "(", + "texture": { + "file9": "meshes/keyboard/key_open_paren.png", + "file10": "meshes/keyboard/key_open_paren.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.53364823303222656, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": ")", + "texture": { + "file9": "meshes/keyboard/key_close_paren.png", + "file10": "meshes/keyboard/key_close_paren.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.59634823303222656, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "/", + "texture": { + "file9": "meshes/keyboard/key_slash.png", + "file10": "meshes/keyboard/key_slash.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.4206973831176758, + "y": 0.019300000742077827, + "z": -0.01745915412902832 + }, + "key": "+", + "texture": { + "file9": "meshes/keyboard/key_plus.png", + "file10": "meshes/keyboard/key_plus.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "6", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_6.png", + "file10": "meshes/keyboard/key_6.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.30902957916259766, + "y": 0.019300000742077827, + "z": 0.0374448299407959 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "4", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_4.png", + "file10": "meshes/keyboard/key_4.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.19474029541015625, + "y": 0.019300000742077827, + "z": 0.03745102882385254 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "$", + "texture": { + "file9": "meshes/keyboard/key_dollar.png", + "file10": "meshes/keyboard/key_dollar.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.13772640228271484, + "y": 0.019300000742077827, + "z": -0.01747274398803711 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "5", + "texture": { + "file9": "meshes/keyboard/key_5.png", + "file10": "meshes/keyboard/key_5.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2517843246459961, + "y": 0.019300000742077827, + "z": 0.03744622692465782 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "%", + "texture": { + "file9": "meshes/keyboard/key_percentage.png", + "file10": "meshes/keyboard/key_percentage.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.1947756072998047, + "y": 0.019300000742077827, + "z": -0.01746511459350586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "_", + "texture": { + "file9": "meshes/keyboard/key_under.png", + "file10": "meshes/keyboard/key_under.png" + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.25172222900390625, + "y": 0.019300000742077827, + "z": -0.017457084730267525 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "2", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_2.png", + "file10": "meshes/keyboard/key_2.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.08203601837158203, + "y": 0.019300000742077827, + "z": 0.03743100166320801 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "1", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_1.png", + "file10": "meshes/keyboard/key_1.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026292800903320312, + "y": 0.019300000742077827, + "z": 0.037427663803100586 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "@", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_at.png", + "file10": "meshes/keyboard/key_at.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026209996032714844, + "y": 0.019300000742077827, + "z": -0.017487764358520508 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "3", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_3.png", + "file10": "meshes/keyboard/key_3.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.13773441314697266, + "y": 0.019300000742077827, + "z": 0.03745269775390625 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "#", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_hashtag.png", + "file10": "meshes/keyboard/key_hashtag.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.08209980773925781, + "y": 0.019300000742077827, + "z": -0.017481565475463867 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "7", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_7.png", + "file10": "meshes/keyboard/key_7.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.36423587799072266, + "y": 0.019300000742077827, + "z": 0.03743577003479004 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "&", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_ampersand.png", + "file10": "meshes/keyboard/key_ampersand.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.3090561813354492, + "y": 0.019300000742077827, + "z": -0.017461776733398438 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "-", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_min.png", + "file10": "meshes/keyboard/key_min.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.36433604888916016, + "y": 0.019300000742077827, + "z": -0.01746058464050293 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "'", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_squote.png", + "file10": "meshes/keyboard/key_squote.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.22023773193359375, + "y": 0.019300000742077827, + "z": -0.07282757759094238 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": ":", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_colon.png", + "file10": "meshes/keyboard/key_colon.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.2767200469970703, + "y": 0.019300000742077827, + "z": -0.07291850447654724 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": ";", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_semi.png", + "file10": "meshes/keyboard/key_semi.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.33254528045654297, + "y": 0.019300000742077827, + "z": -0.07283258438110352 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": " ", + "name": "space", + "dimensions": { + "x": 0.14597539603710175, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.27660465240478516, + "y": 0.019300000742077827, + "z": -0.12705934047698975 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/white.png", + "file10": "meshes/keyboard/white.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "*", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_ast.png", + "file10": "meshes/keyboard/key_ast.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.10669422149658203, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "!", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_exclam.png", + "file10": "meshes/keyboard/key_exclam.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020292000845074654, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38794422149658203, + "y": 0.019300000742077827, + "z": -0.0728309154510498 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "?", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_question.png", + "file10": "meshes/keyboard/key_question.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.01846799999475479, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.44464683532714844, + "y": 0.019300000742077827, + "z": -0.07282185554504395 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "\"", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_dquote.png", + "file10": "meshes/keyboard/key_dquote.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.1630725860595703, + "y": 0.019300000742077827, + "z": -0.07284045219421387 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "DEL", + "type": "backspace", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.53203323516845703, + "y": 0.019300000742077827, + "z": -0.07286686894893646 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_backspace.png", + "file10": "meshes/keyboard/key_backspace.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Caps", + "type": "caps", + "switchToLayer": 1, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.02603323516845703, + "y": 0.019300000742077827, + "z": -0.07285571098327637 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_cap.png", + "file10": "meshes/keyboard/key_cap.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Close", + "type": "close", + "dimensions": { + "x": 0.04787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.59333323516845703, + "y": 0.019300000742077827, + "z": 0.037454843521118164 + }, + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_exit.png", + "file10": "meshes/keyboard/key_exit.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "Enter", + "type": "enter", + "dimensions": { + "x": 0.08787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.5103323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_enter.png", + "file11": "meshes/keyboard/key_enter.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": "numbers", + "type": "layer", + "switchToLayer": 0, + "dimensions": { + "x": 0.07787999764084816, + "z": 0.020519999787211418, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.026323516845703, + "y": 0.019300000742077827, + "z": -0.127054843521118164 + }, + "modelURL": "meshes/keyboard/SM_enter.fbx", + "texture": { + "file10": "meshes/keyboard/key_abc.png", + "file11": "meshes/keyboard/key_abc.png" + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": ".", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_period.png", + "file10": "meshes/keyboard/key_period.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.38794422149658203, + "y": 0.019300000742077827, + "z": -0.12705934047698975 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + }, + { + "key": ",", + "modelURL": "meshes/keyboard/SM_key.fbx", + "texture": { + "file9": "meshes/keyboard/key_comma.png", + "file10": "meshes/keyboard/key_comma.png" + }, + "dimensions": { + "x": 0.04787999764084816, + "z": 0.02051999792456627, + "y": 0.04787999764084816 + }, + "position": { + "x": -0.1630725860595703, + "y": 0.019300000742077827, + "z": -0.12705934047698975 + }, + "localOrientation": { + "w": 0.000, + "x": 0.000, + "y": 0.707, + "z": 0.707 + } + } + ] + ] +} diff --git a/interface/resources/meshes/drumstick.fbx b/interface/resources/meshes/drumstick.fbx new file mode 100644 index 0000000000000000000000000000000000000000..0243d9fd1b89f0dfe6572a7ec293a38ec571ce53 GIT binary patch literal 63952 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/SM_enter.fbx b/interface/resources/meshes/keyboard/SM_enter.fbx new file mode 100644 index 0000000000000000000000000000000000000000..119e3fc5356a9dcaf271190d3e64912fdba4a3f3 GIT binary patch literal 33248 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 SA;rMJz~JfX=d#Wzp$Py6*tu~4 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_a.png b/interface/resources/meshes/keyboard/keyCap_a.png new file mode 100644 index 0000000000000000000000000000000000000000..f409254292dbe07c27c37341049f2c1f584f1f2e GIT binary patch literal 3081 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_d.png b/interface/resources/meshes/keyboard/keyCap_d.png new file mode 100644 index 0000000000000000000000000000000000000000..4eccee2dd533ea24e6bca5e6ab3114fce37db803 GIT binary patch literal 2648 zcmeHJZB&eD7{1@v$UChvW#|LVs1|9gLHf{C6Ka~3eAGyXKF~y%3Z*bLZ|UP`%0%a+ zXlccUP_n0dw39>#Vc?Hv4mbJm?sFLc#1ZyNSD%=+H|_F0Z5|-*d^zXcIwEUgmNnA{e1g^?YvWf>tea*$zxV>& zMkUh{4V#d%Z4J+!eZMbr)dt(kgRxW60xt(=<)}kjMm`M3FyhcW!5EzYDBu8808Cre zzlY=De09_}(y?(={MG5-Ob7HEtovNZ_?`cv<4YXpPXDActL_-1UWK72OL_8PiuyxR zWkHWKMa0S%6Vu)EQ&LhgHFg1+8Qj*I8n;jNfr%8pD+c^N81JY z#1h5Pi!c*oQEle^yJgA16rp`(G|7XK@>{n`=1`-qq6vYGr92Mj#EBEL{iFP6kO**a zE^YHKjjw~~Pj}n8PeAYvhnf1T|Ir+R;CGVJw!uD%qJE7YRp#QU7WR|?BD?L0GOB)CPGCV-Pft%NPriNU zX|IAcYt{sK90BHKy2{9{Usf^J{w`U^X7%;h1MhqT@CdQ$8yIM6-Y-?EUZu}RA++uT zzd|0}jLT%3gjzvQK^uj*Hn>TN7)`H6-yEcFrmE^WHd>#AORSeedL1iu@( zSM;>Hy1Fxho{hmd%lfgFmX`k7i^{ejbI8c9*=nFEr`~2_U}+U+Ahk*NrxM_?cNTZl zTr8;~fyZOMvxrd@R<2mzB2QLV0gh@GP}x!bEVIrR zFRJL~ka4;_%;-R3oza8CASV9Gn^A!lPfP^n!DTsW{|TwUJh-8d^U~mrh6))kUkgT^ z#MPZr53QXtnXJwt*v~e{GtCkOtJdAN$4+P_g$M{>*jUON?G@0?M0#^jup(X;jP_Qj zDFiU|Zi(V>`fIHukF__F7V*O6)Wc;CO?+p&(-@rlnJ0T4Y3kfHWoeElgFr^`;1JED z9b}C{P@utJAo}CN0AmgTe)rEFa?Dj9Rw{Spr-Jxqq4JijwSu;)K$9yqS^+N2rEhoJ z3I!cKE?mFv0g5%)@3OPAJ6xG3Yt=MLxdAt3dT%pQmG#Ck!T;1V$ zd19uzJx^>PTRGU)Jq3kpS&~=b74D02 zpGU{A8#F$5`f6r_wDSI;-9dA+^8{9U<3U!>CU{)cBzTu_M3RS&7u;qNxY1Kvs@ zK>ZjMB1%)w(p459brjZAy=idRXEHBxq34Klux)S*g8_-MW~_Jc)o3f4aCFV+km|Qq ziZv*XmC&3zO%OcJD%IRm-SIXFm?FZ7v~j8bRNW~p7lF9;O#fkeSI>a}a?+*)!x#Q~A9=Q?%3_owMY{W9vlW2_ae=xh zh*{lUL0>iGxZvOYJT8d*Kjy#Xi}PR1>2VI{zI_5B9k>f;9upnkMFjw0yLqj=x;!Z1 EPktEd`Tzg` literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_e.png b/interface/resources/meshes/keyboard/keyCap_e.png new file mode 100644 index 0000000000000000000000000000000000000000..09bd5bc289d89475106bc4583820f10cdaa0f7f4 GIT binary patch literal 1689 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|T42r;B4q#jQ7Y59YQQ2sm7{Sk$1I>Q#_F#ldsglD7#; zOB;Gc8b2JW{yz1?jHllFc7$FPXE^^kmxY0ep@D%xfq{VoWG(4Ta`YfsMWJ>MGF*&g zJH^@=m?#K3utUfXd6++7G?^x$Yd^pq-P4fy_IKUsQdgI>Tk(3=SDg}>@#SsK+i9;~ z?d>;wqd&cvg@qK$3IxKPiu`Bp%)LG5yqKUF2V;Y<{ieO;#SP{%jEv~klac}ttUt=J zW9jwRTi^eF+AkF$zP-Qi=8|@9&o09@ zT{ngYKN!xOkMBQCiZu@^x&;l^md5fjo_p+O(9m8)T6AEEIxIq@#DfBZ8|6k|Bt+6& uK#}%_f!3}-Ug3+BdB`kC>wgO-FjNN{?^~8w#mvCKz~JfX=d#Wzp$Py-;?-vW literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_g.png b/interface/resources/meshes/keyboard/keyCap_g.png new file mode 100644 index 0000000000000000000000000000000000000000..9c52605428d9c8b82cf213068b1cb6e0ddde9f7d GIT binary patch literal 3298 zcmc&$TTl~b7Cs39BccH`aw(b!B8b!!wOoY+xfN{zS)_s(xrJCn5D2#rNCZ~_gHT1l z1q8Gfwty6}lC=SXEjG$UWLQ)#*&u?Dg>VhT07-W2OlO>(edyxDKAbtv02kl696`Qe&Om1fQ0vke(zEZUFrb9faZ3R_NSN#T2e?H1)RfULMeF9^C47< zFC`>ACH5)hFo4!2PgiHZ*K})hOCf@7i(NIH8v@WlnrbW8?xZNWs%sxhFsDf zwLM4~uQ6$6dZzaCH{VnYrQgOCju&cKcc%1+qkkf{G&h%mkE5z#BcK2fKmyQ)b;18H zUHjvb|9Cp;Bc%IH@%1bJd&7SB>wM`y=`7vVsI5Lb^78UZy7#pHD#KtfL}k^xlsf;_ zd9x+DLMr{+vPEl8W0%HQrd+zP5V~*f_<8P7ePw@9g92G}Hb#mooPLxxLqM}j>PQbj~^O@E?ITy~{OQX`$7!8h@4zEaY)5nF81nb6p^6;!I9=Ea~)0wRl7ma53 zGObI#+H+%=aD*GQUl^{BsQ4+=@BAS{xmQEjq)5;bVC=``a_yY@1M8O8)}~bm6e}mv zx}>JxqTZwC6#sD6GS$mIgh3u{~U_Hu5gitL)9BNK9LHy#4DB#>n`jq@vd5 zEHS?qw+}2VBKoLQYKr7|cC0*K7W!rLmw!E_9;{Bfx3sFbeoe`fukr}f2Mu%M0$f;F zxVP=#WU;b{CRO#u?D!=ss{MHk7vm3pfn>ckyO~K`dK!t?woCIzWW{7?htbvvsUUmT z=g13EB9$k!Hq5ngGR>z9oIPY8UEQY1I#S#-A;vU1yy2jkUhLTz#`iY2C*mF+S}}n+ zQHx4E(aU#yyOYdb$b2+XPMlq$MG{ zJB_wjhJ3+ve!Aypvh(ZJhFo)JwsQZnXGmJpM%(6rsKWFargNe~)i*vqKC=*SH7^Sc zQwQd^=>rq`;YFSIKHZ2U-D)3Jbrun_F{8R2`Oln#w|uH7BrMT|lNcmPJ}eTMIUV5+ zJuui+cA8`qI4UOw6gm=#4mG;9V17#=5VS8c2PI9tI23EalRtl~roYj^8O+P8s;b&O zF1*u2v3%*WZcb~Sa#_U!pT`gs6!hvc9twIsdD)>_!m_&2ND@|UB&P!p1-_%o^68uJ zcjUX%k!?~Oij{Lfu>nz0+nTQU%fi&e#6;6BG~6pl*VcdXtwY8mzTuWDSh z914^(D=YRzZlsWU9pS93?EcY5Y4nto2CQw4``vHTPhX+8w36$uE?0F&a7s!_WQzOT z-TuH}h_7ULlL^|2naU>CUE)cE50*-$KAisLmRFT{16K3EO6svtb&}~=!h=%%cuY!e z1+#Bf)~#qjvRnwrYrHM5qPbQp6S})%JZqskNB_Yhy*jK;%{aI_f`dM$tqmPx3n#8^ zMqA;CuNKc(Zhu(%40VO7r!CMsILYB~jBr1a>jgg>D{@?7u6DUb#SsTYr*!v$#drQS zubxR z&b0DJ8`0oaKpCLfe`OcE2@MqNKGw+%X8{d{fQJ5aq&k1jUJ_J?s=b&>Fv!~|h@V-a zlbuoUPeNzo$qn1aE}!8)NVIcwTw2%#`GYJm&E7Eg=zP@O6Ah8EQY3;kETy;TpWKxG z1>WN3n@^)jFyhT~K|UVlwho=3@QFxBHKK+U?l5>V?-VK?lq@eU##i3fMFTEzuXdkA zOruL3kIbWH$OGa$XnRewFDp*M?NhTF#b{(EBdrv&Ks;RN(lUy|bjd22wnl?WG#C-p zI>+OZvXYsbGK7-1NiO^}rvf3hgq8Q5GV+k2qm$FKiN*fNo1Jy)qgM6x^+S@TDPKJJ zT+?m08ImL>of1@4Rdq)!UUkeGNLyQZ{Me1r(%j4tg^N6$x0O0%9Jk}`Zg!Lo9Yq7+ z3NzaP2`m6S0Q&tH`oF{Va6Sly4Bq82x!yk#obhk#Nl(%M literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_h.png b/interface/resources/meshes/keyboard/keyCap_h.png new file mode 100644 index 0000000000000000000000000000000000000000..f29323da1173bea026ac0f1f5f0ff97d51fae35c GIT binary patch literal 1616 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V9T==L4kpR945(n z4%F)@Cj<*7ay;Nh^CkY&m%#Xs;+hqM7 zaB*OGpR~+Cj`jxm7`FpDYfW!Ab}AYBGCpuv*Fd7J7*S1{qcOD~;GozDOjGek4r<6z nsC^h_L*hc@!R~$rW`?GV>faV^YB|8bz`)??>gTe~DWM4fe!z+W literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_i.png b/interface/resources/meshes/keyboard/keyCap_i.png new file mode 100644 index 0000000000000000000000000000000000000000..721e6b84b1a7626aeadc8439c65bf580709b88db GIT binary patch literal 1581 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V=MPZ!6Kid%1P9OP|K5O8%g6-&}AVJmUUEZd}Jmhi~+ zz|7xq|DLMuc00m!;D3%9g8%~q3j+fqg98Iu%z@SJK$+ty*DgRo&_P0+?2v~=7>p*< zBn<6r+h#ZHoV)${^W$fGI2d}CZw*SU?7J{jvged zI2t%9O0Ja0IR4D?;z%CTvuQ!UKFCyUb88W0s5`;Uff2&^jX=*)6l?cE4nU-^B$`RI zLhQ+cLhTLYB{U>kDNJ(;l%;s`wV&5xWMepJC4PT@ov9oH0|SGntDnm{r-UW|>3f0w literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_j.png b/interface/resources/meshes/keyboard/keyCap_j.png new file mode 100644 index 0000000000000000000000000000000000000000..7186df71c1026d15ea1426e95e77882ae4318d6c GIT binary patch literal 2213 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|SS(hG8{;(RA-$d${;&Ov>W|(gxKG)|BSSdpIArFf%7)_>0=-L~4Y7JRFOg@_? z{P}TcsHF3z3%B2YcVqnbIr`%3qkneX*DQJ{B+$UXpuoVu0ZPW?Fv-z_WK{!$1QXGT z_JnI_C>tYVgN~_hvVZ`Ch1>b_=h+xRNxosbBo7-{%Jm-ei|@aW2{1P3>|}3XV5ncL z!PVTr;4p35?c6i{(^E5f1Q||D@^+BvzgM^4oa@8;^)BnhKR@1m_nec$97cz_NA_$v z-`p7e{y|8MT@=2rHMv*BvG z(zqA9cGaNJ=LqqfTI}4co`0zm>F4|@?M}ETIxZ9B_&$|^B zQWH1*I~-V3Tkxrt_g{vI)zAA?e?FX_wXCwTl5_pU{$D@meyqsxf5^iSWhTS-SUjUl z>qy%6+c$%sobXM!B>KN(TX{ikZLYr}!-Y*(v!?#A=4t;w`Q(v#P7DiDC&r)u#3$C> zde>#&9cG1x|K}FoKY!1(yS{*pVeX5Jqkhp{i+1eazkRjO6r*$7abfw>z;YY*I^4bsofXDu?mE43keE^;j4_S*U05#qBr!_m;c; zP>Fosm&4in@9Xp1Tw!Evc+<-B=bhBVPB|8ahWs^5Oz0Vx=o|qlvnkf@Oi>ZQz%;e}{g3sptlC@%BU|{fc^>bP0l+XkK^(*8l literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_k.png b/interface/resources/meshes/keyboard/keyCap_k.png new file mode 100644 index 0000000000000000000000000000000000000000..69667f42e0c4bf63668dee1391701b5659cbddc6 GIT binary patch literal 2800 zcmd^>aa2=P9LIleXT^&UwlpCRxj7;&!C~&0A#4kQT#6_sXzMTop;}T=X<~R z`}y5_?%ltB-3zmP=lKG_EZ*waBmfc+4@$iD%v<#t06dl(mz=v%{7P=Va927!pC;as zPUne)8R<#s!nA^%i|J7SzVGm2pWh_CK58`P7~%Ag$T%^Ns&=Lq6K!4D9pff}U0LAMx3bExQv^^5c!l%F4<>Xwa!@fD1qY zAOj#XYW}x!K<;bu_b=z(2fAtEGbVqxU^6S+UD>^IbhJEFaq_Clpa36$!s0skU*GasNuNv->XLY z?Joem*RFF6ewUx=#3Z6LXwrF>%NRiU`eay~#&z`fulOovQRvVLr3ZzQoHK&d)Kpum>!?|*C+w@8=eNvr z%S>An$#8xxZtv&FyXAV8cbJP~(n~6jBt>V-T1o}9(Q?`qj#r8}(;kL;b0wVBC*X`a zHnX-oL4`ue=OV+HH0&9-LGwie3MGd}OU>a8lP~wj1$CDjRr07~H$ZJb+j!x&F z<2a8EeUMSdra~FZU7qEjbzUJ#_cp^SEm$l6vp=y>Yu#hn4_v>dAI5rF2Z_9~N>7u< zrL$!b#a-~NyV){+RExER042*=dwpE&3;_zSDja9td6zA6#(Ecoyt8|!vsLsOArUIo zjIEx4{HwDOMNsce=1KFigL?@}w6Slc;OEoxJFgTnfU@dXe*30rLc*x!vbp`^99;_m ze|G|2J5>JNcTBfNY^O$-Z=?X) z(E(&==(}AQzN>2y3SYH^b+Zz#B8Wmd9x!Q-07M~OQrjJ+I~;h*n4N+Z1Sx=f#4KMC ztW=w)*tR6IKi%k)oJ0oFiu!e?)KPZDPR~oYo)zA`Lr8_mzTTVo;A`8=*>qgU#UwrD z@i4G45oi4>pR6OKv(p6qRAP>Go&cGtRa95t78!!6#e@ld$dk$EAI9IaIY!%GB?E)t zlTY$YV6NE}Wn36&c~$unZQX=@akL*72Ad5v3}H}+rSb0Z<6q}o!fCgNgsb)V;Oi@ft-fajC(P`#ss~7btL#*EhLba= z|50b&NFo_g;7o9EzDnHE#aV2>KtN+WEr~4&EL{K;U4&nPK27k15s?yjqT+gdB?1-+ z#y0W!!)545TnJDS4H{D_-KO#aTpcF})--=&`-lpty6>eA2`u!6T%YyBWo6A4Z~O?E zX~a9B`47^8l33LeQ=6Vc40^7*Fle9*CBuzOgT^}Lvi$Cu_hux6geVRj2&N7av~E1L zYxB)+w@^VOM%C6SS750zyVbo{OzeJjSHQj~KQ7Q4l9>K=>WkhQ=vfI>rO1@~hvxNU z*!S353*=iqE=5o%*>pDrpC8xb8?Dk2{ORv^3?9<{LF4qcCFad^8)G_4Qd>-Y;6dO< zHC2>(8B5<1cFA+D{gSuaOQt%K!7%zHuO$QH*zx0C(AUe>;Kx;eF)Z1MJ_GlmD+*pvgV*ha$lJ$n|(dl`R_pfETwewmD||zTW_K C1v!ZT literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_l.png b/interface/resources/meshes/keyboard/keyCap_l.png new file mode 100644 index 0000000000000000000000000000000000000000..d59c24f7e24a7223186b2907e536504da6623385 GIT binary patch literal 1631 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|OhQr;B4q#jQ7Y&vLdHh`3!W+|V#3iT#*Y5_i}N)g>a< z6`RYA6_ozX*)0C#^dl#;Or=N62Y%Q1GAJ-GaDXgqU?7V*u-Y3ab3EnR6(|TgNQjdi z@~{Yl(PWy0q5V!oHq*NG*LJOY{qFqkb>5mY1M=Q+GdXSek`74-&Rs>;9j3 z|9fBO=bcPUL>u>@LayZ@o0sPKIwRHRZ>Ctr3k!xZR49f!kQs$=Z4{;@rUWMP0}~Pl zWXA5#SY8q@V-&SbB~z?aTTM_Tf<+MfNQGihP^&@&0f7Ly>e$p4 zh)64)u|j;HR13jYL~4W}3ZWEHgknVvHby`xBs?#0lXNbOfBL60*qQ$6oH?`m}=gKv~%OSDHo zOyu_H(CEmhl(_on_W=ZNoJzN>dPh&28*4@2`U=8b*j2Toq8ef~ReCVK` z-Bp!JRmq%1+OPZA1_ z>8t2CU2{*UX>82W^F@#ui_DWsrJvEx$S(IE!ra|&Y_^m^DTq4_hQgC9UP05|6=MhK zntRTIFph`bq(e0*zRe%k^PM0n0*W(nb)Ad}Ot|lY^Cfw3&fw#o}IrH%q`CCQCP%?7-?CF+Ow^10vC$XY42FJA_9!5yv!<%P~A0E z-L@T(&tk$eHqu(jMVvsl2brf(DBcb~7CJ~s5HTXS))`wT<3g7dY2l%P*kq!?LGwUo zu+`ZCTr{O4C0B;%2%eJ-O%@LDjEv;uq8GFb&6hH4QILYF* zOyap;sA+BehJv)E5*`UPTP|u7ccslJuM~j@jHWpC07FYeH0I3su($8b0Wh98k)NMr z*|*nR`;jrobM9u80s6$GB&TJ*+g*`ry=-hIDf0tmgH6}Xn>TSzM|bz4crv&^eGV@= z=DdFq>F6Y75#~}L&cDx>G<8~QBRz%aiI$}@fAI3q~gKY%+X8L zs0Jm>1Fo*f-R?n?DXwUOL~t+*kF`yqN>H=~nU`CCW*~PN+h6d6Kk|5|8%2p#B`TG4 zHj?x4^rPB_TU5nKgK>6BXvPKBk&*ef)R9wTDf2yucAI>29g5GD>%wK;=gKG3*C_Cy zN3EStpVm_7?hJ?e`uh5sn$?F{4(cHTK@b*8Wq|}0LFR@hDjTzv!H5`h8NDF@@#&4^ z?xoL3m29{-zx$+hc=+;wJg6YFWPG~-IyTOauw%@zt{7K28X(iIYJ#3+rSwNPVq>*RSJf3Ka}k;<A%Ff_8`^WCM&|sjH)fF%!xKTQcA-G$R?$Wr)7nIG8 zKU1&W>W*Yv)4U^SXN|%498|K!c@6bZN6ouF;X29X^5Qf{4JyuTu1R=uU0W51mA0Se zQ*698C!_88P<6F$tyfgpro_tO2C5-gXp$^zpGjA!5W($VAN%MTU${@`(M3#)swnnJ zcc5spqsKT~&5PfzUx!VIex32UmYuh93nI8G*J&Brc(NQyL3xeUL5VL0%eb0_hfj8w z&)pIBqZ^GU`b1z1nqC$5sY}J5g$hc6ExRtTI${bgLPr=o!^a&-nH1&{Z`9rDdKjyu zXV&dr+8%{O$=>*^C_x#P_qEw>phqr2%lcs z7n+ZvQ|$0B!d}=Z&U**=|F`fmKc#xlpW0c+O#m8=pU F_zNQ|_;COL literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_n.png b/interface/resources/meshes/keyboard/keyCap_n.png new file mode 100644 index 0000000000000000000000000000000000000000..950fb88e41b66abd8f22d6ac9beb1a036f6cd853 GIT binary patch literal 2230 zcmc&#ZA?>F7=CXl?FCD7*;x1()f?CZ-GaodQ;~&REFdh!f+ny#D8&pF9? zpZ9&v^St+*Gt%S)AMYS<0PsoND^3Mq0s3$>eH65(2>=2Wap{Wvd50B6biNEk*?Czq zGBKAvB1@Ig*&oXLWzhh5dZJi#p!n`zV<~d$UcXbn);D2?@GL@fE-;;UjPqt$AN~sG z1&_~8dj3EJl?4=cwt~KOxcb7-TXy>1WTW|XsdYhSGdgCTZBP|26|w*W3V;A$!@8FL zFP!ZjdAH$!xgi~+`1+N18RnYTnQ1j#mlad5YPpqba?UM`)G0FT$4NM_ZTau<>Di0V z&O7I#&ssMvov`KQWQ;B^+Nz9u>`#-Fo?=16(RXWm1r%tf^4m+F_$R2~k>Hr=_t_Im zqgyDz!@gET)tfQ4O8anjK9ctkSd@6wAw1HN&&RslZ2(<6dS@5sr5I^?JZq7u_`5!|-dU8^IeIFhQ(#lEo zb>k-?AgydwKWcq__>K0hEGYQY-fkg3A7_8#__Gm*k%`B}e!-ggt{n(*8i}DBxfZDL zg)50By)(M5M5vBrfpC{Hd?sg*n-&K;zKly(ZHNQiW*K(^sYrp2-^-ma1GC%9rB}!! z^Cq90r&AikG#TjFH3H3O!HgHphQ^da;){lUvH~b!dCc~^%)*;**yvvllw^Q!*zzi> z{#zt!fwae>I{_-7gadp-EwCXc^$GGsTKO@T_Mw2hq|Y_V6PwMxlZ7EyL@w?i)FU;Z)N}R7POO~7$e~SLaX)>8+=ewRBR(_YcEe-I#7S(@# z$OUuF>%0jG_o}-Ocl$Y7qhl_eEZvKtJy3nwJXNqYegFU< MF)mr$9DAtjAC2nT7XSbN literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_o.png b/interface/resources/meshes/keyboard/keyCap_o.png new file mode 100644 index 0000000000000000000000000000000000000000..2c1755c8877805917a20d32661f936d5988dcff2 GIT binary patch literal 3508 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 P000NOlgFOgps)M~_BGVb literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_p.png b/interface/resources/meshes/keyboard/keyCap_p.png new file mode 100644 index 0000000000000000000000000000000000000000..366a9ef3ff594dac68e666b81bfa347f8fac82d4 GIT binary patch literal 2282 zcmeHIYfO`87=6BvQc5TGLv&$4X;Gt5SVxqR2?k0F+KM8VmB0{6)vBO!krv8S_+$f$ zVi;bi=%ks>IbebUTDdqbZexfH2}1@7t8yt=UwPlzZecAqFoa6|}Rdt-MC>DZxuIKyb+nVuJ&-#$tRIhC=RxvWF@Y z45R@eI(b3CLj=LCsX1FIrVfA=w(RRWO(($a<-vxjj}I?>nrBr%RGPOo?Yyw7DJxuX zt3<68xry~uxoWa2fA`bRtmvK#T zz}dOH%juA*TNEx>3C_;N9Ywn1gSwR1GbC8DbM6u%4zFI34RzyRMqN}TH=h#e%}p?v zCHn+a)HDo>Hf$jPvC{m2=0t=$5FjJkL$1)o&j1G2MbrG@Jytl>(Ql_(|J+!QfmCFr z{3enB8SA;r*srCntRA;pi$ZR+#_oFiGfR-hPZ^%A3nai53!DlleDb?q-#al-YsB{x z?nQ*9oDZ8TU4|mKt>oy@gBsGK11?4>8T-q-Dry=Ixo$M-8q{bi2jUr{l;tJ}UK-pf zo|743fDX!1=`}-N9~QH9Kwz(jQfAjJy;AJpX8hMx#+j(xEQN)jr2luFBuOMxI&e-bzJq4>!A| zG4~r{C1lcTk^J{Q{WrRH!1$Fl$2{`4>$Q#TF7LBvPgj2xwH=|edQtSvRyPL!@1BKr l_TN>%$mOvxI&PTu#Ss`v40Xs2eAx^D!1eL-E@4Hc{Q>m=0S5p8 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_q.png b/interface/resources/meshes/keyboard/keyCap_q.png new file mode 100644 index 0000000000000000000000000000000000000000..2d58bc2b46cb5d2cfdbf58abc76e3763756e9d1f GIT binary patch literal 3681 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_s.png b/interface/resources/meshes/keyboard/keyCap_s.png new file mode 100644 index 0000000000000000000000000000000000000000..365a833af2441a593c95a3b45e1b4ed3dac1cf6b GIT binary patch literal 3405 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_v.png b/interface/resources/meshes/keyboard/keyCap_v.png new file mode 100644 index 0000000000000000000000000000000000000000..1dbe395005c5da1f53826fc94752310af14369e2 GIT binary patch literal 3024 zcmds3X;4#H7Cu?%1QV9D2+}A47X&8?ibEq?0*bg{L>v*t4j@an4M_l77LgYbC*8x0 zAPz=oBsf-!3L23Ta0Ff?0%Am{Mx@z5dj$0_Jn5PSeRJmsblU+RC9Cxmd_fUu z8PX^e3MWUtRz+SdWsR}3gER(M3`rVD4%j`q!fq(uDOhfT#|U9&FGXDm`F{`G7N}sC9kvikoHO0*9Srq|gO6Q4mKn zOkfxKR}aJnOw;S8?eS9chMt7mk*2oj$_7pb;DUG8WDAla&eoVb2tXd8){rOSzrLQ; z=*cehpWm4^z#$4sFG-SLIZ)oeZ7H1G=7608{)L*yJneM!#!AX2wGTM^t2){JFYp8P zsI!KK>n*m(OjcEtvJ_aif3;24gm}|_5 zf(DA1WFVH=nOb6fBTa{+NWR`3G4GSA_U&j9%B(B@W@~JTZGP579fzCp&=RuvJUzv3 zUa53)Q_EZFI01UTlqIt#q|Go-A;-7h5ZruGTkCzlu%^4aJ6Q6v>^L?Mo6|M5BEzR1 z?KK1~&10##VS}(z@ig8`FCL!n_9*7&MtuI|A1)&?B*4(rb?hBw8Zt+obdA5z)OuoI zBm<Xtl9#*235AVhK-I`6h8a39u#kZ$rRH1GHrga4&IBPv(`Cbt?B^r7pP6daf ziqG`V@F=wEm{W;~Z`sdvI7v5GM17M{e;hf@Sm$_fjNPh@(pFax1mb5I`lD}2lnkUq zTB>SWTeFLpct8UbTQ4f6><1BJNVs_FUf#QR zuj)6mrY{Fp_xzCzk!$kwM#^wwzc5vQHpIN3cUjE_$4A|!tMafC4FL}_gS%Rp8JieJE@6l&R=)lnj+eT=`sK({*YmDoT4YZI6f5RI%)b1!S#Jwu_pA6L=|< zIjY^P`6D?493uSVGZQ4bdh6F&nT?K!ClQvcEoCW`(IH+`j0WGQ^$&-U=}MW-&8;xo zt5n6M0ge!{hUT+x0&~Nbwe@3U_$wnhw>ruS?U}gbsGs%aau)P58yNfBSspo9iF!|C z9=Yy)P*9Kwd$6m!yGm&p`Y^~8^%Gg#2z;dL3I z!SFAkCAM5?krT=H)aM3Gn+Z6CRJ@raVh;KS5(Qq*E#kV zi(r*mJT!}{#U$SgNX)z$9^6Mx$2Gs1HY5y$s{KzMTeY?0Jl!%; zp^ThytCeB=@)O38aJ4V6KN&ELbD4>hw9(7?%4?sTbzVG<`!zM-i&4RNcj( zRMg!lHwsJC9Y_I4uNc@a)bI#cUlA(PK?0{3&E)||x)_^!ziw%`>(>!KB^V7U$FC(2 zoP~(O5b6TdjEJdLX8T1GBDgrJBd9u|Z`MdVOCDgZ8Q5yrpKjgr^t;)}pBZ?^ZMAzI zOTu-E?iYpR%e%Pcx|i`~_>~;<4_mL|UPY|bo(I-Xzgp(Is?*Kyk)KE1X&db;tx6f? zJ4ZQL&^M1m!48eP(LWOhm+pXq1DXv4U9JTuG zcxbsKA)a*2CgOq6V8%P9$)Qi+_#0oN2--7|j&>2K5bw- ze;fD0?3`caERFBM<#JzFou2EE+@EE}h0~Vz?#;pDjRA-NIA8%RX1W;6N1=>?j{E&x q)xUkp|6BO~VTr~V5;$>|&b literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_w.png b/interface/resources/meshes/keyboard/keyCap_w.png new file mode 100644 index 0000000000000000000000000000000000000000..a71de5124d9c153c076ceaa22550a6617a37b514 GIT binary patch literal 4149 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 Ypx$t3IWFmu2LJ#BQG)&JHpQ_14`e)Sg#Z8m literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/keyCap_y.png b/interface/resources/meshes/keyboard/keyCap_y.png new file mode 100644 index 0000000000000000000000000000000000000000..ed68e21384a58128e8b737f998abf650aa2b6bb1 GIT binary patch literal 2701 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_0.png b/interface/resources/meshes/keyboard/key_0.png new file mode 100644 index 0000000000000000000000000000000000000000..ff852cda9dbc378f423851fc44298a9595a6d1d6 GIT binary patch literal 3132 zcmc(gYfuvC8pmG{)AiEP)I6EHaLP?nldQZU36|PwNv7p}H>E^t3C#=P70k+XQ_fj4 zDpM;fn#>F@*;y}OUOH;X8k&)UXcsd?F_eW6*r}QQaK7e;op;`Oo-e<5{_}fgp0_9j zx5w1T#s~ml8u)`>C;(9K`5JyM**3fD008UK{Ug)Ek_qXj;!_iV@6lv@0wVB4d}2aq zLj2LplYI$j0E|U}e!d4OuV#hP0~?~&({L!W*1_ZW&v+>63XM#3jFwWDu1)^_jtK z&CzwL9MWc*6}Wf;RY`6Y=5n=T%iW>ZIW=4akp0YHFj&&+X;ogKx!(Esm*4t*E714b z(sM1QSvuDlxAn@t9Jfda=jm0Ajv|6v>B*%CAY&1zrdSZy;(|mv<#oHAvo2T+K44r5 z30d#)A@Xn~&zHHF@p5wt6D<=6eV*ETW)A zME50|G!xHr($<>(S?J9Vb8~aQ2}!{Q>{u5e$`V5JK%r2MIdb+;Qd3o~cG*j-QYs(k zg)4CtghrK2Cfav|(IXNTaka!j@*T!x2^!8Q{O$w4YQt63H3I64XRn)Y!w38<% zYrBm=-tK2lSgOOrv1pqT{DwR((!1AbEv3`94etzHzG-Lm2C;R-YHaZAGcBu~9bJca zUFeVU)DpK{ab^tcB1kF53jsM5kr1q)LRX1P^r>c_aHK~d-;nuhA(0SH@?{)zrjc4d zx>xXTx6gi+aVn%hNwZP~E)B+4ut$Q+|CeLtvmoo8qAc)#o~ zSK2L*gVcR^GjN>9inR0TO4{KlV`h0*;?VI^WzPuPC!EXi)@J2p)2hx+->IDTSQlgX zp;jR-is5N_!3r$zx|A?ep*u@=+%(tCEID(F?PdlE#W0b9fV1Zb_ zNd1&frw^=4oH}-AwH^Y5JbXU*U~}CL1c-!OWW{TEwgn@fgGTHBi|F_vW*$YbL``rK{ z-k8b=HUr1+2iT7?$9YfPV1^(E`PQYvEgJIpiUSn*JIb|2r0F7n?Oc=mKKxZ%n}DcL zy88l3;hIyWj7QcE3*@R&2m;93yVbJ{a>d) z4(Qh|a0y{ge!A{FpjcYv3n)As4j1~JPX(8}^GOe$_0+>q|I^~);+wGRD7;07ki=F^ zatZRu$@o)U1M+3D81Z&pcx|~q2nVv$muer@ZD%r>oBzSiepJKNb?@b@nXs`Oa0QO`xtMHX5|T0SWX}kx#X?Ue z9rK9DkJkqn%dn&MqkCgv+bp5IRL}*O+=$S&ETYyM(5yfk`MK@t!Wmv;dy~?%jB+UmTLYmBQ4Fs%L{|i^(Ek0BXFu2HGFtB zSq^ss6!iv#=3}jO2CDKhqlL}Oqf+6>re7q572J2no7Vss5N&wWuh!%R8(^w=7RciT zLYDFQdy<{oQy)Ub8yyu%DPFef9wShEW#6vZ}1Fn{gIkJ3nc(IO?M7f%?%}p~w*iKKzR(C$+?g>#B?o4r}(@ zuq}mzYm8Zz)^{3#pyr?I)f%k} zY3-s7myqUD-4ea!Ff1H|Ji43{Q9IJpEus1Mq{ zbVMHUnG{@o$4$<97W_u%`W=OJhuIol79741CpBz2V(1SPM zNg1p3YmsaF`U00nztI|k;pbWHr6%JO6Ukb|RJB`m4?#OQ8PDXi+UWquR(lzmtnedU zG42Be5P${13;-DTs`l^2VJi#2VjMtLpze$DuQ4xfS^k~_Vs4-LBe!s>9{>OX{c(N| IcEz3k6MG5?0ssI2 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_1.png b/interface/resources/meshes/keyboard/key_1.png new file mode 100644 index 0000000000000000000000000000000000000000..7115e92be8b2bdd84f739daefd0f55ed8623ff6d GIT binary patch literal 1909 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|UFbr;B4q#jQ7YFXldS5OKJeI+aO-QR~1u28~881JM8` zXI2MRGg$}rcM9(qra$1DBlvrA|I{!g5AJ!z|JJUFTT-6xVeR7GW@&Op_?oE`UfjZ7&aVc(`9L^}THK z=f{(C5|TZE_rEW5Q2J!^-;<$icPz(22}z#y4IW3+Hvc^Njh(UK`{mNRSzA}FI;HWz zdOoAUpSSyC^ydCO$lI{xN~!H!zubBOhD$UOSgH6_^B8!-ua_#eye!H3-7L+(X(`wL`|rK>y$TE}C9n3!=oNn5 zo|`SDz@Va-_h;2AzvYvI?Kv1c=X{^NY~|};ReOK+$0{%^QEJ(6|NU9Ud8hpu896|i zvw?v@fq?X)P!Kuo=+*a6`9UuG zSRupzZ~pxG+w4I+`}{x2%sotZ7(6n*-p|{d%_GA0OAfp0%v<$H$X*nfcdUf4xYf=68juyxRfk%-h@6z5f5> z>SA`eIp?46E8nBAzJd6}RAE;y&{n=FLg&xshmn6gJsMmpSy>Q4jTGv{Gbz^YM!6BN zv_h6i6ndb6GTSNFu7HtcNc9q0jw4q4|8J@X7!2DsT@_%<6=h&xVDNPHb6Mw<&;$U< Cyhs-S literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_123.png b/interface/resources/meshes/keyboard/key_123.png new file mode 100644 index 0000000000000000000000000000000000000000..07a3187a70ec9b39052d4d5d7445c9c8c75a7613 GIT binary patch literal 4399 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 Fe*p@B`1k+- literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_2.png b/interface/resources/meshes/keyboard/key_2.png new file mode 100644 index 0000000000000000000000000000000000000000..99a7e650f1431b60189211951446818a086a3b61 GIT binary patch literal 2864 zcmc&#X;hO}8h#Vmj37&)ATC5uJ4ZrUMyV5)MA=7CVXqF7!1fBV1S{3k%A)0FbK*b|(h1GCqw8WhBt9(&2bWR4^UyafKE} zKS!s9B*pa5?E!F4Je-evB|e`M3uB(U12R7O3Rk2u4TphfT&t<3Coy?Ud_?%c>UteF}j*Kp{od6G@u^|V6O~K!m zj%-c**6H{ySof3Tn|A*Fg}slP%(O*1V1I4_AM^|x8KvlV&M*%5+N>)^`B7aH(sEYC z7|IUxtE4WwtYkZNd2h7d-0z}0&6o=^nZBA_X)IygKYaY2;NlTnON^H;xO*8*o9x@S z&%}iLjw#aCw$|8X#Ob=;)>`Ze_~IB=gP3Y(A9tBQS~EiEXjRk2LW}272|JLaAnkQk z=KN)CEhP+tK{&C*eA~Rf|{GNh~sT_;n)C!*c4)T@UM*t2?@D9li9na<1Mwmgcr=k zpnCu0kR#ixRh?%A)9;!Mt*M;Q9W|)cO0(Q1!rf)Xyk>;(f}=D1VZRv`!ggFpFzOlz zC$E?%6`;#&38Ye%W1(9kR#+Eh6KfWM5o$)YSpVx$@}8Idy?vn{yw2RID5j|b>NP*L z{)S_5yH^}(__}$mCnOl%hd~Y)FOWuUhE7vt(q!8_mmUv_em@=yhMqmF)#shzGTuaG z<9M#aV=WAFAngy#?p98za!sP2oFz@1t1^R}H2Z!UlQ$0wl_=|@LQ>$$c17`i(5b<=P*T5BYl7&1L|}woCh|9x4i46(6Zy`TzxI%;x4Z%JcM(@=Q&ObZIR6{Nk`L~s301~Gd{ zam)9No~k+AqZrBBJCtwy6oFc5*ZGaDpaRVDcd5+*Jj*dDi@wNqty1mq9F4He>NJ(u zrEt}68p}6cjA&kg&d1vgFpG3WYX7m=DQ`Cvx+$$*4=jpG$EvW%~`BSTX%Bvoacp%eNs=Qle+ur&tE4?UOH*&=o2-0cfqzmPz$;BXZM2-Q zA-)1G-k6X+n_-lsdaZRvDKiC!L+bsBHD&EbLC-+?9F?X!;grEUGMumQ{nRlkNM0;4 ze>P=g2Oi3}!=E&6!;&AJQXYpo3WvkliPZoqAQ1ig*~A$$Je2ht1nB^%fH{x>HV|U| zMkbrYBM_Y>+J^h_bJEE(6y)5ICW_Ey9y;~M@yd_|2VR;=9pxbcDgc=G)J?$6aDLSL zQ&pAL{lA?4V&zxEU%l~XfBx@~TcndWy#Fm7)Y)3~P1BLUwUY=2t{hU{x|wmT3;@8x M<+Ss+CxSlv0qVJa4gdfE literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_3.png b/interface/resources/meshes/keyboard/key_3.png new file mode 100644 index 0000000000000000000000000000000000000000..5ec80db3e01555227ff2b9b95886f333a519f00d GIT binary patch literal 3341 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 YK$iZ&p3I@_5dZ+U9Ujhio&3-I4|zN9)Bpeg literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_6.png b/interface/resources/meshes/keyboard/key_6.png new file mode 100644 index 0000000000000000000000000000000000000000..bf4e81a7a1171dfa395e69f9b845ef44c11ea632 GIT binary patch literal 3631 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_7.png b/interface/resources/meshes/keyboard/key_7.png new file mode 100644 index 0000000000000000000000000000000000000000..5d9765b37e87c4926961c38bc4d514643e8eab52 GIT binary patch literal 1921 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 CDBSe` literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_9.png b/interface/resources/meshes/keyboard/key_9.png new file mode 100644 index 0000000000000000000000000000000000000000..89a6397c8210e9b324bd21f6bb13e2482927de97 GIT binary patch literal 3617 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_a.png b/interface/resources/meshes/keyboard/key_a.png new file mode 100644 index 0000000000000000000000000000000000000000..74d57d5bd4b1bb2b884a76011c21cb117ad612bd GIT binary patch literal 3066 zcmd^>Sy0ni7RFCvz_5s*En+~4(qgNa78*jJfdoY`vIK1rM8%cG7(tP+1(GOJW@Hfy zw3PycmKLnBfu^I-VaEc3Mr2SB0tRH86cQ335FnELr|c>o=LH}7VIFRM_vLr)t?%AC z=eyGJ$6!TM5SWMj6=umLK5OppTPrZCL zJoHHD*$W9*UxvB>801ks+>gfh$s|LU`{?G}A=MLP!pGmvy$y3+uW&ua8lThn5<|5H zYithw!i_9F{QA3ojS2sF{Dhus-$Lvit!7)yB{}Q3S-Do7?(ek>eA>Ml1_}@c8c;w1 zFb2TDinf0*j`_OFe>D!Mudwbf$5-zB@`Ekw3M+jjZv4N$O<}5=7$d6_Gb&G}k!xoP zo(GpMg!9EdS^!`*HS5gNZQwut zi{*NbT@HgdecHs}Mr2k0c+7AC(E$wswzv$rTz*a-HB|QqQx_7o*Q|NC@nHs0gIbp3ujGf0>!uR{iu!qm|6=Lo1>18jEwYfB6N?zl(nsTH|w=@dj9~!lSdn3{I8}kY(oezwalUn8;=Chguh}=zfj1wqlfz87Kp|9kYt>8#+wgbx zdy-E7IAA~EEKN&7w2~ayB?_P%J@tD2{!Nxl(IHj#iU=bIy=xh@Ol{(#t_k#hP&DYZ z$?LAMSSLQYt0E|GyblXUD#|loMPvV&7+?Q`N7lEUDSSSEvQ`l-NUwUM90@78QGw>7 zO;KXof>K}PH^-DFe8=vt;jq{s6y>q6b#}f>;`Uf%t&tH?J5i?dLLX-Ox>KsoncR_< zN~~}wE^Z>m8O?=8)-$mp)d5i4`7J=hQlPMP=H#p6HD0+}feUy5Sw4-3V#(&EbxbZUJRYb76sxbzEFrLRsbO z$X?^}Y}Yl#oe>^-&iQ_(y5^fuT>7LU)3azg{$y)_N?g?RN3K(NrWwD<1bXWpys0Rt ze|}<;_MQ=of!Nv}4x26Cx!WIm|N0U0o}L~ak5^0%LQU1jE;X<R z!`@ouFP}@y_ml3S&1x1Ka!OrYTzst8dmF2Ih28Yf-Tpf()(%8gDKoY9zRC~Hh?DNf zH0jWt9S5hW1Og$6e>CG79KAYE?l>Jq%pI&xHaFremT*+`z`#J|Vr@5lk!}p%bw=2~ z;+IP%V-*U8WY`kJRSN_H@q$8?Sl8cwA}&f7RmlE>U!JRx$bvF1W6_j7V}gm9E2L+N zwP?yIR~HwCYGaM0Y($HPuHIJii046W zb)X!N9@rnV3m=P(eOQk#XESA-SaESR&G#V zGFL1)&=35w~?j#p0Is#c%UAMPY%l-v==h_6vyiH(9a;WUg)(^EM8{O;*(gh76R&5!Hhv zv3W*o*=^tJFtsI=my+5}NuROz<#!@9+OIF)Zzt$Ks{dekyE@p$)X~w=d{Fd5SaMar ztE+1}xyh^Oa8j1zz0;Sf!qTX> zrPXI3vzOv;yVrI~I|}%5w5;Z66bjDF=dD`l^jsdg%eulWWemA1MVC)p))gkQuf+dP egV4eb#9%Mj<;D5XNV5q50MrA19%cK^-1slYRmbE2 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_abc.png b/interface/resources/meshes/keyboard/key_abc.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7f1bcb0fac2bbec87755d94c1c430b2a6cde6c GIT binary patch literal 5346 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_b.png b/interface/resources/meshes/keyboard/key_b.png new file mode 100644 index 0000000000000000000000000000000000000000..50b607cbd3f6e5e6bec11845cca09a68200da6ec GIT binary patch literal 2839 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 KT-G@yGywpG#*C~0 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_c.png b/interface/resources/meshes/keyboard/key_c.png new file mode 100644 index 0000000000000000000000000000000000000000..da8a4a4f2d531742844622ed80c5de4646949f6f GIT binary patch literal 2950 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 Jm$nEi{{oXY0UQ7T literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_close_paren.png b/interface/resources/meshes/keyboard/key_close_paren.png new file mode 100644 index 0000000000000000000000000000000000000000..6a93b0fe05348fdf8344a6162784517cdaea991d GIT binary patch literal 2621 zcmdT`ZA?>F7=G{N0xgJEtFR%$MUmha7Ly@k0(SxyBH&O_6qOdK1huG$2rJMQu^>3w zxCDzhi63E;34Tn`QKeYL&LQGt+CstN5VgXf3IY{-yIh@#Ci^k<$Fd(M=gm3qbIx;~ zJmD9W`+Hw5{Lmf*mHkHxbymJbA8oN`ii5&8bHJ~5@ zzyW9!tM?B-1oPHb9|@;@0OPlI|7bX%A0gc5M?Y-tQO+p*BP(!;Xv{j2b>=hb7#ktSij<~ zbj3Nb9B}Rx)@;Wj?dtyBTvU`7$u?FbCv0*+sMIdMs9>V^vTHDSLRf6{#2@d>tQjK7 zdo~HEC?X$%#)|1MZwlH@ug5?FEJWlE5x}7V6FgX0o}RAlANFN1!Q)2JWe$ojTQ|mr z2_`#B$Pezogkx2emD-)CXP~15h&EO8uG&d^foOYDTb$j?b72Pq3iN>mUNkV(DilR? zdgHYOrA8K)1_;i+A9q>ZKq*&n~GcY(P@jC4g7D@$+2?=0udqoLv{A^plU`@QYlY*!df<|+CH@TIC01h(dN-+b%;zpdr z6d_;$U;qdZ(Amta+dr9{9zC)>WBlHW7Pv^0&|gn+?yBO49h8AK)SE-(GV9|4y~d%S z)Jxrh672Pt1WCgTN&u&T4l9o9MO)&UWtbh0`jt2HR<#ikAUkHv?w-0uK z?D!UaU4!Gj_I7Om2B`<39ogX9j(M{GwwZ9A9O?~0G&uIqV3<*392&aY=MEec5YaIg zsal&~UwFhg#tQ|yeZ1v(Pg|cm$c8XR++!Qc5Sw(qS!OjR0z$RMf*?5i3hM%Cg%ZVW zHQ&rhGutAfvs1?AYZtPG7~Bptj}uyT_VwMgnF_KFgK$;JI#>jFOtwB>NOI4uZhd7a zegBnBWfiBzH1J4vQ5y$>T7FY{*$E1O*q5$Yn^C1x-jL7D^|7>A8WP5jn)16-GyT8o zb@zm@=Yv7%gUZxVCnk9C{ms$fDF$i~-aQ)5Fs;bH-CR3|x0xI&T()1YHnl`Mbe7RJ!L*3<5Q<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|OhEr;B4q#jQ7Y4JWcFinv~UyH>D)MMZ&eiXtz!$H9a5 z3k{sNTs>>~(?9OQ_O2!CJAN=eu-UK1Ai%)D!oa}D;J`o@b6~YQQ092bwF^)XbdV4y zJLF*z2BXO|i9+oRj0BRvgZjNb2RN@q6`xx>ImpZFWC;huo!h@(-;cU&C7FCB%J+D4 zB@+`lspx@q9oKomqH}w{-xc2dF(=$5g7JaFyatL=GtLC@?m;S(--c_miu2}92@3LB z=FITm-o5z$*KS=qDY5ukmX+KYVSxq)1_cHN4p8PLo_YUca7EvQ;&*%B&%4o^zFX-K z7X$kz78cSCLa~%$?W5%ax%mm64Jpjn|Bpq2;k~KmZT~0xDj66U7(8A5T-G@y GGywn=Zk|K{ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_comma.png b/interface/resources/meshes/keyboard/key_comma.png new file mode 100644 index 0000000000000000000000000000000000000000..69da507ec60038d15f5183d3bff4c588e0f70135 GIT binary patch literal 1785 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|VP^PZ!6Kid%1PAIv>uAkh%GLP8?|>DDiB5-|^&s zlLeY~Dl!BtD3tlEFT=+kyeQ}ObAQF)1!k8`w&u>RT$nlQ_~XQY8;lO6_W%FeC@?WL z)W0m+`6RyJpEIuvW8mVudE&`2Rj>ZATQg_`dueX|wSLZj1%`lCe_s7Bv6}0{$=Ja3 zG{3=_qxSx6AtnaaQ}-FH|1Xr&Yg~D*Y`3qbuI=8q^|9Ay3Y9Sg%#uh>`^v8v@mG*R zBlsWFfuHmdKI;Vst04nX{djJ3c literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_d.png b/interface/resources/meshes/keyboard/key_d.png new file mode 100644 index 0000000000000000000000000000000000000000..557f3816f070ce467eca46e65e5bf8ce64f86204 GIT binary patch literal 2962 zcmc&$Sx}Q{7QKHGFb09N+W>6lk^G5TgyUCAKI64KWI%AVd^HR3HI?1Og;obQRU}IE4@W@YOjl=exJgt-9x) z@%H>)pI|}&0DbpuEe@hSQ|HgZRU|-w20#SB!JO!ya#YmEOhrYq;o;cYpa?Bzg862Um1Tb0zd7YQ z=*eE7j>?m~eY>95Ox5yA#2*9Zx3;!A7GxNa;4atyW_!G2fk-CU+M(r3{YNj0BVBY8 zYlEhG&d1qzVMBtSyiQ6^mN3W`*^Tquv|2xN^1@4>S=Gb*uya83*h--kGN%>og{rd` zU2;O7X8*>WO`JE$8jjvKH;YJ(cK(6B_Xza^sx=O@DP5S}cHBsFsJ+B>RvHtr*MR48Z zDvFJ544WO%_vp&Ry$*({PIR35ErYBlP;-wQ*<`dp^5rUT{G;Xbc*nJ41}-j<<$>c> zKVwyJUHpk#zLJ?gMx|0kbiQ4k%*s9MU5t@n;iJ<4uzR6UZ7%S1 zKW3zVx@!C?5iF06inx;%;>ZQMCwx>JKKm%YM%Wum+su!U^TZ{3Yo;X?} zwp7HfB!YpfhHVuT6yz^>V_{)Yw;2a42kf@2`Y;C)4E&VOMM55V62Tx|x5sAz9_BSI z033=9PazXQ>7!Y7xf{_%dT7``R;5rAqhp3cu~GsB4;*ieT_Xy@pe8|%DINygO2kAU z0*|2?EL1!%~ZU>%cmx1I8s(Wka7JS^^cQ^9noAU+1r|F*KZnA<`RPt(X`vXj=7JoCm(Gb*Py7ZG~#c)XL0 z2uji4k_+qht~+NkooOAK4npqeL_qX=%G# z7{8pk+`<(u|L86(yQ1VAq!bCHww~Kfs=DQ`%7U9U2g?{4afyl1Yge+K`O@09rHO4; z=dh!@Ivpt#jY_>}_)2DGrk9u3j&q+C&kb}>G-=X|ap>;m)(%6NHUM6);Gx%f_I4s| z>LV2H@}Bm_wr(o6;;)~aj}azs%le>H{vxzE{;wC&4~lozt;d1EK}I^Ih0a$85OCV#c$04 zLE}=ppX@Im*?dQ^Rki-ZAuHlanRyJ}DXf6+4Hli5QTcjV1RXy~CC7k&5={ zty{L#C{>N&w?gM(KpmpPkZ- zpSHWxwsf)t!Gm9^IP!q8rP9N;ykq!izW%hLlQFP;jCYM>*z7doUK*9iW>@T8M^!qe z7^yZFv`VGntpdfqvRB!SANueO0)ZRa~eHjrf-O_4y>BRq-KaN-1eRrjFI1R003}z^>n%OUC8m@0g|(h APyhe` literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_dollar.png b/interface/resources/meshes/keyboard/key_dollar.png new file mode 100644 index 0000000000000000000000000000000000000000..0105debb68db7fd3bd1c3d209956013ed76c3b52 GIT binary patch literal 3481 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_e.png b/interface/resources/meshes/keyboard/key_e.png new file mode 100644 index 0000000000000000000000000000000000000000..1b356d9d5b4e2e207f7027410a16925164a01876 GIT binary patch literal 3029 zcmc&$Sx}Q%8ohrA5)ergP{55vK@o(g7&alu;($m55hKbfF~}AI$P$pmwrqq5Xlr*s zgy7aGjch7Si2`AhfeZwsK-oG1VptQ95Wpm4imvI0d6=m#dFZ-z>$@-K)>r4d_neb? z((Bj;H9a)|zy|k!xcC471FIXcT2l3#O8@}639k5rQ>2RtNg;6r;1os*CFr|HhY$%q zgpjZlax1|efb~i4E>6D5{nJCkr$@$ja6)Q`b-3r)=fTCW6UtcSqkpV$Aa{mlP4us`c}-n2$I>VH3*;;fZ&)g}9^hj~1~9I|^ttdJM> z)6KY(3)SX70?v-yC3J5~v138|2vJ5ZVH{I3T*qRuh(scV(-OZVlSS>Dtv6$ETf^e0 zR7IO7HK4*cywbn@;=%O(1_bc!NVc!7sWB%HDhR5!g&ob!u4Wohv2b9D79%qn^Q5Gi z?M6xr2IBw?a7^^R5dcKnUf{)&r&BM7@H7Z#6Uk(>j{5XQI5=FoCo69uC?Mdpz^G?x zp`c*vmh}dp+zCA$+?8vDVL51kXQAA(6cwM3)nx}(9Lxlr348iQ1{+UGerYGuh z27`eXmq{D&YGSF}=fEvOUuZ?FH|pn+Z9v;d~;u?Ynner<9}v^vKhjLx68 z$JuPwBKw+up)^0UmE)~Ni!$g&TPYiC2OHb zbSkj6ogL|i(V{GJNhH!t3>>uOZ>jPxG2iKH+RK z&*fUOx0aWZK8U@wgeolysuO;`V6xv1P8<5xXk4teDWjn;>adQwgxobM>_h-R;J%MG zL9daPrBf&`ruqyrXqH;SNC_5LYQbQD256U&2;l2G5})hzAJKkBipQ0zKs1km)(z9x zRT($s)T2H+EW#dcs!dz|>FZZBt^{qc7x^Mna*20016Mtrh`5oJ#}j0` zjSgAxUQA0DMTd>>P&svSfzswAd${M_Xhk~$#tDq5^xtc-QzPRy0|ZFjJ4g5Cj>Q$w zX*~nYI6Do9=DV;ZlTIr+LfGx5h^b1Ai+dk_=$pKjd+Vc}7wnWd+uLdkBc{}Jg}=H! zK~EC={Fc4{HYwK~pnLjkNyT~i~RFR+zw`>H6MB=JCRL>lQfjWYv z&aR@4Y-n+KI3&I*gKpL4Z?g3v#3o!VC^7`I_wrUSdf82P*aeZC>PHG3)6~x-lb`bA z3u{%+v8FvKDJl7Zx9xc*cidkSJapwqiR8B)1c<`1y4{(^Ey+VMRZ@BS-Ve$rSlxKW zuE2|WA@L5h$ymgVERVMbn;d&0Ds@tfOhu{7aj$P?J+?45ytKogrVZYf1eP%_TM=)5 zVy~B6SgGxe&|fFF=Z#)}vdyEvq4{aBGN+|0riyzcXZ0J(5&yEtJ#d26jd&Mzn7(K} zXGcJPTcjMZ(}HOH({0*LW#&-jvdt8^&=~HmWz>#p{kydZ+fCa1>H#ltzG0jf7^{tvMmR8&-VC(|ysaz6GnzU@HkNpZ z^R7fgCEM*$A+G9-rs9f?Bjg9twz+zr4+sbd3L+`!Ftj-6{CP3uNJ|Krj0rbh=NcqN*7=DO$v)L>!L4D zb@s$xi9QDh5;u78%E&+!iMum!S;`jaHWgFT(#}(dt&uRmX&F9n@^8*4P`T<_O?HhQ yy+-)|vG~6j9oHBV*BsOSVw?ZP6RlFVmjlM;&TMpcRVxPo0C!g}m(rubKl~e4s-)xq literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_enter.png b/interface/resources/meshes/keyboard/key_enter.png new file mode 100644 index 0000000000000000000000000000000000000000..cf935fe07e68d6d899b9ce200a13d0ddf56fb590 GIT binary patch literal 2201 zcmeHIe@v8h82`NQ9d|Dn-AmaV=jOe$g<)-8LrOH`y*y`7N+TIZirhJ0qXD}^Kw_QT zcSehxt+s5oMuwF-+j*QLCW4GNq$ygNweA{6a+m`3`s2zj;pFbx>!t(7ZJqRIe?0qs z_TBS&p3nF5-Ja*|7uT&xPSPd;NX~iAm=Dm9gOQKdwX2Q*NUdINs(#73wYt`_O@LvG zb+f?bR9IdS@&(J5y2?J`X@FUgV>GOPt^dj|zIXb~bKg8)X`~$LcEu=a&AIxA_{3^H z@nFJY_hDsjvqJqu{@h1)HP}<$YxR^Zy#Q@FtU*N;k|Gs zwVEZJxd+9Qy3d*pdKT8*780lPyUoK&aZX#sic2T=o)vQYQg8KjxT+HT!u&nLs%K8T z`q8J4HXYSjDQe0q`7JE&HQp$!iHHl$?8&H!W3W7e0fVTy7` zWGyk^G$Ao7-FeD1rISQYg$jlQ)hr|p2anGGRK}t)XD~1Q+mRdL`s=4fzQ|kS3ZzS# ziT(1A5A4@jsiGK0k>Ififyn{N9qBr-r)eHbY*I<^y*K(< zV9n5}a?$(isou2EL?n=&vQZZaw$$w#JTzG5pcpT*7rnsVj2aI8c(}q>GWfv9`5oO@ zeD;T`zuWEez8^~quQh0~`)e=K>hAw=wB}6LWA%kf4qC(t2HN<8s7mF6%@jc?QGsrz zwD>od1ac-VQ~t}i+n$j%ak8enol0lAoDkYx&RW7?NUJX{1m)jxvp@6(*rSw3rzDNZ z?g$5CISHyMZnMIf%^+pIIcz}V&yX}U->Wh?SS)-}oQd+*xy&j$l*t|V()749+wO8o z%L~Dr-oVE_Y^Ke?S*`@y|Nl;#=IEVW5csY>Ne{!0jE0Il#|HqAvwEG;^{jd4KSw<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|Ohgr;B4q#jQ7Y5B4%E3b;BZ`!cjRaI`q2wXn_HYJ6kr z#G1@i?`nTaz4VM(=5mC);rAY21_cHN4v=LH3}i6}R(k_wj;CC^0tG<_330MR9u{FR znoN^WwNE&l%yj8?wfy~W_p;ZTsVo%|3{&_S9(U^F+oX;|dIF3<3-c zEDQ{c3=RxLG8^)3)fDzxKL41TeYPPbROt{GgE;X%v47fb@MQaUyM1r>UXRN4@T%iD z%o<;*d3<~JTEjM8&y&Ies9{KS03DE*6P|Ey&gUQ7wx&63(h^K$ZQ!nCVj|gls6j!o zc43MN1j_RiCB+dWCMe8P6l&){%Tq+B2Us$K(Ih(Lz<)a?9tQWfO6#x9|7FF%z`)?? L>gTe~DWM4fmm{&g literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_exit.png b/interface/resources/meshes/keyboard/key_exit.png new file mode 100644 index 0000000000000000000000000000000000000000..4c06660d569be01afdaf2bb290eb1525c39f5ff5 GIT binary patch literal 2466 zcmd5;eQXnD7=PYg?dtTTn=_7LvP&>VO>~lM;3lI7cG+g)n2f{*m3FwYu45@5(+MQp ziw1Ef>P%!yOr~eZ#JP@^NLC5V7RMUR5?3v@i`~`%%NQK6sX0gv?`qz+!Qf}K{X_n^ z+*PIIgY)!Rw9e@tVWhA%V<|hXLU~yZet*zd(xvjlnyAxJ7 zdNw(^n%0IU=Q?LYwmS?;@G>jxv(=ga!He0g^7mG16+WuC;#I&Q4E3OE2GDUBhc<^LOwvjd-f zIF|)=X~r{V{zk(vgpys(W6Ak%KEhX54Yo(i7A^*sPABBxM&IybUa9=;gUp3pYH|1I zxgTAYwb2=VD>I#xl6zeJ-wqB}h+!pg?Q{hoM_jQq^KZ$*cc8~Zk`En-*%=c|JtwfA z9M#Mi7N(RIzE_Te9$$;unTH301`bXHG;>|ES_)`-NRH#DBp)(jIjBwFTPlW?7SK6V zVGZN<1`S4t*_rKeJEI55hmJ`;q~qV*cHjPn$1i?gB8HU}m+Q}*6m+}_Ug=X5ON}bK zf9eMjnH_2#l<A%6+! zS@jmuEba*D5%~~p!on~PLR6n1Jelh=E4#I9CB$zo& zG}d{X$dA85IH%2fomtS#uG&GK!kfs82nnAftZED3n}L*N48WNr}QBu%c1U| zfV~1`4a;NnmHuOJs&J3rjKQIn@{9?*L4yl)SdIhBgI_a?h>mm#ppepn4IJ16mJloX z(BB7O)FPD8QNkiJv#WKf7~KGcQzLUw?G$_4<3r{56$^ zlskwT?_T0iZzE$!=7>yqipUM|%+=)~%-=Ww`OTLoCBg iH_pHe!{JbLKQM4ya~B+PKS2J*pr*3cdU%cF)n5UT`Lr_t literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_f.png b/interface/resources/meshes/keyboard/key_f.png new file mode 100644 index 0000000000000000000000000000000000000000..93306c60359d04f8e1c8a07dcc5167bbb0da082b GIT binary patch literal 2112 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_h.png b/interface/resources/meshes/keyboard/key_h.png new file mode 100644 index 0000000000000000000000000000000000000000..c73f37c271fad09d1dfd91c54c42528baf87b75f GIT binary patch literal 2186 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|SSSr;B4q#jQ7Y?*=|fkZ8DAdc68_<3{E;3M(B}9Ef0y za$LZ)foUt#My7>K&a6R9vMKu`)AkhHo!ZL6WcmD1_tgDAZ&c3z)2N;r`Hx{iZCw-- z10#b21A_nq0}BIL%z@R;LYdm|9|@Q>BoAzVMx?MX%u?-nX0Sk8{_p&G zO1_uhe%EqfSYXtAFu{EG^Tcok6F24zyKu(BKVia^Ml;`h@;q$ZHa~J&Wc?e)1NWBI z&st_Z@1HzxfgJ;5L#^L`Tfc{K>%Tu;e$s*A!Hnhg8zOX$J>K}8iLs%~_y5%2zkmPI zvtnXwIREb5JE@{SwO@ZSyyswI*!|&0baCAI6r(~l2F8Y4x?~H#Qr5`Te(U`|aLEFHTGQEWiA)V8=gsj^ESPw=T$(VOrEX`_KCI z>+|#TBd7h%{Z)TQ=g0Bff8IBi78q(UG5i*E`r&wu~q%EaHWC@>VP zI3&Oy{a0G1mH+07EPDqA0frk}bxt4pa{ovFw)U6*|NPnW_jCaV2V+C5|Nr*(c3pWD z&q;aPcRy`D^Sn5por$5{Zh6C3ZX>i^C7aDu$f8yWQ?v4xz_MdFlKQ8dCICE+-6BEPK&c{3U<=-uo zVqk1=%l~&hN?f~#!NJ-5|7M-l??0YDe_q#!iNW{E`|r9;91OWz{ys1Lf7X(Lg+Xf7 zyW}tHn4HKc2q`K&VdYQM<10cJObTV5SH64dc-QaLrY&3yx=*N?-H>t~(aZ*hh|%hS r++r108pCKZ(+Qc{f85h&WMc?yv=V4+Yq4TrU|{fc^>bP0l+XkKroGA8 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_hashtag.png b/interface/resources/meshes/keyboard/key_hashtag.png new file mode 100644 index 0000000000000000000000000000000000000000..df673653b0416114f905965f1ade51d11897a0b3 GIT binary patch literal 2394 zcmc&!Yfw{X5Iy&jU_h{e8LXJpMihU5j}(hMhCmc8;se8@z)Tf`5~+ZM$kPGJMG@bb)2#v61}`=0^en6fM~B~-pCB|Unp42xsr8)SmOEzz;E z5LtB0HpLa055O4?T)H?cqqFbs4MnGrua3T{!8Mj*PL+qcEQaky2#M+#@*CS}xpwDs zAI)m?{lj^0h*fmcfY-^#tFHTX(9+6do8wUHcZpAefEeHcER5^;|H4@lYktCT5EG!z z%=q}39~ErWjx*E0;XU~xPuAR9zo%Hw%%UIYYCg!HnO0&n8c%#TFh9#%D|KJJuR`r4 z^7Z|MEv@e0PuX?CS)Y>=<=h*gch>LB^46;72@+^JvhsGFimq1hv%NWp3DawRV&@D+ z*gtm5g*dOMsOXMitFERl;@n68%IjUJ^P$OoE>`KKVhFYq;Gl+fTyvGAAWpS`7)k8n zC)xy15U*>qf&EKp9YCVPLelffYYwUnwS)j&6^3PxLz90z_yZG?x`i$aDS|y%czP-s zJwy@*Vd-qiCP+<15J$-@Aqyu6s(@!`_5c@=`EiqArKBKkZrs+{>@&26N}%n~>Y69% z`DM6aMFMQV#ff-R_cRDFh0sU=l5(XAc*)%mqkO(B@hT4jBpGyc^DIh?$b2`Ie&fQM zsfP);XC`rK>+^QG4i9U;O}a5SG!!l^sdJn$BfwF)`4FFdHeRLcH)gi{cGI)4<=zcN zIaJhZ^1QyR)W!3<{$m-s{zY*mXCK*zcO)zdR@z(^juZssI$?0vzg zr|jrQ797O*xF2xD(K93oyAATI#}6N{WmqX>gfl+O+M6l&T@c)pX(>RlV&jpiSWoz) z+`EY|Qzd`G5pc0zkSCEsSZU8g1US%M7?H2A#VX1Ut;%b1v@0_#`6N=;5l8QqD(FcB zRX@Ay&;o-)RntJWch~o~K3-i&{@l>iG{+}qo)@e?(y6Ztwyu5Z{Z80DO-EJ=-$D>>6DvtxZ;sF z?Of|LU{N0T!0`O}bFbcr!j_GFuJvuF$qS0fU!MBZG&LhOhlQtKW&34zZmVl|Vg6hm zrFT|e7C!r&i&ir!N2=Pcw_Fln(9c8vHN%xg@&M9l`88XZ9SKWe7pKMhra9=COfJds zzXAkR*8S3h8SAT;fUn4trL7n+^{4St&}i|9GLJ6-Vt@12sky6NFQ5o8d7^ r{<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V=CPZ!6Kid%2)9^`B>5NNwN_b5|Plj@R|lC(_E^kmM{ z&z}9bXFFZ~;o$~}nLimH)b3Mb5MW?nVPIfna9|*dIk4ItD04jJ+65>GI!K6<9rCaU zgVAJ~1k>JdicdJ;UR3t)ocDXTra60co!P>4bpEG~x0{R48Mg6io)i^mU|>*SVBi3y zOd^?o&iX%GdA@)BL1~aC1qRII)}a1L)!^&et=rCjyBD2pwq(hNsWx9JwHC#RL^>5k z50VcX7-qo3h{7O-YnOX%!1y%w`qtWSHvY^(p()Em8RAZm6TT>p9)unxtz>~EMR+)o lrk#Otq?Sf?yICa|3>y_Lb9_0?!oa}5;OXk;vd$@?2>=FFoU{M{ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_j.png b/interface/resources/meshes/keyboard/key_j.png new file mode 100644 index 0000000000000000000000000000000000000000..f723de1f55df97011773af238e7fe85d00f387bc GIT binary patch literal 2070 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|WaGPZ!6Kid%2)KFpo%z|e4U*(-*g2RRMu9P<>;v2>~z z%wRBbym8c#5?%Fj5e7;1DN2h^Sfy7eBeo|z^!d>%ip}|HB6aQBg%4ldxXxcWiQKf0!30IrJU7H>M9IyVq-L5 zA<^F$VT3!_4n*AAX3C+!P~)c9a__jBXA*-T!;g~9^;4FuG<0Ejpp(1(wqkoUqd}s0 z>~%@jWvg;;pFVwB@jSzy#spS|^vtbMyfgo9)-i75VCY$|b9&XPe_7wwzpgqVY0Hps z>kiW+n-+m*pDX{Q?u@asvpbiz+5XS-imUUV|Ga$p@`1_k%B;H?SQy;I(*O7Wu6+0I z-MaPblX>dq{(8LU|A!9-HSg;usjOeW-c63N!7wybbp4C^-~O4i_T^vcoc!;A?|k{A z$8yZ1oBJ6KWbm~+@A+f@Z+YeS-+R*;d}g;Fn6s zh^tNaO?hnwi;GJg<~{%a=eEKoo!dEP-+n7IFdbOg&3Gek`~N4!iKm|y?TxEG%gw>? zd`a%@UF+u0pI^P_g`w}|+qZ99{NDYFo$nw^b)2C1W@p=3E#-AS-%~-v9^_SjC zc@~C-`EQt*$jq!TZ7`b5qFaE$nfT0$k%+;0p6I+!6YUMemOR*Pr(C-Nu|?pA`L*H+ a4ENTmO$eFAah!pHfx*+&&t;ucLK6VsNvXB~ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_k.png b/interface/resources/meshes/keyboard/key_k.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3e806a8d135bf1b79703fde235ea1d067f9b96 GIT binary patch literal 2389 zcmc(feN0nV7{=e*b=i$fq@dv=OiO8CP9jQFn23~*A{Z%?iAutD!4=$=p+#Y^fl&FX zfZ7g(3D}h_C=7;d5MeNFMZ^}IA_E}^6R0KbQniH@TP*EeZk@(te~o6@A2;{hbDrP( zKIb_%r|3P-j^)c-mI1)>i15&801A)~O1?5(LYe@;IhDOX_5H(1sp$zv5+R6t_&_2( zB01q;Vsv5x_luO?#BBhmJ|Z+|PsY7RzYnA+_pan8oEuGlVEguQ+ml=Q%idhIzX9r5 zM_#{keEfJ!?#>!&95Qf~>Fpe4Z(D8fn$VvabuJSddD+H`rx$r!#bGcmnQ%q;0CWJ9 zw6m1qQkYj-S&0M1D)ZLkd@G`R9>bE8f0^Ra4(=uP0$=RQ_M=E!r5d69K^o@&?$JVR zxnK}itu(Y1fA>e{lg{Q3nn$W{-Eh9PFAhPhwEa)~>an%OO1DWI|6Y?RKLISC#=8yb zPKD=CT7Z}%W>TPXPt2tWjl1N>vBwoR&zFS)y(~fJ8|ko+KV3!-v}cR0L9>_qwCB5D%2}TRdHNl=ijgEc4^sshc4Z z6jasbu7YMf_0uUEgwH+HqMe_eonq1;e2Xd12K!pDJz!1*E`1sJfNJu}Ap3ls1@p{+>I50@DFAxP%c&ttq7_aP2WtvU*_$&?j_4@2pG7YI46! zsdoC?C@MsAa%?a+k$dUoD#^;FxN*CmNzgIT)`Saz)q-Oy+1ii~F)Tn=1S}7UMADTuQPSSt zK6xKdSgfnMP!vTBtf}t4?SSwzhxAu`&F;XfYa2`E6*y6$rC1hhONHpIuD0F%$Zdqw zY>Lz-2h%}RUP7nBlc5c!yv80ni0&FIkFtQ?s8Am(TtoTc4R;Xz_Tb`)5+K=fp7C)8`q zNAEibfYpcR>>iot`gA-iYaeTR0i0T&Yw7dZGwj!-))ud3GnNtMN`bX?M9-I3PRCI< zM}8FGgz&Qlb5slz;bUR36~xooZzkCyvV^oiE|;&9zLP=Inazht;Tff4(AeYQOr;%! z_mV#J>oRCVX<||x-Py|)>94ziC|b+-SQU_hf@;-e)Fqoz-_W;_0iviyM*Sx)nxYV( zpRbOi#;ynh`uR!rohcrVj9}+%NsTF6LvpniH%5}aKpgmqcal7F89LK>+tPV$HqBC= zJ58GQUnN6ljQ1@oxQAnqC6Aq5B{g5w1=ApW#=|kFyB{I{k1XZit9xflYI+V#F-{Nz zD|TE>e>yiex44)!?~4b{YqqQwj<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|UFLr;B4q#jQ7YFXr9~kZ8EL>=Z*9(+9SD491PNOg;?~ zjl8E=W^g7jpJCh3IFrGcaj#N-?iGzKr9HLhlp7;{E&cI%>-rO?-c#5?%Fj5e7;1DN2QBhRH7 zKXzhR5PV1}!D!~5-I^MEB{IP!i z=4=kehCD9yzxMHuxf%SA>nd1j`(9S#FSn36Bg5DbCeiEWEBEgEY^MuF`rB`x6?w7h zU_!w4(&YBtF?xX_lGb4iAHGWM<4zH5U~q^|iBw}yVEA&e#47jGyfZILjEY$pZY9~x zH!st%cC6HsW4c^e8hgC(&W*(h^#Ku#4$&@wTjJL1GC453SZs5C`Q=RGg<;+fmW7O|k#XyFVdQ&MBb@0R0&yz5oCK literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_m.png b/interface/resources/meshes/keyboard/key_m.png new file mode 100644 index 0000000000000000000000000000000000000000..2e83b7b214666b41f24db1021b1ea570d9ece81a GIT binary patch literal 2438 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|Tdqr;B4q#jQ7YANp!#iZmp?ezmHWrpLbSvZyhx={f)T@SQhqHbO;lGaR2?ud%z&@ZkB+ z)3FQ+3=A9~*EBGY#T;1e4U{>aa_tHf1RW&A$qso~gu!SsO`=dc0~78f@Z`nz-3?+t ze$V~?@BZ1dXY;n-uDtpo;r#jYn{Tf9GwW)C0>hG!yj}0>!nb}Ex6iPed++Yuqx`+~ zIzM7J7IZJNu&{XXwd&*hUs)z{{iko*@m^ZR*4+PWg2$Q-T>S?g7VP-ZUr|-HYu~;; z-JO4w?ekKMW~%)8n!K&MySu_||Cx}De}Dh|_xSPRc41xthHJ_U8-@M-{U_U7_wnfL z&wVVGl&wBTtL|)mso<(Yp&55g(%`X6jUCG45eX$UTzRtA4#V_y2Y_olhnE zYLf2V$Vp}1!{@;8LokW^_$lwY9rXv->VT$u2&V$Y|y@mBWs{|hFh zo6UY|5Y6+fu!-S<+02|9=HcPt@8gBvynp}P;OdvD?%YfctB-s;|4QZg*|TSF+eFT7 zsEt<>V7PYg=FP~w{NkS8_BBcX8iRl6E#mWHh%aXL?*JbB_DF3@dTU0ND?|9EkoVT6z9W>d2n3=`VC zl{VbsKBTiFvHkGF%a<=JCHtsNKDKVvj#US%_Rc#lefl$3!=2LIcQ0ma`BB=u*m+}? z^bd=jqI}PqAA(|KQ(0A2Rek;cyQ_~K&*}?}ex2~QuD-R|lD~)HLecf>*Tu!fr_P_p z-+uTP8@D7M8Rxf4%#|=>F_KTU;g`bYRHn{59>(vDb!k zPTh|dajng^x*8qg$PkhIUVqUf(f3y!85jf@7+4q>7#SQ$U<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V=JPZ!6Kid%1P9OP|q;Ba*`{N}o%<=^TAVeb!1-uZCO zKkVHx(~{xF`AjATMg|841_1^J76!7I1FM~dGRIS_oq>^ppaX|E`5_OBFc?jyNfc@q zATQY<*-BChYKRU!YQ2M%iJ^gkL4kpR1C%=mGB<2I%~BIyp1w@>!OR(w+6*}-xH%|F z^c1HX2L>PFosZprMEjm5+8c-sd+fGTu3dqsn8#^5<=Q#$8csyQM0MK-e(wfGhC@Gi W<;|Dv&tPC+VDNPHb6Mw<&;$VfXLQd1 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_n.png b/interface/resources/meshes/keyboard/key_n.png new file mode 100644 index 0000000000000000000000000000000000000000..be1a6a9e7a52b5b517fee281f23fd19d6fe9d257 GIT binary patch literal 2165 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|SS+r;B4q#jQ7YF9tr!5ovh%b^cy+)gxR-xTbKma82Nv z!X>0Fpyi-Ff$sv>6|OBUB@=9wjjgT!S+DV8n*Xd)Z%+Y>k-YJ9UQdJS(%AnD3#$KZ zW?^7rXkcJaU|`??xrlToIeL(+qEI^r87@Y$onq|_OcVqi*dgSHJj@?3noN@@)UJR# z**J97MKM)8ei|AY+TY(_|F=1A{q*ORf3}2iFqmYXeG;p$Rx~Yje)Z9#Nguy_5m{ed zUjF?b`@A`GWaQ=f*_aI`j*y&DG~?&*8LaekI|cMIMIH~a{1!_&z?Oi-dh>m zbTOl(tnAs_$J~W{4SKuw?dv-xBO~LZHu>!G|5{OxKU&P&JTaY_u_4T@{>)#AYw}iC zKL6v9k(Kpb9{jq8!NDoXYQ5Y4)2F>x>x<0SdK9W-@v>NG#{2cFR?RrkeKcwIeB(#Y zYwWiF;9z)DbiCd|#*KN~pIuT8zhsymS!KSxaar(s^g5iQFH4)lqoP=So?B+(f3ocn;0BkpW5V;{%if4KOQ0+4EN*T zWUE``Ca<2u>AoP=r&Ek>UKO4Hf6LkR3mX|OSZd0rb>@Dq zeq;scCE~2oo@Szii#f>Vvk+xn6N`agt1|+W|xxN z|KIQLN^FhLo9=t}bs&QP!;QupH~a7Yys`5Ck>tn1&dS?s%SAXC8*;m2f83aOKc(aT zy?beq+a9K={5EA}Vc7TfOMmPS-s9!-o8(*`VwgMo zpMBhVZYdtNFj2;aD_^Vju3o)5ZT{ykUrHvo?>1y$Vp!X~_v_jI-~Z!-?{hLRHf$4d zy?CN^cK(yfdlexL3=2*g-o3|p+iVvT6GQLYf7`!5VQOG-*tPq=@89Qb`)}R9zx@OU z!;*~8>$4jf4otuDZRsyQMn#4RD{Rh3*VctIDw0v$k(s?<+934YkC%le%(6M(y=X(_ zg>8Fxty|0Fz;4OH0#ikL`AdyLM1f%%g98JD00RRH0|O&j%z@R;z(|qfDc9~mLC`@$ hoXn8__jWo1Gs8q@CF|#@%RtQu22WQ%mvv4FO#n-)*EawF literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_o.png b/interface/resources/meshes/keyboard/key_o.png new file mode 100644 index 0000000000000000000000000000000000000000..883feec8715fc0621edb9d9150f2f51643195407 GIT binary patch literal 3054 zcmeHJYc$l07T+^xXmY$WLSC_SDsJe3$jne)d8O2$bFNojx$;UVGiGp4Xol&Wipulk zNDqw8Tt$OXIF7_5hh#92Ms770w@AtU|s3|mX6uj z@EfKBY6H^!Tk-WP|8-$sah;tuNLT#NXH#?qSu*5{tLy4A2{~;Om+FuS$*a))x7&l) z9N4_28CxhC)=#SDa2Pg}s?&8-A+$T`YMMGaPYPFpJ}rG9;PIlr&1E-zNHWXi4GlFF z);v}>ObsQLc1_2zg(FF=Sn!Ofr;y3kw_F}CE18NMzUkm{%^j`~!EgDnBy|6P=9saA z5q6KUv6hA{@U`T`gh2lL_b3qu7>cYqeNtPzT;O)J<#^GU)#g*s9lpRwt~#DZMjXoE-F(f%FO|y&L;~yl zdzJDHGBC`WlDK-P(3+or1`PNdyn(m0d@w%6=?piUoUf5S<|>c!IC~&}mBJxKpWBHB zW7Wfx+brn#TQ2ydwW-11QZ&(b?wIwSh*;FG=S0Upbl(!TUxU_sXQzy>AsQ%^KFlA= z-#Gh@e#%R(9-NJ{%nxRLo{!Sh(NM2uvvb>n`w5l-`wF>amS>y3CwF2ZXqYmY8?tza zvgvNu*`-aZwV|7eFsH!N?PWb9D{F6uh*?q5(b3@(uNG#a_V%<~Q^BD_hpP3`-$yCm ze4(CcudYzrbaVA-CuM$|);!ijK<2ma2uZ!KVhF<@7AO{)e+&`Y?(VqeWF%#Tu0&Hc zTqG^y(G{J9COuuVP$eo3JjtTM=#PbDtz#;7Mrcy67pj5sGMKfH8O!FKqa(Q&h`$+F z$9N;R{iGUlmAfQO&JjlwkkcZo&3YbP)7`z9I3VGQ!$eX!rpqHsyWT8J*p)$;n5vI~ zz3lpWw;@|LFROL&@$x>rf%y3`hh#-q`CinO@cRg%woc(`O^q;bh|kxJ z4)A%`7&yXDA6->tD{oLEuefc~!hO-agnh3g1WF4kpKcL})Tmv^Uo1)~7 zu?5{$p-@o&mM|wQGKmF z78!y?-l+H=y5Q+J7HW~(eiVT^E6BA=&ugA!R(h*wo^PDUkkbXu|%3rIZgf+WOz8s zzL>6XJ4@>(ce4IR3?L>e1qB7~c)@gzI7z}tyZL73!b=g8$z*ZP@JC0VP5WX1DeIjn zAwP9$OAfnhe<+}vCkA`&;64z*n8bWJDZ8>3;7W)sNoDs|6&Qolw~4yYcf zHGDTW7qxF;RZ0(_c2@-&z0RlkmG%|aX`vM1{|0O$=JUVdpw_cOz8#|E2U+19Dv6bL P4gkQ(&h1ErtzXJtJnXg| literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_open_paren.png b/interface/resources/meshes/keyboard/key_open_paren.png new file mode 100644 index 0000000000000000000000000000000000000000..9dcee0b9a0b0e84bea376265a735a0a07238fb65 GIT binary patch literal 2656 zcmc&!TToM16y5g{@bZq}qgoypwW%$JfVLJzNkptLWzbQBwFrb~w1_+dQo&q65lTl; zoF<&gkMZs*eWs~??_4?oV?`(v-Q*IsAN zF5Mcw+0kyX9RN6nh6HZ|K!G|K6%H=mcm)6!Wo`OAYkO)^R!(e2B5X)VjZ4HsQ)2mv z+Y)0Fa?^iITnAu>h6ZogaiCu}@JqTXVgV8BJ)h%F%oo>kwpyd!^xeo6yFFZ7f`-l) z*D15C3%&8RzH52=Pb~VWtE;v*Ft5Z}pYMDwfpI`K^t3|;1!diTjM)&AlWi`5QF;gf z9Ns8>yKsDV;%5)XW?|io<8SW#zYq3rd_yKOIG`imTXwf5{lTY~f zm0aSxGiJpW5JaV3S2d;7NE%j(fpl|ng zG{3&|f{MQJae+>H{^-%8h7olnhX&jFM-1BWwP`#qw`1s+RY4b@dq87$PR_!BtGg`$ z(@UY^(dcNVZthRjd*yYFL;~NwRT~k zP@yuL^L2j;8z1{m$D>4TaqMytSzr}qiARYs5h?vUTN7>R6-4=vOq>DY0S%em{KHpb zP-@u!Ts(=0^suzhVAU;?LGR3fv`J?Mh#zW%s-`Xr)j2cTw9ALo%A1PCfM3#=v8$@n zCpH5v6^TTB1yv8*sgb$H#u!b%A|=wk1e7`-GBq{zibwZzhVS+sgZaxJzINA6uow`c zd;Qukn@2HYknr)bp+kyt_k)?4@yJ}bCplw|-y)3QJ`$X@4yO7}2x~o1*yY&vvmsyi z`7|4YWeWz)1|u5<*%5>AbhY#=ip9jtt~Gnoz{P%q+`&yG=ODzL)_P&ujK<7!q)aYv zATUS}_`9`soCZ3C=pKkzQmd-!*W;Thm>Ov!i+~fCukIKmN84Fc2{X^?g5*Pl1Guz4 zMj;Kfc}MEN4Q|Jm?}f>Kk;?HeWT#$nYbj!GKrIx+%PK!|HyROq6@Fn^KYy zKZFzUtnx+_HF>j4R_eZIA%+mA!ePiikj-N~)Eg|2indcCtfJ2!XDvG}LT>b-$N%f6X5QN4Ga*10+5 z&wR9o{(Y;T#d~^sGV$&t`;yY;+qaz*FD4JH>k*_l`XNePF5C1Hh!}8b##A65OM4$&p)(7+td%<;J#<${ za7SC&cB4sH7*s^ADX)+4-Uwn4pUrS_R@*Lrt@o!hAf$7M!$W!1U3qTz=!7jeAUu>f zyiwWKR^$a?(7D4n%t^huWS%QroERIM7dT{>Af7e8`ni<6eP zYs*@~ikNtkq)t1W{cb?hH%`;$!Nru`_VnFXBBaeN(M|=n;1clS_|MgQ9 z@X23^5%LQzSJf154y$6~#s?WP6MyODGgA-Pefky}j5er-<#nU2d>RlRG+NVUxxQx& z{}wm^bO0Iv@~g#$7x@w10>W?|jmj{mRomi0nAUpU@=Swk!G?*ae_-4q^N7sM<4 E3thGQkN^Mx literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_p.png b/interface/resources/meshes/keyboard/key_p.png new file mode 100644 index 0000000000000000000000000000000000000000..8d55d351cedabfda1e3a0883cb13f8d2f5f617b0 GIT binary patch literal 2821 zcmchWYf#fy8phv*ATfdjTah4Zf+7{_5~wUviXo!hrYsj55fKQcTx!)2E(VZG{t#SI zK@b)gxvGqWl>$XT!eW{@5CQ@MDjRE>k`O3Rpp>RXAPw1JJJZ?j%&VD#^-Y>s- zpYuG=xfbB(WrQ=w0f3Q@_wf?|Xjnhk^()o7oB8oZ(L7U9c53 ziuh=6eX4$tK_<@KV6M}C$~XGqu3S5;+Le>cj^=iI9~$$owwr#DA=DW|`;^cLg#eHM zuj7~YT zEu`hkgQ{tcVV_E+DkalbwQGcrO!08wnBnj|_r=gsgd?o+1WPgYY1=dYJ=UY(Q=c_Yzd}Idb}i7kXWa3R{Y`$I@|%A_!VnPY4@9?^ zT_GnsS=XZFOz8{>15fv8HQF_?u!qsizmQD@`&GJcS*YGLf z3b&9T;EwZB(;R8H(KNer$pj56<-XQCg&N8iRjJ2J;6P;ZNgF)WHQHA5+(Ge;mF~p_ zryJqY=oT9_&qhH{u{3PIA5?$IrBb8Om@RTRO&{itoGWXT&Wag<-NtE@BS*v!{L@lV zZx3!eTUVeC$0n>kS&OjG8PRofgdq(#WApp8=MR>Cs425$;d1iH+m}iWc=>CRTbC)- zLV;j1Rg{so`+j8>P?erFfh9+OHp`z$EK|vdVOyziC)goU@~X1mXt_&iZ(1z9!YBd^ z;E37;*VKdx+{7p{H5H@5qL6!@x~~@I2I89aoPTJ_UM&h9k^Uk6PC@h$J-9h0Q184+9ZSlK}(csC! z1D`yB}VsBl&o!dG;~}W5sodC zetyq+X^0|Y>6@Vt*Wa7o*x`o})AniH2LKPfoJ6}H8;2K@E7Dhfx-8cR48%W@&A*;! zA6+;@ZwTu6iurOTan1$qg!rU^+}uP9D!MR?F_p!tRYBz#M9n9?;eVcLBp=1J%4`uJSVS< z^PtOybYLjarOgBny*kBT$9X~Hug3CqBq$J?X}xL!i#4>#rmG;jR6l3Q?w*;P6t~ea zfTIn@v0W-xl?E8NezHkArA`?wo9Lip;Aw%CPj1J~mG`=o!_)pzDccd_Y3FZB)XZLv zeWjoLr+g3%{y}t`_+ZF}M2Xic>5p2GW`CR)8yoA%!GN8OPode3{>O(~U(Ecxyx4Wb z4GW>+g7Ewl>Q=WWrv24K{umL3;0XB=ab?N-lxz!wwXfATf$G6SlgVTUU#~4cxOnSy z!^F%uMLviwMUvL4(nT3*JXIP+x1#3Vt)8893Sf@JVce=rLVkClT{ak9R68>!SU&7i zm?LSA9lIG@*lc71{L6ir^CF{o9F_`z2fzTJU?byinPoghhM{>CTE%c4g*VBN@9nCW zG=c_TaVya1&8z{`rd0j|FaH;@|FxdqzV-i&|1T`qc+S7n<3HDg-|B`j1LElY1A)D? Q5dZ)m55MD$?h#pk2i4?4z5oCK literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_percentage.png b/interface/resources/meshes/keyboard/key_percentage.png new file mode 100644 index 0000000000000000000000000000000000000000..fbe1fa959975b50926d92b6a67e70340b36a9493 GIT binary patch literal 4316 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 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_period.png b/interface/resources/meshes/keyboard/key_period.png new file mode 100644 index 0000000000000000000000000000000000000000..ff726df39b64f8e6650da997339242afc8383173 GIT binary patch literal 1591 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V<#PZ!6Kid%2)Zsa|zAkcQv;*>%U(_~p?BQEJVi@yXq zWqyvUKO6sJaz;(cGv{rdXs+?Og! zRqvNQK4!znNKVf9AaCEA&b@R>M}*bNt+#rst&J{oF-U)6VHw!0LP0pef)_@U7|bwj zFq%y54&+5LlC9*V5hU#bWF#?aXm7ah$H>OOsw8pdSzFp}1_lNOPgg&ebxsLQ0A@&y ADF6Tf literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_plus.png b/interface/resources/meshes/keyboard/key_plus.png new file mode 100644 index 0000000000000000000000000000000000000000..3eab84c34d621c9b3431a897f0b740cf8bd3fee0 GIT binary patch literal 1635 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|OhYr;B4q#jQ7Y5ArrENHko$RlBro^9PJ3(6=GWnTfV#~t7z{udhz#zcDK#*Bya3#vO z@wBJr-`m-`hR5b?UVKoVuvL)s^x>-!)4Gg#&ogK7>D~KYdwW~kdY#ioeUA?wRb+X7 zXlsQ)+rOznL0-Jl#pev&n3sgFYar|%u!A3j$h9o2S8OSby=^v2PWH0`&jH_^OiaZ1 z2aks-)~-ys5%}CkFvckKfCFW=Q?6Zrgmj5L9*IuY*tN3|4rBb0;K0DJWBzsqW(JAN X+Ddy0r>bP0l+XkK`dOi4 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_q.png b/interface/resources/meshes/keyboard/key_q.png new file mode 100644 index 0000000000000000000000000000000000000000..fc733fd7fd9918056c61702f3233b4ffae05893b GIT binary patch literal 3012 zcmc(hdr;F?7ROJ1gohC@T13q9h$0VN3n~F4MiLZ-(2^p>6;LXIAgGvt7$1=2Cn^dG z8BwD&25=Z&?G^-z6k|+Wgj7O6i-16RD56z}LfMdnN3vr(&d%=a&g{zU>>qc|y?5q& z=gjAR&zzGP%<#9s5pV!tK@ac?0YKpWGJ79V30uAcfTby0!&1Iv#-$wFn;Z+i`);?^9?=TGuk(s)DuCFJyW-SN~<82q|=Olv>U{&UbUtI z%AgE(pQ0PzUmR90`@zUNYT#_1Xf){U#TX9p(ZtN9hd*x=>SYjM*QQ+fB3Ku@grZFqn!5dZG$>bDe$avwm35X zm7L0(mC|!l#C<}en6n`3?mb*LqMjNac5!%FWzl6pANTg3JjJ$)%(f+qqiy`1ViLY{Qz3{@$gCi^*cT4xY2Xvul{iFJ9Q zB(>p?Pl22nUfI=Ti$U9a4|z$xVtR`@t%YK-IP5?ejtH#4q(NS4>MldKF6_XK7n*dC z@%Rs7a1|<fOyS=r<(YzR%Kb$>sjj=WAZ>WsyQ{r`OMfP9LZZ%zcpm7aTNy z|5fF;dI59g%ZYl?S)e84lJO%%^6# zUzbSlshtqKR5Lv6#I2p)n6SY8%*}9b5u^S~R_=;$!6phNZOqR7G1?ft{dHZA1fP+? zvOrD3zx&g0t3K(8VQR`-sPxg}nzWbRnfCXb2O1|Zt#g;Hmg)w!P2l4A36_5q&Oy*&&+Ig}3O3#-F-vfkLdf zuR=RZJ<4b+HGYi4hcf6ao#^wpdabRGeH^~gFcFfFtG25Obh-KR%uvH?JF42vz zpudrNxLDh&cP9g8Fn4j9zODSNBE=n#fQuzfIVAU1qF`@v}1v123?N!wa`>AVz}InDkTCd?gCn*VsEsy zj9JBx(eWuBZkCgdHkY!=W15Q_-1>FJ`mwyZGW0$N&Hnq^;WZK}4n!x@IG@Yx# zv!rxsC2&BmriVMCpD9rwqGN58NVK8WC=6mP;!4wNU@!q2TzP`o~SuhbE zd5@^?bd5wtMm82~Gs2)=)t3L?-SZq^!PZ%%kqX}7ZhSg z)F;Wd+^G_x!MqAVk9gXc_xJZ#ulBGC!_YoUtm|!zUN}FU|Fd1nkw+?aWB?5YlSkQN zN?V<&+_2~qPVNe+-VO_BM7_!H1^kZ`K9u!;a{DhJ{&(_>k8STCaDRv&Iqc0*4w$*l j8UMR6<#!_FT7%gdu#6#iJr$G{0RYgqGW;qwN2UJ_a}S@( literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_question.png b/interface/resources/meshes/keyboard/key_question.png new file mode 100644 index 0000000000000000000000000000000000000000..57f518721333f4698abdc2e415d2ce44a97bd49e GIT binary patch literal 2727 zcmds3ZBP?t65fO$DV8KkR2nltl`j!=v-%df7H`|_v4-2nRlPvXZL+) zcZ-7pc37Ab%mKiHzSAcJ011l+y(n@Bw0Z#eAj3BzBQzy0Ba@LH3*KL*#KhXtlNj-_ zA+d}vv(xUydI6Zz=|0}OvmTGj)k%+;R-C~hzR53^-HBict53Wa;{XmEKR5&N$h)W$ z@7Y=&vzT1s(Qqp`Y-S84OGwI%qFmT@Tzc2%S9QuhQALYmHaJ+x2UZ-PjOugCUy!5g`rAiPfjPBEH`&QdiJej&c^bCQ7&9bh24ZT<_ zmZ4d-5PlXV6biq&X_6GXNxm?XO2u3yqF;f~NGCZp&9{O+G{EZ82^H~_6?D}C~Ib%zM6_I@@#Hy{%L$# z#JaZr-aSA;`_?mlC6q^_HAzC5n}1$JcRr2UO|$x!{%g@n3KQ?^;^LCL{Eju8x-z$q zYqMF>bUS8nt=Jk)@miOvmF}7umONQ|EmSbCH_Xj>jK_Z;y(%=Y}S$@o$NSOSgaDuy8AE;oGt4H@< z(FyB6gdj%-(P(%-saR_Kv3G3v+7CMb2Tpye_tgyr=}tbyQsdCkM30mQcE@?K*@mv%iBc+W0UA7Rls zCbfC_{ZG@H+$fw`1NVb8&p!oTaXRI(^mYvzx@BT9 zt;O{E2UZ9UL6$6JU~Sv-NuT)wuRmQ>-alo70%oE+afGZN8)2;P{A_TL+vR3~xGPM= z`?e_`c(z(Eev*CHKjC-&p(pFi;;-LWK8~Q;`#DtPOGUk7XAHX)j&;!DNyIRwSFuYa z8J>bojb3Ybz=+ZAsEzUe6Nezq{y356%sN49N+16a5~k~H-tK4c2oS=kfi z4eJWp(=nWpGQ;hdG&Z|>VqFSF5J{?<_XS>8ms^#egMFD{zn8TmJw_g~wi9vRS*G6d zcSDzr#%4SF(SnfGXa-^UE80Raqh*w3=+7l%D~%6b1xh>D<0sB1)b*^-)9ncU`HOl# z_Bev-yLG{_f{v!D6wUJFZDXZCJzUEcDU3zEa|QJVRmSs@Kok(%^h$TkYJ+BG80l`t zQL+0AjLE`}^^IoG6E@@@O<7T3$R%gC+8{W)_ByY$kDjz_OS95PPu?0%TWiI2kTiwY zg|$bu1mllyujxyStB=6~b3miHcuF%BFiH=T&r|#d zaJW^&Q&S&ln}YEjqlW^gfAk~7JtVtsV)`r$$5FD0dYO?yl!J%*$f+UO@S>!AsvkkeL zkTzv7Trz{>vV|0(Y|)$lP`eEZ2+t*4mvE&2NH8w4$f=|=FZgp}+r!oVBX+J-ngMnajF`U4QEK?uJ(X1IIQ}LwY+N0^-qK@9)QPNXwwZkDanhJmeKmj1&4b{$(bCZ!} zstl(6jrr_#UT;9-wPXC%S-b%}9PwJCR~x+g_O*M#JHr3(=Jp%!Q41orsTJgNI1GY` RSrh;O-8aDJ;-~xbe+OGEGsXY_ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_r.png b/interface/resources/meshes/keyboard/key_r.png new file mode 100644 index 0000000000000000000000000000000000000000..6277c640978d83298abdb27c9c8db0ee56a9c428 GIT binary patch literal 1897 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|Ps^r;B4q#jQ7Y59Zz~5IOL0$tl_X2ZCqV(wIK5888|& zeq@=!nZUe7%2!kaEOy1@~{Yl(PWxLp>_e>$>u@*mo%0Sp2r`5 zEZX_!*JPd3U5gAPSs3;?MIFo(wEuAA&$Gs*_e<|SEV%Lhd;Py{I;ZdEJ$H3vc+kyv zn7#QRv;UgcRU0Gz{Jx)WHv4MU*N7Ykh6mAJtM_Nl`usCTgoD9l;4zS zhLuG=0C|@6xkd1{^_^)V`o?x8=g-o z`&(e4leeatg<;KPC2`lYc8S z2=p1-b1=A=%s%_>{tE^shQ^f0ubP0l+XkKpqEGn literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_s.png b/interface/resources/meshes/keyboard/key_s.png new file mode 100644 index 0000000000000000000000000000000000000000..1fc108539141e31c575f9ff41b1eaef9c22bf2e5 GIT binary patch literal 3080 zcmc(hdr;F?7ROIwf)S%UL}{_5FUAS*qzS!$L>rQ{^;DfbIzT6zW3bw zIroo~{q?nEnCxFE_JzSlA5?{RQ8HssuWF@mNct~x2EG-6`rkk??4hNP)d9yca zcEn=amXmGBl@{00oUY~^`nryFcl0f`ixq1MIwrp{XdHUJeV4m=fWQBj@Ig7nTMhaE z7yuNIVNUK}D<{wI^7EJL&ja0mh|ih)9|ilv&heyq$^rGizd-gIWGM>CQY07O2v?aP z2!nB+gDA1Jej~!N{WBaNn@eu&zS7YLXHt`rVvG{2E?OleCuch&^~XXdCw}WxC=#f= zQCBF%$TVCoH`0NxQ;YnzPDBFhEjsv51TBBJWZ+uF{8z@c{Mbt*stz28~W3zMz z8p0fm@#_ja?4`k+&2HLua+SCMg@I0L{V|$jF||a&2Q;)kl`DC&QAvPXj)pBL?}S_=R$0o$C}wI7 zPj)xwX097IBi))P2J75@TQ%pVk4=T>5iiY}*qC6qnV-7L<#JlQ{a&?v8&@ zS63%_u&tA)J^65GU(hPT=Sz_@g;lNQk6L-;^=V~gWs$v!2vWj-qdiU+BsvyHITUGs z@M`hl_Hc0UdSmwjGt#^7G~;L1UPvSI0yeH7RgTTP*87pVDAP_AHa$u`C(n-4iXsxO z7uNJdWpMk36T(@f?@?rp9kNSXJ|vr22rZ=nk~@>2B~W4SN>geEm0q=_rdew z1eCcM9t~ysY^2HG9ItZ&{lRQo`hjy=uPr^eiDES`p`jN_=R8MrQ*2s;o)I7b{}OE2C`3Z&LhtP3{aVPSG?sS10CMyDCV_$_4V5& z8Pg|g{aQ*Tk*mgxO=hX)Xavk<+Na{JmO?ZJI>QOZ_zZ5GdP2Qm;1Zyiya@Mc-^LJm6F43zcHQgiWn70!XKYNcG5*A3+ zdxh~1MNfvtEuFAG`BsdWuaKW{25J07tE9xlOj1{hX(b8=s;0HS00wjqqz}X|8fJXTNojb`kO}0=o`lO@V}52geJhHdw59EaQv2`t1E-0PLxu!Z0i0sIN}UpyrRz8k_v( z#_Uti*4h=+Z`Rmy@nDx7r|o5og&}99d#cl1Dz_iiI)lZGgr37eG_4oXSS%K$tU#=T zA7wKdraxv>R#i1+;UL&xLGrn3x$)g$MY0#JO%^j~*iy5G(Y~6EoT&AC<(l2G6?DI7 zdvBbsr`;}!s1=-O=}0^^Bo4T8BOI?DpHh9;(b-SG<-0+?1`W})>oW}Mu}UAC184bs zz8FbmFwS2kT`Z8T{rlVa2)#uZcopeTl;BuA_JEO>?hF=e7K^n^uFS7$4eCR~?mG&8ra!ta3 literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_semi.png b/interface/resources/meshes/keyboard/key_semi.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb1b495a4b4f79db88a8a913014169269d1153c GIT binary patch literal 1797 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|VP~PZ!6Kid%2)9xS}&AkvVi?aa7{`9%X?gjNIJl~ zl%%{PW?%35Hh%D19=!HzaQlxefqAM542%p83=9Gc3@i-9Gyk1E#&IGca@ymM8GIjh zul%0J#lRWK#6+Sg=r&^4{)5lV&4F#}HGP>-FGDM)*;VhCIsD=OWm5Z4<33-~W)>Fm z0{?*iU;d?2J}OQ>ee{k11ILPMR`(v(*v%GVVt9G(-#Z2YhK`k6zdgKJKQF!6i9uo3 z>#tV#4*ueo^x0b;tT6j;-Tw+3Hfx*t{^blim>V+W?0L&J{A*@#@R9$=v`42~Q;|X7 z!rLTxNve}U7Q^oE- z_3!Hwo>kh(@VU8Y)iE=8oDvcsy%a%7gcNI+V5Haxa&r?n9Z+cdFe+38ZmniuX4t0L Wsxx)s?;Q*b3=E#GelF{r5}E*Qq!^|E literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_slash.png b/interface/resources/meshes/keyboard/key_slash.png new file mode 100644 index 0000000000000000000000000000000000000000..be7b0fecb486566ae909baecac777cdb4e78e496 GIT binary patch literal 2580 zcmeHJT}+c#7=Bx;{S+%7!d6h!4jB?tEr<(>MCvR*O%Vo)0tyvqVa%}#gF!QBD{=B6 zL_lIeP@6dsBLM>_3}JjAVilr9$Y>NTEr{6QPeEKM{af0N-Rur8cC(9<_gtLkoaa36 z^PcwS+QmX08rQ=tVjSNSO;PaKd#wZ4*)Kifzg@!1<9G&2^oCY!xJR(Y3#Iw z6n-Q>fhSCV!1o7O-oj?>Igs=8_4Dqm>S!m4n)k?wxjd45kQ_0fXM!;nK)>`P(ik*z1FPdbiQB5?OgYE{D&k+E%G zdHIVxXF|8%sRRT!9E$p&)vCON8)Pz>>>`m=H7RU=zSVN-2oE2X4Wtwl`U1wjG#X9{u%Vq0zo0B*H0e#4nTtm%RtxtB)hOMsZHg z4%xtZi{3Mcsr_`{&`Eo7SZ#TG01>=ho_<9?wgH8g6fCKRURdlXHFYw zbA?UxylDzS@*;YE;*1NSTU<3MY#oWap(rh)x0+L{Ts^Feg_p=mQ#1X2otOk}J{?F$ zNK}+m)D{!O)G}-bdvfqAT?k@MJy*lEJh4*(ZCgb@Uud*zbVcVs5 z{>@|8#jvo|1W9FP9M#(SB_Ux$+tnQ0tStg2?_T=r+gr76opbl{yohd})zM9F zb%yu)*Iv`hz&nPoQEb|P0v2l3Tpb+5bnhNyAW-P%x@vsKsUzKk44oXo42Kk1s^^w+ zwY?H(ep1mM2stoE9i^KxZWK#?P2tBh%=G$hDgNpxt`ETsH~Xw>#wajW zKH0|TJOQN_)Pbr%X}cx4Ilc0k_eR>=hnqRLeEhMm7zM1jhC3G+#C&$HVTx|b_l2{~ zeS?ApJ0&J+sPVlBwktw$tA>lhS)VgWL)&e7ep4*LF4b_g%b1t@nr_b85t$jM5?&d0 zYu%(GcDV!UP;D)P*!iUbaG#u>_SU||{pZ%2X@I(LaP&TcdDhQZ1xF-+%A%yAfyrjM zI5L^U${miK&(a0k%pH)cX}!LggC8ZaLT&6;-|{yco?l9!?dHoBu0&j) zq@i{YP}%K1xME^YQ_erMqr1-FcTxy8AF@ zqDWv(4K^@7o}_wuNXicn4;Sf|SOySmWLpQ)f!Pq}r#@6o3RQupFYj1ivdU3p-G6oG zZ2OaMucpq-*((8KE3(uaymM|Dk;&Uy!LcR!PMtF$-SLO^IMq`{#+2;p19rYIBsTdQ z3K*vO-vbaRi{bwzkTy1mnd{EMRTK&99N#5Jr|b1aVeB9K+X<3Mcjc7}FVhvz(=BQ1 zJP`11oVDBV2*K=+YN#ZMlU^oL2RRd_m2^`+l)f4l&rO<3<-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|VPkPZ!6Kid%2)9?U&tAky$KptNCw<4lGd3O;Po2F%h2 z*wUE$CkXCw)Y{tawU)*I(Us@#t!JN}d`REAam}<;Nro5aXA3fLfUIs{U{GKni#f2` z6)1B&<=QzY2s&_xlOOW12!qjNngrACkoMlqAwO&F+O5a4?e@(O-MMyJsv+yHO>yhz zvpuM=ndhgzS;^ep;mgt~pT6AwU(KEn#=)?pX!l*E(<}{g7EFuuXP-@*_k8*381Wm7 z4qBh5FF#$jdv2s6!-P{`t9o^c|1~o>m>ra6xc&Cp8UcnAi?@b(b*`DS?Bossh7-ya z|BNk|oEQ{l{{AZ$Y1X%xL4o1IW4;5*4jc?AzrXeWVTfQ5U^t;x@z1z|g@du7#=7Bw zmpQ|W_Wex?`=egh?dR7kb4_4psAQ48C<5FaNMJHrPaEZ@u;)sl4H3 zS2cr5xR<32-$&U4>wORWtSQrT3A@M1WB>18e94FF%pc?}nV19^7+4q>7#SQG7>HrU zJ)Am0#k}vaiSPQFJ3J>(c|T;dddk5;q;6a`QLMd(awCZHK8-xUz(|qplxue&Efru1 z1}s9vq$eb8M5jR{JxELnwX=|$f*v&bGqN!}`CF{Y;b1t2fq{X+)78&qol`;+02ll3 A_W%F@ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_t.png b/interface/resources/meshes/keyboard/key_t.png new file mode 100644 index 0000000000000000000000000000000000000000..c2082b8f51223e7033afe73b82a3cca584bdd803 GIT binary patch literal 2038 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|WaEPZ!6Kid%2)ehj>oAaUT~rDBFV3ONVZS~!nz_6QgF z2^e)6G+HnwHs>VFIFK`8S;gDRZJ`Taxlelj^R#tK!Fsd(YVj(~yVmVzykLL7m%)L7 zL4bjQg@J*QEat#!XJDkr@sw+KpdjcVAx?J4!y}Bmh=XgxrTsz4TuDaRV3DI|&z`;L ze?P}8t%JcqJ2Z6a^PQFlX3t?_q9oZg>@`+iP5yz-AS7F z)h;vETd5l(QXP+(xd$&5exmmy=mMPGbn$@bg2 z(@!ssxADEqUt?Tw>JERyx|5%4tmex7E%~qXf`54fr9n?r%rz9OT4lt-!tiT_2A8t~ z!-D=(n@m95jIB|UOiT>5Q{Kz|n)0o*NRVt0+sNjS9vW&Y z)w}N>UpzlQ|H5_7tnLgSE*Dtb$uZ+&a$tC|_+v#)b#*r1hj2f}i?y%6{`&Rn*XHxA zu7M`=&ren()fB$XSQ~mSuz2Ek|wU2!9 z@^!#`Lv|*H1MWqV8U&ITq4YsQTzdnavVoA_AqsF7EqJsS fOps^dVc5Uw-;Y%q?4KAI7#KWV{an^LB{Ts5EkS@@ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_u.png b/interface/resources/meshes/keyboard/key_u.png new file mode 100644 index 0000000000000000000000000000000000000000..657527f6c0068fc41a5ecbdcdff232defe858b02 GIT binary patch literal 2232 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|Q5tr;B4q#jQ7YFBabNU}#7zp3D@|WW^BP@PeVdLHj_6 zLJZ?FrgNeUq6%6YtPE5yG;U(npLx1fF@ih$_iwpBA|{)r+^pX^$#r?=ENg~+^Xp}p z7#JBG7#IW?7+4s{Vh*f!7Rnq?v33SV3W5$C;^c=sEW%(knI=)FT>x*g@i@lc-moMj zwDeaTP56&enYJ zXeZZiv2PbN)nunNQ0hd=eE6g5+wZ;23`_^gL#}UaW?*7?d;4u!Xs9J02ZKub?z?Vx z|2=-ZxVeYHVUh8d3hDKHGAS$!Lc9)3ejiBOyzD;*gNp7Ym0F{jE_?SdFgEPAIdA{z z)aLx zMowE>!pG9EUd`vg?W(oW5`WV-M`|2=$+ISG_4Vu5KYY&GpS=FMbpLThHa~OoFNYNs zOkD%Fh&DX_+i)^PXxq)VWrxqlKmUEOZ1>)i+;WHXU(_+IV|cLtP{$uxxqUjPi>&5a z-Muqm_JJi8tOfrL{pBscG}DP;LG9s(1|LuUKK?(Bk%i&k@d_Ki%6IuM*%%uls`kds z%GRlzr$60WorAHV9Fo+j}&r zaP@4)%{O&={?$jl_52AE-PiH7sPgH{GLLh=e^>ALXYn^bhDCuv;QGg(r!OBajPCRQ z)!zBvGCzkeTs@BsWN)FP+P&Fd4hP7weKGiGUmy4WW&jJrx+NN2+qZAuq!V4kWt@{` zBDGgGrr_|;8x5~x<~r=&!#B%6bN~JIVXMDZ)qdQ*qeZ*Z;PU(LzjN0Mp-}q>EzN&u+p|hAEW8%^h0lIP2?GNI NgQu&X%Q~loCIIFy+yVdq literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_under.png b/interface/resources/meshes/keyboard/key_under.png new file mode 100644 index 0000000000000000000000000000000000000000..3694dd31090a560a802191acaf243c676263769a GIT binary patch literal 1579 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|V<>PZ!6Kid%2)9^`FM5O8%g72C2lkW)91Np|hD2Z{Vb zX<7>Rk4!Ikc;*aq!`~P+1_1^J76t}J1_uVRm;$Fc?jy zNfc^lU>x8iAiyBZzyZoB4Gatl3}i9M(f;9XE|b`$sM*E;{!9?OSp9vY0K5ZvO*tS%9LEcio5p7Y4%FAXkBh+d_C?fqvQybAv^rq0v zIkniDUMSM^Vk%OpMqAP}Ct)VD8k#0zMq_?^aQwS}u;=VQ`^P=cJ@?$t^W6J=o^zj@ z?eDkB#E4=9046@Gy*B_LFfLdK{hKlCiafps0Rqqf$N>5yPj74Igw&*R-{@#obV-4bDD`5pRzO4>vV6z1%mT$V-IheS*4sFH~dGM$Z` z=kKZsoWKW*WBKXl1Krv~4J%(;igyQ6adUXHwQ}?1@M4K>f>gtq28PhJQGJkQ>dcNg z;^yw|?%~m^vw^Fkqop*Jw!RP3hvh-)FmX^RO{^_V_(=~DHpkMmZ-c0$Q%!YJDjC%L zd=p32@eVJ=SNCtnjJb&fL^!n~WWI0Q=_)7UlMb{gqE>4_U{QQOoq4!)iR7=t7f~;1 zp^)^hr>AG{a!y2x+OhwJq&x8@DZE%f4jmt(Jo!BZxx3n=5aswZ`<#9cD<$?ijadBnuA@*cA;QKsVE0Yh(llAghr-LI>QrqwyKkbW>=K=wDqcmxjE+K zb5pq!Z^|*RP$C?iV82W69g0JN4R1 zGVs#b8Q&QkI*l*-F%EGwzb57jc+DA2v6X_GH}~`}WGknnYW0Gkv9P3sc28SBjM+)p z5%!dp9o8lts1?ov8n->k=6#q-u@7$;-mvi2#BYkpXaQb5U<-{k%-Q>P)!}YLqbgj2 zvw?=UC)r?rnIBsqVZasvuK)+)z%IL1DH@TfBrLL1x#bl;i@6gX5f;{zcA^^(Aqf3z z<=#QTxS)qxirT=U`Qpo3g+d|dp-OPerM5GFn6rF##p?|ZIyoT(VQcbLm-l@bALv_g_Xf{R zDQxQ`Gic3mK#(!M+N0R09b*qC!~Z{k@%(=NjTSz}6sq5#2nqPP7Zfg+cQgY4_^kBv JzPy6I|8IdU_TK;i literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_w.png b/interface/resources/meshes/keyboard/key_w.png new file mode 100644 index 0000000000000000000000000000000000000000..15de9b25a8cfe59c3350c498126de9eafdf30158 GIT binary patch literal 3466 zcmc(hSyYqP8iscwQ)O-|qEbVl1=BJWgh0RqtcuhE4$z1L1PR8&V60F;f&39HLP4-1 zpalY_)`AJsA`BvsKv7W!LyZW5LKIAx!jQy<=}-Q%I9;pfT%8NJI2U{E@8BScaf%GQ}THFt-}qa$k7dyEbR27_V% zU=D!Jnx^%X>tX)usSV-)y&>@%#~B;2?k^kG?fl;^u5WOEV;jVE{^ty%uJe*LV#D#p z1R_y8WJ$DDTRpX>^(j}WnBc47gK%2*sgMvVbTx!WpPfzT2rH(IyuG~xMTlJPxO)0I zpFhsw@%A`E&d4#ZhY!;}($f+S5C{aVRL1M{yT6yi0f`U<#l^kquz!(Nl%12q$(fs* ztHPEOiRf2g^6QwfO#Z&c#zvKytOQbHkxm|6#cDO>OKNLFcw(vn+OvQQAauhMl@t;va?({;fBX| zNo1Yl-?qSsmo80J8=%M&+Ni)>r|9hC38h0N*1?ajdlP$&BL~Vuf80;TD`D8fRIhf` zi^>VQa6tE)!vi~aEiq+s8vL!VRqmS$ll}&lpkaT^nb}KN*V~Cf?ybk%kq`LEdDT8G zUnISm*GG|?A8|ZeE5EPWb9mPO1tO=xA9oaC&0WUczNO%cwL+E_7H;erz?3P5{SH$d2>kryvVGd{FOl*$Dso3PF4`gnKuZo7$b_dnQYX`{uOtP@{n zZw(v2uJ|bu>Fo4|PMD%Ukkh4)^wCT@tnenk4H4k?48)v2K7(2B^SGS1{2 z_v~SJWUc2iMuTzs1!DEAzVL02e`laVE}P#%ONv%tG#DcSL{%E-T~w0Ie%>v6YDHfz z=!WoAh$KS%c?_# zVo_9Zx0rYUNNc1|Iwc z1~_s}!JxLVJ|<}#F4J#u{dDvPH+n_8n22s8r1P1>ipeOxKk_7}@CvB;uGZ;d(jrAr zDAn)i=AP^yxfdf-RrJ1zT64;JY(|)1Qr>az_0q}5#Fk}@OP7U1a`S@+I(F9@L$R*G z>fW7K&p)vY8Tm$fmy>)YM>98qG&+v)7ET%^p$= z_u3#9z*Ga~&(21>RxE_I@X+U6uwz;^Ypec z8h41|7_@4BvR@Y)3E4ZK$(Tp{=6pRgzDY%FuH!RF=^|&t13>zVEFtOg*>S9Q*-Xtk< z@r_={zqO4RK1}$OX^U{5`79q@fb)3m>@I>3)oO_9t4<7Kpl#iZc_)$l z3TyS%7mSLXj*QD5xW(4weBTXaf!CD@k);{RasrGD1)JrS4Wv!CoOuKq_VsF}cDGG~iD=&Ru% z99r|H9C#%|v7H zw78^%Iw3NcALCopO%3c|0J2eyj6E;)n3M8ftqgm&8R(-^*z*RB!RNUB=XSG3N&~>) ziH1}y*TcUufCd04;0b^cSXa4Gyv_Un94t4I$^VLU8w{2k#LfQ`FxS~>ow;;I^?4h} XH&uO{;pSNe002Jw{q|MuC8zugEve^% literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_x.png b/interface/resources/meshes/keyboard/key_x.png new file mode 100644 index 0000000000000000000000000000000000000000..d81a423f3b3651de72b207bed25a6dc7d38976f4 GIT binary patch literal 2997 zcmdT`Yg7{07Cs;)7AAVrn~VZ!jP_8nOv_71Dm6)inW?F$J;2ZmwU9JX8EbrGjWylG zu?&0pno+aI#Yd%HDst3B$-1P4T8f5>PZ%X+maa8x&97Pa=CAwXti9LT>)ZSL_Bm(m zowb*`%Xk@T834d|_cvbq0Dyw|1D`iZD9>^LfF+xJfbAC(!9I291Ox1##~fy0cOO4= zl(CO-h@KeN#2^5$JaD(yj{OO5hu@9I^*u*7#3@?W^UOZ3*kdU9ZV;^7S_1qaY+&8s z&vWCgD1^Dia=4o{j)^~*sC?jATw*HS@uX?MSWAkGfzyqv)oNHneOv0IB>)O|0AK-t z&y)J!-7mrXsnjCj@GoHer|OG_1Lz`Jw?O2}JO77{KkOG~S|l9%m(NCm(ob7R(Ek49 z6&S=)syU2Dot@KZqaOZBvqxP%r)x7i5j}#;}F|hKUFs{$~7d_{qjaRd_-DE_-PL-IelmsJ!j5+ z&hWw<9B_O3`x!N~2uzrNr&6gLp~gH6CI{$?rIA6oDa{Yg;;?U-Tdj6Jmx+2WdhF8% zS?7q^hh-SObnS>H38Oa!zwJTM1peuv5vS=4shDxKY<%AU7a8Lf+MkEyMbhE7A3j-DLHMEYqNkqZq z3jDdg*sZVb4WPTPMak_(=th}p?=u6Ox>h3L%8{xAPkoNKTqRqi4r{#tR@~g&%q6xF zxetR!ICU?K_WPUHY?VFndD~)!v_{IzQ9<7sRqT*yvMhlYIl$M3Z-}?Z9&Xn+^_f6H z9NE{)(d7MO?I+*HXM!0nOK#_I)F|`istY@1%V)zM`_N*?#ZP=%Kp|B*?WUy)!b9QS z`Z)C<=FvH07>Gh2s0{CD3fWYK+b>x|A+cMcU7Ex4>myd%Cwz5tJYo2QI}`}bY8*8} zO;$Eny7$9Fu>?>^Y#(}3ze1DM7435AnP7x-Y-$(iNl#m#W@3j_Om3UF9j+w~Sy4!A ze|q_V=os7f%~xIX8l3H_kyAqLQ}Xp`rra(9NFF#Fd^5=#7Nrt5_HcXq`<+P_}jygXOjhR(Czmz z@d6%?U%Og%D!F95^sNpL3wie zWF*s$LLy{3Te<$rGPQ<=(m5IMI9W6__s+cy!me^53c}+U5u23xiRsA#0)j#!{4+#; zrSar@Q^2fjJcz6b9JDKvmL>}bW)u>k=!NGIB7H%e@ig)3GabHx?`d$`34vVRmcP&2)NI!R`nE%J5V#$a3SbIyv{gCr>KB^oO z3Vzi3IRd;bDJ$!ZO^{-$-m#kRO$FEji4DKGe`aRpwaF6Y^yy-0Q^j614~IpI>6;>Z zE?pejb$+L<$^{CJR|S`|bQ|sp%F0eBCr@9O#^>@9VB!x|a+T|6Pm_!@p*Qj%4ckK< z`E|6?f1oa)4#YtrywsI4%{Fw-eau9^g(>>@w;aeDzZ(kf@zDzx%VsZEM$ z9)xr&P0%Q92wh3ehwLeq*0s1dAi$HV#{>;H-DKv;`;3Y(M~(<3vei zTVT>_WxN_bYGDco-2t&xj2fB+dE6)U>fE|5SnacSHfgLXm>1tFN8B5mm#jM;l>3G& zyBe|Tk|C*<41EmUywq zlo2%RZv)1bG>+sw4pYT$<(`lQ3aJKBM~yPo`K=K$(Wq40k?J5~p&D)ZhH_=r6)a$P z4uqKE|1ACD@zw0Qui7YuDe#RcgC%mK%7Pb zwBJ~+No#cVdDXJ`Y2WCWJr4K^C^^t%P6wpyaJy8B~?w_E3n zEz1}|`7kU1-5EG6fB+HzNC05qi`>P+$1D~*L>D=O7YYCC5&qw-75}GOT!TutwgJo@ W?s*+|hRFf|fZb%OSB1yHwBG^ucD9EA literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_y.png b/interface/resources/meshes/keyboard/key_y.png new file mode 100644 index 0000000000000000000000000000000000000000..cb85af5b322234afe8e41e3d2c24f2ff8aafc1a4 GIT binary patch literal 3072 zcmc(hYfzJC7RS%~a*LpbtO!D_NtG%Q0YzL{MG{)kQWWG;i9`YXxi%TfTaW_bp&0+{<)hr_548ox=?Gk%0b0jfw=ZhIZ8Hnm`rlI|Ga-u+V$24`{EF>Rx7*(bO3Vz zLjdHX!ao)VWJbl$9H-Afy5AK4Xyt!z*vB>QcWj2Z;r|=~8#R%MJSgv;Mb00sdM@LZ z(H0o;ml`Y8k}OqI`sG9p#|;y1STH+ZC^Rt1l&E`pb_v&y_8+S3O04n!_lP?{5t;E2}l8S7vOwa;_<&vg-cyU2a znPw_irjg)66jAQJ%Gu%ATG4KN_r_{whf71SY*{*pM|}mJe#eQ#kgK}Vu{FkWo*_&; zxlcW%)u;btZmP;*-pgF{Ub|((r$Q&h>6I4-}0 zKG2MJ?iu`m5WcRp*sZd#tH~dmv@(Qe{+sOPkC<8fEp3>#f9Vp+PW8)|%j+c(pC48p z#NQ70n!C15o7lrw&Nkm?PCsZkW3>wR0LnMg3sq-Aj^A^H+qenB52R-K&tmrCf==U@ zMfg#mukpV|m%ni77tWROI0pz2NMX>b?^4_3@&KjXJG^c02zr@O6Vk_wjt-YNTBZll zq!mM~b*YQ88C^#jCQgSL!IMORcVoFWOM)+kZH4Dre^ljq>=`G&!`osu(W`tXO?Fs4 zqHYUacG*oPXe}g~*+a3vv|XAK^;yl}-^K2%5!?d`ELBq;1_6|DvQIY2ywsx3L32;1 z(|Lr*>J%eC&=&XH@Uu)mku}R6ii2|)s-%N+yB~cvF)us>Cqc2_q-|20g7L$q!S*hc_c_Q$@WbN zA19LyQIOPZ&^B{9Q#hA2fRPQsheH}0u&}#{>`t6*ghI(35h2&7D9O=`+hF*LM1e8) zJH3r~($B>EydnG;h?BnCs=}+)yFv|aM>&JKN$JiK$;Zp|F*- zeu*gWy$;S-h8y0UWjdrzDDRO*tz58>by7>*4s9#Sy&F@CXg^P<&j%OpN&m4Q-1Ng9 znKM?XJeD|ZhQe0X@!YtBxL1!g5_?fvF_ci3;@vp48gDd(i^U_FxD&T70mAQoawI)d zOI#)H>pb}(_+n^dtx=Th$BqGA{v?*#saG;GRa!)U zh!LRbOJ>m>t15go3Z;H3YR`wqvz-G4OlQ3STPs#v{}GwsZGr;E@(bwOBMok{h6@C! z^76BIp@|moNZ=~>Ys9XfK)X=BS4k^j+}9=TmSssm?sou@2lML%>F!1fwr%S zL}FS5FN@~^1c>ZoO>G}=S8Z+yayz#%R! z?(C%Lhq7cHWg+v{;a7bZLf!?z*+Os!ykKC~&wYe=$+@v`0Ip zYHH=xV`=TJImELqwy&Cb`kjfqjAJ+Ldv^>6P7=SFv}2n?v1fk~magaz$>OQ{Y6-KM zA2#5jU%5J&4$7en$sQ((LWE{wqNZbzO*RKtCw4wh`}*D#AH?jIiXjYpS{Y&vuItsW zI>$^9e&x7YLKIDZ0pc#QA>`>)tS-}|@hYFQJTL7CCV=8Dvr zz7O+A<`H|Rl6fvR7bcHEo3p3i^t3ka&$-zQ_VaHsu85bM9HlfUW82yek55m#31zod zC9<9Gi}H0-uQ}&ev|>jNoWG>=0GP~87Xu1>015y>@BjY)@MD<&_zG1z{J~~vCO-2w qn=(VQG(+6{-=^umGZkFGEJC6Cjbp}9M~DRg@bdK8c;k!EqrU>>4D2BQ literal 0 HcmV?d00001 diff --git a/interface/resources/meshes/keyboard/key_z.png b/interface/resources/meshes/keyboard/key_z.png new file mode 100644 index 0000000000000000000000000000000000000000..462531351db199b49c68afdcebcd304daa202f80 GIT binary patch literal 2115 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4iHr><-C@GfkCpwHKHUqKdq!Zu_%?nF(p4K zRlzeiF+DXXH8G{K@MNkD0|Wb0PZ!6Kid%2)2Ik&&kU8+MM2o@ML-Gbs7Mo%7EXUpj znoVtvXBMOd7@u%7U@<+49oBH=goWM_rG_e zywG!@8EgFizeophSQsu{Eu9OZH*LQE{`bEA=-s#9+S%E?QCwNf?$Fosw}D~8VgaYn zP>^v2{(G2|^e>xSEtTC{Q~9m@momeId6n~KFEjkc%;Zp3bBmvY!SdCvJc+g)TaVT4 z@YZWQ*|bq!fT1Ym4g0!JTphpd|Mr3moLf2X_BK@@6)6)MJ$~?X%^;?l$uW_gU zu{sBa6Bj={kgxri{ieO9g7wH=PDK-6;k|cm@G~)Z8iDjKd$r39Y)0PCHRd2+E_qd8 z;ZlDzIdaFEt)RHpmIC0WM(Hb;0R>yawm^zQ~d-x#qM zjf@*uAt|-seT{_7>#cY5?%%uj?!opC%Twmo$e1`R+rz{(umz(4Lp07Dg*~5Ph~mnS z7z)vIEJOi?+8cH#K6E{Tf|$zz`!6`;u=vBoS#-wo>-L1 z;Fyx1l&avFo0y&&l$w}QS$HzlhJk@)pQnpsNX4x;cQ*DeH4t&Q_+NbyOS-@%#?%0& zt$|Fb7Z?>&^bShCzUm)-D}n99y7Ma!9Ma#hXq>!o+MoxQ`rV88VJ??j1R>nUT zkl}%qVoT;MF3*ihRz59^45G_!gmcbMjuaDQW4NOn?w;2#3%_SV@XfcF4&_PBa$s)N1&2ea4+>XQH^Z`sSFsBm?SD4dD zaO->q^dLkFQ8X?*fXn7?%e~9U5Rl$(b0<-e#hHQO%1uT7@`%eOwfAQ;GdxJ!=}^=j zayRj&0goFa!-@?K^WJY*_u8hDk)iz5+lkyR?SIYs7P}fUJdoRIRa8BtVAA)>d8%`i zmzta}?zvdNz%bqIhKX-~e%sWmiStFGr={LDQ)Xg_S@`~Up$vEDhD}y)lJ9>njlC+t z@L@Bn8ILokb)swe4R()y}pNo?{(JaZG%Q-e|yQz{EjrrIztFdX-EaSW-r_2!}>BLf4+VS@wvpDQx=g*aXI^eXRV zWnkc7U|?iWU|?VYnJ>V=z{KD{nIN^yK{!J`ijj>WA&vQO32T210|Nttr>mdKI;Vst E0L)M=YXATM literal 0 HcmV?d00001 diff --git a/interface/resources/qml/controlsUit/Keyboard.qml b/interface/resources/qml/controlsUit/Keyboard.qml index 9d4fd33022..c38631ff79 100644 --- a/interface/resources/qml/controlsUit/Keyboard.qml +++ b/interface/resources/qml/controlsUit/Keyboard.qml @@ -36,13 +36,29 @@ Rectangle { readonly property int raisedHeight: keyboardHeight + (showMirrorText ? keyboardRowHeight : 0) - height: enabled && raised ? raisedHeight : 0 - visible: enabled && raised + height: 0 + visible: false property bool shiftMode: false property bool numericShiftMode: false + + onPasswordChanged: { + var use3DKeyboard = (typeof MenuInterface === "undefined") ? false : MenuInterface.isOptionChecked("Use 3D Keyboard"); + if (use3DKeyboard) { + KeyboardScriptingInterface.password = password; + } + } + onRaisedChanged: { + var use3DKeyboard = (typeof MenuInterface === "undefined") ? false : MenuInterface.isOptionChecked("Use 3D Keyboard"); + if (!use3DKeyboard) { + keyboardBase.height = raised ? raisedHeight : 0; + keyboardBase.visible = raised; + } else { + KeyboardScriptingInterface.raised = raised; + KeyboardScriptingInterface.password = raised ? password : false; + } mirroredText = ""; } diff --git a/interface/resources/qml/dialogs/TabletLoginDialog.qml b/interface/resources/qml/dialogs/TabletLoginDialog.qml index 6314921286..08aa903ae9 100644 --- a/interface/resources/qml/dialogs/TabletLoginDialog.qml +++ b/interface/resources/qml/dialogs/TabletLoginDialog.qml @@ -127,6 +127,10 @@ TabletModalWindow { } } + Component.onDestruction: { + loginKeyboard.raised = false; + } + Keyboard { id: loginKeyboard raised: root.keyboardEnabled && root.keyboardRaised diff --git a/interface/resources/qml/hifi/AvatarApp.qml b/interface/resources/qml/hifi/AvatarApp.qml index 39590748cf..9635681c34 100644 --- a/interface/resources/qml/hifi/AvatarApp.qml +++ b/interface/resources/qml/hifi/AvatarApp.qml @@ -19,7 +19,7 @@ Rectangle { HifiControls.Keyboard { id: keyboard z: 1000 - raised: parent.keyboardEnabled && parent.keyboardRaised + raised: parent.keyboardEnabled && parent.keyboardRaised && HMD.active numeric: parent.punctuationMode anchors { left: parent.left @@ -204,7 +204,8 @@ Rectangle { property bool isInManageState: false - Component.onCompleted: { + Component.onDestruction: { + keyboard.raised = false; } AvatarAppStyle { @@ -235,6 +236,8 @@ Rectangle { avatarIconVisible: mainPageVisible settingsButtonVisible: mainPageVisible onSettingsClicked: { + displayNameInput.focus = false; + root.keyboardRaised = false; settings.open(currentAvatarSettings, currentAvatar.avatarScale); } } @@ -344,6 +347,10 @@ Rectangle { emitSendToScript({'method' : 'changeDisplayName', 'displayName' : text}) focus = false; } + + onFocusChanged: { + root.keyboardRaised = focus; + } } ShadowImage { diff --git a/interface/resources/qml/hifi/avatarapp/Settings.qml b/interface/resources/qml/hifi/avatarapp/Settings.qml index bad1394133..cd892c17b1 100644 --- a/interface/resources/qml/hifi/avatarapp/Settings.qml +++ b/interface/resources/qml/hifi/avatarapp/Settings.qml @@ -14,6 +14,22 @@ Rectangle { signal scaleChanged(real scale); + property bool keyboardEnabled: true + property bool keyboardRaised: false + property bool punctuationMode: false + + HifiControlsUit.Keyboard { + id: keyboard + z: 1000 + raised: parent.keyboardEnabled && parent.keyboardRaised + numeric: parent.punctuationMode + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + } + property alias onSaveClicked: dialogButtons.onYesClicked property alias onCancelClicked: dialogButtons.onNoClicked @@ -314,6 +330,10 @@ Rectangle { anchors.left: parent.left anchors.right: parent.right placeholderText: 'user\\file\\dir' + + onFocusChanged: { + keyboardRaised = (avatarAnimationUrlInputText.focus || avatarCollisionSoundUrlInputText.focus); + } } } @@ -340,6 +360,10 @@ Rectangle { anchors.left: parent.left anchors.right: parent.right placeholderText: 'https://hifi-public.s3.amazonaws.com/sounds/Collisions-' + + onFocusChanged: { + keyboardRaised = (avatarAnimationUrlInputText.focus || avatarCollisionSoundUrlInputText.focus); + } } } diff --git a/interface/resources/qml/hifi/commerce/wallet/Wallet.qml b/interface/resources/qml/hifi/commerce/wallet/Wallet.qml index ed4ba66b2b..874c1c53b7 100644 --- a/interface/resources/qml/hifi/commerce/wallet/Wallet.qml +++ b/interface/resources/qml/hifi/commerce/wallet/Wallet.qml @@ -40,6 +40,10 @@ Rectangle { source: "images/wallet-bg.jpg"; } + Component.onDestruction: { + keyboard.raised = false; + } + Connections { target: Commerce; diff --git a/interface/resources/qml/hifi/dialogs/TabletRunningScripts.qml b/interface/resources/qml/hifi/dialogs/TabletRunningScripts.qml index 6cd220307d..b0f17ff841 100644 --- a/interface/resources/qml/hifi/dialogs/TabletRunningScripts.qml +++ b/interface/resources/qml/hifi/dialogs/TabletRunningScripts.qml @@ -88,6 +88,10 @@ Rectangle { checkMenu.restart(); } + Component.onDestruction: { + keyboard.raised = false; + } + function updateRunningScripts() { function simplify(path) { // trim URI querystring/fragment diff --git a/interface/resources/qml/hifi/tablet/Edit.qml b/interface/resources/qml/hifi/tablet/Edit.qml index 4acced86ce..099c53cda2 100644 --- a/interface/resources/qml/hifi/tablet/Edit.qml +++ b/interface/resources/qml/hifi/tablet/Edit.qml @@ -49,5 +49,11 @@ StackView { if (currentItem && currentItem.fromScript) currentItem.fromScript(message); } + + Component.onDestruction: { + if (KeyboardScriptingInterface.raised) { + KeyboardScriptingInterface.raised = false; + } + } } diff --git a/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml b/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml index 0f26ba20aa..b8972378ad 100644 --- a/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml +++ b/interface/resources/qml/hifi/tablet/TabletAddressDialog.qml @@ -56,10 +56,13 @@ StackView { Qt.callLater(function() { addressBarDialog.keyboardEnabled = HMD.active; addressLine.forceActiveFocus(); + addressBarDialog.raised = true; }) } + Component.onDestruction: { root.parentChanged.disconnect(center); + keyboard.raised = false; } function center() { @@ -218,6 +221,11 @@ StackView { leftMargin: 8; verticalCenter: addressLineContainer.verticalCenter; } + + onFocusChanged: { + addressBarDialog.raised = focus; + } + onTextChanged: { updateLocationText(text.length > 0); } diff --git a/interface/resources/qml/hifi/tablet/tabletWindows/TabletPreferencesDialog.qml b/interface/resources/qml/hifi/tablet/tabletWindows/TabletPreferencesDialog.qml index 57ca705352..a5d7b23df6 100644 --- a/interface/resources/qml/hifi/tablet/tabletWindows/TabletPreferencesDialog.qml +++ b/interface/resources/qml/hifi/tablet/tabletWindows/TabletPreferencesDialog.qml @@ -242,6 +242,10 @@ Item { keyboardEnabled = HMD.active; } + Component.onDestruction: { + keyboard.raised = false; + } + onKeyboardRaisedChanged: { if (keyboardEnabled && keyboardRaised) { var delta = mouseArea.mouseY - (dialog.height - footer.height - keyboard.raisedHeight -hifi.dimensions.controlLineHeight); diff --git a/interface/resources/sounds/keyboard_key.mp3 b/interface/resources/sounds/keyboard_key.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e2cec81032389f719f90dd60b0ae4ca10719e725 GIT binary patch literal 9089 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 literal 0 HcmV?d00001 diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index bd126048dd..ccefb3c772 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -187,6 +187,7 @@ #include "scripting/SelectionScriptingInterface.h" #include "scripting/WalletScriptingInterface.h" #include "scripting/TTSScriptingInterface.h" +#include "scripting/KeyboardScriptingInterface.h" #if defined(Q_OS_MAC) || defined(Q_OS_WIN) #include "SpeechRecognizer.h" #endif @@ -205,6 +206,7 @@ #include "ui/UpdateDialog.h" #include "ui/overlays/Overlays.h" #include "ui/DomainConnectionModel.h" +#include "ui/Keyboard.h" #include "Util.h" #include "InterfaceParentFinder.h" #include "ui/OctreeStatsProvider.h" @@ -953,6 +955,8 @@ bool setupEssentials(int& argc, char** argv, bool runningMarkerExisted) { DependencyManager::set(); DependencyManager::set(); + DependencyManager::set(); + DependencyManager::set(); return previousSessionCrashed; } @@ -2327,6 +2331,7 @@ Application::Application(int& argc, char** argv, QElapsedTimer& startupTimer, bo // Preload Tablet sounds DependencyManager::get()->preloadSounds(); + DependencyManager::get()->createKeyboard(); _pendingIdleEvent = false; _pendingRenderEvent = false; @@ -2436,11 +2441,17 @@ QString Application::getUserAgent() { } void Application::toggleTabletUI(bool shouldOpen) const { - auto tabletScriptingInterface = DependencyManager::get(); auto hmd = DependencyManager::get(); if (!(shouldOpen && hmd->getShouldShowTablet())) { auto HMD = DependencyManager::get(); HMD->toggleShouldShowTablet(); + + if (!HMD->getShouldShowTablet()) { + DependencyManager::get()->setRaised(false); + _window->activateWindow(); + auto tablet = DependencyManager::get()->getTablet(SYSTEM_TABLET); + tablet->unfocus(); + } } } @@ -2633,6 +2644,7 @@ void Application::cleanupBeforeQuit() { // it accesses the PickManager to delete its associated Pick DependencyManager::destroy(); DependencyManager::destroy(); + DependencyManager::destroy(); qCDebug(interfaceapp) << "Application::cleanupBeforeQuit() complete"; } @@ -3113,6 +3125,7 @@ void Application::onDesktopRootContextCreated(QQmlContext* surfaceContext) { surfaceContext->setContextProperty("Vec3", new Vec3()); surfaceContext->setContextProperty("Uuid", new ScriptUUID()); surfaceContext->setContextProperty("Assets", DependencyManager::get().data()); + surfaceContext->setContextProperty("Keyboard", DependencyManager::get().data()); surfaceContext->setContextProperty("AvatarList", DependencyManager::get().data()); surfaceContext->setContextProperty("Users", DependencyManager::get().data()); @@ -6848,6 +6861,8 @@ void Application::registerScriptEngineWithApplicationServices(ScriptEnginePointe scriptEngine->registerGlobalObject("LODManager", DependencyManager::get().data()); + scriptEngine->registerGlobalObject("Keyboard", DependencyManager::get().data()); + scriptEngine->registerGlobalObject("Paths", DependencyManager::get().data()); scriptEngine->registerGlobalObject("HMD", DependencyManager::get().data()); diff --git a/interface/src/Menu.cpp b/interface/src/Menu.cpp index 16e8af5683..2ca997a1fc 100644 --- a/interface/src/Menu.cpp +++ b/interface/src/Menu.cpp @@ -356,6 +356,8 @@ Menu::Menu() { qApp->setHmdTabletBecomesToolbarSetting(action->isChecked()); }); + addCheckableActionToQMenuAndActionHash(uiOptionsMenu, MenuOption::Use3DKeyboard, 0, false); + // Developer > Render >>> MenuWrapper* renderOptionsMenu = developerMenu->addMenu("Render"); diff --git a/interface/src/Menu.h b/interface/src/Menu.h index 1e9955a760..f1d56825b5 100644 --- a/interface/src/Menu.h +++ b/interface/src/Menu.h @@ -210,6 +210,7 @@ namespace MenuOption { const QString TurnWithHead = "Turn using Head"; const QString UseAudioForMouth = "Use Audio for Mouth"; const QString UseCamera = "Use Camera"; + const QString Use3DKeyboard = "Use 3D Keyboard"; const QString VelocityFilter = "Velocity Filter"; const QString VisibleToEveryone = "Everyone"; const QString VisibleToFriends = "Friends"; diff --git a/interface/src/raypick/PickScriptingInterface.cpp b/interface/src/raypick/PickScriptingInterface.cpp index 26b5aacac5..6e979d2d91 100644 --- a/interface/src/raypick/PickScriptingInterface.cpp +++ b/interface/src/raypick/PickScriptingInterface.cpp @@ -31,6 +31,9 @@ #include +static const float WEB_TOUCH_Y_OFFSET = 0.105f; // how far forward (or back with a negative number) to slide stylus in hand +static const glm::vec3 TIP_OFFSET = glm::vec3(0.0f, StylusPick::WEB_STYLUS_LENGTH - WEB_TOUCH_Y_OFFSET, 0.0f); + unsigned int PickScriptingInterface::createPick(const PickQuery::PickType type, const QVariant& properties) { switch (type) { case PickQuery::PickType::Ray: @@ -137,7 +140,12 @@ unsigned int PickScriptingInterface::createStylusPick(const QVariant& properties maxDistance = propMap["maxDistance"].toFloat(); } - return DependencyManager::get()->addPick(PickQuery::Stylus, std::make_shared(side, filter, maxDistance, enabled)); + glm::vec3 tipOffset = TIP_OFFSET; + if (propMap["tipOffset"].isValid()) { + tipOffset = vec3FromVariant(propMap["tipOffset"]); + } + + return DependencyManager::get()->addPick(PickQuery::Stylus, std::make_shared(side, filter, maxDistance, enabled, tipOffset)); } // NOTE: Laser pointer still uses scaleWithAvatar. Until scaleWithAvatar is also deprecated for pointers, scaleWithAvatar should not be removed from the pick API. @@ -416,4 +424,4 @@ void PickScriptingInterface::setParentTransform(std::shared_ptr pick, pick->parentTransform = std::make_shared(pickID); } } -} \ No newline at end of file +} diff --git a/interface/src/raypick/PointerScriptingInterface.cpp b/interface/src/raypick/PointerScriptingInterface.cpp index a44d14b4a6..f0edb4d9ef 100644 --- a/interface/src/raypick/PointerScriptingInterface.cpp +++ b/interface/src/raypick/PointerScriptingInterface.cpp @@ -16,6 +16,11 @@ #include "LaserPointer.h" #include "StylusPointer.h" #include "ParabolaPointer.h" +#include "StylusPick.h" + +static const glm::quat X_ROT_NEG_90{ 0.70710678f, -0.70710678f, 0.0f, 0.0f }; +static const glm::vec3 DEFAULT_POSITION_OFFSET{0.0f, 0.0f, -StylusPick::WEB_STYLUS_LENGTH / 2.0f}; +static const glm::vec3 DEFAULT_MODEL_DIMENSIONS{0.01f, 0.01f, StylusPick::WEB_STYLUS_LENGTH}; void PointerScriptingInterface::setIgnoreItems(unsigned int uid, const QScriptValue& ignoreItems) const { DependencyManager::get()->setIgnoreItems(uid, qVectorQUuidFromScriptValue(ignoreItems)); @@ -50,6 +55,8 @@ unsigned int PointerScriptingInterface::createPointer(const PickQuery::PickType& * @typedef {object} Pointers.StylusPointerProperties * @property {boolean} [hover=false] If this pointer should generate hover events. * @property {boolean} [enabled=false] + * @property {Vec3} [tipOffset] The specified offset of the from the joint index. + * @property {object} [model] Data to replace the default model url, positionOffset and rotationOffset. */ unsigned int PointerScriptingInterface::createStylus(const QVariant& properties) const { QVariantMap propertyMap = properties.toMap(); @@ -64,7 +71,28 @@ unsigned int PointerScriptingInterface::createStylus(const QVariant& properties) enabled = propertyMap["enabled"].toBool(); } - return DependencyManager::get()->addPointer(std::make_shared(properties, StylusPointer::buildStylusOverlay(propertyMap), hover, enabled)); + glm::vec3 modelPositionOffset = DEFAULT_POSITION_OFFSET; + glm::quat modelRotationOffset = X_ROT_NEG_90; + glm::vec3 modelDimensions = DEFAULT_MODEL_DIMENSIONS; + + if (propertyMap["model"].isValid()) { + QVariantMap modelData = propertyMap["model"].toMap(); + + if (modelData["positionOffset"].isValid()) { + modelPositionOffset = vec3FromVariant(modelData["positionOffset"]); + } + + if (modelData["rotationOffset"].isValid()) { + modelRotationOffset = quatFromVariant(modelData["rotationOffset"]); + } + + if (modelData["dimensions"].isValid()) { + modelDimensions = vec3FromVariant(modelData["dimensions"]); + } + } + + return DependencyManager::get()->addPointer(std::make_shared(properties, StylusPointer::buildStylusOverlay(propertyMap), hover, enabled, modelPositionOffset, + modelRotationOffset, modelDimensions)); } /**jsdoc diff --git a/interface/src/raypick/RayPick.cpp b/interface/src/raypick/RayPick.cpp index ad12db4df2..b6adba4a12 100644 --- a/interface/src/raypick/RayPick.cpp +++ b/interface/src/raypick/RayPick.cpp @@ -10,6 +10,7 @@ #include "Application.h" #include "EntityScriptingInterface.h" #include "ui/overlays/Overlays.h" +#include "ui/Keyboard.h" #include "avatar/AvatarManager.h" #include "scripting/HMDScriptingInterface.h" #include "DependencyManager.h" @@ -40,9 +41,12 @@ PickResultPointer RayPick::getEntityIntersection(const PickRay& pick) { PickResultPointer RayPick::getOverlayIntersection(const PickRay& pick) { bool precisionPicking = !(getFilter().doesPickCoarse() || DependencyManager::get()->getForceCoarsePicking()); + auto keyboard = DependencyManager::get(); + QVector ignoreItems = keyboard->getKeysID(); + ignoreItems.append(getIgnoreItemsAs()); RayToOverlayIntersectionResult overlayRes = qApp->getOverlays().findRayIntersectionVector(pick, precisionPicking, - getIncludeItemsAs(), getIgnoreItemsAs(), !getFilter().doesPickInvisible(), !getFilter().doesPickNonCollidable()); + getIncludeItemsAs(), ignoreItems, !getFilter().doesPickInvisible(), !getFilter().doesPickNonCollidable()); if (overlayRes.intersects) { return std::make_shared(IntersectionType::OVERLAY, overlayRes.overlayID, overlayRes.distance, overlayRes.intersection, pick, overlayRes.surfaceNormal, overlayRes.extraInfo); } else { @@ -118,4 +122,4 @@ glm::vec2 RayPick::projectOntoOverlayXYPlane(const QUuid& overlayID, const glm:: glm::vec2 RayPick::projectOntoEntityXYPlane(const QUuid& entityID, const glm::vec3& worldPos, bool unNormalized) { auto props = DependencyManager::get()->getEntityProperties(entityID); return projectOntoXYPlane(worldPos, props.getPosition(), props.getRotation(), props.getDimensions(), props.getRegistrationPoint(), unNormalized); -} \ No newline at end of file +} diff --git a/interface/src/raypick/StylusPick.cpp b/interface/src/raypick/StylusPick.cpp index c495ddd194..0416563272 100644 --- a/interface/src/raypick/StylusPick.cpp +++ b/interface/src/raypick/StylusPick.cpp @@ -21,11 +21,7 @@ #include using namespace bilateral; - -// TODO: make these configurable per pick -static const float WEB_STYLUS_LENGTH = 0.2f; -static const float WEB_TOUCH_Y_OFFSET = 0.105f; // how far forward (or back with a negative number) to slide stylus in hand -static const glm::vec3 TIP_OFFSET = glm::vec3(0.0f, WEB_STYLUS_LENGTH - WEB_TOUCH_Y_OFFSET, 0.0f); +float StylusPick::WEB_STYLUS_LENGTH = 0.2f; struct SideData { QString avatarJoint; @@ -64,8 +60,8 @@ bool StylusPickResult::checkOrFilterAgainstMaxDistance(float maxDistance) { return distance < maxDistance; } -StylusPick::StylusPick(Side side, const PickFilter& filter, float maxDistance, bool enabled) : - Pick(StylusTip(side), filter, maxDistance, enabled) +StylusPick::StylusPick(Side side, const PickFilter& filter, float maxDistance, bool enabled, const glm::vec3& tipOffset) : + Pick(StylusTip(side), filter, maxDistance, enabled), _tipOffset(tipOffset) { } @@ -90,7 +86,7 @@ static StylusTip getFingerWorldLocation(Side side) { } // controllerWorldLocation is where the controller would be, in-world, with an added offset -static StylusTip getControllerWorldLocation(Side side) { +static StylusTip getControllerWorldLocation(Side side, const glm::vec3& tipOffset) { static const std::array INPUTS{ { UserInputMapper::makeStandardInput(SIDES[0].channel), UserInputMapper::makeStandardInput(SIDES[1].channel) } }; const auto sideIndex = index(side); @@ -114,7 +110,7 @@ static StylusTip getControllerWorldLocation(Side side) { // add to the real position so the grab-point is out in front of the hand, a bit result.position += result.orientation * (sideData.grabPointSphereOffset * sensorScaleFactor); // move the stylus forward a bit - result.position += result.orientation * (TIP_OFFSET * sensorScaleFactor); + result.position += result.orientation * (tipOffset * sensorScaleFactor); auto worldControllerPos = avatarPosition + avatarOrientation * pose.translation; // compute tip velocity from hand controller motion, it is more accurate than computing it from previous positions. @@ -131,7 +127,7 @@ StylusTip StylusPick::getMathematicalPick() const { if (qApp->getPreferAvatarFingerOverStylus()) { result = getFingerWorldLocation(_mathPick.side); } else { - result = getControllerWorldLocation(_mathPick.side); + result = getControllerWorldLocation(_mathPick.side, _tipOffset); } return result; } @@ -236,4 +232,4 @@ Transform StylusPick::getResultTransform() const { Transform transform; transform.setTranslation(stylusResult->intersection); return transform; -} \ No newline at end of file +} diff --git a/interface/src/raypick/StylusPick.h b/interface/src/raypick/StylusPick.h index cd01df20e9..3e0ee452e9 100644 --- a/interface/src/raypick/StylusPick.h +++ b/interface/src/raypick/StylusPick.h @@ -58,7 +58,7 @@ public: class StylusPick : public Pick { using Side = bilateral::Side; public: - StylusPick(Side side, const PickFilter& filter, float maxDistance, bool enabled); + StylusPick(Side side, const PickFilter& filter, float maxDistance, bool enabled, const glm::vec3& tipOffset); StylusTip getMathematicalPick() const override; PickResultPointer getDefaultResult(const QVariantMap& pickVariant) const override; @@ -71,6 +71,11 @@ public: bool isLeftHand() const override { return _mathPick.side == Side::Left; } bool isRightHand() const override { return _mathPick.side == Side::Right; } bool isMouse() const override { return false; } + + static float WEB_STYLUS_LENGTH; + +private: + glm::vec3 _tipOffset; }; -#endif // hifi_StylusPick_h \ No newline at end of file +#endif // hifi_StylusPick_h diff --git a/interface/src/raypick/StylusPointer.cpp b/interface/src/raypick/StylusPointer.cpp index 4ba3813c4a..caa3151cc5 100644 --- a/interface/src/raypick/StylusPointer.cpp +++ b/interface/src/raypick/StylusPointer.cpp @@ -17,9 +17,6 @@ #include "PickScriptingInterface.h" #include -// TODO: make these configurable per pointer -static const float WEB_STYLUS_LENGTH = 0.2f; - static const float TABLET_MIN_HOVER_DISTANCE = -0.1f; static const float TABLET_MAX_HOVER_DISTANCE = 0.1f; static const float TABLET_MIN_TOUCH_DISTANCE = -0.1f; @@ -28,9 +25,15 @@ static const float TABLET_MAX_TOUCH_DISTANCE = 0.005f; static const float HOVER_HYSTERESIS = 0.01f; static const float TOUCH_HYSTERESIS = 0.001f; -StylusPointer::StylusPointer(const QVariant& props, const OverlayID& stylusOverlay, bool hover, bool enabled) : +static const QString DEFAULT_STYLUS_MODEL_URL = PathUtils::resourcesUrl() + "/meshes/tablet-stylus-fat.fbx"; + +StylusPointer::StylusPointer(const QVariant& props, const OverlayID& stylusOverlay, bool hover, bool enabled, + const glm::vec3& modelPositionOffset, const glm::quat& modelRotationOffset, const glm::vec3& modelDimensions) : Pointer(DependencyManager::get()->createStylusPick(props), enabled, hover), - _stylusOverlay(stylusOverlay) + _stylusOverlay(stylusOverlay), + _modelPositionOffset(modelPositionOffset), + _modelDimensions(modelDimensions), + _modelRotationOffset(modelRotationOffset) { } @@ -42,9 +45,19 @@ StylusPointer::~StylusPointer() { OverlayID StylusPointer::buildStylusOverlay(const QVariantMap& properties) { QVariantMap overlayProperties; - // TODO: make these configurable per pointer + // TODO: make these configurable per pointe + QString modelUrl = DEFAULT_STYLUS_MODEL_URL; + + if (properties["model"].isValid()) { + QVariantMap modelData = properties["model"].toMap(); + + if (modelData["url"].isValid()) { + modelUrl = modelData["url"].toString(); + } + } + overlayProperties["name"] = "stylus"; - overlayProperties["url"] = PathUtils::resourcesUrl() + "/meshes/tablet-stylus-fat.fbx"; + overlayProperties["url"] = modelUrl; overlayProperties["loadPriority"] = 10.0f; overlayProperties["solid"] = true; overlayProperties["visible"] = false; @@ -72,13 +85,12 @@ void StylusPointer::updateVisuals(const PickResultPointer& pickResult) { void StylusPointer::show(const StylusTip& tip) { if (!_stylusOverlay.isNull()) { QVariantMap props; - static const glm::quat X_ROT_NEG_90{ 0.70710678f, -0.70710678f, 0.0f, 0.0f }; - auto modelOrientation = tip.orientation * X_ROT_NEG_90; + auto modelOrientation = tip.orientation * _modelRotationOffset; auto sensorToWorldScale = DependencyManager::get()->getMyAvatar()->getSensorToWorldScale(); - auto modelPositionOffset = modelOrientation * (vec3(0.0f, 0.0f, -WEB_STYLUS_LENGTH / 2.0f) * sensorToWorldScale); + auto modelPositionOffset = modelOrientation * (_modelPositionOffset * sensorToWorldScale); props["position"] = vec3toVariant(tip.position + modelPositionOffset); props["rotation"] = quatToVariant(modelOrientation); - props["dimensions"] = vec3toVariant(sensorToWorldScale * vec3(0.01f, 0.01f, WEB_STYLUS_LENGTH)); + props["dimensions"] = vec3toVariant(sensorToWorldScale * _modelDimensions); props["visible"] = true; qApp->getOverlays().editOverlay(_stylusOverlay, props); } diff --git a/interface/src/raypick/StylusPointer.h b/interface/src/raypick/StylusPointer.h index ff60fd78e5..64e2a38bed 100644 --- a/interface/src/raypick/StylusPointer.h +++ b/interface/src/raypick/StylusPointer.h @@ -21,7 +21,8 @@ class StylusPointer : public Pointer { using Ptr = std::shared_ptr; public: - StylusPointer(const QVariant& props, const OverlayID& stylusOverlay, bool hover, bool enabled); + StylusPointer(const QVariant& props, const OverlayID& stylusOverlay, bool hover, bool enabled, + const glm::vec3& modelPositionOffset, const glm::quat& modelRotationOffset, const glm::vec3& modelDimensions); ~StylusPointer(); void updateVisuals(const PickResultPointer& pickResult) override; @@ -81,6 +82,10 @@ private: bool _showing { true }; + glm::vec3 _modelPositionOffset; + glm::vec3 _modelDimensions; + glm::quat _modelRotationOffset; + }; #endif // hifi_StylusPointer_h diff --git a/interface/src/scripting/HMDScriptingInterface.cpp b/interface/src/scripting/HMDScriptingInterface.cpp index ea24d6c793..f2f8d3b8d4 100644 --- a/interface/src/scripting/HMDScriptingInterface.cpp +++ b/interface/src/scripting/HMDScriptingInterface.cpp @@ -119,8 +119,11 @@ void HMDScriptingInterface::toggleShouldShowTablet() { } void HMDScriptingInterface::setShouldShowTablet(bool value) { - _showTablet = value; - _tabletContextualMode = false; + if (_showTablet != value) { + _showTablet = value; + _tabletContextualMode = false; + emit showTabletChanged(value); + } } QScriptValue HMDScriptingInterface::getHUDLookAtPosition2D(QScriptContext* context, QScriptEngine* engine) { diff --git a/interface/src/scripting/HMDScriptingInterface.h b/interface/src/scripting/HMDScriptingInterface.h index 2c0a3fe45f..6cc695762b 100644 --- a/interface/src/scripting/HMDScriptingInterface.h +++ b/interface/src/scripting/HMDScriptingInterface.h @@ -355,6 +355,8 @@ signals: */ bool shouldShowHandControllersChanged(); + void showTabletChanged(bool showTablet); + public: HMDScriptingInterface(); static QScriptValue getHUDLookAtPosition2D(QScriptContext* context, QScriptEngine* engine); diff --git a/interface/src/scripting/KeyboardScriptingInterface.cpp b/interface/src/scripting/KeyboardScriptingInterface.cpp new file mode 100644 index 0000000000..b26e1ec378 --- /dev/null +++ b/interface/src/scripting/KeyboardScriptingInterface.cpp @@ -0,0 +1,34 @@ +// +// KeyboardScriptingInterface.cpp +// interface/src/scripting +// +// Created by Dante Ruiz on 2018-08-27. +// Copyright 2017 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "KeyboardScriptingInterface.h" +#include "ui/Keyboard.h" + +bool KeyboardScriptingInterface::isRaised() { + return DependencyManager::get()->isRaised(); +} + +void KeyboardScriptingInterface::setRaised(bool raised) { + DependencyManager::get()->setRaised(raised); +} + + +bool KeyboardScriptingInterface::isPassword() { + return DependencyManager::get()->isPassword(); +} + +void KeyboardScriptingInterface::setPassword(bool password) { + DependencyManager::get()->setPassword(password); +} + +void KeyboardScriptingInterface::loadKeyboardFile(const QString& keyboardFile) { + DependencyManager::get()->loadKeyboardFile(keyboardFile); +} diff --git a/interface/src/scripting/KeyboardScriptingInterface.h b/interface/src/scripting/KeyboardScriptingInterface.h new file mode 100644 index 0000000000..1ab91ea7c3 --- /dev/null +++ b/interface/src/scripting/KeyboardScriptingInterface.h @@ -0,0 +1,43 @@ +// +// KeyboardScriptingInterface.h +// interface/src/scripting +// +// Created by Dante Ruiz on 2018-08-27. +// Copyright 2017 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_KeyboardScriptingInterface_h +#define hifi_KeyboardScriptingInterface_h + +#include + +#include "DependencyManager.h" + +/**jsdoc + * The Keyboard API provides facilities to use 3D Physical keyboard. + * @namespace Keyboard + * + * @hifi-interface + * @hifi-client-entity + * + * @property {bool} raised - true If the keyboard is visible false otherwise + * @property {bool} password - true Will show * instead of characters in the text display false otherwise + */ +class KeyboardScriptingInterface : public QObject, public Dependency { + Q_OBJECT + Q_PROPERTY(bool raised READ isRaised WRITE setRaised) + Q_PROPERTY(bool password READ isPassword WRITE setPassword) + +public: + Q_INVOKABLE void loadKeyboardFile(const QString& string); +private: + bool isRaised(); + void setRaised(bool raised); + + bool isPassword(); + void setPassword(bool password); +}; +#endif diff --git a/interface/src/ui/Keyboard.cpp b/interface/src/ui/Keyboard.cpp new file mode 100644 index 0000000000..677d384a17 --- /dev/null +++ b/interface/src/ui/Keyboard.cpp @@ -0,0 +1,799 @@ +// +// Keyboard.cpp +// interface/src/scripting +// +// Created by Dante Ruiz on 2018-08-27. +// Copyright 2018 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "Keyboard.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/overlays/Overlays.h" +#include "ui/overlays/Overlay.h" +#include "ui/overlays/ModelOverlay.h" +#include "ui/overlays/Cube3DOverlay.h" +#include "ui/overlays/Text3DOverlay.h" +#include "avatar/AvatarManager.h" +#include "avatar/MyAvatar.h" +#include "avatar/AvatarManager.h" +#include "raypick/PickScriptingInterface.h" +#include "scripting/HMDScriptingInterface.h" +#include "scripting/WindowScriptingInterface.h" +#include "DependencyManager.h" + +#include "raypick/StylusPointer.h" +#include "GLMHelpers.h" +#include "Application.h" + +static const int LEFT_HAND_CONTROLLER_INDEX = 0; +static const int RIGHT_HAND_CONTROLLER_INDEX = 1; + +static const float MALLET_LENGTH = 0.4f; +static const float MALLET_TOUCH_Y_OFFSET = 0.105f; +static const float MALLET_Y_OFFSET = 0.35f; + +static const glm::quat MALLET_ROTATION_OFFSET{0.70710678f, 0.0f, -0.70710678f, 0.0f}; +static const glm::vec3 MALLET_MODEL_DIMENSIONS{0.05f, MALLET_LENGTH, 0.05f}; +static const glm::vec3 MALLET_POSITION_OFFSET{0.0f, -MALLET_Y_OFFSET / 2.0f, 0.0f}; +static const glm::vec3 MALLET_TIP_OFFSET{0.0f, MALLET_LENGTH - MALLET_TOUCH_Y_OFFSET, 0.0f}; + + +static const glm::vec3 Z_AXIS {0.0f, 0.0f, 1.0f}; +static const glm::vec3 KEYBOARD_TABLET_OFFSET{0.28f, -0.3f, -0.05f}; +static const glm::vec3 KEYBOARD_TABLET_DEGREES_OFFSET{-45.0f, 0.0f, 0.0f}; +static const glm::vec3 KEYBOARD_TABLET_LANDSCAPE_OFFSET{-0.2f, -0.27f, -0.05f}; +static const glm::vec3 KEYBOARD_TABLET_LANDSCAPE_DEGREES_OFFSET{-45.0f, 0.0f, -90.0f}; +static const glm::vec3 KEYBOARD_AVATAR_OFFSET{-0.6f, 0.3f, -0.7f}; +static const glm::vec3 KEYBOARD_AVATAR_DEGREES_OFFSET{0.0f, 180.0f, 0.0f}; + +static const QString SOUND_FILE = PathUtils::resourcesUrl() + "sounds/keyboard_key.mp3"; +static const QString MALLET_MODEL_URL = PathUtils::resourcesUrl() + "meshes/drumstick.fbx"; + +static const float PULSE_STRENGTH = 0.6f; +static const float PULSE_DURATION = 3.0f; + +static const int KEY_PRESS_TIMEOUT_MS = 100; +static const int LAYER_SWITCH_TIMEOUT_MS = 200; + +static const QString CHARACTER_STRING = "character"; +static const QString CAPS_STRING = "caps"; +static const QString CLOSE_STRING = "close"; +static const QString LAYER_STRING = "layer"; +static const QString BACKSPACE_STRING = "backspace"; +static const QString SPACE_STRING = "space"; +static const QString ENTER_STRING = "enter"; + +std::pair calculateKeyboardPositionAndOrientation() { + auto myAvatar = DependencyManager::get()->getMyAvatar(); + auto hmd = DependencyManager::get(); + + std::pair keyboardLocation = std::make_pair(glm::vec3(), glm::quat()); + float sensorToWorldScale = myAvatar->getSensorToWorldScale(); + QUuid tabletID = hmd->getCurrentTabletFrameID(); + if (!tabletID.isNull() && hmd->getShouldShowTablet()) { + Overlays& overlays = qApp->getOverlays(); + auto tabletOverlay = std::dynamic_pointer_cast(overlays.getOverlay(tabletID)); + if (tabletOverlay) { + auto tablet = DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system"); + bool landscapeMode = tablet->getLandscape(); + glm::vec3 keyboardOffset = landscapeMode ? KEYBOARD_TABLET_LANDSCAPE_OFFSET : KEYBOARD_TABLET_OFFSET; + glm::vec3 keyboardDegreesOffset = landscapeMode ? KEYBOARD_TABLET_LANDSCAPE_DEGREES_OFFSET : KEYBOARD_TABLET_DEGREES_OFFSET; + glm::vec3 tabletWorldPosition = tabletOverlay->getWorldPosition(); + glm::quat tabletWorldOrientation = tabletOverlay->getWorldOrientation(); + glm::vec3 scaledKeyboardTabletOffset = keyboardOffset * sensorToWorldScale; + + keyboardLocation.first = tabletWorldPosition + (tabletWorldOrientation * scaledKeyboardTabletOffset); + keyboardLocation.second = tabletWorldOrientation * glm::quat(glm::radians(keyboardDegreesOffset)); + } + + } else { + glm::vec3 avatarWorldPosition = myAvatar->getWorldPosition(); + glm::quat avatarWorldOrientation = myAvatar->getWorldOrientation(); + glm::vec3 scaledKeyboardAvatarOffset = KEYBOARD_AVATAR_OFFSET * sensorToWorldScale; + + keyboardLocation.first = avatarWorldPosition + (avatarWorldOrientation * scaledKeyboardAvatarOffset); + keyboardLocation.second = avatarWorldOrientation * glm::quat(glm::radians(KEYBOARD_AVATAR_DEGREES_OFFSET)); + } + + return keyboardLocation; +} + +void Key::saveDimensionsAndLocalPosition() { + Overlays& overlays = qApp->getOverlays(); + auto model3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(_keyID)); + + if (model3DOverlay) { + _originalLocalPosition = model3DOverlay->getLocalPosition(); + _originalDimensions = model3DOverlay->getDimensions(); + _currentLocalPosition = _originalLocalPosition; + } +} + +void Key::scaleKey(float sensorToWorldScale) { + Overlays& overlays = qApp->getOverlays(); + auto model3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(_keyID)); + + if (model3DOverlay) { + glm::vec3 scaledLocalPosition = _originalLocalPosition * sensorToWorldScale; + glm::vec3 scaledDimensions = _originalDimensions * sensorToWorldScale; + _currentLocalPosition = scaledLocalPosition; + + QVariantMap properties { + { "dimensions", vec3toVariant(scaledDimensions) }, + { "localPosition", vec3toVariant(scaledLocalPosition) } + }; + + overlays.editOverlay(_keyID, properties); + } +} + +void Key::startTimer(int time) { + if (_timer) { + _timer->start(time); + _timer->setSingleShot(true); + } +} + +bool Key::timerFinished() { + if (_timer) { + return (_timer->remainingTime() <= 0); + } + return false; +} + +QString Key::getKeyString(bool toUpper) const { + return toUpper ? _keyString.toUpper() : _keyString; +} + +int Key::getScanCode(bool toUpper) const { + QString character = toUpper ? _keyString.toUpper() : _keyString; + auto utf8Key = character.toUtf8(); + return (int)utf8Key[0]; +} + +Key::Type Key::getKeyTypeFromString(const QString& keyTypeString) { + if (keyTypeString == SPACE_STRING) { + return Type::SPACE; + } else if (keyTypeString == BACKSPACE_STRING) { + return Type::BACKSPACE; + } else if (keyTypeString == LAYER_STRING) { + return Type::LAYER; + } else if (keyTypeString == CAPS_STRING) { + return Type::CAPS; + } else if (keyTypeString == CLOSE_STRING) { + return Type::CLOSE; + } else if (keyTypeString == ENTER_STRING) { + return Type::ENTER; + } + + return Type::CHARACTER; +} + +Keyboard::Keyboard() { + auto pointerManager = DependencyManager::get(); + auto windowScriptingInterface = DependencyManager::get(); + auto myAvatar = DependencyManager::get()->getMyAvatar(); + connect(pointerManager.data(), &PointerManager::triggerBeginOverlay, this, &Keyboard::handleTriggerBegin, Qt::QueuedConnection); + connect(pointerManager.data(), &PointerManager::triggerContinueOverlay, this, &Keyboard::handleTriggerContinue, Qt::QueuedConnection); + connect(pointerManager.data(), &PointerManager::triggerEndOverlay, this, &Keyboard::handleTriggerEnd, Qt::QueuedConnection); + connect(myAvatar.get(), &MyAvatar::sensorToWorldScaleChanged, this, &Keyboard::scaleKeyboard, Qt::QueuedConnection); + connect(windowScriptingInterface.data(), &WindowScriptingInterface::domainChanged, [&]() { setRaised(false); }); +} + + +void Keyboard::createKeyboard() { + auto pointerManager = DependencyManager::get(); + + QVariantMap modelProperties { + { "url", MALLET_MODEL_URL } + }; + + QVariantMap leftStylusProperties { + { "hand", LEFT_HAND_CONTROLLER_INDEX }, + { "filter", PickScriptingInterface::PICK_OVERLAYS() }, + { "model", modelProperties }, + { "tipOffset", vec3toVariant(MALLET_TIP_OFFSET) } + }; + + QVariantMap rightStylusProperties { + { "hand", RIGHT_HAND_CONTROLLER_INDEX }, + { "filter", PickScriptingInterface::PICK_OVERLAYS() }, + { "model", modelProperties }, + { "tipOffset", vec3toVariant(MALLET_TIP_OFFSET) } + }; + + _leftHandStylus = pointerManager->addPointer(std::make_shared(leftStylusProperties, StylusPointer::buildStylusOverlay(leftStylusProperties), true, true, + MALLET_POSITION_OFFSET, MALLET_ROTATION_OFFSET, MALLET_MODEL_DIMENSIONS)); + _rightHandStylus = pointerManager->addPointer(std::make_shared(rightStylusProperties, StylusPointer::buildStylusOverlay(rightStylusProperties), true, true, + MALLET_POSITION_OFFSET, MALLET_ROTATION_OFFSET, MALLET_MODEL_DIMENSIONS)); + + pointerManager->disablePointer(_rightHandStylus); + pointerManager->disablePointer(_leftHandStylus); + + QString keyboardSvg = PathUtils::resourcesUrl() + "config/keyboard.json"; + loadKeyboardFile(keyboardSvg); + + _keySound = DependencyManager::get()->getSound(SOUND_FILE); +} + +bool Keyboard::isRaised() const { + return resultWithReadLock([&] { return _raised; }); +} + +void Keyboard::setRaised(bool raised) { + + bool isRaised; + withReadLock([&] { isRaised = _raised; }); + if (isRaised != raised) { + raiseKeyboardAnchor(raised); + raiseKeyboard(raised); + raised ? enableStylus() : disableStylus(); + withWriteLock([&] { + _raised = raised; + _layerIndex = 0; + _capsEnabled = false; + _typedCharacters.clear(); + }); + + updateTextDisplay(); + } +} + +void Keyboard::updateTextDisplay() { + Overlays& overlays = qApp->getOverlays(); + + auto myAvatar = DependencyManager::get()->getMyAvatar(); + float sensorToWorldScale = myAvatar->getSensorToWorldScale(); + float textWidth = (float) overlays.textSize(_textDisplay.overlayID, _typedCharacters).width(); + + glm::vec3 scaledDimensions = _textDisplay.dimensions; + scaledDimensions *= sensorToWorldScale; + float leftMargin = (scaledDimensions.x / 2); + scaledDimensions.x += textWidth; + + + QVariantMap textDisplayProperties { + { "dimensions", vec3toVariant(scaledDimensions) }, + { "leftMargin", leftMargin }, + { "text", _typedCharacters }, + { "lineHeight", (_textDisplay.lineHeight * sensorToWorldScale) } + }; + + overlays.editOverlay(_textDisplay.overlayID, textDisplayProperties); +} + +void Keyboard::raiseKeyboardAnchor(bool raise) const { + Overlays& overlays = qApp->getOverlays(); + OverlayID anchorOverlayID = _anchor.overlayID; + auto anchorOverlay = std::dynamic_pointer_cast(overlays.getOverlay(anchorOverlayID)); + if (anchorOverlay) { + std::pair keyboardLocation = calculateKeyboardPositionAndOrientation(); + anchorOverlay->setWorldPosition(keyboardLocation.first); + anchorOverlay->setWorldOrientation(keyboardLocation.second); + anchorOverlay->setVisible(raise); + + QVariantMap textDisplayProperties { + { "visible", raise } + }; + + overlays.editOverlay(_textDisplay.overlayID, textDisplayProperties); + } +} + +void Keyboard::scaleKeyboard(float sensorToWorldScale) { + Overlays& overlays = qApp->getOverlays(); + + glm::vec3 scaledDimensions = _anchor.originalDimensions * sensorToWorldScale; + auto volume3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(_anchor.overlayID)); + + if (volume3DOverlay) { + volume3DOverlay->setDimensions(scaledDimensions); + } + + for (auto& keyboardLayer: _keyboardLayers) { + for (auto& key: keyboardLayer) { + key.scaleKey(sensorToWorldScale); + } + } + + + + glm::vec3 scaledLocalPosition = _textDisplay.localPosition * sensorToWorldScale; + glm::vec3 textDisplayScaledDimensions = _textDisplay.dimensions * sensorToWorldScale; + + QVariantMap textDisplayProperties { + { "localPosition", vec3toVariant(scaledLocalPosition) }, + { "dimensions", vec3toVariant(textDisplayScaledDimensions) }, + { "lineHeight", (_textDisplay.lineHeight * sensorToWorldScale) } + }; + + overlays.editOverlay(_textDisplay.overlayID, textDisplayProperties); +} + +void Keyboard::startLayerSwitchTimer() { + if (_layerSwitchTimer) { + _layerSwitchTimer->start(LAYER_SWITCH_TIMEOUT_MS); + _layerSwitchTimer->setSingleShot(true); + } +} + +bool Keyboard::isLayerSwitchTimerFinished() { + if (_layerSwitchTimer) { + return (_layerSwitchTimer->remainingTime() <= 0); + } + return false; +} + +void Keyboard::raiseKeyboard(bool raise) const { + if (_keyboardLayers.empty()) { + return; + } + Overlays& overlays = qApp->getOverlays(); + for (const auto& key: _keyboardLayers[_layerIndex]) { + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(key.getID())); + if (base3DOverlay) { + base3DOverlay->setVisible(raise); + } + } +} + +bool Keyboard::isPassword() const { + return resultWithReadLock([&] { return _password; }); +} + +void Keyboard::setPassword(bool password) { + if (_password != password) { + withWriteLock([&] { + _password = password; + _typedCharacters.clear(); + }); + } + + updateTextDisplay(); +} + +void Keyboard::switchToLayer(int layerIndex) { + if (layerIndex >= 0 && layerIndex < (int)_keyboardLayers.size()) { + Overlays& overlays = qApp->getOverlays(); + + OverlayID currentAnchorOverlayID = _anchor.overlayID; + + glm::vec3 currentOverlayPosition; + glm::quat currentOverlayOrientation; + + auto currentAnchorOverlay = std::dynamic_pointer_cast(overlays.getOverlay(currentAnchorOverlayID)); + if (currentAnchorOverlay) { + currentOverlayPosition = currentAnchorOverlay->getWorldPosition(); + currentOverlayOrientation = currentAnchorOverlay->getWorldOrientation(); + } + + raiseKeyboardAnchor(false); + raiseKeyboard(false); + + setLayerIndex(layerIndex); + + raiseKeyboardAnchor(true); + raiseKeyboard(true); + + OverlayID newAnchorOverlayID = _anchor.overlayID; + auto newAnchorOverlay = std::dynamic_pointer_cast(overlays.getOverlay(newAnchorOverlayID)); + if (newAnchorOverlay) { + newAnchorOverlay->setWorldPosition(currentOverlayPosition); + newAnchorOverlay->setWorldOrientation(currentOverlayOrientation); + } + + startLayerSwitchTimer(); + } +} + +void Keyboard::handleTriggerBegin(const OverlayID& overlayID, const PointerEvent& event) { + if (_keyboardLayers.empty() || !isLayerSwitchTimerFinished()) { + return; + } + + auto pointerID = event.getID(); + auto buttonType = event.getButton(); + + for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { + Key& key = *index; + if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus) && + buttonType == PointerEvent::PrimaryButton) { + + + if (key.timerFinished()) { + + auto handIndex = (pointerID == _leftHandStylus) ? controller::Hand::LEFT : controller::Hand::RIGHT; + auto userInputMapper = DependencyManager::get(); + userInputMapper->triggerHapticPulse(PULSE_STRENGTH, PULSE_DURATION, handIndex); + + Overlays& overlays = qApp->getOverlays(); + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + + glm::vec3 keyWorldPosition; + if (base3DOverlay) { + keyWorldPosition = base3DOverlay->getWorldPosition(); + } + + AudioInjectorOptions audioOptions; + audioOptions.localOnly = true; + audioOptions.position = keyWorldPosition; + audioOptions.volume = 0.4f; + + AudioInjector::playSound(_keySound->getByteArray(), audioOptions); + + int scanCode = key.getScanCode(_capsEnabled); + QString keyString = key.getKeyString(_capsEnabled); + + auto tablet = DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system"); + + switch (key.getKeyType()) { + case Key::Type::CLOSE: + setRaised(false); + tablet->unfocus(); + return; + + case Key::Type::CAPS: + _capsEnabled = !_capsEnabled; + switchToLayer(key.getSwitchToLayerIndex()); + return; + case Key::Type::LAYER: + _capsEnabled = false; + switchToLayer(key.getSwitchToLayerIndex()); + return; + case Key::Type::BACKSPACE: + scanCode = Qt::Key_Backspace; + keyString = "\x08"; + _typedCharacters = _typedCharacters.left(_typedCharacters.length() -1); + updateTextDisplay(); + break; + case Key::Type::ENTER: + scanCode = Qt::Key_Return; + keyString = "\x0d"; + _typedCharacters.clear(); + updateTextDisplay(); + break; + case Key::Type::CHARACTER: + if (keyString != " ") { + _typedCharacters.push_back((_password ? "*" : keyString)); + } else { + _typedCharacters.clear(); + } + updateTextDisplay(); + break; + + default: + break; + } + + QKeyEvent* pressEvent = new QKeyEvent(QEvent::KeyPress, scanCode, Qt::NoModifier, keyString); + QKeyEvent* releaseEvent = new QKeyEvent(QEvent::KeyRelease, scanCode, Qt::NoModifier, keyString); + QCoreApplication::postEvent(QCoreApplication::instance(), pressEvent); + QCoreApplication::postEvent(QCoreApplication::instance(), releaseEvent); + + key.startTimer(KEY_PRESS_TIMEOUT_MS); + } + + break; + } + } +} + +void Keyboard::handleTriggerEnd(const OverlayID& overlayID, const PointerEvent& event) { + if (_keyboardLayers.empty() || !isLayerSwitchTimerFinished()) { + return; + } + + auto pointerID = event.getID(); + + for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { + Key& key = *index; + + if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus)) { + Overlays& overlays = qApp->getOverlays(); + + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + + if (base3DOverlay) { + base3DOverlay->setLocalPosition(key.getCurrentLocalPosition()); + } + + key.setIsPressed(false); + if (key.timerFinished()) { + key.startTimer(KEY_PRESS_TIMEOUT_MS); + } + break; + } + } + +} + +void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEvent& event) { + if (_keyboardLayers.empty() || !isLayerSwitchTimerFinished()) { + return; + } + + auto pointerID = event.getID(); + + for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { + Key& key = *index; + + if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus)) { + Overlays& overlays = qApp->getOverlays(); + + if (!key.isPressed()) { + auto pointerManager = DependencyManager::get(); + auto pickResult = pointerManager->getPrevPickResult(pointerID); + + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + + if (base3DOverlay) { + auto pickResultVariant = pickResult->toVariantMap(); + auto stylusTipVariant = pickResultVariant["stylusTip"]; + auto stylusTipPositionVariant = stylusTipVariant.toMap()["position"]; + glm::vec3 stylusTipPosition = vec3FromVariant(stylusTipPositionVariant); + + glm::quat overlayOrientation = base3DOverlay->getWorldOrientation(); + glm::vec3 overlayPosition = base3DOverlay->getWorldPosition(); + + glm::mat4 overlayWorldMat = createMatFromQuatAndPos(overlayOrientation, overlayPosition); + glm::mat4 overlayWorldToLocalMat = glm::inverse(overlayWorldMat); + + glm::vec3 stylusTipInOverlaySpace = transformPoint(overlayWorldToLocalMat, stylusTipPosition); + + static const float PENATRATION_THRESHOLD = 0.025f; + if (stylusTipInOverlaySpace.z < PENATRATION_THRESHOLD) { + static const float Z_OFFSET = 0.002f; + glm::vec3 overlayYAxis = overlayOrientation * Z_AXIS; + glm::vec3 overlayYOffset = overlayYAxis * Z_OFFSET; + glm::vec3 localPosition = key.getCurrentLocalPosition() - overlayYOffset; + base3DOverlay->setLocalPosition(localPosition); + key.setIsPressed(true); + } + } + } + break; + } + } +} + +void Keyboard::disableStylus() { + auto pointerManager = DependencyManager::get(); + pointerManager->setRenderState(_leftHandStylus, "events off"); + pointerManager->disablePointer(_leftHandStylus); + pointerManager->setRenderState(_rightHandStylus, "events off"); + pointerManager->disablePointer(_rightHandStylus); +} + +void Keyboard::setLayerIndex(int layerIndex) { + if (layerIndex >= 0 && layerIndex < (int)_keyboardLayers.size()) { + _layerIndex = layerIndex; + } else { + _layerIndex = 0; + } +} + +void Keyboard::loadKeyboardFile(const QString& keyboardFile) { + if (keyboardFile.isEmpty()) { + return; + } + + auto request = DependencyManager::get()->createResourceRequest(this, keyboardFile); + + if (!request) { + qCWarning(interfaceapp) << "Could not create resource for Keyboard file" << keyboardFile; + } + + + connect(request, &ResourceRequest::finished, this, [=]() { + if (request->getResult() != ResourceRequest::Success) { + qCWarning(interfaceapp) << "Keyboard file failed to download"; + return; + } + + clearKeyboardKeys(); + Overlays& overlays = qApp->getOverlays(); + auto requestData = request->getData(); + + QVector includeItems; + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(requestData, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + qCWarning(interfaceapp) << "Failed to parse keyboard json file - Error: " << parseError.errorString(); + return; + } + QJsonObject jsonObject = jsonDoc.object(); + QJsonArray layer = jsonObject["Layer1"].toArray(); + QJsonObject anchorObject = jsonObject["anchor"].toObject(); + bool useResourcePath = jsonObject["useResourcesPath"].toBool(); + QString resourcePath = PathUtils::resourcesUrl(); + + + if (anchorObject.isEmpty()) { + qCWarning(interfaceapp) << "No Anchor specified. Not creating keyboard"; + return; + } + + QVariantMap anchorProperties { + { "name", "KeyboardAnchor"}, + { "isSolid", true }, + { "visible", false }, + { "grabbable", true }, + { "ignoreRayIntersection", false }, + { "dimensions", anchorObject["dimensions"].toVariant() }, + { "position", anchorObject["position"].toVariant() }, + { "orientation", anchorObject["rotation"].toVariant() } + }; + + glm::vec3 dimensions = vec3FromVariant(anchorObject["dimensions"].toVariant()); + + Anchor anchor; + anchor.overlayID = overlays.addOverlay("cube", anchorProperties); + anchor.originalDimensions = dimensions; + _anchor = anchor; + + const QJsonArray& keyboardLayers = jsonObject["layers"].toArray(); + int keyboardLayerCount = keyboardLayers.size(); + _keyboardLayers.reserve(keyboardLayerCount); + + + for (int keyboardLayerIndex = 0; keyboardLayerIndex < keyboardLayerCount; keyboardLayerIndex++) { + const QJsonValue& keyboardLayer = keyboardLayers[keyboardLayerIndex].toArray(); + + std::vector keyboardLayerKeys; + foreach (const QJsonValue& keyboardKeyValue, keyboardLayer.toArray()) { + + QVariantMap textureMap; + if (!keyboardKeyValue["texture"].isNull()) { + textureMap = keyboardKeyValue["texture"].toObject().toVariantMap(); + + if (useResourcePath) { + for (auto iter = textureMap.begin(); iter != textureMap.end(); iter++) { + QString modifiedPath = resourcePath + iter.value().toString(); + textureMap[iter.key()] = modifiedPath; + } + } + } + + QString modelUrl = keyboardKeyValue["modelURL"].toString(); + QString url = (useResourcePath ? (resourcePath + modelUrl) : modelUrl); + + QVariantMap properties { + { "dimensions", keyboardKeyValue["dimensions"].toVariant() }, + { "position", keyboardKeyValue["position"].toVariant() }, + { "visible", false }, + { "isSolid", true }, + { "emissive", true }, + { "parentID", _anchor.overlayID }, + { "url", url }, + { "textures", textureMap }, + { "grabbable", false }, + { "localOrientation", keyboardKeyValue["localOrientation"].toVariant() } + }; + + OverlayID overlayID = overlays.addOverlay("model", properties); + + QString keyType = keyboardKeyValue["type"].toString(); + QString keyString = keyboardKeyValue["key"].toString(); + + Key key; + if (!keyType.isNull()) { + Key::Type type= Key::getKeyTypeFromString(keyType); + key.setKeyType(type); + + if (type == Key::Type::LAYER || type == Key::Type::CAPS) { + int switchToLayer = keyboardKeyValue["switchToLayer"].toInt(); + key.setSwitchToLayerIndex(switchToLayer); + } + } + key.setID(overlayID); + key.setKeyString(keyString); + key.saveDimensionsAndLocalPosition(); + + includeItems.append(key.getID()); + _itemsToIgnore.append(key.getID()); + keyboardLayerKeys.push_back(key); + } + + _keyboardLayers.push_back(keyboardLayerKeys); + + } + + TextDisplay textDisplay; + QJsonObject displayTextObject = jsonObject["textDisplay"].toObject(); + + QVariantMap displayTextProperties { + { "dimensions", displayTextObject["dimensions"].toVariant() }, + { "localPosition", displayTextObject["localPosition"].toVariant() }, + { "localOrientation", displayTextObject["localOrientation"].toVariant() }, + { "leftMargin", displayTextObject["leftMargin"].toVariant() }, + { "rightMargin", displayTextObject["rightMargin"].toVariant() }, + { "topMargin", displayTextObject["topMargin"].toVariant() }, + { "bottomMargin", displayTextObject["bottomMargin"].toVariant() }, + { "lineHeight", displayTextObject["lineHeight"].toVariant() }, + { "visible", false }, + { "emissive", true }, + { "grabbable", false }, + { "text", ""}, + { "parentID", _anchor.overlayID } + }; + + textDisplay.overlayID = overlays.addOverlay("text3d", displayTextProperties); + textDisplay.localPosition = vec3FromVariant(displayTextObject["localPosition"].toVariant()); + textDisplay.dimensions = vec3FromVariant(displayTextObject["dimensions"].toVariant()); + textDisplay.lineHeight = (float) displayTextObject["lineHeight"].toDouble(); + + _textDisplay = textDisplay; + + _ignoreItemsLock.withWriteLock([&] { + _itemsToIgnore.push_back(_textDisplay.overlayID); + _itemsToIgnore.push_back(_anchor.overlayID); + }); + _layerIndex = 0; + auto pointerManager = DependencyManager::get(); + pointerManager->setIncludeItems(_leftHandStylus, includeItems); + pointerManager->setIncludeItems(_rightHandStylus, includeItems); + }); + + request->send(); +} + +QVector Keyboard::getKeysID() { + return _ignoreItemsLock.resultWithReadLock>([&] { + return _itemsToIgnore; + }); +} + +void Keyboard::clearKeyboardKeys() { + Overlays& overlays = qApp->getOverlays(); + + for (const auto& keyboardLayer: _keyboardLayers) { + for (const Key& key: keyboardLayer) { + overlays.deleteOverlay(key.getID()); + } + } + + overlays.deleteOverlay(_anchor.overlayID); + overlays.deleteOverlay(_textDisplay.overlayID); + + _keyboardLayers.clear(); + + _ignoreItemsLock.withWriteLock([&] { + _itemsToIgnore.clear(); + }); +} + +void Keyboard::enableStylus() { + auto pointerManager = DependencyManager::get(); + pointerManager->setRenderState(_leftHandStylus, "events on"); + pointerManager->enablePointer(_leftHandStylus); + pointerManager->setRenderState(_rightHandStylus, "events on"); + pointerManager->enablePointer(_rightHandStylus); + +} diff --git a/interface/src/ui/Keyboard.h b/interface/src/ui/Keyboard.h new file mode 100644 index 0000000000..662a51c2da --- /dev/null +++ b/interface/src/ui/Keyboard.h @@ -0,0 +1,152 @@ +// +// Keyboard.h +// interface/src/scripting +// +// Created by Dante Ruiz on 2018-08-27. +// Copyright 2017 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_Keyboard_h +#define hifi_Keyboard_h + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "ui/overlays/Overlay.h" + +class PointerEvent; + + +class Key { +public: + Key() = default; + ~Key() = default; + + enum Type { + CHARACTER, + CAPS, + CLOSE, + LAYER, + BACKSPACE, + SPACE, + ENTER + }; + + + static Key::Type getKeyTypeFromString(const QString& keyTypeString); + + OverlayID getID() const { return _keyID; } + void setID(OverlayID overlayID) { _keyID = overlayID; } + + void startTimer(int time); + bool timerFinished(); + + void setKeyString(QString keyString) { _keyString = keyString; } + QString getKeyString(bool toUpper) const; + int getScanCode(bool toUpper) const; + + bool isPressed() const { return _pressed; } + void setIsPressed(bool pressed) { _pressed = pressed; } + + void setSwitchToLayerIndex(int layerIndex) { _switchToLayer = layerIndex; } + int getSwitchToLayerIndex() const { return _switchToLayer; } + + Type getKeyType() const { return _type; } + void setKeyType(Type type) { _type = type; } + + glm::vec3 getCurrentLocalPosition() const { return _currentLocalPosition; } + + void saveDimensionsAndLocalPosition(); + + void scaleKey(float sensorToWorldScale); +private: + Type _type { Type::CHARACTER }; + + int _switchToLayer { 0 }; + bool _pressed { false }; + + OverlayID _keyID; + QString _keyString; + + glm::vec3 _originalLocalPosition; + glm::vec3 _originalDimensions; + glm::vec3 _currentLocalPosition; + + std::shared_ptr _timer { std::make_shared() }; +}; + +class Keyboard : public Dependency, public QObject, public ReadWriteLockable { +public: + Keyboard(); + void createKeyboard(); + bool isRaised() const; + void setRaised(bool raised); + + bool isPassword() const; + void setPassword(bool password); + + void loadKeyboardFile(const QString& keyboardFile); + QVector getKeysID(); + +public slots: + void handleTriggerBegin(const OverlayID& overlayID, const PointerEvent& event); + void handleTriggerEnd(const OverlayID& overlayID, const PointerEvent& event); + void handleTriggerContinue(const OverlayID& overlayID, const PointerEvent& event); + void scaleKeyboard(float sensorToWorldScale); + +private: + struct Anchor { + OverlayID overlayID; + glm::vec3 originalDimensions; + }; + + struct TextDisplay { + float lineHeight; + OverlayID overlayID; + glm::vec3 localPosition; + glm::vec3 dimensions; + }; + + void raiseKeyboard(bool raise) const; + void raiseKeyboardAnchor(bool raise) const; + + void setLayerIndex(int layerIndex); + void enableStylus(); + void disableStylus(); + void clearKeyboardKeys(); + void switchToLayer(int layerIndex); + void updateTextDisplay(); + + void startLayerSwitchTimer(); + bool isLayerSwitchTimerFinished(); + + bool _raised { false }; + bool _password { false }; + bool _capsEnabled { false }; + int _layerIndex { 0 }; + unsigned int _leftHandStylus { 0 }; + unsigned int _rightHandStylus { 0 }; + SharedSoundPointer _keySound { nullptr }; + std::shared_ptr _layerSwitchTimer { std::make_shared() }; + + QString _typedCharacters; + TextDisplay _textDisplay; + Anchor _anchor; + + mutable ReadWriteLockable _ignoreItemsLock; + QVector _itemsToIgnore; + std::vector> _keyboardLayers; +}; + +#endif diff --git a/interface/src/ui/overlays/ModelOverlay.cpp b/interface/src/ui/overlays/ModelOverlay.cpp index 190d9c3895..fd3ff69691 100644 --- a/interface/src/ui/overlays/ModelOverlay.cpp +++ b/interface/src/ui/overlays/ModelOverlay.cpp @@ -766,4 +766,4 @@ render::ItemKey ModelOverlay::getKey() { builder.withMetaCullGroup(); } return builder.build(); -} \ No newline at end of file +} diff --git a/interface/src/ui/overlays/Overlays.cpp b/interface/src/ui/overlays/Overlays.cpp index f4c98bb9e0..35228c6247 100644 --- a/interface/src/ui/overlays/Overlays.cpp +++ b/interface/src/ui/overlays/Overlays.cpp @@ -35,6 +35,7 @@ #include "RectangleOverlay.h" #include "Text3DOverlay.h" #include "Web3DOverlay.h" +#include "ui/Keyboard.h" #include #include @@ -868,8 +869,13 @@ void Overlays::mousePressPointerEvent(const OverlayID& overlayID, const PointerE QMetaObject::invokeMethod(thisOverlay.get(), "handlePointerEvent", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit mousePressOnOverlay(overlayID, event); + + auto keyboard = DependencyManager::get(); + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit mousePressOnOverlay(overlayID, event); + } } bool Overlays::mouseDoublePressEvent(QMouseEvent* event) { @@ -902,8 +908,12 @@ void Overlays::hoverEnterPointerEvent(const OverlayID& overlayID, const PointerE QMetaObject::invokeMethod(thisOverlay.get(), "hoverEnterOverlay", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit hoverEnterOverlay(overlayID, event); + auto keyboard = DependencyManager::get(); + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit hoverEnterOverlay(overlayID, event); + } } void Overlays::hoverOverPointerEvent(const OverlayID& overlayID, const PointerEvent& event) { @@ -917,8 +927,12 @@ void Overlays::hoverOverPointerEvent(const OverlayID& overlayID, const PointerEv QMetaObject::invokeMethod(thisOverlay.get(), "handlePointerEvent", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit hoverOverOverlay(overlayID, event); + auto keyboard = DependencyManager::get(); + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit hoverOverOverlay(overlayID, event); + } } void Overlays::hoverLeavePointerEvent(const OverlayID& overlayID, const PointerEvent& event) { @@ -932,8 +946,12 @@ void Overlays::hoverLeavePointerEvent(const OverlayID& overlayID, const PointerE QMetaObject::invokeMethod(thisOverlay.get(), "hoverLeaveOverlay", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit hoverLeaveOverlay(overlayID, event); + auto keyboard = DependencyManager::get(); + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit hoverLeaveOverlay(overlayID, event); + } } bool Overlays::mouseReleaseEvent(QMouseEvent* event) { @@ -962,8 +980,12 @@ void Overlays::mouseReleasePointerEvent(const OverlayID& overlayID, const Pointe QMetaObject::invokeMethod(thisOverlay.get(), "handlePointerEvent", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit mouseReleaseOnOverlay(overlayID, event); + auto keyboard = DependencyManager::get(); + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit mouseReleaseOnOverlay(overlayID, event); + } } bool Overlays::mouseMoveEvent(QMouseEvent* event) { @@ -1014,8 +1036,13 @@ void Overlays::mouseMovePointerEvent(const OverlayID& overlayID, const PointerEv QMetaObject::invokeMethod(thisOverlay.get(), "handlePointerEvent", Q_ARG(PointerEvent, event)); } - // emit to scripts - emit mouseMoveOnOverlay(overlayID, event); + auto keyboard = DependencyManager::get(); + + // Do not send keyboard key event to scripts to prevent malignant scripts from gathering what you typed + if (!keyboard->getKeysID().contains(overlayID)) { + // emit to scripts + emit mouseMoveOnOverlay(overlayID, event); + } } QVector Overlays::findOverlays(const glm::vec3& center, float radius) { @@ -1035,7 +1062,7 @@ QVector Overlays::findOverlays(const glm::vec3& center, float radius) { OverlayID thisID = i.key(); auto overlay = std::dynamic_pointer_cast(i.value()); // FIXME: this ignores overlays with ignorePickIntersection == true, which seems wrong - if (overlay && overlay->getVisible() && !overlay->getIgnorePickIntersection() && overlay->isLoaded()) { + if (overlay && overlay->getVisible() && overlay->isLoaded()) { // get AABox in frame of overlay glm::vec3 dimensions = overlay->getDimensions(); glm::vec3 low = dimensions * -0.5f; diff --git a/interface/src/ui/overlays/Web3DOverlay.cpp b/interface/src/ui/overlays/Web3DOverlay.cpp index f13d25f22c..e7a0c5934e 100644 --- a/interface/src/ui/overlays/Web3DOverlay.cpp +++ b/interface/src/ui/overlays/Web3DOverlay.cpp @@ -41,6 +41,7 @@ #include "scripting/AssetMappingsScriptingInterface.h" #include "scripting/MenuScriptingInterface.h" #include "scripting/SettingsScriptingInterface.h" +#include "scripting/KeyboardScriptingInterface.h" #include #include #include @@ -273,6 +274,7 @@ void Web3DOverlay::setupQmlSurface(bool isTablet) { _webSurface->getSurfaceContext()->setContextProperty("HiFiAbout", AboutUtil::getInstance()); _webSurface->getSurfaceContext()->setContextProperty("WalletScriptingInterface", DependencyManager::get().data()); _webSurface->getSurfaceContext()->setContextProperty("ResourceRequestObserver", DependencyManager::get().data()); + _webSurface->getSurfaceContext()->setContextProperty("KeyboardScriptingInterface", DependencyManager::get().data()); // Override min fps for tablet UI, for silky smooth scrolling setMaxFPS(90); diff --git a/libraries/render-utils/src/Model.cpp b/libraries/render-utils/src/Model.cpp index 6f285a9f0c..7da7a45e83 100644 --- a/libraries/render-utils/src/Model.cpp +++ b/libraries/render-utils/src/Model.cpp @@ -1180,9 +1180,9 @@ int Model::getLastFreeJointIndex(int jointIndex) const { void Model::setTextures(const QVariantMap& textures) { if (isLoaded()) { - _pendingTextures.clear(); _needsFixupInScene = true; _renderGeometry->setTextures(textures); + _pendingTextures.clear(); } else { _pendingTextures = textures; } diff --git a/libraries/ui/src/ui/TabletScriptingInterface.cpp b/libraries/ui/src/ui/TabletScriptingInterface.cpp index 1081f8c4e7..52d359ad0d 100644 --- a/libraries/ui/src/ui/TabletScriptingInterface.cpp +++ b/libraries/ui/src/ui/TabletScriptingInterface.cpp @@ -888,6 +888,12 @@ void TabletProxy::desktopWindowClosed() { gotoHomeScreen(); } +void TabletProxy::unfocus() { + if (_qmlOffscreenSurface) { + _qmlOffscreenSurface->lowerKeyboard(); + } +} + QQuickItem* TabletProxy::getQmlTablet() const { if (!_qmlTabletRoot) { diff --git a/libraries/ui/src/ui/TabletScriptingInterface.h b/libraries/ui/src/ui/TabletScriptingInterface.h index 2d37402d01..9821ad1263 100644 --- a/libraries/ui/src/ui/TabletScriptingInterface.h +++ b/libraries/ui/src/ui/TabletScriptingInterface.h @@ -232,6 +232,7 @@ public: const QString getName() const { return _name; } bool getToolbarMode() const { return _toolbarMode; } void setToolbarMode(bool toolbarMode); + void unfocus(); /**jsdoc * @function TabletProxy#gotoMenuScreen diff --git a/scripts/system/controllers/controllerModules/inEditMode.js b/scripts/system/controllers/controllerModules/inEditMode.js index 2b17f447a0..6adfa88fb2 100644 --- a/scripts/system/controllers/controllerModules/inEditMode.js +++ b/scripts/system/controllers/controllerModules/inEditMode.js @@ -175,6 +175,23 @@ Script.include("/~/system/libraries/utils.js"); return this.exitModule(); } } + + var stopRunning = false; + + if ((controllerData.triggerClicks[this.hand] === 0 && controllerData.secondaryValues[this.hand] === 0)) { + var stopRunning = false; + controllerData.nearbyOverlayIDs[this.hand].forEach(function(overlayID) { + var overlayName = Overlays.getProperty(overlayID, "name"); + if (overlayName === "KeyboardAnchor") { + stopRunning = true; + } + }); + + if (stopRunning) { + return this.exitModule(); + } + } + this.sendPickData(controllerData); return this.isReady(controllerData); }; diff --git a/scripts/system/controllers/controllerModules/nearParentGrabOverlay.js b/scripts/system/controllers/controllerModules/nearParentGrabOverlay.js index 763a0a0a27..9bddeb236a 100644 --- a/scripts/system/controllers/controllerModules/nearParentGrabOverlay.js +++ b/scripts/system/controllers/controllerModules/nearParentGrabOverlay.js @@ -46,6 +46,10 @@ Script.include("/~/system/libraries/utils.js"); return this.getOtherModule().thisHandIsParent(props); }; + this.isGrabbedThingVisible = function() { + return Overlays.getProperty(this.grabbedThingID, "visible"); + }; + this.thisHandIsParent = function(props) { if (props.parentID !== MyAvatar.sessionUUID && props.parentID !== MyAvatar.SELF_ID) { return false; @@ -198,7 +202,7 @@ Script.include("/~/system/libraries/utils.js"); }; this.run = function (controllerData) { - if (controllerData.triggerClicks[this.hand] === 0 && controllerData.secondaryValues[this.hand] === 0) { + if ((controllerData.triggerClicks[this.hand] === 0 && controllerData.secondaryValues[this.hand] === 0) || !this.isGrabbedThingVisible()) { this.endNearParentingGrabOverlay(); this.robbed = false; return makeRunningValues(false, [], []); diff --git a/scripts/system/marketplaces/marketplaces.js b/scripts/system/marketplaces/marketplaces.js index 8bfd776971..74c1e4baf0 100644 --- a/scripts/system/marketplaces/marketplaces.js +++ b/scripts/system/marketplaces/marketplaces.js @@ -726,9 +726,14 @@ var onTabletScreenChanged = function onTabletScreenChanged(type, url) { filterText = ""; } + var wasIsOpen = ui.isOpen; ui.isOpen = (onMarketplaceScreen || onCommerceScreen) && !onWalletScreen; ui.buttonActive(ui.isOpen); + if (wasIsOpen !== ui.isOpen && Keyboard.raised) { + Keyboard.raised = false; + } + if (type === "Web" && url.indexOf(MARKETPLACE_URL) !== -1) { ContextOverlay.isInMarketplaceInspectionMode = true; } else { From f664421fe07ddb4603cccbf36ba862fc44141d59 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Wed, 31 Oct 2018 17:44:20 -0700 Subject: [PATCH 33/34] addressing code review feedback --- interface/src/Menu.cpp | 2 +- .../src/raypick/PointerScriptingInterface.cpp | 10 +- interface/src/raypick/RayPick.cpp | 6 +- interface/src/raypick/StylusPick.cpp | 4 +- interface/src/raypick/StylusPick.h | 3 - interface/src/raypick/StylusPointer.cpp | 4 +- interface/src/ui/Keyboard.cpp | 262 +++++++++--------- interface/src/ui/Keyboard.h | 4 +- interface/src/ui/overlays/Overlays.cpp | 7 +- libraries/shared/src/RegisteredMetaTypes.h | 10 +- 10 files changed, 160 insertions(+), 152 deletions(-) diff --git a/interface/src/Menu.cpp b/interface/src/Menu.cpp index 2ca997a1fc..1fc1e0c033 100644 --- a/interface/src/Menu.cpp +++ b/interface/src/Menu.cpp @@ -356,7 +356,7 @@ Menu::Menu() { qApp->setHmdTabletBecomesToolbarSetting(action->isChecked()); }); - addCheckableActionToQMenuAndActionHash(uiOptionsMenu, MenuOption::Use3DKeyboard, 0, false); + addCheckableActionToQMenuAndActionHash(uiOptionsMenu, MenuOption::Use3DKeyboard, 0, true); // Developer > Render >>> MenuWrapper* renderOptionsMenu = developerMenu->addMenu("Render"); diff --git a/interface/src/raypick/PointerScriptingInterface.cpp b/interface/src/raypick/PointerScriptingInterface.cpp index f0edb4d9ef..1893132917 100644 --- a/interface/src/raypick/PointerScriptingInterface.cpp +++ b/interface/src/raypick/PointerScriptingInterface.cpp @@ -56,8 +56,16 @@ unsigned int PointerScriptingInterface::createPointer(const PickQuery::PickType& * @property {boolean} [hover=false] If this pointer should generate hover events. * @property {boolean} [enabled=false] * @property {Vec3} [tipOffset] The specified offset of the from the joint index. - * @property {object} [model] Data to replace the default model url, positionOffset and rotationOffset. + * @property {Pointers.StylusPointerProperties.model} [model] Data to replace the default model url, positionOffset and rotationOffset. {@link Pointers.StylusPointerProperties.model} */ + /**jsdoc + * properties defining stylus pick model that can be included to {@link Pointers.StylusPointerProperties} + * @typedef {object} Pointers.StylusPointerProperties.model + * @property {string} [url] url to the model + * @property {Vec3} [dimensions] the dimensions of the model + * @property {Vec3} [positionOffset] the position offset of the model from the parent joint index + * @property {Vec3} [rotationOffset] the rotation offset of the model from the parent joint index + */ unsigned int PointerScriptingInterface::createStylus(const QVariant& properties) const { QVariantMap propertyMap = properties.toMap(); diff --git a/interface/src/raypick/RayPick.cpp b/interface/src/raypick/RayPick.cpp index b6adba4a12..a48d858504 100644 --- a/interface/src/raypick/RayPick.cpp +++ b/interface/src/raypick/RayPick.cpp @@ -10,7 +10,6 @@ #include "Application.h" #include "EntityScriptingInterface.h" #include "ui/overlays/Overlays.h" -#include "ui/Keyboard.h" #include "avatar/AvatarManager.h" #include "scripting/HMDScriptingInterface.h" #include "DependencyManager.h" @@ -41,12 +40,9 @@ PickResultPointer RayPick::getEntityIntersection(const PickRay& pick) { PickResultPointer RayPick::getOverlayIntersection(const PickRay& pick) { bool precisionPicking = !(getFilter().doesPickCoarse() || DependencyManager::get()->getForceCoarsePicking()); - auto keyboard = DependencyManager::get(); - QVector ignoreItems = keyboard->getKeysID(); - ignoreItems.append(getIgnoreItemsAs()); RayToOverlayIntersectionResult overlayRes = qApp->getOverlays().findRayIntersectionVector(pick, precisionPicking, - getIncludeItemsAs(), ignoreItems, !getFilter().doesPickInvisible(), !getFilter().doesPickNonCollidable()); + getIncludeItemsAs(), getIgnoreItemsAs(), !getFilter().doesPickInvisible(), !getFilter().doesPickNonCollidable()); if (overlayRes.intersects) { return std::make_shared(IntersectionType::OVERLAY, overlayRes.overlayID, overlayRes.distance, overlayRes.intersection, pick, overlayRes.surfaceNormal, overlayRes.extraInfo); } else { diff --git a/interface/src/raypick/StylusPick.cpp b/interface/src/raypick/StylusPick.cpp index 0416563272..0a76180be8 100644 --- a/interface/src/raypick/StylusPick.cpp +++ b/interface/src/raypick/StylusPick.cpp @@ -61,7 +61,7 @@ bool StylusPickResult::checkOrFilterAgainstMaxDistance(float maxDistance) { } StylusPick::StylusPick(Side side, const PickFilter& filter, float maxDistance, bool enabled, const glm::vec3& tipOffset) : - Pick(StylusTip(side), filter, maxDistance, enabled), _tipOffset(tipOffset) + Pick(StylusTip(side, tipOffset), filter, maxDistance, enabled) { } @@ -127,7 +127,7 @@ StylusTip StylusPick::getMathematicalPick() const { if (qApp->getPreferAvatarFingerOverStylus()) { result = getFingerWorldLocation(_mathPick.side); } else { - result = getControllerWorldLocation(_mathPick.side, _tipOffset); + result = getControllerWorldLocation(_mathPick.side, _mathPick.tipOffset); } return result; } diff --git a/interface/src/raypick/StylusPick.h b/interface/src/raypick/StylusPick.h index 3e0ee452e9..14821c0ce5 100644 --- a/interface/src/raypick/StylusPick.h +++ b/interface/src/raypick/StylusPick.h @@ -73,9 +73,6 @@ public: bool isMouse() const override { return false; } static float WEB_STYLUS_LENGTH; - -private: - glm::vec3 _tipOffset; }; #endif // hifi_StylusPick_h diff --git a/interface/src/raypick/StylusPointer.cpp b/interface/src/raypick/StylusPointer.cpp index caa3151cc5..5595c54b71 100644 --- a/interface/src/raypick/StylusPointer.cpp +++ b/interface/src/raypick/StylusPointer.cpp @@ -45,7 +45,7 @@ StylusPointer::~StylusPointer() { OverlayID StylusPointer::buildStylusOverlay(const QVariantMap& properties) { QVariantMap overlayProperties; - // TODO: make these configurable per pointe + QString modelUrl = DEFAULT_STYLUS_MODEL_URL; if (properties["model"].isValid()) { @@ -55,7 +55,7 @@ OverlayID StylusPointer::buildStylusOverlay(const QVariantMap& properties) { modelUrl = modelData["url"].toString(); } } - + // TODO: make these configurable per pointer overlayProperties["name"] = "stylus"; overlayProperties["url"] = modelUrl; overlayProperties["loadPriority"] = 10.0f; diff --git a/interface/src/ui/Keyboard.cpp b/interface/src/ui/Keyboard.cpp index 677d384a17..c00ea007f0 100644 --- a/interface/src/ui/Keyboard.cpp +++ b/interface/src/ui/Keyboard.cpp @@ -316,8 +316,8 @@ void Keyboard::scaleKeyboard(float sensorToWorldScale) { } for (auto& keyboardLayer: _keyboardLayers) { - for (auto& key: keyboardLayer) { - key.scaleKey(sensorToWorldScale); + for (auto iter = keyboardLayer.begin(); iter != keyboardLayer.end(); iter++) { + iter.value().scaleKey(sensorToWorldScale); } } @@ -354,8 +354,9 @@ void Keyboard::raiseKeyboard(bool raise) const { return; } Overlays& overlays = qApp->getOverlays(); - for (const auto& key: _keyboardLayers[_layerIndex]) { - auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(key.getID())); + const auto& keyboardLayer = _keyboardLayers[_layerIndex]; + for (auto iter = keyboardLayer.begin(); iter != keyboardLayer.end(); iter++) { + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(iter.key())); if (base3DOverlay) { base3DOverlay->setVisible(raise); } @@ -419,87 +420,90 @@ void Keyboard::handleTriggerBegin(const OverlayID& overlayID, const PointerEvent auto pointerID = event.getID(); auto buttonType = event.getButton(); - for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { - Key& key = *index; - if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus) && - buttonType == PointerEvent::PrimaryButton) { + if ((pointerID != _leftHandStylus && pointerID != _rightHandStylus) || buttonType != PointerEvent::PrimaryButton) { + return; + } + auto& keyboardLayer = _keyboardLayers[_layerIndex]; + auto search = keyboardLayer.find(overlayID); - if (key.timerFinished()) { + if (search == keyboardLayer.end()) { + return; + } - auto handIndex = (pointerID == _leftHandStylus) ? controller::Hand::LEFT : controller::Hand::RIGHT; - auto userInputMapper = DependencyManager::get(); - userInputMapper->triggerHapticPulse(PULSE_STRENGTH, PULSE_DURATION, handIndex); + Key& key = search.value(); - Overlays& overlays = qApp->getOverlays(); - auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + if (key.timerFinished()) { - glm::vec3 keyWorldPosition; - if (base3DOverlay) { - keyWorldPosition = base3DOverlay->getWorldPosition(); - } + auto handIndex = (pointerID == _leftHandStylus) ? controller::Hand::LEFT : controller::Hand::RIGHT; + auto userInputMapper = DependencyManager::get(); + userInputMapper->triggerHapticPulse(PULSE_STRENGTH, PULSE_DURATION, handIndex); - AudioInjectorOptions audioOptions; - audioOptions.localOnly = true; - audioOptions.position = keyWorldPosition; - audioOptions.volume = 0.4f; + Overlays& overlays = qApp->getOverlays(); + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); - AudioInjector::playSound(_keySound->getByteArray(), audioOptions); - - int scanCode = key.getScanCode(_capsEnabled); - QString keyString = key.getKeyString(_capsEnabled); - - auto tablet = DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system"); - - switch (key.getKeyType()) { - case Key::Type::CLOSE: - setRaised(false); - tablet->unfocus(); - return; - - case Key::Type::CAPS: - _capsEnabled = !_capsEnabled; - switchToLayer(key.getSwitchToLayerIndex()); - return; - case Key::Type::LAYER: - _capsEnabled = false; - switchToLayer(key.getSwitchToLayerIndex()); - return; - case Key::Type::BACKSPACE: - scanCode = Qt::Key_Backspace; - keyString = "\x08"; - _typedCharacters = _typedCharacters.left(_typedCharacters.length() -1); - updateTextDisplay(); - break; - case Key::Type::ENTER: - scanCode = Qt::Key_Return; - keyString = "\x0d"; - _typedCharacters.clear(); - updateTextDisplay(); - break; - case Key::Type::CHARACTER: - if (keyString != " ") { - _typedCharacters.push_back((_password ? "*" : keyString)); - } else { - _typedCharacters.clear(); - } - updateTextDisplay(); - break; - - default: - break; - } - - QKeyEvent* pressEvent = new QKeyEvent(QEvent::KeyPress, scanCode, Qt::NoModifier, keyString); - QKeyEvent* releaseEvent = new QKeyEvent(QEvent::KeyRelease, scanCode, Qt::NoModifier, keyString); - QCoreApplication::postEvent(QCoreApplication::instance(), pressEvent); - QCoreApplication::postEvent(QCoreApplication::instance(), releaseEvent); - - key.startTimer(KEY_PRESS_TIMEOUT_MS); - } - - break; + glm::vec3 keyWorldPosition; + if (base3DOverlay) { + keyWorldPosition = base3DOverlay->getWorldPosition(); } + + AudioInjectorOptions audioOptions; + audioOptions.localOnly = true; + audioOptions.position = keyWorldPosition; + audioOptions.volume = 0.4f; + + AudioInjector::playSound(_keySound->getByteArray(), audioOptions); + + int scanCode = key.getScanCode(_capsEnabled); + QString keyString = key.getKeyString(_capsEnabled); + + auto tablet = DependencyManager::get()->getTablet("com.highfidelity.interface.tablet.system"); + + switch (key.getKeyType()) { + case Key::Type::CLOSE: + setRaised(false); + tablet->unfocus(); + return; + + case Key::Type::CAPS: + _capsEnabled = !_capsEnabled; + switchToLayer(key.getSwitchToLayerIndex()); + return; + case Key::Type::LAYER: + _capsEnabled = false; + switchToLayer(key.getSwitchToLayerIndex()); + return; + case Key::Type::BACKSPACE: + scanCode = Qt::Key_Backspace; + keyString = "\x08"; + _typedCharacters = _typedCharacters.left(_typedCharacters.length() -1); + updateTextDisplay(); + break; + case Key::Type::ENTER: + scanCode = Qt::Key_Return; + keyString = "\x0d"; + _typedCharacters.clear(); + updateTextDisplay(); + break; + case Key::Type::CHARACTER: + if (keyString != " ") { + _typedCharacters.push_back((_password ? "*" : keyString)); + } else { + _typedCharacters.clear(); + } + updateTextDisplay(); + break; + + default: + break; + } + + QKeyEvent* pressEvent = new QKeyEvent(QEvent::KeyPress, scanCode, Qt::NoModifier, keyString); + QKeyEvent* releaseEvent = new QKeyEvent(QEvent::KeyRelease, scanCode, Qt::NoModifier, keyString); + QCoreApplication::postEvent(QCoreApplication::instance(), pressEvent); + QCoreApplication::postEvent(QCoreApplication::instance(), releaseEvent); + + key.startTimer(KEY_PRESS_TIMEOUT_MS); } } @@ -509,27 +513,29 @@ void Keyboard::handleTriggerEnd(const OverlayID& overlayID, const PointerEvent& } auto pointerID = event.getID(); - - for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { - Key& key = *index; - - if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus)) { - Overlays& overlays = qApp->getOverlays(); - - auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); - - if (base3DOverlay) { - base3DOverlay->setLocalPosition(key.getCurrentLocalPosition()); - } - - key.setIsPressed(false); - if (key.timerFinished()) { - key.startTimer(KEY_PRESS_TIMEOUT_MS); - } - break; - } + if (pointerID != _leftHandStylus && pointerID != _rightHandStylus) { + return; } + auto& keyboardLayer = _keyboardLayers[_layerIndex]; + auto search = keyboardLayer.find(overlayID); + + if (search == keyboardLayer.end()) { + return; + } + + Key& key = search.value();; + Overlays& overlays = qApp->getOverlays(); + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + + if (base3DOverlay) { + base3DOverlay->setLocalPosition(key.getCurrentLocalPosition()); + } + + key.setIsPressed(false); + if (key.timerFinished()) { + key.startTimer(KEY_PRESS_TIMEOUT_MS); + } } void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEvent& event) { @@ -539,44 +545,40 @@ void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEv auto pointerID = event.getID(); - for (auto index = _keyboardLayers[_layerIndex].begin(); index != _keyboardLayers[_layerIndex].end(); index++) { - Key& key = *index; + if (pointerID != _leftHandStylus && pointerID != _rightHandStylus) { + return; + } - if (key.getID() == overlayID && (pointerID == _leftHandStylus || pointerID == _rightHandStylus)) { - Overlays& overlays = qApp->getOverlays(); + auto& keyboardLayer = _keyboardLayers[_layerIndex]; + auto search = keyboardLayer.find(overlayID); - if (!key.isPressed()) { - auto pointerManager = DependencyManager::get(); - auto pickResult = pointerManager->getPrevPickResult(pointerID); + if (search == keyboardLayer.end()) { + return; + } - auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); + Key& key = search.value(); + Overlays& overlays = qApp->getOverlays(); - if (base3DOverlay) { - auto pickResultVariant = pickResult->toVariantMap(); - auto stylusTipVariant = pickResultVariant["stylusTip"]; - auto stylusTipPositionVariant = stylusTipVariant.toMap()["position"]; - glm::vec3 stylusTipPosition = vec3FromVariant(stylusTipPositionVariant); + if (!key.isPressed()) { + auto base3DOverlay = std::dynamic_pointer_cast(overlays.getOverlay(overlayID)); - glm::quat overlayOrientation = base3DOverlay->getWorldOrientation(); - glm::vec3 overlayPosition = base3DOverlay->getWorldPosition(); + if (base3DOverlay) { + auto pointerManager = DependencyManager::get(); + auto pickResult = pointerManager->getPrevPickResult(pointerID); + auto pickResultVariant = pickResult->toVariantMap(); - glm::mat4 overlayWorldMat = createMatFromQuatAndPos(overlayOrientation, overlayPosition); - glm::mat4 overlayWorldToLocalMat = glm::inverse(overlayWorldMat); + float distance = pickResultVariant["distance"].toFloat(); - glm::vec3 stylusTipInOverlaySpace = transformPoint(overlayWorldToLocalMat, stylusTipPosition); - - static const float PENATRATION_THRESHOLD = 0.025f; - if (stylusTipInOverlaySpace.z < PENATRATION_THRESHOLD) { - static const float Z_OFFSET = 0.002f; - glm::vec3 overlayYAxis = overlayOrientation * Z_AXIS; - glm::vec3 overlayYOffset = overlayYAxis * Z_OFFSET; - glm::vec3 localPosition = key.getCurrentLocalPosition() - overlayYOffset; - base3DOverlay->setLocalPosition(localPosition); - key.setIsPressed(true); - } - } + static const float PENATRATION_THRESHOLD = 0.025f; + if (distance < PENATRATION_THRESHOLD) { + static const float Z_OFFSET = 0.002f; + glm::quat overlayOrientation = base3DOverlay->getWorldOrientation(); + glm::vec3 overlayYAxis = overlayOrientation * Z_AXIS; + glm::vec3 overlayYOffset = overlayYAxis * Z_OFFSET; + glm::vec3 localPosition = key.getCurrentLocalPosition() - overlayYOffset; + base3DOverlay->setLocalPosition(localPosition); + key.setIsPressed(true); } - break; } } } @@ -666,7 +668,7 @@ void Keyboard::loadKeyboardFile(const QString& keyboardFile) { for (int keyboardLayerIndex = 0; keyboardLayerIndex < keyboardLayerCount; keyboardLayerIndex++) { const QJsonValue& keyboardLayer = keyboardLayers[keyboardLayerIndex].toArray(); - std::vector keyboardLayerKeys; + QHash keyboardLayerKeys; foreach (const QJsonValue& keyboardKeyValue, keyboardLayer.toArray()) { QVariantMap textureMap; @@ -718,7 +720,7 @@ void Keyboard::loadKeyboardFile(const QString& keyboardFile) { includeItems.append(key.getID()); _itemsToIgnore.append(key.getID()); - keyboardLayerKeys.push_back(key); + keyboardLayerKeys.insert(overlayID, key); } _keyboardLayers.push_back(keyboardLayerKeys); @@ -774,8 +776,8 @@ void Keyboard::clearKeyboardKeys() { Overlays& overlays = qApp->getOverlays(); for (const auto& keyboardLayer: _keyboardLayers) { - for (const Key& key: keyboardLayer) { - overlays.deleteOverlay(key.getID()); + for (auto iter = keyboardLayer.begin(); iter != keyboardLayer.end(); iter++) { + overlays.deleteOverlay(iter.key()); } } diff --git a/interface/src/ui/Keyboard.h b/interface/src/ui/Keyboard.h index 662a51c2da..2a29a12961 100644 --- a/interface/src/ui/Keyboard.h +++ b/interface/src/ui/Keyboard.h @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -43,7 +44,6 @@ public: ENTER }; - static Key::Type getKeyTypeFromString(const QString& keyTypeString); OverlayID getID() const { return _keyID; } @@ -146,7 +146,7 @@ private: mutable ReadWriteLockable _ignoreItemsLock; QVector _itemsToIgnore; - std::vector> _keyboardLayers; + std::vector> _keyboardLayers; }; #endif diff --git a/interface/src/ui/overlays/Overlays.cpp b/interface/src/ui/overlays/Overlays.cpp index 35228c6247..7593e12e07 100644 --- a/interface/src/ui/overlays/Overlays.cpp +++ b/interface/src/ui/overlays/Overlays.cpp @@ -583,7 +583,7 @@ ParabolaToOverlayIntersectionResult Overlays::findParabolaIntersectionVector(con bool visibleOnly, bool collidableOnly) { float bestDistance = std::numeric_limits::max(); bool bestIsFront = false; - + const QVector keyboardKeysToDiscard = DependencyManager::get()->getKeysID(); QMutexLocker locker(&_mutex); ParabolaToOverlayIntersectionResult result; QMapIterator i(_overlaysWorld); @@ -593,7 +593,8 @@ ParabolaToOverlayIntersectionResult Overlays::findParabolaIntersectionVector(con auto thisOverlay = std::dynamic_pointer_cast(i.value()); if ((overlaysToDiscard.size() > 0 && overlaysToDiscard.contains(thisID)) || - (overlaysToInclude.size() > 0 && !overlaysToInclude.contains(thisID))) { + (overlaysToInclude.size() > 0 && !overlaysToInclude.contains(thisID)) || + (keyboardKeysToDiscard.size() > 0 && keyboardKeysToDiscard.contains(thisID))) { continue; } @@ -1061,7 +1062,7 @@ QVector Overlays::findOverlays(const glm::vec3& center, float radius) { i.next(); OverlayID thisID = i.key(); auto overlay = std::dynamic_pointer_cast(i.value()); - // FIXME: this ignores overlays with ignorePickIntersection == true, which seems wrong + if (overlay && overlay->getVisible() && overlay->isLoaded()) { // get AABox in frame of overlay glm::vec3 dimensions = overlay->getDimensions(); diff --git a/libraries/shared/src/RegisteredMetaTypes.h b/libraries/shared/src/RegisteredMetaTypes.h index 64a874f63d..18c1339223 100644 --- a/libraries/shared/src/RegisteredMetaTypes.h +++ b/libraries/shared/src/RegisteredMetaTypes.h @@ -269,6 +269,7 @@ void pickRayFromScriptValue(const QScriptValue& object, PickRay& pickRay); * * @typedef {object} StylusTip * @property {number} side - The hand the tip is attached to: 0 for left, 1 for right. + * @property {Vec3} tipOffset - the position offset of the stylus tip. * @property {Vec3} position - The position of the stylus tip. * @property {Quat} orientation - The orientation of the stylus tip. * @property {Vec3} velocity - The velocity of the stylus tip. @@ -276,12 +277,14 @@ void pickRayFromScriptValue(const QScriptValue& object, PickRay& pickRay); class StylusTip : public MathPick { public: StylusTip() : position(NAN), velocity(NAN) {} - StylusTip(const bilateral::Side& side, const glm::vec3& position = Vectors::ZERO, const glm::quat& orientation = Quaternions::IDENTITY, const glm::vec3& velocity = Vectors::ZERO) : - side(side), position(position), orientation(orientation), velocity(velocity) {} + StylusTip(const bilateral::Side& side, const glm::vec3& tipOffset = Vectors::ZERO ,const glm::vec3& position = Vectors::ZERO, + const glm::quat& orientation = Quaternions::IDENTITY, const glm::vec3& velocity = Vectors::ZERO) : + side(side), tipOffset(tipOffset), position(position), orientation(orientation), velocity(velocity) {} StylusTip(const QVariantMap& pickVariant) : side(bilateral::Side(pickVariant["side"].toInt())), position(vec3FromVariant(pickVariant["position"])), orientation(quatFromVariant(pickVariant["orientation"])), velocity(vec3FromVariant(pickVariant["velocity"])) {} bilateral::Side side { bilateral::Side::Invalid }; + glm::vec3 tipOffset; glm::vec3 position; glm::quat orientation; glm::vec3 velocity; @@ -289,12 +292,13 @@ public: operator bool() const override { return side != bilateral::Side::Invalid; } bool operator==(const StylusTip& other) const { - return (side == other.side && position == other.position && orientation == other.orientation && velocity == other.velocity); + return (side == other.side && tipOffset == other.tipOffset && position == other.position && orientation == other.orientation && velocity == other.velocity); } QVariantMap toVariantMap() const override { QVariantMap stylusTip; stylusTip["side"] = (int)side; + stylusTip["tipOffset"] = vec3toVariant(tipOffset); stylusTip["position"] = vec3toVariant(position); stylusTip["orientation"] = quatToVariant(orientation); stylusTip["velocity"] = vec3toVariant(velocity); From 358aa547b17a6d6b578743d79c6cf571b5f389c3 Mon Sep 17 00:00:00 2001 From: Dante Ruiz Date: Mon, 5 Nov 2018 16:30:30 -0800 Subject: [PATCH 34/34] feedback changes --- interface/resources/config/keyboard.json | 4 +- .../resources/qml/controls/TabletWebView.qml | 4 + .../qml/dialogs/TabletLoginDialog.qml | 16 +++ .../qml/hifi/commerce/wallet/Wallet.qml | 2 +- interface/src/Application.cpp | 2 + .../src/raypick/PointerScriptingInterface.cpp | 4 +- interface/src/ui/Keyboard.cpp | 97 +++++++++++++++++-- interface/src/ui/Keyboard.h | 3 + interface/src/ui/PreferencesDialog.cpp | 1 - libraries/shared/src/RegisteredMetaTypes.h | 4 +- 10 files changed, 121 insertions(+), 16 deletions(-) diff --git a/interface/resources/config/keyboard.json b/interface/resources/config/keyboard.json index ba113da1f5..186a9c1084 100644 --- a/interface/resources/config/keyboard.json +++ b/interface/resources/config/keyboard.json @@ -31,8 +31,8 @@ "localOrientation": { "w": 0.000, "x": 0.000, - "y": 0.976, - "z": 0.216 + "y": 0.906, + "z": 0.423 }, "leftMargin": 0.0, "rightMargin": 0.0, diff --git a/interface/resources/qml/controls/TabletWebView.qml b/interface/resources/qml/controls/TabletWebView.qml index 0c5ca37e00..94f4c7978c 100644 --- a/interface/resources/qml/controls/TabletWebView.qml +++ b/interface/resources/qml/controls/TabletWebView.qml @@ -195,6 +195,10 @@ Item { keyboardEnabled = HMD.active; } + Component.onDestruction: { + keyboardRaised = false; + } + Keys.onPressed: { switch(event.key) { case Qt.Key_L: diff --git a/interface/resources/qml/dialogs/TabletLoginDialog.qml b/interface/resources/qml/dialogs/TabletLoginDialog.qml index 08aa903ae9..dad2bb91aa 100644 --- a/interface/resources/qml/dialogs/TabletLoginDialog.qml +++ b/interface/resources/qml/dialogs/TabletLoginDialog.qml @@ -95,6 +95,18 @@ TabletModalWindow { } } + Timer { + id: keyboardTimer + repeat: false + interval: 200 + + onTriggered: { + if (MenuInterface.isOptionChecked("Use 3D Keyboard")) { + KeyboardScriptingInterface.raised = true; + } + } + } + TabletModalFrame { id: mfRoot @@ -131,6 +143,10 @@ TabletModalWindow { loginKeyboard.raised = false; } + Component.onCompleted: { + keyboardTimer.start(); + } + Keyboard { id: loginKeyboard raised: root.keyboardEnabled && root.keyboardRaised diff --git a/interface/resources/qml/hifi/commerce/wallet/Wallet.qml b/interface/resources/qml/hifi/commerce/wallet/Wallet.qml index 874c1c53b7..81fec4ace3 100644 --- a/interface/resources/qml/hifi/commerce/wallet/Wallet.qml +++ b/interface/resources/qml/hifi/commerce/wallet/Wallet.qml @@ -41,7 +41,7 @@ Rectangle { } Component.onDestruction: { - keyboard.raised = false; + KeyboardScriptingInterface.raised = false; } Connections { diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index ccefb3c772..afb2bde7f3 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -2644,6 +2644,7 @@ void Application::cleanupBeforeQuit() { // it accesses the PickManager to delete its associated Pick DependencyManager::destroy(); DependencyManager::destroy(); + DependencyManager::destroy(); DependencyManager::destroy(); qCDebug(interfaceapp) << "Application::cleanupBeforeQuit() complete"; @@ -2929,6 +2930,7 @@ void Application::initializeRenderEngine() { // Now that OpenGL is initialized, we are sure we have a valid context and can create the various pipeline shaders with success. DependencyManager::get()->initializeShapePipelines(); + DependencyManager::get()->registerKeyboardHighlighting(); }); } diff --git a/interface/src/raypick/PointerScriptingInterface.cpp b/interface/src/raypick/PointerScriptingInterface.cpp index 1893132917..0009536479 100644 --- a/interface/src/raypick/PointerScriptingInterface.cpp +++ b/interface/src/raypick/PointerScriptingInterface.cpp @@ -56,14 +56,14 @@ unsigned int PointerScriptingInterface::createPointer(const PickQuery::PickType& * @property {boolean} [hover=false] If this pointer should generate hover events. * @property {boolean} [enabled=false] * @property {Vec3} [tipOffset] The specified offset of the from the joint index. - * @property {Pointers.StylusPointerProperties.model} [model] Data to replace the default model url, positionOffset and rotationOffset. {@link Pointers.StylusPointerProperties.model} + * @property {Pointers.StylusPointerProperties.model} [model] Data to replace the default model url, positionOffset and rotationOffset. */ /**jsdoc * properties defining stylus pick model that can be included to {@link Pointers.StylusPointerProperties} * @typedef {object} Pointers.StylusPointerProperties.model * @property {string} [url] url to the model * @property {Vec3} [dimensions] the dimensions of the model - * @property {Vec3} [positionOffset] the position offset of the model from the parent joint index + * @property {Vec3} [positionOffset] the position offset of the model from the stylus tip. * @property {Vec3} [rotationOffset] the rotation offset of the model from the parent joint index */ unsigned int PointerScriptingInterface::createStylus(const QVariant& properties) const { diff --git a/interface/src/ui/Keyboard.cpp b/interface/src/ui/Keyboard.cpp index c00ea007f0..773253f85c 100644 --- a/interface/src/ui/Keyboard.cpp +++ b/interface/src/ui/Keyboard.cpp @@ -44,6 +44,7 @@ #include "raypick/PickScriptingInterface.h" #include "scripting/HMDScriptingInterface.h" #include "scripting/WindowScriptingInterface.h" +#include "scripting/SelectionScriptingInterface.h" #include "DependencyManager.h" #include "raypick/StylusPointer.h" @@ -53,12 +54,12 @@ static const int LEFT_HAND_CONTROLLER_INDEX = 0; static const int RIGHT_HAND_CONTROLLER_INDEX = 1; -static const float MALLET_LENGTH = 0.4f; -static const float MALLET_TOUCH_Y_OFFSET = 0.105f; -static const float MALLET_Y_OFFSET = 0.35f; +static const float MALLET_LENGTH = 0.2f; +static const float MALLET_TOUCH_Y_OFFSET = 0.052f; +static const float MALLET_Y_OFFSET = 0.180f; static const glm::quat MALLET_ROTATION_OFFSET{0.70710678f, 0.0f, -0.70710678f, 0.0f}; -static const glm::vec3 MALLET_MODEL_DIMENSIONS{0.05f, MALLET_LENGTH, 0.05f}; +static const glm::vec3 MALLET_MODEL_DIMENSIONS{0.03f, MALLET_LENGTH, 0.03f}; static const glm::vec3 MALLET_POSITION_OFFSET{0.0f, -MALLET_Y_OFFSET / 2.0f, 0.0f}; static const glm::vec3 MALLET_TIP_OFFSET{0.0f, MALLET_LENGTH - MALLET_TOUCH_Y_OFFSET, 0.0f}; @@ -88,6 +89,28 @@ static const QString BACKSPACE_STRING = "backspace"; static const QString SPACE_STRING = "space"; static const QString ENTER_STRING = "enter"; +static const QString KEY_HOVER_HIGHLIGHT = "keyHoverHiglight"; +static const QString KEY_PRESSED_HIGHLIGHT = "keyPressesHighlight"; +static const QVariantMap KEY_HOVERING_STYLE { + { "isOutlineSmooth", true }, + { "outlineWidth", 3 }, + { "outlineUnoccludedColor", QVariantMap {{"red", 13}, {"green", 152}, {"blue", 186}}}, + { "outlineUnoccludedAlpha", 1.0 }, + { "outlineOccludedAlpha", 0.0 }, + { "fillUnoccludedAlpha", 0.0 }, + { "fillOccludedAlpha", 0.0 } +}; + +static const QVariantMap KEY_PRESSING_STYLE { + { "isOutlineSmooth", true }, + { "outlineWidth", 3 }, + { "fillUnoccludedColor", QVariantMap {{"red", 50}, {"green", 50}, {"blue", 50}}}, + { "outlineUnoccludedAlpha", 0.0 }, + { "outlineOccludedAlpha", 0.0 }, + { "fillUnoccludedAlpha", 0.6 }, + { "fillOccludedAlpha", 0.0 } +}; + std::pair calculateKeyboardPositionAndOrientation() { auto myAvatar = DependencyManager::get()->getMyAvatar(); auto hmd = DependencyManager::get(); @@ -201,10 +224,20 @@ Keyboard::Keyboard() { connect(pointerManager.data(), &PointerManager::triggerBeginOverlay, this, &Keyboard::handleTriggerBegin, Qt::QueuedConnection); connect(pointerManager.data(), &PointerManager::triggerContinueOverlay, this, &Keyboard::handleTriggerContinue, Qt::QueuedConnection); connect(pointerManager.data(), &PointerManager::triggerEndOverlay, this, &Keyboard::handleTriggerEnd, Qt::QueuedConnection); + connect(pointerManager.data(), &PointerManager::hoverBeginOverlay, this, &Keyboard::handleHoverBegin, Qt::QueuedConnection); + connect(pointerManager.data(), &PointerManager::hoverEndOverlay, this, &Keyboard::handleHoverEnd, Qt::QueuedConnection); connect(myAvatar.get(), &MyAvatar::sensorToWorldScaleChanged, this, &Keyboard::scaleKeyboard, Qt::QueuedConnection); connect(windowScriptingInterface.data(), &WindowScriptingInterface::domainChanged, [&]() { setRaised(false); }); } +void Keyboard::registerKeyboardHighlighting() { + auto selection = DependencyManager::get(); + selection->enableListHighlight(KEY_HOVER_HIGHLIGHT, KEY_HOVERING_STYLE); + selection->enableListToScene(KEY_HOVER_HIGHLIGHT); + selection->enableListHighlight(KEY_PRESSED_HIGHLIGHT, KEY_PRESSING_STYLE); + selection->enableListToScene(KEY_PRESSED_HIGHLIGHT); +} + void Keyboard::createKeyboard() { auto pointerManager = DependencyManager::get(); @@ -450,7 +483,7 @@ void Keyboard::handleTriggerBegin(const OverlayID& overlayID, const PointerEvent AudioInjectorOptions audioOptions; audioOptions.localOnly = true; audioOptions.position = keyWorldPosition; - audioOptions.volume = 0.4f; + audioOptions.volume = 0.1f; AudioInjector::playSound(_keySound->getByteArray(), audioOptions); @@ -504,6 +537,8 @@ void Keyboard::handleTriggerBegin(const OverlayID& overlayID, const PointerEvent QCoreApplication::postEvent(QCoreApplication::instance(), releaseEvent); key.startTimer(KEY_PRESS_TIMEOUT_MS); + auto selection = DependencyManager::get(); + selection->addToSelectedItemsList(KEY_PRESSED_HIGHLIGHT, "overlay", overlayID); } } @@ -536,6 +571,9 @@ void Keyboard::handleTriggerEnd(const OverlayID& overlayID, const PointerEvent& if (key.timerFinished()) { key.startTimer(KEY_PRESS_TIMEOUT_MS); } + + auto selection = DependencyManager::get(); + selection->removeFromSelectedItemsList(KEY_PRESSED_HIGHLIGHT, "overlay", overlayID); } void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEvent& event) { @@ -565,9 +603,8 @@ void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEv if (base3DOverlay) { auto pointerManager = DependencyManager::get(); auto pickResult = pointerManager->getPrevPickResult(pointerID); - auto pickResultVariant = pickResult->toVariantMap(); - - float distance = pickResultVariant["distance"].toFloat(); + auto stylusPickResult = std::dynamic_pointer_cast(pickResult); + float distance = stylusPickResult->distance; static const float PENATRATION_THRESHOLD = 0.025f; if (distance < PENATRATION_THRESHOLD) { @@ -583,6 +620,50 @@ void Keyboard::handleTriggerContinue(const OverlayID& overlayID, const PointerEv } } +void Keyboard::handleHoverBegin(const OverlayID& overlayID, const PointerEvent& event) { + if (_keyboardLayers.empty() || !isLayerSwitchTimerFinished()) { + return; + } + + auto pointerID = event.getID(); + + if (pointerID != _leftHandStylus && pointerID != _rightHandStylus) { + return; + } + + auto& keyboardLayer = _keyboardLayers[_layerIndex]; + auto search = keyboardLayer.find(overlayID); + + if (search == keyboardLayer.end()) { + return; + } + + auto selection = DependencyManager::get(); + selection->addToSelectedItemsList(KEY_HOVER_HIGHLIGHT, "overlay", overlayID); +} + +void Keyboard::handleHoverEnd(const OverlayID& overlayID, const PointerEvent& event) { + if (_keyboardLayers.empty() || !isLayerSwitchTimerFinished()) { + return; + } + + auto pointerID = event.getID(); + + if (pointerID != _leftHandStylus && pointerID != _rightHandStylus) { + return; + } + + auto& keyboardLayer = _keyboardLayers[_layerIndex]; + auto search = keyboardLayer.find(overlayID); + + if (search == keyboardLayer.end()) { + return; + } + + auto selection = DependencyManager::get(); + selection->removeFromSelectedItemsList(KEY_HOVER_HIGHLIGHT, "overlay", overlayID); +} + void Keyboard::disableStylus() { auto pointerManager = DependencyManager::get(); pointerManager->setRenderState(_leftHandStylus, "events off"); diff --git a/interface/src/ui/Keyboard.h b/interface/src/ui/Keyboard.h index 2a29a12961..18db38b2ae 100644 --- a/interface/src/ui/Keyboard.h +++ b/interface/src/ui/Keyboard.h @@ -90,6 +90,7 @@ class Keyboard : public Dependency, public QObject, public ReadWriteLockable { public: Keyboard(); void createKeyboard(); + void registerKeyboardHighlighting(); bool isRaised() const; void setRaised(bool raised); @@ -103,6 +104,8 @@ public slots: void handleTriggerBegin(const OverlayID& overlayID, const PointerEvent& event); void handleTriggerEnd(const OverlayID& overlayID, const PointerEvent& event); void handleTriggerContinue(const OverlayID& overlayID, const PointerEvent& event); + void handleHoverBegin(const OverlayID& overlayID, const PointerEvent& event); + void handleHoverEnd(const OverlayID& overlayID, const PointerEvent& event); void scaleKeyboard(float sensorToWorldScale); private: diff --git a/interface/src/ui/PreferencesDialog.cpp b/interface/src/ui/PreferencesDialog.cpp index 34d80f50cf..d71ad9dc82 100644 --- a/interface/src/ui/PreferencesDialog.cpp +++ b/interface/src/ui/PreferencesDialog.cpp @@ -147,7 +147,6 @@ void setupPreferences() { preferences->addPreference(new CheckPreference(UI_CATEGORY, "Prefer Avatar Finger Over Stylus", getter, setter)); } */ - // Snapshots static const QString SNAPSHOTS { "Snapshots" }; { diff --git a/libraries/shared/src/RegisteredMetaTypes.h b/libraries/shared/src/RegisteredMetaTypes.h index 18c1339223..ed637fe771 100644 --- a/libraries/shared/src/RegisteredMetaTypes.h +++ b/libraries/shared/src/RegisteredMetaTypes.h @@ -280,8 +280,8 @@ public: StylusTip(const bilateral::Side& side, const glm::vec3& tipOffset = Vectors::ZERO ,const glm::vec3& position = Vectors::ZERO, const glm::quat& orientation = Quaternions::IDENTITY, const glm::vec3& velocity = Vectors::ZERO) : side(side), tipOffset(tipOffset), position(position), orientation(orientation), velocity(velocity) {} - StylusTip(const QVariantMap& pickVariant) : side(bilateral::Side(pickVariant["side"].toInt())), position(vec3FromVariant(pickVariant["position"])), - orientation(quatFromVariant(pickVariant["orientation"])), velocity(vec3FromVariant(pickVariant["velocity"])) {} + StylusTip(const QVariantMap& pickVariant) : side(bilateral::Side(pickVariant["side"].toInt())), tipOffset(vec3FromVariant(pickVariant["tipOffset"])), + position(vec3FromVariant(pickVariant["position"])), orientation(quatFromVariant(pickVariant["orientation"])), velocity(vec3FromVariant(pickVariant["velocity"])) {} bilateral::Side side { bilateral::Side::Invalid }; glm::vec3 tipOffset;