├── .gitignore ├── data └── cert │ ├── x509_crt_bundle.bin │ ├── gts_root_r4.pem │ ├── gsrsaovsslca2018.pem │ └── gts_root_r1.pem ├── src ├── app │ ├── lang.h │ ├── App.h │ ├── AppServer.h │ ├── AppFace.h │ ├── AppVoice.h │ ├── AppSettings.h │ ├── lang.cpp │ ├── AppChat.h │ ├── App.cpp │ ├── AppFace.cpp │ ├── AppServer.cpp │ ├── AppVoice.cpp │ ├── AppSettings.cpp │ └── AppChat.cpp ├── lib │ ├── sdcard.h │ ├── network.h │ ├── spiffs.h │ ├── nvs.h │ ├── url.h │ ├── AudioFileSourceGoogleTranslateTts.h │ ├── AudioFileSourceVoiceText.h │ ├── AudioFileSourceTtsQuestVoicevox.h │ ├── utils.h │ ├── AudioFileSourceGoogleTranslateTts.cpp │ ├── sdcard.cpp │ ├── ssl.h │ ├── AudioFileSourceHttp.h │ ├── NvsSettings.h │ ├── ChatGptClient.h │ ├── AudioFileSourceVoiceText.cpp │ ├── AudioOutputM5Speaker.hpp │ ├── spiffs.cpp │ ├── url.cpp │ ├── utils.cpp │ ├── nvs.cpp │ ├── network.cpp │ ├── AudioFileSourceTtsQuestVoicevox.cpp │ ├── AudioFileSourceHttp.cpp │ ├── NvsSettings.cpp │ └── ChatGptClient.cpp └── main.cpp ├── test └── README ├── LICENSE ├── lib └── README ├── include └── README ├── platformio.ini └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .pio/ 2 | .idea/ 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /data/cert/x509_crt_bundle.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yh1224/AIStackchan-hrs/HEAD/data/cert/x509_crt_bundle.bin -------------------------------------------------------------------------------- /src/app/lang.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_LANG) 2 | #define APP_LANG_H 3 | 4 | const char *t(const char *lang, const char *key); 5 | 6 | #endif // !defined(APP_LANG_H) 7 | -------------------------------------------------------------------------------- /src/lib/sdcard.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_SDCARD_H) 2 | #define LIB_SDCARD_H 3 | 4 | std::unique_ptr sdLoadString(const char *path); 5 | 6 | #endif // !defined(LIB_SDCARD_H) 7 | -------------------------------------------------------------------------------- /src/lib/network.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_NETWORK_H) 2 | #define LIB_NETWORK_H 3 | 4 | bool connectNetwork(const char *ssid, const char *passphrase); 5 | 6 | void setMDnsHostname(const char *hostname); 7 | 8 | void syncTime(const char *tz, const char *ntpServer); 9 | 10 | #endif // !defined(LIB_NETWORK_H) 11 | -------------------------------------------------------------------------------- /src/lib/spiffs.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_SPIFFS_H) 2 | #define LIB_SPIFFS_H 3 | 4 | #include 5 | #include 6 | 7 | bool spiffsSaveString(const char *path, const String &value); 8 | 9 | std::unique_ptr spiffsLoadString(const char *path); 10 | 11 | #endif // !defined(LIB_SPIFFS_H) 12 | -------------------------------------------------------------------------------- /src/lib/nvs.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_NVS_H) 2 | #define LIB_NVS_H 3 | 4 | #include 5 | #include 6 | 7 | bool nvsSaveString(const String &name, const String &key, const String &value); 8 | 9 | std::unique_ptr nvsLoadString(const String &name, const String &key, size_t maxLength); 10 | 11 | #endif // !defined(LIB_NVS_H) 12 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "app/App.h" 4 | 5 | namespace di = boost::di; 6 | 7 | static std::shared_ptr app; 8 | 9 | void setup() { 10 | auto injector = di::make_injector(); 11 | app = injector.create>(); 12 | app->setup(); 13 | } 14 | 15 | void loop() { 16 | app->loop(); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/url.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_URL_H) 2 | #define LIB_URL_H 3 | 4 | #include 5 | 6 | std::string urlEncode(const char *msg); 7 | 8 | std::string urlDecode(const char *msg); 9 | 10 | typedef std::unordered_map UrlParams; 11 | 12 | std::string qsBuild(const UrlParams &query); 13 | 14 | UrlParams qsParse(const char *query); 15 | 16 | #endif // !defined(LIB_URL_H) 17 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceGoogleTranslateTts.h: -------------------------------------------------------------------------------- 1 | #if !defined(AudioFileSourceGoogleTranslateTts_H) 2 | #define AudioFileSourceGoogleTranslateTts_H 3 | 4 | #include "AudioFileSourceHttp.h" 5 | #include "lib/url.h" 6 | 7 | class AudioFileSourceGoogleTranslateTts : public AudioFileSourceHttp { 8 | public: 9 | explicit AudioFileSourceGoogleTranslateTts(const char *text, UrlParams params); 10 | }; 11 | 12 | #endif // AudioFileSourceGoogleTranslateTts_H 13 | -------------------------------------------------------------------------------- /test/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for PlatformIO Test Runner and project tests. 3 | 4 | Unit Testing is a software testing method by which individual units of 5 | source code, sets of one or more MCU program modules together with associated 6 | control data, usage procedures, and operating procedures, are tested to 7 | determine whether they are fit for use. Unit testing finds problems early 8 | in the development cycle. 9 | 10 | More information about PlatformIO Unit Testing: 11 | - https://docs.platformio.org/en/latest/advanced/unit-testing/index.html 12 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceVoiceText.h: -------------------------------------------------------------------------------- 1 | #if !defined(AudioFileSourceVoiceText_H) 2 | #define AudioFileSourceVoiceText_H 3 | 4 | #include 5 | 6 | #include "AudioFileSourceHttp.h" 7 | #include "lib/url.h" 8 | 9 | class AudioFileSourceVoiceText : public AudioFileSourceHttp { 10 | public: 11 | AudioFileSourceVoiceText(String apiKey, String text, UrlParams params); 12 | 13 | bool open(const char *url) override; 14 | 15 | private: 16 | String _apiKey; 17 | String _text; 18 | UrlParams _params; 19 | }; 20 | 21 | #endif // AudioFileSourceVoiceText_H 22 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceTtsQuestVoicevox.h: -------------------------------------------------------------------------------- 1 | #if !defined(AudioFileSourceTtsQuestVoicevox_H) 2 | #define AudioFileSourceTtsQuestVoicevox_H 3 | 4 | #include 5 | 6 | #include "AudioFileSourceHttp.h" 7 | #include "lib/url.h" 8 | 9 | class AudioFileSourceTtsQuestVoicevox : public AudioFileSourceHttp { 10 | public: 11 | AudioFileSourceTtsQuestVoicevox(String apiKey, String text, UrlParams params); 12 | 13 | bool open(const char *url) override; 14 | 15 | private: 16 | String _apiKey; 17 | String _text; 18 | UrlParams _params; 19 | }; 20 | 21 | #endif // AudioFileSourceTtsQuestVoicevox_H 22 | -------------------------------------------------------------------------------- /src/lib/utils.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_UTILS_H) 2 | #define LIB_UTILS_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | String jsonEncode(const DynamicJsonDocument &jsonDoc); 9 | 10 | std::vector splitString( 11 | const std::string &str, const std::string &delimiter, bool includeDelimiter = false); 12 | 13 | std::vector splitString( 14 | const std::string &str, const std::vector &delimiters, bool includeDelimiter = false); 15 | 16 | std::vector splitLines(const std::string &str); 17 | 18 | std::vector splitSentence(const std::string &str); 19 | 20 | #endif // !defined(LIB_UTILS_H) 21 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceGoogleTranslateTts.cpp: -------------------------------------------------------------------------------- 1 | #include "AudioFileSourceGoogleTranslateTts.h" 2 | #include "lib/ssl.h" 3 | #include "lib/url.h" 4 | 5 | static const char *GOOGLE_TRANSLATION_TTS_API_URL = "http://translate.google.com/translate_tts"; 6 | 7 | AudioFileSourceGoogleTranslateTts::AudioFileSourceGoogleTranslateTts(const char *text, UrlParams params) { 8 | #if defined(USE_CA_CERT_BUNDLE) 9 | _secureClient.setCACertBundle(rootca_crt_bundle); 10 | #else 11 | _secureClient.setCACert(gts_root_r1_crt); 12 | #endif 13 | params["ie"] = "UTF-8"; 14 | params["q"] = text; 15 | params["client"] = "tw-ob"; 16 | params["ttsspeed"] = "1"; 17 | auto url = String(GOOGLE_TRANSLATION_TTS_API_URL) + "?" + qsBuild(params).c_str(); 18 | open(url.c_str()); 19 | } 20 | -------------------------------------------------------------------------------- /data/cert/gts_root_r4.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD 3 | VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG 4 | A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw 5 | WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz 6 | IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi 7 | AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi 8 | QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR 9 | HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW 10 | BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D 11 | 9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 12 | p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD 13 | -----END CERTIFICATE----- 14 | -------------------------------------------------------------------------------- /src/lib/sdcard.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | /** 5 | * Load string from file on SD card 6 | * 7 | * @param path file path 8 | * @return string (nullptr: failed) 9 | */ 10 | std::unique_ptr sdLoadString(const char *path) { 11 | std::unique_ptr value = nullptr; 12 | if (!SD.begin(GPIO_NUM_4, SPI, 25000000)) { 13 | Serial.println("ERROR: Failed to begin SD"); 14 | } else { 15 | auto fs = SD.open(path, FILE_READ); 16 | if (!fs) { 17 | Serial.printf("ERROR: Failed to open SD for reading (path=%s)\n", path); 18 | } else { 19 | auto tmpValue = fs.readString(); 20 | Serial.printf("SD/Loaded: %s (%d bytes)\n", path, tmpValue.length()); 21 | value = std::unique_ptr(new String(tmpValue)); 22 | fs.close(); 23 | } 24 | SD.end(); 25 | } 26 | return value; 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/ssl.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | /// Bundle of root certificate 4 | // https://github.com/espressif/arduino-esp32/blob/master/libraries/WiFiClientSecure/README.md 5 | extern const uint8_t rootca_crt_bundle[] asm("_binary_data_cert_x509_crt_bundle_bin_start"); 6 | 7 | // Failed to verify Letsencrypt cert with ISRG Root X1 CA (IDFGH-11039) 8 | // https://github.com/espressif/arduino-esp32/issues/8626 9 | 10 | // Google Trust Services https://pki.goog/repository/ 11 | /// GTS Root R1, valid until 2036-06-22 12 | extern const char gts_root_r1_crt[] asm("_binary_data_cert_gts_root_r1_pem_start"); 13 | /// GTS Root R4, Valid Until 2036-06-22 14 | extern const char gts_root_r4_crt[] asm("_binary_data_cert_gts_root_r4_pem_start"); 15 | 16 | // https://support.globalsign.com/ca-certificates/intermediate-certificates/organizationssl-intermediate-certificates 17 | /// GlobalSign RSA Organization Validation CA - 2018, Valid until: 21 November 2028 18 | extern const char gsrsaovsslca2018_crt[] asm("_binary_data_cert_gsrsaovsslca2018_pem_start"); 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yoshiharu Hirose 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceHttp.h: -------------------------------------------------------------------------------- 1 | #if !defined(AudioFileSourceHttp_H) 2 | #define AudioFileSourceHttp_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | class AudioFileSourceHttp : public AudioFileSource { 9 | public: 10 | explicit AudioFileSourceHttp() = default; 11 | 12 | explicit AudioFileSourceHttp(const char *text); 13 | 14 | ~AudioFileSourceHttp() override; 15 | 16 | bool open(const char *url) override; 17 | 18 | uint32_t read(void *data, uint32_t len) override; 19 | 20 | uint32_t readNonBlock(void *data, uint32_t len) override; 21 | 22 | bool seek(int32_t pos, int dir) override; 23 | 24 | bool close() override; 25 | 26 | bool isOpen() override; 27 | 28 | uint32_t getSize() override; 29 | 30 | uint32_t getPos() override; 31 | 32 | protected: 33 | WiFiClient _client; 34 | WiFiClientSecure _secureClient; 35 | HTTPClient _http; 36 | 37 | protected: 38 | int _pos = 0; 39 | size_t _chunkLen = 0; 40 | 41 | uint32_t _read(void *data, uint32_t len, bool nonBlock); 42 | 43 | bool _isChunked(); 44 | }; 45 | 46 | #endif // AudioFileSourceHttp_H 47 | -------------------------------------------------------------------------------- /lib/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for project specific (private) libraries. 3 | PlatformIO will compile them to static libraries and link into executable file. 4 | 5 | The source code of each library should be placed in a an own separate directory 6 | ("lib/your_library_name/[here are source files]"). 7 | 8 | For example, see a structure of the following two libraries `Foo` and `Bar`: 9 | 10 | |--lib 11 | | | 12 | | |--Bar 13 | | | |--docs 14 | | | |--examples 15 | | | |--src 16 | | | |- Bar.c 17 | | | |- Bar.h 18 | | | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html 19 | | | 20 | | |--Foo 21 | | | |- Foo.c 22 | | | |- Foo.h 23 | | | 24 | | |- README --> THIS FILE 25 | | 26 | |- platformio.ini 27 | |--src 28 | |- main.c 29 | 30 | and a contents of `src/main.c`: 31 | ``` 32 | #include 33 | #include 34 | 35 | int main (void) 36 | { 37 | ... 38 | } 39 | 40 | ``` 41 | 42 | PlatformIO Library Dependency Finder will find automatically dependent 43 | libraries scanning project source files. 44 | 45 | More information about PlatformIO Library Dependency Finder 46 | - https://docs.platformio.org/page/librarymanager/ldf.html 47 | -------------------------------------------------------------------------------- /src/app/App.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_APP_H) 2 | #define APP_APP_H 3 | 4 | #include 5 | #include 6 | 7 | #include "app/AppChat.h" 8 | #include "app/AppFace.h" 9 | #include "app/AppSettings.h" 10 | #include "app/AppServer.h" 11 | #include "app/AppVoice.h" 12 | 13 | class App { 14 | public: 15 | explicit App( 16 | std::shared_ptr settings, 17 | std::shared_ptr voice, 18 | std::shared_ptr face, 19 | std::shared_ptr chat, 20 | std::shared_ptr server 21 | ) : _settings(std::move(settings)), 22 | _voice(std::move(voice)), 23 | _face(std::move(face)), 24 | _chat(std::move(chat)), 25 | _server(std::move(server)) {}; 26 | 27 | void setup(); 28 | 29 | void loop(); 30 | 31 | private: 32 | std::shared_ptr _settings; 33 | std::shared_ptr _voice; 34 | std::shared_ptr _face; 35 | std::shared_ptr _chat; 36 | std::shared_ptr _server; 37 | 38 | bool _isServoEnabled(); 39 | 40 | void _onTapCenter(); 41 | 42 | void _onButtonA(); 43 | 44 | void _onButtonB(); 45 | 46 | void _onButtonC(); 47 | }; 48 | 49 | #endif // !defined(APP_APP_H) 50 | -------------------------------------------------------------------------------- /src/lib/NvsSettings.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_NVS_SETTINGS_H) 2 | #define LIB_NVS_SETTINGS_H 3 | 4 | #include 5 | #include 6 | 7 | /// 設定 JSON サイズ 8 | static const size_t SETTINGS_MAX_SIZE = 4 * 1024; 9 | 10 | class NvsSettings { 11 | public: 12 | explicit NvsSettings(String nvsNamespace, String nvsKey); 13 | 14 | bool load(); 15 | 16 | bool save(); 17 | 18 | bool load(const String &text, bool merge = false); 19 | 20 | bool has(const String &keyStr); 21 | 22 | JsonVariant get(const String &keyStr); 23 | 24 | template 25 | bool set(const String &keyStr, const T &value); 26 | 27 | bool remove(const String &keyStr); 28 | 29 | size_t count(const String &keyStr); 30 | 31 | template 32 | std::vector getArray(const String &keyStr); 33 | 34 | template 35 | bool add(const String &keyStr, const T &value); 36 | 37 | bool clear(const String &keyStr); 38 | 39 | protected: 40 | String _nvsNamespace; 41 | String _nvsKey; 42 | 43 | DynamicJsonDocument _settings{SETTINGS_MAX_SIZE}; 44 | 45 | JsonVariant _get(std::vector &keys); 46 | 47 | JsonVariant _getParentOrCreate(std::vector &keys); 48 | }; 49 | 50 | #endif // !defined(LIB_NVS_SETTINGS_H) 51 | -------------------------------------------------------------------------------- /src/lib/ChatGptClient.h: -------------------------------------------------------------------------------- 1 | #if !defined(LIB_CHATGPT_CLIENT_H) 2 | #define LIB_CHATGPT_CLIENT_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | class ChatGptClientError : public std::exception { 10 | public: 11 | explicit ChatGptClientError(String msg) : _msg(std::move(msg)) {}; 12 | 13 | const char *what() { return _msg.c_str(); } 14 | 15 | private: 16 | String _msg; 17 | }; 18 | 19 | class ChatGptHttpError : public ChatGptClientError { 20 | public: 21 | ChatGptHttpError(int code, String msg) : _statusCode(code), ChatGptClientError(msg) {}; 22 | 23 | int statusCode() const { return _statusCode; } 24 | 25 | private: 26 | int _statusCode; 27 | }; 28 | 29 | class ChatGptClient { 30 | public: 31 | explicit ChatGptClient(String apiKey, String model); 32 | 33 | String chat( 34 | const String &data, const std::vector &roles, const std::deque &history, 35 | const std::function &onReceiveContent); 36 | 37 | private: 38 | String _apiKey; 39 | String _model; 40 | 41 | String _httpPost( 42 | const String &url, const String &body, 43 | const std::function &onReceiveData); 44 | }; 45 | 46 | #endif // !defined(LIB_CHATGPT_CLIENT_H) 47 | -------------------------------------------------------------------------------- /src/app/AppServer.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_SERVER_H) 2 | #define APP_SERVER_H 3 | 4 | #include 5 | 6 | #include 7 | 8 | #include "app/AppChat.h" 9 | #include "app/AppFace.h" 10 | #include "app/AppSettings.h" 11 | #include "app/AppVoice.h" 12 | 13 | class AppServer { 14 | public: 15 | explicit AppServer( 16 | std::shared_ptr settings, 17 | std::shared_ptr voice, 18 | std::shared_ptr face, 19 | std::shared_ptr chat 20 | ) : _settings(std::move(settings)), 21 | _voice(std::move(voice)), 22 | _face(std::move(face)), 23 | _chat(std::move(chat)) {}; 24 | 25 | void setup(); 26 | 27 | void loop(); 28 | 29 | private: 30 | std::shared_ptr _settings; 31 | std::shared_ptr _voice; 32 | std::shared_ptr _face; 33 | std::shared_ptr _chat; 34 | 35 | ESP32WebServer _httpServer{80}; 36 | 37 | /// Busy flag 38 | bool _busy = false; 39 | 40 | void _onRoot(); 41 | 42 | void _onSpeech(); 43 | 44 | void _onFace(); 45 | 46 | void _onChat(); 47 | 48 | void _onApikey(); 49 | 50 | void _onApikeySet(); 51 | 52 | void _onRoleGet(); 53 | 54 | void _onRoleSet(); 55 | 56 | void _onSetting(); 57 | 58 | void _onSettings(); 59 | 60 | void _onNotFound(); 61 | }; 62 | 63 | #endif // !defined(APP_SERVER_H) 64 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceVoiceText.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "AudioFileSourceVoiceText.h" 5 | #include "lib/ssl.h" 6 | #include "lib/url.h" 7 | 8 | static const char *VOICETEXT_TTS_API_URL = "https://api.voicetext.jp/v1/tts"; 9 | 10 | AudioFileSourceVoiceText::AudioFileSourceVoiceText(String apiKey, String text, UrlParams params) 11 | : _apiKey(std::move(apiKey)), _text(std::move(text)), _params(std::move(params)) { 12 | #if defined(USE_CA_CERT_BUNDLE) 13 | _secureClient.setCACertBundle(rootca_crt_bundle); 14 | #else 15 | _secureClient.setCACert(gsrsaovsslca2018_crt); 16 | #endif 17 | open(VOICETEXT_TTS_API_URL); 18 | } 19 | 20 | bool AudioFileSourceVoiceText::open(const char *url) { 21 | _http.setReuse(false); 22 | if (!_http.begin(_secureClient, url)) { 23 | Serial.println("ERROR: HTTPClient begin failed."); 24 | return false; 25 | } 26 | _http.setAuthorization(_apiKey.c_str(), ""); 27 | _http.addHeader("Content-Type", "application/x-www-form-urlencoded"); 28 | auto params = _params; 29 | params["text"] = _text.c_str(); 30 | params["format"] = "mp3"; 31 | String request = qsBuild(params).c_str(); 32 | 33 | Serial.printf(">>> POST %s\n", url); 34 | Serial.println(request); 35 | auto httpCode = _http.POST(request); 36 | if (httpCode != HTTP_CODE_OK) { 37 | Serial.printf("ERROR: %d\n", httpCode); 38 | _http.end(); 39 | return false; 40 | } 41 | return true; 42 | } 43 | -------------------------------------------------------------------------------- /include/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for project header files. 3 | 4 | A header file is a file containing C declarations and macro definitions 5 | to be shared between several project source files. You request the use of a 6 | header file in your project source file (C, C++, etc) located in `src` folder 7 | by including it, with the C preprocessing directive `#include'. 8 | 9 | ```src/main.c 10 | 11 | #include "header.h" 12 | 13 | int main (void) 14 | { 15 | ... 16 | } 17 | ``` 18 | 19 | Including a header file produces the same results as copying the header file 20 | into each source file that needs it. Such copying would be time-consuming 21 | and error-prone. With a header file, the related declarations appear 22 | in only one place. If they need to be changed, they can be changed in one 23 | place, and programs that include the header file will automatically use the 24 | new version when next recompiled. The header file eliminates the labor of 25 | finding and changing all the copies as well as the risk that a failure to 26 | find one copy will result in inconsistencies within a program. 27 | 28 | In C, the usual convention is to give header files names that end with `.h'. 29 | It is most portable to use only letters, digits, dashes, and underscores in 30 | header file names, and at most one dot. 31 | 32 | Read more about using header files in official GCC documentation: 33 | 34 | * Include Syntax 35 | * Include Operation 36 | * Once-Only Headers 37 | * Computed Includes 38 | 39 | https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html 40 | -------------------------------------------------------------------------------- /data/cert/gsrsaovsslca2018.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIETjCCAzagAwIBAgINAe5fIh38YjvUMzqFVzANBgkqhkiG9w0BAQsFADBMMSAw 3 | HgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFs 4 | U2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0xODExMjEwMDAwMDBaFw0yODEx 5 | MjEwMDAwMDBaMFAxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52 6 | LXNhMSYwJAYDVQQDEx1HbG9iYWxTaWduIFJTQSBPViBTU0wgQ0EgMjAxODCCASIw 7 | DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKdaydUMGCEAI9WXD+uu3Vxoa2uP 8 | UGATeoHLl+6OimGUSyZ59gSnKvuk2la77qCk8HuKf1UfR5NhDW5xUTolJAgvjOH3 9 | idaSz6+zpz8w7bXfIa7+9UQX/dhj2S/TgVprX9NHsKzyqzskeU8fxy7quRU6fBhM 10 | abO1IFkJXinDY+YuRluqlJBJDrnw9UqhCS98NE3QvADFBlV5Bs6i0BDxSEPouVq1 11 | lVW9MdIbPYa+oewNEtssmSStR8JvA+Z6cLVwzM0nLKWMjsIYPJLJLnNvBhBWk0Cq 12 | o8VS++XFBdZpaFwGue5RieGKDkFNm5KQConpFmvv73W+eka440eKHRwup08CAwEA 13 | AaOCASkwggElMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G 14 | A1UdDgQWBBT473/yzXhnqN5vjySNiPGHAwKz6zAfBgNVHSMEGDAWgBSP8Et/qC5F 15 | JK5NUPpjmove4t0bvDA+BggrBgEFBQcBAQQyMDAwLgYIKwYBBQUHMAGGImh0dHA6 16 | Ly9vY3NwMi5nbG9iYWxzaWduLmNvbS9yb290cjMwNgYDVR0fBC8wLTAroCmgJ4Yl 17 | aHR0cDovL2NybC5nbG9iYWxzaWduLmNvbS9yb290LXIzLmNybDBHBgNVHSAEQDA+ 18 | MDwGBFUdIAAwNDAyBggrBgEFBQcCARYmaHR0cHM6Ly93d3cuZ2xvYmFsc2lnbi5j 19 | b20vcmVwb3NpdG9yeS8wDQYJKoZIhvcNAQELBQADggEBAJmQyC1fQorUC2bbmANz 20 | EdSIhlIoU4r7rd/9c446ZwTbw1MUcBQJfMPg+NccmBqixD7b6QDjynCy8SIwIVbb 21 | 0615XoFYC20UgDX1b10d65pHBf9ZjQCxQNqQmJYaumxtf4z1s4DfjGRzNpZ5eWl0 22 | 6r/4ngGPoJVpjemEuunl1Ig423g7mNA2eymw0lIYkN5SQwCuaifIFJ6GlazhgDEw 23 | fpolu4usBCOmmQDo8dIm7A9+O4orkjgTHY+GzYZSR+Y0fFukAj6KYXwidlNalFMz 24 | hriSqHKvoflShx8xpfywgVcvzfTO3PYkz6fiNJBonf6q8amaEsybwMbDqKWwIX7e 25 | SPY= 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /src/app/AppFace.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_FACE_H) 2 | #define APP_FACE_H 3 | 4 | #if !defined(WITHOUT_AVATAR) 5 | #include 6 | #define SUPPRESS_HPP_WARNING 7 | #include 8 | #undef SUPPRESS_HPP_WARNING 9 | #endif // !defined(WITHOUT_AVATAR) 10 | 11 | #include "app/AppSettings.h" 12 | #include "app/AppVoice.h" 13 | 14 | typedef enum { 15 | Neutral = 0, 16 | Happy, 17 | Sleepy, 18 | Doubt, 19 | Sad, 20 | Angry, 21 | } Expression; 22 | 23 | class AppFace { 24 | public: 25 | explicit AppFace( 26 | std::shared_ptr settings, 27 | std::shared_ptr voice 28 | ) : _settings(std::move(settings)), 29 | _voice(std::move(voice)) {}; 30 | 31 | bool init(); 32 | 33 | void setup(); 34 | 35 | void start(); 36 | 37 | void loop(); 38 | 39 | void lipSync(void *args); 40 | 41 | void servo(void *args); 42 | 43 | void setText(const char *text); 44 | 45 | bool setExpression(Expression expression); 46 | 47 | void toggleHeadSwing(); 48 | 49 | private: 50 | std::shared_ptr _settings; 51 | std::shared_ptr _voice; 52 | 53 | #if !defined(WITHOUT_AVATAR) 54 | /// M5Stack-Avatar https://github.com/meganetaaan/m5stack-avatar 55 | m5avatar::Avatar _avatar; 56 | 57 | /// servo to swing head 58 | ServoEasing _servoX, _servoY; 59 | 60 | /// Swing parameters 61 | int _homeX, _homeY, _rangeX, _rangeY; 62 | 63 | /// head swing mode 64 | bool _headSwing; 65 | 66 | /// last time of get battery status 67 | unsigned long _lastBatteryStatus = 0; 68 | #endif // !defined(WITHOUT_AVATAR) 69 | }; 70 | 71 | #endif // !defined(APP_FACE_H) 72 | -------------------------------------------------------------------------------- /src/lib/AudioOutputM5Speaker.hpp: -------------------------------------------------------------------------------- 1 | #if !defined(AudioOutputM5Speaker_H) 2 | #define AudioOutputM5Speaker_H 3 | 4 | #include 5 | #include 6 | 7 | static const int BUF_SIZE = 640; 8 | static const int BUF_NUM = 3; 9 | 10 | class AudioOutputM5Speaker : public AudioOutput { 11 | public: 12 | explicit AudioOutputM5Speaker(m5::Speaker_Class *m5Speaker, uint8_t channel = 0) 13 | : _m5Speaker(m5Speaker), _channel(channel) {}; 14 | 15 | bool begin() override { 16 | return true; 17 | } 18 | 19 | bool ConsumeSample(int16_t sample[2]) override { 20 | if (_pos >= BUF_SIZE) { 21 | flush(); 22 | return false; 23 | } 24 | _buf[_index][_pos++] = sample[LEFTCHANNEL]; 25 | _buf[_index][_pos++] = sample[LEFTCHANNEL]; 26 | return true; 27 | } 28 | 29 | void flush() override { 30 | if (_pos > 0) { 31 | _m5Speaker->playRaw( 32 | _buf[_index], _pos, 33 | hertz, true, 1, _channel); 34 | _index = (_index + 1) % BUF_NUM; 35 | _pos = 0; 36 | } 37 | } 38 | 39 | bool stop() override { 40 | flush(); 41 | _m5Speaker->stop(_channel); 42 | memset(_buf, 0, BUF_NUM * BUF_SIZE * sizeof(int16_t)); 43 | return true; 44 | } 45 | 46 | const int16_t *getBuffer() const { 47 | return _buf[(_index + BUF_NUM - 1) % BUF_NUM]; 48 | } 49 | 50 | private: 51 | m5::Speaker_Class *_m5Speaker; 52 | uint8_t _channel; 53 | 54 | int16_t _buf[BUF_NUM][BUF_SIZE]{}; 55 | size_t _pos = 0; 56 | size_t _index = 0; 57 | }; 58 | 59 | #endif // !defined(AudioOutputM5Speaker_H) 60 | -------------------------------------------------------------------------------- /src/lib/spiffs.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "lib/spiffs.h" 7 | 8 | /** 9 | * Save string to SPIFFS 10 | * 11 | * @param path file path 12 | * @param value string 13 | * @return true: success, false: failure 14 | */ 15 | bool spiffsSaveString(const char *path, const String &value) { 16 | bool result = false; 17 | if (!SPIFFS.begin(true)) { 18 | Serial.println("ERROR: Failed to begin SPIFFS"); 19 | } else { 20 | File f = SPIFFS.open(path, "w"); 21 | if (!f) { 22 | Serial.printf("ERROR: Failed to open SPIFFS for writing (path=%s)\n", path); 23 | } else { 24 | f.write((u_int8_t *) value.c_str(), value.length()); 25 | Serial.printf("SPIFFS/Saved: %s=%s\n", path, value.c_str()); 26 | result = true; 27 | f.close(); 28 | } 29 | SPIFFS.end(); 30 | } 31 | return result; 32 | } 33 | 34 | /** 35 | * Load string from SPIFFS 36 | * 37 | * @param path file path 38 | * @return string (nullptr: failed) 39 | */ 40 | std::unique_ptr spiffsLoadString(const char *path) { 41 | std::unique_ptr value = nullptr; 42 | if (!SPIFFS.begin(true)) { 43 | Serial.println("ERROR: Failed to begin SPIFFS"); 44 | } else { 45 | File f = SPIFFS.open(path, "r"); 46 | if (!f || f.size() == 0) { 47 | Serial.printf("ERROR: Failed to open SPIFFS for reading (path=%s)\n", path); 48 | } else { 49 | auto tmpValue = f.readString(); 50 | Serial.printf("SPIFFS/Loaded: %s (%d bytes)\n", path, tmpValue.length()); 51 | value = std::make_unique(tmpValue); 52 | f.close(); 53 | } 54 | SPIFFS.end(); 55 | } 56 | return value; 57 | } 58 | -------------------------------------------------------------------------------- /src/app/AppVoice.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_VOICE_H) 2 | #define APP_VOICE_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "app/AppSettings.h" 10 | #include "lib/AudioFileSourceVoiceText.h" 11 | #include "lib/AudioOutputM5Speaker.hpp" 12 | 13 | class SpeechMessage { 14 | public: 15 | SpeechMessage(String text, String voice) : text(std::move(text)), voice(std::move(voice)) {}; 16 | String text; 17 | String voice; 18 | }; 19 | 20 | class AppVoice { 21 | public: 22 | explicit AppVoice( 23 | std::shared_ptr settings 24 | ) : _settings(std::move(settings)) {}; 25 | 26 | bool init(); 27 | 28 | void setup(); 29 | 30 | void start(); 31 | 32 | float getAudioLevel(); 33 | 34 | bool isPlaying(); 35 | 36 | bool setVoiceName(const String &voiceName); 37 | 38 | void speak(const String &text, const String &voiceName); 39 | 40 | void stopSpeak(); 41 | 42 | private: 43 | std::shared_ptr _settings; 44 | 45 | TaskHandle_t _taskHandle{}; 46 | 47 | SemaphoreHandle_t _lock = xSemaphoreCreateMutex(); 48 | 49 | bool _isRunning{}; 50 | 51 | /// M5Speaker virtual channel (0-7) 52 | uint8_t _speakerChannel = 0; 53 | 54 | /// message list to play 55 | std::deque> _speechMessages; 56 | 57 | /// output speaker 58 | AudioOutputM5Speaker _audioOut{&M5.Speaker, _speakerChannel}; 59 | 60 | /// mp3 decoder 61 | std::unique_ptr _audioMp3; 62 | 63 | /// audio source from text 64 | std::unique_ptr _audioSource; 65 | 66 | /// buffer for playing audio 67 | std::unique_ptr _audioSourceBuffer; 68 | 69 | /// buffer area for playing audio 70 | std::unique_ptr _allocatedBuffer; 71 | 72 | void _loop(); 73 | }; 74 | 75 | #endif // !defined(APP_VOICE_H) 76 | -------------------------------------------------------------------------------- /src/lib/url.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "url.h" 6 | #include "utils.h" 7 | 8 | std::string urlEncode(const char *msg) { 9 | std::stringstream ss; 10 | for (const char *p = msg; *p != '\0'; p++) { 11 | if (('a' <= *p && *p <= 'z') || ('A' <= *p && *p <= 'Z') || ('0' <= *p && *p <= '9') 12 | || *p == '-' || *p == '_' || *p == '.' || *p == '~') { 13 | ss << *p; 14 | } else if (*p == ' ') { 15 | ss << '+'; 16 | } else { 17 | ss << '%' << std::setfill('0') << std::setw(2) << std::uppercase << std::hex << (int) *p; 18 | } 19 | } 20 | return ss.str(); 21 | } 22 | 23 | std::string urlDecode(const char *msg) { 24 | std::stringstream ss; 25 | for (const char *p = msg; *p != '\0'; p++) { 26 | if (*p == '%') { 27 | int c1 = toupper(*(p + 1)); 28 | int c2 = toupper(*(p + 2)); 29 | if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } 30 | if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } 31 | ss << (char) (((c1 << 4) + c2)); 32 | p += 2; 33 | } else if (*p == '+') { 34 | ss << " "; 35 | } else { 36 | ss << *p; 37 | } 38 | } 39 | return ss.str(); 40 | } 41 | 42 | std::string qsBuild(const UrlParams &query) { 43 | std::stringstream ss; 44 | int n = 0; 45 | for (const auto &item: query) { 46 | if (n > 0) ss << "&"; 47 | n++; 48 | ss << item.first << "=" << urlEncode(item.second.c_str()); 49 | } 50 | return ss.str(); 51 | } 52 | 53 | UrlParams qsParse(const char *query) { 54 | UrlParams result; 55 | for (const auto &item: splitString(query, "&")) { 56 | auto s = splitString(item, "="); 57 | result[s[0]] = urlDecode(s[1].c_str()); 58 | } 59 | return result; 60 | } 61 | -------------------------------------------------------------------------------- /data/cert/gts_root_r1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw 3 | CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU 4 | MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw 5 | MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp 6 | Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA 7 | A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo 8 | 27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w 9 | Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw 10 | TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl 11 | qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH 12 | szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8 13 | Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk 14 | MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92 15 | wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p 16 | aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN 17 | VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID 18 | AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E 19 | FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb 20 | C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe 21 | QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy 22 | h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4 23 | 7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J 24 | ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef 25 | MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/ 26 | Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT 27 | 6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ 28 | 0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm 29 | 2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb 30 | bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /src/lib/utils.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | String jsonEncode(const DynamicJsonDocument &jsonDoc) { 7 | String jsonStr; 8 | serializeJson(jsonDoc, jsonStr); 9 | return jsonStr; 10 | } 11 | 12 | std::vector splitString( 13 | const std::string &str, const std::string &delimiter, bool includeDelimiter = false) { 14 | std::vector tokens; 15 | std::string token; 16 | size_t start = 0, end; 17 | 18 | while ((end = str.find(delimiter, start)) != std::string::npos) { 19 | size_t len = end - start; 20 | if (includeDelimiter) len += delimiter.length(); 21 | token = str.substr(start, len); 22 | if (!token.empty()) tokens.push_back(token); 23 | start = end + delimiter.length(); 24 | } 25 | token = str.substr(start); 26 | if (!token.empty()) tokens.push_back(token); 27 | 28 | return tokens; 29 | } 30 | 31 | std::vector splitString( 32 | const std::string &str, const std::vector &delimiters, bool includeDelimiter = false) { 33 | std::vector tokens = {str}; 34 | 35 | for (const auto &delimiter: delimiters) { 36 | std::vector temp_tokens; 37 | for (const auto &token: tokens) { 38 | std::vector split_tokens = splitString(token, delimiter, includeDelimiter); 39 | temp_tokens.insert(temp_tokens.end(), split_tokens.begin(), split_tokens.end()); 40 | } 41 | tokens = temp_tokens; 42 | } 43 | 44 | return tokens; 45 | } 46 | 47 | std::vector splitLines(const std::string &str) { 48 | return splitString(str, std::vector{"\r", "\n"}); 49 | } 50 | 51 | std::vector splitSentence(const std::string &str) { 52 | std::vector tokens; 53 | for (const auto &token: splitString(str, std::vector{"\r", "\n"})) { 54 | auto tempTokens = splitString(token, std::vector{".", "。", "?", "?", "!", "!"}, true); 55 | tokens.insert(tokens.end(), tempTokens.begin(), tempTokens.end()); 56 | } 57 | return tokens; 58 | } 59 | -------------------------------------------------------------------------------- /src/app/AppSettings.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_SETTINGS_H) 2 | #define APP_SETTINGS_H 3 | 4 | #include 5 | #include 6 | #include "lib/NvsSettings.h" 7 | 8 | #define NVS_NAMESPACE "AIStackchan-hrs" 9 | #define NVS_SETTINGS_KEY "settings" 10 | 11 | #define VOICE_SERVICE_GOOGLE_TRANSLATE_TTS "google-translate-tts" 12 | #define VOICE_SERVICE_GOOGLE_CLOUD_TTS "google-cloud-tts" 13 | #define VOICE_SERVICE_VOICETEXT "voicetext" 14 | #define VOICE_SERVICE_TTS_QUEST_VOICEVOX "tts-quest-voicevox" 15 | 16 | class AppSettings : public NvsSettings { 17 | public: 18 | explicit AppSettings() : NvsSettings(NVS_NAMESPACE, NVS_SETTINGS_KEY) {} 19 | 20 | bool init(); 21 | 22 | const char *getNetworkWifiSsid(); 23 | 24 | const char *getNetworkWifiPass(); 25 | 26 | const char *getNetworkHostname(); 27 | 28 | const char *getTimeZone(); 29 | 30 | const char *getTimeNtpServer(); 31 | 32 | bool isServoEnabled(); 33 | 34 | std::pair getServoPin(); 35 | 36 | bool getSwingEnabled(); 37 | 38 | std::pair getSwingHome(); 39 | 40 | std::pair getSwingRange(); 41 | 42 | String getLang(); 43 | 44 | uint8_t getVoiceVolume(); 45 | 46 | bool setVoiceVolume(uint8_t volume); 47 | 48 | const char *getVoiceService(); 49 | 50 | bool setVoiceService(const String &service); 51 | 52 | const char *getVoiceTextApiKey(); 53 | 54 | bool setVoiceTextApiKey(const String &apiKey); 55 | 56 | const char *getVoiceTextParams(); 57 | 58 | bool setVoiceTextParams(const String ¶ms); 59 | 60 | const char *getTtsQuestVoicevoxApiKey(); 61 | 62 | bool setTtsQuestVoicevoxApiKey(const String &apiKey); 63 | 64 | const char *getTtsQuestVoicevoxParams(); 65 | 66 | bool setTtsQuestVoicevoxParams(const String ¶ms); 67 | 68 | const char *getOpenAiApiKey(); 69 | 70 | bool setOpenAiApiKey(const String &apiKey); 71 | 72 | const char *getChatGptModel(); 73 | 74 | bool useChatGptStream(); 75 | 76 | std::vector getChatRoles(); 77 | 78 | bool addRole(const String &role); 79 | 80 | bool clearRoles(); 81 | 82 | int getMaxHistory(); 83 | 84 | bool isRandomSpeakEnabled(); 85 | 86 | std::pair getChatRandomInterval(); 87 | 88 | std::vector getChatRandomQuestions(); 89 | 90 | bool isClockSpeakEnabled(); 91 | 92 | std::vector getChatClockHours(); 93 | }; 94 | 95 | #endif // !defined(APP_SETTINGS_H) 96 | -------------------------------------------------------------------------------- /src/lib/nvs.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "lib/nvs.h" 7 | 8 | /** 9 | * Save string to NVS 10 | * 11 | * @param key Key 12 | * @param value string to save 13 | * @return true: success, false: failure 14 | */ 15 | bool nvsSaveString(const String &name, const String &key, const String &value) { 16 | bool result = false; 17 | nvs_handle_t nvsHandle; 18 | auto openResult = nvs_open(name.c_str(), NVS_READWRITE, &nvsHandle); 19 | if (openResult != ESP_OK) { 20 | Serial.printf("ERROR: Failed to open nvs for writing: %s (namespace=%s)\n", 21 | esp_err_to_name(openResult), name.c_str()); 22 | } else { 23 | auto setResult = nvs_set_str(nvsHandle, key.c_str(), value.c_str()); 24 | if (setResult != ESP_OK) { 25 | Serial.printf("ERROR: Failed to write string to nvs: %s (key=%s)\n", esp_err_to_name(setResult), 26 | key.c_str()); 27 | } else { 28 | Serial.printf("NVS/Saved: %s/%s=%s\n", name.c_str(), key.c_str(), value.c_str()); 29 | result = true; 30 | } 31 | nvs_close(nvsHandle); 32 | } 33 | return result; 34 | } 35 | 36 | /** 37 | * Load string from NVS 38 | * 39 | * @param key Key 40 | * @return string (nullptr: failed) 41 | */ 42 | std::unique_ptr nvsLoadString(const String &name, const String &key, size_t maxLength) { 43 | std::unique_ptr value = nullptr; 44 | nvs_handle_t nvsHandle; 45 | auto openResult = nvs_open(name.c_str(), NVS_READONLY, &nvsHandle); 46 | if (openResult != ESP_OK) { 47 | Serial.printf("ERROR: Failed to open nvs for reading: %s (namespace=%s)\n", 48 | esp_err_to_name(openResult), name.c_str()); 49 | } else { 50 | size_t len = maxLength + 1; 51 | auto valueBuf = std::unique_ptr((char *) malloc(len)); 52 | auto getResult = nvs_get_str(nvsHandle, key.c_str(), valueBuf.get(), &len); 53 | if (getResult != ESP_OK) { 54 | Serial.printf("ERROR: Failed to read string from nvs: %s (key=%s)\n", esp_err_to_name(getResult), 55 | key.c_str()); 56 | } else { 57 | Serial.printf("NVS/Loaded: %s/%s=%s\n", name.c_str(), key.c_str(), valueBuf.get()); 58 | value = std::unique_ptr(new String(valueBuf.get())); 59 | } 60 | nvs_close(nvsHandle); 61 | } 62 | return value; 63 | } 64 | -------------------------------------------------------------------------------- /src/app/lang.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | static const std::map> clockSpeechMap = { 4 | { 5 | "en", 6 | { 7 | {"apikey_not_set", "The API Key is not set"}, 8 | {"clock_not_set", "The clock is not set"}, 9 | {"clock_now", "It's %d %d"}, 10 | {"clock_now_noon", "It's %d o'clock"}, 11 | {"chat_thinking...", "Thinking..."}, 12 | {"chat_i_dont_understand", "I don't understand"}, 13 | {"chat_random_started", "The random speak mode started."}, 14 | {"chat_random_stopped", "The random speak mode stopped."}, 15 | } 16 | }, 17 | { 18 | "ja", 19 | { 20 | {"apikey_not_set", "API キーが設定されていません"}, 21 | {"clock_not_set", "時刻が設定されていません"}, 22 | {"clock_now", "%d時 %d分です"}, 23 | {"clock_now_noon", "%d時 ちょうどです"}, 24 | {"chat_thinking...", "考え中..."}, 25 | {"chat_i_dont_understand", "わかりません"}, 26 | {"chat_random_started", "ひとりごと始めます"}, 27 | {"chat_random_stopped", "ひとりごとやめます"}, 28 | } 29 | }, 30 | { 31 | "ro", 32 | { 33 | {"apikey_not_set", "Cheia API nu este setată"}, 34 | {"clock_not_set", "Ceasul nu este setat"}, 35 | {"clock_now", "Este ora %d și %d de minute"}, 36 | {"clock_now_noon", "Este exact ora %d"}, 37 | {"chat_thinking...", "Mă gândesc..."}, 38 | {"chat_i_dont_understand", "Nu înțeleg"}, 39 | {"chat_random_started", "Modul de vorbire aleatorie a început."}, 40 | {"chat_random_stopped", "Modul de vorbire aleatorie s-a oprit."}, 41 | } 42 | }, 43 | }; 44 | 45 | /** 46 | * Get text from language code 47 | * 48 | * @param lang language code (ISO 639-1) 49 | * @param key text key 50 | * @return text 51 | */ 52 | const char *t(const char *lang, const char *key) { 53 | if (clockSpeechMap.find(lang) != clockSpeechMap.end()) { 54 | auto langMap = clockSpeechMap.at(lang); 55 | if (langMap.find(key) != langMap.end()) { 56 | return langMap.at(key); 57 | } 58 | } 59 | auto defaultLangMap = clockSpeechMap.at("en"); 60 | if (defaultLangMap.find(key) != defaultLangMap.end()) { 61 | return defaultLangMap.at(key); 62 | } 63 | return key; // unexpected 64 | } 65 | -------------------------------------------------------------------------------- /src/app/AppChat.h: -------------------------------------------------------------------------------- 1 | #if !defined(APP_CHAT_H) 2 | #define APP_CHAT_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "app/AppFace.h" 10 | #include "app/AppSettings.h" 11 | #include "app/AppVoice.h" 12 | 13 | class ChatRequest { 14 | public: 15 | ChatRequest(String text, String voice, const std::function &onReceiveAnswer) 16 | : text(std::move(text)), voice(std::move(voice)), onReceiveAnswer(onReceiveAnswer) {}; 17 | String text; 18 | String voice; 19 | std::function onReceiveAnswer; 20 | }; 21 | 22 | class AppChat { 23 | public: 24 | explicit AppChat( 25 | std::shared_ptr settings, 26 | std::shared_ptr voice, 27 | std::shared_ptr face 28 | ) : _settings(std::move(settings)), 29 | _voice(std::move(voice)), 30 | _face(std::move(face)) {}; 31 | 32 | void setup(); 33 | 34 | void start(); 35 | 36 | void toggleRandomSpeakMode(); 37 | 38 | void speakCurrentTime(); 39 | 40 | void talk(const String &text, String &voiceName, 41 | const std::function &onReceiveAnswer) { 42 | talk(text, voiceName, false, onReceiveAnswer); 43 | } 44 | 45 | void talk(const String &text, const String &voiceName, bool useHistory, 46 | const std::function &onReceiveAnswer); 47 | 48 | private: 49 | std::shared_ptr _settings; 50 | std::shared_ptr _voice; 51 | std::shared_ptr _face; 52 | 53 | TaskHandle_t _taskHandle; 54 | 55 | SemaphoreHandle_t _lock = xSemaphoreCreateMutex(); 56 | 57 | /// text to display in the balloon 58 | String _balloonText = ""; 59 | 60 | /// time to hide text in the balloon 61 | unsigned long _hideBalloon = -1; 62 | 63 | /// random speak mode 64 | bool _randomSpeakMode = false; 65 | 66 | /// random speak mode: next speak time 67 | unsigned long _randomSpeakNextTime = 0; 68 | 69 | /// chat requests 70 | std::deque> _chatRequests; 71 | 72 | /// chat history (questions and answers) 73 | std::deque _chatHistory; 74 | 75 | unsigned long _getRandomSpeakNextTime(); 76 | 77 | String _getRandomSpeakQuestion(); 78 | 79 | bool _isRandomSpeakTimeNow(unsigned long now); 80 | 81 | bool _isClockSpeakTimeNow(); 82 | 83 | void _setFace(Expression expression, const char *text) { 84 | _setFace(expression, text, -1); 85 | } 86 | 87 | void _setFace(Expression expression, const String &text, int duration); 88 | 89 | String _talk(const String &text, const String &voiceName, bool useHistory); 90 | 91 | void _loop(); 92 | }; 93 | 94 | #endif // !defined(APP_CHAT_H) 95 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | default_envs = m5stack-core2 13 | 14 | [env:m5stack-core2] 15 | platform = espressif32@^6.12.0 16 | board = m5stack-core2 17 | framework = arduino 18 | upload_speed = 1500000 19 | monitor_speed = 115200 20 | monitor_filters = esp32_exception_decoder 21 | build_flags = 22 | -std=gnu++14 23 | # -DUSE_CA_CERT_BUNDLE 24 | build_unflags = 25 | -std=gnu++11 26 | lib_deps = 27 | m5stack/M5Unified@^0.1.7 28 | earlephilhower/ESP8266Audio@^1.9.7 29 | meganetaaan/M5Stack-Avatar@^0.8.3 30 | arminjo/ServoEasing@^3.2.1 31 | madhephaestus/ESP32Servo@^0.13.0 32 | bblanchon/ArduinoJson@^6.21.2 33 | ESPmDNS 34 | https://github.com/yh1224/ESP32WebServer#fix-empty-request 35 | board_build.embed_files = 36 | data/cert/gts_root_r1.pem 37 | data/cert/gts_root_r4.pem 38 | data/cert/gsrsaovsslca2018.pem 39 | data/cert/x509_crt_bundle.bin 40 | 41 | [env:m5stack-cores3] 42 | platform = espressif32 43 | board = m5stack-cores3 44 | framework = arduino 45 | monitor_speed = 115200 46 | monitor_filters = esp32_exception_decoder 47 | build_flags = 48 | -std=gnu++14 49 | -DARDUINO_M5STACK_CORES3 50 | -DBOARD_HAS_PSRAM 51 | -DARDUINO_USB_MODE=1 52 | -DARDUINO_USB_CDC_ON_BOOT=1 53 | # -DUSE_CA_CERT_BUNDLE 54 | build_unflags = 55 | -std=gnu++11 56 | lib_deps = 57 | m5stack/M5Unified@^0.1.7 58 | earlephilhower/ESP8266Audio@^1.9.7 59 | meganetaaan/M5Stack-Avatar@^0.8.3 60 | arminjo/ServoEasing@^3.2.1 61 | madhephaestus/ESP32Servo@^0.13.0 62 | bblanchon/ArduinoJson@^6.21.2 63 | ESPmDNS 64 | https://github.com/yh1224/ESP32WebServer#fix-empty-request 65 | board_build.embed_files = 66 | data/cert/gts_root_r1.pem 67 | data/cert/gts_root_r4.pem 68 | data/cert/gsrsaovsslca2018.pem 69 | data/cert/x509_crt_bundle.bin 70 | 71 | [env:m5stack-atom] 72 | platform = espressif32 73 | board = m5stack-atom 74 | framework = arduino 75 | board_build.partitions = no_ota.csv 76 | monitor_speed = 115200 77 | monitor_filters = esp32_exception_decoder 78 | build_flags = 79 | -std=gnu++14 80 | -DWITHOUT_AVATAR 81 | # -DUSE_CA_CERT_BUNDLE 82 | build_unflags = 83 | -std=gnu++11 84 | lib_deps = 85 | m5stack/M5Unified@^0.1.7 86 | FastLED/FastLED@^3.5.0 87 | earlephilhower/ESP8266Audio@^1.9.7 88 | bblanchon/ArduinoJson@^6.21.2 89 | ESPmDNS 90 | https://github.com/yh1224/ESP32WebServer#fix-empty-request 91 | board_build.embed_files = 92 | data/cert/gts_root_r1.pem 93 | data/cert/gts_root_r4.pem 94 | data/cert/gsrsaovsslca2018.pem 95 | data/cert/x509_crt_bundle.bin 96 | -------------------------------------------------------------------------------- /src/lib/network.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "lib/utils.h" 7 | 8 | /** 9 | * Connect to network 10 | * 11 | * @param ssid Wi-Fi SSID 12 | * @param passphrase Wi-Fi passphrase 13 | * @return true: success, false: failure 14 | */ 15 | bool connectNetwork(const char *ssid, const char *passphrase) { 16 | WiFi.disconnect(); 17 | WiFi.softAPdisconnect(true); 18 | WiFiClass::mode(WIFI_STA); 19 | 20 | bool manualConfig = ssid != nullptr && passphrase != nullptr; 21 | if (manualConfig) { 22 | M5.Display.printf("SSID: %s\n", ssid); 23 | Serial.printf("SSID: %s\n", ssid); 24 | WiFi.begin(ssid, passphrase); 25 | } else { 26 | WiFi.begin(); 27 | } 28 | M5.Display.print("Connecting"); 29 | Serial.print("Connecting"); 30 | while (WiFiClass::status() != WL_CONNECTED) { 31 | M5.Display.print("."); 32 | Serial.print("."); 33 | delay(500); 34 | // Give up in 10 seconds 35 | if (10000 < millis()) { 36 | break; 37 | } 38 | } 39 | M5.Display.println(""); 40 | Serial.println(""); 41 | 42 | if (WiFiClass::status() != WL_CONNECTED) { 43 | if (manualConfig) { 44 | return false; 45 | } 46 | 47 | // Try autoconfiguration by SmartConfig 48 | WiFiClass::mode(WIFI_STA); 49 | WiFi.beginSmartConfig(); 50 | M5.Display.printf("SmartConfig: "); 51 | Serial.printf("SmartConfig: "); 52 | while (!WiFi.smartConfigDone()) { 53 | delay(500); 54 | M5.Display.print("#"); 55 | Serial.print("#"); 56 | // Give up in 30 seconds 57 | if (30000 < millis()) { 58 | M5.Display.println(""); 59 | Serial.println(""); 60 | return false; 61 | } 62 | } 63 | M5.Display.println(""); 64 | Serial.println(""); 65 | 66 | // Wait for connection 67 | M5.Display.println("Connecting"); 68 | Serial.println("Connecting"); 69 | while (WiFiClass::status() != WL_CONNECTED) { 70 | delay(500); 71 | M5.Display.print("."); 72 | Serial.print("."); 73 | // Give up in 60 seconds. 74 | if (60000 < millis()) { 75 | M5.Display.println(""); 76 | Serial.println(""); 77 | return false; 78 | } 79 | } 80 | } 81 | M5.Display.printf("Connected\nIP: %s\n", WiFi.localIP().toString().c_str()); 82 | Serial.printf("Connected\nIP: %s\n", WiFi.localIP().toString().c_str()); 83 | 84 | delay(3000); 85 | return true; 86 | } 87 | 88 | /** 89 | * Setup mDNS host 90 | * 91 | * @param hostname host name 92 | */ 93 | void setMDnsHostname(const char *hostname) { 94 | if (MDNS.begin(hostname)) { 95 | M5.Display.printf("hostname: %s\n", hostname); 96 | } 97 | } 98 | 99 | /** 100 | * Synchronize clock 101 | * 102 | * @param tz time zone 103 | * @param ntpServer NTP server 104 | */ 105 | void syncTime(const char *tz, const char *ntpServer) { 106 | M5.Display.printf("Synchronizing time:"); 107 | configTzTime(tz, ntpServer); 108 | 109 | struct tm now{}; 110 | if (!getLocalTime(&now)) { 111 | M5.Display.println(" failed"); 112 | } else { 113 | std::stringstream ss; 114 | ss << std::put_time(&now, "%Y-%m-%d %H:%M:%S"); 115 | M5.Display.println(""); 116 | M5.Display.println(ss.str().c_str()); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/app/App.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "app/App.h" 5 | #include "lib/network.h" 6 | #include "lib/sdcard.h" 7 | 8 | [[noreturn]] void halt() { 9 | while (true) { delay(1000); } 10 | } 11 | 12 | class Box { 13 | public: 14 | int x, y, w, h; 15 | 16 | Box(int x, int y, int w, int h) : x(x), y(y), w(w), h(h) {} 17 | 18 | bool contain(int x1, int y1) const { 19 | return this->x <= x1 && x1 < (this->x + this->w) && this->y <= y1 && y1 < (this->y + this->h); 20 | } 21 | }; 22 | 23 | void App::setup() { 24 | auto cfg = m5::M5Unified::config(); 25 | cfg.external_spk = true; 26 | cfg.serial_baudrate = 115200; 27 | M5.begin(cfg); 28 | 29 | M5.Display.setTextSize(2); 30 | M5.Display.setCursor(0, 0); 31 | 32 | // Load settings 33 | if (!_settings->init()) { 34 | M5.Display.println("ERROR: Invalid settings."); 35 | halt(); 36 | } 37 | 38 | // Initialize 39 | _voice->init(); 40 | _face->init(); 41 | 42 | // Connect 43 | const char *wifiSsid = _settings->getNetworkWifiSsid(); 44 | const char *wifiPass = _settings->getNetworkWifiPass(); 45 | if (!connectNetwork(wifiSsid, wifiPass)) { 46 | Serial.println("ERROR: Failed to connect network. Rebooting..."); 47 | delay(5000); 48 | ESP.restart(); 49 | halt(); 50 | } 51 | 52 | // Setup mDNS 53 | const char *mDnsHostname = _settings->getNetworkHostname(); 54 | if (mDnsHostname != nullptr) { 55 | Serial.printf("Setting up mDNS hostname: %s\n", mDnsHostname); 56 | setMDnsHostname(mDnsHostname); 57 | } 58 | 59 | // Synchronize time 60 | const char *tz = _settings->getTimeZone(); 61 | const char *ntpServer = _settings->getTimeNtpServer(); 62 | if (tz != nullptr && ntpServer != nullptr) { 63 | Serial.printf("Synchronizing time: %s (%s)\n", ntpServer, tz); 64 | syncTime(tz, ntpServer); 65 | } 66 | 67 | delay(3000); 68 | M5.Display.clear(); 69 | 70 | // Setup 71 | _voice->setup(); 72 | _face->setup(); 73 | _chat->setup(); 74 | _server->setup(); 75 | 76 | // Start 77 | _voice->start(); 78 | _face->start(); 79 | _chat->start(); 80 | } 81 | 82 | static const Box boxCenter{120, 80, 80, 80}; 83 | static const Box boxButtonA{0, 200, 106, 40}; 84 | static const Box boxButtonB{106, 200, 108, 40}; 85 | static const Box boxButtonC{214, 200, 106, 40}; 86 | 87 | void App::loop() { 88 | M5.update(); 89 | if (M5.Touch.getCount()) { 90 | auto t = M5.Touch.getDetail(); 91 | if (t.wasPressed()) { 92 | if (boxCenter.contain(t.x, t.y)) _onTapCenter(); 93 | if (boxButtonA.contain(t.x, t.y)) _onButtonA(); 94 | if (boxButtonB.contain(t.x, t.y)) _onButtonB(); 95 | if (boxButtonC.contain(t.x, t.y)) _onButtonC(); 96 | } 97 | } 98 | if (M5.BtnA.wasPressed()) _onButtonA(); 99 | if (M5.BtnB.wasPressed()) _onButtonB(); 100 | if (M5.BtnC.wasPressed()) _onButtonC(); 101 | 102 | _server->loop(); 103 | _face->loop(); 104 | 105 | delay(50); 106 | } 107 | 108 | bool App::_isServoEnabled() { 109 | return _settings->has("servo"); 110 | } 111 | 112 | /** 113 | * Tap Center: Swing ON/OFF 114 | */ 115 | void App::_onTapCenter() { 116 | if (_isServoEnabled()) { 117 | M5.Speaker.tone(1000, 100); 118 | _face->toggleHeadSwing(); 119 | } 120 | } 121 | 122 | /** 123 | * Button A: Random speak mode ON/OFF 124 | */ 125 | void App::_onButtonA() { 126 | M5.Speaker.tone(1000, 100); 127 | if (_settings->getOpenAiApiKey() == nullptr) { 128 | _chat->speakCurrentTime(); 129 | } else { 130 | _chat->toggleRandomSpeakMode(); 131 | } 132 | } 133 | 134 | void App::_onButtonB() {} 135 | 136 | /** 137 | * Button C: Speak current time 138 | */ 139 | void App::_onButtonC() { 140 | M5.Speaker.tone(1000, 100); 141 | _chat->speakCurrentTime(); 142 | } 143 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceTtsQuestVoicevox.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "AudioFileSourceTtsQuestVoicevox.h" 6 | #include "lib/url.h" 7 | #include "lib/utils.h" 8 | 9 | /* 10 | * TTS QUEST V3 VOICEVOX API 11 | * https://github.com/ts-klassen/ttsQuestV3Voicevox 12 | */ 13 | 14 | // Let’s Encrypt https://letsencrypt.org/certificates/ 15 | /// Let’s Encrypt ISRG Root X1, valid until 2035-06-04 16 | static const char *caCert = \ 17 | "-----BEGIN CERTIFICATE-----\n" \ 18 | "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" \ 19 | "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" \ 20 | "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" \ 21 | "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" \ 22 | "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" \ 23 | "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" \ 24 | "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" \ 25 | "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" \ 26 | "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" \ 27 | "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" \ 28 | "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" \ 29 | "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" \ 30 | "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" \ 31 | "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" \ 32 | "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" \ 33 | "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" \ 34 | "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" \ 35 | "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" \ 36 | "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" \ 37 | "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" \ 38 | "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" \ 39 | "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" \ 40 | "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" \ 41 | "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" \ 42 | "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" \ 43 | "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" \ 44 | "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" \ 45 | "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" \ 46 | "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" \ 47 | "-----END CERTIFICATE-----\n" \ 48 | ""; 49 | 50 | static const char *TTS_QUEST_VOICEVOX_API_URL = "https://api.tts.quest/v3/voicevox/synthesis"; 51 | 52 | /// size for response 53 | static const size_t CONTENT_MAX_SIZE = 1024; 54 | 55 | AudioFileSourceTtsQuestVoicevox::AudioFileSourceTtsQuestVoicevox(String apiKey, String text, UrlParams params) 56 | : _apiKey(std::move(apiKey)), _text(std::move(text)), _params(std::move(params)) { 57 | _secureClient.setCACert(caCert); 58 | open(TTS_QUEST_VOICEVOX_API_URL); 59 | } 60 | 61 | bool AudioFileSourceTtsQuestVoicevox::open(const char *url) { 62 | _http.setReuse(false); 63 | if (!_http.begin(_secureClient, url)) { 64 | Serial.println("ERROR: HTTPClient begin failed."); 65 | return false; 66 | } 67 | _http.addHeader("Content-Type", "application/x-www-form-urlencoded"); 68 | auto params = _params; 69 | params["key"] = _apiKey.c_str(); 70 | params["text"] = _text.c_str(); 71 | String request = qsBuild(params).c_str(); 72 | 73 | Serial.printf(">>> POST %s\n", url); 74 | Serial.println(request); 75 | auto httpCode = _http.POST(request); 76 | if (httpCode != HTTP_CODE_OK) { 77 | Serial.printf("ERROR: %d\n", httpCode); 78 | _http.end(); 79 | return false; 80 | } 81 | auto response = _http.getString(); 82 | DynamicJsonDocument doc{CONTENT_MAX_SIZE}; 83 | auto error = deserializeJson(doc, response.c_str()); 84 | if (error != DeserializationError::Ok) { 85 | Serial.printf("ERROR: Failed to deserialize JSON: %s\n", error.c_str()); 86 | return false; 87 | } 88 | Serial.println(jsonEncode(doc)); 89 | bool success = doc["success"]; 90 | String mp3Url = doc["mp3StreamingUrl"]; 91 | if (!success || mp3Url == nullptr) { 92 | Serial.println("ERROR: Failed to synthesize"); 93 | return false; 94 | } 95 | _http.end(); 96 | return AudioFileSourceHttp::open(mp3Url.c_str()); 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/AudioFileSourceHttp.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include "AudioFileSourceHttp.h" 3 | 4 | AudioFileSourceHttp::AudioFileSourceHttp(const char *url) { 5 | open(url); 6 | } 7 | 8 | bool AudioFileSourceHttp::open(const char *url) { 9 | _http.setReuse(false); 10 | static const char *headerKeys[] = {"Transfer-Encoding"}; 11 | _http.collectHeaders(headerKeys, 1); 12 | if (!_http.begin(String(url).startsWith("https://") ? _secureClient : _client, url)) { 13 | Serial.println("ERROR: HTTPClient begin failed."); 14 | return false; 15 | } 16 | 17 | Serial.printf(">>> GET %s\n", url); 18 | auto httpCode = _http.GET(); 19 | if (httpCode != HTTP_CODE_OK) { 20 | Serial.printf("ERROR: %d\n", httpCode); 21 | _http.end(); 22 | return false; 23 | } 24 | return true; 25 | } 26 | 27 | AudioFileSourceHttp::~AudioFileSourceHttp() { 28 | _http.end(); 29 | } 30 | 31 | uint32_t AudioFileSourceHttp::read(void *data, uint32_t len) { 32 | return _read(data, len, false); 33 | } 34 | 35 | uint32_t AudioFileSourceHttp::readNonBlock(void *data, uint32_t len) { 36 | return _read(data, len, true); 37 | } 38 | 39 | bool AudioFileSourceHttp::seek(int32_t pos, int dir) { 40 | audioLogger->printf_P(PSTR("ERROR: AudioFileSourceHttpStream::seek() not implemented")); 41 | return false; 42 | } 43 | 44 | bool AudioFileSourceHttp::close() { 45 | _http.end(); 46 | return true; 47 | } 48 | 49 | bool AudioFileSourceHttp::isOpen() { 50 | return _http.connected(); 51 | } 52 | 53 | uint32_t AudioFileSourceHttp::getSize() { 54 | return _http.getSize(); 55 | } 56 | 57 | uint32_t AudioFileSourceHttp::getPos() { 58 | return _pos; 59 | } 60 | 61 | uint32_t AudioFileSourceHttp::_read(void *data, uint32_t len, bool nonBlock) { 62 | auto size = getSize(); 63 | if (!_http.connected() || (size > 0 && _pos >= size)) { 64 | return 0; 65 | } 66 | 67 | auto stream = _http.getStreamPtr(); 68 | if (!nonBlock) { 69 | auto start = millis(); 70 | while (_http.connected() && stream->available() < (int) len && (millis() - start < 500)) { 71 | delay(10); 72 | } 73 | } 74 | 75 | auto availBytes = stream->available(); 76 | if (availBytes == 0) { 77 | return 0; 78 | } 79 | 80 | if (_isChunked() && _chunkLen == 0) { 81 | // Read chunk size 82 | char buf[16]; 83 | int pos = 0; 84 | while (true) { 85 | auto available = stream->available(); 86 | if (available == 0) { 87 | delay(10); 88 | continue; 89 | } 90 | auto c = stream->read(); 91 | if (c < 0) { 92 | Serial.printf("readChunk: read failed (%d)\n", c); 93 | _http.end(); 94 | return 0; 95 | } 96 | buf[pos++] = (char) c; 97 | if (pos > 8) { // hex 6 桁まで 98 | Serial.println("readChunk: Invalid chunk size (too long)"); 99 | _http.end(); 100 | return 0; 101 | } 102 | if (pos >= 2 && buf[pos - 2] == '\r' && buf[pos - 1] == '\n') { 103 | buf[pos - 2] = '\0'; 104 | //Serial.printf("readChunk: chunkSize=%s\n", buf); 105 | char *endp; 106 | _chunkLen = strtol((char *) buf, &endp, 16); 107 | if (endp != (char *) &buf[pos - 2]) { 108 | Serial.printf("readChunk: Invalid chunk size: %s\n", buf); 109 | _http.end(); 110 | return 0; 111 | } 112 | //Serial.printf("readChunk: chunkSize=%d\n", _chunkLen); 113 | break; 114 | } 115 | } 116 | if (_chunkLen == 0) { 117 | _http.end(); 118 | return 0; 119 | } 120 | } 121 | 122 | if (_isChunked() && len > _chunkLen) { 123 | len = _chunkLen; 124 | } 125 | int readBytes = stream->read(reinterpret_cast(data), len); 126 | if (readBytes <= 0) { 127 | return 0; 128 | } 129 | _pos += readBytes; 130 | if (_isChunked()) { 131 | _chunkLen -= readBytes; 132 | if (_chunkLen == 0) { 133 | // Skip chunk delimiter 134 | char buf[2]; 135 | auto skipLen = stream->readBytes(buf, 2); 136 | if (skipLen != 2 || buf[0] != '\r' || buf[1] != '\n') { 137 | Serial.println("readChunk: Invalid chunk delimiter"); 138 | _http.end(); 139 | return 0; 140 | } 141 | } 142 | } 143 | return readBytes; 144 | } 145 | 146 | bool AudioFileSourceHttp::_isChunked() { 147 | return _http.header("Transfer-Encoding") == "chunked"; 148 | } 149 | -------------------------------------------------------------------------------- /src/app/AppFace.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #if !defined(WITHOUT_AVATAR) 3 | #include 4 | #include 5 | #endif // !defined(WITHOUT_AVATAR) 6 | 7 | #include "app/AppFace.h" 8 | #include "app/AppVoice.h" 9 | 10 | #pragma clang diagnostic push 11 | #pragma ide diagnostic ignored "EndlessLoop" 12 | 13 | bool AppFace::init() { 14 | #if !defined(WITHOUT_AVATAR) 15 | auto pin = _settings->getServoPin(); 16 | int servoPinX = pin.first; 17 | int servoPinY = pin.second; 18 | if (_settings->isServoEnabled() && servoPinX != 0 && servoPinY != 0) { 19 | _headSwing = _settings->getSwingEnabled(); 20 | auto home = _settings->getSwingHome(); 21 | _homeX = home.first; 22 | _homeY = home.second; 23 | auto range = _settings->getSwingRange(); 24 | _rangeX = range.first; 25 | _rangeY = range.second; 26 | auto retX = _servoX.attach( 27 | servoPinX, 28 | _homeX, 29 | DEFAULT_MICROSECONDS_FOR_0_DEGREE, 30 | DEFAULT_MICROSECONDS_FOR_180_DEGREE 31 | ); 32 | if (retX == 0 || retX == INVALID_SERVO) { 33 | Serial.println("ERROR: Failed to attach servo x."); 34 | } 35 | _servoX.setEasingType(EASE_QUADRATIC_IN_OUT); 36 | _servoX.setEaseTo(_homeX); 37 | 38 | auto retY = _servoY.attach( 39 | servoPinY, 40 | _homeY, 41 | DEFAULT_MICROSECONDS_FOR_0_DEGREE, 42 | DEFAULT_MICROSECONDS_FOR_180_DEGREE 43 | ); 44 | if (retY == 0 || retY == INVALID_SERVO) { 45 | Serial.println("ERROR: Failed to attach servo y."); 46 | } 47 | _servoY.setEasingType(EASE_QUADRATIC_IN_OUT); 48 | _servoY.setEaseTo(_homeY); 49 | 50 | setSpeedForAllServos(30); 51 | synchronizeAllServosStartAndWaitForAllServosToStop(); 52 | } 53 | #endif // !defined(WITHOUT_AVATAR) 54 | return true; 55 | } 56 | 57 | void AppFace::setup() { 58 | #if !defined(WITHOUT_AVATAR) 59 | _avatar.init(); 60 | _avatar.setBatteryIcon(true); 61 | _avatar.setSpeechFont(&fonts::efontJA_16); 62 | #endif // !defined(WITHOUT_AVATAR) 63 | } 64 | 65 | void AppFace::start() { 66 | #if !defined(WITHOUT_AVATAR) 67 | static auto face = this; 68 | _avatar.addTask([](void *args) { face->lipSync(args); }, "lipSync"); 69 | if (_settings->isServoEnabled()) { 70 | _avatar.addTask([](void *args) { face->servo(args); }, "servo"); 71 | } 72 | #endif // !defined(WITHOUT_AVATAR) 73 | } 74 | 75 | void AppFace::loop() { 76 | #if !defined(WITHOUT_AVATAR) 77 | if (_lastBatteryStatus == 0 || millis() - _lastBatteryStatus > 5000) { 78 | _avatar.setBatteryStatus(M5.Power.isCharging(), M5.Power.getBatteryLevel()); 79 | _lastBatteryStatus = millis(); 80 | } 81 | #endif // !defined(WITHOUT_AVATAR) 82 | } 83 | 84 | #if !defined(WITHOUT_AVATAR) 85 | /** 86 | * Task to open mouth to match the voice. 87 | */ 88 | void AppFace::lipSync(void *args) { 89 | if (((m5avatar::DriveContext *) args)->getAvatar() != &_avatar) return; 90 | 91 | while (true) { 92 | _avatar.setMouthOpenRatio(_voice->getAudioLevel()); 93 | delay(50); 94 | } 95 | } 96 | 97 | /** 98 | * Task to swing head to the gaze. 99 | */ 100 | void AppFace::servo(void *args) { 101 | if (((m5avatar::DriveContext *) args)->getAvatar() != &_avatar) return; 102 | 103 | while (true) { 104 | if (!_headSwing) { 105 | // Reset to home position 106 | _servoX.setEaseTo(_homeX); 107 | _servoY.setEaseTo(_homeY); 108 | } else if (!_voice->isPlaying()) { 109 | // Swing head to the gaze 110 | float gazeH, gazeV; 111 | _avatar.getGaze(&gazeV, &gazeH); 112 | auto degreeX = (_homeX + (int) ((float) _rangeX / 2 * gazeH) + 360) % 360; 113 | auto degreeY = (_homeY + (int) ((float) _rangeY / 2 * gazeV) + 360) % 360; 114 | //Serial.printf("gaze (%.2f, %.2f) -> degree (%d, %d)\n", gazeH, gazeV, degreeX, degreeY); 115 | _servoX.setEaseTo(degreeX); 116 | _servoY.setEaseTo(degreeY); 117 | } 118 | synchronizeAllServosStartAndWaitForAllServosToStop(); 119 | delay(50); 120 | } 121 | } 122 | #endif // !defined(WITHOUT_AVATAR) 123 | 124 | #pragma clang diagnostic pop 125 | 126 | /** 127 | * Set text to speech bubble 128 | * 129 | * @param text text 130 | */ 131 | void AppFace::setText(const char *text) { 132 | #if !defined(WITHOUT_AVATAR) 133 | _avatar.setSpeechText(text); 134 | #endif // !defined(WITHOUT_AVATAR) 135 | } 136 | 137 | /** 138 | * Set face expression 139 | * 140 | * @param expression expression 141 | */ 142 | bool AppFace::setExpression(Expression expression) { 143 | #if !defined(WITHOUT_AVATAR) 144 | static const m5avatar::Expression EXPRESSIONS[] = { 145 | m5avatar::Expression::Neutral, 146 | m5avatar::Expression::Happy, 147 | m5avatar::Expression::Sleepy, 148 | m5avatar::Expression::Doubt, 149 | m5avatar::Expression::Sad, 150 | m5avatar::Expression::Angry, 151 | }; 152 | int numExpressions = sizeof(EXPRESSIONS) / sizeof(EXPRESSIONS[0]); 153 | if (expression >= numExpressions) { 154 | Serial.printf("ERROR: Unknown expression: %d", expression); 155 | return false; 156 | } 157 | Serial.printf("Setting expression: %d\n", expression); 158 | _avatar.setExpression(EXPRESSIONS[expression]); 159 | #endif // !defined(WITHOUT_AVATAR) 160 | return true; 161 | } 162 | 163 | /** 164 | * Swing ON/OFF 165 | */ 166 | void AppFace::toggleHeadSwing() { 167 | #if !defined(WITHOUT_AVATAR) 168 | _headSwing = !_headSwing; 169 | #endif // !defined(WITHOUT_AVATAR) 170 | } 171 | -------------------------------------------------------------------------------- /src/app/AppServer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "app/AppChat.h" 5 | #include "app/AppFace.h" 6 | #include "app/AppServer.h" 7 | #include "app/AppVoice.h" 8 | #include "lib/utils.h" 9 | 10 | void AppServer::setup() { 11 | static const char *headerKeys[] = {"Content-Type"}; 12 | _httpServer.collectHeaders(headerKeys, 1); 13 | _httpServer.on("/", [&] { _onRoot(); }); 14 | _httpServer.on("/speech", [&] { _onSpeech(); }); 15 | _httpServer.on("/face", [&] { _onFace(); }); 16 | _httpServer.on("/chat", [&] { _onChat(); }); 17 | _httpServer.on("/apikey", HTTP_GET, [&] { _onApikey(); }); 18 | _httpServer.on("/apikey_set", HTTP_POST, [&] { _onApikeySet(); }); 19 | _httpServer.on("/role_get", HTTP_GET, [&] { _onRoleGet(); }); 20 | _httpServer.on("/role_set", HTTP_POST, [&] { _onRoleSet(); }); 21 | _httpServer.on("/setting", [&] { _onSetting(); }); 22 | _httpServer.on("/settings", [&] { _onSettings(); }); 23 | _httpServer.onNotFound([&] { _onNotFound(); }); 24 | _httpServer.begin(); 25 | } 26 | 27 | void AppServer::loop() { 28 | if (!_busy) { 29 | _httpServer.handleClient(); 30 | } 31 | } 32 | 33 | void AppServer::_onRoot() { 34 | _httpServer.send(200, "text/plain", "Hello, I'm Stack-chan!"); 35 | } 36 | 37 | void AppServer::_onSpeech() { 38 | auto message = _httpServer.arg("say"); 39 | auto expressionStr = _httpServer.arg("expression"); 40 | auto voice = _httpServer.arg("voice"); 41 | if (!_face->setExpression((Expression) expressionStr.toInt())) { 42 | _httpServer.send(400); 43 | } 44 | _voice->stopSpeak(); 45 | _voice->speak(message, voice); 46 | _httpServer.send(200, "text/plain", "OK"); 47 | } 48 | 49 | void AppServer::_onFace() { 50 | auto expressionStr = _httpServer.arg("expression"); 51 | if (!_face->setExpression((Expression) expressionStr.toInt())) { 52 | _httpServer.send(400); 53 | } 54 | _httpServer.send(200, "text/plain", "OK"); 55 | } 56 | 57 | void AppServer::_onChat() { 58 | auto text = _httpServer.arg("text"); 59 | auto voiceName = _httpServer.arg("voice"); 60 | _voice->stopSpeak(); 61 | _chat->talk(text, voiceName, true, [&](const char *answer) { 62 | _httpServer.send(200, "text/plain", answer); 63 | _busy = false; 64 | }); 65 | _busy = true; 66 | } 67 | 68 | void AppServer::_onApikey() { 69 | _httpServer.send(200, "text/plain", "OK"); 70 | } 71 | 72 | void AppServer::_onApikeySet() { 73 | auto openAiApiKey = _httpServer.arg("openai"); 74 | auto voiceTextApiKey = _httpServer.arg("voicetext"); 75 | auto voicevoxApiKey = _httpServer.arg("voicevox"); 76 | _settings->setOpenAiApiKey(openAiApiKey); 77 | _settings->setVoiceTextApiKey(voiceTextApiKey); 78 | _settings->setTtsQuestVoicevoxApiKey(voicevoxApiKey); 79 | if (!voicevoxApiKey.isEmpty()) { 80 | _settings->setVoiceService(VOICE_SERVICE_TTS_QUEST_VOICEVOX); 81 | } else if (!voiceTextApiKey.isEmpty()) { 82 | _settings->setVoiceService(VOICE_SERVICE_VOICETEXT); 83 | } else { 84 | _settings->setVoiceService(VOICE_SERVICE_GOOGLE_TRANSLATE_TTS); 85 | } 86 | _httpServer.send(200, "text/plain", "OK"); 87 | } 88 | 89 | void AppServer::_onRoleGet() { 90 | DynamicJsonDocument result(4 * 1024); 91 | result.createNestedArray("roles"); 92 | for (const auto &role: _settings->getChatRoles()) { 93 | result["roles"].add(role); 94 | } 95 | _httpServer.send(200, "application/json", jsonEncode(result)); 96 | } 97 | 98 | void AppServer::_onRoleSet() { 99 | auto roleStr = _httpServer.arg("plain"); 100 | bool result; 101 | if (roleStr == "") { 102 | result = _settings->clearRoles(); 103 | } else { 104 | result = _settings->addRole(roleStr); 105 | } 106 | if (!result) { 107 | _httpServer.send(400); 108 | } else { 109 | _httpServer.send(200, "text/plain", "OK"); 110 | } 111 | } 112 | 113 | void AppServer::_onSetting() { 114 | auto volumeStr = _httpServer.arg("volume"); 115 | auto voiceName = _httpServer.arg("voice"); 116 | if (volumeStr != "") { 117 | if (!_settings->setVoiceVolume(volumeStr.toInt())) { 118 | _httpServer.send(400); 119 | } 120 | } 121 | if (voiceName != "") { 122 | if (!_voice->setVoiceName(voiceName)) { 123 | _httpServer.send(400); 124 | } 125 | } 126 | _httpServer.send(200, "text/plain", "OK"); 127 | } 128 | 129 | void AppServer::_onSettings() { 130 | bool result = true; 131 | if (_httpServer.method() == HTTPMethod::HTTP_POST || 132 | _httpServer.method() == HTTPMethod::HTTP_PUT) { 133 | if (_httpServer.header("Content-Type").startsWith("application/json")) { 134 | result = _settings->load(_httpServer.arg("plain"), 135 | _httpServer.method() == HTTPMethod::HTTP_PUT); 136 | } else { 137 | for (int i = 0; i < _httpServer.args(); i++) { 138 | auto name = _httpServer.argName(i); 139 | auto val = _httpServer.arg(i); 140 | if (val == "") { 141 | _settings->remove(name); 142 | } else if (std::all_of(val.begin(), val.end(), ::isdigit)) { 143 | _settings->set(name, std::stoi(val.c_str())); 144 | } else if (val == "true") { 145 | _settings->set(name, true); 146 | } else if (val == "false") { 147 | _settings->set(name, false); 148 | } else { 149 | _settings->set(name, val); 150 | } 151 | } 152 | } 153 | } 154 | auto settings = jsonEncode(_settings->get("")); 155 | if (!result) { 156 | _httpServer.send(400); 157 | } else { 158 | _httpServer.send(200, "application/json", settings); 159 | } 160 | } 161 | 162 | void AppServer::_onNotFound() { 163 | _httpServer.send(404); 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AIStackchan-hrs 2 | 3 | [![DeepWiki](https://img.shields.io/badge/DeepWiki-Documentation-blue)](https://deepwiki.com/yh1224/AIStackchan-hrs) 4 | 5 | ## Summary 6 | 7 | This is an alternative implementation of AI Stack-chan. Super thanks to [meganetaaan](https://github.com/meganetaaan) the originator of Stack-chan and [robo8080](https://github.com/robo8080) the originator of AI Stack-chan. 8 | 9 | ## Features 10 | 11 | - The following speech services can be used 12 | - [Google Translate](https://translate.google.com/) Text-to-Speech API (no API Key reqiured) - *unofficial?* 13 | - [VoiceText Web API](https://cloud.voicetext.jp/webapi) (API Key required) - *free registration suspended for now* 14 | - [TTS QUEST V3 VOICEVOX API](https://github.com/ts-klassen/ttsQuestV3Voicevox) (API Key required) 15 | - API 16 | - Speak API 17 | - Chat API (OpenAI API Key required) 18 | - Speak randomly 19 | - send a question to the ChatGPT and speak answer periodically 20 | - start/stop by button A 21 | - Speak clock 22 | - on every hour 23 | - current time by button C 24 | 25 | ## Settings 26 | 27 | Store the file named settings.json on SD card in advance (OPTIONAL). Once the stack-chan is running and connected to network, you can update settings via API. 28 | 29 | ```shell 30 | curl -X POST "http://(Stack-chan's IP address)/settings" \ 31 | -H "Content-Type: application/json" \ 32 | -d @settings.json 33 | ``` 34 | 35 | You can also update some settings individually. 36 | 37 | ```shell 38 | curl -X POST "http://(Stack-chan's IP address)/settings" \ 39 | -d "voice.lang=en_US" \ 40 | -d "voice.service=google-translate-tts" 41 | ``` 42 | 43 | Here is the example of settings. 44 | 45 | ```json 46 | { 47 | "network": { 48 | "wifi": { 49 | "ssid": "SSID", 50 | "pass": "PASSPHRASE" 51 | }, 52 | "hostname": "stackchan" 53 | }, 54 | "time": { 55 | "zone": "JST-9", 56 | "ntpServer": "ntp.nict.jp" 57 | }, 58 | "servo": { 59 | "pin": {"x": 13, "y": 14} 60 | }, 61 | "swing": { 62 | "home": {"x": 90, "y": 80}, 63 | "range": {"x": 30, "y": 20} 64 | }, 65 | "voice": { 66 | "lang": "ja", 67 | "volume": 200, 68 | "service": "voicetext", 69 | "voicetext": { 70 | "apiKey": "VoiceText API Key", 71 | "params": "speaker=hikari&speed=120&pitch=130&emotion=happiness" 72 | } 73 | }, 74 | "chat": { 75 | "openai": { 76 | "apiKey": "OpenAI API Key", 77 | "model": "gpt-3.5-turbo", 78 | "stream": false, 79 | "roles": [ 80 | "Answer in Japanese.", 81 | "あなたはスーパーカワイイロボット「スタックチャン」となり人々の心を癒やすことが使命です。" 82 | ], 83 | "maxHistory": 10 84 | }, 85 | "random": { 86 | "interval": {"min": 60, "max": 120}, 87 | "questions": [ 88 | "何かためになることを教えて", 89 | "面白い話をして", 90 | "ジョークを言って", 91 | "詩を書いて" 92 | ] 93 | }, 94 | "clock": { 95 | "hours": [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | ### Network settings *(reboot required)* 102 | 103 | - `network.wifi.ssid` [string] : Wi-Fi SSID 104 | - `network.wifi.pass` [string] : Wi-Fi passphrase 105 | - `network.hostname` [string] : mDNS hostname 106 | - `time.zone` [string] : Time zone (Default: `"JST-9"`) 107 | - `time.ntpServer` [string] : NTP Server (Default: `"ntp.nict.jp"`) 108 | 109 | ### Servo/Swing settings *(reboot required)* 110 | 111 | - `servo.pin.x`, `servo.pin.y` [int] : Pin number for servo (Required to swing head) 112 | - M5Stack CoreS3 - Port A : `{"x": 1, "y": 2}` 113 | - M5Stack CoreS3 - Port B : `{"x": 8, "y": 9}` 114 | - M5Stack CoreS3 - Port C : `{"x": 18, "y": 17}` 115 | - M5Stack Core2 - Port A : `{"x": 33, "y": 32}` 116 | - M5Stack Core2 - Port C : `{"x": 13, "y": 14}` 117 | - M5Stack Core/Fire : `{"x": 21, "y": 22}` 118 | - `swing.enable` [boolean] : Enable swing 119 | - `swing.home.x`, `swing.home.y` [int] : Home position in degrees (Default: `{"x": 90, "y": 80}`) 120 | - `swing.range.x`, `swing.range.y` [int] : Swing range in degrees (Default: `{"x": 30, "y": 20}`) 121 | 122 | ### Voice settings 123 | 124 | - `voice.lang` [string] : Speech language for Google Translate TTS (Default: `"ja"`) 125 | - `voice.volume` [int] : Speech volume (Default: `200`) 126 | - `voice.service` [string] : Speech service (Default: `"google-translate-tts"`) 127 | - `"google-translate-tts"` : [Google Translate](https://translate.google.com/) Text-to-Speech API 128 | - `"voicetext"` : [VoiceText Web API](https://cloud.voicetext.jp/webapi) 129 | - `"tts-quest-voicevox"` : [TTS QUEST V3 VOICEVOX API](https://github.com/ts-klassen/ttsQuestV3Voicevox) 130 | - `voice.voicetext.apiKey` [string] : VoiceText: API Key (Required to speech by VoiceText) 131 | - `voice.voicetext.params` [string] : VoiceText: extra parameters (Default: `"speaker=hikari&speed=120&pitch=130&emotion=happiness"`) 132 | - `voice.tts-quest-voicevox.apiKey` [string] : TTS QUEST V3 VOICEVOX: API Key (Optional) 133 | - `voice.tts-quest-voicevox.params` [string] : TTS QUEST V3 VOICEVOX: extra parameters (Default: `""`) 134 | 135 | ### Chat settings 136 | 137 | - `chat.openai.apiKey` [string] : [OpenAI](https://platform.openai.com/) API Key (Required for chat) 138 | - `chat.openai.model` [string] : ChatGPT model (Default: `gpt-3.5-turbo`) 139 | - `chat.openai.stream` [boolean] : Use stream or not (Default: `false`) 140 | - `chat.openai.roles` [string[]] : Roles for ChatGPT 141 | - `chat.openai.maxHistory` [int] : Send talk history (Default: `10`) 142 | - `chat.random.interval.min`-`random.interval.max` [int] : Random speech interval (Default: `60`-`120`) 143 | - `chat.random.questions` [string[]] : Questions to ChatGPT for random speech 144 | - `chat.clock.hours` [int[]] : Speech hours list 145 | 146 | ## API 147 | 148 | ### Speak API 149 | 150 | - Path: /speech 151 | - Parameters 152 | - say : Text to speak 153 | 154 | Example 155 | 156 | ```shell 157 | curl -X POST "http://(Stack-chan's IP address)/speech" \ 158 | -d "say=Hello" 159 | ``` 160 | 161 | ### Chat API 162 | 163 | - Path: /chat 164 | - Parameters 165 | - text : question 166 | 167 | ```shell 168 | curl -X POST "http://(Stack-chan's IP address)/chat" \ 169 | -d "text=Say something" 170 | ``` 171 | -------------------------------------------------------------------------------- /src/app/AppVoice.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "app/AppVoice.h" 9 | #include "lib/AudioFileSourceGoogleTranslateTts.h" 10 | #include "lib/AudioFileSourceTtsQuestVoicevox.h" 11 | #include "lib/AudioFileSourceVoiceText.h" 12 | #include "lib/AudioOutputM5Speaker.hpp" 13 | #include "lib/url.h" 14 | #include "lib/utils.h" 15 | 16 | /// size for audio buffer 17 | static const int BUFFER_SIZE = 16 * 1024; 18 | 19 | /// parameters for VoiceText 20 | const static char *VOICETEXT_VOICE_PARAMS[] = { 21 | "speaker=takeru&speed=100&pitch=130&emotion=happiness&emotion_level=4", 22 | "speaker=hikari&speed=120&pitch=130&emotion=happiness&emotion_level=2", 23 | "speaker=bear&speed=120&pitch=100&emotion=anger&emotion_level=2", 24 | "speaker=haruka&speed=80&pitch=70&emotion=happiness&emotion_level=2", 25 | "&speaker=santa&speed=120&pitch=90&emotion=happiness&emotion_level=4", 26 | }; 27 | 28 | bool AppVoice::init() { 29 | _audioMp3 = std::make_unique(); 30 | 31 | _allocatedBuffer = std::unique_ptr((uint8_t *) malloc(BUFFER_SIZE)); 32 | if (!_allocatedBuffer) { 33 | M5.Display.printf("FATAL: Unable to allocate buffer"); 34 | return false; 35 | } 36 | 37 | return true; 38 | } 39 | 40 | void AppVoice::setup() { 41 | auto spk_cfg = M5.Speaker.config(); 42 | spk_cfg.sample_rate = 96000; 43 | spk_cfg.task_pinned_core = APP_CPU_NUM; 44 | M5.Speaker.config(spk_cfg); 45 | M5.Speaker.begin(); 46 | } 47 | 48 | void AppVoice::start() { 49 | xTaskCreatePinnedToCore( 50 | [](void *arg) { 51 | auto *self = (AppVoice *) arg; 52 | #pragma clang diagnostic push 53 | #pragma ide diagnostic ignored "EndlessLoop" 54 | while (true) { 55 | self->_loop(); 56 | } 57 | #pragma clang diagnostic pop 58 | }, 59 | "AppVoice", 60 | 8192, 61 | this, 62 | 1, 63 | &_taskHandle, 64 | APP_CPU_NUM 65 | ); 66 | } 67 | 68 | static const int LEVEL_MIN = 100; 69 | static const int LEVEL_MAX = 15000; 70 | 71 | /** 72 | * Get audio level 73 | * 74 | * @return audio level (0.0-1.0) 75 | */ 76 | float AppVoice::getAudioLevel() { 77 | xSemaphoreTake(_lock, portMAX_DELAY); 78 | int level = abs(*_audioOut.getBuffer()); 79 | xSemaphoreGive(_lock); 80 | if (level < LEVEL_MIN) { 81 | level = 0; 82 | } else if (level > LEVEL_MAX) { 83 | level = LEVEL_MAX; 84 | } 85 | return (float) level / LEVEL_MAX; 86 | } 87 | 88 | /** 89 | * Check if voice is playing 90 | * 91 | * @return true: playing, false: not playing 92 | */ 93 | bool AppVoice::isPlaying() { 94 | xSemaphoreTake(_lock, portMAX_DELAY); 95 | auto result = _audioMp3->isRunning(); 96 | xSemaphoreGive(_lock); 97 | return result; 98 | } 99 | 100 | /** 101 | * Set voice name 102 | * 103 | * @param voiceName voice name 104 | * @return true: success, false: failure 105 | */ 106 | bool AppVoice::setVoiceName(const String &voiceName) { 107 | if (strcasecmp(_settings->getVoiceService(), VOICE_SERVICE_VOICETEXT) == 0) { 108 | auto params = qsParse(_settings->getVoiceTextParams()); 109 | int voiceNum = std::stoi(voiceName.c_str()); 110 | if (voiceNum >= 0 && voiceNum <= 4) { 111 | for (const auto &item: qsParse(VOICETEXT_VOICE_PARAMS[voiceNum])) { 112 | params[item.first] = item.second; 113 | } 114 | return _settings->setVoiceTextParams(qsBuild(params).c_str()); 115 | } else { 116 | return false; 117 | } 118 | } else if (strcasecmp(_settings->getVoiceService(), VOICE_SERVICE_TTS_QUEST_VOICEVOX) == 0) { 119 | auto params = qsParse(_settings->getTtsQuestVoicevoxParams()); 120 | params["speaker"] = voiceName.c_str(); 121 | return _settings->setTtsQuestVoicevoxParams(qsBuild(params).c_str()); 122 | } else { 123 | return false; 124 | } 125 | } 126 | 127 | /** 128 | * Start speaking text 129 | * 130 | * @param text text 131 | */ 132 | void AppVoice::speak(const String &text, const String &voiceName) { 133 | xSemaphoreTake(_lock, portMAX_DELAY); 134 | // add each sentence to message list 135 | for (const auto &sentence: splitSentence(text.c_str())) { 136 | _speechMessages.push_back(std::make_unique(sentence.c_str(), voiceName)); 137 | } 138 | xSemaphoreGive(_lock); 139 | } 140 | 141 | /** 142 | * Stop speaking 143 | */ 144 | void AppVoice::stopSpeak() { 145 | xSemaphoreTake(_lock, portMAX_DELAY); 146 | _isRunning = false; 147 | _speechMessages.clear(); 148 | xSemaphoreGive(_lock); 149 | } 150 | 151 | void AppVoice::_loop() { 152 | xSemaphoreTake(_lock, portMAX_DELAY); 153 | auto isRunning = _isRunning; 154 | xSemaphoreGive(_lock); 155 | 156 | if (_audioMp3->isRunning()) { // playing 157 | if (!isRunning || !_audioMp3->loop()) { 158 | _audioMp3->stop(); 159 | Serial.println("voice stop"); 160 | } 161 | } else { 162 | // Get next message and start playing 163 | xSemaphoreTake(_lock, portMAX_DELAY); 164 | std::unique_ptr message = nullptr; 165 | if (!_speechMessages.empty()) { 166 | message = std::move(_speechMessages.front()); 167 | _speechMessages.pop_front(); 168 | } 169 | xSemaphoreGive(_lock); 170 | if (message != nullptr) { 171 | xSemaphoreTake(_lock, portMAX_DELAY); 172 | _isRunning = true; 173 | xSemaphoreGive(_lock); 174 | M5.Speaker.setVolume(_settings->getVoiceVolume()); 175 | M5.Speaker.setChannelVolume(_speakerChannel, _settings->getVoiceVolume()); 176 | if (strcasecmp(_settings->getVoiceService(), VOICE_SERVICE_TTS_QUEST_VOICEVOX) == 0) { 177 | // TTS QUEST VOICEVOX API 178 | auto params = qsParse(_settings->getTtsQuestVoicevoxParams()); 179 | if (!message->voice.isEmpty()) { 180 | params["speaker"] = message->voice.c_str(); 181 | } 182 | _audioSource = std::make_unique( 183 | _settings->getTtsQuestVoicevoxApiKey(), message->text.c_str(), params); 184 | } else if (strcasecmp(_settings->getVoiceService(), VOICE_SERVICE_VOICETEXT) == 0 185 | && _settings->getVoiceTextApiKey() != nullptr) { 186 | // VoiceText API 187 | auto params = qsParse(_settings->getVoiceTextParams()); 188 | if (!message->voice.isEmpty()) { 189 | int voiceNum = std::stoi(message->voice.c_str()); 190 | if (voiceNum >= 0 && voiceNum <= 4) { 191 | for (const auto &item: qsParse(VOICETEXT_VOICE_PARAMS[voiceNum])) { 192 | params[item.first] = item.second; 193 | } 194 | } 195 | } 196 | _audioSource = std::unique_ptr(new AudioFileSourceVoiceText( 197 | _settings->getVoiceTextApiKey(), message->text.c_str(), params)); 198 | } else { 199 | // Google Translate TTS 200 | UrlParams params; 201 | params["tl"] = _settings->getLang().c_str(); 202 | _audioSource = std::unique_ptr(new AudioFileSourceGoogleTranslateTts( 203 | message->text.c_str(), params)); 204 | } 205 | _audioSourceBuffer = std::make_unique( 206 | _audioSource.get(), _allocatedBuffer.get(), BUFFER_SIZE); 207 | _audioMp3->begin(_audioSourceBuffer.get(), &_audioOut); 208 | Serial.printf("voice start: %s\n", message->text.c_str()); 209 | } 210 | delay(200); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/app/AppSettings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "AppSettings.h" 4 | #include "lib/sdcard.h" 5 | 6 | /// File path for settings 7 | static const char *APP_SETTINGS_SD_PATH = "/settings.json"; 8 | 9 | static const char *NETWORK_WIFI_SSID_KEY = "network.wifi.ssid"; 10 | static const char *NETWORK_WIFI_PASS_KEY = "network.wifi.pass"; 11 | static const char *NETWORK_HOSTNAME_KEY = "network.hostname"; 12 | static const char *TIME_ZONE_KEY = "time.zone"; 13 | static const char *TIME_ZONE_DEFAULT = "JST-9"; 14 | static const char *TIME_NTP_SERVER_KEY = "time.ntpServer"; 15 | static const char *TIME_NTP_SERVER_DEFAULT = "ntp.nict.jp"; 16 | 17 | static const char *SERVO_KEY = "servo"; 18 | static const char *SERVO_PIN_X_KEY = "servo.pin.x"; 19 | static const char *SERVO_PIN_Y_KEY = "servo.pin.y"; 20 | static const char *SWING_HOME_X_KEY = "swing.home.x"; 21 | static const char *SWING_ENABLE_KEY = "swing.enable"; 22 | static const int SWING_ENABLE_DEFAULT = true; 23 | static const int SWING_HOME_X_DEFAULT = 90; 24 | static const char *SWING_HOME_Y_KEY = "swing.home.y"; 25 | static const int SWING_HOME_Y_DEFAULT = 80; 26 | static const char *SWING_RANGE_X_KEY = "swing.range.x"; 27 | static const int SWING_RANGE_X_DEFAULT = 30; 28 | static const char *SWING_RANGE_Y_KEY = "swing.range.y"; 29 | static const int SWING_RANGE_Y_DEFAULT = 20; 30 | 31 | static const char *VOICE_LANG_KEY = "voice.lang"; 32 | static const char *VOICE_LANG_DEFAULT = "ja"; 33 | static const char *VOICE_VOLUME_KEY = "voice.volume"; 34 | static const int VOICE_VOLUME_DEFAULT = 200; 35 | static const char *VOICE_SERVICE_KEY = "voice.service"; 36 | static const char *VOICE_SERVICE_DEFAULT = VOICE_SERVICE_GOOGLE_TRANSLATE_TTS; 37 | static const char *VOICE_VOICETEXT_APIKEY_KEY = "voice.voicetext.apiKey"; 38 | static const char *VOICE_VOICETEXT_PARAMS_KEY = "voice.voicetext.params"; 39 | static const char *VOICE_VOICETEXT_PARAMS_DEFAULT = "speaker=hikari&speed=120&pitch=130&emotion=happiness"; 40 | static const char *VOICE_TTS_QUEST_VOICEVOX_APIKEY_KEY = "voice.tts-quest-voicevox.apiKey"; 41 | static const char *VOICE_TTS_QUEST_VOICEVOX_PARAMS_KEY = "voice.tts-quest-voicevox.params"; 42 | static const char *VOICE_TTS_QUEST_VOICEVOX_PARAMS_DEFAULT = ""; 43 | 44 | static const char *CHAT_OPENAI_APIKEY_KEY = "chat.openai.apiKey"; 45 | static const char *CHAT_OPENAI_CHATGPT_MODEL_KEY = "chat.openai.model"; 46 | static const char *CHAT_OPENAI_CHATGPT_MODEL_DEFAULT = "gpt-3.5-turbo"; 47 | static const char *CHAT_OPENAI_STREAM_KEY = "chat.openai.stream"; 48 | static const bool CHAT_OPENAI_STREAM_DEFAULT = false; 49 | static const char *CHAT_OPENAI_ROLES_KEY = "chat.openai.roles"; 50 | static const char *CHAT_OPENAI_MAX_HISTORY_KEY = "chat.openai.maxHistory"; 51 | static const int CHAT_OPENAI_MAX_HISTORY_DEFAULT = 10; 52 | static const char *CHAT_RANDOM_INTERVAL_MIN_KEY = "chat.random.interval.min"; 53 | static const int CHAT_RANDOM_INTERVAL_MIN_DEFAULT = 60; 54 | static const char *CHAT_RANDOM_INTERVAL_MAX_KEY = "chat.random.interval.min"; 55 | static const int CHAT_RANDOM_INTERVAL_MAX_DEFAULT = 120; 56 | static const char *CHAT_RANDOM_QUESTIONS_KEY = "chat.random.questions"; 57 | static const char *CHAT_CLOCK_HOURS_KEY = "chat.clock.hours"; 58 | 59 | bool AppSettings::init() { 60 | auto settings = sdLoadString(APP_SETTINGS_SD_PATH); 61 | if (settings != nullptr) { 62 | if (!load(*settings)) { 63 | return false; 64 | } 65 | } else { 66 | load(); 67 | } 68 | return true; 69 | } 70 | 71 | const char *AppSettings::getNetworkWifiSsid() { 72 | return get(NETWORK_WIFI_SSID_KEY); 73 | } 74 | 75 | const char *AppSettings::getNetworkWifiPass() { 76 | return get(NETWORK_WIFI_PASS_KEY); 77 | } 78 | 79 | const char *AppSettings::getNetworkHostname() { 80 | return get(NETWORK_HOSTNAME_KEY); 81 | } 82 | 83 | const char *AppSettings::getTimeZone() { 84 | return get(TIME_ZONE_KEY) | TIME_ZONE_DEFAULT; 85 | } 86 | 87 | const char *AppSettings::getTimeNtpServer() { 88 | return get(TIME_NTP_SERVER_KEY) | TIME_NTP_SERVER_DEFAULT;; 89 | } 90 | 91 | bool AppSettings::isServoEnabled() { 92 | return has(SERVO_KEY); 93 | } 94 | 95 | std::pair AppSettings::getServoPin() { 96 | int servoPinX = get(SERVO_PIN_X_KEY); 97 | int servoPinY = get(SERVO_PIN_Y_KEY); 98 | return std::make_pair(servoPinX, servoPinY); 99 | } 100 | 101 | bool AppSettings::getSwingEnabled() { 102 | return has(SWING_ENABLE_KEY) ? get(SWING_ENABLE_KEY) : SWING_ENABLE_DEFAULT; 103 | } 104 | 105 | std::pair AppSettings::getSwingHome() { 106 | int homeX = get(SWING_HOME_X_KEY) | SWING_HOME_X_DEFAULT; 107 | int homeY = get(SWING_HOME_Y_KEY) | SWING_HOME_Y_DEFAULT; 108 | return std::make_pair(homeX, homeY); 109 | } 110 | 111 | std::pair AppSettings::getSwingRange() { 112 | int homeX = get(SWING_RANGE_X_KEY) | SWING_RANGE_X_DEFAULT; 113 | int homeY = get(SWING_RANGE_Y_KEY) | SWING_RANGE_Y_DEFAULT; 114 | return std::make_pair(homeX, homeY); 115 | } 116 | 117 | String AppSettings::getLang() { 118 | String lang = get(VOICE_LANG_KEY) | VOICE_LANG_DEFAULT; 119 | return lang.substring(0, 2); // en-US -> en 120 | } 121 | 122 | uint8_t AppSettings::getVoiceVolume() { 123 | return get(VOICE_VOLUME_KEY) | VOICE_VOLUME_DEFAULT; 124 | } 125 | 126 | bool AppSettings::setVoiceVolume(uint8_t volume) { 127 | return set(VOICE_VOLUME_KEY, (int) volume); 128 | } 129 | 130 | const char *AppSettings::getVoiceService() { 131 | return get(VOICE_SERVICE_KEY) | VOICE_SERVICE_DEFAULT; 132 | } 133 | 134 | bool AppSettings::setVoiceService(const String &service) { 135 | return set(VOICE_SERVICE_KEY, service); 136 | } 137 | 138 | const char *AppSettings::getVoiceTextApiKey() { 139 | return get(VOICE_VOICETEXT_APIKEY_KEY); 140 | } 141 | 142 | bool AppSettings::setVoiceTextApiKey(const String &apiKey) { 143 | if (apiKey.isEmpty()) { 144 | return remove(VOICE_VOICETEXT_APIKEY_KEY); 145 | } else { 146 | return set(VOICE_VOICETEXT_APIKEY_KEY, apiKey); 147 | } 148 | } 149 | 150 | const char *AppSettings::getVoiceTextParams() { 151 | return get(VOICE_VOICETEXT_PARAMS_KEY) | VOICE_VOICETEXT_PARAMS_DEFAULT; 152 | } 153 | 154 | bool AppSettings::setVoiceTextParams(const String ¶ms) { 155 | return set(VOICE_VOICETEXT_PARAMS_KEY, params); 156 | } 157 | 158 | const char *AppSettings::getTtsQuestVoicevoxApiKey() { 159 | return get(VOICE_TTS_QUEST_VOICEVOX_APIKEY_KEY); 160 | } 161 | 162 | bool AppSettings::setTtsQuestVoicevoxApiKey(const String &apiKey) { 163 | if (apiKey.isEmpty()) { 164 | return remove(VOICE_TTS_QUEST_VOICEVOX_APIKEY_KEY); 165 | } else { 166 | return set(VOICE_TTS_QUEST_VOICEVOX_APIKEY_KEY, apiKey); 167 | } 168 | } 169 | 170 | const char *AppSettings::getTtsQuestVoicevoxParams() { 171 | return get(VOICE_TTS_QUEST_VOICEVOX_PARAMS_KEY) | VOICE_TTS_QUEST_VOICEVOX_PARAMS_DEFAULT; 172 | } 173 | 174 | bool AppSettings::setTtsQuestVoicevoxParams(const String ¶ms) { 175 | return set(VOICE_TTS_QUEST_VOICEVOX_PARAMS_KEY, params); 176 | } 177 | 178 | const char *AppSettings::getOpenAiApiKey() { 179 | return get(CHAT_OPENAI_APIKEY_KEY); 180 | } 181 | 182 | bool AppSettings::setOpenAiApiKey(const String &apiKey) { 183 | if (apiKey.isEmpty()) { 184 | return remove(CHAT_OPENAI_APIKEY_KEY); 185 | } else { 186 | return set(CHAT_OPENAI_APIKEY_KEY, apiKey); 187 | } 188 | } 189 | 190 | const char *AppSettings::getChatGptModel() { 191 | return get(CHAT_OPENAI_CHATGPT_MODEL_KEY) | CHAT_OPENAI_CHATGPT_MODEL_DEFAULT; 192 | } 193 | 194 | bool AppSettings::useChatGptStream() { 195 | return get(CHAT_OPENAI_STREAM_KEY) | CHAT_OPENAI_STREAM_DEFAULT; 196 | } 197 | 198 | std::vector AppSettings::getChatRoles() { 199 | return getArray(CHAT_OPENAI_ROLES_KEY); 200 | } 201 | 202 | bool AppSettings::addRole(const String &role) { 203 | return add(CHAT_OPENAI_ROLES_KEY, role); 204 | } 205 | 206 | bool AppSettings::clearRoles() { 207 | return clear(CHAT_OPENAI_ROLES_KEY); 208 | } 209 | 210 | int AppSettings::getMaxHistory() { 211 | return get(CHAT_OPENAI_MAX_HISTORY_KEY) | CHAT_OPENAI_MAX_HISTORY_DEFAULT; 212 | } 213 | 214 | bool AppSettings::isRandomSpeakEnabled() { 215 | return has(CHAT_RANDOM_QUESTIONS_KEY); 216 | } 217 | 218 | std::pair AppSettings::getChatRandomInterval() { 219 | int min = get(CHAT_RANDOM_INTERVAL_MIN_KEY) | CHAT_RANDOM_INTERVAL_MIN_DEFAULT; 220 | int max = get(CHAT_RANDOM_INTERVAL_MAX_KEY) | CHAT_RANDOM_INTERVAL_MAX_DEFAULT; 221 | return std::make_pair(min, max); 222 | } 223 | 224 | std::vector AppSettings::getChatRandomQuestions() { 225 | return getArray(CHAT_RANDOM_QUESTIONS_KEY); 226 | } 227 | 228 | bool AppSettings::isClockSpeakEnabled() { 229 | return has(CHAT_CLOCK_HOURS_KEY); 230 | } 231 | 232 | std::vector AppSettings::getChatClockHours() { 233 | return getArray(CHAT_CLOCK_HOURS_KEY); 234 | } 235 | -------------------------------------------------------------------------------- /src/app/AppChat.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "app/AppChat.h" 7 | #include "app/AppFace.h" 8 | #include "app/AppVoice.h" 9 | #include "app/lang.h" 10 | #include "lib/ChatGptClient.h" 11 | #include "lib/utils.h" 12 | 13 | void AppChat::setup() { 14 | } 15 | 16 | void AppChat::start() { 17 | xTaskCreatePinnedToCore( 18 | [](void *arg) { 19 | auto *self = (AppChat *) arg; 20 | #pragma clang diagnostic push 21 | #pragma ide diagnostic ignored "EndlessLoop" 22 | while (true) { 23 | self->_loop(); 24 | } 25 | #pragma clang diagnostic pop 26 | }, 27 | "AppChat", 28 | 8192, 29 | this, 30 | 1, 31 | &_taskHandle, 32 | APP_CPU_NUM 33 | ); 34 | } 35 | 36 | void AppChat::toggleRandomSpeakMode() { 37 | xSemaphoreTake(_lock, portMAX_DELAY); 38 | String message; 39 | if (!_randomSpeakMode) { 40 | _randomSpeakMode = true; 41 | _randomSpeakNextTime = _getRandomSpeakNextTime(); 42 | message = String(t(_settings->getLang().c_str(), "chat_random_started")); 43 | } else { 44 | _randomSpeakMode = false; 45 | message = String(t(_settings->getLang().c_str(), "chat_random_stopped")); 46 | } 47 | xSemaphoreGive(_lock); 48 | _voice->stopSpeak(); 49 | _voice->speak(message, ""); 50 | _setFace(Expression::Happy, "", 3000); 51 | } 52 | 53 | /** 54 | * Speak current time 55 | */ 56 | void AppChat::speakCurrentTime() { 57 | String message; 58 | struct tm tm{}; 59 | if (getLocalTime(&tm)) { 60 | const char *format; 61 | if (tm.tm_min == 0) { 62 | format = t(_settings->getLang().c_str(), "clock_now_noon"); 63 | } else { 64 | format = t(_settings->getLang().c_str(), "clock_now"); 65 | } 66 | char messageBuf[strlen(format) + 1]; 67 | snprintf(messageBuf, sizeof(messageBuf), format, tm.tm_hour, tm.tm_min); 68 | message = String(messageBuf); 69 | } else { 70 | t(_settings->getLang().c_str(), "clock_not_set"); 71 | } 72 | _voice->stopSpeak(); 73 | _voice->speak(message, ""); 74 | } 75 | 76 | /** 77 | * Ask to the ChatGPT and get answer 78 | * 79 | * @param text question 80 | * @param useHistory use chat history 81 | */ 82 | void AppChat::talk(const String &text, const String &voiceName, bool useHistory, 83 | const std::function &onReceiveAnswer) { 84 | xSemaphoreTake(_lock, portMAX_DELAY); 85 | _chatRequests.push_back(std::make_unique(text, voiceName, onReceiveAnswer)); 86 | xSemaphoreGive(_lock); 87 | } 88 | 89 | unsigned long AppChat::_getRandomSpeakNextTime() { 90 | auto interval = _settings->getChatRandomInterval(); 91 | int min = interval.first; 92 | int max = interval.second; 93 | return millis() + ((unsigned long) random(min, max)) * 1000; 94 | } 95 | 96 | String AppChat::_getRandomSpeakQuestion() { 97 | auto questions = _settings->getChatRandomQuestions(); 98 | return questions[(int) random((int) questions.size())]; 99 | } 100 | 101 | bool AppChat::_isRandomSpeakTimeNow(unsigned long now) { 102 | if (now > _randomSpeakNextTime) { 103 | _randomSpeakNextTime = _getRandomSpeakNextTime(); 104 | return true; 105 | } 106 | return false; 107 | } 108 | 109 | bool AppChat::_isClockSpeakTimeNow() { 110 | struct tm tm{}; 111 | if (getLocalTime(&tm) && tm.tm_min == 0 && tm.tm_sec == 0) { 112 | auto hours = _settings->getChatClockHours(); 113 | for (auto hour: hours) { 114 | if (hour == tm.tm_hour) { 115 | return true; 116 | } 117 | } 118 | } 119 | return false; 120 | } 121 | 122 | /** 123 | * Set face 124 | * 125 | * @param expression expression 126 | * @param text text 127 | * @param duration delay to hide balloon (0: no hide) 128 | */ 129 | void AppChat::_setFace(Expression expression, const String &text, int duration) { 130 | _face->setExpression(expression); 131 | _face->setText(""); 132 | _balloonText = text; 133 | _face->setText(_balloonText.c_str()); 134 | _hideBalloon = (duration > 0) ? millis() + duration : -1; 135 | } 136 | 137 | /** 138 | * Ask to the ChatGPT and get answer 139 | * 140 | * @param text question 141 | * @param useHistory use chat history 142 | * @return answer (nullptr: error) 143 | */ 144 | String AppChat::_talk(const String &text, const String &voiceName, bool useHistory) { 145 | auto apiKey = _settings->getOpenAiApiKey(); 146 | if (apiKey == nullptr) { 147 | String message = t(_settings->getLang().c_str(), "apikey_not_set"); 148 | _voice->speak(message, voiceName); 149 | return message; 150 | } 151 | 152 | ChatGptClient client{apiKey, _settings->getChatGptModel()}; 153 | _setFace(Expression::Doubt, t(_settings->getLang().c_str(), "chat_thinking...")); 154 | 155 | // call ChatGPT 156 | try { 157 | std::deque noHistory; 158 | String response; 159 | if (_settings->useChatGptStream()) { 160 | std::stringstream ss; 161 | int index = 0; 162 | response = client.chat( 163 | text, _settings->getChatRoles(), useHistory ? _chatHistory : noHistory, 164 | [&](const String &body) { 165 | //Serial.printf("%s", body.c_str()); 166 | ss << body.c_str(); 167 | auto sentences = splitSentence(ss.str()); 168 | if (sentences.size() > (index + 1)) { 169 | _setFace(Expression::Neutral, ""); 170 | for (int i = index; i < sentences.size() - 1; i++) { 171 | _voice->speak(sentences[i].c_str(), voiceName); 172 | index++; 173 | } 174 | } 175 | }); 176 | _setFace(Expression::Neutral, ""); 177 | auto sentences = splitSentence(response.c_str()); 178 | for (int i = index; i < sentences.size(); i++) { 179 | _voice->speak(sentences[i].c_str(), voiceName); 180 | } 181 | } else { 182 | response = client.chat(text, _settings->getChatRoles(), useHistory ? _chatHistory : noHistory, nullptr); 183 | //Serial.printf("%s\n", response.c_str()); 184 | _setFace(Expression::Neutral, ""); 185 | _voice->speak(response, voiceName); 186 | } 187 | 188 | if (useHistory) { 189 | // チャット履歴が最大数を超えた場合、古い質問と回答を削除 190 | if (_chatHistory.size() > _settings->getMaxHistory() * 2) { 191 | _chatHistory.pop_front(); 192 | _chatHistory.pop_front(); 193 | } 194 | // 質問と回答をチャット履歴に追加 195 | _chatHistory.push_back(text); 196 | _chatHistory.push_back(response); 197 | } 198 | return response; 199 | } catch (ChatGptClientError &e) { 200 | Serial.printf("ERROR: %s\n", e.what()); 201 | String errorMessage; 202 | try { 203 | throw; 204 | } catch (ChatGptHttpError &e) { 205 | errorMessage = "Error: " + String(e.statusCode()); 206 | } catch (std::exception &e) { 207 | errorMessage = "Error"; 208 | } 209 | _setFace(Expression::Sad, errorMessage, 3000); 210 | _voice->speak(t(_settings->getLang().c_str(), "chat_i_dont_understand"), voiceName); 211 | return errorMessage; 212 | } 213 | } 214 | 215 | void AppChat::_loop() { 216 | auto now = millis(); 217 | 218 | // reset balloon and face 219 | if (_hideBalloon >= 0 && now > _hideBalloon) { 220 | _face->setExpression(Expression::Neutral); 221 | _face->setText(""); 222 | _hideBalloon = -1; 223 | } 224 | 225 | if (!_voice->isPlaying()) { 226 | if (_settings->isClockSpeakEnabled() && _isClockSpeakTimeNow()) { 227 | // clock speak mode 228 | speakCurrentTime(); 229 | } else if (_randomSpeakMode && _isRandomSpeakTimeNow(now)) { 230 | // random speak mode 231 | _talk(_getRandomSpeakQuestion(), "", false); 232 | } else { 233 | xSemaphoreTake(_lock, portMAX_DELAY); 234 | std::unique_ptr request = nullptr; 235 | if (!_chatRequests.empty()) { 236 | request = std::move(_chatRequests.front()); 237 | _chatRequests.pop_front(); 238 | } 239 | xSemaphoreGive(_lock); 240 | if (request != nullptr) { 241 | // handle request 242 | auto answer = _talk(request->text, request->voice, true); 243 | request->onReceiveAnswer(answer.c_str()); 244 | } 245 | } 246 | } 247 | delay(500); 248 | } 249 | -------------------------------------------------------------------------------- /src/lib/NvsSettings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "lib/NvsSettings.h" 7 | #include "lib/nvs.h" 8 | #include "lib/utils.h" 9 | 10 | NvsSettings::NvsSettings(String nvsNamespace, String nvsKey) 11 | : _nvsNamespace(std::move(nvsNamespace)), _nvsKey(std::move(nvsKey)) { 12 | } 13 | 14 | /** 15 | * Load from NVS 16 | * 17 | * @return true: success, false: failure 18 | */ 19 | bool NvsSettings::load() { 20 | auto settings = nvsLoadString(_nvsNamespace, _nvsKey, SETTINGS_MAX_SIZE); 21 | if (settings != nullptr) { 22 | return deserializeJson(_settings, settings->c_str()) == DeserializationError::Ok; 23 | } 24 | return false; 25 | } 26 | 27 | /** 28 | * Save to NVS 29 | * 30 | * @return true: success, false: failure 31 | */ 32 | bool NvsSettings::save() { 33 | String settings; 34 | serializeJson(_settings, settings); 35 | return nvsSaveString(_nvsNamespace, _nvsKey, settings.c_str()); 36 | } 37 | 38 | /** 39 | * Merge json objects 40 | * https://arduinojson.org/v6/how-to/merge-json-objects/ 41 | */ 42 | static void mergeJsonObjects(JsonVariant dst, JsonVariantConst src) { 43 | if (src.is()) { 44 | for (JsonPairConst kvp: src.as()) { 45 | if (dst[kvp.key()]) { 46 | mergeJsonObjects(dst[kvp.key()], kvp.value()); 47 | } else { 48 | dst[kvp.key()] = kvp.value(); 49 | } 50 | } 51 | } else { 52 | dst.set(src); 53 | } 54 | } 55 | 56 | /** 57 | * Import from json string (and save to NVS) 58 | * 59 | * @param text json string 60 | * @param merge true: merge, false: overwrite 61 | * @return true: success, false: failure 62 | */ 63 | bool NvsSettings::load(const String &text, bool merge) { 64 | DynamicJsonDocument tmp{SETTINGS_MAX_SIZE}; 65 | bool result = deserializeJson(tmp, text) == DeserializationError::Ok; 66 | if (result) { 67 | if (merge) { 68 | mergeJsonObjects(_settings, tmp); 69 | } else { 70 | _settings = tmp; 71 | } 72 | } 73 | return result && save(); 74 | } 75 | 76 | /** 77 | * Check if the key exists 78 | * 79 | * @param keyStr key string delimited by "." 80 | * @return true: exists, false: not exists 81 | */ 82 | bool NvsSettings::has(const String &keyStr) { 83 | auto keys = splitString(keyStr.c_str(), "."); 84 | return !_get(keys).isNull(); 85 | } 86 | 87 | /** 88 | * Get the value 89 | * 90 | * @param keyStr key string delimited by "." 91 | * @return value (can be cast to any type) 92 | */ 93 | JsonVariant NvsSettings::get(const String &keyStr) { 94 | auto keys = splitString(keyStr.c_str(), "."); 95 | return _get(keys); 96 | } 97 | 98 | /** 99 | * Set the value 100 | * 101 | * @param keyStr key string delimited by "." 102 | * @param value (any type) 103 | * @return true: success, false: failure 104 | */ 105 | template 106 | bool NvsSettings::set(const String &keyStr, const T &value) { 107 | auto keys = splitString(keyStr.c_str(), "."); 108 | auto key = keys[keys.size() - 1]; 109 | if (std::all_of(key.begin(), key.end(), ::isdigit)) { 110 | _getParentOrCreate(keys)[std::stoi(key)].set(value); 111 | } else { 112 | _getParentOrCreate(keys)[key].set(value); 113 | } 114 | return save(); 115 | } 116 | 117 | template bool NvsSettings::set(const String &key, const std::string &value); 118 | 119 | template bool NvsSettings::set(const String &key, const String &value); 120 | 121 | template bool NvsSettings::set(const String &key, const int &value); 122 | 123 | template bool NvsSettings::set(const String &key, const bool &value); 124 | 125 | /** 126 | * Remove the value 127 | * 128 | * @param keyStr key string delimited by "." 129 | * @return true: success, false: failure 130 | */ 131 | bool NvsSettings::remove(const String &keyStr) { 132 | auto keys = splitString(keyStr.c_str(), "."); 133 | auto key = keys[keys.size() - 1]; 134 | if (std::all_of(key.begin(), key.end(), ::isdigit)) { 135 | _getParentOrCreate(keys).remove(std::stoi(key)); 136 | } else { 137 | _getParentOrCreate(keys).remove(key); 138 | } 139 | return save(); 140 | } 141 | 142 | /** 143 | * Count the number of elements (for array only) 144 | * 145 | * @param keyStr key string delimited by "." 146 | * @return number of elements 147 | */ 148 | size_t NvsSettings::count(const String &keyStr) { 149 | auto keys = splitString(keyStr.c_str(), "."); 150 | return _get(keys).size(); 151 | } 152 | 153 | /** 154 | * Get all elements (for array only) 155 | * 156 | * @param keyStr key string delimited by "." 157 | * @return elements (can be cast to any type) 158 | */ 159 | template 160 | std::vector NvsSettings::getArray(const String &keyStr) { 161 | auto keys = splitString(keyStr.c_str(), "."); 162 | JsonArray arr = _get(keys); 163 | std::vector values; 164 | for (const auto &e: arr) { 165 | values.push_back(e.as()); 166 | } 167 | return values; 168 | } 169 | 170 | template std::vector NvsSettings::getArray(const String &keyStr); 171 | 172 | template std::vector NvsSettings::getArray(const String &keyStr); 173 | 174 | template std::vector NvsSettings::getArray(const String &keyStr); 175 | 176 | template std::vector NvsSettings::getArray(const String &keyStr); 177 | 178 | /** 179 | * Add element (for array only) 180 | * 181 | * @param keyStr key string delimited by "." 182 | * @param value element (any type) 183 | * @return true: success, false: failure 184 | */ 185 | template 186 | bool NvsSettings::add(const String &keyStr, const T &value) { 187 | auto keys = splitString(keyStr.c_str(), "."); 188 | auto key = keys[keys.size() - 1]; 189 | auto val = _getParentOrCreate(keys); 190 | if (val[key].isNull()) { 191 | val = val.createNestedArray(key); 192 | } else { 193 | val = val[key]; 194 | } 195 | val.add(value); 196 | return save(); 197 | } 198 | 199 | template bool NvsSettings::add(const String &keyStr, const std::string &value); 200 | 201 | template bool NvsSettings::add(const String &keyStr, const String &value); 202 | 203 | template bool NvsSettings::add(const String &keyStr, const int &value); 204 | 205 | template bool NvsSettings::add(const String &keyStr, const bool &value); 206 | 207 | /** 208 | * Clear elements (for array only) 209 | * 210 | * @param keyStr key string delimited by "." 211 | * @return true: success, false: failure 212 | */ 213 | bool NvsSettings::clear(const String &keyStr) { 214 | auto keys = splitString(keyStr.c_str(), "."); 215 | auto key = keys[keys.size() - 1]; 216 | auto val = _getParentOrCreate(keys); 217 | if (val[key].isNull()) { 218 | val = val.createNestedArray(key); 219 | } else { 220 | val = val[key]; 221 | } 222 | val.clear(); 223 | return save(); 224 | } 225 | 226 | /** 227 | * Get the json element from key list 228 | * 229 | * @param keys key list 230 | * @return json element 231 | */ 232 | JsonVariant NvsSettings::_get(std::vector &keys) { 233 | JsonVariant val = _settings; 234 | for (const auto &key: keys) { 235 | if (std::all_of(key.begin(), key.end(), ::isdigit)) { 236 | val = val[std::stoi(key)]; 237 | } else { 238 | val = val[key]; 239 | } 240 | } 241 | return val; 242 | } 243 | 244 | /** 245 | * Get the parent json element (create if not exists) 246 | * 247 | * @param keys key list 248 | * @return json element 249 | */ 250 | JsonVariant NvsSettings::_getParentOrCreate(std::vector &keys) { 251 | JsonVariant val = _settings; 252 | for (int i = 0; i < keys.size() - 1; i++) { 253 | auto key = keys[i]; 254 | auto nextKey = keys[i + 1]; 255 | if (std::all_of(key.begin(), key.end(), ::isdigit)) { 256 | int keyNum = std::stoi(key); 257 | // val is array 258 | if (val[keyNum].isNull()) { 259 | if (std::all_of(nextKey.begin(), nextKey.end(), ::isdigit)) { 260 | // create array when the next key is number 261 | val = val.createNestedArray(keyNum); 262 | } else { 263 | val = val.createNestedObject(keyNum); 264 | } 265 | } else { 266 | val = val[keyNum]; 267 | } 268 | } else { 269 | // val is object 270 | if (val[key].isNull()) { 271 | if (std::all_of(nextKey.begin(), nextKey.end(), ::isdigit)) { 272 | // create array when the next key is number 273 | val = val.createNestedArray(key); 274 | } else { 275 | val = val.createNestedObject(key); 276 | } 277 | } else { 278 | val = val[key]; 279 | } 280 | } 281 | } 282 | return val; 283 | } 284 | -------------------------------------------------------------------------------- /src/lib/ChatGptClient.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include 7 | 8 | #include "lib/ChatGptClient.h" 9 | #include "lib/ssl.h" 10 | #include "lib/utils.h" 11 | 12 | /// Chat API URL 13 | static const char *CHAT_URL = "https://api.openai.com/v1/chat/completions"; 14 | 15 | /// size for request/response 16 | static const size_t CONTENT_MAX_SIZE = 16 * 1024; 17 | 18 | ChatGptClient::ChatGptClient(String apiKey, String model) : _apiKey(std::move(apiKey)), _model(std::move(model)) {} 19 | 20 | /** 21 | * Ask to the ChatGPT and get answer 22 | * 23 | * @param text question 24 | * @param roles roles 25 | * @param history chat history 26 | * @param onReceiveContent callback on receive content (use stream if present) 27 | * @return answer (nullptr: failure) 28 | * @throws ChatGptClientError 29 | */ 30 | String ChatGptClient::chat( 31 | const String &text, const std::vector &roles, const std::deque &history, 32 | const std::function &onReceiveContent) { 33 | DynamicJsonDocument requestDoc{CONTENT_MAX_SIZE}; 34 | DynamicJsonDocument responseDoc{CONTENT_MAX_SIZE}; 35 | requestDoc["model"] = _model; 36 | if (onReceiveContent != nullptr) { 37 | requestDoc["stream"] = true; 38 | } 39 | JsonArray messages = requestDoc.createNestedArray("messages"); 40 | // Append roles to request parameters 41 | for (auto &&role: roles) { 42 | JsonObject newMessage = messages.createNestedObject(); 43 | newMessage["role"] = "system"; 44 | newMessage["content"] = role; 45 | } 46 | // Append chat history to request parameters 47 | for (int i = 0; i < history.size(); i++) { 48 | JsonObject newMessage = messages.createNestedObject(); 49 | newMessage["role"] = (i % 2 == 0) ? "user" : "assistant"; 50 | newMessage["content"] = history[i]; 51 | } 52 | // Append question to request parameters 53 | JsonObject newMessage = messages.createNestedObject(); 54 | newMessage["role"] = "user"; 55 | newMessage["content"] = text; 56 | 57 | if (onReceiveContent != nullptr) { 58 | std::stringstream ss; 59 | _httpPost(CHAT_URL, jsonEncode(requestDoc), [&](const String &data) { 60 | // Handle server-sent event 61 | // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format 62 | if (data == "[DONE]") { 63 | return; 64 | } 65 | auto error = deserializeJson(responseDoc, data.c_str()); 66 | if (error != DeserializationError::Ok) { 67 | Serial.printf("ERROR: Failed to deserialize JSON: %s\n", error.c_str()); 68 | throw ChatGptClientError("Failed to deserialize JSON"); 69 | } 70 | const char *content = responseDoc["choices"][0]["delta"]["content"]; 71 | if (content != nullptr) { 72 | onReceiveContent(content); 73 | ss << content; 74 | } 75 | }); 76 | return String{ss.str().c_str()}; 77 | } else { 78 | auto result = _httpPost(CHAT_URL, jsonEncode(requestDoc), nullptr); 79 | auto error = deserializeJson(responseDoc, result.c_str()); 80 | if (error != DeserializationError::Ok) { 81 | Serial.printf("ERROR: Failed to deserialize JSON: %s\n", error.c_str()); 82 | throw ChatGptClientError("Failed to deserialize JSON"); 83 | } 84 | auto content = responseDoc["choices"][0]["message"]["content"]; 85 | if (content == nullptr) { 86 | throw ChatGptClientError("No content"); 87 | } 88 | return content.as(); 89 | } 90 | } 91 | 92 | /** 93 | * Read chunk 94 | * 95 | * @param stream WifiClient stream 96 | * @param onReceiveChunk callback on receive chunk 97 | * @return true: success, false: failure 98 | */ 99 | static bool readChunk(WiFiClient *stream, const std::function &onReceiveChunk) { 100 | char buf[16]; 101 | while (true) { 102 | // Read chunk size 103 | int chunkSize; 104 | int pos = 0; 105 | while (true) { 106 | delay(10); 107 | auto available = stream->available(); 108 | if (available == 0) { 109 | continue; 110 | } 111 | auto c = stream->read(); 112 | if (c < 0) { 113 | Serial.printf("readChunk: read failed (%d)\n", c); 114 | return false; 115 | } 116 | buf[pos++] = (char) c; 117 | if (pos > 8) { // hex 6 桁まで 118 | Serial.println("readChunk: Invalid chunk size (too long)"); 119 | return false; 120 | } 121 | if (pos >= 2 && buf[pos - 2] == '\r' && buf[pos - 1] == '\n') { 122 | buf[pos - 2] = '\0'; 123 | //Serial.printf("readChunk: chunkSize=%s\n", buf); 124 | char *endp; 125 | chunkSize = strtol((char *) buf, &endp, 16); 126 | if (endp != (char *) &buf[pos - 2]) { 127 | Serial.printf("readChunk: Invalid chunk size: %s\n", buf); 128 | return -1; 129 | } 130 | //Serial.printf("readChunk: chunkSize=%d\n", chunkSize); 131 | break; 132 | } 133 | } 134 | if (chunkSize == 0) { 135 | //Serial.println("readChunk: stream end"); 136 | break; 137 | } 138 | 139 | // Read chunk 140 | auto chunkData = std::unique_ptr((char *) malloc(chunkSize + 1)); 141 | char *p = chunkData.get(); 142 | int rest = chunkSize; 143 | while (rest > 0) { 144 | delay(10); 145 | auto len = stream->readBytes(p, rest); 146 | p += len; 147 | rest -= (int) len; 148 | } 149 | *p = '\0'; 150 | //Serial.printf("readChunk: chunkData=[[[%s]]]\n", chunkData.get()); 151 | 152 | // Skip chunk delimiter 153 | auto len = stream->readBytes(buf, 2); 154 | if (len != 2 || buf[0] != '\r' || buf[1] != '\n') { 155 | Serial.println("readChunk: Invalid chunk delimiter"); 156 | return false; 157 | } 158 | 159 | if (!onReceiveChunk((const char *) chunkData.get())) { 160 | return false; 161 | } 162 | } 163 | return true; 164 | } 165 | 166 | /** 167 | * Read data of Server-Sent Events 168 | * 169 | * @param stream WifiClient stream 170 | * @param onReceiveData callback on receive data 171 | * @return true: success, false: failure 172 | */ 173 | static bool readData(WiFiClient *stream, const std::function &onReceiveData) { 174 | #define SSE_DATA_PREFIX "data: " 175 | #define SSE_DATA_DELIMITER "\n\n" // TODO: or "\r\n\r\n" ? 176 | std::stringstream ss; 177 | return readChunk(stream, [&](const std::string &chunk) { 178 | ss << chunk; 179 | //Serial.printf("readData: Read chunk: %d\n", chunk.length()); 180 | 181 | while (true) { 182 | std::string str = ss.str(); 183 | //Serial.printf("readData: str=[[[%s]]] ", str.c_str()); 184 | //for (int i = 0; i < str.length(); i++) { 185 | // Serial.printf("%02x", str[i]); 186 | //} 187 | //Serial.println(); 188 | 189 | auto dataEnd = str.find(SSE_DATA_DELIMITER); 190 | //Serial.printf("readData: dataEnd=%d\n", dataEnd); 191 | if (dataEnd == std::string::npos) { 192 | break; 193 | } 194 | std::string nextData = str.substr(dataEnd + strlen(SSE_DATA_DELIMITER)); 195 | std::string oneData = str.substr(strlen(SSE_DATA_PREFIX), dataEnd - strlen(SSE_DATA_PREFIX)); 196 | //Serial.printf("readData: data=[[[%s]]]\n", data.c_str()); 197 | //Serial.printf("readData: tail=[[[%s]]]\n", tail.c_str()); 198 | onReceiveData(oneData); 199 | ss.clear(); 200 | ss.str(nextData); 201 | } 202 | return true; 203 | }); 204 | } 205 | 206 | /** 207 | * HTTP POST 208 | * 209 | * @param url URL 210 | * @param body body 211 | * @param onReceiveData callback on receive data 212 | * @return response body 213 | * @throws ChatGptClientError 214 | */ 215 | String ChatGptClient::_httpPost( 216 | const String &url, const String &body, 217 | const std::function &onReceiveData) { 218 | HTTPClient http; 219 | http.setReuse(false); 220 | http.setTimeout(60000); 221 | static const char *headerKeys[] = {"Content-Type", "Transfer-Encoding"}; 222 | http.collectHeaders(headerKeys, 2); 223 | 224 | WiFiClientSecure client; 225 | #if defined(USE_CA_CERT_BUNDLE) 226 | client.setCACertBundle(rootca_crt_bundle); 227 | #else 228 | client.setCACert(gts_root_r4_crt); 229 | #endif 230 | if (http.begin(client, url)) { 231 | http.addHeader("Content-Type", "application/json"); 232 | http.addHeader("Authorization", String("Bearer ") + _apiKey); 233 | Serial.printf(">>> POST %s\n", url.c_str()); 234 | Serial.printf("%s\n", body.c_str()); 235 | int httpCode = http.POST((uint8_t *) body.c_str(), body.length()); 236 | if (httpCode != HTTP_CODE_OK) { 237 | Serial.println(HTTPClient::errorToString(httpCode).c_str()); 238 | throw ChatGptHttpError(httpCode, "HTTP client error: " + String(httpCode)); 239 | } 240 | 241 | Serial.printf("<<< %d\n", httpCode); 242 | String payload; 243 | if (onReceiveData != nullptr 244 | && http.header("Content-Type").startsWith("text/event-stream") 245 | && http.header("Transfer-Encoding") == "chunked") { 246 | // Receive Event Stream 247 | std::stringstream ss; 248 | auto result = readData(http.getStreamPtr(), [&](const std::string &data) { 249 | onReceiveData(data.c_str()); 250 | ss << data; 251 | }); 252 | if (!result) { 253 | throw ChatGptClientError("Failed to receive data"); 254 | } 255 | payload = String(ss.str().c_str()); 256 | } else { 257 | payload = http.getString(); 258 | } 259 | http.end(); 260 | return payload; 261 | } else { 262 | throw ChatGptClientError("HTTP begin failed"); 263 | } 264 | } 265 | --------------------------------------------------------------------------------