diff --git a/cmake/macros/TargetLiblo.cmake b/cmake/macros/TargetLiblo.cmake new file mode 100644 index 0000000000..dac4197b1e --- /dev/null +++ b/cmake/macros/TargetLiblo.cmake @@ -0,0 +1,4 @@ +macro(target_liblo) + find_library(LIBLO LIBLO) + target_link_libraries(${TARGET_NAME} ${LIBLO}) +endmacro() \ No newline at end of file diff --git a/cmake/ports/hifi-client-deps/CONTROL b/cmake/ports/hifi-client-deps/CONTROL index 7d5b87805c..86bb64e287 100644 --- a/cmake/ports/hifi-client-deps/CONTROL +++ b/cmake/ports/hifi-client-deps/CONTROL @@ -1,4 +1,4 @@ Source: hifi-client-deps -Version: 0 +Version: 0.1 Description: Collected dependencies for High Fidelity applications -Build-Depends: hifi-deps, glslang, nlohmann-json, openvr (windows), quazip (!android), sdl2 (!android), spirv-cross (!android), spirv-tools (!android), vulkanmemoryallocator +Build-Depends: hifi-deps, glslang, nlohmann-json, openvr (windows), quazip (!android), sdl2 (!android), spirv-cross (!android), spirv-tools (!android), vulkanmemoryallocator, liblo (windows) diff --git a/cmake/ports/liblo/CONTROL b/cmake/ports/liblo/CONTROL new file mode 100644 index 0000000000..5e05c95e3e --- /dev/null +++ b/cmake/ports/liblo/CONTROL @@ -0,0 +1,3 @@ +Source: liblo +Version: 0.30 +Description: liblo is an implementation of the Open Sound Control protocol for POSIX systems \ No newline at end of file diff --git a/cmake/ports/liblo/portfile.cmake b/cmake/ports/liblo/portfile.cmake new file mode 100644 index 0000000000..27e41af186 --- /dev/null +++ b/cmake/ports/liblo/portfile.cmake @@ -0,0 +1,36 @@ +include(vcpkg_common_functions) + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO radarsat1/liblo + REF 0.30 + SHA512 d36c141c513f869e6d1963bd0d584030038019b8be0b27bb9a684722b6e7a38e942ad2ee7c2e67ac13b965560937aad97259435ed86034aa2dc8cb92d23845d8 + HEAD_REF master +) + +vcpkg_configure_cmake( + SOURCE_PATH ${SOURCE_PATH}/cmake + PREFER_NINJA # Disable this option if project cannot be built with Ninja + OPTIONS -DTHREADING=1 +) + +vcpkg_install_cmake() + +# Install needed files into package directory +vcpkg_fixup_cmake_targets(CONFIG_PATH lib/cmake/liblo) + +file(INSTALL ${CURRENT_PACKAGES_DIR}/bin/oscsend.exe DESTINATION ${CURRENT_PACKAGES_DIR}/tools/liblo) +file(INSTALL ${CURRENT_PACKAGES_DIR}/bin/oscdump.exe DESTINATION ${CURRENT_PACKAGES_DIR}/tools/liblo) +vcpkg_copy_tool_dependencies(${CURRENT_PACKAGES_DIR}/tools/liblo) + +# Remove unnecessary files +file(REMOVE_RECURSE ${CURRENT_PACKAGES_DIR}/debug/include) +file(REMOVE ${CURRENT_PACKAGES_DIR}/bin/oscsend.exe ${CURRENT_PACKAGES_DIR}/bin/oscdump.exe) +file(REMOVE ${CURRENT_PACKAGES_DIR}/debug/bin/oscsend.exe ${CURRENT_PACKAGES_DIR}/debug/bin/oscdump.exe) + +if(VCPKG_LIBRARY_LINKAGE STREQUAL static) + file(REMOVE_RECURSE ${CURRENT_PACKAGES_DIR}/bin ${CURRENT_PACKAGES_DIR}/debug/bin) +endif() + +# Handle copyright +file(INSTALL ${SOURCE_PATH}/COPYING DESTINATION ${CURRENT_PACKAGES_DIR}/share/liblo RENAME copyright) diff --git a/hifi_vcpkg.py b/hifi_vcpkg.py index 978243d3b1..1d80223357 100644 --- a/hifi_vcpkg.py +++ b/hifi_vcpkg.py @@ -82,8 +82,9 @@ endif() self.vcpkgUrl = 'https://hifi-public.s3.amazonaws.com/dependencies/vcpkg/builds/vcpkg-win32-client.zip?versionId=tSFzbw01VkkVFeRQ6YuAY4dro2HxJR9U' self.vcpkgHash = 'a650db47a63ccdc9904b68ddd16af74772e7e78170b513ea8de5a3b47d032751a3b73dcc7526d88bcb500753ea3dd9880639ca842bb176e2bddb1710f9a58cd3' self.hostTriplet = 'x64-windows' - if ('CI_BUILD' in os.environ) and os.environ["CI_BUILD"] == "Github" and (not self.noClean): - self.prebuiltArchive = "https://hifi-public.s3.amazonaws.com/dependencies/vcpkg/builds/vcpkg-win32.zip?versionId=LtGKnBydCxteY3Ub1W_UNBN5sH.Ccp5g" +# Don't use prebuilt on windows, because it is out of date with the inclusion of liblo. +# if ('CI_BUILD' in os.environ) and os.environ["CI_BUILD"] == "Github" and (not self.noClean): +# self.prebuiltArchive = "https://hifi-public.s3.amazonaws.com/dependencies/vcpkg/builds/vcpkg-win32.zip?versionId=LtGKnBydCxteY3Ub1W_UNBN5sH.Ccp5g" elif 'Darwin' == system: self.exe = os.path.join(self.path, 'vcpkg') self.bootstrapCmds = [ os.path.join(self.path, 'bootstrap-vcpkg.sh'), '--allowAppleClang' ] diff --git a/interface/resources/controllers/osc.json b/interface/resources/controllers/osc.json new file mode 100644 index 0000000000..e5c639f8b7 --- /dev/null +++ b/interface/resources/controllers/osc.json @@ -0,0 +1,71 @@ +{ + "name": "OSC to Standard", + "channels": [ + { "from": "OSC.Head", "to" : "Standard.Head" }, + { "from": "OSC.LeftEye", "to" : "Standard.LeftEye" }, + { "from": "OSC.RightEye", "to" : "Standard.RightEye" }, + + { "from": "OSC.EyeBlink_L", "to": "Standard.EyeBlink_L" }, + { "from": "OSC.EyeBlink_R", "to": "Standard.EyeBlink_R" }, + { "from": "OSC.EyeSquint_L", "to": "Standard.EyeSquint_L" }, + { "from": "OSC.EyeSquint_R", "to": "Standard.EyeSquint_R" }, + { "from": "OSC.EyeDown_L", "to": "Standard.EyeDown_L" }, + { "from": "OSC.EyeDown_R", "to": "Standard.EyeDown_R" }, + { "from": "OSC.EyeIn_L", "to": "Standard.EyeIn_L" }, + { "from": "OSC.EyeIn_R", "to": "Standard.EyeIn_R" }, + { "from": "OSC.EyeOpen_L", "to": "Standard.EyeOpen_L" }, + { "from": "OSC.EyeOpen_R", "to": "Standard.EyeOpen_R" }, + { "from": "OSC.EyeOut_L", "to": "Standard.EyeOut_L" }, + { "from": "OSC.EyeOut_R", "to": "Standard.EyeOut_R" }, + { "from": "OSC.EyeUp_L", "to": "Standard.EyeUp_L" }, + { "from": "OSC.EyeUp_R", "to": "Standard.EyeUp_R" }, + { "from": "OSC.BrowsD_L", "to": "Standard.BrowsD_L" }, + { "from": "OSC.BrowsD_R", "to": "Standard.BrowsD_R" }, + { "from": "OSC.BrowsU_C", "to": "Standard.BrowsU_C" }, + { "from": "OSC.BrowsU_L", "to": "Standard.BrowsU_L" }, + { "from": "OSC.BrowsU_R", "to": "Standard.BrowsU_R" }, + { "from": "OSC.JawFwd", "to": "Standard.JawFwd" }, + { "from": "OSC.JawLeft", "to": "Standard.JawLeft" }, + { "from": "OSC.JawOpen", "to": "Standard.JawOpen" }, + { "from": "OSC.JawRight", "to": "Standard.JawRight" }, + { "from": "OSC.MouthLeft", "to": "Standard.MouthLeft" }, + { "from": "OSC.MouthRight", "to": "Standard.MouthRight" }, + { "from": "OSC.MouthFrown_L", "to": "Standard.MouthFrown_L" }, + { "from": "OSC.MouthFrown_R", "to": "Standard.MouthFrown_R" }, + { "from": "OSC.MouthSmile_L", "to": "Standard.MouthSmile_L" }, + { "from": "OSC.MouthSmile_R", "to": "Standard.MouthSmile_R" }, + { "from": "OSC.MouthDimple_L", "to": "Standard.MouthDimple_L" }, + { "from": "OSC.MouthDimple_R", "to": "Standard.MouthDimple_R" }, + { "from": "OSC.LipsStretch_L", "to": "Standard.LipsStretch_L" }, + { "from": "OSC.LipsStretch_R", "to": "Standard.LipsStretch_R" }, + { "from": "OSC.LipsUpperClose", "to": "Standard.LipsUpperClose" }, + { "from": "OSC.LipsLowerClose", "to": "Standard.LipsLowerClose" }, + { "from": "OSC.LipsFunnel", "to": "Standard.LipsFunnel" }, + { "from": "OSC.LipsPucker", "to": "Standard.LipsPucker" }, + { "from": "OSC.Puff", "to": "Standard.Puff" }, + { "from": "OSC.CheekSquint_L", "to": "Standard.CheekSquint_L" }, + { "from": "OSC.CheekSquint_R", "to": "Standard.CheekSquint_R" }, + { "from": "OSC.MouthClose", "to": "Standard.MouthClose" }, + { "from": "OSC.MouthUpperUp_L", "to": "Standard.MouthUpperUp_L" }, + { "from": "OSC.MouthUpperUp_R", "to": "Standard.MouthUpperUp_R" }, + { "from": "OSC.MouthLowerDown_L", "to": "Standard.MouthLowerDown_L" }, + { "from": "OSC.MouthLowerDown_R", "to": "Standard.MouthLowerDown_R" }, + { "from": "OSC.MouthPress_L", "to": "Standard.MouthPress_L" }, + { "from": "OSC.MouthPress_R", "to": "Standard.MouthPress_R" }, + { "from": "OSC.MouthShrugLower", "to": "Standard.MouthShrugLower" }, + { "from": "OSC.MouthShrugUpper", "to": "Standard.MouthShrugUpper" }, + { "from": "OSC.NoseSneer_L", "to": "Standard.NoseSneer_L" }, + { "from": "OSC.NoseSneer_R", "to": "Standard.NoseSneer_R" }, + { "from": "OSC.TongueOut", "to": "Standard.TongueOut" }, + { "from": "OSC.UserBlendshape0", "to": "Standard.UserBlendshape0" }, + { "from": "OSC.UserBlendshape1", "to": "Standard.UserBlendshape1" }, + { "from": "OSC.UserBlendshape2", "to": "Standard.UserBlendshape2" }, + { "from": "OSC.UserBlendshape3", "to": "Standard.UserBlendshape3" }, + { "from": "OSC.UserBlendshape4", "to": "Standard.UserBlendshape4" }, + { "from": "OSC.UserBlendshape5", "to": "Standard.UserBlendshape5" }, + { "from": "OSC.UserBlendshape6", "to": "Standard.UserBlendshape6" }, + { "from": "OSC.UserBlendshape7", "to": "Standard.UserBlendshape7" }, + { "from": "OSC.UserBlendshape8", "to": "Standard.UserBlendshape8" }, + { "from": "OSC.UserBlendshape9", "to": "Standard.UserBlendshape9" } + ] +} diff --git a/interface/resources/qml/hifi/tablet/ControllerSettings.qml b/interface/resources/qml/hifi/tablet/ControllerSettings.qml index 6727047eb0..5db784789e 100644 --- a/interface/resources/qml/hifi/tablet/ControllerSettings.qml +++ b/interface/resources/qml/hifi/tablet/ControllerSettings.qml @@ -332,7 +332,7 @@ Item { anchors.fill: stackView id: controllerPrefereneces objectName: "TabletControllerPreferences" - showCategories: ["VR Movement", "Game Controller", "Sixense Controllers", "Perception Neuron", "Leap Motion"] + showCategories: ["VR Movement", "Game Controller", "Sixense Controllers", "Perception Neuron", "Leap Motion", "Open Sound Control (OSC)"] categoryProperties: { "VR Movement" : { "User real-world height (meters)" : { "anchors.right" : "undefined" }, diff --git a/interface/src/avatar/MyAvatar.cpp b/interface/src/avatar/MyAvatar.cpp index c12defe7b6..c3e4758e50 100644 --- a/interface/src/avatar/MyAvatar.cpp +++ b/interface/src/avatar/MyAvatar.cpp @@ -6592,22 +6592,23 @@ void MyAvatar::updateEyesLookAtPosition(float deltaTime) { int rightEyeJointIndex = getJointIndex("RightEye"); bool eyesAreOverridden = getIsJointOverridden(leftEyeJointIndex) || getIsJointOverridden(rightEyeJointIndex); + const float DEFAULT_GAZE_DISTANCE = 20.0f; // meters if (eyesAreOverridden) { // A script has set the eye rotations, so use these to set lookAtSpot glm::quat leftEyeRotation = getAbsoluteJointRotationInObjectFrame(leftEyeJointIndex); glm::quat rightEyeRotation = getAbsoluteJointRotationInObjectFrame(rightEyeJointIndex); - glm::vec3 leftVec = getWorldOrientation() * leftEyeRotation * IDENTITY_FORWARD; - glm::vec3 rightVec = getWorldOrientation() * rightEyeRotation * IDENTITY_FORWARD; + glm::vec3 leftVec = getWorldOrientation() * leftEyeRotation * Vectors::UNIT_Z; + glm::vec3 rightVec = getWorldOrientation() * rightEyeRotation * Vectors::UNIT_Z; glm::vec3 leftEyePosition = myHead->getLeftEyePosition(); glm::vec3 rightEyePosition = myHead->getRightEyePosition(); float t1, t2; bool success = findClosestApproachOfLines(leftEyePosition, leftVec, rightEyePosition, rightVec, t1, t2); - if (success) { + if (success && t1 > 0 && t2 > 0) { glm::vec3 leftFocus = leftEyePosition + leftVec * t1; glm::vec3 rightFocus = rightEyePosition + rightVec * t2; lookAtSpot = (leftFocus + rightFocus) / 2.0f; // average } else { - lookAtSpot = myHead->getEyePosition() + glm::normalize(leftVec) * 1000.0f; + lookAtSpot = myHead->getEyePosition() + glm::normalize(leftVec) * DEFAULT_GAZE_DISTANCE; } } else if (_scriptControlsEyesLookAt) { if (_scriptEyesControlTimer < MAX_LOOK_AT_TIME_SCRIPT_CONTROL) { @@ -6621,19 +6622,18 @@ void MyAvatar::updateEyesLookAtPosition(float deltaTime) { controller::Pose rightEyePose = getControllerPoseInAvatarFrame(controller::Action::RIGHT_EYE); if (leftEyePose.isValid() && rightEyePose.isValid()) { // an eye tracker is in use, set lookAtSpot from this - glm::vec3 leftVec = getWorldOrientation() * leftEyePose.rotation * glm::vec3(0.0f, 0.0f, -1.0f); - glm::vec3 rightVec = getWorldOrientation() * rightEyePose.rotation * glm::vec3(0.0f, 0.0f, -1.0f); - + glm::vec3 leftVec = getWorldOrientation() * leftEyePose.rotation * Vectors::UNIT_Z; + glm::vec3 rightVec = getWorldOrientation() * rightEyePose.rotation * Vectors::UNIT_Z; glm::vec3 leftEyePosition = myHead->getLeftEyePosition(); glm::vec3 rightEyePosition = myHead->getRightEyePosition(); float t1, t2; bool success = findClosestApproachOfLines(leftEyePosition, leftVec, rightEyePosition, rightVec, t1, t2); - if (success) { + if (success && t1 > 0 && t2 > 0) { glm::vec3 leftFocus = leftEyePosition + leftVec * t1; glm::vec3 rightFocus = rightEyePosition + rightVec * t2; lookAtSpot = (leftFocus + rightFocus) / 2.0f; // average } else { - lookAtSpot = myHead->getEyePosition() + glm::normalize(leftVec) * 1000.0f; + lookAtSpot = myHead->getEyePosition() + glm::normalize(leftVec) * DEFAULT_GAZE_DISTANCE; } } else { // no script override, no eye tracker, so do procedural eye motion diff --git a/interface/src/avatar/MyHead.cpp b/interface/src/avatar/MyHead.cpp index 1b88a518c8..4b6f85de1c 100644 --- a/interface/src/avatar/MyHead.cpp +++ b/interface/src/avatar/MyHead.cpp @@ -134,11 +134,11 @@ void MyHead::simulate(float deltaTime) { userInputMapper->getActionStateValid(controller::Action::MOUTHSMILE_L) || userInputMapper->getActionStateValid(controller::Action::MOUTHSMILE_R); - bool eyesTracked = - userInputMapper->getPoseState(controller::Action::LEFT_EYE).valid && - userInputMapper->getPoseState(controller::Action::RIGHT_EYE).valid; - MyAvatar* myAvatar = static_cast(_owningAvatar); + bool eyesTracked = + myAvatar->getControllerPoseInSensorFrame(controller::Action::LEFT_EYE).valid && + myAvatar->getControllerPoseInSensorFrame(controller::Action::RIGHT_EYE).valid; + int leftEyeJointIndex = myAvatar->getJointIndex("LeftEye"); int rightEyeJointIndex = myAvatar->getJointIndex("RightEye"); bool eyeJointsOverridden = myAvatar->getIsJointOverridden(leftEyeJointIndex) || myAvatar->getIsJointOverridden(rightEyeJointIndex); diff --git a/libraries/avatars-renderer/src/avatars-renderer/Head.cpp b/libraries/avatars-renderer/src/avatars-renderer/Head.cpp index b8bc7a03e8..ee0543fa6b 100644 --- a/libraries/avatars-renderer/src/avatars-renderer/Head.cpp +++ b/libraries/avatars-renderer/src/avatars-renderer/Head.cpp @@ -96,17 +96,21 @@ void Head::simulate(float deltaTime) { const float BLINK_START_VARIABILITY = 0.25f; const float FULLY_OPEN = 0.0f; const float FULLY_CLOSED = 1.0f; + const float TALKING_LOUDNESS = 150.0f; + + _timeWithoutTalking += deltaTime; + if ((_averageLoudness - _longTermAverageLoudness) > TALKING_LOUDNESS) { + _timeWithoutTalking = 0.0f; + } + if (getProceduralAnimationFlag(HeadData::BlinkProceduralBlendshapeAnimation) && !getSuppressProceduralAnimationFlag(HeadData::BlinkProceduralBlendshapeAnimation)) { + // handle automatic blinks // Detect transition from talking to not; force blink after that and a delay bool forceBlink = false; - const float TALKING_LOUDNESS = 150.0f; const float BLINK_AFTER_TALKING = 0.25f; - _timeWithoutTalking += deltaTime; - if ((_averageLoudness - _longTermAverageLoudness) > TALKING_LOUDNESS) { - _timeWithoutTalking = 0.0f; - } else if (_timeWithoutTalking - deltaTime < BLINK_AFTER_TALKING && _timeWithoutTalking >= BLINK_AFTER_TALKING) { + if (_timeWithoutTalking - deltaTime < BLINK_AFTER_TALKING && _timeWithoutTalking >= BLINK_AFTER_TALKING) { forceBlink = true; } if (_leftEyeBlinkVelocity == 0.0f && _rightEyeBlinkVelocity == 0.0f) { @@ -150,11 +154,13 @@ void Head::simulate(float deltaTime) { } else { _rightEyeBlink = FULLY_OPEN; _leftEyeBlink = FULLY_OPEN; + updateEyeLookAt(); } // use data to update fake Faceshift blendshape coefficients if (getProceduralAnimationFlag(HeadData::AudioProceduralBlendshapeAnimation) && !getSuppressProceduralAnimationFlag(HeadData::AudioProceduralBlendshapeAnimation)) { + // Update audio attack data for facial animation (eyebrows and mouth) float audioAttackAveragingRate = (10.0f - deltaTime * NORMAL_HZ) / 10.0f; // --> 0.9 at 60 Hz _audioAttack = audioAttackAveragingRate * _audioAttack + @@ -188,6 +194,7 @@ void Head::simulate(float deltaTime) { if (getProceduralAnimationFlag(HeadData::LidAdjustmentProceduralBlendshapeAnimation) && !getSuppressProceduralAnimationFlag(HeadData::LidAdjustmentProceduralBlendshapeAnimation)) { + // This controls two things, the eye brow and the upper eye lid, it is driven by the vertical up/down angle of the // eyes relative to the head. This is to try to help prevent sleepy eyes/crazy eyes. applyEyelidOffset(getOrientation()); diff --git a/libraries/avatars/src/AvatarData.cpp b/libraries/avatars/src/AvatarData.cpp index 5fe7998ecb..e670a5437a 100755 --- a/libraries/avatars/src/AvatarData.cpp +++ b/libraries/avatars/src/AvatarData.cpp @@ -376,7 +376,7 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent tranlationChangedSince(lastSentTime) || parentInfoChangedSince(lastSentTime)); hasHandControllers = _controllerLeftHandMatrixCache.isValid() || _controllerRightHandMatrixCache.isValid(); - hasFaceTrackerInfo = !dropFaceTracking && getHasScriptedBlendshapes() && + hasFaceTrackerInfo = !dropFaceTracking && (getHasScriptedBlendshapes() || _headData->_hasInputDrivenBlendshapes) && (sendAll || faceTrackerInfoChangedSince(lastSentTime)); hasJointData = !sendMinimum; hasJointDefaultPoseFlags = hasJointData; diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 1150e27cac..b17be50c39 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -36,6 +36,12 @@ if (NOT SERVER_ONLY AND NOT ANDROID) add_subdirectory(${DIR}) set(DIR "hifiLeapMotion") add_subdirectory(${DIR}) + + if (WIN32) + set(DIR "hifiOsc") + add_subdirectory(${DIR}) + endif() + endif() # server-side plugins diff --git a/plugins/hifiOsc/CMakeLists.txt b/plugins/hifiOsc/CMakeLists.txt new file mode 100644 index 0000000000..cb8b437ab6 --- /dev/null +++ b/plugins/hifiOsc/CMakeLists.txt @@ -0,0 +1,14 @@ +# +# Created by Anthony Thibault on 2019/8/24 +# Copyright 2019 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 +# + +set(TARGET_NAME hifiOsc) +setup_hifi_plugin(Qml) +link_hifi_libraries(shared controllers ui plugins input-plugins display-plugins) +target_liblo() + + diff --git a/plugins/hifiOsc/src/OscPlugin.cpp b/plugins/hifiOsc/src/OscPlugin.cpp new file mode 100644 index 0000000000..788342971d --- /dev/null +++ b/plugins/hifiOsc/src/OscPlugin.cpp @@ -0,0 +1,636 @@ +// +// OscPlugin.cpp +// +// Created by Anthony Thibault on 2019/8/24 +// Copyright 2019 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 "OscPlugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_DECLARE_LOGGING_CATEGORY(inputplugins) +Q_LOGGING_CATEGORY(inputplugins, "hifi.inputplugins") + +const char* OscPlugin::NAME = "Open Sound Control (OSC)"; +const char* OscPlugin::OSC_ID_STRING = "Open Sound Control (OSC)"; +const bool DEFAULT_ENABLED = false; + +enum class FaceCap { + BrowsU_C = 0, + BrowsD_L, + BrowsD_R, + BrowsU_L, + BrowsU_R, + EyeUp_L, + EyeUp_R, + EyeDown_L, + EyeDown_R, + EyeIn_L, + EyeIn_R, + EyeOut_L, + EyeOut_R, + EyeBlink_L, + EyeBlink_R, + EyeSquint_L, + EyeSquint_R, + EyeOpen_L, + EyeOpen_R, + Puff, + CheekSquint_L, + CheekSquint_R, + NoseSneer_L, + NoseSneer_R, + JawOpen, + JawFwd, + JawLeft, + JawRight, + LipsFunnel, + LipsPucker, + MouthLeft, + MouthRight, + LipsUpperClose, + LipsLowerClose, + MouthShrugUpper, + MouthShrugLower, + MouthClose, + MouthSmile_L, + MouthSmile_R, + MouthFrown_L, + MouthFrown_R, + MouthDimple_L, + MouthDimple_R, + MouthUpperUp_L, + MouthUpperUp_R, + MouthLowerDown_L, + MouthLowerDown_R, + MouthPress_L, + MouthPress_R, + LipsStretch_L, + LipsStretch_R, + TongueOut, + BlendshapeCount +}; + +// used to mirror left/right shapes from FaceCap. +// i.e. right and left shapes are swapped. +FaceCap faceMirrorMap[(int)FaceCap::BlendshapeCount] = { + FaceCap::BrowsU_C, + FaceCap::BrowsD_R, + FaceCap::BrowsD_L, + FaceCap::BrowsU_R, + FaceCap::BrowsU_L, + FaceCap::EyeUp_R, + FaceCap::EyeUp_L, + FaceCap::EyeDown_R, + FaceCap::EyeDown_L, + FaceCap::EyeIn_R, + FaceCap::EyeIn_L, + FaceCap::EyeOut_R, + FaceCap::EyeOut_L, + FaceCap::EyeBlink_R, + FaceCap::EyeBlink_L, + FaceCap::EyeSquint_R, + FaceCap::EyeSquint_L, + FaceCap::EyeOpen_R, + FaceCap::EyeOpen_L, + FaceCap::Puff, + FaceCap::CheekSquint_R, + FaceCap::CheekSquint_L, + FaceCap::NoseSneer_R, + FaceCap::NoseSneer_L, + FaceCap::JawOpen, + FaceCap::JawFwd, + FaceCap::JawRight, + FaceCap::JawLeft, + FaceCap::LipsFunnel, + FaceCap::LipsPucker, + FaceCap::MouthRight, + FaceCap::MouthLeft, + FaceCap::LipsUpperClose, + FaceCap::LipsLowerClose, + FaceCap::MouthShrugUpper, + FaceCap::MouthShrugLower, + FaceCap::MouthClose, + FaceCap::MouthSmile_R, + FaceCap::MouthSmile_L, + FaceCap::MouthFrown_R, + FaceCap::MouthFrown_L, + FaceCap::MouthDimple_R, + FaceCap::MouthDimple_L, + FaceCap::MouthUpperUp_R, + FaceCap::MouthUpperUp_L, + FaceCap::MouthLowerDown_R, + FaceCap::MouthLowerDown_L, + FaceCap::MouthPress_R, + FaceCap::MouthPress_L, + FaceCap::LipsStretch_R, + FaceCap::LipsStretch_L, + FaceCap::TongueOut +}; + +static const char* STRINGS[FaceCap::BlendshapeCount] = { + "BrowsU_C", + "BrowsD_L", + "BrowsD_R", + "BrowsU_L", + "BrowsU_R", + "EyeUp_L", + "EyeUp_R", + "EyeDown_L", + "EyeDown_R", + "EyeIn_L", + "EyeIn_R", + "EyeOut_L", + "EyeOut_R", + "EyeBlink_L", + "EyeBlink_R", + "EyeSquint_L", + "EyeSquint_R", + "EyeOpen_L", + "EyeOpen_R", + "Puff", + "CheekSquint_L", + "CheekSquint_R", + "NoseSneer_L", + "NoseSneer_R", + "JawOpen", + "JawFwd", + "JawLeft", + "JawRight", + "LipsFunnel", + "LipsPucker", + "MouthLeft", + "MouthRight", + "LipsUpperClose", + "LipsLowerClose", + "MouthShrugUpper", + "MouthShrugLower", + "MouthClose", + "MouthSmile_L", + "MouthSmile_R", + "MouthFrown_L", + "MouthFrown_R", + "MouthDimple_L", + "MouthDimple_R", + "MouthUpperUp_L", + "MouthUpperUp_R", + "MouthLowerDown_L", + "MouthLowerDown_R", + "MouthPress_L", + "MouthPress_R", + "LipsStretch_L", + "LipsStretch_R", + "TongueOut" +}; + +static enum controller::StandardAxisChannel CHANNELS[FaceCap::BlendshapeCount] = { + controller::BROWSU_C, + controller::BROWSD_L, + controller::BROWSD_R, + controller::BROWSU_L, + controller::BROWSU_R, + controller::EYEUP_L, + controller::EYEUP_R, + controller::EYEDOWN_L, + controller::EYEDOWN_R, + controller::EYEIN_L, + controller::EYEIN_R, + controller::EYEOUT_L, + controller::EYEOUT_R, + controller::EYEBLINK_L, + controller::EYEBLINK_R, + controller::EYESQUINT_L, + controller::EYESQUINT_R, + controller::EYEOPEN_L, + controller::EYEOPEN_R, + controller::PUFF, + controller::CHEEKSQUINT_L, + controller::CHEEKSQUINT_R, + controller::NOSESNEER_L, + controller::NOSESNEER_R, + controller::JAWOPEN, + controller::JAWFWD, + controller::JAWLEFT, + controller::JAWRIGHT, + controller::LIPSFUNNEL, + controller::LIPSPUCKER, + controller::MOUTHLEFT, + controller::MOUTHRIGHT, + controller::LIPSUPPERCLOSE, + controller::LIPSLOWERCLOSE, + controller::MOUTHSHRUGUPPER, + controller::MOUTHSHRUGLOWER, + controller::MOUTHCLOSE, + controller::MOUTHSMILE_L, + controller::MOUTHSMILE_R, + controller::MOUTHFROWN_L, + controller::MOUTHFROWN_R, + controller::MOUTHDIMPLE_L, + controller::MOUTHDIMPLE_R, + controller::MOUTHUPPERUP_L, + controller::MOUTHUPPERUP_R, + controller::MOUTHLOWERDOWN_L, + controller::MOUTHLOWERDOWN_R, + controller::MOUTHPRESS_L, + controller::MOUTHPRESS_R, + controller::LIPSSTRETCH_L, + controller::LIPSSTRETCH_R, + controller::TONGUEOUT +}; + + +void OscPlugin::init() { + + _inputDevice = std::make_shared(); + _inputDevice->setContainer(this); + + { + std::lock_guard guard(_dataMutex); + _blendshapeValues.assign((int)FaceCap::BlendshapeCount, 0.0f); + _blendshapeValidFlags.assign((int)FaceCap::BlendshapeCount, false); + _headRot = glm::quat(); + _headRotValid = false; + _headTransTarget = extractTranslation(_lastInputCalibrationData.defaultHeadMat); + _headTransSmoothed = extractTranslation(_lastInputCalibrationData.defaultHeadMat); + _headTransValid = false; + _eyeLeftRot = glm::quat(); + _eyeLeftRotValid = false; + _eyeRightRot = glm::quat(); + _eyeRightRotValid = false; + } + + loadSettings(); + + auto preferences = DependencyManager::get(); + static const QString OSC_PLUGIN { OscPlugin::NAME }; + { + auto getter = [this]()->bool { return _enabled; }; + auto setter = [this](bool value) { + _enabled = value; + saveSettings(); + if (!_enabled) { + auto userInputMapper = DependencyManager::get(); + userInputMapper->withLock([&, this]() { + _inputDevice->clearState(); + restartServer(); + }); + } + }; + auto preference = new CheckPreference(OSC_PLUGIN, "Enabled", getter, setter); + preferences->addPreference(preference); + } + { + auto debugGetter = [this]()->bool { return _debug; }; + auto debugSetter = [this](bool value) { + _debug = value; + saveSettings(); + }; + auto preference = new CheckPreference(OSC_PLUGIN, "Extra Debugging", debugGetter, debugSetter); + preferences->addPreference(preference); + } + + { + static const int MIN_PORT_NUMBER { 0 }; + static const int MAX_PORT_NUMBER { 65535 }; + + auto getter = [this]()->float { return (float)_serverPort; }; + auto setter = [this](float value) { + _serverPort = (int)value; + saveSettings(); + restartServer(); + }; + auto preference = new SpinnerPreference(OSC_PLUGIN, "Server Port", getter, setter); + + preference->setMin(MIN_PORT_NUMBER); + preference->setMax(MAX_PORT_NUMBER); + preference->setStep(1); + preferences->addPreference(preference); + } +} + +bool OscPlugin::isSupported() const { + // networking/UDP is pretty much always available... + return true; +} + +static void errorHandlerFunc(int num, const char* msg, const char* path) { + qDebug(inputplugins) << "OscPlugin: server error" << num << "in path" << path << ":" << msg; +} + +static int genericHandlerFunc(const char* path, const char* types, lo_arg** argv, + int argc, void* data, void* user_data) { + + OscPlugin* container = reinterpret_cast(user_data); + assert(container); + + QString key(path); + std::lock_guard guard(container->_dataMutex); + + // Special case: decode blendshapes from face-cap iPhone app. + // http://www.bannaflak.com/face-cap/livemode.html + if (path[0] == '/' && path[1] == 'W' && argc == 2 && types[0] == 'i' && types[1] == 'f') { + int index = argv[0]->i; + if (index >= 0 && index < (int)FaceCap::BlendshapeCount) { + int mirroredIndex = (int)faceMirrorMap[index]; + container->_blendshapeValues[mirroredIndex] = argv[1]->f; + container->_blendshapeValidFlags[mirroredIndex] = true; + } + } + + // map /HT to head translation + if (path[0] == '/' && path[1] == 'H' && path[2] == 'T' && + types[0] == 'f' && types[1] == 'f' && types[2] == 'f') { + glm::vec3 trans(-argv[0]->f, -argv[1]->f, argv[2]->f); // in cm + + // convert trans into a delta (in meters) from the sweet spot of the iphone camera. + const float CM_TO_METERS = 0.01f; + const glm::vec3 FACE_CAP_HEAD_SWEET_SPOT(0.0f, 0.0f, -45.0f); + glm::vec3 delta = (trans - FACE_CAP_HEAD_SWEET_SPOT) * CM_TO_METERS; + + container->_headTransTarget = extractTranslation(container->_lastInputCalibrationData.defaultHeadMat) + delta; + container->_headTransValid = true; + } + + // map /HR to head rotation + if (path[0] == '/' && path[1] == 'H' && path[2] == 'R' && path[3] == 0 && + types[0] == 'f' && types[1] == 'f' && types[2] == 'f') { + glm::vec3 euler(-argv[0]->f, -argv[1]->f, argv[2]->f); + container->_headRot = glm::quat(glm::radians(euler)) * Quaternions::Y_180; + container->_headRotValid = true; + } + + // map /ELR to left eye rot + if (path[0] == '/' && path[1] == 'E' && path[2] == 'L' && path[3] == 'R' && + types[0] == 'f' && types[1] == 'f') { + glm::vec3 euler(argv[0]->f, -argv[1]->f, 0.0f); + container->_eyeLeftRot = container->_headRot * glm::quat(glm::radians(euler)); + container->_eyeLeftRotValid = true; + } + + // map /ERR to right eye rot + if (path[0] == '/' && path[1] == 'E' && path[2] == 'R' && path[3] == 'R' && + types[0] == 'f' && types[1] == 'f') { + glm::vec3 euler((float)argv[0]->f, (float)-argv[1]->f, 0.0f); + container->_eyeRightRot = container->_headRot * glm::quat(glm::radians(euler)); + container->_eyeRightRotValid = true; + } + + // AJT: TODO map /STRINGS[i] to _blendshapeValues[i] + + if (container->_debug) { + for (int i = 0; i < argc; i++) { + switch (types[i]) { + case 'i': + // int32 + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] =" << argv[i]->i; + break; + case 'f': + // float32 + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] =" << argv[i]->f32; + break; + case 's': + // OSC-string + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'b': + // OSC-blob + break; + case 'h': + // 64 bit big-endian two's complement integer + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] =" << argv[i]->h; + break; + case 't': + // OSC-timetag + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'd': + // 64 bit ("double") IEEE 754 floating point number + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] =" << argv[i]->d; + break; + case 'S': + // Alternate type represented as an OSC-string (for example, for systems that differentiate "symbols" from "strings") + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'c': + // an ascii character, sent as 32 bits + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] =" << argv[i]->c; + break; + case 'r': + // 32 bit RGBA color + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'm': + // 4 byte MIDI message. Bytes from MSB to LSB are: port id, status byte, data1, data2 + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'T': + // true + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'F': + // false + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'N': + // nil + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case 'I': + // inf + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case '[': + // Indicates the beginning of an array. The tags following are for data in the Array until a close brace tag is reached. + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + case ']': + // Indicates the end of an array. + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = "; + break; + default: + qDebug(inputplugins) << "OscPlugin: " << path << "[" << i << "] = " << types[i]; + break; + } + } + } + + return 1; +} + + +bool OscPlugin::activate() { + InputPlugin::activate(); + + loadSettings(); + + if (_enabled) { + + qDebug(inputplugins) << "OscPlugin: activated"; + + // register with userInputMapper + auto userInputMapper = DependencyManager::get(); + userInputMapper->registerDevice(_inputDevice); + + return startServer(); + } + return false; +} + +void OscPlugin::deactivate() { + qDebug(inputplugins) << "OscPlugin: deactivated, _oscServerThread =" << _oscServerThread; + + if (_oscServerThread) { + stopServer(); + } +} + +void OscPlugin::pluginUpdate(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) { + if (!_enabled) { + return; + } + + _lastInputCalibrationData = inputCalibrationData; + + auto userInputMapper = DependencyManager::get(); + userInputMapper->withLock([&, this]() { + _inputDevice->update(deltaTime, inputCalibrationData); + }); +} + +void OscPlugin::saveSettings() const { + Settings settings; + QString idString = getID(); + settings.beginGroup(idString); + { + settings.setValue(QString("enabled"), _enabled); + settings.setValue(QString("extraDebug"), _debug); + settings.setValue(QString("serverPort"), _serverPort); + } + settings.endGroup(); +} + +void OscPlugin::loadSettings() { + Settings settings; + QString idString = getID(); + settings.beginGroup(idString); + { + _enabled = settings.value("enabled", QVariant(DEFAULT_ENABLED)).toBool(); + _debug = settings.value("extraDebug", QVariant(DEFAULT_ENABLED)).toBool(); + _serverPort = settings.value("serverPort", QVariant(DEFAULT_OSC_SERVER_PORT)).toInt(); + } + settings.endGroup(); +} + +bool OscPlugin::startServer() { + if (_oscServerThread) { + qWarning(inputplugins) << "OscPlugin: (startServer) server is already running, _oscServerThread =" << _oscServerThread; + return true; + } + + // start a new server on specified port + const size_t BUFFER_SIZE = 64; + char serverPortString[BUFFER_SIZE]; + snprintf(serverPortString, BUFFER_SIZE, "%d", _serverPort); + _oscServerThread = lo_server_thread_new(serverPortString, errorHandlerFunc); + + qDebug(inputplugins) << "OscPlugin: server started on port" << serverPortString << ", _oscServerThread =" << _oscServerThread; + + // add method that will match any path and args + // NOTE: callback function will be called on the OSC thread, not the appliation thread. + lo_server_thread_add_method(_oscServerThread, NULL, NULL, genericHandlerFunc, (void*)this); + + lo_server_thread_start(_oscServerThread); + + return true; +} + +void OscPlugin::stopServer() { + if (!_oscServerThread) { + qWarning(inputplugins) << "OscPlugin: (stopServer) server is already shutdown."; + } + + // stop and free server + lo_server_thread_stop(_oscServerThread); + lo_server_thread_free(_oscServerThread); + _oscServerThread = nullptr; +} + +void OscPlugin::restartServer() { + if (_oscServerThread) { + stopServer(); + } + startServer(); +} + +// +// InputDevice +// + +controller::Input::NamedVector OscPlugin::InputDevice::getAvailableInputs() const { + static controller::Input::NamedVector availableInputs; + if (availableInputs.size() == 0) { + for (int i = 0; i < (int)FaceCap::BlendshapeCount; i++) { + availableInputs.push_back(makePair(CHANNELS[i], STRINGS[i])); + } + } + availableInputs.push_back(makePair(controller::HEAD, "Head")); + availableInputs.push_back(makePair(controller::LEFT_EYE, "LeftEye")); + availableInputs.push_back(makePair(controller::RIGHT_EYE, "RightEye")); + return availableInputs; +} + +QString OscPlugin::InputDevice::getDefaultMappingConfig() const { + static const QString MAPPING_JSON = PathUtils::resourcesPath() + "/controllers/osc.json"; + return MAPPING_JSON; +} + +void OscPlugin::InputDevice::update(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) { + glm::mat4 sensorToAvatarMat = glm::inverse(inputCalibrationData.avatarMat) * inputCalibrationData.sensorToWorldMat; + std::lock_guard guard(_container->_dataMutex); + for (int i = 0; i < (int)FaceCap::BlendshapeCount; i++) { + if (_container->_blendshapeValidFlags[i]) { + _axisStateMap[CHANNELS[i]] = controller::AxisValue(_container->_blendshapeValues[i], 0, true); + } + } + if (_container->_headRotValid && _container->_headTransValid) { + const float SMOOTH_TIMESCALE = 2.0f; + float tau = deltaTime / SMOOTH_TIMESCALE; + _container->_headTransSmoothed = lerp(_container->_headTransSmoothed, _container->_headTransTarget, tau); + glm::vec3 delta = _container->_headTransSmoothed - _container->_headTransTarget; + glm::vec3 trans = extractTranslation(inputCalibrationData.defaultHeadMat) + delta; + + controller::Pose sensorSpacePose(trans, _container->_headRot); + _poseStateMap[controller::HEAD] = sensorSpacePose.transform(sensorToAvatarMat); + } + if (_container->_eyeLeftRotValid) { + controller::Pose sensorSpacePose(vec3(0.0f), _container->_eyeLeftRot); + _poseStateMap[controller::LEFT_EYE] = sensorSpacePose.transform(sensorToAvatarMat); + } + if (_container->_eyeRightRotValid) { + controller::Pose sensorSpacePose(vec3(0.0f), _container->_eyeRightRot); + _poseStateMap[controller::RIGHT_EYE] = sensorSpacePose.transform(sensorToAvatarMat); + } +} + +void OscPlugin::InputDevice::clearState() { + std::lock_guard guard(_container->_dataMutex); + for (int i = 0; i < (int)FaceCap::BlendshapeCount; i++) { + _axisStateMap[CHANNELS[i]] = controller::AxisValue(0.0f, 0, false); + } + _poseStateMap[controller::HEAD] = controller::Pose(); + _poseStateMap[controller::LEFT_EYE] = controller::Pose(); + _poseStateMap[controller::RIGHT_EYE] = controller::Pose(); +} + diff --git a/plugins/hifiOsc/src/OscPlugin.h b/plugins/hifiOsc/src/OscPlugin.h new file mode 100644 index 0000000000..3ce198b625 --- /dev/null +++ b/plugins/hifiOsc/src/OscPlugin.h @@ -0,0 +1,95 @@ +// +// OscPlugin.h +// +// Created by Anthony Thibault on 2019/8/24 +// Copyright 2019 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_OscPlugin_h +#define hifi_OscPlugin_h + +#include +#include +#include + +#include "lo/lo.h" + +// OSC (Open Sound Control) input plugin. +class OscPlugin : public InputPlugin { + Q_OBJECT +public: + + // Plugin functions + virtual void init() override; + virtual bool isSupported() const override; + virtual const QString getName() const override { return NAME; } + const QString getID() const override { return OSC_ID_STRING; } + + virtual bool activate() override; + virtual void deactivate() override; + + virtual void pluginFocusOutEvent() override { _inputDevice->focusOutEvent(); } + virtual void pluginUpdate(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) override; + + virtual void saveSettings() const override; + virtual void loadSettings() override; + + bool startServer(); + void stopServer(); + void restartServer(); + +protected: + + class InputDevice : public controller::InputDevice { + public: + friend class OscPlugin; + + InputDevice() : controller::InputDevice("OSC") {} + + // Device functions + virtual controller::Input::NamedVector getAvailableInputs() const override; + virtual QString getDefaultMappingConfig() const override; + virtual void update(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) override; + virtual void focusOutEvent() override {}; + + void clearState(); + void setContainer(OscPlugin* container) { _container = container; } + + OscPlugin* _container { nullptr }; + }; + + std::shared_ptr _inputDevice; + + static const char* NAME; + static const char* OSC_ID_STRING; + + bool _enabled { false }; + mutable bool _initialized { false }; + + lo_server_thread _oscServerThread { nullptr }; + +public: + bool _debug { false }; + enum Constants { DEFAULT_OSC_SERVER_PORT = 7700 }; + int _serverPort { DEFAULT_OSC_SERVER_PORT }; + controller::InputCalibrationData _lastInputCalibrationData; + + std::vector _blendshapeValues; + std::vector _blendshapeValidFlags; + glm::quat _headRot; + bool _headRotValid { false }; + glm::vec3 _headTransTarget; + glm::vec3 _headTransSmoothed; + bool _headTransValid { false }; + glm::quat _eyeLeftRot; + bool _eyeLeftRotValid { false }; + glm::quat _eyeRightRot; + bool _eyeRightRotValid { false }; + std::mutex _dataMutex; +}; + +#endif // hifi_OscPlugin_h + diff --git a/plugins/hifiOsc/src/OscProvider.cpp b/plugins/hifiOsc/src/OscProvider.cpp new file mode 100644 index 0000000000..0d4c582d16 --- /dev/null +++ b/plugins/hifiOsc/src/OscProvider.cpp @@ -0,0 +1,49 @@ +// +// Created by Anthony Thibault on 2019/8/25 +// Copyright 2019 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 + +#include +#include +#include + +#include +#include + +#include "OscPlugin.h" + +class OscProvider : public QObject, public InputProvider +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID InputProvider_iid FILE "plugin.json") + Q_INTERFACES(InputProvider) + +public: + OscProvider(QObject* parent = nullptr) : QObject(parent) {} + virtual ~OscProvider() {} + + virtual InputPluginList getInputPlugins() override { + static std::once_flag once; + std::call_once(once, [&] { + InputPluginPointer plugin(new OscPlugin()); + if (plugin->isSupported()) { + _inputPlugins.push_back(plugin); + } + }); + return _inputPlugins; + } + + virtual void destroyInputPlugins() override { + _inputPlugins.clear(); + } + +private: + InputPluginList _inputPlugins; +}; + +#include "OscProvider.moc" diff --git a/plugins/hifiOsc/src/plugin.json b/plugins/hifiOsc/src/plugin.json new file mode 100644 index 0000000000..d977e34a1c --- /dev/null +++ b/plugins/hifiOsc/src/plugin.json @@ -0,0 +1,4 @@ +{ + "name":"Osc", + "version": 1 +}