From 4620c97698c42bfe23f7bb5058857d3be225da30 Mon Sep 17 00:00:00 2001 From: Dee Adesanwo Date: Thu, 27 Jun 2024 14:43:00 -0500 Subject: [PATCH 1/5] Merge things up (#1) * Update leopard so Pi 5 works, fix command order for other STT choices in start.sh * Add animation queue so animations don't play atop each other, enhance prompt * If geolocation API returns an error, print the whole thing * Only process the chunks that have active frames in them (vosk) * Use gpt-4o model * Add LLM command for getting an image from the bot camera, make sure any command after the last punctuation mark in the response is recognized, add 'requiresexact' to en-US JSON (if true, exact keyphrase matches are required, disabling partial matches for that intent), remove special unspeakable characters from LLM response, make sure LLM knows that love is an animation, fix remembered chats * Only allow get image command with supported models (gpt-4o), allow LLM commands with Together.AI, set default Together model to llama-3 if it is set to llama-2 * Begin webroot changes * changes are noted in webroot-overhaul-notes.md * little touchup * Add small HRs * maybe fix macOS * Use PxPlus VGA font, change size * align checkboxes correctly, add better checkbox CSS, align icons like how they were * Make sure intent-graph doesn't try to work if kg-provider is set to None * (attempt) use OpenAI voice for non-English languages * OpenAI voice now works - you need libsoxr installed * Make sure wire-pod can communicate with bot over SDK before trying to stream KG, add libsoxr to setup script * Implemented downsampling in pure Go - no more need for an external library * Tune lowpass filter, only enable OpenAI Vector Voice for languages that aren't English by default - use USE_OPENAI_VOICE=true flag to bypass * use soxr by default, again. you can use go desampling with USE_GO_DESAMPLE=true * Prepare for merge, remove experimental OpenAI voice code, make sure everything passes staticcheck, buttons now use correct font * go mod tidy * Allow custom LLM endpoint * the beginnings of the refactor. does not work yet! * theoretical full functionality. still some erroneous errors * add style for disabled radiobuttons * Update style.css * buttons to radiobuttons. Control depending on the functions * buttons to radiobuttons. Control depending on the functions * set default font back to Droid Sans, start work on customizer, make statuses more user-friendly * Revert "set default font back to Droid Sans, start work on customizer, make statuses more user-friendly" This reverts commit 4bce4be1a7f54c267e127a7f39786a4dfa4e41be. * Reapply "set default font back to Droid Sans, start work on customizer, make statuses more user-friendly" This reverts commit 6e39c5232c424fdd9bb11749e67745236b82c2c3. * remove random extra font * functional ui customizer * Officially call api 'v1', put commit in version info, use commit in determining whether source-built instances need an update * Add installed commit hash to built binary in daemon-enable * Make custom intent response messages more clear, center a button * Disable caching for the webroot * Make input focusing/hovering not change the border size * Fix control page elements * Add highpass filter for mic data so VAD works in loud environments - in a branch because I still need to test it in different environments. Also make a bunch of whisper.cpp fixes * Add OpenAI voices for non-English langs again, include soxr in setup script, add voice chooser in knowledge graph settings, add language selection to initial * Little whisper fixes * libsoxr in docker * Update go in setup.sh * Remove soxr yet again and use a pure-Go desampler * Only use filtered audio data for VAD, not STT processing * With whisper, detect if the string contains a number or not if the timer command is used. Also don't print debug files you silly goose * Allow download of large whisper model in setup.sh - quantized is recommended * go mod tidy --------- Co-authored-by: kercre123 Co-authored-by: Hamlet3000 <75612866+Hamlet3000@users.noreply.github.com> --- chipper/go.mod | 6 +- chipper/go.sum | 12 +- chipper/intent-data/en-US.json | 162 ++- chipper/pkg/logger/logger.go | 4 +- chipper/pkg/vars/config.go | 8 + chipper/pkg/vars/vars.go | 51 +- chipper/pkg/wirepod/config-ws/webserver.go | 825 +++++------ .../pkg/wirepod/localization/localization.go | 2 +- chipper/pkg/wirepod/preqs/intent.go | 4 +- chipper/pkg/wirepod/preqs/intent_graph.go | 4 +- chipper/pkg/wirepod/preqs/knowledgegraph.go | 4 + chipper/pkg/wirepod/preqs/server.go | 9 +- chipper/pkg/wirepod/preqs/stream_houndify.go | 2 +- chipper/pkg/wirepod/sdkapp/server.go | 11 +- .../wirepod/speechrequest/speechrequest.go | 143 +- chipper/pkg/wirepod/stt/coqui/Coqui.go | 2 +- chipper/pkg/wirepod/stt/houndify/Houndify.go | 2 +- chipper/pkg/wirepod/stt/leopard/Leopard.go | 4 +- chipper/pkg/wirepod/stt/vosk/Vosk.go | 8 +- chipper/pkg/wirepod/stt/vosk/context.go | 4 +- .../pkg/wirepod/stt/whisper.cpp/WhisperCpp.go | 25 +- chipper/pkg/wirepod/stt/whisper/Whisper.go | 2 +- chipper/pkg/wirepod/ttr/convert.go | 92 ++ chipper/pkg/wirepod/ttr/intentparam.go | 2 +- chipper/pkg/wirepod/ttr/kgsim.go | 215 ++- chipper/pkg/wirepod/ttr/kgsim_cmds.go | 423 +++++- chipper/pkg/wirepod/ttr/matchIntentSend.go | 28 +- chipper/pkg/wirepod/ttr/weather.go | 1 + chipper/pkg/wirepod/ttr/words2num.go | 3 +- chipper/start.sh | 31 +- chipper/webroot/css/PxPlus_IBM_VGA_8x16.ttf | Bin 0 -> 70640 bytes chipper/webroot/css/h1Font.ttf | Bin 0 -> 6632 bytes chipper/webroot/css/style.css | 236 ++- chipper/webroot/index.html | 455 +++--- chipper/webroot/initial.html | 486 +++---- chipper/webroot/js/ble.js | 576 +++----- chipper/webroot/js/initial.js | 266 ++-- chipper/webroot/js/main.js | 1260 +++++++---------- chipper/webroot/js/ui.js | 79 ++ chipper/webroot/sdkapp/control.html | 191 ++- chipper/webroot/sdkapp/index.html | 69 +- chipper/webroot/sdkapp/js/control.js | 49 + chipper/webroot/sdkapp/js/main.js | 1 + chipper/webroot/sdkapp/settings.html | 402 +++--- chipper/webroot/setup.html | 559 ++++---- dockerfile | 2 +- setup.sh | 31 +- 47 files changed, 3588 insertions(+), 3163 deletions(-) create mode 100644 chipper/pkg/wirepod/ttr/convert.go create mode 100644 chipper/webroot/css/PxPlus_IBM_VGA_8x16.ttf create mode 100644 chipper/webroot/css/h1Font.ttf create mode 100644 chipper/webroot/js/ui.js diff --git a/chipper/go.mod b/chipper/go.mod index cf4ec7fd..fa0b3d4e 100644 --- a/chipper/go.mod +++ b/chipper/go.mod @@ -3,7 +3,7 @@ module github.com/kercre123/wire-pod/chipper go 1.18 require ( - github.com/Picovoice/leopard/binding/go v1.2.0 + github.com/Picovoice/leopard/binding/go/v2 v2.0.2 github.com/asticode/go-asticoqui v0.2.0 github.com/bramvdbogaerde/go-scp v1.2.1 github.com/digital-dream-labs/api v0.0.0-20210824232136-8cc90c1bb12c @@ -11,7 +11,7 @@ require ( github.com/digital-dream-labs/opus-go v0.0.0-20201230195736-934a8a9e0a1e github.com/digital-dream-labs/vector-bluetooth v0.0.0-20210604051118-1c511122d877 github.com/fforchino/vector-go-sdk v0.0.0-20231108155304-62168f3595d6 - github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20231214200047-940de9dbe9c9 + github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240618151033-bf4cb4abad4e github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 github.com/golang-jwt/jwt v3.2.2+incompatible @@ -22,7 +22,7 @@ require ( github.com/ncruces/zenity v0.10.10 github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e github.com/pkg/errors v0.9.1 - github.com/sashabaranov/go-openai v1.20.4 + github.com/sashabaranov/go-openai v1.24.0 github.com/soheilhy/cmux v0.1.5 github.com/soundhound/houndify-sdk-go v0.3.5 github.com/wlynxg/anet v0.0.1 diff --git a/chipper/go.sum b/chipper/go.sum index caebf5d2..293bf8e7 100644 --- a/chipper/go.sum +++ b/chipper/go.sum @@ -42,8 +42,8 @@ github.com/Microsoft/go-winio v0.4.15-0.20200113171025-3fe6c5262873/go.mod h1:tT github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/Picovoice/leopard/binding/go v1.2.0 h1:NbUW+Fni5UydvcFlMx8RZtk2pccFaRJFG14kGaUa4CA= -github.com/Picovoice/leopard/binding/go v1.2.0/go.mod h1:5kaEg9ZcH2dLkrX/H1xMVF6QFM7l3vd9GKxeXSanA8s= +github.com/Picovoice/leopard/binding/go/v2 v2.0.2 h1:Knk/UV51oRuHTHd7MGtlZXwsFF5jxu6AqttB0jGMHxs= +github.com/Picovoice/leopard/binding/go/v2 v2.0.2/go.mod h1:/rYUeRDH4xBgtwBe9D8BwHIauPJ+M7czqLfyeJQJu7c= github.com/aalpern/go-metrics v0.0.0-20181116155206-644932c99203/go.mod h1:wHUOZ2LlAirciaWYGZM3apvZftM7aRXhLMDsdjqEFB4= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= @@ -136,8 +136,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsouza/go-dockerclient v1.6.6/go.mod h1:3/oRIWoe7uT6bwtAayj/EmJmepBjeL4pYvt7ZxC7Rnk= -github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20231214200047-940de9dbe9c9 h1:B3bFAoLQ8y4RFNR8A4GjkCiKzuUIwblYUiIgRgWr604= -github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20231214200047-940de9dbe9c9/go.mod h1:QIjZ9OktHFG7p+/m3sMvrAJKKdWrr1fZIK0rM6HZlyo= +github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240618151033-bf4cb4abad4e h1:np99/bjGH4/khEujoGbwc0ohMLh32GjovhEv2mJRNfs= +github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240618151033-bf4cb4abad4e/go.mod h1:QIjZ9OktHFG7p+/m3sMvrAJKKdWrr1fZIK0rM6HZlyo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= @@ -424,8 +424,8 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sashabaranov/go-openai v1.20.4 h1:095xQ/fAtRa0+Rj21sezVJABgKfGPNbyx/sAN/hJUmg= -github.com/sashabaranov/go-openai v1.20.4/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sashabaranov/go-openai v1.24.0 h1:4H4Pg8Bl2RH/YSnU8DYumZbuHnnkfioor/dtNlB20D4= +github.com/sashabaranov/go-openai v1.24.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/schollz/progressbar/v3 v3.7.4/go.mod h1:1H8m5kMPW6q5fyjpDqtBHW1JT22mu2NwHQ1ApuCPh/8= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= diff --git a/chipper/intent-data/en-US.json b/chipper/intent-data/en-US.json index 8d3225df..42f415b9 100755 --- a/chipper/intent-data/en-US.json +++ b/chipper/intent-data/en-US.json @@ -1,218 +1,272 @@ [ { "name" : "intent_names_username_extend", - "keyphrases": ["name is", "native is", "names", "name's", "my name is" ] + "keyphrases": ["name is", "native is", "names", "name's", "my name is" ], + "requiresexact": false }, { "name": "intent_weather_extend", - "keyphrases" : ["weather", "whether", "the other", "the water", "no other", "weather forecast", "weather tomorrow", "whats the weather" ] + "keyphrases" : ["weather", "whether", "the other", "the water", "no other", "weather forecast", "weather tomorrow", "whats the weather" ], + "requiresexact": false }, { "name": "intent_names_ask", - "keyphrases" : ["my name", "who am", "who am i"] + "keyphrases" : ["my name", "who am", "who am i"], + "requiresexact": false }, { "name": "intent_imperative_eyecolor", - "keyphrases" : ["eye color", "colo", "i call her", "i foller", "icolor", "ecce", "erior", "ichor", "agricola", "change", "oracular", "oracle", "set your eye color to"] + "keyphrases" : ["eye color", "colo", "i call her", "i foller", "icolor", "ecce", "erior", "ichor", "agricola", "change", "oracular", "oracle", "set your eye color to"], + "requiresexact": false }, { "name": "intent_character_age", - "keyphrases" : ["older", "how old", "old are you", "old or yo", "how there you"] + "keyphrases" : ["older", "how old", "old are you", "old or yo", "how there you"], + "requiresexact": false }, { "name": "intent_explore_start", - "keyphrases" : ["start", "plor", "owing", "tailoring", "oding", "oring", "pling", "start exploring"] + "keyphrases" : ["start", "plor", "owing", "tailoring", "oding", "oring", "pling", "start exploring"], + "requiresexact": false }, { "name": "intent_system_charger", - "keyphrases" : ["charge", "home", "go to your", "church", "find your ch", "charger" ] + "keyphrases" : ["charge", "home", "go to your", "church", "find your ch", "charger" ], + "requiresexact": false }, { "name": "intent_system_sleep", - "keyphrases" : ["flee", "sleep", "sheep", "go to sleep" ] + "keyphrases" : ["flee", "sleep", "sheep", "go to sleep" ], + "requiresexact": false }, { "name": "intent_greeting_goodmorning", - "keyphrases" : ["morning", "mourning", "mooning", "it bore", "afternoon", "after noon", "after whom", "good morning" ] + "keyphrases" : ["morning", "mourning", "mooning", "it bore", "afternoon", "after noon", "after whom", "good morning" ], + "requiresexact": false }, { "name": "intent_greeting_goodnight", - "keyphrases" : ["night", "might", "goodnight" ] + "keyphrases" : ["night", "might", "goodnight" ], + "requiresexact": false }, { "name": "intent_greeting_goodbye", - "keyphrases" : ["good bye", "good by", "good buy", "goodbye" ] + "keyphrases" : ["good bye", "good by", "good buy", "goodbye" ], + "requiresexact": false }, { "name": "intent_seasonal_happynewyear", - "keyphrases" : ["fireworks", "new year", "happy new", "happy to", "have been", "i now you", "no year", "enee", "i never", "knew her", "hobhouse", "bennie" ] + "keyphrases" : ["fireworks", "new year", "happy new", "happy to", "have been", "i now you", "no year", "enee", "i never", "knew her", "hobhouse", "bennie" ], + "requiresexact": false }, { "name": "intent_seasonal_happyholidays", - "keyphrases" : ["he holds", "christmas", "behold", "holiday" ] + "keyphrases" : ["he holds", "christmas", "behold", "holiday" ], + "requiresexact": false }, { "name": "intent_amazon_signin", - "keyphrases" : ["in intellect", "fine in electa", "in alex", "ing alex", "in an elect", "to alex", "in angelica", "up alexa", "sign in to alexa" ] + "keyphrases" : ["in intellect", "fine in electa", "in alex", "ing alex", "in an elect", "to alex", "in angelica", "up alexa", "sign in to alexa" ], + "requiresexact": false }, { "name": "intent_amazon_signin", - "keyphrases" : ["in outlet", "i now of elea", "out alexa", "out of ale" ] + "keyphrases" : ["in outlet", "i now of elea", "out alexa", "out of ale" ], + "requiresexact": false }, { "name": "intent_imperative_forward", - "keyphrases" : ["forward", "for ward", "for word", "move forward", "forwards" ] + "keyphrases" : ["forward", "for ward", "for word", "move forward", "forwards" ], + "requiresexact": false }, { "name": "intent_imperative_turnaround", - "keyphrases" : ["around", "one eighty", "one ate he", "turn around" ] + "keyphrases" : ["around", "one eighty", "one ate he", "turn around" ], + "requiresexact": false }, { "name": "intent_imperative_turnleft", - "keyphrases" : ["rn left", "go left", "e left", "ed left", "ernest" ] + "keyphrases" : ["rn left", "go left", "e left", "ed left", "ernest" ], + "requiresexact": false }, { "name": "intent_imperative_turnright", - "keyphrases" : ["rn right", "go right", "e right", "ernie", "credit", "ed right" ] + "keyphrases" : ["rn right", "go right", "e right", "ernie", "credit", "ed right" ], + "requiresexact": false }, { "name": "intent_play_rollcube", - "keyphrases" : ["roll cu", "roll your cu", "all your cu", "roll human", "yorke", "old your he", "roll your cube" ] + "keyphrases" : ["roll cu", "roll your cu", "all your cu", "roll human", "yorke", "old your he", "roll your cube" ], + "requiresexact": false }, { "name": "intent_play_popawheelie", - "keyphrases" : ["pop a w", "polwhele", "olwen", "i wieland", "do a wheel", "doorstone", "thibetan", "powell", "welst", "a wheel", "willie", "a really", "o' billy", "pop a wheelie", "do a wheel stand" ] + "keyphrases" : ["pop a w", "polwhele", "olwen", "i wieland", "do a wheel", "doorstone", "thibetan", "powell", "welst", "a wheel", "willie", "a really", "o' billy", "pop a wheelie", "do a wheel stand" ], + "requiresexact": false }, { "name": "intent_play_fistbump", - "keyphrases" : ["this pomp", "this pump", "bump", "fistb", "fistf", "this book", "pisto", "with pomp", "fison", "first", "fifth", "were fifteen", "if bump", "wisdom", "this bu", "fist bomb", "fist ball", "this ball", "system", "fistbump" ] + "keyphrases" : ["this pomp", "this pump", "bump", "fistb", "fistf", "this book", "pisto", "with pomp", "fison", "first", "fifth", "were fifteen", "if bump", "wisdom", "this bu", "fist bomb", "fist ball", "this ball", "system", "fistbump" ], + "requiresexact": false }, { "name": "intent_play_blackjack", - "keyphrases" : ["black", "cards", "game", "play blackjack" ] + "keyphrases" : ["black", "cards", "game", "play blackjack" ], + "requiresexact": false }, { "name": "intent_imperative_affirmative", - "keyphrases" : ["yes", "correct", "sure" ] + "keyphrases" : ["yes", "correct", "sure", "yes please" ], + "requiresexact": false }, { "name": "intent_imperative_negative", - "keyphrases" : ["no", "dont" ] + "keyphrases" : ["no", "dont", "no thanks" ], + "requiresexact": true }, { "name": "intent_photo_take_extend", - "keyphrases" : ["photo", "foto", "selby", "capture", "picture", "take a photo of me" ] + "keyphrases" : ["photo", "foto", "selby", "capture", "picture", "take a photo of me" ], + "requiresexact": false }, { "name": "intent_imperative_praise", - "keyphrases" : ["good", "awesome", "also", "as some", "of them", "battle", "t rob", "the ro", "amazing", "woodcourt", "good robot" ] + "keyphrases" : ["good", "awesome", "also", "as some", "of them", "battle", "t rob", "the ro", "amazing", "woodcourt", "good robot" ], + "requiresexact": false }, { "name": "intent_imperative_abuse", - "keyphrases" : ["bad", "that ro", "ad ro", "a root", "hate", "horrible", "bad robot" ] + "keyphrases" : ["bad", "that ro", "ad ro", "a root", "hate", "horrible", "bad robot" ], + "requiresexact": false }, { "name": "intent_imperative_apologize", - "keyphrases" : ["sorry", "apologize", "apologise", "the tory", "nevermind", "never mind", "im sorry" ] + "keyphrases" : ["sorry", "apologize", "apologise", "the tory", "nevermind", "never mind", "im sorry" ], + "requiresexact": false }, { "name": "intent_imperative_backup", - "keyphrases" : ["back", "back up", "backwards","beck"] + "keyphrases" : ["back", "back up", "backwards","beck"], + "requiresexact": false }, { "name": "intent_imperative_volumedown", - "keyphrases" : ["all you down", "volume down", "down volume", "down the volume", "quieter", "turn the volume down" ] + "keyphrases" : ["all you down", "volume down", "down volume", "down the volume", "quieter", "turn the volume down" ], + "requiresexact": false }, { "name": "intent_imperative_volumeup", - "keyphrases" : ["all you up", "volume up", "up volume", "up the volume", "louder" ] + "keyphrases" : ["all you up", "volume up", "up volume", "up the volume", "louder" ], + "requiresexact": false }, { "name": "intent_imperative_lookatme", - "keyphrases" : ["stare", "at me" ] + "keyphrases" : ["stare", "at me" ], + "requiresexact": false }, { "name": "intent_imperative_volumelevel_extend", - "keyphrases" : ["all you", "volume", "loudness" ] + "keyphrases" : ["all you", "volume", "loudness" ], + "requiresexact": false }, { "name": "intent_imperative_shutup", - "keyphrases" : ["shut up" ] + "keyphrases" : ["shut up" ], + "requiresexact": false }, { "name": "intent_greeting_hello", - "keyphrases" : ["hello", "our you", "high", "below", "little", "follow", "for you", "far you", "how about you", "how are you", "the low", "the loo", "our are you" ] + "keyphrases" : ["hello", "our you", "follow", "for you", "far you", "how about you", "how are you", "the low", "the loo", "our are you" ], + "requiresexact": false }, { "name": "intent_imperative_come", - "keyphrases" : ["come", "to me", "come here" ] + "keyphrases" : ["come to me", "come here" ], + "requiresexact": false }, { "name": "intent_imperative_love", - "keyphrases" : ["love", "dove", "i love you" ] + "keyphrases" : ["love you", "dove you", "i love you" ], + "requiresexact": false }, { "name": "intent_knowledge_promptquestion", - "keyphrases" : ["question", "weston", "i have a question" ] + "keyphrases" : ["question", "weston", "i have a question" ], + "requiresexact": false }, { "name": "intent_clock_checktimer", - "keyphrases" : ["check timer", "check the timer", "check the time her", "check time her", "check time her", "check time of her", "checked the timer", "checked the time her", "checked the time of her" ] + "keyphrases" : ["check timer", "check the timer", "check the time her", "check time her", "check time her", "check time of her", "checked the timer", "checked the time her", "checked the time of her" ], + "requiresexact": false }, { "name": "intent_global_stop_extend", - "keyphrases" : ["up the timer", "stop timer", "cancel the", "cancel timer", "stop clock", "stop be", "stopped t", "stopped be", "stopped at", "stop the" ] + "keyphrases" : ["up the timer", "stop timer", "cancel the", "cancel timer", "stop clock", "stop be", "stopped t", "stopped be", "stopped at", "stop the" ], + "requiresexact": false }, { "name": "intent_clock_settimer_extend", - "keyphrases" : ["timer", "time for", "time of for", "time or", "time of", "set a timer for" ] + "keyphrases" : ["timer", "time for", "time of for", "time or", "time of", "set a timer for" ], + "requiresexact": false }, { "name": "intent_clock_time", - "keyphrases" : ["time is it", "the time", "what time", "clock", "what time is it" ] + "keyphrases" : ["time is it", "the time", "what time", "clock", "what time is it" ], + "requiresexact": false }, { "name": "intent_imperative_quiet", - "keyphrases" : ["quiet", "stop", "be quiet" ] + "keyphrases" : ["quiet", "stop", "be quiet" ], + "requiresexact": false }, { "name": "intent_imperative_dance", - "keyphrases" : ["dance", "dancing", "thence", "dance to the beat", "the beat", "boogie", "to the music" ] + "keyphrases" : ["dance", "dancing", "thence", "dance to the beat", "the beat", "boogie", "to the music" ], + "requiresexact": false }, { "name": "intent_play_pickupcube", - "keyphrases" : ["pickup", "pick up" ] + "keyphrases" : ["pickup", "pick up" ], + "requiresexact": false }, { "name": "intent_imperative_fetchcube", - "keyphrases" : ["fetch your cu", "fetch cu", "fetch the cu", "bring to me", "bring me", "bring me your cube" ] + "keyphrases" : ["fetch your cu", "fetch cu", "fetch the cu", "bring to me", "bring me", "bring me your cube" ], + "requiresexact": false }, { "name": "intent_imperative_findcube", - "keyphrases" : ["your cu", "the cu", "find your cube" ] + "keyphrases" : ["your cu", "the cu", "find your cube" ], + "requiresexact": false }, { "name": "intent_play_anytrick", - "keyphrases" : ["trick", "something cool", "some thing cool", "do a trick" ] + "keyphrases" : ["trick", "something cool", "some thing cool", "do a trick" ], + "requiresexact": false }, { "name": "intent_message_recordmessage_extend", - "keyphrases" : ["record" ] + "keyphrases" : ["record" ], + "requiresexact": false }, { "name": "intent_message_playmessage_extend", - "keyphrases" : ["play message", "play method", "play a message", "play a method" ] + "keyphrases" : ["play message", "play method", "play a message", "play a method" ], + "requiresexact": false }, { "name": "intent_blackjack_hit", - "keyphrases" : ["hit"] + "keyphrases" : ["hit"], + "requiresexact": false }, { "name": "intent_blackjack_stand", - "keyphrases" : ["stand", "stan" ] + "keyphrases" : ["stand", "stan" ], + "requiresexact": false }, { "name": "intent_play_keepaway", - "keyphrases": ["keepaway", "keep away", "play keepaway" ] + "keyphrases": ["keepaway", "keep away", "play keepaway" ], + "requiresexact": false } ] diff --git a/chipper/pkg/logger/logger.go b/chipper/pkg/logger/logger.go index 149bffc9..2a589258 100644 --- a/chipper/pkg/logger/logger.go +++ b/chipper/pkg/logger/logger.go @@ -36,7 +36,7 @@ func Println(a ...any) { func LogUI(a ...any) { LogArray = append(LogArray, time.Now().Format("2006.01.02 15:04:05")+": "+fmt.Sprint(a...)+"\n") - if len(LogArray) >= 30 { + if len(LogArray) >= 50 { LogArray = LogArray[1:] } LogList = "" @@ -47,7 +47,7 @@ func LogUI(a ...any) { func LogTray(a ...any) { LogTrayArray = append(LogTrayArray, time.Now().Format("2006.01.02 15:04:05")+": "+fmt.Sprint(a...)+"\n") - if len(LogTrayArray) >= 30 { + if len(LogTrayArray) >= 200 { LogTrayArray = LogTrayArray[1:] } LogTrayList = "" diff --git a/chipper/pkg/vars/config.go b/chipper/pkg/vars/config.go index a283f152..1a57f573 100644 --- a/chipper/pkg/vars/config.go +++ b/chipper/pkg/vars/config.go @@ -29,8 +29,10 @@ type apiConfig struct { IntentGraph bool `json:"intentgraph"` RobotName string `json:"robotName"` OpenAIPrompt string `json:"openai_prompt"` + OpenAIVoice string `json:"openai_voice"` SaveChat bool `json:"save_chat"` CommandsEnable bool `json:"commands_enable"` + Endpoint string `json:"endpoint"` } `json:"knowledge"` STT struct { Service string `json:"provider"` @@ -118,6 +120,12 @@ func ReadConfig() { APIConfig.PastInitialSetup = true } } + + if APIConfig.Knowledge.Model == "meta-llama/Llama-2-70b-chat-hf" { + logger.Println("Setting Together model to Llama3") + APIConfig.Knowledge.Model = "meta-llama/Llama-3-70b-chat-hf" + } + writeBytes, _ := json.Marshal(APIConfig) os.WriteFile(ApiConfigPath, writeBytes, 0644) logger.Println("API config successfully read") diff --git a/chipper/pkg/vars/vars.go b/chipper/pkg/vars/vars.go index 18c3c025..1cbdf7ff 100644 --- a/chipper/pkg/vars/vars.go +++ b/chipper/pkg/vars/vars.go @@ -19,6 +19,8 @@ import ( "github.com/wlynxg/anet" ) +var CommitSHA string + // initialize variables so they don't have to be found during runtime var VarsInited bool @@ -66,8 +68,11 @@ var VoskGrammerEnable bool = false // here to prevent import cycle (localization restructure) var SttInitFunc func() error -var MatchListList [][]string -var IntentsList = []string{} + +var IntentList []JsonIntent + +//var MatchListList [][]string +// var IntentsList = []string{} var ChipperCert []byte var ChipperKey []byte @@ -103,8 +108,9 @@ type RecurringInfoStore struct { } type JsonIntent struct { - Name string `json:"name"` - Keyphrases []string `json:"keyphrases"` + Name string `json:"name"` + Keyphrases []string `json:"keyphrases"` + RequireExactMatch bool `json:"requiresexact"` } type IntentsStruct []struct { @@ -142,6 +148,7 @@ func join(p1, p2 string) string { } func Init() { + logger.Println("Commit SHA: " + CommitSHA) if VarsInited { logger.Println("Not initting vars again") return @@ -278,7 +285,7 @@ func LoadCustomIntents() { } } -func LoadIntents() ([][]string, []string, error) { +func LoadIntents() ([]JsonIntent, error) { var path string if runtime.GOOS == "darwin" && Packaged { appPath, _ := os.Executable() @@ -290,24 +297,23 @@ func LoadIntents() ([][]string, []string, error) { } jsonFile, err := os.ReadFile(path + "intent-data/" + APIConfig.STT.Language + ".json") - var matches [][]string - var intents []string - + // var matches [][]string + // var intents []string + var jsonIntents []JsonIntent if err == nil { - var jsonIntents []JsonIntent err = json.Unmarshal(jsonFile, &jsonIntents) - if err != nil { - logger.Println("Failed to load intents: " + err.Error()) - } - - for _, element := range jsonIntents { - //logger.Println("Loading intent " + strconv.Itoa(index) + " --> " + element.Name + "( " + strconv.Itoa(len(element.Keyphrases)) + " keyphrases )") - intents = append(intents, element.Name) - matches = append(matches, element.Keyphrases) - } - logger.Println("Loaded " + strconv.Itoa(len(jsonIntents)) + " intents and " + strconv.Itoa(len(matches)) + " matches (language: " + APIConfig.STT.Language + ")") + // if err != nil { + // logger.Println("Failed to load intents: " + err.Error()) + // } + + // for _, element := range jsonIntents { + // //logger.Println("Loading intent " + strconv.Itoa(index) + " --> " + element.Name + "( " + strconv.Itoa(len(element.Keyphrases)) + " keyphrases )") + // intents = append(intents, element.Name) + // matches = append(matches, element.Keyphrases) + // } + // logger.Println("Loaded " + strconv.Itoa(len(jsonIntents)) + " intents and " + strconv.Itoa(len(matches)) + " matches (language: " + APIConfig.STT.Language + ")") } - return matches, intents, err + return jsonIntents, err } func WriteJdocs() { @@ -414,7 +420,10 @@ func AddToRInfo(esn string, id string, ip string) { } func SaveChats() { - marshalled, _ := json.Marshal(RememberedChats) + marshalled, err := json.Marshal(RememberedChats) + if err != nil { + logger.Println(err) + } os.WriteFile(SavedChatsPath, marshalled, 0777) } diff --git a/chipper/pkg/wirepod/config-ws/webserver.go b/chipper/pkg/wirepod/config-ws/webserver.go index a0a4443e..1d3ee319 100755 --- a/chipper/pkg/wirepod/config-ws/webserver.go +++ b/chipper/pkg/wirepod/config-ws/webserver.go @@ -10,7 +10,6 @@ import ( "path" "path/filepath" "runtime" - "strconv" "strings" "github.com/kercre123/wire-pod/chipper/pkg/logger" @@ -22,411 +21,429 @@ import ( var SttInitFunc func() error +type CustomIntent struct { + Name string `json:"name"` + Description string `json:"description"` + Utterances []string `json:"utterances"` + Intent string `json:"intent"` + Params struct { + ParamName string `json:"paramname"` + ParamValue string `json:"paramvalue"` + } `json:"params"` + Exec string `json:"exec"` + ExecArgs []string `json:"execargs"` + IsSystemIntent bool `json:"issystem"` +} + func apiHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "*") - switch { + + switch strings.TrimPrefix(r.URL.Path, "/api/") { + case "add_custom_intent": + handleAddCustomIntent(w, r) + case "edit_custom_intent": + handleEditCustomIntent(w, r) + case "get_custom_intents_json": + handleGetCustomIntentsJSON(w) + case "remove_custom_intent": + handleRemoveCustomIntent(w, r) + case "set_weather_api": + handleSetWeatherAPI(w, r) + case "get_weather_api": + handleGetWeatherAPI(w) + case "set_kg_api": + handleSetKGAPI(w, r) + case "get_kg_api": + handleGetKGAPI(w) + case "set_stt_info": + handleSetSTTInfo(w, r) + case "get_download_status": + handleGetDownloadStatus(w) + case "get_stt_info": + handleGetSTTInfo(w) + case "get_config": + handleGetConfig(w) + case "get_logs": + handleGetLogs(w) + case "get_debug_logs": + handleGetDebugLogs(w) + case "is_running": + handleIsRunning(w) + case "delete_chats": + handleDeleteChats(w) + case "get_ota": + handleGetOTA(w, r) + case "get_version_info": + handleGetVersionInfo(w) + case "generate_certs": + handleGenerateCerts(w) + case "is_api_v1": + fmt.Fprintf(w, "it is!") default: http.Error(w, "not found", http.StatusNotFound) + } +} + +func handleAddCustomIntent(w http.ResponseWriter, r *http.Request) { + var intent CustomIntent + if err := json.NewDecoder(r.Body).Decode(&intent); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) return - case r.URL.Path == "/api/add_custom_intent": - name := r.FormValue("name") - description := r.FormValue("description") - utterances := r.FormValue("utterances") - intent := r.FormValue("intent") - paramName := r.FormValue("paramname") - paramValue := r.FormValue("paramvalue") - exec := r.FormValue("exec") - execArgs := r.FormValue("execargs") - if name == "" || description == "" || utterances == "" || intent == "" { - fmt.Fprintf(w, "missing required field (name, description, utterances, and intent are required)") - return - } - vars.CustomIntentsExist = true - vars.CustomIntents = append(vars.CustomIntents, struct { - Name string `json:"name"` - Description string `json:"description"` - Utterances []string `json:"utterances"` - Intent string `json:"intent"` - Params struct { - ParamName string `json:"paramname"` - ParamValue string `json:"paramvalue"` - } `json:"params"` - Exec string `json:"exec"` - ExecArgs []string `json:"execargs"` - IsSystemIntent bool `json:"issystem"` - }{Name: name, Description: description, Utterances: strings.Split(utterances, ","), Intent: intent, Params: struct { - ParamName string `json:"paramname"` - ParamValue string `json:"paramvalue"` - }{ParamName: paramName, ParamValue: paramValue}, Exec: exec, ExecArgs: strings.Split(execArgs, ","), IsSystemIntent: false}) - customIntentJSONFile, _ := json.Marshal(vars.CustomIntents) - os.WriteFile(vars.CustomIntentsPath, customIntentJSONFile, 0644) - fmt.Fprintf(w, "intent added successfully") - return - case r.URL.Path == "/api/edit_custom_intent": - number := r.FormValue("number") - name := r.FormValue("name") - description := r.FormValue("description") - utterances := r.FormValue("utterances") - intent := r.FormValue("intent") - paramName := r.FormValue("paramname") - paramValue := r.FormValue("paramvalue") - exec := r.FormValue("exec") - execArgs := r.FormValue("execargs") - if number == "" { - fmt.Fprintf(w, "err: a number is required") - return - } - if name == "" && description == "" && utterances == "" && intent == "" && paramName == "" && paramValue == "" && exec == "" { - fmt.Fprintf(w, "err: an entry must be edited") - return - } - if !vars.CustomIntentsExist { - fmt.Fprintf(w, "err: you must create an intent first") - return - } - newNumbera, _ := strconv.Atoi(number) - newNumber := newNumbera - 1 - if newNumber > len(vars.CustomIntents) { - fmt.Fprintf(w, "err: there are only "+strconv.Itoa(len(vars.CustomIntents))+" intents") - return - } - if name != "" { - vars.CustomIntents[newNumber].Name = name - } - if description != "" { - vars.CustomIntents[newNumber].Description = description - } - if utterances != "" { - vars.CustomIntents[newNumber].Utterances = strings.Split(utterances, ",") - } - if intent != "" { - vars.CustomIntents[newNumber].Intent = intent - } - if paramName != "" { - vars.CustomIntents[newNumber].Params.ParamName = paramName - } - if paramValue != "" { - vars.CustomIntents[newNumber].Params.ParamValue = paramValue - } - if exec != "" { - vars.CustomIntents[newNumber].Exec = exec - } - if execArgs != "" { - vars.CustomIntents[newNumber].ExecArgs = strings.Split(execArgs, ",") - } - vars.CustomIntents[newNumber].IsSystemIntent = false - newCustomIntentJSONFile, _ := json.Marshal(vars.CustomIntents) - os.WriteFile(vars.CustomIntentsPath, newCustomIntentJSONFile, 0644) - fmt.Fprintf(w, "intent edited successfully") - return - case r.URL.Path == "/api/get_custom_intents_json": - if !vars.CustomIntentsExist { - fmt.Fprintf(w, "error: you must create an intent first") - return - } - customIntentJSONFile, err := os.ReadFile(vars.CustomIntentsPath) - if err != nil { - logger.Println(err) - } - fmt.Fprint(w, string(customIntentJSONFile)) - return - case r.URL.Path == "/api/remove_custom_intent": - number := r.FormValue("number") - if number == "" { - fmt.Fprintf(w, "error: a number is required") - return - } - if _, err := os.Stat(vars.CustomIntentsPath); err != nil { - fmt.Fprintf(w, "error: you must create an intent first") - return - } - newNumbera, _ := strconv.Atoi(number) - newNumber := newNumbera - 1 - if newNumber > len(vars.CustomIntents) { - fmt.Fprintf(w, "err: there are only "+strconv.Itoa(len(vars.CustomIntents))+" intents") - return - } - vars.CustomIntents = append(vars.CustomIntents[:newNumber], vars.CustomIntents[newNumber+1:]...) - newCustomIntentJSONFile, _ := json.Marshal(vars.CustomIntents) - os.WriteFile("./customIntents.json", newCustomIntentJSONFile, 0644) - fmt.Fprintf(w, "intent removed successfully") - return - case r.URL.Path == "/api/set_weather_api": - weatherProvider := r.FormValue("provider") - weatherAPIKey := r.FormValue("api_key") - if weatherProvider == "" { - vars.APIConfig.Weather.Enable = false - } else { - vars.APIConfig.Weather.Enable = true - vars.APIConfig.Weather.Key = strings.TrimSpace(weatherAPIKey) - vars.APIConfig.Weather.Provider = weatherProvider - } - vars.WriteConfigToDisk() - fmt.Fprintf(w, "Changes successfully applied.") - return - case r.URL.Path == "/api/get_weather_api": - weatherEnabled := false - weatherProvider := "" - weatherAPIKey := "" - if vars.APIConfig.Weather.Enable { - weatherEnabled = true - weatherProvider = vars.APIConfig.Weather.Provider - weatherAPIKey = vars.APIConfig.Weather.Key - } - fmt.Fprintf(w, "{ ") - fmt.Fprintf(w, " \"weatherEnabled\": %t,", weatherEnabled) - fmt.Fprintf(w, " \"weatherProvider\": \"%s\",", weatherProvider) - fmt.Fprintf(w, " \"weatherApiKey\": \"%s\"", weatherAPIKey) - fmt.Fprintf(w, "}") - return - case r.URL.Path == "/api/set_kg_api": - kgProvider := r.FormValue("provider") - kgAPIKey := r.FormValue("api_key") - // for houndify - kgAPIID := r.FormValue("api_id") - kgIntent := r.FormValue("intent_graph") - // for Together AI Service - kgModel := r.FormValue("model") - - if kgProvider == "" { - vars.APIConfig.Knowledge.Enable = false - } else { - vars.APIConfig.Knowledge.Enable = true - vars.APIConfig.Knowledge.Provider = kgProvider - vars.APIConfig.Knowledge.Key = strings.TrimSpace(kgAPIKey) - vars.APIConfig.Knowledge.Model = strings.TrimSpace(kgModel) - vars.APIConfig.Knowledge.ID = strings.TrimSpace(kgAPIID) - } - if kgModel == "" && kgProvider == "together" { - logger.Println("Together model wasn't provided, using default meta-llama/Llama-2-70b-chat-hf") - vars.APIConfig.Knowledge.Model = "meta-llama/Llama-2-70b-chat-hf" - } - if kgProvider == "openai" || kgProvider == "together" { - if strings.TrimSpace(r.FormValue("openai_prompt")) != "" { - vars.APIConfig.Knowledge.OpenAIPrompt = r.FormValue("openai_prompt") - } else { - vars.APIConfig.Knowledge.OpenAIPrompt = "" - } - if r.FormValue("save_chat") == "true" { - vars.APIConfig.Knowledge.SaveChat = true - } else { - vars.APIConfig.Knowledge.SaveChat = false - } - if r.FormValue("commands_enable") == "true" { - vars.APIConfig.Knowledge.CommandsEnable = true - } else { - vars.APIConfig.Knowledge.CommandsEnable = false - } - } - if (kgProvider == "openai" || kgProvider == "together") && kgIntent == "true" { - vars.APIConfig.Knowledge.IntentGraph = true - if r.FormValue("robot_name") == "" { - vars.APIConfig.Knowledge.RobotName = "Vector" - } else { - vars.APIConfig.Knowledge.RobotName = strings.TrimSpace(r.FormValue("robot_name")) - } - } else if (kgProvider == "openai" || kgProvider == "together") && kgIntent == "false" { - vars.APIConfig.Knowledge.IntentGraph = false - vars.APIConfig.Knowledge.RobotName = "" - } - vars.WriteConfigToDisk() - fmt.Fprintf(w, "Changes successfully applied.") + } + if anyEmpty(intent.Name, intent.Description, intent.Intent) || len(intent.Utterances) == 0 { + http.Error(w, "missing required field (name, description, utterances, and intent are required)", http.StatusBadRequest) return - case r.URL.Path == "/api/get_kg_api": - kgEnabled := false - kgProvider := "" - kgAPIKey := "" - kgAPIID := "" - kgModel := "" - kgIntent := false - kgRobotName := "" - kgOpenAIPrompt := "" - kgSavePrompt := false - kgCommandsEnable := false - if vars.APIConfig.Knowledge.Enable { - kgEnabled = true - kgProvider = vars.APIConfig.Knowledge.Provider - kgAPIKey = vars.APIConfig.Knowledge.Key - kgModel = vars.APIConfig.Knowledge.Model - kgAPIID = vars.APIConfig.Knowledge.ID - kgIntent = vars.APIConfig.Knowledge.IntentGraph - kgRobotName = vars.APIConfig.Knowledge.RobotName - kgOpenAIPrompt = vars.APIConfig.Knowledge.OpenAIPrompt - kgSavePrompt = vars.APIConfig.Knowledge.SaveChat - kgCommandsEnable = vars.APIConfig.Knowledge.CommandsEnable - } - fmt.Fprintf(w, "{ ") - fmt.Fprintf(w, " \"kgEnabled\": %t,", kgEnabled) - fmt.Fprintf(w, " \"kgProvider\": \"%s\",", kgProvider) - fmt.Fprintf(w, " \"kgApiKey\": \"%s\",", kgAPIKey) - fmt.Fprintf(w, " \"kgModel\": \"%s\",", kgModel) - fmt.Fprintf(w, " \"kgApiID\": \"%s\",", kgAPIID) - fmt.Fprintf(w, " \"kgIntentGraph\": \"%t\",", kgIntent) - fmt.Fprintf(w, " \"kgRobotName\": \"%s\",", kgRobotName) - fmt.Fprintf(w, " \"kgOpenAIPrompt\": \"%s\",", kgOpenAIPrompt) - fmt.Fprintf(w, " \"kgSaveChat\": \"%t\",", kgSavePrompt) - fmt.Fprintf(w, " \"kgCommandsEnable\": \"%t\"", kgCommandsEnable) - fmt.Fprintf(w, "}") + } + vars.CustomIntentsExist = true + vars.CustomIntents = append(vars.CustomIntents, intent) + saveCustomIntents() + fmt.Fprint(w, "Intent added successfully.") +} + +func handleEditCustomIntent(w http.ResponseWriter, r *http.Request) { + var request struct { + Number int `json:"number"` + CustomIntent + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) return - case r.URL.Path == "/api/set_stt_info": - language := r.FormValue("language") - if vars.APIConfig.STT.Service == "vosk" { - // check if language is valid - matched := false - for _, lang := range localization.ValidVoskModels { - if lang == language { - matched = true - break - } - } - if !matched { - fmt.Fprint(w, "error: language not valid") - return - } - // check if language is downloaded already - matched = false - for _, lang := range vars.DownloadedVoskModels { - if lang == language { - matched = true - break - } - } - if !matched { - go localization.DownloadVoskModel(language) - fmt.Fprint(w, "downloading language model") - } else { - vars.APIConfig.STT.Language = language - vars.APIConfig.PastInitialSetup = true - vars.WriteConfigToDisk() - processreqs.ReloadVosk() - logger.Println("Reloaded voice processor successfully") - fmt.Fprint(w, "language switched successfully") - } - } else if vars.APIConfig.STT.Service == "whisper.cpp" { - matched := false - for _, lang := range localization.ValidVoskModels { - if lang == language { - matched = true - break - } - } - if !matched { - fmt.Fprint(w, "error: language not valid") - return - } - - vars.APIConfig.STT.Language = language - vars.APIConfig.PastInitialSetup = true - vars.WriteConfigToDisk() - processreqs.ReloadVosk() - logger.Println("Reloaded voice processor successfully") - fmt.Fprint(w, "language switched successfully") - } else { - fmt.Fprint(w, "error: service must be vosk or whisper") - } + } + if request.Number < 1 || request.Number > len(vars.CustomIntents) { + http.Error(w, "invalid intent number", http.StatusBadRequest) return - case r.URL.Path == "/api/get_download_status": - fmt.Fprint(w, localization.DownloadStatus) - if localization.DownloadStatus == "success" || strings.Contains(localization.DownloadStatus, "error") { - localization.DownloadStatus = "not downloading" - } + } + intent := &vars.CustomIntents[request.Number-1] + if request.Name != "" { + intent.Name = request.Name + } + if request.Description != "" { + intent.Description = request.Description + } + if len(request.Utterances) != 0 { + intent.Utterances = request.Utterances + } + if request.Intent != "" { + intent.Intent = request.Intent + } + if request.Params.ParamName != "" { + intent.Params.ParamName = request.Params.ParamName + } + if request.Params.ParamValue != "" { + intent.Params.ParamValue = request.Params.ParamValue + } + if request.Exec != "" { + intent.Exec = request.Exec + } + if len(request.ExecArgs) != 0 { + intent.ExecArgs = request.ExecArgs + } + intent.IsSystemIntent = false + saveCustomIntents() + fmt.Fprint(w, "Intent edited successfully.") +} + +func handleGetCustomIntentsJSON(w http.ResponseWriter) { + if !vars.CustomIntentsExist { + http.Error(w, "you must create an intent first", http.StatusBadRequest) return - case r.URL.Path == "/api/get_stt_info": - sttLanguage := vars.APIConfig.STT.Language - sttProvider := vars.APIConfig.STT.Service - fmt.Fprintf(w, "{ ") - fmt.Fprintf(w, " \"sttProvider\": \"%s\",", sttProvider) - fmt.Fprintf(w, " \"sttLanguage\": \"%s\"", sttLanguage) - fmt.Fprintf(w, "}") + } + customIntentJSONFile, err := os.ReadFile(vars.CustomIntentsPath) + if err != nil { + http.Error(w, "could not read custom intents file", http.StatusInternalServerError) + logger.Println(err) return - case r.URL.Path == "/api/get_config": - writeBytes, _ := json.Marshal(vars.APIConfig) - w.Write(writeBytes) + } + w.Header().Set("Content-Type", "application/json") + w.Write(customIntentJSONFile) +} + +func handleRemoveCustomIntent(w http.ResponseWriter, r *http.Request) { + var request struct { + Number int `json:"number"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) return - case r.URL.Path == "/api/get_logs": - fmt.Fprintf(w, logger.LogList) + } + if request.Number < 1 || request.Number > len(vars.CustomIntents) { + http.Error(w, "invalid intent number", http.StatusBadRequest) return - case r.URL.Path == "/api/get_debug_logs": - fmt.Fprintf(w, logger.LogTrayList) + } + vars.CustomIntents = append(vars.CustomIntents[:request.Number-1], vars.CustomIntents[request.Number:]...) + saveCustomIntents() + fmt.Fprint(w, "Intent removed successfully.") +} + +func handleSetWeatherAPI(w http.ResponseWriter, r *http.Request) { + var config struct { + Provider string `json:"provider"` + Key string `json:"key"` + } + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) return - case r.URL.Path == "/api/is_running": - fmt.Fprintf(w, "true") + } + if config.Provider == "" { + vars.APIConfig.Weather.Enable = false + } else { + vars.APIConfig.Weather.Enable = true + vars.APIConfig.Weather.Key = strings.TrimSpace(config.Key) + vars.APIConfig.Weather.Provider = config.Provider + } + vars.WriteConfigToDisk() + fmt.Fprint(w, "Changes successfully applied.") +} + +func handleGetWeatherAPI(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vars.APIConfig.Weather) +} + +func handleSetKGAPI(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&vars.APIConfig.Knowledge); err != nil { + fmt.Println(err) + http.Error(w, "invalid request body", http.StatusBadRequest) return - case r.URL.Path == "/api/delete_chats": - os.Remove(vars.SavedChatsPath) - vars.RememberedChats = []vars.RememberedChat{} - fmt.Fprintf(w, "done") + } + vars.WriteConfigToDisk() + fmt.Fprint(w, "Changes successfully applied.") +} + +func handleGetKGAPI(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vars.APIConfig.Knowledge) +} + +func handleSetSTTInfo(w http.ResponseWriter, r *http.Request) { + var request struct { + Language string `json:"language"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) return - case strings.Contains(r.URL.Path, "/api/get_ota"): - otaName := strings.Split(r.URL.Path, "/")[3] - //https://archive.org/download/vector-pod-firmware/vicos-2.0.1.6076ep.ota - targetURL, err := url.Parse("https://archive.org/download/vector-pod-firmware/" + strings.TrimSpace(otaName)) - if err != nil { - http.Error(w, "Failed to parse URL", http.StatusInternalServerError) - return - } - req, err := http.NewRequest(r.Method, targetURL.String(), nil) - if err != nil { - http.Error(w, "Failed to create request", http.StatusInternalServerError) - return - } - for key, values := range r.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - http.Error(w, "Failed to perform request", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - for key, values := range resp.Header { - for _, value := range values { - w.Header().Add(key, value) - } - } - //w.WriteHeader(resp.StatusCode) - _, err = io.Copy(w, resp.Body) - if err != nil { - http.Error(w, "failed to copy response body", http.StatusInternalServerError) + } + if vars.APIConfig.STT.Service == "vosk" { + if !isValidLanguage(request.Language, localization.ValidVoskModels) { + http.Error(w, "language not valid", http.StatusBadRequest) return } - case r.URL.Path == "/api/get_version_info": - type VerInfo struct { - Installed string `json:"installed"` - Current string `json:"current"` - UpdateAvailable bool `json:"avail"` - } - var verInfo VerInfo - ver, err := os.ReadFile(vars.VersionFile) - if err != nil { - fmt.Fprint(w, "error: version file doesn't exist") + if !isDownloadedLanguage(request.Language, vars.DownloadedVoskModels) { + go localization.DownloadVoskModel(request.Language) + fmt.Fprint(w, "downloading language model...") return } - installedVer := strings.TrimSpace(string(ver)) - currentVer, err := GetLatestReleaseTag("kercre123", "WirePod") - if err != nil { - fmt.Fprint(w, "error comming with github: "+err.Error()) + } else if vars.APIConfig.STT.Service == "whisper.cpp" { + if !isValidLanguage(request.Language, localization.ValidVoskModels) { + http.Error(w, "language not valid", http.StatusBadRequest) return } - verInfo.Installed = installedVer - verInfo.Current = strings.TrimSpace(currentVer) - if installedVer != strings.TrimSpace(currentVer) { - verInfo.UpdateAvailable = true + } else { + http.Error(w, "service must be vosk or whisper", http.StatusBadRequest) + return + } + vars.APIConfig.STT.Language = request.Language + vars.APIConfig.PastInitialSetup = true + vars.WriteConfigToDisk() + processreqs.ReloadVosk() + logger.Println("Reloaded voice processor successfully") + fmt.Fprint(w, "Language switched successfully.") +} + +func handleGetDownloadStatus(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(localization.DownloadStatus)) + if localization.DownloadStatus == "success" || strings.Contains(localization.DownloadStatus, "error") { + localization.DownloadStatus = "not downloading" + } +} + +func handleGetSTTInfo(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vars.APIConfig.STT) +} + +func handleGetConfig(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vars.APIConfig) +} + +func handleGetLogs(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(logger.LogList)) +} + +func handleGetDebugLogs(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(logger.LogTrayList)) +} + +func handleIsRunning(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("true")) +} + +func handleDeleteChats(w http.ResponseWriter) { + os.Remove(vars.SavedChatsPath) + vars.RememberedChats = []vars.RememberedChat{} + fmt.Fprint(w, "done") +} + +func handleGetOTA(w http.ResponseWriter, r *http.Request) { + otaName := strings.Split(r.URL.Path, "/")[3] + targetURL, err := url.Parse("https://archive.org/download/vector-pod-firmware/" + strings.TrimSpace(otaName)) + if err != nil { + http.Error(w, "failed to parse URL", http.StatusInternalServerError) + return + } + req, err := http.NewRequest(r.Method, targetURL.String(), nil) + if err != nil { + http.Error(w, "failed to create request", http.StatusInternalServerError) + return + } + for key, values := range r.Header { + for _, value := range values { + req.Header.Add(key, value) } - marshalled, _ := json.Marshal(verInfo) - w.Write(marshalled) - case r.URL.Path == "/api/generate_certs": - err := botsetup.CreateCertCombo() - if err != nil { - fmt.Fprint(w, "error: "+err.Error()) + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + http.Error(w, "failed to perform request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) } - fmt.Fprint(w, "done") + } + _, err = io.Copy(w, resp.Body) + if err != nil { + http.Error(w, "failed to copy response body", http.StatusInternalServerError) + } +} + +func handleGetVersionInfo(w http.ResponseWriter) { + var installedVer string + ver, err := os.ReadFile(vars.VersionFile) + if err == nil { + installedVer = strings.TrimSpace(string(ver)) + } + currentVer, err := GetLatestReleaseTag("kercre123", "WirePod") + if err != nil { + http.Error(w, "error communicating with github (ver): "+err.Error(), http.StatusInternalServerError) + return + } + currentCommit, err := GetLatestCommitSha() + if err != nil { + http.Error(w, "error communicating with github (commit): "+err.Error(), http.StatusInternalServerError) + return + } + type VersionInfo struct { + FromSource bool `json:"fromsource"` + InstalledVer string `json:"installedversion"` + InstalledCommit string `json:"installedcommit"` + CurrentVer string `json:"currentversion"` + CurrentCommit string `json:"currentcommit"` + UpdateAvailable bool `json:"avail"` + } + var fromSource bool + if installedVer == "" { + fromSource = true + } + var uAvail bool + if fromSource { + uAvail = vars.CommitSHA != strings.TrimSpace(currentCommit) + } else { + uAvail = installedVer != strings.TrimSpace(currentVer) + } + verInfo := VersionInfo{ + FromSource: fromSource, + InstalledVer: installedVer, + InstalledCommit: vars.CommitSHA, + CurrentVer: strings.TrimSpace(currentVer), + CurrentCommit: strings.TrimSpace(currentCommit), + UpdateAvailable: uAvail, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(verInfo) +} + +func handleGenerateCerts(w http.ResponseWriter) { + if err := botsetup.CreateCertCombo(); err != nil { + http.Error(w, "error: "+err.Error(), http.StatusInternalServerError) return } + fmt.Fprint(w, "done") +} + +func saveCustomIntents() { + customIntentJSONFile, _ := json.Marshal(vars.CustomIntents) + os.WriteFile(vars.CustomIntentsPath, customIntentJSONFile, 0644) +} + +func DisableCachingAndSniffing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate;") + w.Header().Set("pragma", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + +func StartWebServer() { + botsetup.RegisterSSHAPI() + botsetup.RegisterBLEAPI() + http.HandleFunc("/api/", apiHandler) + http.HandleFunc("/session-certs/", certHandler) + var webRoot http.Handler + if runtime.GOOS == "darwin" && vars.Packaged { + appPath, _ := os.Executable() + webRoot = http.FileServer(http.Dir(filepath.Dir(appPath) + "/../Frameworks/chipper/webroot")) + } else if runtime.GOOS == "android" || runtime.GOOS == "ios" { + webRoot = http.FileServer(http.Dir(vars.AndroidPath + "/static/webroot")) + } else { + webRoot = http.FileServer(http.Dir("./webroot")) + } + http.Handle("/", DisableCachingAndSniffing(webRoot)) + fmt.Printf("Starting webserver at port " + vars.WebPort + " (http://localhost:" + vars.WebPort + ")\n") + if err := http.ListenAndServe(":"+vars.WebPort, nil); err != nil { + logger.Println("Error binding to " + vars.WebPort + ": " + err.Error()) + if vars.Packaged { + logger.ErrMsg("FATAL: Wire-pod was unable to bind to port " + vars.WebPort + ". Another process is likely using it. Exiting.") + } + os.Exit(1) + } +} + +func GetLatestCommitSha() (string, error) { + client := &http.Client{} + req, err := http.NewRequest("GET", "https://api.github.com/repos/kercre123/wire-pod/commits", nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get commits: %s", resp.Status) + } + type Commit struct { + Sha string `json:"sha"` + } + var commits []Commit + if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil { + return "", err + } + if len(commits) == 0 { + return "", fmt.Errorf("no commits found") + } + return commits[0].Sha[:7], nil } func GetLatestReleaseTag(owner, repo string) (string, error) { @@ -458,42 +475,42 @@ func certHandler(w http.ResponseWriter, r *http.Request) { case strings.Contains(r.URL.Path, "/session-certs/"): split := strings.Split(r.URL.Path, "/") if len(split) < 3 { - fmt.Fprint(w, "error: must request a cert by esn (ex. /session-certs/00e20145)") + http.Error(w, "must request a cert by esn (ex. /session-certs/00e20145)", http.StatusBadRequest) return } esn := split[2] fileBytes, err := os.ReadFile(path.Join(vars.SessionCertPath, esn)) if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, "error: cert does not exist") + http.Error(w, "cert does not exist", http.StatusNotFound) return } w.Write(fileBytes) - return } } -func StartWebServer() { - botsetup.RegisterSSHAPI() - botsetup.RegisterBLEAPI() - http.HandleFunc("/api/", apiHandler) - http.HandleFunc("/session-certs/", certHandler) - var webRoot http.Handler - if runtime.GOOS == "darwin" && vars.Packaged { - appPath, _ := os.Executable() - webRoot = http.FileServer(http.Dir(filepath.Dir(appPath) + "/../Frameworks/chipper/webroot")) - } else if runtime.GOOS == "android" || runtime.GOOS == "ios" { - webRoot = http.FileServer(http.Dir(vars.AndroidPath + "/static/webroot")) - } else { - webRoot = http.FileServer(http.Dir("./webroot")) +func anyEmpty(values ...string) bool { + for _, v := range values { + if v == "" { + return true + } } - http.Handle("/", webRoot) - fmt.Printf("Starting webserver at port " + vars.WebPort + " (http://localhost:" + vars.WebPort + ")\n") - if err := http.ListenAndServe(":"+vars.WebPort, nil); err != nil { - logger.Println("Error binding to " + vars.WebPort + ": " + err.Error()) - if vars.Packaged { - logger.ErrMsg("FATAL: Wire-pod was unable to bind to port " + vars.WebPort + ". Another process is likely using it. Exiting.") + return false +} + +func isValidLanguage(language string, validLanguages []string) bool { + for _, lang := range validLanguages { + if lang == language { + return true + } + } + return false +} + +func isDownloadedLanguage(language string, downloadedLanguages []string) bool { + for _, lang := range downloadedLanguages { + if lang == language { + return true } - os.Exit(1) } + return false } diff --git a/chipper/pkg/wirepod/localization/localization.go b/chipper/pkg/wirepod/localization/localization.go index 9223a4df..7421abe3 100755 --- a/chipper/pkg/wirepod/localization/localization.go +++ b/chipper/pkg/wirepod/localization/localization.go @@ -143,7 +143,7 @@ func GetText(key string) string { func ReloadVosk() { if vars.APIConfig.STT.Service == "vosk" || vars.APIConfig.STT.Service == "whisper.cpp" { - vars.MatchListList, vars.IntentsList, _ = vars.LoadIntents() + vars.IntentList, _ = vars.LoadIntents() vars.SttInitFunc() } } diff --git a/chipper/pkg/wirepod/preqs/intent.go b/chipper/pkg/wirepod/preqs/intent.go index df94ed81..c83839a4 100755 --- a/chipper/pkg/wirepod/preqs/intent.go +++ b/chipper/pkg/wirepod/preqs/intent.go @@ -26,7 +26,7 @@ func (s *Server) ProcessIntent(req *vtt.IntentRequest) (*vtt.IntentResponse, err ttr.IntentPass(req, "intent_system_noaudio", "", map[string]string{}, false) return nil, nil } - successMatched = ttr.ProcessTextAll(req, transcribedText, vars.MatchListList, vars.IntentsList, speechReq.IsOpus) + successMatched = ttr.ProcessTextAll(req, transcribedText, vars.IntentList, speechReq.IsOpus) } else { intent, slots, err := stiHandler(speechReq) if err != nil { @@ -43,7 +43,7 @@ func (s *Server) ProcessIntent(req *vtt.IntentRequest) (*vtt.IntentResponse, err return nil, nil } if !successMatched { - if vars.APIConfig.Knowledge.IntentGraph { + if vars.APIConfig.Knowledge.IntentGraph && vars.APIConfig.Knowledge.Enable { logger.Println("Making LLM request for device " + req.Device + "...") _, err := ttr.StreamingKGSim(req, req.Device, transcribedText) if err != nil { diff --git a/chipper/pkg/wirepod/preqs/intent_graph.go b/chipper/pkg/wirepod/preqs/intent_graph.go index 4388d33d..d3f8bfc7 100755 --- a/chipper/pkg/wirepod/preqs/intent_graph.go +++ b/chipper/pkg/wirepod/preqs/intent_graph.go @@ -25,7 +25,7 @@ func (s *Server) ProcessIntentGraph(req *vtt.IntentGraphRequest) (*vtt.IntentGra ttr.IntentPass(req, "intent_system_noaudio", "", map[string]string{}, false) return nil, nil } - successMatched = ttr.ProcessTextAll(req, transcribedText, vars.MatchListList, vars.IntentsList, speechReq.IsOpus) + successMatched = ttr.ProcessTextAll(req, transcribedText, vars.IntentList, speechReq.IsOpus) } else { intent, slots, err := stiHandler(speechReq) if err != nil { @@ -60,7 +60,7 @@ func (s *Server) ProcessIntentGraph(req *vtt.IntentGraphRequest) (*vtt.IntentGra // return nil, nil // } if !successMatched { - if vars.APIConfig.Knowledge.IntentGraph { + if vars.APIConfig.Knowledge.IntentGraph && vars.APIConfig.Knowledge.Enable { logger.Println("Making LLM request for device " + req.Device + "...") _, err := ttr.StreamingKGSim(req, req.Device, transcribedText) if err != nil { diff --git a/chipper/pkg/wirepod/preqs/knowledgegraph.go b/chipper/pkg/wirepod/preqs/knowledgegraph.go index 56cae8f1..17d55131 100755 --- a/chipper/pkg/wirepod/preqs/knowledgegraph.go +++ b/chipper/pkg/wirepod/preqs/knowledgegraph.go @@ -69,8 +69,12 @@ func houndifyKG(req sr.SpeechRequest) string { } func togetherRequest(transcribedText string) string { + // will also handle custom sendString := "You are a helpful robot called Vector. You will be given a question asked by a user and you must provide the best answer you can. It may not be punctuated or spelled correctly. Keep the answer concise yet informative. Here is the question: " + "\\" + "\"" + transcribedText + "\\" + "\"" + " , Answer: " url := "https://api.together.xyz/inference" + if vars.APIConfig.Knowledge.Provider == "custom" { + url = vars.APIConfig.Knowledge.Endpoint + } model := vars.APIConfig.Knowledge.Model formData := `{ "model": "` + model + `", diff --git a/chipper/pkg/wirepod/preqs/server.go b/chipper/pkg/wirepod/preqs/server.go index 4c3b9c73..cd18626b 100755 --- a/chipper/pkg/wirepod/preqs/server.go +++ b/chipper/pkg/wirepod/preqs/server.go @@ -15,8 +15,9 @@ type Server struct{} var VoiceProcessor = "" type JsonIntent struct { - Name string `json:"name"` - Keyphrases []string `json:"keyphrases"` + Name string `json:"name"` + Keyphrases []string `json:"keyphrases"` + RequireExactMatch bool `json:"requiresexact"` } var sttLanguage string = "en-US" @@ -32,7 +33,7 @@ var isSti bool = false func ReloadVosk() { if vars.APIConfig.STT.Service == "vosk" || vars.APIConfig.STT.Service == "whisper.cpp" { vars.SttInitFunc() - vars.MatchListList, vars.IntentsList, _ = vars.LoadIntents() + vars.IntentList, _ = vars.LoadIntents() } } @@ -44,7 +45,7 @@ func New(InitFunc func() error, SttHandler interface{}, voiceProcessor string) ( vars.APIConfig.STT.Language = "en-US" } sttLanguage = vars.APIConfig.STT.Language - vars.MatchListList, vars.IntentsList, _ = vars.LoadIntents() + vars.IntentList, _ = vars.LoadIntents() logger.Println("Initiating " + voiceProcessor + " voice processor with language " + sttLanguage) vars.SttInitFunc = InitFunc err := InitFunc() diff --git a/chipper/pkg/wirepod/preqs/stream_houndify.go b/chipper/pkg/wirepod/preqs/stream_houndify.go index 5e4f9fde..0fa49dd5 100644 --- a/chipper/pkg/wirepod/preqs/stream_houndify.go +++ b/chipper/pkg/wirepod/preqs/stream_houndify.go @@ -28,7 +28,7 @@ func StreamAudioToHoundify(sreq sr.SpeechRequest, client houndify.Client) string default: var chunk []byte chunk, err = sreq.GetNextStreamChunkOpus() - speechDone = sreq.DetectEndOfSpeech() + speechDone, _ = sreq.DetectEndOfSpeech() if err != nil { fmt.Println("End of stream") return diff --git a/chipper/pkg/wirepod/sdkapp/server.go b/chipper/pkg/wirepod/sdkapp/server.go index a88d8c22..b8264ab9 100755 --- a/chipper/pkg/wirepod/sdkapp/server.go +++ b/chipper/pkg/wirepod/sdkapp/server.go @@ -509,6 +509,15 @@ func camStreamHandler(w http.ResponseWriter, r *http.Request) { } } +func DisableCachingAndSniffing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate;") + w.Header().Set("pragma", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + func BeginServer() { if os.Getenv("JDOCS_PINGER_ENABLED") == "false" { PingerEnabled = false @@ -519,7 +528,7 @@ func BeginServer() { serverFiles = filepath.Join(vars.AndroidPath, "/static/webroot") } fileServer := http.FileServer(http.Dir(serverFiles)) - http.Handle("/sdk-app", fileServer) + http.Handle("/sdk-app", DisableCachingAndSniffing(fileServer)) // in jdocspinger.go http.HandleFunc("/ok:80", connCheck) http.HandleFunc("/ok", connCheck) diff --git a/chipper/pkg/wirepod/speechrequest/speechrequest.go b/chipper/pkg/wirepod/speechrequest/speechrequest.go index c6f504c4..c808b400 100755 --- a/chipper/pkg/wirepod/speechrequest/speechrequest.go +++ b/chipper/pkg/wirepod/speechrequest/speechrequest.go @@ -1,9 +1,13 @@ package speechrequest import ( + "bytes" "encoding/binary" "errors" + "fmt" + "math" "os" + "time" pb "github.com/digital-dream-labs/api/go/chipperpb" "github.com/digital-dream-labs/opus-go/opus" @@ -19,22 +23,23 @@ var debugWriteFile bool = false var debugFile *os.File type SpeechRequest struct { - Device string - Session string - FirstReq []byte - Stream interface{} - IsKG bool - IsIG bool - MicData []byte - DecodedMicData []byte - PrevLen int - PrevLenRaw int - InactiveFrames int - ActiveFrames int - VADInst *webrtcvad.VAD - LastAudioChunk []byte - IsOpus bool - OpusStream *opus.OggStream + Device string + Session string + FirstReq []byte + Stream interface{} + IsKG bool + IsIG bool + MicData []byte + DecodedMicData []byte + FilteredMicData []byte + PrevLen int + PrevLenRaw int + InactiveFrames int + ActiveFrames int + VADInst *webrtcvad.VAD + LastAudioChunk []byte + IsOpus bool + OpusStream *opus.OggStream } func BytesToSamples(buf []byte) []int16 { @@ -67,7 +72,7 @@ func (req *SpeechRequest) OpusDecode(chunk []byte) []byte { } return n } else { - return req.MicData + return chunk } } @@ -101,17 +106,15 @@ func BytesToIntVAD(stream opus.OggStream, data []byte, die bool, isOpus bool) [] } // Uses VAD to detect when the user stops speaking -func (req *SpeechRequest) DetectEndOfSpeech() bool { +func (req *SpeechRequest) DetectEndOfSpeech() (bool, bool) { // changes InactiveFrames and ActiveFrames in req inactiveNumMax := 23 - vad := req.VADInst - vad.SetMode(3) for _, chunk := range SplitVAD(req.LastAudioChunk) { - active, err := vad.Process(16000, chunk) + active, err := req.VADInst.Process(16000, chunk) if err != nil { logger.Println("VAD err:") logger.Println(err) - return true + return true, false } if active { req.ActiveFrames = req.ActiveFrames + 1 @@ -121,10 +124,87 @@ func (req *SpeechRequest) DetectEndOfSpeech() bool { } if req.InactiveFrames >= inactiveNumMax && req.ActiveFrames > 18 { logger.Println("(Bot " + req.Device + ") End of speech detected.") - return true + return true, true } } - return false + if req.ActiveFrames < 5 { + return false, false + } + return false, true +} + +func bytesToInt16(data []byte) ([]int16, error) { + var samples []int16 + buf := bytes.NewReader(data) + for buf.Len() > 0 { + var sample int16 + err := binary.Read(buf, binary.LittleEndian, &sample) + if err != nil { + return nil, err + } + samples = append(samples, sample) + } + return samples, nil +} + +func int16ToBytes(samples []int16) []byte { + buf := new(bytes.Buffer) + for _, sample := range samples { + err := binary.Write(buf, binary.LittleEndian, sample) + if err != nil { + return nil + } + } + return buf.Bytes() +} + +func applyGain(samples []int16, gain float64) []int16 { + for i, sample := range samples { + amplifiedSample := float64(sample) * gain + if amplifiedSample > math.MaxInt16 { + samples[i] = math.MaxInt16 + } else if amplifiedSample < math.MinInt16 { + samples[i] = math.MinInt16 + } else { + samples[i] = int16(amplifiedSample) + } + } + return samples +} + +// remove noise +func highPassFilter(data []byte) []byte { + bTime := time.Now() + sampleRate := 16000 + cutoffFreq := 300.0 + samples, err := bytesToInt16(data) + if err != nil { + return nil + } + samples = applyGain(samples, 5) + filteredSamples := make([]float64, len(samples)) + rc := 1.0 / (2.0 * math.Pi * cutoffFreq) + dt := 1.0 / float64(sampleRate) + alpha := dt / (rc + dt) + + previous := float64(samples[0]) + for i := 1; i < len(samples); i++ { + current := float64(samples[i]) + filtered := alpha * (filteredSamples[i-1] + current - previous) + filteredSamples[i] = filtered + previous = current + } + int16FilteredSamples := make([]int16, len(filteredSamples)) + for i, sample := range filteredSamples { + int16FilteredSamples[i] = int16(sample) + } + + gained := applyGain(int16FilteredSamples, 1.5) + if os.Getenv("DEBUG_PRINT_HIGHPASS") == "true" { + logger.Println("highpass filter took: " + fmt.Sprint(time.Since(bTime))) + } + + return int16ToBytes(gained) } // Converts a vtt.*Request to a SpeechRequest, which allows functions like DetectEndOfSpeech to work @@ -136,6 +216,7 @@ func ReqToSpeechRequest(req interface{}) SpeechRequest { request.PrevLen = 0 var err error request.VADInst, err = webrtcvad.New() + request.VADInst.SetMode(2) if err != nil { logger.Println(err) } @@ -172,9 +253,10 @@ func ReqToSpeechRequest(req interface{}) SpeechRequest { if isOpus { request.OpusStream = &opus.OggStream{} decodedFirstReq, _ := request.OpusStream.Decode(request.FirstReq) - request.FirstReq = decodedFirstReq + request.FirstReq = highPassFilter(decodedFirstReq) + request.FilteredMicData = append(request.FilteredMicData, request.FirstReq...) request.DecodedMicData = append(request.DecodedMicData, decodedFirstReq...) - request.LastAudioChunk = request.DecodedMicData[request.PrevLen:] + request.LastAudioChunk = request.FilteredMicData[request.PrevLen:] request.PrevLen = len(request.DecodedMicData) request.IsOpus = true } @@ -193,8 +275,9 @@ func (req *SpeechRequest) GetNextStreamChunk() ([]byte, error) { } req.MicData = append(req.MicData, chunk.InputAudio...) req.DecodedMicData = append(req.DecodedMicData, req.OpusDecode(chunk.InputAudio)...) + req.FilteredMicData = append(req.FilteredMicData, highPassFilter(req.OpusDecode(chunk.InputAudio))...) dataReturn := req.DecodedMicData[req.PrevLen:] - req.LastAudioChunk = req.DecodedMicData[req.PrevLen:] + req.LastAudioChunk = req.FilteredMicData[req.PrevLen:] req.PrevLen = len(req.DecodedMicData) return dataReturn, nil } else if str, ok := req.Stream.(pb.ChipperGrpc_StreamingIntentGraphServer); ok { @@ -206,8 +289,9 @@ func (req *SpeechRequest) GetNextStreamChunk() ([]byte, error) { } req.MicData = append(req.MicData, chunk.InputAudio...) req.DecodedMicData = append(req.DecodedMicData, req.OpusDecode(chunk.InputAudio)...) + req.FilteredMicData = append(req.FilteredMicData, highPassFilter(req.OpusDecode(chunk.InputAudio))...) dataReturn := req.DecodedMicData[req.PrevLen:] - req.LastAudioChunk = req.DecodedMicData[req.PrevLen:] + req.LastAudioChunk = req.FilteredMicData[req.PrevLen:] req.PrevLen = len(req.DecodedMicData) if debugWriteFile { debugFile.Write(chunk.InputAudio) @@ -222,8 +306,9 @@ func (req *SpeechRequest) GetNextStreamChunk() ([]byte, error) { } req.MicData = append(req.MicData, chunk.InputAudio...) req.DecodedMicData = append(req.DecodedMicData, req.OpusDecode(chunk.InputAudio)...) + req.FilteredMicData = append(req.FilteredMicData, highPassFilter(req.OpusDecode(chunk.InputAudio))...) dataReturn := req.DecodedMicData[req.PrevLen:] - req.LastAudioChunk = req.DecodedMicData[req.PrevLen:] + req.LastAudioChunk = req.FilteredMicData[req.PrevLen:] req.PrevLen = len(req.DecodedMicData) return dataReturn, nil } diff --git a/chipper/pkg/wirepod/stt/coqui/Coqui.go b/chipper/pkg/wirepod/stt/coqui/Coqui.go index 757f788f..47dfb995 100755 --- a/chipper/pkg/wirepod/stt/coqui/Coqui.go +++ b/chipper/pkg/wirepod/stt/coqui/Coqui.go @@ -70,7 +70,7 @@ func STT(req sr.SpeechRequest) (string, error) { return "", err } coquiStream.FeedAudioContent(sr.BytesToSamples(chunk)) - speechIsDone = req.DetectEndOfSpeech() + speechIsDone, _ = req.DetectEndOfSpeech() if speechIsDone { break } diff --git a/chipper/pkg/wirepod/stt/houndify/Houndify.go b/chipper/pkg/wirepod/stt/houndify/Houndify.go index 43827059..26eefb3e 100755 --- a/chipper/pkg/wirepod/stt/houndify/Houndify.go +++ b/chipper/pkg/wirepod/stt/houndify/Houndify.go @@ -58,7 +58,7 @@ func STT(sreq sr.SpeechRequest) (string, error) { default: var chunk []byte chunk, err = sreq.GetNextStreamChunkOpus() - speechDone = sreq.DetectEndOfSpeech() + speechDone, _ = sreq.DetectEndOfSpeech() if err != nil { fmt.Println("End of stream") return diff --git a/chipper/pkg/wirepod/stt/leopard/Leopard.go b/chipper/pkg/wirepod/stt/leopard/Leopard.go index 6941651e..d946e85c 100755 --- a/chipper/pkg/wirepod/stt/leopard/Leopard.go +++ b/chipper/pkg/wirepod/stt/leopard/Leopard.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - leopard "github.com/Picovoice/leopard/binding/go" + leopard "github.com/Picovoice/leopard/binding/go/v2" "github.com/kercre123/wire-pod/chipper/pkg/logger" sr "github.com/kercre123/wire-pod/chipper/pkg/wirepod/speechrequest" ) @@ -77,7 +77,7 @@ func STT(req sr.SpeechRequest) (transcribedText string, err error) { BotNumMu.Unlock() return "", err } - speechIsDone = req.DetectEndOfSpeech() + speechIsDone, _ = req.DetectEndOfSpeech() if speechIsDone { break } diff --git a/chipper/pkg/wirepod/stt/vosk/Vosk.go b/chipper/pkg/wirepod/stt/vosk/Vosk.go index 4405efc1..286c3203 100755 --- a/chipper/pkg/wirepod/stt/vosk/Vosk.go +++ b/chipper/pkg/wirepod/stt/vosk/Vosk.go @@ -185,7 +185,6 @@ func getRec(withGrm bool) (*vosk.VoskRecognizer, int) { func STT(req sr.SpeechRequest) (string, error) { logger.Println("(Bot " + req.Device + ", Vosk) Processing...") - speechIsDone := false var withGrm bool if (vars.APIConfig.Knowledge.IntentGraph || req.IsKG) || !GrammerEnable { logger.Println("Using general recognizer") @@ -203,9 +202,10 @@ func STT(req sr.SpeechRequest) (string, error) { if err != nil { return "", err } - rec.AcceptWaveform(chunk) - // has to be split into 320 []byte chunks for VAD - speechIsDone = req.DetectEndOfSpeech() + speechIsDone, doProcess := req.DetectEndOfSpeech() + if doProcess { + rec.AcceptWaveform(chunk) + } if speechIsDone { break } diff --git a/chipper/pkg/wirepod/stt/vosk/context.go b/chipper/pkg/wirepod/stt/vosk/context.go index 40f25ac6..72629410 100644 --- a/chipper/pkg/wirepod/stt/vosk/context.go +++ b/chipper/pkg/wirepod/stt/vosk/context.go @@ -24,8 +24,8 @@ func GetGrammerList(lang string) string { var wordsList []string var grammer string // add words in intent json - for _, words := range vars.MatchListList { - for _, word := range words { + for _, words := range vars.IntentList { + for _, word := range words.Keyphrases { wors := strings.Split(word, " ") for _, wor := range wors { found := model.FindWord(wor) diff --git a/chipper/pkg/wirepod/stt/whisper.cpp/WhisperCpp.go b/chipper/pkg/wirepod/stt/whisper.cpp/WhisperCpp.go index 55425d3f..bd99a28c 100644 --- a/chipper/pkg/wirepod/stt/whisper.cpp/WhisperCpp.go +++ b/chipper/pkg/wirepod/stt/whisper.cpp/WhisperCpp.go @@ -19,6 +19,26 @@ var Name string = "whisper.cpp" var context *whisper.Context var params whisper.Params +func padPCM(data []byte) []byte { + const sampleRate = 16000 + const minDurationMs = 1020 + const minDurationSamples = sampleRate * minDurationMs / 1000 + const bytesPerSample = 2 + + currentSamples := len(data) / bytesPerSample + + if currentSamples >= minDurationSamples { + return data + } + + logger.Println("Padding audio data to be 1000ms") + + paddingSamples := minDurationSamples - currentSamples + paddingBytes := make([]byte, paddingSamples*bytesPerSample) + + return append(data, paddingBytes...) +} + func Init() error { whispModel := os.Getenv("WHISPER_MODEL") if whispModel == "" { @@ -65,13 +85,12 @@ func STT(req sr.SpeechRequest) (string, error) { return "", err } // has to be split into 320 []byte chunks for VAD - speechIsDone = req.DetectEndOfSpeech() + speechIsDone, _ = req.DetectEndOfSpeech() if speechIsDone { break } } - - transcribedText, err := process(BytesToFloat32Buffer(req.DecodedMicData)) + transcribedText, err := process(BytesToFloat32Buffer(padPCM(req.DecodedMicData))) if err != nil { return "", err } diff --git a/chipper/pkg/wirepod/stt/whisper/Whisper.go b/chipper/pkg/wirepod/stt/whisper/Whisper.go index e5dd67a6..96e985b1 100755 --- a/chipper/pkg/wirepod/stt/whisper/Whisper.go +++ b/chipper/pkg/wirepod/stt/whisper/Whisper.go @@ -120,7 +120,7 @@ func STT(req sr.SpeechRequest) (string, error) { return "", err } // has to be split into 320 []byte chunks for VAD - speechIsDone = req.DetectEndOfSpeech() + speechIsDone, _ = req.DetectEndOfSpeech() if speechIsDone { break } diff --git a/chipper/pkg/wirepod/ttr/convert.go b/chipper/pkg/wirepod/ttr/convert.go new file mode 100644 index 00000000..42a8d81b --- /dev/null +++ b/chipper/pkg/wirepod/ttr/convert.go @@ -0,0 +1,92 @@ +package wirepod_ttr + +import ( + "encoding/binary" + "math" +) + +func bytesToInt16s(data []byte) []int16 { + int16s := make([]int16, len(data)/2) + for i := range int16s { + int16s[i] = int16(binary.LittleEndian.Uint16(data[i*2 : i*2+2])) + } + return int16s +} + +func int16sToBytes(data []int16) []byte { + bytes := make([]byte, len(data)*2) + for i, val := range data { + binary.LittleEndian.PutUint16(bytes[i*2:], uint16(val)) + } + return bytes +} + +func downsample24kTo16k(input []byte) [][]byte { + outBytes := downsample24kTo16kLinear(input) + var audioChunks [][]byte + filteredBytes := lowPassFilter(outBytes, 4000, 16000) + iVolBytes := increaseVolume(filteredBytes, 5) + for len(iVolBytes) > 0 { + if len(iVolBytes) < 1024 { + chunk := make([]byte, 1024) + copy(chunk, iVolBytes) + audioChunks = append(audioChunks, chunk) + break + } + audioChunks = append(audioChunks, iVolBytes[:1024]) + iVolBytes = iVolBytes[1024:] + } + + return audioChunks +} + +func increaseVolume(data []byte, factor float64) []byte { + int16s := bytesToInt16s(data) + + for i := range int16s { + scaled := float64(int16s[i]) * factor + if scaled > math.MaxInt16 { + int16s[i] = math.MaxInt16 + } else if scaled < math.MinInt16 { + int16s[i] = math.MinInt16 + } else { + int16s[i] = int16(scaled) + } + } + + return int16sToBytes(int16s) +} + +// this is copied +func lowPassFilter(data []byte, cutoffFreq float64, sampleRate int) []byte { + int16s := bytesToInt16s(data) + filtered := make([]int16, len(int16s)) + rc := 1.0 / (2 * 3.1416 * cutoffFreq) + dt := 1.0 / float64(sampleRate) + alpha := dt / (rc + dt) + filtered[0] = int16s[0] + for i := 1; i < len(int16s); i++ { + current := alpha*float64(int16s[i]) + (1-alpha)*float64(filtered[i-1]) + filtered[i] = int16(current) + } + + return int16sToBytes(filtered) +} + +// copied too +func downsample24kTo16kLinear(input []byte) []byte { + int16s := bytesToInt16s(input) + outputLength := (len(int16s) * 2) / 3 + output := make([]int16, outputLength) + + j := 0 + for i := 0; i < len(int16s)-2; i += 3 { + first := (2*int32(int16s[i]) + int32(int16s[i+1])) / 3 + second := (int32(int16s[i+1]) + 2*int32(int16s[i+2])) / 3 + output[j] = int16(first) + output[j+1] = int16(second) + j += 2 + } + + return int16sToBytes(output) +} diff --git a/chipper/pkg/wirepod/ttr/intentparam.go b/chipper/pkg/wirepod/ttr/intentparam.go index 7da68457..ba130f90 100755 --- a/chipper/pkg/wirepod/ttr/intentparam.go +++ b/chipper/pkg/wirepod/ttr/intentparam.go @@ -515,7 +515,7 @@ func ParamCheckerSlotsEnUS(req interface{}, intent string, slots map[string]stri IntentPass(req, newIntent, intent, intentParams, isParam) } -func prehistoricParamChecker(req interface{}, intent string, speechText string, botSerial string) { +func prehistoricParamChecker(req interface{}, intent string, speechText string) { // intent.go detects if the stream uses opus or PCM. // If the stream is PCM, it is likely a bot with 0.10. // This accounts for the newer 0.10.1### builds. diff --git a/chipper/pkg/wirepod/ttr/kgsim.go b/chipper/pkg/wirepod/ttr/kgsim.go index d3a760be..6e2b770d 100644 --- a/chipper/pkg/wirepod/ttr/kgsim.go +++ b/chipper/pkg/wirepod/ttr/kgsim.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "regexp" "strings" "time" @@ -31,6 +32,7 @@ func PlaceChat(chat vars.RememberedChat) { for i, achat := range vars.RememberedChats { if achat.ESN == chat.ESN { vars.RememberedChats[i] = chat + vars.SaveChats() return } } @@ -39,16 +41,10 @@ func PlaceChat(chat vars.RememberedChat) { } // remember last 16 lines of chat -func Remember(user, ai, esn string) { +func Remember(user, ai openai.ChatCompletionMessage, esn string) { chatAppend := []openai.ChatCompletionMessage{ - { - Role: openai.ChatMessageRoleUser, - Content: user, - }, - { - Role: openai.ChatMessageRoleAssistant, - Content: ai, - }, + user, + ai, } currentChat := GetChat(esn) if len(currentChat.Chats) == 16 { @@ -67,25 +63,19 @@ func Remember(user, ai, esn string) { PlaceChat(currentChat) } -func StreamingKGSim(req interface{}, esn string, transcribedText string) (string, error) { - var fullRespText string - var fullRespSlice []string - var isDone bool - var c *openai.Client - if vars.APIConfig.Knowledge.Provider == "together" { - if vars.APIConfig.Knowledge.Model == "" { - vars.APIConfig.Knowledge.Model = "meta-llama/Llama-2-70b-chat-hf" - vars.WriteConfigToDisk() - } - conf := openai.DefaultConfig(vars.APIConfig.Knowledge.Key) - conf.BaseURL = "https://api.together.xyz/v1" - c = openai.NewClientWithConfig(conf) - } else if vars.APIConfig.Knowledge.Provider == "openai" { - c = openai.NewClient(vars.APIConfig.Knowledge.Key) - } - ctx := context.Background() - speakReady := make(chan string) +func removeSpecialCharacters(str string) string { + re := regexp.MustCompile(`[&^*#@]`) + return removeEmojis(re.ReplaceAllString(str, "")) +} + +func removeEmojis(input string) string { + // a mess, but it works! + re := regexp.MustCompile(`[\x{1F600}-\x{1F64F}]|[\x{1F300}-\x{1F5FF}]|[\x{1F680}-\x{1F6FF}]|[\x{1F1E0}-\x{1F1FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]|[\x{1F900}-\x{1F9FF}]|[\x{1F004}]|[\x{1F0CF}]|[\x{1F18E}]|[\x{1F191}-\x{1F251}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]|[\x{1F004}-\x{1F0CF}]|[\x{1F191}-\x{1F251}]|[\x{2B50}]`) + result := re.ReplaceAllString(input, "") + return result +} +func CreateAIReq(transcribedText, esn string, gpt3tryagain bool) openai.ChatCompletionRequest { defaultPrompt := "You are a helpful, animated robot called Vector. Keep the response concise yet informative." var nChat []openai.ChatCompletionMessage @@ -99,7 +89,19 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string smsg.Content = defaultPrompt } - smsg.Content = CreatePrompt(smsg.Content) + var model string + + if gpt3tryagain { + model = openai.GPT3Dot5Turbo + } else if vars.APIConfig.Knowledge.Provider == "openai" { + model = openai.GPT4o + logger.Println("Using " + model) + } else { + logger.Println("Using " + vars.APIConfig.Knowledge.Model) + model = vars.APIConfig.Knowledge.Model + } + + smsg.Content = CreatePrompt(smsg.Content, model) nChat = append(nChat, smsg) if vars.APIConfig.Knowledge.SaveChat { @@ -113,23 +115,74 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string }) aireq := openai.ChatCompletionRequest{ - MaxTokens: 2048, - Messages: nChat, - Stream: true, + Model: model, + MaxTokens: 2048, + Temperature: 1, + TopP: 1, + FrequencyPenalty: 0, + PresencePenalty: 0, + Messages: nChat, + Stream: true, } - if vars.APIConfig.Knowledge.Provider == "openai" { - aireq.Model = "gpt-4-turbo" - logger.Println("Using " + aireq.Model) - } else { - logger.Println("Using " + vars.APIConfig.Knowledge.Model) - aireq.Model = vars.APIConfig.Knowledge.Model + return aireq +} + +func StreamingKGSim(req interface{}, esn string, transcribedText string) (string, error) { + matched := false + var robot *vector.Vector + var guid string + var target string + for _, bot := range vars.BotInfo.Robots { + if esn == bot.Esn { + guid = bot.GUID + target = bot.IPAddress + ":443" + matched = true + break + } + } + if matched { + var err error + robot, err = vector.New(vector.WithSerialNo(esn), vector.WithToken(guid), vector.WithTarget(target)) + if err != nil { + return err.Error(), err + } + } + _, err := robot.Conn.BatteryState(context.Background(), &vectorpb.BatteryStateRequest{}) + if err != nil { + return "", err + } + var fullRespText string + var fullfullRespText string + var fullRespSlice []string + var isDone bool + var c *openai.Client + if vars.APIConfig.Knowledge.Provider == "together" { + if vars.APIConfig.Knowledge.Model == "" { + vars.APIConfig.Knowledge.Model = "meta-llama/Llama-3-70b-chat-hf" + vars.WriteConfigToDisk() + } + conf := openai.DefaultConfig(vars.APIConfig.Knowledge.Key) + conf.BaseURL = "https://api.together.xyz/v1" + c = openai.NewClientWithConfig(conf) + } else if vars.APIConfig.Knowledge.Provider == "custom" { + conf := openai.DefaultConfig(vars.APIConfig.Knowledge.Key) + conf.BaseURL = vars.APIConfig.Knowledge.Endpoint + c = openai.NewClientWithConfig(conf) + } else if vars.APIConfig.Knowledge.Provider == "openai" { + c = openai.NewClient(vars.APIConfig.Knowledge.Key) } + ctx := context.Background() + speakReady := make(chan string) + successIntent := make(chan bool) + + aireq := CreateAIReq(transcribedText, esn, false) + stream, err := c.CreateChatCompletionStream(ctx, aireq) if err != nil { if strings.Contains(err.Error(), "does not exist") && vars.APIConfig.Knowledge.Provider == "openai" { logger.Println("GPT-4 model cannot be accessed with this API key. You likely need to add more than $5 dollars of funds to your OpenAI account.") logger.LogUI("GPT-4 model cannot be accessed with this API key. You likely need to add more than $5 dollars of funds to your OpenAI account.") - aireq.Model = openai.GPT3Dot5Turbo + aireq := CreateAIReq(transcribedText, esn, true) logger.Println("Falling back to " + aireq.Model) logger.LogUI("Falling back to " + aireq.Model) stream, err = c.CreateChatCompletionStream(ctx, aireq) @@ -141,13 +194,21 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string return "", err } } - //defer stream.Close() - + nChat := aireq.Messages + nChat = append(nChat, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + }) fmt.Println("LLM stream response: ") go func() { for { response, err := stream.Recv() if errors.Is(err, io.EOF) { + // if fullRespSlice != fullRespText, add that missing bit to fullRespSlice + if len(fullRespSlice) == 0 { + logger.Println("LLM returned no response") + successIntent <- false + break + } isDone = true newStr := fullRespSlice[0] for i, str := range fullRespSlice { @@ -156,8 +217,21 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string } newStr = newStr + " " + str } + if strings.TrimSpace(newStr) != strings.TrimSpace(fullfullRespText) { + logger.Println("LLM debug: there is content after the last punctuation mark") + extraBit := strings.TrimPrefix(fullRespText, newStr) + fullRespSlice = append(fullRespSlice, extraBit) + } if vars.APIConfig.Knowledge.SaveChat { - Remember(transcribedText, newStr, esn) + Remember(openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: transcribedText, + }, + openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: newStr, + }, + esn) } logger.LogUI("LLM response for " + esn + ": " + newStr) logger.Println("LLM stream finished") @@ -169,7 +243,8 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string return } - fullRespText = fullRespText + response.Choices[0].Delta.Content + fullfullRespText = fullfullRespText + removeSpecialCharacters(response.Choices[0].Delta.Content) + fullRespText = fullRespText + removeSpecialCharacters(response.Choices[0].Delta.Content) if strings.Contains(fullRespText, "...") || strings.Contains(fullRespText, ".'") || strings.Contains(fullRespText, ".\"") || strings.Contains(fullRespText, ".") || strings.Contains(fullRespText, "?") || strings.Contains(fullRespText, "!") { var sepStr string if strings.Contains(fullRespText, "...") { @@ -188,7 +263,10 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string splitResp := strings.Split(strings.TrimSpace(fullRespText), sepStr) fullRespSlice = append(fullRespSlice, strings.TrimSpace(splitResp[0])+sepStr) fullRespText = splitResp[1] - //fmt.Println("FROM OPENAI: " + splitResp[0]) + select { + case successIntent <- true: + default: + } select { case speakReady <- strings.TrimSpace(splitResp[0]) + sepStr: default: @@ -196,29 +274,15 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string } } }() - for range speakReady { - IntentPass(req, "intent_greeting_hello", transcribedText, map[string]string{}, false) - break - } - matched := false - var robot *vector.Vector - var guid string - var target string - for _, bot := range vars.BotInfo.Robots { - if esn == bot.Esn { - guid = bot.GUID - target = bot.IPAddress + ":443" - matched = true + for is := range successIntent { + if is { + IntentPass(req, "intent_greeting_hello", transcribedText, map[string]string{}, false) break + } else { + return "", errors.New("llm returned no response") } } - if matched { - var err error - robot, err = vector.New(vector.WithSerialNo(esn), vector.WithToken(guid), vector.WithTarget(target)) - if err != nil { - return err.Error(), err - } - } + time.Sleep(time.Millisecond * 200) controlRequest := &vectorpb.BehaviorControlRequest{ RequestType: &vectorpb.BehaviorControlRequest_ControlRequest{ ControlRequest: &vectorpb.ControlRequest{ @@ -310,6 +374,7 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string } }() } + var disconnect bool numInResp := 0 for { respSlice := fullRespSlice @@ -326,7 +391,11 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string } logger.Println(respSlice[numInResp]) acts := GetActionsFromString(respSlice[numInResp]) - PerformActions(acts, robot) + nChat[len(nChat)-1].Content = fullRespText + disconnect = PerformActions(nChat, acts, robot) + if disconnect { + break + } numInResp = numInResp + 1 } if !vars.APIConfig.Knowledge.CommandsEnable { @@ -336,15 +405,15 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string) (string } } time.Sleep(time.Millisecond * 100) - robot.Conn.PlayAnimation( - ctx, - &vectorpb.PlayAnimationRequest{ - Animation: &vectorpb.Animation{ - Name: "anim_knowledgegraph_success_01", - }, - Loops: 1, - }, - ) + // robot.Conn.PlayAnimation( + // ctx, + // &vectorpb.PlayAnimationRequest{ + // Animation: &vectorpb.Animation{ + // Name: "anim_knowledgegraph_success_01", + // }, + // Loops: 1, + // }, + // ) //time.Sleep(time.Millisecond * 3300) stop <- true } diff --git a/chipper/pkg/wirepod/ttr/kgsim_cmds.go b/chipper/pkg/wirepod/ttr/kgsim_cmds.go index d5be52f5..3453ff4a 100644 --- a/chipper/pkg/wirepod/ttr/kgsim_cmds.go +++ b/chipper/pkg/wirepod/ttr/kgsim_cmds.go @@ -2,12 +2,19 @@ package wirepod_ttr import ( "context" + "encoding/base64" + "errors" + "fmt" + "io" + "os" "strings" + "time" "github.com/fforchino/vector-go-sdk/pkg/vector" "github.com/fforchino/vector-go-sdk/pkg/vectorpb" "github.com/kercre123/wire-pod/chipper/pkg/logger" "github.com/kercre123/wire-pod/chipper/pkg/vars" + "github.com/sashabaranov/go-openai" ) const ( @@ -18,8 +25,10 @@ const ( ActionPlayAnimation = 1 // arg: animation name ActionPlayAnimationWI = 2 + // arg: now + ActionGetImage = 3 // arg: sound file - ActionPlaySound = 3 + ActionPlaySound = 4 ) var animationMap [][2]string = [][2]string{ @@ -64,6 +73,10 @@ var animationMap [][2]string = [][2]string{ "celebrate", "anim_pounce_success_03", }, + { + "love", + "anim_feedback_iloveyou_02", + }, } var soundMap [][2]string = [][2]string{ @@ -79,26 +92,37 @@ type RobotAction struct { } type LLMCommand struct { - Command string - Description string - ParamChoices string - Action int + Command string + Description string + ParamChoices string + Action int + SupportedModels []string } // create function which parses from LLM and makes a struct of RobotActions var ValidLLMCommands []LLMCommand = []LLMCommand{ { - Command: "playAnimation", - Description: "Plays an animation on the robot. This will interrupt speech.", - ParamChoices: "happy, veryHappy, sad, verySad, angry, frustrated, dartingEyes, confused, thinking, celebrate", - Action: ActionPlayAnimation, + Command: "playAnimationWI", + Description: "Plays an animation on the robot without interrupting speech. This should be used FAR more than the playAnimation command. This is great for storytelling and making any normal response animated. Don't put two of these right next to each other. Use this MANY times. The param choices are the only choices you have. You can't create any.", + ParamChoices: "happy, veryHappy, sad, verySad, angry, frustrated, dartingEyes, confused, thinking, celebrate, love", + Action: ActionPlayAnimationWI, + SupportedModels: []string{"all"}, + }, + { + Command: "playAnimation", + Description: "Plays an animation on the robot. This will interrupt speech. Only use this if you are directed to play an animaion.", + ParamChoices: "happy, veryHappy, sad, verySad, angry, frustrated, dartingEyes, confused, thinking, celebrate, love", + Action: ActionPlayAnimation, + SupportedModels: []string{"all"}, }, { - Command: "playAnimationWI", - Description: "Plays an animation on the robot without interrupting speech.", - ParamChoices: "happy, veryHappy, sad, verySad, angry, frustrated, dartingEyes, confused, thinking, celebrate", - Action: ActionPlayAnimationWI, + Command: "getImage", + Description: "Gets an image from the robot's camera and places it in the next message. If you want to do this, tell the user what you are about to do THEN use the command. This command should END a sentence. Your response will be stopped when this command is recognized. If a user says something like 'what do you see', you should assume that you need to take a new photo. Do NOT automatically assume that you are analyzing a previous photo.", + // not impl yet + ParamChoices: "front, lookingUp", + Action: ActionGetImage, + SupportedModels: []string{openai.GPT4o}, }, // { // Command: "playSound", @@ -108,13 +132,24 @@ var ValidLLMCommands []LLMCommand = []LLMCommand{ // }, } -func CreatePrompt(origPrompt string) string { - prompt := origPrompt + "\n\n" + "The user input might not be spelt/puntuated correctly as it is coming from speech-to-text software. Do not include special characters in your answer. This includes the following characters (not including the quotes): '& ^ * # @ -'. If you want to use a hyphen, Use it like this: 'something something -- something -- something something'." +func ModelIsSupported(cmd LLMCommand, model string) bool { + for _, str := range cmd.SupportedModels { + if str == "all" || str == model { + return true + } + } + return false +} + +func CreatePrompt(origPrompt string, model string) string { + prompt := origPrompt + "\n\n" + "The user input might not be spelt/punctuated correctly as it is coming from speech-to-text software. Do not include special characters in your answer. This includes the following characters (not including the quotes): '& ^ * # @ -'. DON'T INCLUDE THESE. DON'T MAKE LISTS WITH FORMATTING. THINK OF THE SPEECH-TO-TEXT ENGINE. If you want to use a hyphen, Use it like this: 'something something -- something -- something something'." if vars.APIConfig.Knowledge.CommandsEnable { - prompt = prompt + "\n\n" + "You are running ON an Anki Vector robot. You have a set of commands. YOU ARE TO USE THESE. DO NOT BE AFRAID TO LITTER YOUR RESPONSE WITH THEM. Your response MUST include THREE OF THESE COMMANDS OR MORE. You are going to litter your response with them. If you include just one, I will make you start over. If you include an emoji, I will make you start over. If you want to use a command but it doesn't exist or your desired parameter isn't in the list, avoid using the command. The format is {{command||parameter}}. You can embed these in sentences. Example: \"User: How are you feeling? | Response: \"{{playAnimationWI||sad}} I'm feeling sad...\". Square brackets ([]) are not valid.\n\nDO NOT USE EMOJIS! Use the playAnimation or playAnimationWI commands if you want to express emotion! IF YOU DO NOT ABIDE BY THESE RULES, I WILL CANCEL YOUR RESPONSE AND WILL MAKE YOU START OVER. You are very animated and good at following instructions. Animation takes precendence over words. You are to include many animations in your response.\n\nHere is every valid command:" + prompt = prompt + "\n\n" + "You are running ON an Anki Vector robot. You have a set of commands. If you include an emoji, I will make you start over. If you want to use a command but it doesn't exist or your desired parameter isn't in the list, avoid using the command. The format is {{command||parameter}}. You can embed these in sentences. Example: \"User: How are you feeling? | Response: \"{{playAnimationWI||sad}} I'm feeling sad...\". Square brackets ([]) are not valid.\n\nUse the playAnimation or playAnimationWI commands if you want to express emotion! You are very animated and good at following instructions. Animation takes precendence over words. You are to include many animations in your response.\n\nHere is every valid command:" for _, cmd := range ValidLLMCommands { - promptAppendage := "\n\nCommand Name: " + cmd.Command + "\nDescription: " + cmd.Description + "\nParameter choices: " + cmd.ParamChoices - prompt = prompt + promptAppendage + if ModelIsSupported(cmd, model) { + promptAppendage := "\n\nCommand Name: " + cmd.Command + "\nDescription: " + cmd.Description + "\nParameter choices: " + cmd.ParamChoices + prompt = prompt + promptAppendage + } } } return prompt @@ -181,6 +216,7 @@ func CmdParamToAction(cmd, param string) RobotAction { func DoPlayAnimation(animation string, robot *vector.Vector) error { for _, animThing := range animationMap { if animation == animThing[0] { + StartAnim_Queue(robot.Cfg.SerialNo) robot.Conn.PlayAnimation( context.Background(), &vectorpb.PlayAnimationRequest{ @@ -190,6 +226,7 @@ func DoPlayAnimation(animation string, robot *vector.Vector) error { Loops: 1, }, ) + StopAnim_Queue(robot.Cfg.SerialNo) return nil } } @@ -201,6 +238,7 @@ func DoPlayAnimationWI(animation string, robot *vector.Vector) error { for _, animThing := range animationMap { if animation == animThing[0] { go func() { + StartAnim_Queue(robot.Cfg.SerialNo) robot.Conn.PlayAnimation( context.Background(), &vectorpb.PlayAnimationRequest{ @@ -210,6 +248,7 @@ func DoPlayAnimationWI(animation string, robot *vector.Vector) error { Loops: 1, }, ) + StopAnim_Queue(robot.Cfg.SerialNo) }() return nil } @@ -229,6 +268,11 @@ func DoPlaySound(sound string, robot *vector.Vector) error { } func DoSayText(input string, robot *vector.Vector) error { + // TODO + if (vars.APIConfig.STT.Language != "en-US" && vars.APIConfig.Knowledge.Provider == "openai") || os.Getenv("USE_OPENAI_VOICE") == "true" { + err := DoSayText_OpenAI(robot, input) + return err + } robot.Conn.SayText( context.Background(), &vectorpb.SayTextRequest{ @@ -240,7 +284,288 @@ func DoSayText(input string, robot *vector.Vector) error { return nil } -func PerformActions(actions []RobotAction, robot *vector.Vector) { +func pcmLength(data []byte) time.Duration { + bytesPerSample := 2 + sampleRate := 16000 + numSamples := len(data) / bytesPerSample + duration := time.Duration(numSamples*1000/sampleRate) * time.Millisecond + return duration +} + +func getOpenAIVoice(voice string) openai.SpeechVoice { + voiceMap := map[string]openai.SpeechVoice{ + "alloy": openai.VoiceAlloy, + "onyx": openai.VoiceOnyx, + "fable": openai.VoiceFable, + "shimmer": openai.VoiceShimmer, + "nova": openai.VoiceNova, + "echo": openai.VoiceEcho, + "": openai.VoiceFable, + } + return voiceMap[voice] +} + +// TODO +func DoSayText_OpenAI(robot *vector.Vector, input string) error { + if strings.TrimSpace(input) == "" { + return nil + } + openaiVoice := getOpenAIVoice(vars.APIConfig.Knowledge.OpenAIPrompt) + // if vars.APIConfig.Knowledge.OpenAIVoice == "" { + // openaiVoice = openai.VoiceFable + // } else { + // openaiVoice = getOpenAIVoice(vars.APIConfig.Knowledge.OpenAIPrompt) + // } + oc := openai.NewClient(vars.APIConfig.Knowledge.Key) + resp, err := oc.CreateSpeech(context.Background(), openai.CreateSpeechRequest{ + Model: openai.TTSModel1, + Input: input, + Voice: openaiVoice, + ResponseFormat: openai.SpeechResponseFormatPcm, + }) + if err != nil { + logger.Println(err) + return err + } + speechBytes, _ := io.ReadAll(resp) + vclient, err := robot.Conn.ExternalAudioStreamPlayback(context.Background()) + if err != nil { + return err + } + vclient.Send(&vectorpb.ExternalAudioStreamRequest{ + AudioRequestType: &vectorpb.ExternalAudioStreamRequest_AudioStreamPrepare{ + AudioStreamPrepare: &vectorpb.ExternalAudioStreamPrepare{ + AudioFrameRate: 16000, + AudioVolume: 100, + }, + }, + }) + //time.Sleep(time.Millisecond * 30) + audioChunks := downsample24kTo16k(speechBytes) + + var chunksToDetermineLength []byte + for _, chunk := range audioChunks { + chunksToDetermineLength = append(chunksToDetermineLength, chunk...) + } + go func() { + for _, chunk := range audioChunks { + vclient.Send(&vectorpb.ExternalAudioStreamRequest{ + AudioRequestType: &vectorpb.ExternalAudioStreamRequest_AudioStreamChunk{ + AudioStreamChunk: &vectorpb.ExternalAudioStreamChunk{ + AudioChunkSizeBytes: 1024, + AudioChunkSamples: chunk, + }, + }, + }) + time.Sleep(time.Millisecond * 25) + } + vclient.Send(&vectorpb.ExternalAudioStreamRequest{ + AudioRequestType: &vectorpb.ExternalAudioStreamRequest_AudioStreamComplete{ + AudioStreamComplete: &vectorpb.ExternalAudioStreamComplete{}, + }, + }) + }() + time.Sleep(pcmLength(chunksToDetermineLength) + (time.Millisecond * 50)) + return nil +} + +func DoGetImage(msgs []openai.ChatCompletionMessage, param string, robot *vector.Vector) { + logger.Println("Get image here...") + // get image + robot.Conn.EnableMirrorMode(context.Background(), &vectorpb.EnableMirrorModeRequest{ + Enable: true, + }) + for i := 3; i > 0; i-- { + time.Sleep(time.Millisecond * 300) + robot.Conn.SayText( + context.Background(), + &vectorpb.SayTextRequest{ + Text: fmt.Sprint(i), + UseVectorVoice: true, + DurationScalar: 1.05, + }, + ) + } + resp, _ := robot.Conn.CaptureSingleImage( + context.Background(), + &vectorpb.CaptureSingleImageRequest{ + EnableHighResolution: true, + }, + ) + robot.Conn.EnableMirrorMode( + context.Background(), + &vectorpb.EnableMirrorModeRequest{ + Enable: false, + }, + ) + go func() { + robot.Conn.PlayAnimation( + context.Background(), + &vectorpb.PlayAnimationRequest{ + Animation: &vectorpb.Animation{ + Name: "anim_photo_shutter_01", + }, + Loops: 1, + }, + ) + }() + // encode to base64 + reqBase64 := base64.StdEncoding.EncodeToString(resp.Data) + + // add image to messages + msgs = append(msgs, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + MultiContent: []openai.ChatMessagePart{ + { + Type: openai.ChatMessagePartTypeImageURL, + ImageURL: &openai.ChatMessageImageURL{ + URL: fmt.Sprintf("data:image/jpeg;base64,%s", reqBase64), + Detail: openai.ImageURLDetailLow, + }, + }, + }, + }) + + // recreate openai + var fullRespText string + var fullfullRespText string + var fullRespSlice []string + var isDone bool + var c *openai.Client + if vars.APIConfig.Knowledge.Provider == "together" { + if vars.APIConfig.Knowledge.Model == "" { + vars.APIConfig.Knowledge.Model = "meta-llama/Llama-2-70b-chat-hf" + vars.WriteConfigToDisk() + } + conf := openai.DefaultConfig(vars.APIConfig.Knowledge.Key) + conf.BaseURL = "https://api.together.xyz/v1" + c = openai.NewClientWithConfig(conf) + } else if vars.APIConfig.Knowledge.Provider == "openai" { + c = openai.NewClient(vars.APIConfig.Knowledge.Key) + } + ctx := context.Background() + speakReady := make(chan string) + + aireq := openai.ChatCompletionRequest{ + MaxTokens: 2048, + Temperature: 1, + TopP: 1, + FrequencyPenalty: 0, + PresencePenalty: 0, + Messages: msgs, + Stream: true, + } + if vars.APIConfig.Knowledge.Provider == "openai" { + aireq.Model = openai.GPT4o + logger.Println("Using " + aireq.Model) + } else { + logger.Println("Using " + vars.APIConfig.Knowledge.Model) + aireq.Model = vars.APIConfig.Knowledge.Model + } + stream, err := c.CreateChatCompletionStream(ctx, aireq) + if err != nil { + if strings.Contains(err.Error(), "does not exist") && vars.APIConfig.Knowledge.Provider == "openai" { + logger.Println("GPT-4 model cannot be accessed with this API key. You likely need to add more than $5 dollars of funds to your OpenAI account.") + logger.LogUI("GPT-4 model cannot be accessed with this API key. You likely need to add more than $5 dollars of funds to your OpenAI account.") + aireq.Model = openai.GPT3Dot5Turbo + logger.Println("Falling back to " + aireq.Model) + logger.LogUI("Falling back to " + aireq.Model) + stream, err = c.CreateChatCompletionStream(ctx, aireq) + if err != nil { + logger.Println("OpenAI still not returning a response even after falling back. Erroring.") + return + } + } else { + logger.Println("LLM error: " + err.Error()) + return + } + } + //defer stream.Close() + + fmt.Println("LLM stream response: ") + go func() { + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + isDone = true + newStr := fullRespSlice[0] + for i, str := range fullRespSlice { + if i == 0 { + continue + } + newStr = newStr + " " + str + } + if strings.TrimSpace(newStr) != strings.TrimSpace(fullfullRespText) { + logger.Println("LLM debug: there is content after the last punctuation mark") + extraBit := strings.TrimPrefix(fullRespText, newStr) + fullRespSlice = append(fullRespSlice, extraBit) + } + if vars.APIConfig.Knowledge.SaveChat { + Remember(msgs[len(msgs)-1], + openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: newStr, + }, + robot.Cfg.SerialNo) + } + logger.LogUI("LLM response for " + robot.Cfg.SerialNo + ": " + newStr) + logger.Println("LLM stream finished") + return + } + + if err != nil { + logger.Println("Stream error: " + err.Error()) + return + } + fullfullRespText = fullfullRespText + removeSpecialCharacters(response.Choices[0].Delta.Content) + fullRespText = fullRespText + removeSpecialCharacters(response.Choices[0].Delta.Content) + if strings.Contains(fullRespText, "...") || strings.Contains(fullRespText, ".'") || strings.Contains(fullRespText, ".\"") || strings.Contains(fullRespText, ".") || strings.Contains(fullRespText, "?") || strings.Contains(fullRespText, "!") { + var sepStr string + if strings.Contains(fullRespText, "...") { + sepStr = "..." + } else if strings.Contains(fullRespText, ".'") { + sepStr = ".'" + } else if strings.Contains(fullRespText, ".\"") { + sepStr = ".\"" + } else if strings.Contains(fullRespText, ".") { + sepStr = "." + } else if strings.Contains(fullRespText, "?") { + sepStr = "?" + } else if strings.Contains(fullRespText, "!") { + sepStr = "!" + } + splitResp := strings.Split(strings.TrimSpace(fullRespText), sepStr) + fullRespSlice = append(fullRespSlice, strings.TrimSpace(splitResp[0])+sepStr) + fullRespText = splitResp[1] + select { + case speakReady <- strings.TrimSpace(splitResp[0]) + sepStr: + default: + } + } + } + }() + numInResp := 0 + for { + respSlice := fullRespSlice + if len(respSlice)-1 < numInResp { + if !isDone { + logger.Println("Waiting for more content from LLM...") + for range speakReady { + respSlice = fullRespSlice + break + } + } else { + break + } + } + logger.Println(respSlice[numInResp]) + acts := GetActionsFromString(respSlice[numInResp]) + PerformActions(msgs, acts, robot) + numInResp = numInResp + 1 + } +} + +func PerformActions(msgs []openai.ChatCompletionMessage, actions []RobotAction, robot *vector.Vector) bool { // assuming we have behavior control already for _, action := range actions { switch { @@ -250,8 +575,68 @@ func PerformActions(actions []RobotAction, robot *vector.Vector) { DoPlayAnimation(action.Parameter, robot) case action.Action == ActionPlayAnimationWI: DoPlayAnimationWI(action.Parameter, robot) + case action.Action == ActionGetImage: + DoGetImage(msgs, action.Parameter, robot) + return true case action.Action == ActionPlaySound: DoPlaySound(action.Parameter, robot) } } + WaitForAnim_Queue(robot.Cfg.SerialNo) + return false +} + +func WaitForAnim_Queue(esn string) { + for i, q := range AnimationQueues { + if q.ESN == esn { + if q.AnimCurrentlyPlaying { + for range AnimationQueues[i].AnimDone { + break + } + return + } + } + } +} + +func StartAnim_Queue(esn string) { + // if animation is already playing, just wait for it to be done + for i, q := range AnimationQueues { + if q.ESN == esn { + if q.AnimCurrentlyPlaying { + for range AnimationQueues[i].AnimDone { + logger.Println("(waiting for animation to be done...)") + break + } + } else { + AnimationQueues[i].AnimCurrentlyPlaying = true + } + return + } + } + var aq AnimationQueue + aq.AnimCurrentlyPlaying = true + aq.AnimDone = make(chan bool) + aq.ESN = esn + AnimationQueues = append(AnimationQueues, aq) +} + +func StopAnim_Queue(esn string) { + for i, q := range AnimationQueues { + if q.ESN == esn { + AnimationQueues[i].AnimCurrentlyPlaying = false + select { + case AnimationQueues[i].AnimDone <- true: + default: + } + } + } } + +type AnimationQueue struct { + ESN string + AnimDone chan bool + AnimCurrentlyPlaying bool +} + +var AnimationQueues []AnimationQueue diff --git a/chipper/pkg/wirepod/ttr/matchIntentSend.go b/chipper/pkg/wirepod/ttr/matchIntentSend.go index e3760a8c..7cd70bd5 100755 --- a/chipper/pkg/wirepod/ttr/matchIntentSend.go +++ b/chipper/pkg/wirepod/ttr/matchIntentSend.go @@ -96,7 +96,7 @@ func IntentPass(req interface{}, intentThing string, speechText string, intentPa } } -func customIntentHandler(req interface{}, voiceText string, intentList []string, isOpus bool, botSerial string) bool { +func customIntentHandler(req interface{}, voiceText string, botSerial string) bool { var successMatched bool = false if vars.CustomIntentsExist { for _, c := range vars.CustomIntents { @@ -228,7 +228,7 @@ func pluginFunctionHandler(req interface{}, voiceText string, botSerial string) return matched } -func ProcessTextAll(req interface{}, voiceText string, listOfLists [][]string, intentList []string, isOpus bool) bool { +func ProcessTextAll(req interface{}, voiceText string, intents []vars.JsonIntent, isOpus bool) bool { var botSerial string var req2 *vtt.IntentRequest var req1 *vtt.KnowledgeGraphRequest @@ -248,18 +248,18 @@ func ProcessTextAll(req interface{}, voiceText string, listOfLists [][]string, i var successMatched bool = false voiceText = strings.ToLower(voiceText) pluginMatched := pluginFunctionHandler(req, voiceText, botSerial) - customIntentMatched := customIntentHandler(req, voiceText, intentList, isOpus, botSerial) + customIntentMatched := customIntentHandler(req, voiceText, botSerial) if !customIntentMatched && !pluginMatched { logger.Println("Not a custom intent") // Look for a perfect match first - for _, b := range listOfLists { - for _, c := range b { + for _, b := range intents { + for _, c := range b.Keyphrases { if voiceText == strings.ToLower(c) { - logger.Println("Bot " + botSerial + " Perfect match for intent " + intentList[intentNum] + " (" + strings.ToLower(c) + ")") + logger.Println("Bot " + botSerial + " Perfect match for intent " + b.Name + " (" + strings.ToLower(c) + ")") if isOpus { - ParamChecker(req, intentList[intentNum], voiceText, botSerial) + ParamChecker(req, b.Name, voiceText, botSerial) } else { - prehistoricParamChecker(req, intentList[intentNum], voiceText, botSerial) + prehistoricParamChecker(req, b.Name, voiceText) } successMatched = true matched = 1 @@ -276,14 +276,14 @@ func ProcessTextAll(req interface{}, voiceText string, listOfLists [][]string, i if !successMatched { intentNum = 0 matched = 0 - for _, b := range listOfLists { - for _, c := range b { - if strings.Contains(voiceText, strings.ToLower(c)) { - logger.Println("Bot " + botSerial + " Partial match for intent " + intentList[intentNum] + " (" + strings.ToLower(c) + ")") + for _, b := range intents { + for _, c := range b.Keyphrases { + if strings.Contains(voiceText, strings.ToLower(c)) && !b.RequireExactMatch { + logger.Println("Bot " + botSerial + " Partial match for intent " + b.Name + " (" + strings.ToLower(c) + ")") if isOpus { - ParamChecker(req, intentList[intentNum], voiceText, botSerial) + ParamChecker(req, b.Name, voiceText, botSerial) } else { - prehistoricParamChecker(req, intentList[intentNum], voiceText, botSerial) + prehistoricParamChecker(req, b.Name, voiceText) } successMatched = true matched = 1 diff --git a/chipper/pkg/wirepod/ttr/weather.go b/chipper/pkg/wirepod/ttr/weather.go index 6fc1e3d5..67dba3c1 100755 --- a/chipper/pkg/wirepod/ttr/weather.go +++ b/chipper/pkg/wirepod/ttr/weather.go @@ -261,6 +261,7 @@ func getWeather(location string, botUnits string, hoursFromNow int) (string, str err = json.Unmarshal([]byte(geoCodingResponse), &geoCodingInfoStruct) if err != nil { logger.Println(err) + logger.Println("Geolocation API error: " + geoCodingResponse) } if len(geoCodingInfoStruct) == 0 { logger.Println("Geo provided no response.") diff --git a/chipper/pkg/wirepod/ttr/words2num.go b/chipper/pkg/wirepod/ttr/words2num.go index cec1eed3..a4e76489 100755 --- a/chipper/pkg/wirepod/ttr/words2num.go +++ b/chipper/pkg/wirepod/ttr/words2num.go @@ -45,7 +45,8 @@ var textToNumber = map[string]int{ } func words2num(input string) string { - if os.Getenv("STT_SERVICE") == "whisper.cpp" { + containsNum, _ := regexp.MatchString(`\b\d+\b`, input) + if os.Getenv("STT_SERVICE") == "whisper.cpp" && containsNum { return whisperSpeechtoNum(input) } totalSeconds := 0 diff --git a/chipper/start.sh b/chipper/start.sh index 323404bc..861d9cab 100755 --- a/chipper/start.sh +++ b/chipper/start.sh @@ -1,6 +1,7 @@ #!/bin/bash UNAME=$(uname -a) +COMMIT_HASH="$(git rev-parse --short HEAD)" if [[ $EUID -ne 0 ]]; then echo "This script must be run as root. sudo ./start.sh" @@ -34,50 +35,52 @@ if [[ ${USE_INBUILT_BLE} == "true" ]]; then GOTAGS="${GOTAGS},inbuiltble" fi +export GOLDFLAGS="-X 'github.com/kercre123/wire-pod/chipper/pkg/vars.CommitSHA=${COMMIT_HASH}'" + #./chipper if [[ ${STT_SERVICE} == "leopard" ]]; then if [[ -f ./chipper ]]; then ./chipper else - /usr/local/go/bin/go -tags $GOTAGS run cmd/leopard/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/leopard/main.go fi elif [[ ${STT_SERVICE} == "rhino" ]]; then if [[ -f ./chipper ]]; then ./chipper else - /usr/local/go/bin/go -tags $GOTAGS run cmd/experimental/rhino/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/experimental/rhino/main.go fi elif [[ ${STT_SERVICE} == "houndify" ]]; then if [[ -f ./chipper ]]; then ./chipper else - /usr/local/go/bin/go -tags $GOTAGS run cmd/experimental/houndify/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/experimental/houndify/main.go fi elif [[ ${STT_SERVICE} == "whisper" ]]; then if [[ -f ./chipper ]]; then ./chipper else - /usr/local/go/bin/go -tags $GOTAGS run cmd/experimental/whisper/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/experimental/whisper/main.go fi elif [[ ${STT_SERVICE} == "whisper.cpp" ]]; then if [[ -f ./chipper ]]; then export C_INCLUDE_PATH="../whisper.cpp" export LIBRARY_PATH="../whisper.cpp" - export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$(pwd)/../whisper.cpp" + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$(pwd)/../whisper.cpp:$(pwd)/../whisper.cpp/build:$(pwd)/../whisper.cpp/build/src" export CGO_LDFLAGS="-L$(pwd)/../whisper.cpp" export CGO_CFLAGS="-I$(pwd)/../whisper.cpp" ./chipper else export C_INCLUDE_PATH="../whisper.cpp" export LIBRARY_PATH="../whisper.cpp" - export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$(pwd)/../whisper.cpp" - export CGO_LDFLAGS="-L$(pwd)/../whisper.cpp" - export CGO_CFLAGS="-I$(pwd)/../whisper.cpp" + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$(pwd)/../whisper.cpp:$(pwd)/../whisper.cpp/build" + export CGO_LDFLAGS="-L$(pwd)/../whisper.cpp -L$(pwd)/../whisper.cpp/build -L$(pwd)/../whisper.cpp/build/src -L$(pwd)/../whisper.cpp/build/ggml/src" + export CGO_CFLAGS="-I$(pwd)/../whisper.cpp -I$(pwd)/../whisper.cpp/include -I$(pwd)/../whisper.cpp/ggml/include" if [[ ${UNAME} == *"Darwin"* ]]; then export GGML_METAL_PATH_RESOURCES="../whisper.cpp" /usr/local/go/bin/go run -tags $GOTAGS -ldflags "-extldflags '-framework Foundation -framework Metal -framework MetalKit'" cmd/experimental/whisper.cpp/main.go else - /usr/local/go/bin/go run -tags $GOTAGS cmd/experimental/whisper.cpp/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/experimental/whisper.cpp/main.go fi fi elif [[ ${STT_SERVICE} == "vosk" ]]; then @@ -89,10 +92,10 @@ if [[ ${STT_SERVICE} == "leopard" ]]; then ./chipper else export CGO_ENABLED=1 - export CGO_CFLAGS="-I/root/.vosk/libvosk" - export CGO_LDFLAGS="-L /root/.vosk/libvosk -lvosk -ldl -lpthread" - export LD_LIBRARY_PATH="/root/.vosk/libvosk:$LD_LIBRARY_PATH" - /usr/local/go/bin/go run -tags $GOTAGS -exec "env DYLD_LIBRARY_PATH=$HOME/.vosk/libvosk" cmd/vosk/main.go + export CGO_CFLAGS="-I$HOME/.vosk/libvosk -I/root/.vosk/libvosk" + export CGO_LDFLAGS="-L$HOME/.vosk/libvosk -L/root/.vosk/libvosk -lvosk -ldl -lpthread" + export LD_LIBRARY_PATH="/root/.vosk/libvosk:$HOME/.vosk/libvosk:$LD_LIBRARY_PATH" + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" -exec "env DYLD_LIBRARY_PATH=$HOME/.vosk/libvosk" cmd/vosk/main.go fi else if [[ -f ./chipper ]]; then @@ -104,6 +107,6 @@ else export CGO_LDFLAGS="-L$HOME/.coqui/" export CGO_CXXFLAGS="-I$HOME/.coqui/" export LD_LIBRARY_PATH="$HOME/.coqui/:$LD_LIBRARY_PATH" - /usr/local/go/bin/go run -tags $GOTAGS cmd/coqui/main.go + /usr/local/go/bin/go run -tags $GOTAGS -ldflags="${GOLDFLAGS}" cmd/coqui/main.go fi fi diff --git a/chipper/webroot/css/PxPlus_IBM_VGA_8x16.ttf b/chipper/webroot/css/PxPlus_IBM_VGA_8x16.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3bf32d0f8034091b61f5c3b2a31771f8391a79a8 GIT binary patch literal 70640 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�w@BC!N?JR7}R)H zag0DHFJXkU^Ykjw&2gwKe1E^oL=#a3?EfNx=eUUdK2d5#HSW@*>OG@>2A zuvil@quN0t){X%(tQ7@Q%)@xj{)14b#H`c>h1F$sWxp^}96@S~w&&Ag`q2Iqkztr3 zo3JJ14onItxCD^Vu+B#^a1CLed(o51$cvy=HiJ?p^I6Oa+Pwgjst=%{t_g6CdYF~s zD|2Nu#@#8x56q%4 zc`=1k6{1GH@SOaZSc&rs9GS+8R1nM11B>6JNc1#VhGWw_pa%6|c}Sn)3E~~%m#CCQ z2a@RpPB;(6TD5Vwiw6{l8+D(@uwG;$Zbiq+*k{yFb}H&TjtjzvDFTFnIQUgGaK$)? zYQa7ySumow6@V;tCHuR5_p5ANQ>k)5f$crCiXspLAVLQWnt(6dLY-_vyTarm+D7a2 zpbLd6>Vk;1zL%sBztjd1ichX?f;P3LE71@icp_sfN&|L+BIE}v9D-p2M=I>YLkY4D z_JXx;henSue-4glv2Dp|WmIxXMnw<=9SBzHf}lo*Scx%CzG1T%un8rDk}b|5UP4;U z4O2Q~SQ={P=ojQnUgyXB>KV31u=;Snz|e!^(QaNm_ULmx5RU^Q+H>)^&DD%T*l zthnRdP1rBRxwURReuucC@9WV|U5PVm@O=p~&x%`s@2ha-d^ZN)Cb;o>y9;mJuxjO+ z&dTvObuQhocKr#Jn>yFuuyXC1%90OO7OY&=Ie$!L!uSc}9jvzz?^US%29%%UrX$NG z)pL;ZR@@}~x7D4B%Qntib>j_{In&RtEI4OcWzxo0oSct(m!sq=Ysemgs~G&rW7Z);=2EiAiY-JdllD2U0oA( zbxp0Vu7R$ufv&DGUFAA4iHcfEXI8-48{9fK87tPt2?-mZf$?qyG(!|DcW%Xo4eKV4 z8@F=JhSu>b#;#q@Q@>WJ8KBn*yKO){L|VnoKpm^`BD#o$X`;Ix|6d|(8tzWU$jJ)X zyAxLsuh*;eDd2HDO3gv(4LGwNd!tTzi*co!vA%QhhLtyUR%WbSy?X7M8!FQ_Y*@c? z$&DL;%abo&v3Pywv{fsw@2s3Mc6?>dnhl-n*WmKCYZjwuwTw$^cwbt@dD7K5akmt_ zP@Yb_$H`|-#?fLN8^_}^oi2^+Ej8|_+;OW`F6~@%L+1_SmVEH!8y1fPXSn{qgET}T z=Hb}z5HdEQUSA3yr9n6ue971OKEAK-=llCZ`~W}D*ZV{LAb*%2>>KWBFw{ZamCKinVVNBCoX#Wx|c)L{g@FQRaNjAsr&HeQeQ=Y!C*8H|xZL`HWwp0siV zTK-|U`|~JdB*XE%pApD+Dp>hE62BOC9M(UNLUcbKvFAjr+8zVX91E?D2O=k6rPV3u z8J~*vR+G>_o{W*TDd;7iiFIpJfr{zC(oFQ2XCaEr1|rVIh}&Gml?xE%=AjQg9~im> zJ?RA)gIkFH^dgMJU5Q@x)fkVv)-CoUeKW>?y4-KMzjF7uo$e0z35*T>k^6#s$RFoE z=DzQK)BV10@uS?wFE)o3*$v!g1wizP0;hVVgD`e z1&klP=>Fcl8)yZaq?op8Mx2wHwvi%&Ne zS^gY9+t2am`t$r;f4;xKU+Cw#_uTjVMSi}&*k9r=^$YxEexbkIFY;ITEB#geYJZJ; z)BVt2>lgbaeyQ*9oqm~L?pOGg{yKlXU*%W(HSRI@=YFkU=YPYm_c!TmbI>AU=I`8)h?`#b&b_>cMD^>_J?`%k#P z^uLE}=#&0#_Y?Oo{`cL({!{+b{vQ7a{$Brw{yzU1f4~1D|5^XX{sI3v_p00BAH*o$ zYi^s{?*7@m;Xm&m@_*tV_J8U(`!D!M{1^SB{?GiE{FnV>{?Gm6{ww|o|5g7r|8@VQ z{|o<=|4aY0|119u|4sjl|7-uO{~Q0D|6BjO|2x0Mf6Kq%zwKZ2fA3%N-|;W|fAFvP zfAm}ZKl$(a@A+5#Kl|_dANbeYXZ$w*x_`rO_dEQX{)hfY{>T0&{$Kn~|F8b1{@?u1 z{J;C3`~UE7`TynL_Wy~l!Y}+g{+Iq;|11BVf8X!+d;DI%FSy`C2&GUL`h>orU+5nW z2?N5wP#+ErgTi59aA*iaLSr~Ql*18WXc!ib3`d2d!|-rS7!i&QmCzJMhURcwXbGdj z=x}^EA)FXa3S+{_VQd%|#)sB0A+&{4LVGwhObnC4X<>3WJxmE_gfqigVQQEbriU3} zW;i>{3g?8`VNN(VoEPSX^TP$hDXDng)fCKhsVO7hsVQL!V}@E;cMaR;mPn9 z;i>SK?p}-`Yz}|r?sI?W{v&)NeAE4jyE{A+{@UG-5$4|u&$>^!Plvw=&xOAY&xgMY zTf(=(3*p=0#qjsxrSP5ba`=bvO8CdHHT+ZfZunk!HT-kb^b_+R1e@SkB<_(gap{4%^7eihye?}y!C zPuLswm0Zb}g8Pe7sZ>|$Q|eplSL$Cnq%@#3uvA|8$=8tZM1^|r=(TVuVgvEJ5LZ)>c#HOki->u-(qPmKF%k&kKdy6KUR z>5-4=r}dk*dhycrYuEH!Z2P{`maOl*sk855?fOkyyL|1M&g=Uvw*9~vOINO6dgJP4 zt2#FhT$&#bn9;Fz!{VjL1vU&=njQ9?xpXl|_wBIVOw_Y@L%*|Qg`Kg&v#r8T?FO8k zl^)QU9rimrve9Y#zGoZ7PVEMslQS}Kd44?LoQ%ML<=LSO)H=Zw(K?}R;OxA_z!mv% z-Rvcc*VnDUN8dRcR<7#k?7LFCesd!KD+HxS_M}D(&K>X8+c<8GYxP3RY={q0_Y+ z>Q>>S?*&$BO`+5&hO*XEPVIYvm0DwpTeEoG+8Z{kU%PHaXX)%U%S)YWmiN0binlh3 zccF>5R=YzlTyf)?<%`$fxO&y%8#f%fw)m~@e5-kVq2~4|TKmMl^R4Ffw!7G_y+ONy z7ys`ruC2#yA_}KYoNVGOs0i@}6#^md*yEXQmiun)07B4;c2!yd!bzOO)Ui`vx^20L z`vW&|5klfG0Pp+`o)G*H9?Lg#j7%yGV~_Gn6+@<^wcPxGTX?RPKG z;L3>31^s|3{eTYW0pUd%C75!4*P0Yy+7VILGpwyLhEQi`{ii8j<8);lUZcFQd_Uq*6Rrt zvhlz=m$YA7=Jxqyv5MLkqPoZ4w9hS*_8I)1D-(j*+Z}3qjYqe0$EG}pHWM+7k43Zv zM`(I_7xLaYC{a zZCy`#ImOp5mU*|-!Fje3?Fun6etV7hruckXMaV2M5$r)F2#+96LCGQ!@dq(n}k-TRlXI#k{S8~Rc zoN=X~`H+I+=L91K$1ga3!SM@@UvT__;};yi;P?f{FF1a|@e7V$aQr^w`;71N{QHdW zGd{i<)o)0WlaBNm-)DTE@qNbk8Q*7opYeUh_ZeR@zGQsK_>%D@<8v;OlH-?*FBxAl zzGQsK_>%D@<4eYujIS7<&o-$TUopO7e8ur|W|E5W72_+$SB$S1UopO7e8u>R@pl=2 zm*Q9Z)UJ4+sDSGO@=FEemkP))6_8&lpm|XN`K1E#O9kYY3dk=NkY6ewzf?eesi46n zzf?$msgUtQ#t#`kWc-lvL&gsoKVooy|bz zPIFXG((S7?a+ZFlEy{Steaz&58Qtlwl1#s3mGLysxYbwv`1%xQE=E&-TI|B5#V%Z0 z?82o*CEOh|iI8RzAki2h6fb8E?g_b#()C(8J5i z3LjF8(?jSF!rGC+i(jlm0y;P=}T6N;9b#g6A<7^sJ-(8ww-*xR}UYGGUR#uuX zl4+FU-Dsc(V|}L8dZSLNt@C3~e*;qDV>EUV<;Kq)r!CTKH&8Jt)lTz4Bc=)<(Q*AmYSyWuE@CH4L zZFBFjvL)vV0rYw%}0xRO|szfETeKd@9GZ& zX>7*(em}{sO=^s-v_?&Xd>v<8S+4Lk&ODmNSbx_xg?3qI-8nufr*TK$>~al{JJYnD z*vmKq=q!owRl&?z)g`aN__kwubA;{C4j=E+`x&G;V#ecfpgHzix4^ny5cfN@CGhK@ z0l~8mz`Q%)*&S&5?}!|*>Gwjv51hIKKK*{M@(+SlcfhJYbkYH{{_rUW?D`|9c@$R% zEc;_%?H|Y00oVQn^e4f%x52PK1^sDU4w&|5?r^}iKMVc2)^qnb;M|{w{zmH!)W3<# z0hj(3>LW1f4jAh9Q2#zI2Q2jmsQ(arwF9>LBiQ^HmjjOd6ZrN8SUU%d`B%7q4_4d; zJN5(6f5hd0LHh~!pK-OpitGPD_B$>IeD0qu2VC(Q_xJH#sRJJJ0ep!OTj!xKt$E7^ zrs!?PVtohwSL@eSr#-NKYdK)dwp-3E{IpvC0&ONxa{vGU literal 0 HcmV?d00001 diff --git a/chipper/webroot/css/h1Font.ttf b/chipper/webroot/css/h1Font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6338c199e1d86eaec23fd27a1301a39d89d855fc GIT binary patch literal 6632 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 bk@G`C%_GH;;lkK=&W`kt^^Kn&8 - - Wire-Pod Interface - - - - - -
-
-

Wire-Pod Interface

-
- -
+ + Wire-Pod + + + + -