This commit is contained in:
akdeb 2025-04-08 14:05:27 +01:00
parent 8c089a87d7
commit 5c76b9cd92
312 changed files with 34375 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
firmware-cpp/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
firmware-cpp/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

194
firmware-cpp/README.md Normal file
View file

@ -0,0 +1,194 @@
# ESP32 WebSocket Audio Client
This firmware turns your Seed Studio XIAO ESP32-S3 (or general ESP32 WROOM Dev module) device into a WebSocket audio client, enabling real-time full-duplex audio communication with the server hosted at `../backend`. It's designed to be used in interactive toys or devices to converse with your personal AI characters.
## Pin Configuration
<!-- ### For Seeed Studio XIAO ESP32S3 -->
| **Component** | **Seeed Studio XIAO ESP32S3** | **General ESP32 Dev Board** |
| -------------------------- | ----------------------------- | --------------------------- |
| **I2S Input (Microphone)** | | |
| SD | D9 | GPIO 13 |
| WS | D7 | GPIO 5 |
| SCK | GD8 | GPIO 18 |
| **I2S Output (Speaker)** | | |
| WS | D0 | GPIO 32 |
| BCK | D1 | GPIO 33 |
| DATA | D2 | GPIO 25 |
| SD (shutdown) | D3 | N/A |
| **Others** | | |
| LED | D4 | GPIO 2 |
| Button | D5 | GPIO 26 |
<!--
I2S Input (Microphone)
- SD: D9
- WS: D7
- SCK: GD8
I2S Output (Speaker with amp MAX98357A)
- WS: D0
- BCK: D1
- DATA: D2
- SD: D3 (shutdown)
Other
- LED: D4
- Button: D5
### For a general ESP32 dev board
I2S Input (Microphone)
- SD: GPIO 13
- WS: GPIO 5
- SCK: GPIO 18
I2S Output (Speaker)
- WS: GPIO 32
- BCK: GPIO 33
- DATA: GPIO 25
Other
- LED: GPIO 2
- Button: GPIO 26 -->
## Firmware burning with PlatformIO
1. Install PlatformIO IDE (Visual Studio Code extension) if you haven't already.
2. Create a new PlatformIO project:
- Open PlatformIO Home
- Click "New Project"
- Name your project (e.g., "FullDuplexWebSocketAudio")
- Select "Espressif ESP32 Dev Module" as the board
- Choose "Arduino" as the framework
- Select a location for your project
3. Replace the contents of `src/main.cpp` with the provided ESP32 WebSocket Audio Client code.
4. Add the required libraries to your `platformio.ini` file:
- For Seeed Studio XIAO ESP32S3
```ini
[env:seeed_xiao_esp32s3]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
monitor_speed = 115200
lib_deps =
https://github.com/tzapu/WiFiManager.git
gilmaimon/ArduinoWebsockets@^0.5.4
bblanchon/ArduinoJson@^7.1.0
```
- For a general ESP32 Dev board
```ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
https://github.com/tzapu/WiFiManager.git
gilmaimon/ArduinoWebsockets @ ^0.5.3
bblanchon/ArduinoJson @ ^7.1.0
```
5. Update the WebSocket server details in the code:
- Find the following lines in the code and update them with your information:
```cpp
const char *websocket_server_host = "<your-server-host>"; // this is your WiFi I.P. Address
const uint16_t websocket_server_port = 8000;
const char *websocket_server_path = "/Elato";
const char *auth_token = "<your-auth-token-here>"; // generate auth-token in your Elato web-app in Settings
```
6. Build the project:
- Click the "PlatformIO: Build" button in the PlatformIO toolbar or run the build task.
7. Upload the firmware:
- Connect your ESP32 to your computer.
- Click the "PlatformIO: Upload" button or run the upload task.
8. Monitor the device:
- Open the Serial Monitor to view debug output and device status.
- You can do this by clicking the "PlatformIO: Serial Monitor" button or running the monitor task.
9. Connect to WiFi using the WiFi Captive portal
- It is straightforward to connect to your local Wifi network with an SSID (WiFi name) and Password.
- Once the device is on, it acts as an Access Point to connect to a known WiFi network.
- Find the device name "Elato device" in your list of local wifi networks.
- Press "Configure Wifi" and type in your SSID and PW for your Wifi and connect.
- The Seeed Stuido XIAO ESP32S3 should then automatically connect to your Wifi and save your Wifi details.
## Usage
1. Power on the ESP32 device.
2. The device will automatically connect to the WiFi network as set on the Captive portal.
3. Press the button to initiate a full-duplex WebSocket connection to the server.
4. The LED indicates the current status:
- Off: Not connected
- Solid On: Connected and listening on microphone
- Pulsing: Streaming audio output (receiving from server)
5. Speak into the microphone to send audio to the server.
6. The device will play audio received from the server through the speaker.
<!-- ## Features -->
<!-- - Real-time audio streaming using WebSocket
- Full-duplex I2S audio input (microphone) and I2S audio output (speaker)
- WiFi connectivity
- LED status indicator -->
<!-- - Button interrupt for connection management -->
<!-- ## Hardware Requirements
- ESP32 development board
- INMP441 MEMS microphone (I2S input)
- MAX98357A amplifier (I2S output)
- LED (for status indication)
- Push button (for connection control)
- USB Type-C or Micro USB power cable -->
## Functions
- `micTask`: Handles audio input from the microphone
- `buttonTask`: Manages button presses for connection control
- `ledControlTask`: Controls the LED status indicator
- `handleTextMessage`: Processes text messages from the server
- `handleBinaryAudio`: Processes binary audio data from the server
## Customization
You can modify the following parameters in the code:
<!-- - Audio sample rate (`SAMPLE_RATE`) -->
- Buffer sizes (`bufferCnt`, `bufferLen`)
<!-- - LED brightness levels (`MIN_BRIGHTNESS`, `MAX_BRIGHTNESS`) -->
- Debounce time for the button (`DEBOUNCE_TIME`)
## Troubleshooting
- If you experience connection issues, check your WiFi credentials and server details.
- Ensure all required libraries are installed and up to date.
- Verify that the pin configuration matches your hardware setup.
## Contributing
Feel free to submit issues or pull requests to improve this firmware.

Binary file not shown.

View file

@ -0,0 +1,39 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the usual convention is to give header files names that end with `.h'.
It is most portable to use only letters, digits, dashes, and underscores in
header file names, and at most one dot.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

46
firmware-cpp/lib/README Normal file
View file

@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into executable file.
The source code of each library should be placed in an own separate directory
("lib/your_library_name/[here are source files]").
For example, see a structure of the following two libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
and a contents of `src/main.c`:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
PlatformIO Library Dependency Finder will find automatically dependent
libraries scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View file

@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 36K, 20K,
otadata, data, ota, 56K, 8K,
app0, app, ota_0, 64K, 2M,
app1, app, ota_1, , 2M,
spiffs, data, spiffs, , 3M,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 36K 20K
3 otadata data ota 56K 8K
4 app0 app ota_0 64K 2M
5 app1 app ota_1 2M
6 spiffs data spiffs 3M

View file

@ -0,0 +1,43 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp32-s3-devkitc-1]
platform = espressif32 @ 6.10.0
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
lib_deps =
bblanchon/ArduinoJson@^7.1.0
links2004/WebSockets@^2.4.1
https://github.com/esp-arduino-libs/ESP32_Button.git
https://github.com/pschatzmann/arduino-audio-tools.git#v1.0.1
https://github.com/pschatzmann/arduino-libopus.git
ESP32Async/AsyncTCP
ESP32Async/ESPAsyncWebServer
; board_build.arduino.memory_type = qio_opi
; board_build.flash_mode = qio
; board_build.prsam_type = opi
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.filesystem = spiffs
board_build.partitions = partition.csv
upload_protocol = esptool
monitor_filters =
esp32_exception_decoder
time
build_unflags = -std=gnu++11
build_flags =
-std=gnu++17
-D CORE_DEBUG_LEVEL=5
-D DEBUG_ESP_PORT=Serial
-D TOUCH_SENSOR_ENABLE=1

370
firmware-cpp/src/Audio.cpp Normal file
View file

@ -0,0 +1,370 @@
#include "OTA.h"
#include "Print.h"
#include "Config.h"
#include "AudioTools.h"
// #include "AudioTools/Concurrency/RTOS.h"
#include "AudioTools/AudioCodecs/CodecOpus.h"
#include <WebSocketsClient.h>
#include "Audio.h"
// WEBSOCKET
SemaphoreHandle_t wsMutex;
WebSocketsClient webSocket;
// TASK HANDLES
TaskHandle_t speakerTaskHandle = NULL;
TaskHandle_t micTaskHandle = NULL;
TaskHandle_t networkTaskHandle = NULL;
// TIMING REGISTERS
bool scheduleListeningRestart = false;
unsigned long scheduledTime = 0;
unsigned long speakingStartTime = 0;
// AUDIO SETTINGS
int currentVolume = 70;
const int CHANNELS = 1; // Mono
const int BITS_PER_SAMPLE = 16; // 16-bit audio
// AUDIO OUTPUT
class BufferPrint : public Print {
public:
BufferPrint(BufferRTOS<uint8_t>& buf) : _buffer(buf) {}
virtual size_t write(uint8_t data) override {
if (webSocket.isConnected() && deviceState == SPEAKING) {
return _buffer.writeArray(&data, 1);
}
return 0;
}
virtual size_t write(const uint8_t *buffer, size_t size) override {
if (webSocket.isConnected() && deviceState == SPEAKING) {
return _buffer.writeArray(buffer, size);
}
return 0;
}
private:
BufferRTOS<uint8_t>& _buffer;
};
BufferPrint bufferPrint(audioBuffer);
OpusAudioDecoder opusDecoder;
BufferRTOS<uint8_t> audioBuffer(AUDIO_BUFFER_SIZE, AUDIO_CHUNK_SIZE);
I2SStream i2s;
VolumeStream volume(i2s);
QueueStream<uint8_t> queue(audioBuffer);
StreamCopy copier(volume, queue);
AudioInfo info(SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE);
unsigned long getSpeakingDuration() {
if (deviceState == SPEAKING && speakingStartTime > 0) {
return millis() - speakingStartTime;
}
return 0;
}
void transitionToSpeaking() {
vTaskDelay(50);
i2sInput.flush();
if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
deviceState = SPEAKING;
digitalWrite(10, HIGH);
speakingStartTime = millis();
webSocket.enableHeartbeat(30000, 15000, 3);
xSemaphoreGive(wsMutex);
}
Serial.println("Transitioned to speaking mode");
}
void transitionToListening() {
deviceState = PROCESSING;
scheduleListeningRestart = false;
Serial.println("Transitioning to listening mode");
// These stream operations don't directly interact with the WebSocket
i2s.flush();
volume.flush();
queue.flush();
i2sInput.flush();
audioBuffer.reset();
Serial.println("Transitioned to listening mode");
if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
deviceState = LISTENING;
digitalWrite(10, LOW);
webSocket.disableHeartbeat();
xSemaphoreGive(wsMutex);
}
}
void audioStreamTask(void *parameter) {
Serial.println("Starting I2S stream pipeline...");
pinMode(10, OUTPUT);
OpusSettings cfg;
cfg.sample_rate = SAMPLE_RATE;
cfg.channels = CHANNELS;
cfg.bits_per_sample = BITS_PER_SAMPLE;
cfg.max_buffer_size = 6144;
opusDecoder.setOutput(bufferPrint);
opusDecoder.begin(cfg);
queue.begin();
auto config = i2s.defaultConfig(TX_MODE);
config.bits_per_sample = BITS_PER_SAMPLE;
config.sample_rate = SAMPLE_RATE;
config.channels = CHANNELS;
config.pin_bck = I2S_BCK_OUT;
config.pin_ws = I2S_WS_OUT;
config.pin_data = I2S_DATA_OUT;
config.port_no = I2S_PORT_OUT;
config.copyFrom(info);
i2s.begin(config);
auto vcfg = volume.defaultConfig();
vcfg.copyFrom(config);
vcfg.allow_boost = true;
volume.begin(vcfg);
while (1) {
if (webSocket.isConnected() && deviceState == SPEAKING) {
copier.copy();
}
vTaskDelay(1);
}
}
// AUDIO INPUT SETTINGS
class WebsocketStream : public Print {
public:
virtual size_t write(uint8_t b) override {
if (!webSocket.isConnected() || deviceState != LISTENING) {
return 1;
}
if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
webSocket.sendBIN(&b, 1);
xSemaphoreGive(wsMutex);
return 1;
}
return 1;
}
virtual size_t write(const uint8_t *buffer, size_t size) override {
if (size == 0 || !webSocket.isConnected() || deviceState != LISTENING) {
return size;
}
if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
webSocket.sendBIN(buffer, size);
xSemaphoreGive(wsMutex);
return size;
}
return size;
}
};
WebsocketStream wsStream;
I2SStream i2sInput;
StreamCopy micToWsCopier(wsStream, i2sInput);
const int MIC_COPY_SIZE = 64;
void micTask1(void *parameter) {
auto i2sConfig = i2sInput.defaultConfig(RX_MODE);
i2sConfig.bits_per_sample = BITS_PER_SAMPLE;
i2sConfig.sample_rate = SAMPLE_RATE;
i2sConfig.channels = CHANNELS;
i2sConfig.i2s_format = I2S_LEFT_JUSTIFIED_FORMAT;
i2sConfig.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
i2sConfig.pin_bck = I2S_SCK;
i2sConfig.pin_ws = I2S_WS;
i2sConfig.pin_data = I2S_SD;
i2sConfig.port_no = I2S_PORT_IN;
i2sInput.begin(i2sConfig);
while (1) {
if (scheduleListeningRestart && millis() >= scheduledTime) {
transitionToListening();
}
if (deviceState == LISTENING && webSocket.isConnected()) {
micToWsCopier.copyBytes(MIC_COPY_SIZE);
}
vTaskDelay(1);
}
}
void micTask(void *parameter) {
// start I2S input stream.
auto i2sConfig = i2sInput.defaultConfig(RX_MODE);
i2sConfig.bits_per_sample = BITS_PER_SAMPLE;
i2sConfig.sample_rate = SAMPLE_RATE;
i2sConfig.channels = CHANNELS;
i2sConfig.i2s_format = I2S_LEFT_JUSTIFIED_FORMAT;
i2sConfig.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
// I2S input pins a
i2sConfig.pin_bck = I2S_SCK;
i2sConfig.pin_ws = I2S_WS;
i2sConfig.pin_data = I2S_SD;
i2sConfig.port_no = I2S_PORT_IN;
i2sInput.begin(i2sConfig);
while (1) {
// Checking to see if a transition to listening mode is scheduled.
if (scheduleListeningRestart && millis() >= scheduledTime) {
transitionToListening();
}
if (deviceState == LISTENING && webSocket.isConnected()) {
// smaller chunk size to avoid blocking too long
micToWsCopier.copyBytes(MIC_COPY_SIZE);
// yielding frequently
vTaskDelay(1);
} else {
vTaskDelay(10);
}
}
}
// WEBSOCKET EVENTS
void webSocketEvent(WStype_t type, uint8_t *payload, size_t length)
{
switch (type)
{
case WStype_DISCONNECTED:
Serial.printf("[WSc] Disconnected!\n");
deviceState = IDLE;
break;
case WStype_CONNECTED:
Serial.printf("[WSc] Connected to url: %s\n", payload);
deviceState = PROCESSING;
break;
case WStype_TEXT:
{
Serial.printf("[WSc] get text: %s\n", payload);
JsonDocument doc;
DeserializationError error = deserializeJson(doc, (char *)payload);
if (error)
{
Serial.println("Error deserializing JSON");
deviceState = IDLE;
return;
}
String type = doc["type"];
// auth messages
if (strcmp((char*)type.c_str(), "auth") == 0) {
currentVolume = doc["volume_control"].as<int>();
bool is_ota = doc["is_ota"].as<bool>();
bool is_reset = doc["is_reset"].as<bool>();
volume.setVolume(currentVolume / 100.0f); // Setting initial volume (e.g., 70/100 = 0.7)
if (is_ota) {
Serial.println("OTA update received");
setOTAStatusInNVS(OTA_IN_PROGRESS);
ESP.restart();
}
if (is_reset) {
Serial.println("Factory reset received");
// setFactoryResetStatusInNVS(true);
ESP.restart();
}
}
// oai messages
if (strcmp((char*)type.c_str(), "server") == 0) {
String msg = doc["msg"];
Serial.println(msg);
if (strcmp((char*)msg.c_str(), "RESPONSE.COMPLETE") == 0 || strcmp((char*)msg.c_str(), "RESPONSE.ERROR") == 0) {
Serial.println("Received RESPONSE.COMPLETE or RESPONSE.ERROR, starting listening again");
// Check if volume_control is included in the message
if (doc.containsKey("volume_control")) {
int newVolume = doc["volume_control"].as<int>();
volume.setVolume(newVolume / 100.0f);
}
scheduleListeningRestart = true;
scheduledTime = millis() + 1000; // 1 second delay
} else if (strcmp((char*)msg.c_str(), "AUDIO.COMMITTED") == 0) {
deviceState = PROCESSING;
} else if (strcmp((char*)msg.c_str(), "RESPONSE.CREATED") == 0) {
Serial.println("Received RESPONSE.CREATED, transitioning to speaking");
transitionToSpeaking();
}
}
}
break;
case WStype_BIN:
{
if (scheduleListeningRestart || deviceState != SPEAKING) {
Serial.println("Skipping audio data due to touch interrupt.");
break;
}
// Otherwise process the audio data normally
size_t processed = opusDecoder.write(payload, length);
if (processed != length) {
Serial.printf("Warning: Only processed %d/%d bytes\n", processed, length);
}
break;
}
case WStype_ERROR:
Serial.printf("[WSc] Error: %s\n", payload);
break;
case WStype_FRAGMENT_TEXT_START:
case WStype_FRAGMENT_BIN_START:
case WStype_FRAGMENT:
case WStype_PONG:
case WStype_PING:
case WStype_FRAGMENT_FIN:
break;
}
}
void websocketSetup(String server_domain, int port, String path)
{
String headers = "Authorization: Bearer " + String(authTokenGlobal);
webSocket.setExtraHeaders(headers.c_str());
webSocket.onEvent(webSocketEvent);
webSocket.setReconnectInterval(1000);
webSocket.enableHeartbeat(30000, 15000, 3); // 30s ping interval, 15s timeout, 3 retries
#ifdef DEV_MODE
webSocket.begin(server_domain.c_str(), port, path.c_str());
#else
webSocket.beginSslWithCA(server_domain.c_str(), port, path.c_str(), CA_cert);
#endif
}
void networkTask(void *parameter) {
while (1) {
webSocket.loop();
vTaskDelay(1);
}
}

49
firmware-cpp/src/Audio.h Normal file
View file

@ -0,0 +1,49 @@
#include "Print.h"
#include "Config.h"
#include "AudioTools.h"
// #include "AudioTools/Concurrency/RTOS.h"
#include "AudioTools/AudioCodecs/CodecOpus.h"
#include <WebSocketsClient.h>
extern SemaphoreHandle_t wsMutex;
extern WebSocketsClient webSocket;
extern TaskHandle_t speakerTaskHandle;
extern TaskHandle_t micTaskHandle;
extern TaskHandle_t networkTaskHandle;
extern bool scheduleListeningRestart;
extern unsigned long scheduledTime;
extern unsigned long speakingStartTime;
extern int currentVolume;
extern const int CHANNELS; // Mono
extern const int BITS_PER_SAMPLE; // 16-bit audio
// AUDIO OUTPUT
constexpr size_t AUDIO_BUFFER_SIZE = 1024 * 10; // total bytes in the buffer
constexpr size_t AUDIO_CHUNK_SIZE = 1024; // ideal read/write chunk size
extern OpusAudioDecoder opusDecoder;
extern BufferRTOS<uint8_t> audioBuffer;
extern I2SStream i2s;
extern VolumeStream volume;
extern QueueStream<uint8_t> queue;
extern StreamCopy copier;
extern AudioInfo info;
// AUDIO INPUT
extern I2SStream i2sInput;
extern StreamCopy micToWsCopier;
// WEBSOCKET
void webSocketEvent(WStype_t type, uint8_t *payload, size_t length);
void websocketSetup(String server_domain, int port, String path);
void networkTask(void *parameter);
// AUDIO OUTPUT
unsigned long getSpeakingDuration();
void audioStreamTask(void *parameter);
// AUDIO INPUT
void micTask(void *parameter);

View file

@ -0,0 +1,82 @@
#include "Config.h"
#include <nvs_flash.h>
// ! define preferences
Preferences preferences;
OtaStatus otaState = OTA_IDLE;
bool factory_reset_status = false;
// websocket_setup("192.168.1.166", 8000, "/");
// websocket_setup("talkedge.deno.dev",443, "/");
// websocket_setup("xygbupeczfhwamhqnucy.supabase.co", 443, "/functions/v1/relay");
// websocket_setup("https://emkmtesvjrqhvx2mo2mxslvmmy0zsuhq.lambda-url.us-east-1.on.aws/", 8000, "/");
// Runtime WebSocket server details
#ifdef DEV_MODE
const char *ws_server = "10.2.1.25";
const uint16_t ws_port = 8000;
const char *ws_path = "/";
// Backend server details
const char *backend_server = "10.2.1.25";
const uint16_t backend_port = 3000;
#else
// PROD
const char *ws_server = "talkedge.deno.dev";
const uint16_t ws_port = 443;
const char *ws_path = "/";
// Backend server details
const char *backend_server = "www.elatoai.com";
const uint16_t backend_port = 3000;
#endif
String authTokenGlobal;
DeviceState deviceState = IDLE;
// I2S and Audio parameters
const uint32_t SAMPLE_RATE = 24000;
// ----------------- Pin Definitions -----------------
const i2s_port_t I2S_PORT_IN = I2S_NUM_1;
const i2s_port_t I2S_PORT_OUT = I2S_NUM_0;
#ifdef USE_NORMAL_ESP32
const int BLUE_LED_PIN = 13;
const int RED_LED_PIN = 9;
const int GREEN_LED_PIN = 8;
const int I2S_SD = 14;
const int I2S_WS = 4;
const int I2S_SCK = 1;
const int I2S_WS_OUT = 5;
const int I2S_BCK_OUT = 6;
const int I2S_DATA_OUT = 7;
const int I2S_SD_OUT = 10;
const gpio_num_t BUTTON_PIN = GPIO_NUM_2; // Only RTC IO are allowed - ESP32 Pin example
#endif
// supabase CA cert
// const char *CA_cert = R"EOF(
// -----BEGIN CERTIFICATE-----
// <YOUR HOST CERTIFICATE HERE>
// -----END CERTIFICATE-----
// )EOF";
const char *Vercel_CA_cert = R"EOF(
-----BEGIN CERTIFICATE-----
<YOUR VERCEL CERTIFICATE HERE>
-----END CERTIFICATE-----
)EOF";
// talkedge.deno.dev CA cert
const char *CA_cert = R"EOF(
-----BEGIN CERTIFICATE-----
<YOUR TALKEDGE CERTIFICATE HERE>
-----END CERTIFICATE-----
)EOF";

91
firmware-cpp/src/Config.h Normal file
View file

@ -0,0 +1,91 @@
#ifndef CONFIG_H
#define CONFIG_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <driver/i2s.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <WebSocketsClient.h>
extern Preferences preferences;
extern bool factory_reset_status;
enum OtaStatus {
OTA_IDLE,
OTA_IN_PROGRESS,
OTA_COMPLETE
};
extern OtaStatus otaState;
enum DeviceState
{
SETUP,
IDLE,
LISTENING,
SPEAKING,
PROCESSING,
WAITING,
OTA,
FACTORY_RESET
};
extern DeviceState deviceState;
// WiFi credentials
extern const char *EAP_IDENTITY;
extern const char *EAP_USERNAME;
extern const char *EAP_PASSWORD;
extern const char *ssid;
extern const char *ssid_peronal;
extern const char *password_personal;
extern String authTokenGlobal;
// WebSocket server details
extern const char *ws_server;
extern const uint16_t ws_port;
extern const char *ws_path;
// Backend server details
extern const char *backend_server;
extern const uint16_t backend_port;
// I2S and Audio parameters
extern const uint32_t SAMPLE_RATE;
// ---------- Development ------------
// #define DEV_MODE
#define TOUCH_MODE
// ----------------- Pin Definitions -----------------
#define USE_NORMAL_ESP32
extern const int BLUE_LED_PIN;
extern const int RED_LED_PIN;
extern const int GREEN_LED_PIN;
extern const gpio_num_t BUTTON_PIN;
// I2S Microphone pins
extern const int I2S_SD;
extern const int I2S_WS;
extern const int I2S_SCK;
extern const i2s_port_t I2S_PORT_IN;
// I2S Speaker pins
extern const int I2S_WS_OUT;
extern const int I2S_BCK_OUT;
extern const int I2S_DATA_OUT;
extern const i2s_port_t I2S_PORT_OUT;
extern const int I2S_SD_OUT;
// SSL certificate
extern const char *CA_cert;
extern const char *Vercel_CA_cert;
void factoryResetDevice();
#endif

View file

@ -0,0 +1,80 @@
#include <Config.h>
#include <nvs_flash.h>
#include <ESPAsyncWebServer.h> //https://github.com/me-no-dev/ESPAsyncWebServer using the latest dev version from @me-no-dev
void setResetComplete() {
HTTPClient http;
WiFiClientSecure client;
client.setCACert(Vercel_CA_cert); // Using the existing server certificate
// Construct JSON payload
JsonDocument doc;
doc["authToken"] = authTokenGlobal;
String jsonString;
serializeJson(doc, jsonString);
// Initialize HTTPS connection with client
#ifdef DEV_MODE
http.begin("http://" + String(backend_server) + ":" + String(backend_port) + "/api/factory_reset_handler");
#else
http.begin(client, "https://" + String(backend_server) + "/api/factory_reset_handler");
#endif
http.addHeader("Content-Type", "application/json");
http.setTimeout(10000); // Add timeout for reliability
// Make the POST request
int httpCode = http.POST(jsonString);
// ... existing code ...
if (httpCode > 0) {
if (httpCode == HTTP_CODE_OK) {
Serial.println("Factory reset status updated successfully");
} else {
Serial.printf("Factory reset status update failed with code: %d\n", httpCode);
}
} else {
Serial.printf("HTTP request failed: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
// Clear NVS
factoryResetDevice();
}
// TODO(@akdeb): Update this to use `false` as default
void getFactoryResetStatusFromNVS()
{
preferences.begin("is_reset", false);
factory_reset_status = preferences.getBool("is_reset", false);
preferences.end();
}
void setFactoryResetStatusInNVS(bool status)
{
preferences.begin("is_reset", false);
preferences.putBool("is_reset", status);
preferences.end();
factory_reset_status = status;
}
void factoryResetDevice() {
Serial.println("Factory reset device");
// Erase the NVS partition
esp_err_t err = nvs_flash_erase();
if (err != ESP_OK) {
Serial.printf("Error erasing NVS: %d\n", err);
return;
}
// Reinitialize NVS
err = nvs_flash_init();
if (err != ESP_OK) {
Serial.printf("Error initializing NVS: %d\n", err);
return;
}
}

View file

@ -0,0 +1,332 @@
#include "LEDHandler.h"
int brightness = 0;
int fadeAmount = 5;
static unsigned long lastToggle = 0;
static bool ledState = false;
void setLEDColor(uint8_t r, uint8_t g, uint8_t b)
{
analogWrite(RED_LED_PIN, r);
analogWrite(GREEN_LED_PIN, g);
analogWrite(BLUE_LED_PIN, b);
}
enum class StaticColor
{
RED,
GREEN,
BLUE,
YELLOW,
MAGENTA,
CYAN,
};
void setStaticColor(StaticColor color)
{
switch (color)
{
case StaticColor::RED:
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, HIGH);
break;
case StaticColor::GREEN:
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, HIGH);
break;
case StaticColor::BLUE:
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, LOW);
break;
case StaticColor::YELLOW:
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, HIGH);
break;
case StaticColor::MAGENTA:
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, LOW);
break;
case StaticColor::CYAN:
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, LOW);
break;
}
}
void loopCyanPinkYellow()
{
// Cyan (Green + Blue)
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, HIGH);
delay(500);
// Pink (Red + Blue)
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, HIGH);
delay(500);
// Yellow (Red + Green)
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, LOW);
delay(500);
}
void pulseWhite()
{
setLEDColor(brightness, brightness, brightness);
brightness += fadeAmount;
if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128
{
fadeAmount = -fadeAmount;
}
}
void pulseMagenta()
{
setLEDColor(brightness, 0, brightness);
brightness += fadeAmount;
if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128
{
fadeAmount = -fadeAmount;
}
}
void pulseYellow()
{
setLEDColor(brightness, brightness, 0);
brightness += fadeAmount;
if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128
{
fadeAmount = -fadeAmount;
}
}
void pulseBlue()
{
setLEDColor(0, 0, brightness);
brightness += fadeAmount;
if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128
{
fadeAmount = -fadeAmount;
}
}
void blinkWhite()
{
if (ledState)
{
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, HIGH);
}
else
{
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, LOW);
}
}
void blinkGreen()
{
digitalWrite(BLUE_LED_PIN, LOW);
digitalWrite(RED_LED_PIN, LOW);
if (ledState)
{
digitalWrite(GREEN_LED_PIN, HIGH);
}
else
{
digitalWrite(GREEN_LED_PIN, LOW);
}
}
void blinkYellow()
{
digitalWrite(BLUE_LED_PIN, LOW);
if (ledState)
{
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
}
else
{
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, LOW);
}
}
void turnOffLED()
{
digitalWrite(RED_LED_PIN, LOW);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(BLUE_LED_PIN, LOW);
}
void turnOnLED()
{
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(BLUE_LED_PIN, HIGH);
}
void setupRGBLED()
{
pinMode(RED_LED_PIN, OUTPUT);
pinMode(GREEN_LED_PIN, OUTPUT);
pinMode(BLUE_LED_PIN, OUTPUT);
turnOffLED(); // Turn off the LED initially
}
void blinkCyanPulse()
{
analogWrite(GREEN_LED_PIN, brightness);
analogWrite(BLUE_LED_PIN, brightness);
brightness += fadeAmount;
if (brightness <= 0 || brightness >= 255)
{
fadeAmount = -fadeAmount;
}
}
const uint8_t colorSequence[][3] = {
{0, 255, 255}, // Cyan (R=0, G=255, B=255)
{255, 0, 255}, // Pink (R=255, G=0, B=255)
{255, 255, 0}, // Yellow (R=255, G=255, B=0)
};
const int NUM_COLORS = sizeof(colorSequence) / sizeof(colorSequence[0]);
void loopCyanPinkYellowPulse(unsigned long currentTime)
{
// Duration of each color fade
const unsigned long transitionDuration = 1000; // 500 ms per fade
// colorIndex = which color in colorSequence were currently *starting* from
static int colorIndex = 0;
// We'll store the "start color" and "end color" for the current fade
static uint8_t startColor[3];
static uint8_t endColor[3];
// The timestamp at which the current fade *started*
static unsigned long transitionStartTime = 0;
// A flag so we can initialize the first fade
static bool initialized = false;
if (!initialized)
{
// On the very first call, set the starting color to colorSequence[0]
// and the endColor to the next color in the array
memcpy(startColor, colorSequence[colorIndex], 3);
int nextIndex = (colorIndex + 1) % NUM_COLORS;
memcpy(endColor, colorSequence[nextIndex], 3);
transitionStartTime = currentTime;
initialized = true;
}
// How long has this transition been running?
unsigned long elapsed = currentTime - transitionStartTime;
float t = (float)elapsed / (float)transitionDuration;
if (t > 1.0f)
{
t = 1.0f; // clamp
}
// Interpolate each channel: R, G, B
uint8_t r = startColor[0] + (endColor[0] - startColor[0]) * t;
uint8_t g = startColor[1] + (endColor[1] - startColor[1]) * t;
uint8_t b = startColor[2] + (endColor[2] - startColor[2]) * t;
// Write these values to your LED pins
analogWrite(RED_LED_PIN, r);
analogWrite(GREEN_LED_PIN, g);
analogWrite(BLUE_LED_PIN, b);
// Check if this transition has finished
if (elapsed >= transitionDuration)
{
// Move to next color in the sequence
colorIndex = (colorIndex + 1) % NUM_COLORS;
memcpy(startColor, endColor, 3); // old 'end' becomes new 'start'
int nextIndex = (colorIndex + 1) % NUM_COLORS;
memcpy(endColor, colorSequence[nextIndex], 3);
transitionStartTime = currentTime; // reset the clock for the next fade
}
}
void blinkBlue()
{
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(RED_LED_PIN, LOW);
if (ledState)
{
digitalWrite(BLUE_LED_PIN, HIGH);
}
else
{
digitalWrite(BLUE_LED_PIN, LOW);
}
}
void staticYellow()
{
digitalWrite(BLUE_LED_PIN, LOW);
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, HIGH);
}
void ledTask(void *parameter)
{
setupRGBLED();
unsigned long currentTime = 0;
while (1)
{
currentTime += 20; // Track time based on vTaskDelay
// Toggle LED state every 200ms for blinking functions
if (currentTime - lastToggle >= 200)
{
ledState = !ledState;
lastToggle = currentTime;
}
switch (deviceState)
{
case IDLE:
setStaticColor(StaticColor::GREEN);
break;
case PROCESSING:
setStaticColor(StaticColor::RED);
break;
case SPEAKING:
setStaticColor(StaticColor::BLUE);
break;
case LISTENING:
setStaticColor(StaticColor::YELLOW);
break;
case OTA:
setStaticColor(StaticColor::CYAN);
break;
default:
setStaticColor(StaticColor::GREEN); // LED on
break;
}
// Delay for smoother LED transitions
vTaskDelay(20 / portTICK_PERIOD_MS); // Approximate the delay from the original `pulsateLED()`
}
}

View file

@ -0,0 +1,14 @@
#ifndef LEDHANDLER_H
#define LEDHANDLER_H
#include "Config.h"
void setLEDColor(uint8_t r, uint8_t g, uint8_t b);
void turnOffLED();
void turnOnLED();
void setupRGBLED();
void turnOnBlueLED();
void turnOnRedLEDFlash();
void ledTask(void *parameter);
#endif

127
firmware-cpp/src/OTA.cpp Normal file
View file

@ -0,0 +1,127 @@
#include "OTA.h"
#include "HttpsOTAUpdate.h"
#include "esp_ota_ops.h"
HttpsOTAStatus_t otastatus;
// OTA firmware url
#ifdef TOUCH_MODE
const char *ota_firmware_url = "https://elato.s3.us-east-1.amazonaws.com/firmware-touch.bin";
#else
const char *ota_firmware_url = "https://elato.s3.us-east-1.amazonaws.com/firmware-button.bin";
#endif
const char *server_certificate = R"EOF(
-----BEGIN CERTIFICATE-----
<YOUR HOST CERTIFICATE HERE>
-----END CERTIFICATE-----
)EOF";
void markOTAUpdateComplete() {
HTTPClient http;
WiFiClientSecure client;
client.setCACert(Vercel_CA_cert); // Using the existing server certificate
// Construct the JSON payload
JsonDocument doc;
doc["authToken"] = authTokenGlobal;
String jsonString;
serializeJson(doc, jsonString);
// Initialize HTTPS connection with client
#ifdef DEV_MODE
http.begin("http://" + String(backend_server) + ":" + String(backend_port) + "/api/ota_update_handler");
#else
http.begin(client, "https://" + String(backend_server) + "/api/ota_update_handler");
#endif
http.addHeader("Content-Type", "application/json");
http.setTimeout(10000); // Add timeout for reliability
// Make the POST request
int httpCode = http.POST(jsonString);
// ... existing code ...
if (httpCode > 0) {
if (httpCode == HTTP_CODE_OK) {
Serial.println("OTA status updated successfully");
setOTAStatusInNVS(OTA_IDLE);
} else {
Serial.printf("OTA status update failed with code: %d\n", httpCode);
}
} else {
Serial.printf("HTTP request failed: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
}
void getOTAStatusFromNVS()
{
preferences.begin("ota", false);
otaState = (OtaStatus)preferences.getUInt("status", OTA_IDLE);
preferences.end();
}
void setOTAStatusInNVS(OtaStatus status)
{
preferences.begin("ota", false);
preferences.putUInt("status", status);
preferences.end();
otaState = status;
}
void loopOTA()
{
otastatus = HttpsOTA.status();
if (otastatus == HTTPS_OTA_SUCCESS)
{
Serial.println("Firmware written successfully. To reboot device, call API ESP.restart() or PUSH restart button on device");
setOTAStatusInNVS(OTA_COMPLETE);
ESP.restart();
}
else if (otastatus == HTTPS_OTA_FAIL)
{
Serial.println("Firmware Upgrade Fail");
setOTAStatusInNVS(OTA_IN_PROGRESS);
ESP.restart();
}
}
void HttpEvent(HttpEvent_t *event)
{
switch (event->event_id)
{
case HTTP_EVENT_ERROR:
// Serial.println("Http Event Error");
break;
case HTTP_EVENT_ON_CONNECTED:
// Serial.println("Http Event On Connected");
break;
case HTTP_EVENT_HEADER_SENT:
// Serial.println("Http Event Header Sent");
break;
case HTTP_EVENT_ON_HEADER:
// Serial.printf("Http Event On Header, key=%s, value=%s\n", event->header_key, event->header_value);
break;
case HTTP_EVENT_ON_DATA:
break;
case HTTP_EVENT_ON_FINISH:
// Serial.println("Http Event On Finish");
break;
case HTTP_EVENT_DISCONNECTED:
// Serial.println("Http Event Disconnected");
break;
}
}
void performOTAUpdate()
{
Serial.println("Starting OTA Update...");
HttpsOTA.onHttpEvent(HttpEvent);
HttpsOTA.begin(ota_firmware_url, server_certificate);
}

13
firmware-cpp/src/OTA.h Normal file
View file

@ -0,0 +1,13 @@
#ifndef OTA_H
#define OTA_H
#include "Config.h"
extern const char *server_certificate;
extern const char *ota_firmware_url;
void performOTAUpdate();
void markOTAUpdateComplete();
void loopOTA();
void setOTAStatusInNVS(OtaStatus status);
void getOTAStatusFromNVS();
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,140 @@
/**
* Wifi Manager
* (c) 2022-2025 Martin Verges
*
* Licensed under CC BY-NC-SA 4.0
* (Attribution-NonCommercial-ShareAlike 4.0 International)
**/
#ifndef WIFIMANAGER_h
#define WIFIMANAGER_h
#ifndef WIFIMANAGER_MAX_APS
#define WIFIMANAGER_MAX_APS 4 // Valid range is uint8_t
#endif
#ifndef ASYNC_WEBSERVER
#define ASYNC_WEBSERVER true
#endif
#include <Arduino.h>
#include <Preferences.h>
#if ASYNC_WEBSERVER == true
#include <ESPAsyncWebServer.h>
#else
#include <WebServer.h>
#endif
#include <Audio.h>
void wifiTask(void* param);
// Callback for when the device connects to Wifi
void connectCb();
class WIFIMANAGER {
protected:
#if ASYNC_WEBSERVER == true
AsyncWebServer * webServer; // The Webserver to register routes on
#else
WebServer * webServer; // The Webserver to register routes on
#endif
String apiPrefix = "/api/wifi"; // Prefix for all IP endpionts
String uiPrefix = "/wifi"; // Prefix for all UI endpionts
Preferences preferences; // Used to store AP credentials to NVS
char * NVS; // Name used for NVS preferences
struct apCredentials_t {
String apName; // Name of the AP SSID
String apPass; // Password if required to the AP
};
apCredentials_t apList[WIFIMANAGER_MAX_APS]; // Stored AP list
uint8_t configuredSSIDs = 0; // Number of stored SSIDs in the NVS
bool softApRunning = false; // Due to lack of functions, we have to remember if the AP is already running...
bool createFallbackAP = true; // Create an AP for configuration if no other connection is available
uint64_t lastWifiCheckMillis = 0; // Time of last Wifi health check
uint32_t intervalWifiCheckMillis = 10000; // Interval of the Wifi health checks
uint64_t startApTimeMillis = 0; // Time when the AP was started
uint32_t timeoutApMillis = 120000; // Timeout of an AP when no client is connected, if timeout reached rescan, tryconnect or createAP
String softApName; // Name of the soft AP if created, default to ESP_XXXXXXXX if empty
String softApPass; // Password for the soft AP, default to no password (empty)
// Wipe the apList credentials
void clearApList();
// Get id of the first non empty entry
uint8_t getApEntry();
// Print a log message to Serial, can be overwritten
virtual void logMessage(String msg);
public:
// We let the loop run as as Task
TaskHandle_t WifiCheckTask;
WIFIMANAGER(const char * ns = "wifimanager");
virtual ~WIFIMANAGER();
// If no known Wifi can't be found, create an AP but retry regulary
void fallbackToSoftAp(bool state = true);
// Get the current fallback state
bool getFallbackState();
// Call to run the Task in the background
void startBackgroundTask(String apName = "", String apPass = "");
// Attach a webserver and register api routes
#if ASYNC_WEBSERVER == true
void attachWebServer(AsyncWebServer * srv);
#else
void attachWebServer(WebServer * srv);
#endif
// Attach an UI
void attachUI();
// Add another AP to the list of known WIFIs
bool addWifi(String apName, String apPass, bool updateNVS = true);
// Delete Wifi from apList by ID
bool delWifi(uint8_t apId);
// Delete Wifi from apList by Name
bool delWifi(String apName);
// Try each known SSID and connect until none is left or one is connected.
bool tryConnect();
// Check if a SSID is stored in the config
bool configAvailable();
// Preconfigure the SoftAP
void configueSoftAp(String apName = "", String apPass = "");
// Start a SoftAP, called if no wifi can be connected
bool runSoftAP(String apName = "", String apPass = "");
// Disconnect/Stop SoftAP Mode
void stopSoftAP();
// Disconnect/Stop STA Mode
void stopClient();
// Disconnect/Stop SoftAP and STA Mode. Optionally end the task loop as well.
void stopWifi(bool killTask = false);
// Run in the loop to maintain state
void loop();
// Write AP Settings into persistent storage. Called on each addAP;
bool writeToNVS();
// Load AP Settings from NVS it known apList
bool loadFromNVS();
};
#endif

265
firmware-cpp/src/main.cpp Normal file
View file

@ -0,0 +1,265 @@
#include "OTA.h"
#include <Arduino.h>
#include <driver/rtc_io.h>
#include "LEDHandler.h"
#include "Config.h"
#include "SPIFFS.h"
#include "WifiManager.h"
#include <driver/touch_sensor.h>
#include "Button.h"
#include "FactoryReset.h"
// #define WEBSOCKETS_DEBUG_LEVEL WEBSOCKETS_LEVEL_ALL
#define TOUCH_THRESHOLD 28000
#define LONG_PRESS_MS 1000
#define REQUIRED_RELEASE_CHECKS 100 // how many consecutive times we need "below threshold" to confirm release
#define TOUCH_DEBOUNCE_DELAY 1000 // milliseconds
AsyncWebServer webServer(80);
WIFIMANAGER WifiManager;
esp_err_t getErr = ESP_OK;
void enterSleep()
{
Serial.println("Going to sleep...");
// First, change device state to prevent any new data processing
deviceState = IDLE;
// Stop audio tasks first
i2s_stop(I2S_PORT_IN);
i2s_stop(I2S_PORT_OUT);
// Clear any remaining audio in buffer
audioBuffer.reset();
// Properly disconnect WebSocket and wait for it to complete
if (webSocket.isConnected()) {
webSocket.disconnect();
// Give some time for the disconnect to process
delay(100);
}
// Stop all tasks that might be using I2S or other peripherals
i2s_driver_uninstall(I2S_PORT_IN);
i2s_driver_uninstall(I2S_PORT_OUT);
// Flush any remaining serial output
Serial.flush();
#ifdef TOUCH_MODE
touch_pad_intr_disable(TOUCH_PAD_INTR_MASK_ALL);
while (touchRead(TOUCH_PAD_NUM2) > TOUCH_THRESHOLD) {
delay(50);
}
delay(500);
touchSleepWakeUpEnable(TOUCH_PAD_NUM2, TOUCH_THRESHOLD);
#endif
esp_deep_sleep_start();
}
void printOutESP32Error(esp_err_t err)
{
switch (err)
{
case ESP_OK:
Serial.println("ESP_OK no errors");
break;
case ESP_ERR_INVALID_ARG:
Serial.println("ESP_ERR_INVALID_ARG if the selected GPIO is not an RTC GPIO, or the mode is invalid");
break;
case ESP_ERR_INVALID_STATE:
Serial.println("ESP_ERR_INVALID_STATE if wakeup triggers conflict or wireless not stopped");
break;
default:
Serial.printf("Unknown error code: %d\n", err);
break;
}
}
static void onButtonLongPressUpEventCb(void *button_handle, void *usr_data)
{
Serial.println("Button long press end");
delay(10);
enterSleep();
}
static void onButtonDoubleClickCb(void *button_handle, void *usr_data)
{
Serial.println("Button double click");
delay(10);
enterSleep();
}
void getAuthTokenFromNVS()
{
preferences.begin("auth", false);
authTokenGlobal = preferences.getString("auth_token", "");
preferences.end();
}
void setupWiFi()
{
WifiManager.startBackgroundTask("ELATO-DEVICE"); // Run the background task to take care of our Wifi
WifiManager.fallbackToSoftAp(true); // Run a SoftAP if no known AP can be reached
WifiManager.attachWebServer(&webServer); // Attach our API to the Webserver
WifiManager.attachUI(); // Attach the UI to the Webserver
// Run the Webserver and add your webpages to it
webServer.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->redirect("/wifi");
});
webServer.onNotFound([&](AsyncWebServerRequest *request) {
request->send(404, "text/plain", "Not found");
});
webServer.begin();
}
void touchTask(void* parameter) {
touch_pad_init();
touch_pad_config(TOUCH_PAD_NUM2);
bool touched = false;
unsigned long pressStartTime = 0;
unsigned long lastTouchTime = 0;
while (1) {
// Read the touch sensor
uint32_t touchValue = touchRead(TOUCH_PAD_NUM2);
bool isTouched = (touchValue > TOUCH_THRESHOLD);
unsigned long currentTime = millis();
// Debounced touch detection
if (isTouched && !touched && (currentTime - lastTouchTime > TOUCH_DEBOUNCE_DELAY)) {
touched = true;
lastTouchTime = currentTime;
enterSleep();
// if (!webSocket.isConnected()) {
// enterSleep();
// } else if (deviceState == SPEAKING) {
// // First, set the flag to prevent further audio processing
// // deviceState = PROCESSING;
// scheduleListeningRestart = true;
// scheduledTime = millis() + 100; // Shorter delay
// unsigned long audio_end_ms = getSpeakingDuration();
// // Use ArduinoJson to create the message
// JsonDocument doc;
// doc["type"] = "instruction";
// doc["msg"] = "INTERRUPT";
// doc["audio_end_ms"] = audio_end_ms;
// String jsonString;
// serializeJson(doc, jsonString);
// // Take mutex to ensure clean WebSocket access
// if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(10)) == pdTRUE) {
// webSocket.sendTXT(jsonString);
// xSemaphoreGive(wsMutex);
// }
// }
}
// Release detection
if (!isTouched && touched) {
touched = false;
}
vTaskDelay(20); // Reduced from 50ms to 20ms for better responsiveness
}
// (This point is never reached.)
vTaskDelete(NULL);
}
void setupDeviceMetadata() {
// factoryResetDevice();
deviceState = IDLE;
getAuthTokenFromNVS();
getOTAStatusFromNVS();
if (otaState == OTA_IN_PROGRESS || otaState == OTA_COMPLETE) {
deviceState = OTA;
}
if (factory_reset_status) {
deviceState = FACTORY_RESET;
}
}
void setup()
{
Serial.begin(115200);
delay(500);
// SETUP
setupDeviceMetadata();
wsMutex = xSemaphoreCreateMutex();
// INTERRUPT
#ifdef TOUCH_MODE
xTaskCreate(touchTask, "Touch Task", 4096, NULL, configMAX_PRIORITIES-2, NULL);
#else
getErr = esp_sleep_enable_ext0_wakeup(BUTTON_PIN, LOW);
printOutESP32Error(getErr);
Button *btn = new Button(BUTTON_PIN, false);
btn->attachLongPressUpEventCb(&onButtonLongPressUpEventCb, NULL);
btn->attachDoubleClickEventCb(&onButtonDoubleClickCb, NULL);
btn->detachSingleClickEvent();
#endif
// Pin audio tasks to Core 1 (application core)
xTaskCreatePinnedToCore(
ledTask, // Function
"LED Task", // Name
4096, // Stack size
NULL, // Parameters
5, // Priority
NULL, // Handle
1 // Core 1 (application core)
);
xTaskCreatePinnedToCore(
audioStreamTask, // Function
"Speaker Task", // Name
4096, // Stack size
NULL, // Parameters
3, // Priority
NULL, // Handle
1 // Core 1 (application core)
);
xTaskCreatePinnedToCore(
micTask, // Function
"Microphone Task", // Name
4096, // Stack size
NULL, // Parameters
4, // Priority
NULL, // Handle
1 // Core 1 (application core)
);
// Pin network task to Core 0 (protocol core)
xTaskCreatePinnedToCore(
networkTask, // Function
"Websocket Task", // Name
8192, // Stack size
NULL, // Parameters
configMAX_PRIORITIES-1, // Highest priority
&networkTaskHandle,// Handle
0 // Core 0 (protocol core)
);
// WIFI
setupWiFi();
}
void loop(){
if (otaState == OTA_IN_PROGRESS)
{
loopOTA();
}
}

11
firmware-cpp/test/README Normal file
View file

@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

View file

@ -0,0 +1,528 @@
#include <AsyncTCP.h> //https://github.com/me-no-dev/AsyncTCP using the latest dev version from @me-no-dev
#include <DNSServer.h>
#include <ESPAsyncWebServer.h> //https://github.com/me-no-dev/ESPAsyncWebServer using the latest dev version from @me-no-dev
#include <esp_wifi.h> //Used for mpdu_rx_disable android workaround
#include <driver/i2s.h>
#include <SPIFFS.h>
#include "FactoryReset.h"
#define uS_TO_S_FACTOR 1000000ULL
#define MAX_CLIENTS 4 // ESP32 supports up to 10 but I have not tested it yet
#define WIFI_CHANNEL 6 // 2.4ghz channel 6 https://en.wikipedia.org/wiki/List_of_WLAN_channels#2.4_GHz_(802.11b/g/n/ax)
const IPAddress localIP(4, 3, 2, 1); // the IP address the web server, Samsung requires the IP to be in public space
const IPAddress gatewayIP(4, 3, 2, 1); // IP address of the network should be the same as the local IP for captive portals
const IPAddress subnetMask(255, 255, 255, 0); // no need to change: https://avinetworks.com/glossary/subnet-mask/
const String localIPURL = "http://4.3.2.1"; // a string version of the local IP with http, used for redirecting clients to your webpage
DNSServer dnsServer;
AsyncWebServer server(80);
int AP_status = 0;
// We'll store our MP3 in SPIFFS at /startup.mp3
static const char* MP3_FILE = "/startup.mp3";
// Create the AudioTools pipeline components
AudioSourceSPIFFS source; // Will read from SPIFFS
MP3DecoderHelix decoder; // MP3 decoder
I2SStream i2s; // Send output to I2S
AudioPlayer player(source, i2s, decoder);
bool isDeviceRegistered(AsyncWebServerRequest *request) {
HTTPClient http;
WiFiClientSecure client;
client.setCACert(Vercel_CA_cert);
#ifdef DEV_MODE
http.begin("http://" + String(backend_server) + ":" + String(backend_port) +
"/api/generate_auth_token?macAddress=" + WiFi.macAddress());
#else
http.begin(client, "https://" + String(backend_server) +
"/api/generate_auth_token?macAddress=" + WiFi.macAddress());
#endif
http.setTimeout(10000);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("JSON parsing failed: ");
Serial.println(error.c_str());
http.end();
return false;
}
String authToken = doc["token"];
if (!authToken.isEmpty()) {
// Store the auth token in NVS
preferences.begin("auth", false);
preferences.putString("auth_token", authToken);
preferences.end();
authTokenGlobal = String(authToken);
http.end();
return true;
}
}
// If we get here, either the request failed or no token was found
http.end();
return false;
}
String urlEncode(const String &msg)
{
String encodedMsg = "";
char c;
char code0;
char code1;
for (int i = 0; i < msg.length(); i++)
{
c = msg.charAt(i);
if (c == ' ')
{
encodedMsg += '+';
}
else if (isalnum(c))
{
encodedMsg += c;
}
else
{
code1 = (c & 0xf) + '0';
if ((c & 0xf) > 9)
{
code1 = (c & 0xf) - 10 + 'A';
}
c = (c >> 4) & 0xf;
code0 = c + '0';
if (c > 9)
{
code0 = c - 10 + 'A';
}
encodedMsg += '%';
encodedMsg += code0;
encodedMsg += code1;
}
}
return encodedMsg;
}
// plays when new wifi network connects
void playStartupSound() {
// Check if startup.mp3 actually exists
// 2) Mount SPIFFS
// Check if startup.mp3 actually exists
if(!SPIFFS.begin(true)) {
Serial.println("SPIFFS mount failed!");
while(true) { delay(10); }
}
File f = SPIFFS.open(MP3_FILE, "r");
if(!f){
Serial.println("startup.mp3 missing in SPIFFS!");
while(true) { delay(10); }
} else {
Serial.printf("startup.mp3 found, size=%d bytes\n", f.size());
f.close();
}
// Configure I2S in TX mode
auto cfg = i2s.defaultConfig(TX_MODE);
cfg.pin_bck = 6;
cfg.pin_ws = 5;
cfg.pin_data = 7;
cfg.channels = 1;
cfg.sample_rate = 44100;
// cfg.port_no = I2S_PORT_OUT;
if(!i2s.begin(cfg)) {
Serial.println("I2S begin failed!");
while(true) { delay(10); }
}
// Initialize the player
player.setVolume(1.0f);
if(!player.begin()) {
Serial.println("Player begin() failed!");
while(true) { delay(10); }
}
// **THIS IS THE MISSING LOOP!**
Serial.println("Playing startup sound...");
while(true) {
size_t copied = player.copy();
if (copied == 0) {
Serial.println("Playback finished.");
delay(500);
break;
}
delay(1); // Give CPU time for other tasks
}
Serial.println("Startup sound played, set up websocket");
}
int wifiConnect()
{
WiFi.mode(WIFI_MODE_STA); // Add this to ensure we're in station mode
// Begin with no arguments to load last stored credentials from NVS
WiFi.begin();
// Wait until connected
unsigned long startMillis = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startMillis < 10000)
{
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
{
Serial.printf("Quick reconnect: Connected to %s\n", WiFi.SSID().c_str());
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
WiFi.setSleep(false); // Disable power saving
return 1;
}
preferences.begin("wifi_store");
int numNetworks = preferences.getInt("numNetworks", 0);
if (numNetworks == 0)
{
preferences.end();
return 0;
}
// Try each stored network
for (int i = 0; i < numNetworks; ++i)
{
String ssid = preferences.getString(("ssid" + String(i)).c_str(), "");
String password = preferences.getString(("password" + String(i)).c_str(), "");
if (ssid.length() > 0 && password.length() > 0)
{
Serial.printf("Attempting connection to %s\n", ssid.c_str());
WiFi.begin(ssid.c_str(), password.c_str());
// More reasonable timeout: 15 seconds (10 * 500ms = 10s)
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20)
{
delay(500); // Longer delay between attempts
Serial.print(".");
attempts++;
}
Serial.println();
if (WiFi.status() == WL_CONNECTED)
{
Serial.printf("Connected to %s\n", ssid.c_str());
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
WiFi.setSleep(false); // Disable power saving
preferences.end();
return 1;
}
Serial.printf("Failed to connect to %s\n", ssid.c_str());
}
}
preferences.end();
return 0;
}
void handleComplete(AsyncWebServerRequest *request)
{
bool isRegistered = !authTokenGlobal.isEmpty();
if (!isRegistered && request->hasParam("registration_attempted")) {
isRegistered = isDeviceRegistered(request);
}
String content;
if (isRegistered) {
content = "<h1>Setup Complete</h1>"
"<p>Your device is ready to use.</p>"
"<p>The setup network will now close.</p>";
} else {
content = "<h1>One Last Step</h1>"
"<p>Please register your personal device code on our website to start using your device.</p>"
"<form action='/complete' method='GET'>"
"<input type='hidden' name='registration_attempted' value='true'>"
"<input type='submit' value='Register Device' class='button'>"
"</form>"
"<p class='note'>Click the button above after registering your device on the website.</p>";
}
request->send(200, "text/html", "<!DOCTYPE html>"
"<html lang='en'>"
"<head>"
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
"<style>"
"body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #fff8e1; }"
".container { padding: 20px; max-width: 600px; margin: auto; }"
".header { background: #facc15; color: black; padding: 15px 0; text-align: center; font-weight: bold; border-radius: 8px 8px 0 0; }"
".content { background: #ffffff; border-radius: 0 0 8px 8px; padding: 25px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-align: center; }"
"h1 { color: #333; margin-bottom: 20px; }"
"p { color: #666; margin: 10px 0; }"
".button { background: #facc15; color: black; padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin: 20px 0; }"
".button:hover { background: #fdd835; }"
".note { font-size: 0.9em; color: #888; margin-top: 20px; }"
"</style>"
"</head>"
"<body>"
"<div class='container'>"
"<div class='header'>Elato AI - Setup Complete</div>"
"<div class='content'>" +
content +
"</div>"
"</div>"
"</body>"
"</html>");
}
void handleRoot(AsyncWebServerRequest *request)
{
String notConnected = ""; // Initialize with empty string
if (request->hasParam("not_connected"))
{
notConnected = request->getParam("not_connected")->value();
}
if (WiFi.status() != WL_CONNECTED)
{
String html = "<!DOCTYPE html>"
"<html lang='en'>"
"<head>"
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
"<style>"
"body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #fff8e1; }" // Light yellow background
".container { padding: 20px; max-width: 600px; margin: auto; }"
".header { background: #facc15; color: black; padding: 15px 0; text-align: center; font-weight: bold; border-radius: 8px 8px 0 0; }" // Yellow header with black text and rounded top corners
".content { background: #ffffff; border-radius: 0 0 8px 8px; padding: 25px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); }" // Rounded bottom corners
".error { margin: 20px 0; padding: 10px; background: #ffebee; border-radius: 5px; color: #c62828; }"
"input[type='text'], input[type='password'] { width: calc(100% - 22px); padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 5px; box-sizing: border-box; }"
"input[type='submit'] { background: #facc15; color: black; padding: 10px; border: none; border-radius: 5px; cursor: pointer; width: 100%; font-weight: bold; }" // Yellow button with black text
"input[type='submit']:hover { background: #fdd835; }" // Slightly darker yellow on hover
"</style>"
"</head>"
"<body>"
"<div class='container'>"
"<div class='header'>Elato AI</div>"
"<div class='content'>"
"<h1>Connect to Wi-Fi</h1>";
if (strcmp(notConnected.c_str(), "true") == 0)
{
html += "<p class='error'>The network connection failed, please try again.</p>";
}
html += "<form action='/wifi' method='POST'>"
"SSID: <input type='text' name='ssid' required><br>"
"Password: <input type='password' name='password' required><br>"
"<input type='submit' value='Connect'>"
"</form>"
"</div>"
"</div>"
"</body>"
"</html>";
request->send(200, "text/html", html);
}
else
{
request->redirect("/complete");
}
}
void handleWifiSave(AsyncWebServerRequest *request)
{
Serial.println("Start Save!");
String ssid = request->arg("ssid");
String password = request->arg("password");
// Attempt to connect to the provided Wi-Fi credentials
Serial.print("Connecting to ");
Serial.println(ssid);
Serial.println(password);
WiFi.begin(ssid.c_str(), password.c_str());
// Wait for connection
int attempts = 30; // 30 * 100ms = 3 seconds
while (attempts-- && WiFi.status() != WL_CONNECTED)
{
delay(100);
}
if (WiFi.status() == WL_CONNECTED)
{
Serial.println("Successfully connected to WiFi!");
// Now that we're connected, save/update the credentials
preferences.begin("wifi_store", false);
int numNetworks = preferences.getInt("numNetworks", 0);
// Check if this SSID already exists
bool updated = false;
for (int i = 0; i < numNetworks; ++i)
{
String storedSsid = preferences.getString(("ssid" + String(i)).c_str(), "");
if (storedSsid == ssid)
{
preferences.putString(("password" + String(i)).c_str(), password);
Serial.println("Success Update!");
updated = true;
break;
}
}
// If it's a new network, add it
if (!updated)
{
preferences.putString(("ssid" + String(numNetworks)).c_str(), ssid);
preferences.putString(("password" + String(numNetworks)).c_str(), password);
preferences.putInt("numNetworks", numNetworks + 1);
Serial.println("Success Save!");
}
preferences.end();
// Check if the device is registered
request->redirect("/complete");
}
else
{
Serial.println("Failed to connect to WiFi");
request->redirect("/?not_connected=true");
}
}
void setUpDNSServer(DNSServer &dnsServer, const IPAddress &localIP)
{
// Define the DNS interval in milliseconds between processing DNS requests
#define DNS_INTERVAL 30
// Set the TTL for DNS response and start the DNS server
dnsServer.setTTL(3600);
dnsServer.start(53, "*", localIP);
}
void startSoftAccessPoint(const char *ssid, const char *password, const IPAddress &localIP, const IPAddress &gatewayIP)
{
// Define the maximum number of clients that can connect to the server
#define MAX_CLIENTS 4
// Define the WiFi channel to be used (channel 6 in this case)
#define WIFI_CHANNEL 6
// Set the WiFi mode to access point and station
WiFi.mode(WIFI_MODE_AP);
// Define the subnet mask for the WiFi network
const IPAddress subnetMask(255, 255, 255, 0);
// Configure the soft access point with a specific IP and subnet mask
WiFi.softAPConfig(localIP, gatewayIP, subnetMask);
// Start the soft access point with the given ssid, password, channel, max number of clients
WiFi.softAP(ssid, password, WIFI_CHANNEL, 0, MAX_CLIENTS);
// Disable AMPDU RX on the ESP32 WiFi to fix a bug on Android
esp_wifi_stop();
esp_wifi_deinit();
wifi_init_config_t my_config = WIFI_INIT_CONFIG_DEFAULT();
my_config.ampdu_rx_enable = false;
esp_wifi_init(&my_config);
esp_wifi_start();
vTaskDelay(100 / portTICK_PERIOD_MS); // Add a small delay
}
void setUpWebserver(AsyncWebServer &server, const IPAddress &localIP)
{
//======================== Webserver ========================
// WARNING IOS (and maybe macos) WILL NOT POP UP IF IT CONTAINS THE WORD "Success" https://www.esp8266.com/viewtopic.php?f=34&t=4398
// SAFARI (IOS) IS STUPID, G-ZIPPED FILES CAN'T END IN .GZ https://github.com/homieiot/homie-esp8266/issues/476 this is fixed by the webserver serve static function.
// SAFARI (IOS) there is a 128KB limit to the size of the HTML. The HTML can reference external resources/images that bring the total over 128KB
// SAFARI (IOS) popup browser has some severe limitations (javascript disabled, cookies disabled)
// Required
server.on("/connecttest.txt", [](AsyncWebServerRequest *request)
{ request->redirect("http://logout.net"); }); // windows 11 captive portal workaround
server.on("/wpad.dat", [](AsyncWebServerRequest *request)
{ request->send(404); }); // Honestly don't understand what this is but a 404 stops win 10 keep calling this repeatedly and panicking the esp32 :)
// Background responses: Probably not all are Required, but some are. Others might speed things up?
// A Tier (commonly used by modern systems)
server.on("/generate_204", [](AsyncWebServerRequest *request)
{ request->redirect(localIPURL); }); // android captive portal redirect
server.on("/redirect", [](AsyncWebServerRequest *request)
{ request->redirect(localIPURL); }); // microsoft redirect
server.on("/hotspot-detect.html", [](AsyncWebServerRequest *request)
{ request->redirect(localIPURL); }); // apple call home
server.on("/canonical.html", [](AsyncWebServerRequest *request)
{ request->redirect(localIPURL); }); // firefox captive portal call home
server.on("/success.txt", [](AsyncWebServerRequest *request)
{ request->send(200); }); // firefox captive portal call home
server.on("/ncsi.txt", [](AsyncWebServerRequest *request)
{ request->redirect(localIPURL); }); // windows call home
// B Tier (uncommon)
// server.on("/chrome-variations/seed",[](AsyncWebServerRequest *request){request->send(200);}); //chrome captive portal call home
// server.on("/service/update2/json",[](AsyncWebServerRequest *request){request->send(200);}); //firefox?
// server.on("/chat",[](AsyncWebServerRequest *request){request->send(404);}); //No stop asking Whatsapp, there is no internet connection
// server.on("/startpage",[](AsyncWebServerRequest *request){request->redirect(localIPURL);});
// return 404 to webpage icon
server.on("/favicon.ico", [](AsyncWebServerRequest *request)
{ request->send(404); }); // webpage icon
server.on("/", HTTP_GET, handleRoot);
server.on("/wifi", HTTP_POST, handleWifiSave);
server.on("/complete", HTTP_GET, handleComplete);
// the catch all
server.onNotFound([](AsyncWebServerRequest *request)
{
request->redirect(localIPURL);
Serial.print("onnotfound ");
Serial.print(request->host()); // This gives some insight into whatever was being requested on the serial monitor
Serial.print(" ");
Serial.print(request->url());
Serial.print(" sent redirect to " + localIPURL + "\n"); });
}
String getAPSSIDName()
{
// Get the MAC address of the device
String macAddress = WiFi.macAddress();
macAddress.replace(":", "");
String lastFourMac = macAddress.substring(macAddress.length() - 4); // Get the last 4 characters
String ssid = "Elato-" + lastFourMac;
return ssid;
}
void openAP()
{
deviceState = SETUP;
AP_status = 1;
startSoftAccessPoint(getAPSSIDName().c_str(), NULL, localIP, gatewayIP);
setUpDNSServer(dnsServer, localIP);
setUpWebserver(server, localIP);
server.begin();
}
void closeAP()
{
deviceState = IDLE;
dnsServer.stop();
server.end();
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_MODE_STA);
AP_status = 0;
Serial.println("Closed Access Point and DNS server");
}

View file

@ -0,0 +1,95 @@
#include "Arduino.h"
#include "WiFi.h"
#include "Audio.h"
#include "SD.h"
#include "FS.h"
// Digital I/O used
#define I2S_DOUT 7
#define I2S_BCLK 6
#define I2S_LRC 5
#define I2S_SD_OUT 10
Audio audio;
String ssid = "S_HOUSE_RESIDENTS_NW";
String password = "Somerset_Residents!";
void setup() {
Serial.begin(115200);
WiFi.disconnect();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), password.c_str());
while (WiFi.status() != WL_CONNECTED) delay(1500);
// setup I2S shutdown pin
pinMode(I2S_SD_OUT, OUTPUT);
digitalWrite(I2S_SD_OUT, HIGH);
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
audio.setVolume(21); // default 0...21
// or alternative
// audio.setVolumeSteps(64); // max 255
// audio.setVolume(63);
//
// *** radio streams ***
audio.connecttohost("https://s6.yesstreaming.net:17082/stream"); // aac
// audio.connecttohost("http://mcrscast.mcr.iol.pt/cidadefm"); // mp3
// audio.connecttohost("http://www.wdr.de/wdrlive/media/einslive.m3u"); // m3u
// audio.connecttohost("https://stream.srg-ssr.ch/rsp/aacp_48.asx"); // asx
// audio.connecttohost("http://tuner.classical102.com/listen.pls"); // pls
// audio.connecttohost("http://stream.radioparadise.com/flac"); // flac
// audio.connecttohost("http://stream.sing-sing-bis.org:8000/singsingFlac"); // flac (ogg)
// audio.connecttohost("http://s1.knixx.fm:5347/dein_webradio_vbr.opus"); // opus (ogg)
// audio.connecttohost("http://stream2.dancewave.online:8080/dance.ogg"); // vorbis (ogg)
// audio.connecttohost("http://26373.live.streamtheworld.com:3690/XHQQ_FMAAC/HLSTS/playlist.m3u8"); // HLS
// audio.connecttohost("http://eldoradolive02.akamaized.net/hls/live/2043453/eldorado/master.m3u8"); // HLS (ts)
// *** web files ***
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Pink-Panther.wav"); // wav
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Santiano-Wellerman.flac"); // flac
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Olsen-Banden.mp3"); // mp3
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Miss-Marple.m4a"); // m4a (aac)
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Collide.ogg"); // vorbis
// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/sample.opus"); // opus
// *** local files ***
// audio.connecttoFS(SD, "/test.wav"); // SD
// audio.connecttoFS(SD_MMC, "/test.wav"); // SD_MMC
// audio.connecttoFS(SPIFFS, "/test.wav"); // SPIFFS
// audio.connecttospeech("Wenn die Hunde schlafen, kann der Wolf gut Schafe stehlen.", "de"); // Google TTS
}
void loop(){
audio.loop();
vTaskDelay(1);
}
// optional
void audio_info(const char *info){
Serial.print("info "); Serial.println(info);
}
void audio_id3data(const char *info){ //id3 metadata
Serial.print("id3data ");Serial.println(info);
}
void audio_eof_mp3(const char *info){ //end of file
Serial.print("eof_mp3 ");Serial.println(info);
}
void audio_showstation(const char *info){
Serial.print("station ");Serial.println(info);
}
void audio_showstreamtitle(const char *info){
Serial.print("streamtitle ");Serial.println(info);
}
void audio_bitrate(const char *info){
Serial.print("bitrate ");Serial.println(info);
}
void audio_commercial(const char *info){ //duration in sec
Serial.print("commercial ");Serial.println(info);
}
void audio_icyurl(const char *info){ //homepage
Serial.print("icyurl ");Serial.println(info);
}
void audio_lasthost(const char *info){ //stream URL played
Serial.print("lasthost ");Serial.println(info);
}
void audio_eof_speech(const char *info){
Serial.print("eof_speech ");Serial.println(info);
}

View file

@ -0,0 +1,56 @@
#include "Arduino.h"
#include <WiFi.h> //Wifi library
#define EAP_IDENTITY "username@city.ac.uk" // if connecting from another corporation, use identity@organization.domain in Eduroam
#define EAP_USERNAME "username@city.ac.uk" // oftentimes just a repeat of the identity
#define EAP_PASSWORD "your password" // your Eduroam password
const char *ssid = "eduroam"; // Eduroam SSID
const char *host = "arduino.php5.sk"; // external server domain for HTTP connection after authentication
int counter = 0;
// NOTE: For some systems, various certification keys are required to connect to the wifi system.
// Usually you are provided these by the IT department of your organization when certs are required
// and you can't connect with just an identity and password.
// Most eduroam setups we have seen do not require this level of authentication, but you should contact
// your IT department to verify.
// You should uncomment these and populate with the contents of the files if this is required for your scenario (See Example 2 and Example 3 below).
// const char *ca_pem = "insert your CA cert from your .pem file here";
// const char *client_cert = "insert your client cert from your .crt file here";
// const char *client_key = "insert your client key from your .key file here";
void setup()
{
Serial.begin(115200);
delay(10);
Serial.println();
Serial.print("Connecting to network: ");
Serial.println(ssid);
WiFi.disconnect(true); // disconnect form wifi to set new wifi connection
WiFi.mode(WIFI_STA); // init wifi mode
// Example1 (most common): a cert-file-free eduroam with PEAP (or TTLS)
WiFi.begin(ssid, WPA2_AUTH_PEAP, EAP_IDENTITY, EAP_USERNAME, EAP_PASSWORD);
// Example 2: a cert-file WPA2 Enterprise with PEAP
// WiFi.begin(ssid, WPA2_AUTH_PEAP, EAP_IDENTITY, EAP_USERNAME, EAP_PASSWORD, ca_pem, client_cert, client_key);
// Example 3: TLS with cert-files and no password
// WiFi.begin(ssid, WPA2_AUTH_TLS, EAP_IDENTITY, NULL, NULL, ca_pem, client_cert, client_key);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
counter++;
if (counter >= 60)
{ // after 30 seconds timeout - reset board
ESP.restart();
}
}
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address set: ");
Serial.println(WiFi.localIP()); // print LAN IP
}
void loop()
{
}

View file

@ -0,0 +1,121 @@
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h> // NVS for storage
// Wi-Fi credentials
const char *ssid = "<wifi>";
const char *password = "<pw>";
// Server endpoint for checking registration
const char *serverEndpoint = "<server_endpoint>";
// Preferences for NVS storage
Preferences preferences;
// Function to connect to WiFi
void connectToWiFi()
{
Serial.println("Connecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(1000);
Serial.println("Connecting...");
}
Serial.println("WiFi connected.");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
}
// Function to get auth token from the server
String getAuthTokenFromServer(String macAddress)
{
HTTPClient http;
http.begin(serverEndpoint); // Specify the server URL
// Set the POST request content type
http.addHeader("Content-Type", "application/json");
// JSON body with mac_address
String jsonBody = "{\"mac_address\": \"" + macAddress + "\"}";
// Send POST request
int httpResponseCode = http.POST(jsonBody);
// Check the response code
if (httpResponseCode > 0)
{
String response = http.getString();
Serial.println("Server response: " + response);
// Assuming response contains the auth token
return response;
}
else
{
Serial.print("Error on sending POST: ");
Serial.println(httpResponseCode);
return "";
}
// Close the HTTP connection
http.end();
}
// Function to store auth token in NVS
void storeAuthToken(String authToken)
{
preferences.begin("auth", false);
preferences.putString("auth_token", authToken);
preferences.end();
Serial.println("Auth token stored in NVS.");
}
// Function to check registration status
void checkRegistration()
{
String macAddress = WiFi.macAddress(); // Get the ESP32's MAC address
Serial.println("Checking registration for MAC Address: " + macAddress);
// Get the auth token from the server
String authToken = getAuthTokenFromServer(macAddress);
if (authToken != "")
{
storeAuthToken(authToken); // Store the auth token in NVS
}
else
{
Serial.println("Device not registered yet.");
}
}
void setup()
{
// Initialize serial for debugging
Serial.begin(115200);
// Connect to Wi-Fi
connectToWiFi();
// Initialize NVS preferences
preferences.begin("auth", true);
String storedToken = preferences.getString("auth_token", "");
preferences.end();
if (storedToken != "")
{
Serial.println("Auth token found in NVS: " + storedToken);
}
else
{
checkRegistration(); // Check registration if no token is found
}
}
void loop()
{
// Main loop logic (e.g., periodic updates, authentication, etc.)
}

View file

@ -0,0 +1,59 @@
#include <Arduino.h>
// Define pin assignments
const int ledPin = 0; // GPIO 2 connected to LED
const int buttonPin = D5; // GPIO 15 connected to button
// Variables for button state tracking
bool ledState = LOW;
bool buttonState = HIGH;
bool lastButtonState = HIGH;
unsigned long lastDebounceTime = 0;
unsigned long debounceDelay = 50; // Adjust debounce delay as needed
void setup()
{
// Initialize serial communication (optional)
Serial.begin(115200);
// Set the LED pin as output
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, ledState);
// Set the button pin as input with internal pull-up resistor
pinMode(buttonPin, INPUT_PULLUP);
}
void loop()
{
// Read the state of the button
int reading = digitalRead(buttonPin);
// Check for state change (debounce)
if (reading != lastButtonState)
{
lastDebounceTime = millis(); // Reset debounce timer
}
if ((millis() - lastDebounceTime) > debounceDelay)
{
// If the button state has changed
if (reading != buttonState)
{
buttonState = reading;
// Only toggle the LED if the new button state is LOW (button pressed)
if (buttonState == LOW)
{
ledState = !ledState; // Toggle LED state
digitalWrite(ledPin, ledState);
// Optional: print the LED state to the Serial Monitor
Serial.print("LED is now ");
Serial.println(ledState == HIGH ? "ON" : "OFF");
}
}
}
lastButtonState = reading; // Save the reading for next loop
}

View file

@ -0,0 +1,73 @@
/**
* @file streams-i2s-webserver_wav.ino
*
* This sketch reads sound data from I2S. The result is provided as WAV stream which can be listened to in a Web Browser
*
* **ADD THIS**
* lib_deps =
https://github.com/pschatzmann/arduino-audio-tools.git
*
* @author Phil Schatzmann
* @copyright GPLv3
*/
#include <WiFi.h>
#include "AudioTools.h"
const char *ssid = "<wifi>";
const char *password = "<pw>";
// AudioEncodedServer server(new WAVEncoder(),"ssid","password");
AudioWAVServer server(ssid, password); // the same a above
I2SStream i2sStream; // Access I2S as stream
ConverterFillLeftAndRight<int16_t> filler(LeftIsEmpty); // fill both channels - or change to RightIsEmpty
void setup()
{
Serial.begin(115200);
AudioLogger::instance().begin(Serial, AudioLogger::Info);
// // Connect to Wi-Fi
Serial.println("Connecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(1000);
Serial.println("Connecting...");
}
Serial.println("Connected to WiFi");
// Print the IP address
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
// start i2s input with default configuration
Serial.println("starting I2S...");
auto config = i2sStream.defaultConfig(RX_MODE);
// config.i2s_format = I2S_LSB_FORMAT; // if quality is bad change to I2S_LSB_FORMAT https://github.com/pschatzmann/arduino-audio-tools/issues/23
// config.sample_rate = 22050;
// config.channels = 2;
// config.bits_per_sample = 32;
// working well
config.i2s_format = I2S_STD_FORMAT;
config.sample_rate = 44100; // INMP441 supports up to 44.1kHz
config.channels = 1; // INMP441 is mono
config.bits_per_sample = 16; // INMP441 is a 24-bit ADC
config.pin_ws = 19; // Adjust these pins according to your wiring
config.pin_bck = 18;
config.pin_data = 21;
config.use_apll = true; // Try with APLL for better clock stability
i2sStream.begin(config);
Serial.println("I2S started");
// start data sink
server.begin(i2sStream, config, &filler);
}
// Arduino loop
void loop()
{
// Handle new connections
server.copy();
}

View file

@ -0,0 +1,153 @@
#include <DebugLog.h>
#include <driver/i2s.h>
#include <opus.h>
// serial
#define SERIAL_BAUD_RATE 115200
// audio speaker
#define AUDIO_SPEAKER_BCLK 26
#define AUDIO_SPEAKER_LRC 13
#define AUDIO_SPEAKER_DIN 25
// audio microphone
#define AUDIO_MIC_SD 2
#define AUDIO_MIC_WS 15
#define AUDIO_MIC_SCK 4
#define AUDIO_SAMPLE_RATE 8000 // 44100
#define AUDIO_OPUS_FRAME_MS 40 // one of 2.5, 5, 10, 20, 40, 60, 80, 100, 120
#define AUDIO_OPUS_BITRATE 3200 // bit rate from 2400 to 512000
#define AUDIO_OPUS_COMPLEXITY 0 // from 0 to 10
OpusEncoder *opus_encoder_;
OpusDecoder *opus_decoder_;
TaskHandle_t audio_task_;
int16_t *opus_samples_;
int opus_samples_size_;
int16_t *opus_samples_out_;
int opus_samples_out_size_;
uint8_t *opus_bits_;
int opus_bits_size_ = 1024;
void setup() {
LOG_SET_LEVEL(DebugLogLevel::LVL_INFO);
LOG_SET_OPTION(false, false, true); // disable file, line, enable func
Serial.begin(SERIAL_BAUD_RATE);
while (!Serial);
LOG_INFO("Board setup started");
// create i2s speaker
i2s_config_t i2s_speaker_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = AUDIO_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB),
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = 1024,
.use_apll=0,
.tx_desc_auto_clear= true,
.fixed_mclk=-1
};
i2s_pin_config_t i2s_speaker_pin_config = {
.bck_io_num = AUDIO_SPEAKER_BCLK,
.ws_io_num = AUDIO_SPEAKER_LRC,
.data_out_num = AUDIO_SPEAKER_DIN,
.data_in_num = I2S_PIN_NO_CHANGE
};
if (i2s_driver_install(I2S_NUM_0, &i2s_speaker_config, 0, NULL) != ESP_OK) {
LOG_ERROR("Failed to install i2s speaker driver");
}
if (i2s_set_pin(I2S_NUM_0, &i2s_speaker_pin_config) != ESP_OK) {
LOG_ERROR("Failed to set i2s speaker pins");
}
// create i2s microphone
i2s_config_t i2s_mic_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = AUDIO_SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
.communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB),
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = 1024,
.use_apll=0,
.tx_desc_auto_clear= true,
.fixed_mclk=-1
};
i2s_pin_config_t i2s_mic_pin_config = {
.bck_io_num = AUDIO_MIC_SCK,
.ws_io_num = AUDIO_MIC_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = AUDIO_MIC_SD
};
if (i2s_driver_install(I2S_NUM_1, &i2s_mic_config, 0, NULL) != ESP_OK) {
LOG_ERROR("Failed to install i2s mic driver");
}
if (i2s_set_pin(I2S_NUM_1, &i2s_mic_pin_config) != ESP_OK) {
LOG_ERROR("Failed to set i2s mic pins");
}
// run codec2 audio loopback on a separate task
xTaskCreate(&audio_task, "audio_task", 32000, NULL, 5, &audio_task_);
LOG_INFO("Board setup completed");
}
void audio_task(void *param) {
// configure encoder
int encoder_error;
opus_encoder_ = opus_encoder_create(AUDIO_SAMPLE_RATE, 1, OPUS_APPLICATION_VOIP, &encoder_error);
if (encoder_error != OPUS_OK) {
LOG_ERROR("Failed to create OPUS encoder, error", encoder_error);
return;
}
encoder_error = opus_encoder_init(opus_encoder_, AUDIO_SAMPLE_RATE, 1, OPUS_APPLICATION_VOIP);
if (encoder_error != OPUS_OK) {
LOG_ERROR("Failed to initialize OPUS encoder, error", encoder_error);
return;
}
opus_encoder_ctl(opus_encoder_, OPUS_SET_BITRATE(AUDIO_OPUS_BITRATE));
opus_encoder_ctl(opus_encoder_, OPUS_SET_COMPLEXITY(AUDIO_OPUS_COMPLEXITY));
opus_encoder_ctl(opus_encoder_, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE));
// configure decoder
int decoder_error;
opus_decoder_ = opus_decoder_create(AUDIO_SAMPLE_RATE, 1, &decoder_error);
if (decoder_error != OPUS_OK) {
LOG_ERROR("Failed to create OPUS decoder, error", decoder_error);
return;
}
opus_samples_size_ = (int)(AUDIO_SAMPLE_RATE / 1000 * AUDIO_OPUS_FRAME_MS);
opus_samples_ = (int16_t*)malloc(sizeof(int16_t) * opus_samples_size_);
opus_samples_out_size_ = 10 * opus_samples_size_;
opus_samples_out_ = (int16_t*)malloc(sizeof(int16_t) * opus_samples_out_size_);
opus_bits_ = (uint8_t*)malloc(sizeof(uint8_t) * opus_bits_size_);
// run loopback record-encode-decode-playback loop
size_t bytes_read, bytes_written;
LOG_INFO("Audio task started");
while(true) {
i2s_read(I2S_NUM_1, opus_samples_, sizeof(uint16_t) * opus_samples_size_, &bytes_read, portMAX_DELAY);
int encoded_size = opus_encode(opus_encoder_, opus_samples_, opus_samples_size_, opus_bits_, opus_bits_size_);
vTaskDelay(1);
int decoded_size = opus_decode(opus_decoder_, opus_bits_, encoded_size, opus_samples_out_, opus_samples_out_size_, 0);
i2s_write(I2S_NUM_0, opus_samples_out_, sizeof(uint16_t) * decoded_size, &bytes_written, portMAX_DELAY);
vTaskDelay(1);
}
}
void loop() {
// do nothing
delay(100);
}

View file

@ -0,0 +1,121 @@
/**
* @file test-codec-opus.ino
* @author Phil Schatzmann
* @brief generate sine wave -> encoder -> decoder -> audiokit (i2s)
* @version 0.1
* @date 2022-04-30
*
* @copyright Copyright (c) 2022
*
*/
#include "AudioTools.h"
#include "AudioTools/AudioLibs/AudioBoardStream.h"
#include "AudioTools/AudioCodecs/CodecOpus.h"
#include "Button.h"
esp_err_t getErr = ESP_OK;
const gpio_num_t BUTTON_PIN = GPIO_NUM_2;
void enterSleep()
{
Serial.println("Going to sleep...");
// Flush any remaining serial output
Serial.flush();
// Now enter deep sleep
esp_deep_sleep_start();
}
void printOutESP32Error(esp_err_t err)
{
switch (err)
{
case ESP_OK:
Serial.println("ESP_OK no errors");
break;
case ESP_ERR_INVALID_ARG:
Serial.println("ESP_ERR_INVALID_ARG if the selected GPIO is not an RTC GPIO, or the mode is invalid");
break;
case ESP_ERR_INVALID_STATE:
Serial.println("ESP_ERR_INVALID_STATE if wakeup triggers conflict or wireless not stopped");
break;
default:
Serial.printf("Unknown error code: %d\n", err);
break;
}
}
static void onButtonLongPressUpEventCb(void *button_handle, void *usr_data)
{
Serial.println("Button long press end");
delay(10);
enterSleep();
}
static void onButtonDoubleClickCb(void *button_handle, void *usr_data)
{
Serial.println("Button double click");
delay(10);
enterSleep();
}
AudioInfo info(24000, 1, 16);
SineWaveGenerator<int16_t> sineWave( 32000); // subclass of SoundGenerator with max amplitude of 32000
GeneratedSoundStream<int16_t> sound( sineWave); // Stream generated from sine wave
I2SStream out;
OpusAudioDecoder dec;
OpusAudioEncoder enc;
EncodedAudioStream decoder(&out, &dec); // encode and write
EncodedAudioStream encoder(&decoder, &enc); // encode and write
StreamCopy copier(encoder, sound);
void setup() {
Serial.begin(115200);
AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning);
// start I2S
Serial.println("starting I2S...");
auto cfgi = out.defaultConfig(TX_MODE);
cfgi.pin_bck = 6;
cfgi.pin_ws = 5;
cfgi.pin_data = 7;
cfgi.channels = 1;
cfgi.copyFrom(info);
out.begin(cfgi);
// Setup sine wave
sineWave.begin(info, N_B4);
// Opus encoder and decoder need to know the audio info
decoder.begin(info);
encoder.begin(info);
getErr = esp_sleep_enable_ext0_wakeup(BUTTON_PIN, LOW);
printOutESP32Error(getErr);
// BUTTON
Button *btn = new Button(BUTTON_PIN, false);
btn->attachLongPressUpEventCb(&onButtonLongPressUpEventCb, NULL);
btn->attachDoubleClickEventCb(&onButtonDoubleClickCb, NULL);
btn->detachSingleClickEvent();
// configure additinal parameters
// auto &enc_cfg = enc.config()
// enc_cfg.application = OPUS_APPLICATION_RESTRICTED_LOWDELAY;
// enc_cfg.frame_sizes_ms_x2 = OPUS_FRAMESIZE_20_MS;
// enc_cfg.complexity = 5;
Serial.println("Test started...");
}
void loop() {
copier.copy();
}

View file

@ -0,0 +1,69 @@
// This sketch provide the functionality of OTA Firmware Upgrade
#include "WiFi.h"
#include "HttpsOTAUpdate.h"
// This sketch shows how to implement HTTPS firmware update Over The Air.
// Please provide your WiFi credentials, https URL to the firmware image and the server certificate.
static const char *ssid = "EE-PPA1GZ"; // your network SSID (name of wifi network)
static const char *password = "9JkyRJHXTDTKb3"; // your network password
#define TOUCH_MODE
#ifdef TOUCH_MODE
static const char *url = "https://elato.s3.us-east-1.amazonaws.com/firmware-touch.bin"; //state url of your firmware image
#else
static const char *url = "https://elato.s3.us-east-1.amazonaws.com/firmware-button.bin"; //state url of your firmware image
#endif
static const char *server_certificate = R"EOF(
-----BEGIN CERTIFICATE-----
<YOUR HOST CERTIFICATE HERE>
-----END CERTIFICATE-----
)EOF";
static HttpsOTAStatus_t otastatus;
void HttpEventCb(HttpEvent_t *event) {
switch (event->event_id) {
case HTTP_EVENT_ERROR: Serial.println("Http Event Error"); break;
case HTTP_EVENT_ON_CONNECTED: Serial.println("Http Event On Connected"); break;
case HTTP_EVENT_HEADER_SENT: Serial.println("Http Event Header Sent"); break;
case HTTP_EVENT_ON_HEADER: Serial.printf("Http Event On Header, key=%s, value=%s\n", event->header_key, event->header_value); break;
case HTTP_EVENT_ON_DATA: break;
case HTTP_EVENT_ON_FINISH: Serial.println("Http Event On Finish"); break;
case HTTP_EVENT_DISCONNECTED: Serial.println("Http Event Disconnected"); break;
}
}
void setup() {
Serial.begin(115200);
Serial.print("Attempting to connect to SSID: ");
WiFi.begin(ssid, password);
// attempt to connect to Wifi network:
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(1000);
}
Serial.print("Connected to ");
Serial.println(ssid);
HttpsOTA.onHttpEvent(HttpEventCb);
Serial.println("Starting OTA");
HttpsOTA.begin(url, server_certificate);
Serial.println("Please Wait it takes some time ...");
}
void loop() {
otastatus = HttpsOTA.status();
if (otastatus == HTTPS_OTA_SUCCESS) {
Serial.println("Firmware written successfully. To reboot device, call API ESP.restart() or PUSH restart button on device");
ESP.restart();
} else if (otastatus == HTTPS_OTA_FAIL) {
Serial.println("Firmware Upgrade Fail");
}
delay(1000);
}

View file

@ -0,0 +1,17 @@
#include <WiFi.h>
void setup() {
Serial.begin(115200);
delay(1000);
WiFi.mode(WIFI_STA); // Ensure WiFi is initialized
}
void loop() {
// Print MAC address using the simple WiFi.macAddress() method
Serial.print("Wi-Fi MAC Address: ");
Serial.println(WiFi.macAddress());
// Delay for 1 second before printing again
delay(1000);
}

View file

@ -0,0 +1,36 @@
#include <Arduino.h>
int redPin = 9; // Red LED pin
int greenPin = 8; // Green LED pin
int bluePin = 13; // Blue LED pin
void setup()
{
pinMode(redPin, OUTPUT);
pinMode(greenPin, OUTPUT);
pinMode(bluePin, OUTPUT);
}
void loop()
{
// Example: turn Red on (LOW), Green off (HIGH), Blue off (HIGH) for Common Anode
digitalWrite(redPin, LOW); // Red on
digitalWrite(greenPin, HIGH); // Green off
digitalWrite(bluePin, HIGH); // Blue off
delay(1000); // Wait 1 second
// Turn Green on and others off
digitalWrite(redPin, HIGH); // Red off
digitalWrite(greenPin, LOW); // Green on
digitalWrite(bluePin, HIGH); // Blue off
delay(1000); // Wait 1 second
// Turn Green on and others off
digitalWrite(redPin, HIGH); // Red off
digitalWrite(greenPin, HIGH); // Green on
digitalWrite(bluePin, LOW); // Blue off
delay(1000); // Wait 1 second
}

View file

@ -0,0 +1,18 @@
#include <Arduino.h>
// define led according to pin diagram in article
const int led = D10; // there is no LED_BUILTIN available for the XIAO ESP32C3.
void setup()
{
// initialize digital pin led as an output
pinMode(led, OUTPUT);
}
void loop()
{
digitalWrite(led, HIGH); // turn the LED on
delay(1000); // wait for a second
digitalWrite(led, LOW); // turn the LED off
delay(1000); // wait for a second
}

View file

@ -0,0 +1,198 @@
#include <Arduino.h>
/*
Simple Internet Radio Demo
esp32-i2s-simple-radio.ino
Simple ESP32 I2S radio
Uses MAX98357 I2S Amplifier Module
Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S
** ADD THIS **
lib_deps =
esphome/ESP32-audioI2S@^2.0.7
DroneBot Workshop 2022
https://dronebotworkshop.com
*/
// Include required libraries
#include "WiFi.h"
#include "Audio.h"
// Define I2S connections
#define I2S_LRC D0
#define I2S_BCLK D1
#define I2S_DOUT D2
#define I2S_SD D3
#define BUTTON_PIN 0
// #define I2S_LRC 18
// #define I2S_BCLK 21
// #define I2S_DOUT 17
// Create audio object
Audio audio;
// // Wifi Credentials
String ssid = "<wifi>";
String password = "<pw>";
bool isPlaying = true; // Track whether audio is playing or not
unsigned long lastDebounceTime = 0; // for debouncing
unsigned long debounceDelay = 50; // debounce delay in ms
int lastButtonState = HIGH; // the previous button state
int buttonState = HIGH; // current button state
// Toggle audio playback by controlling the SD pin
void toggleAudio()
{
if (isPlaying)
{
// Mute the amplifier (pull SD_PIN low)
digitalWrite(I2S_SD, LOW);
Serial.println("Audio Muted");
}
else
{
// Unmute the amplifier (pull SD_PIN high)
digitalWrite(I2S_SD, HIGH);
Serial.println("Audio Unmuted");
}
isPlaying = !isPlaying; // Toggle playback state
}
void setup()
{
// Start Serial Monitor
Serial.begin(115200);
// Setup WiFi in Station mode
WiFi.disconnect();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), password.c_str());
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
// WiFi Connected, print IP to serial monitor
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
Serial.println("");
// Connect MAX98357 I2S Amplifier Module
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
// Set thevolume (0-100)
audio.setVolume(10);
// Connect to an Internet radio station (select one as desired)
// audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3");
// audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module
// audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u");
// audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a");
// audio.connecttohost("www.antenne.de/webradio/antenne.m3u");
audio.connecttohost("0n-80s.radionetz.de:8000/0n-70s.mp3");
// Set SD_PIN as output and initialize to HIGH (unmuted)
pinMode(I2S_SD, OUTPUT);
digitalWrite(I2S_SD, HIGH);
// Set BUTTON_PIN as input with internal pull-up resistor
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop()
{
// Run audio player
audio.loop();
// Read the button state
int reading = digitalRead(BUTTON_PIN);
// Check for button press (debounced)
if (reading != lastButtonState)
{
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > debounceDelay)
{
// Check if the button was pressed (LOW) and change state
if (reading == LOW)
{
if (buttonState == HIGH)
{ // Only toggle on button press, not release
toggleAudio();
}
}
buttonState = reading;
}
// Update the last button state
lastButtonState = reading;
}
// Audio status functions
void audio_info(const char *info)
{
Serial.print("info ");
Serial.println(info);
}
void audio_id3data(const char *info)
{ // id3 metadata
Serial.print("id3data ");
Serial.println(info);
}
void audio_eof_mp3(const char *info)
{ // end of file
Serial.print("eof_mp3 ");
Serial.println(info);
}
void audio_showstation(const char *info)
{
Serial.print("station ");
Serial.println(info);
}
void audio_showstreaminfo(const char *info)
{
Serial.print("streaminfo ");
Serial.println(info);
}
void audio_showstreamtitle(const char *info)
{
Serial.print("streamtitle ");
Serial.println(info);
}
void audio_bitrate(const char *info)
{
Serial.print("bitrate ");
Serial.println(info);
}
void audio_commercial(const char *info)
{ // duration in sec
Serial.print("commercial ");
Serial.println(info);
}
void audio_icyurl(const char *info)
{ // homepage
Serial.print("icyurl ");
Serial.println(info);
}
void audio_lasthost(const char *info)
{ // stream URL played
Serial.print("lasthost ");
Serial.println(info);
}
void audio_eof_speech(const char *info)
{
Serial.print("eof_speech ");
Serial.println(info);
}

View file

@ -0,0 +1,146 @@
#include <Arduino.h>
/*
Simple Internet Radio Demo
esp32-i2s-simple-radio.ino
Simple ESP32 I2S radio
Uses MAX98357 I2S Amplifier Module
Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S
** ADD THIS **
lib_deps =
esphome/ESP32-audioI2S@^2.0.7
DroneBot Workshop 2022
https://dronebotworkshop.com
*/
// Include required libraries
#include "WiFi.h"
#include "Audio.h"
// Define I2S connections
#define I2S_LRC D0
#define I2S_BCLK D1
#define I2S_DOUT D2
#define I2S_SD_OUT D3
// #define I2S_LRC 18
// #define I2S_BCLK 21
// #define I2S_DOUT 17
// Create audio object
Audio audio;
// // Wifi Credentials
String ssid = "<wifi>"; // replace your WiFi name
String password = "<pw>"; // replace your WiFi password
void setup()
{
// Start Serial Monitor
Serial.begin(115200);
Serial.println("Connecting to WiFi");
// Setup WiFi in Station mode
WiFi.disconnect();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), password.c_str());
// Set SD_PIN as output and initialize to HIGH (unmuted)
pinMode(I2S_SD_OUT, OUTPUT);
digitalWrite(I2S_SD_OUT, HIGH);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
// WiFi Connected, print IP to serial monitor
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
Serial.println("");
// Connect MAX98357 I2S Amplifier Module
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
// Set thevolume (0-100)
audio.setVolume(10);
// Connect to an Internet radio station (select one as desired)
// audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3");
// audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module
// audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u");
// audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a");
// audio.connecttohost("www.antenne.de/webradio/antenne.m3u");
audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3");
}
void loop()
{
// Run audio player
audio.loop();
}
// Audio status functions
void audio_info(const char *info)
{
Serial.print("info ");
Serial.println(info);
}
void audio_id3data(const char *info)
{ // id3 metadata
Serial.print("id3data ");
Serial.println(info);
}
void audio_eof_mp3(const char *info)
{ // end of file
Serial.print("eof_mp3 ");
Serial.println(info);
}
void audio_showstation(const char *info)
{
Serial.print("station ");
Serial.println(info);
}
void audio_showstreaminfo(const char *info)
{
Serial.print("streaminfo ");
Serial.println(info);
}
void audio_showstreamtitle(const char *info)
{
Serial.print("streamtitle ");
Serial.println(info);
}
void audio_bitrate(const char *info)
{
Serial.print("bitrate ");
Serial.println(info);
}
void audio_commercial(const char *info)
{ // duration in sec
Serial.print("commercial ");
Serial.println(info);
}
void audio_icyurl(const char *info)
{ // homepage
Serial.print("icyurl ");
Serial.println(info);
}
void audio_lasthost(const char *info)
{ // stream URL played
Serial.print("lasthost ");
Serial.println(info);
}
void audio_eof_speech(const char *info)
{
Serial.print("eof_speech ");
Serial.println(info);
}

View file

@ -0,0 +1,123 @@
#include <Arduino.h>
#include <FreeRTOS.h>
#include <task.h>
// Pin definitions
#define TOUCH_PIN 2
#define RED_LED_PIN 8
#define GREEN_LED_PIN 9
#define BLUE_LED_PIN 13
// Touch sensor threshold (adjust based on your environment)
#define TOUCH_THRESHOLD 40
// Color sequence and timing
const uint8_t colorSequence[][3] = {
{0, 255, 255}, // Cyan
{255, 0, 255}, // Pink
{255, 255, 0}, // Yellow
};
const int NUM_COLORS = sizeof(colorSequence) / sizeof(colorSequence[0]);
// RTOS task handle and state variables
TaskHandle_t colorPulseTaskHandle = NULL;
volatile bool ledOn = false;
void setup() {
Serial.begin(115200);
// Initialize LED pins
pinMode(RED_LED_PIN, OUTPUT);
pinMode(GREEN_LED_PIN, OUTPUT);
pinMode(BLUE_LED_PIN, OUTPUT);
analogWrite(RED_LED_PIN, 0);
analogWrite(GREEN_LED_PIN, 0);
analogWrite(BLUE_LED_PIN, 0);
// Create color pulse task (suspended initially)
xTaskCreate(
colorPulseTask, // Task function
"ColorPulse", // Task name
4096, // Stack size
NULL, // Parameters
1, // Priority
&colorPulseTaskHandle
);
vTaskSuspend(colorPulseTaskHandle);
}
void loop() {
static bool lastTouchState = false;
static unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 50;
// Read touch sensor
int touchValue = touchRead(TOUCH_PIN);
bool currentTouchState = (touchValue < TOUCH_THRESHOLD);
// Debounce logic
if (currentTouchState != lastTouchState) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > debounceDelay) {
if (currentTouchState && !lastTouchState) {
ledOn = !ledOn;
if (ledOn) {
vTaskResume(colorPulseTaskHandle);
} else {
vTaskSuspend(colorPulseTaskHandle);
analogWrite(RED_LED_PIN, 0);
analogWrite(GREEN_LED_PIN, 0);
analogWrite(BLUE_LED_PIN, 0);
}
}
lastTouchState = currentTouchState;
}
vTaskDelay(10 / portTICK_PERIOD_MS); // Yield to other tasks
}
void colorPulseTask(void *pvParameters) {
while (1) {
unsigned long currentTime = millis();
loopCyanPinkYellowPulse(currentTime);
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void loopCyanPinkYellowPulse(unsigned long currentTime) {
const unsigned long transitionDuration = 1000; // 1 second per transition
static int colorIndex = 0;
static uint8_t startColor[3];
static uint8_t endColor[3];
static unsigned long transitionStartTime = 0;
static bool initialized = false;
if (!initialized) {
memcpy(startColor, colorSequence[colorIndex], 3);
memcpy(endColor, colorSequence[(colorIndex + 1) % NUM_COLORS], 3);
transitionStartTime = currentTime;
initialized = true;
}
unsigned long elapsed = currentTime - transitionStartTime;
float t = (float)elapsed / (float)transitionDuration;
t = t > 1.0f ? 1.0f : t;
uint8_t r = startColor[0] + (endColor[0] - startColor[0]) * t;
uint8_t g = startColor[1] + (endColor[1] - startColor[1]) * t;
uint8_t b = startColor[2] + (endColor[2] - startColor[2]) * t;
analogWrite(RED_LED_PIN, r);
analogWrite(GREEN_LED_PIN, g);
analogWrite(BLUE_LED_PIN, b);
if (elapsed >= transitionDuration) {
colorIndex = (colorIndex + 1) % NUM_COLORS;
memcpy(startColor, endColor, 3);
memcpy(endColor, colorSequence[(colorIndex + 1) % NUM_COLORS], 3);
transitionStartTime = currentTime;
}
}

View file

@ -0,0 +1,149 @@
#include <Arduino.h>
#include <esp_sleep.h>
#include <driver/touch_sensor.h>
// Use touch pad channel 2 (T2)
#define TOUCH_PAD_CHANNEL TOUCH_PAD_NUM2
// Set the threshold value adjust this value for your hardware
#define TOUCH_THRESHOLD 60000
// Duration (in milliseconds) required for a long press
#define LONG_PRESS_MS 1000
// Task handles (optional, for task management)
TaskHandle_t fibTaskHandle = NULL;
TaskHandle_t touchTaskHandle = NULL;
// Define RGB LED pins
#define READ_PIN_T 8
#define GREEN_PIN_T 9
#define BLUE_PIN_T 13
//------------------------------------------------------------------------------
// RGB LED Task: Cycle through colors every 1 second
//------------------------------------------------------------------------------
void ledTaskT(void* parameter) {
// Set pins as outputs
pinMode(READ_PIN_T, OUTPUT);
pinMode(GREEN_PIN_T, OUTPUT);
pinMode(BLUE_PIN_T, OUTPUT);
while (true) {
// Red
digitalWrite(READ_PIN_T, HIGH);
digitalWrite(GREEN_PIN_T, LOW);
digitalWrite(BLUE_PIN_T, LOW);
delay(1000);
// Green
digitalWrite(READ_PIN_T, LOW);
digitalWrite(GREEN_PIN_T, HIGH);
digitalWrite(BLUE_PIN_T, LOW);
delay(1000);
// Blue
digitalWrite(READ_PIN_T, LOW);
digitalWrite(GREEN_PIN_T, LOW);
digitalWrite(BLUE_PIN_T, HIGH);
delay(1000);
}
}
//------------------------------------------------------------------------------
// enterSleep: Wait for a stable release, enable touch wake, and enter deep sleep
//------------------------------------------------------------------------------
void enterSleep() {
Serial.println("Preparing to sleep...");
// Wait until the touch pad reading shows "release"
// (Assuming that a reading above TOUCH_THRESHOLD means a touch.)
while (touchRead(TOUCH_PAD_CHANNEL) > TOUCH_THRESHOLD) {
delay(50);
}
// Extra delay to allow any residual contact or noise to settle
delay(500);
// Enable touchpad wakeup using the Arduino API.
// This configures the ESP32 so that a new touch on channel 2 will wake it.
touchSleepWakeUpEnable(TOUCH_PAD_CHANNEL, TOUCH_THRESHOLD);
Serial.println("Entering deep sleep now.");
esp_deep_sleep_start();
// Execution stops here until a wakeup occurs.
}
//------------------------------------------------------------------------------
// Touch Task: Monitor the touch pad to detect a long press and trigger sleep
//------------------------------------------------------------------------------
void touchTask(void* parameter) {
touch_pad_init();
touch_pad_config(TOUCH_PAD_NUM2);
bool touched = false;
unsigned long pressStartTime = 0;
while (true) {
// Read the touch sensor
uint32_t touchValue = touchRead(TOUCH_PAD_CHANNEL);
Serial.printf("Touch Pad Value: %u\n", touchValue);
// On the ESP32-S3, a reading above TOUCH_THRESHOLD indicates a touch.
bool isTouched = (touchValue > TOUCH_THRESHOLD);
// Detect transition from "not touched" to "touched"
if (isTouched && !touched) {
touched = true;
pressStartTime = millis();
Serial.println("Touch detected press started.");
}
// Detect transition from "touched" to "released"
if (!isTouched && touched) {
touched = false;
unsigned long pressDuration = millis() - pressStartTime;
if (pressDuration >= LONG_PRESS_MS) {
Serial.print("Long press detected (");
Serial.print(pressDuration);
Serial.println(" ms) going to sleep.");
// Call enterSleep() which will wait for a stable release, enable wake, and sleep.
enterSleep();
// (The device will reset on wake, so code execution won't continue here.)
} else {
Serial.print("Short press detected (");
Serial.print(pressDuration);
Serial.println(" ms) ignoring.");
}
}
delay(50); // Small delay to avoid spamming readings
}
// (This point is never reached.)
vTaskDelete(NULL);
}
//------------------------------------------------------------------------------
// setup: Initialize Serial, print wakeup cause, and create tasks
//------------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
delay(500);
// Check the wakeup cause and print a message
esp_sleep_wakeup_cause_t wakeupCause = esp_sleep_get_wakeup_cause();
if (wakeupCause == ESP_SLEEP_WAKEUP_TOUCHPAD) {
Serial.println("Woke up from touchpad deep sleep.");
} else {
Serial.println("Normal startup.");
}
// Create the Fibonacci task (prints every 1 second)
xTaskCreate(ledTaskT, "FibonacciTask", 2048, NULL, 1, &fibTaskHandle);
// Create the Touch task (monitors the touch pad)
xTaskCreate(touchTask, "TouchTask", 2048, NULL, 1, &touchTaskHandle);
}
//------------------------------------------------------------------------------
// loop: Not used as tasks handle all work
//------------------------------------------------------------------------------
void loop() {
// Nothing to do here.
}

View file

@ -0,0 +1,29 @@
#include <Arduino.h>
// Adjust to the pin you are using:
#define TOUCH_PIN 2
void setup() {
Serial.begin(115200);
while (!Serial); // wait for Serial to be ready
Serial.println("ESP32 Touch Threshold Test");
Serial.println("Touch or release the pad and watch the values.");
Serial.println("Use these values to decide on a threshold.");
}
void loop() {
uint16_t touchValue = touchRead(TOUCH_PIN);
Serial.println(touchValue);
// If you'd like to show a quick guess for touched vs not touched,
// you can temporarily hardcode a threshold for testing:
// int threshold = 40;
// if (touchValue < threshold) {
// Serial.println("Touched!");
// } else {
// Serial.println("Not touched");
// }
delay(250); // read ~4 times per second
}

View file

@ -0,0 +1,139 @@
#include <Arduino.h>
/*
Simple Internet Radio Demo
esp32-i2s-simple-radio.ino
Simple ESP32 I2S radio
Uses MAX98357 I2S Amplifier Module
Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S
**ADD THIS**
lib_deps =
https://github.com/tzapu/WiFiManager.git
DroneBot Workshop 2022
https://dronebotworkshop.com
*/
// Include required libraries
#include <WiFiManager.h> // Include the WiFiManager library
#include "Audio.h"
// Define I2S connections
#define I2S_LRC D0
#define I2S_BCLK D1
#define I2S_DOUT D2
#define I2S_SD_OUT D3
// #define I2S_LRC 18
// #define I2S_BCLK 21
// #define I2S_DOUT 17
// Create audio object
Audio audio;
void setup()
{
// Start Serial Monitor
Serial.begin(115200);
// Set SD_PIN as output and initialize to HIGH (unmuted)
pinMode(I2S_SD_OUT, OUTPUT);
digitalWrite(I2S_SD_OUT, HIGH);
// Initialize WiFiManager
WiFiManager wifiManager;
// Uncomment for testing to reset saved settings
// wifiManager.resetSettings();
// Automatically connect using saved credentials,
// or start the captive portal to enter new credentials
if (!wifiManager.autoConnect("ESP32RadioAP"))
{
Serial.println("Failed to connect and hit timeout");
ESP.restart(); // Optionally, restart or handle the failure
}
// If you get here, you have connected to the Wi-Fi
Serial.println("Connected to Wi-Fi!");
// Connect MAX98357 I2S Amplifier Module
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
// Set thevolume (0-100)
audio.setVolume(90);
// Connect to an Internet radio station (select one as desired)
// audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3");
// audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module
// audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u");
// audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a");
// audio.connecttohost("www.antenne.de/webradio/antenne.m3u");
audio.connecttohost("0n-80s.radionetz.de:8000/0n-70s.mp3");
}
void loop()
{
// Run audio player
audio.loop();
}
// Audio status functions
void audio_info(const char *info)
{
Serial.print("info ");
Serial.println(info);
}
void audio_id3data(const char *info)
{ // id3 metadata
Serial.print("id3data ");
Serial.println(info);
}
void audio_eof_mp3(const char *info)
{ // end of file
Serial.print("eof_mp3 ");
Serial.println(info);
}
void audio_showstation(const char *info)
{
Serial.print("station ");
Serial.println(info);
}
void audio_showstreaminfo(const char *info)
{
Serial.print("streaminfo ");
Serial.println(info);
}
void audio_showstreamtitle(const char *info)
{
Serial.print("streamtitle ");
Serial.println(info);
}
void audio_bitrate(const char *info)
{
Serial.print("bitrate ");
Serial.println(info);
}
void audio_commercial(const char *info)
{ // duration in sec
Serial.print("commercial ");
Serial.println(info);
}
void audio_icyurl(const char *info)
{ // homepage
Serial.print("icyurl ");
Serial.println(info);
}
void audio_lasthost(const char *info)
{ // stream URL played
Serial.print("lasthost ");
Serial.println(info);
}
void audio_eof_speech(const char *info)
{
Serial.print("eof_speech ");
Serial.println(info);
}

BIN
frontend-nextjs/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,16 @@
# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=<YOUR_SUPABASE_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<YOUR_SUPABASE_ANON_KEY>
JWT_SECRET_KEY=<YOUR_JWT_SECRET_KEY>
ENCRYPTION_KEY=<YOUR_ENCRYPTION_KEY>
OPENAI_API_KEY=<YOUR_OPENAI_API_KEY>
ENCRYPTION_KEY=<YOUR_ENCRYPTION_KEY>
# NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
GOOGLE_OAUTH=True
# Stripe
STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>

4
frontend-nextjs/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
.env
.env.local
.next

View file

@ -0,0 +1,83 @@
# FROM --platform=linux/amd64 node:20-alpine AS base
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG OPENAI_API_KEY
ARG GOOGLE_OAUTH
ARG JWT_SECRET_KEY
# ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV OPENAI_API_KEY=$OPENAI_API_KEY
ENV GOOGLE_OAUTH=$GOOGLE_OAUTH
ENV JWT_SECRET_KEY=$JWT_SECRET_KEY
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

96
frontend-nextjs/README.md Normal file
View file

@ -0,0 +1,96 @@
<a href="https://demo-nextjs-with-supabase.vercel.app/">
<img alt="Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase" src="https://demo-nextjs-with-supabase.vercel.app/opengraph-image.png">
<h1 align="center">Next.js and Supabase Starter Kit</h1>
</a>
<p align="center">
The fastest way to build apps with Next.js and Supabase
</p>
<p align="center">
<a href="#features"><strong>Features</strong></a> ·
<a href="#demo"><strong>Demo</strong></a> ·
<a href="#deploy-to-vercel"><strong>Deploy to Vercel</strong></a> ·
<a href="#clone-and-run-locally"><strong>Clone and run locally</strong></a> ·
<a href="#feedback-and-issues"><strong>Feedback and issues</strong></a>
<a href="#more-supabase-examples"><strong>More Examples</strong></a>
</p>
<br/>
## Features
- Works across the entire [Next.js](https://nextjs.org) stack
- App Router
- Pages Router
- Middleware
- Client
- Server
- It just works!
- supabase-ssr. A package to configure Supabase Auth to use cookies
- Styling with [Tailwind CSS](https://tailwindcss.com)
- Components with [shadcn/ui](https://ui.shadcn.com/)
- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own)
- Environment variables automatically assigned to Vercel project
## Demo
You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/).
## Deploy to Vercel
Vercel deployment will guide you through creating a Supabase account and project.
After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This%20starter%20configures%20Supabase%20Auth%20to%20use%20cookies%2C%20making%20the%20user's%20session%20available%20throughout%20the%20entire%20Next.js%20app%20-%20Client%20Components%2C%20Server%20Components%2C%20Route%20Handlers%2C%20Server%20Actions%20and%20Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6)
The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally.
If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally).
## Clone and run locally
1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new)
2. Create a Next.js app using the Supabase Starter template npx command
```bash
npx create-next-app -e with-supabase
```
3. Use `cd` to change into the app's directory
```bash
cd name-of-new-app
```
4. Rename `.env.local.example` to `.env.local` and update the following:
```
NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL]
NEXT_PUBLIC_SUPABASE_ANON_KEY=[INSERT SUPABASE PROJECT API ANON KEY]
```
Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` can be found in [your Supabase project's API settings](https://app.supabase.com/project/_/settings/api)
5. You can now run the Next.js local development server:
```bash
npm run dev
```
The starter kit should now be running on [localhost:3000](http://localhost:3000/).
6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next)
> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally.
## Feedback and issues
Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose).
## More Supabase examples
- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments)
- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF)
- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs)

View file

@ -0,0 +1,58 @@
import { forgotPasswordAction } from "@/app/actions";
import { FormMessage, Message } from "@/components/form-message";
import { SubmitButton } from "@/components/submit-button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@radix-ui/react-dropdown-menu";
import { Sparkles } from "lucide-react";
import Link from "next/link";
export default function ForgotPassword({
searchParams,
}: {
searchParams: Message;
}) {
return (
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
<Card>
<CardHeader>
<CardTitle className="flex flex-row gap-1 items-center">
Reset Password
<Sparkles size={20} fill="black" />
</CardTitle>
<CardDescription>
Enter your email to reset your account password.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{/* <ToyPreview /> */}
<form className="flex-1 flex flex-col w-full justify-center gap-4">
<div className="flex flex-col gap-2 [&>input]:mb-3">
<Label htmlFor="email">Email</Label>
<Input name="email" placeholder="you@example.com" required />
<SubmitButton formAction={forgotPasswordAction}>
Reset Password
</SubmitButton>
<FormMessage message={searchParams} />
</div>
<div>
<p className="text-sm text-secondary-foreground">
Already have an account?{" "}
<Link className="text-primary underline" href="/login">
Sign in
</Link>
</p>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { getOpenGraphMetadata } from "@/lib/utils";
export const metadata: Metadata = {
title: "Login",
...getOpenGraphMetadata("Login"),
};
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex flex-1 flex-col mx-auto w-full max-w-[1440px] min-h-screen items-center mt-16">
<div className="w-full flex justify-center">{children}</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { useSearchParams } from "next/navigation";
export default function Messages() {
const searchParams = useSearchParams();
const error = searchParams.get("error");
const message = searchParams.get("message");
return (
<>
{error && (
<p className="p-4 rounded-md border bg-red-200 border-red-300 text-gray-800 text-center text-sm">
{error}
</p>
)}
{message && (
<p className="p-4 rounded-md border bg-green-50 border-green-400 text-gray-900 text-center text-sm">
{message}
</p>
)}
</>
);
}

View file

@ -0,0 +1,155 @@
import Link from "next/link";
import { headers } from "next/headers";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { SubmitButton } from "./submit-button";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Sparkles } from "lucide-react";
import { Label } from "@/components/ui/label";
import GoogleLoginButton from "../../components/GoogleLoginButton";
import Image from "next/image";
interface LoginProps {
searchParams?: { [key: string]: string | string[] | undefined };
}
export default async function Login({ searchParams }: LoginProps) {
const toy_id = searchParams?.toy_id as string | undefined;
const personality_id = searchParams?.personality_id as string | undefined;
const isGoogleOAuthEnabled = process.env.GOOGLE_OAUTH === "True";
// const supabase = createClient();
// const {
// data: { user },
// } = await supabase.auth.getUser();
// if (user) {
// return redirect("/home");
// }
const signInOrSignUp = async (formData: FormData) => {
"use server";
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabase = createClient();
// Try to sign in first
const { error: signInError } = await supabase.auth.signInWithPassword({
email,
password,
});
// If sign in succeeds, redirect to home
if (!signInError) {
return redirect("/home");
}
// If sign in fails, try to sign up
const origin = headers().get("origin");
const { error: signUpError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
toy_id: toy_id,
personality_id: personality_id,
},
emailRedirectTo: `${origin}/auth/callback`,
},
});
if (signUpError) {
return redirect(`/login?message=${signUpError.message}`);
}
// if (process.env.NEXT_PUBLIC_ENV === "local") {
// return redirect("/login?message=Sussessfully signed up");
// } else {
// return redirect("/login?message=Check email to continue sign in process");
// }
return redirect("/login?message=Check email to continue sign in process");
};
return (
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
<Card className="shadow-md sm:bg-white bg-transparent shadow-none">
<CardHeader>
<CardTitle className="flex flex-row gap-1 items-center">
Login to Elato
<Sparkles size={20} fill="black" />
</CardTitle>
<CardDescription>
Login or sign up your account to continue
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{/* <ToyPreview /> */}
{isGoogleOAuthEnabled && (
<GoogleLoginButton
toy_id={toy_id}
personality_id={personality_id}
/>
)}
{/* <Separator className="mt-2" />
<div className="flex flex-row gap-2 items-center w-full h-[300px] mx-auto relative rounded-xl overflow-hidden">
<Image src="/teddy.png" alt="Elato Login" fill className="object-cover" />
</div> */}
{/*<form className="flex-1 flex flex-col w-full justify-center gap-4">
<Label className="text-md" htmlFor="email">
Email
</Label>
<input
className="rounded-md px-4 py-2 bg-inherit border"
name="email"
placeholder="you@example.com"
required
/>
<Label className="text-md" htmlFor="email">
Password
</Label>
<input
className="rounded-md px-4 py-2 bg-inherit border"
type="password"
name="password"
placeholder="••••••••"
required
/>
<Link
className="text-xs text-foreground underline"
href="/forgot-password"
>
Forgot Password?
</Link>
<SubmitButton
formAction={signInOrSignUp}
className="text-sm font-medium bg-gray-100 hover:bg-gray-50 dark:text-stone-900 border-[0.1px] rounded-md px-4 py-2 text-foreground my-2"
pendingText="Signing In..."
>
Continue with Email
</SubmitButton>
{searchParams?.message && (
<p className="p-4 rounded-md border bg-green-50 border-green-400 text-gray-900 text-center text-sm">
{searchParams.message}
</p>
)}
</form> */}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { useFormStatus } from "react-dom";
import { type ComponentProps } from "react";
type Props = ComponentProps<"button"> & {
pendingText?: string;
};
export function SubmitButton({ children, pendingText, ...props }: Props) {
const { pending, action } = useFormStatus();
const isPending = pending && action === props.formAction;
return (
<button {...props} type="submit" aria-disabled={pending}>
{isPending ? pendingText : children}
</button>
);
}

View file

@ -0,0 +1,262 @@
"use server";
import { encodedRedirect } from "@/utils/utils";
import { createClient } from "@/utils/supabase/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { encryptSecret, getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils";
import { addUserToDevice, dbCheckUserCode } from "@/db/devices";
import { getSimpleUserById, updateUser } from "@/db/users";
export async function deleteUserApiKey(userId: string) {
const supabase = createClient();
const { error } = await supabase.from('api_keys').delete().eq('user_id', userId);
return error;
}
export const signInAction = async (formData: FormData) => {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return encodedRedirect("error", "/login", error.message);
}
return redirect("/home");
};
export const forgotPasswordAction = async (formData: FormData) => {
const email = formData.get("email")?.toString();
const supabase = createClient();
const origin = headers().get("origin");
const callbackUrl = formData.get("callbackUrl")?.toString();
if (!email) {
return encodedRedirect(
"error",
"/forgot-password",
"Email is required"
);
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`,
});
if (error) {
console.error(error.message);
return encodedRedirect(
"error",
"/forgot-password",
"Could not reset password"
);
}
if (callbackUrl) {
return redirect(callbackUrl);
}
return encodedRedirect(
"success",
"/forgot-password",
"Check your email for a link to reset your password."
);
};
export const resetPasswordAction = async (formData: FormData) => {
const supabase = createClient();
const password = formData.get("password") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!password || !confirmPassword) {
encodedRedirect(
"error",
"/protected/reset-password",
"Password and confirm password are required"
);
}
if (password !== confirmPassword) {
encodedRedirect(
"error",
"/protected/reset-password",
"Passwords do not match"
);
}
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
encodedRedirect(
"error",
"/protected/reset-password",
"Password update failed"
);
}
encodedRedirect("success", "/protected/reset-password", "Password updated");
};
export const signOutAction = async () => {
const supabase = createClient();
await supabase.auth.signOut();
return redirect("/login");
};
export const checkDoctorAction = async (authCode: string) => {
return authCode === "kiwi-subtle-emu";
};
export const connectUserToDevice = async (
userId: string,
userDeviceCode: string
) => {
const supabase = createClient();
const isCodeValid = await dbCheckUserCode(supabase, userDeviceCode.trim());
if (!isCodeValid) {
return false;
}
// if user code is valid, add user to device
const successfullyAdded = await addUserToDevice(
supabase,
userDeviceCode,
userId
);
return successfullyAdded;
};
export const fetchGithubStars = async (repo: string) => {
try {
const response = await fetch(`https://api.github.com/repos/${repo}`, {
headers: {
Accept: "application/vnd.github.v3+json",
},
next: {
revalidate: 3600,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
const data = await response.json();
return {
stars: data.stargazers_count,
error: null,
};
} catch (error) {
console.error("Error fetching GitHub stats:", error);
return {
stars: null,
error: "Failed to load GitHub stats",
};
}
};
export const isPremiumUser = async (userId: string) => {
const supabase = createClient();
const dbUser = await getSimpleUserById(supabase, userId);
return dbUser?.is_premium;
};
export const setDeviceReset = async (userId: string) => {
const supabase = createClient();
await supabase
.from("users")
.update({ is_reset: true })
.eq("user_id", userId);
};
export const setDeviceOta = async (userId: string) => {
const supabase = createClient();
await supabase.from("users").update({ is_ota: true }).eq("user_id", userId);
};
export async function storeUserApiKey(userId: string, rawApiKey: string) {
const supabase = createClient();
const { iv, encryptedData } = encryptSecret(rawApiKey, process.env.ENCRYPTION_KEY!);
const { error } = await supabase
.from('api_keys')
.upsert({
user_id: userId,
encrypted_key: encryptedData,
iv: iv,
});
if (error) {
console.error('Error inserting or updating user secret:', error);
throw error;
}
console.log(`Encrypted API key for user ${userId} stored successfully.`);
}
export async function checkIfUserHasApiKey(userId: string): Promise<boolean> {
const supabase = createClient();
const { data, error } = await supabase
.from('api_keys')
.select('*', { count: 'exact' })
.eq('user_id', userId);
if (error) {
console.error('Error checking if user has API key:', error);
throw error;
}
return data.length > 0;
}
export async function registerDevice(userId: string, deviceCode: string) {
// check if deviceCode is valid mac address
if (!isValidMacAddress(deviceCode)) {
return { error: "Invalid device code" };
}
const supabase = createClient();
const { data, error } = await supabase
.from('devices')
.insert({ user_id: userId, user_code: deviceCode.toLowerCase(), mac_address: getMacAddressFromDeviceCode(deviceCode).toUpperCase() }).select();
if (error) {
console.log(error)
return { error: "Error registering device" };
}
if (data && data.length > 0) {
await updateUser(supabase, { device_id: data[0].device_id }, userId);
}
return { error: null };
}
export const createPersonality = async (userId: string, personality: IPersonality): Promise<IPersonality | null> => {
const supabase = createClient();
const { data, error } = await supabase
.from('personalities')
.insert({
...personality,
creator_id: userId
}).select();
if (error) {
console.error('Error creating personality:', error);
throw error;
}
return data ? data[0] : null;
}

View file

@ -0,0 +1,11 @@
import FluidAISpeakingAnimation from "../components/Realtime/Animation";
import FluidBubbleAnimation from "../components/Realtime/Animation1";
export default async function Home() {
return (
<div className="flex flex-col gap-2">
{/* <FluidAISpeakingAnimation /> */}
<FluidBubbleAnimation />
</div>
);
}

View file

@ -0,0 +1,351 @@
// api/checkout/route.ts
import { createClient } from "@/utils/supabase/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
apiVersion: "2024-10-28.acacia",
});
// ... existing imports and stripe initialization ...
export async function POST(req: Request) {
const {
quantity,
color,
free_shipping,
}: { quantity: number; color: string; free_shipping: boolean } =
await req.json();
console.log("foobar", quantity, color, free_shipping);
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const successUrl = session
? `${req.headers.get("origin")}/home?session_id={CHECKOUT_SESSION_ID}`
: `${req.headers.get("origin")}/login?session_id={CHECKOUT_SESSION_ID}`;
try {
const stripeSession = await stripe.checkout.sessions.create({
line_items: [
{
price: "price_1R4nqzLTb7Djmo1RZuUmWj6c",
quantity: quantity,
},
],
mode: "payment",
success_url: successUrl,
cancel_url: `${req.headers.get("origin")}`,
billing_address_collection: "required",
phone_number_collection: {
enabled: true, // Enable phone number collection
},
shipping_address_collection: {
allowed_countries: [
"AC",
"AD",
"AE",
"AF",
"AG",
"AI",
"AL",
"AM",
"AO",
"AQ",
"AR",
"AT",
"AU",
"AW",
"AX",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BL",
"BM",
"BN",
"BO",
"BQ",
"BR",
"BS",
"BT",
"BV",
"BW",
"BY",
"BZ",
"CA",
"CD",
"CF",
"CG",
"CH",
"CI",
"CK",
"CL",
"CM",
"CN",
"CO",
"CR",
"CV",
"CW",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"EH",
"ER",
"ES",
"ET",
"FI",
"FJ",
"FK",
"FO",
"FR",
"GA",
"GB",
"GD",
"GE",
"GF",
"GG",
"GH",
"GI",
"GL",
"GM",
"GN",
"GP",
"GQ",
"GR",
"GS",
"GT",
"GU",
"GW",
"GY",
"HK",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IM",
"IN",
"IO",
"IQ",
"IS",
"IT",
"JE",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KR",
"KW",
"KY",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MF",
"MG",
"MK",
"ML",
"MM",
"MN",
"MO",
"MQ",
"MR",
"MS",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NC",
"NE",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NU",
"NZ",
"OM",
"PA",
"PE",
"PF",
"PG",
"PH",
"PK",
"PL",
"PM",
"PN",
"PR",
"PS",
"PT",
"PY",
"QA",
"RE",
"RO",
"RS",
"RU",
"RW",
"SA",
"SB",
"SC",
"SE",
"SG",
"SH",
"SI",
"SJ",
"SK",
"SL",
"SM",
"SN",
"SO",
"SR",
"SS",
"ST",
"SV",
"SX",
"SZ",
"TA",
"TC",
"TD",
"TF",
"TG",
"TH",
"TJ",
"TK",
"TL",
"TM",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"US",
"UY",
"UZ",
"VA",
"VC",
"VE",
"VG",
"VN",
"VU",
"WF",
"WS",
"XK",
"YE",
"YT",
"ZA",
"ZM",
"ZW",
"ZZ",
],
},
allow_promotion_codes: true,
shipping_options: [
{
shipping_rate_data: {
type: "fixed_amount",
fixed_amount: {
amount: 0,
currency: "usd",
},
display_name: "Global Shipping",
delivery_estimate: {
minimum: {
unit: "week",
value: 2,
},
maximum: {
unit: "week",
value: 3,
},
},
},
},
],
// shipping_options: free_shipping
// ? []
// : [
// {
// shipping_rate_data: {
// type: "fixed_amount",
// fixed_amount: {
// amount: 1000,
// currency: "usd",
// },
// display_name: "Global Shipping",
// delivery_estimate: {
// minimum: {
// unit: "week",
// value: 2,
// },
// maximum: {
// unit: "week",
// value: 3,
// },
// },
// },
// },
// ],
metadata: {
device_color: color,
},
});
return new Response(JSON.stringify({ url: stripeSession.url }), {
status: 200,
});
} catch (error) {
console.log("Error creating checkout session", error);
return new Response(
JSON.stringify({ error: (error as Error).message }),
{
status: 500,
}
);
}
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import jwt from "jsonwebtoken";
import { createClient } from '@/utils/supabase/server';
export async function POST(req: Request) {
try {
const { authToken } = await req.json();
const supabase = createClient({
global: {
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
const { data: user, error: userError } = await supabase.auth.getUser();
if (userError) {
return NextResponse.json(
{ error: userError.message },
{ status: 401 }
);
}
// set is_reset to false
const { data, error } = await supabase.from('devices').update({
is_reset: false,
}).eq('user_id', user.user.id).select();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
import jwt from "jsonwebtoken";
import { createClient } from '@/utils/supabase/server';
const ALGORITHM = "HS256";
interface TokenPayload {
[key: string]: any;
}
const createSupabaseToken = (
jwtSecretKey: string,
data: TokenPayload,
// Set expiration to null for no expiration, or use a very large number like 10 years
expireDays: number | null = 3650 // Default to 10 years
): string => {
const toEncode = {
aud: 'authenticated',
role: 'authenticated',
sub: data.user_id,
email: data.email,
// Only include exp if expireDays is not null
...(expireDays && {
exp: Math.floor(Date.now() / 1000) + (expireDays * 86400)
}),
user_metadata: {
...data
}
};
const encodedJwt = jwt.sign(toEncode, jwtSecretKey, {
algorithm: ALGORITHM
});
return encodedJwt;
};
const getUserByMacAddress = async (macAddress: string) => {
const supabase = createClient();
const { data, error } = await supabase.from('devices').select('*, user:user_id(*)').eq('mac_address', macAddress).single();
if (error) {
throw new Error(error.message);
}
return data.user;
};
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const macAddress = searchParams.get('macAddress');
if (!macAddress) {
return NextResponse.json(
{ error: 'MAC address is required' },
{ status: 400 }
);
}
const user = await getUserByMacAddress(macAddress);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 400 }
);
}
const payload = {
email: user.email,
user_id: user.user_id,
created_time: new Date()
};
const token = createSupabaseToken(process.env.JWT_SECRET_KEY!, payload, null);
return NextResponse.json({ token });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import jwt from "jsonwebtoken";
import { createClient } from '@/utils/supabase/server';
export async function POST(req: Request) {
try {
const { authToken } = await req.json();
const supabase = createClient({
global: {
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
const { data: user, error: userError } = await supabase.auth.getUser();
if (userError) {
return NextResponse.json(
{ error: userError.message },
{ status: 401 }
);
}
// set is_ota to false
const { data, error } = await supabase.from('devices').update({
is_ota: false,
}).eq('user_id', user.user.id).select();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,266 @@
import { createClient } from "@/utils/supabase/server";
import { SupabaseClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { getUserById } from "@/db/users";
interface IPayload {
user: IUser;
supabase: SupabaseClient;
timestamp: string;
}
const getDoctorGuidanceHistory = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase.from('conversations').select('*').eq('user_id', userId).eq('role', 'doctor').order('created_at', { ascending: false }).limit(10);
return data?.map((chat: IConversation) => {
const timestamp = chat.created_at ? new Date(chat.created_at).toLocaleString() : "";
return `${chat.role} [${timestamp}]: ${chat.content}`;
}).join("") ?? "";}
const getChatHistory = async (
supabase: SupabaseClient,
userId: string,
personalityKey: string | null,
): Promise<string> => {
try {
let query = supabase
.from('conversations')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
if (personalityKey) {
query = query.eq('personality_key', personalityKey);
}
const { data, error } = await query;
if (error) throw error;
const messages = data.map((chat: IConversation) => `${chat.role}: ${chat.content}`)
.join('\n');
return messages;
} catch (error: any) {
throw new Error(`Failed to get chat history: ${error.message}`);
}
};
const UserPromptTemplate = (user: IUser) => `
YOU ARE TALKING TO someone whose name is: ${user.supervisee_name} and age is: ${user.supervisee_age} with a personality described as: ${user.supervisee_persona}.
Do not ask for personal information.
Your physical form is in the form of a physical object or a toy.
A person interacts with you by pressing a button, sends you instructions and you must respond in a concise conversational style.
`;
const DoctorPromptTemplate = (user: IUser) => {
const userMetadata = user.user_info.user_metadata as IDoctorMetadata;
const doctorName = userMetadata.doctor_name || 'Doctor';
const hospitalName = userMetadata.hospital_name || 'An amazing hospital';
const specialization = userMetadata.specialization || 'general medicine';
const favoritePhrases = userMetadata.favorite_phrases || "You're doing an amazing job";
return `
YOU ARE TALKING TO a patient under the care of doctor ${doctorName} from hospital or clinic ${hospitalName}. The child may be undergoing procedures such as ${specialization}.
YOU ARE: A friendly, compassionate toy designed to offer comfort and care. You specialize in calming children and answering their questions with simple, concise and soothing explanations.
YOUR FAVORITE PHRASES ARE: ${favoritePhrases}
`;
};
const getCommonPromptTemplate = (chatHistory: string, user: IUser, timestamp: string) => `
YOUR VOICE IS: ${user.personality?.voice_prompt}
YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt}
CHAT HISTORY:
${chatHistory}
USER'S CURRENT TIME IS: ${timestamp}
LANGUAGE:
You may talk in any language the user would like, but the default language is ${user?.language?.name ?? 'English'}.
`;
const getStoryPromptTemplate = (user: IUser, chatHistory: string) => {
const childName = user.supervisee_name;
const childAge = user.supervisee_age;
const childInterests = user.supervisee_persona;
const title = user.personality?.title;
const characterPrompt = user.personality?.character_prompt;
const voicePrompt = user.personality?.voice_prompt;
return `
You are a lively, imaginative storyteller character named ${title}. You are about to create a fun and exciting adventure story for ${childName}, who is ${childAge} years old. ${childName} loves ${childInterests}.
Your storytelling style must:
- Be creative, immersive, and interactive.
- Include frequent pauses or questions to let ${childName} influence what happens next.
- Feature themes and elements closely related to ${childName}'s interests.
- Be age-appropriate, friendly, playful, and positive.
Your Character Description:
${characterPrompt}
Your Voice Description:
${voicePrompt}
Storytelling Guidelines:
- Begin the story by directly addressing ${childName} and introducing an interesting scenario related to their interests.
- After every 4-5 sentences or at key decision moments, pause and ask ${childName} what should happen next or present a choice.
- Incorporate their responses naturally and creatively to shape the ongoing narrative.
- Conclude the story positively, reinforcing curiosity, creativity, kindness, or bravery.
Chat History:
${chatHistory}
Let's begin the adventure now!
`;
};
const getDoctorGuidanceTemplate = async ({user, supabase, timestamp}: IPayload) => {
const chatHistory = await getDoctorGuidanceHistory(supabase, user.user_id);
const userMetadata = user.user_info.user_metadata as IDoctorMetadata;
const doctorName = userMetadata.doctor_name || 'Doctor';
const hospitalName = userMetadata.hospital_name || 'An amazing hospital';
const specialization = userMetadata.specialization || 'general medicine';
return `
- You are talking to the doctor. Your physical form is actually a medical wellness toy for children.
- The doctor will either ask you questions or give you instructions on how to help this child.
- You must respond in a concise conversational style.
Your voice:
- Talk in a serious, sincere and professional tone.
- Do not add exclamations or excited words.
Current time:
${new Date(timestamp).toLocaleString()}.
Chat history with the doctor:
${chatHistory}
Doctor background:
The doctor's name is ${doctorName} and the hospital is ${hospitalName}. The doctor is a specialist in ${specialization}.
`
};
const createSystemPrompt = async (
payload: IPayload,
): Promise<string> => {
const { user, supabase, timestamp } = payload;
const chatHistory = await getChatHistory(supabase, user.user_id, user.personality?.key ?? null);
const commonPrompt = getCommonPromptTemplate(chatHistory, user, timestamp);
const isStory = user.personality?.is_story;
if (isStory) {
const storyPrompt = getStoryPromptTemplate(user, chatHistory);
return storyPrompt;
}
let systemPrompt;
switch (user.user_info.user_type) {
case 'user':
systemPrompt = UserPromptTemplate(user);
break;
case 'doctor':
systemPrompt = DoctorPromptTemplate(user);
break;
default:
throw new Error('Invalid user type');
}
return systemPrompt + commonPrompt;
};
/**
* Decrypts an encrypted secret with the same master encryption key.
* @param encryptedData - base64 string from the database
* @param iv - base64 IV from the database
* @param masterKey - 32-byte string or buffer
* @returns the original plaintext secret
*/
function decryptSecret(encryptedData: string, iv: string, masterKey: string) {
// Decode the base64 master key
const decodedKey = Buffer.from(masterKey, 'base64');
if (decodedKey.length !== 32) {
throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.');
}
const decipher = crypto.createDecipheriv(
'aes-256-cbc' as any,
Buffer.from(masterKey, 'base64') as any,
Buffer.from(iv, 'base64') as any
);
let decrypted = decipher.update(encryptedData, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
const getOpenAiApiKey = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase
.from('api_keys')
.select('encrypted_key, iv')
.eq('user_id', userId)
.single();
if (error) throw error;
const { encrypted_key, iv } = data;
const masterKey = process.env.ENCRYPTION_KEY!;
const decryptedKey = decryptSecret(encrypted_key, iv, masterKey);
return decryptedKey;
};
export async function GET(request: NextRequest) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const dbUser = await getUserById(supabase, user.id);
if (!dbUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const isDoctor = dbUser.user_info.user_type === "doctor";
const openAiApiKey = await getOpenAiApiKey(supabase, user.id);
const systemPrompt = isDoctor ? await getDoctorGuidanceTemplate({ user: dbUser, supabase, timestamp: new Date().toISOString() }) : await createSystemPrompt({ user: dbUser, supabase, timestamp: new Date().toISOString() });
try {
const response = await fetch(
"https://api.openai.com/v1/realtime/sessions",
{
method: "POST",
headers: {
Authorization: `Bearer ${openAiApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: 'gpt-4o-mini-realtime-preview-2024-12-17',
instructions: systemPrompt,
voice: isDoctor ? 'ballad' : dbUser.personality?.oai_voice ?? 'ballad',
}),
}
);
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error in /session:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,47 @@
import { createUser, doesUserExist } from "@/db/users";
import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";
import { defaultPersonalityId, defaultToyId } from "@/lib/data";
import { getBaseUrl } from "@/lib/utils";
export async function GET(request: Request) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the SSR package. It exchanges an auth code for the user's session.
// https://supabase.com/docs/guides/auth/server-side/nextjs
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const queryParamsToyId = requestUrl.searchParams.get("toy_id");
// const origin = requestUrl.origin;
// const origin = "http://localhost:3000";
const origin = getBaseUrl();
if (code) {
const supabase = createClient();
await supabase.auth.exchangeCodeForSession(code);
const {
data: { user },
} = await supabase.auth.getUser();
// console.log("user+++++", user);
if (user) {
// console.log("user+++++2", user);
const userExists = await doesUserExist(supabase, user);
if (!userExists) {
// Create user if they don't exist
await createUser(supabase, user, {
language_code: "en-US",
personality_id:
user?.user_metadata?.personality_id ??
defaultPersonalityId,
});
return NextResponse.redirect(`${origin}/onboard`);
}
}
}
// URL to redirect to after sign up process completes
return NextResponse.redirect(`${origin}/home`);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function Home() {
redirect("/healthcare");
}

View file

@ -0,0 +1,92 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Check, Key, Trash } from "lucide-react";
import { checkIfUserHasApiKey, storeUserApiKey, deleteUserApiKey } from "../actions";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
interface AuthTokenModalProps {
user: IUser;
userHasApiKey: () => void;
hasApiKey: boolean;
setHasApiKey: (hasApiKey: boolean) => void;
}
const AuthTokenModal: React.FC<AuthTokenModalProps> = ({ user, userHasApiKey, hasApiKey, setHasApiKey }) => {
const { toast } = useToast();
const [apiKey, setApiKey] = useState<string>("");
return (
<Dialog>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex flex-row items-center gap-2"
onClick={async () => {
userHasApiKey();
}}
>
<Key size={16} />
<span>Set your key</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Set your OpenAI API Key</DialogTitle>
<DialogDescription>
This key is kept encrypted and never stored on our servers as plain text.
</DialogDescription>
</DialogHeader>
<div className="flex flex-row gap-2 py-4">
<Input
id="api_key"
value={apiKey}
disabled={hasApiKey}
placeholder={hasApiKey ? "sk-... (OpenAI API Key already set)" : "sk-..."}
onChange={(e) => {
setApiKey(e.target.value);
}}
/>
<Button
size="icon"
variant="ghost"
// disabled={!apiKey || hasApiKey}
onClick={async () => {
if (!hasApiKey) {
await storeUserApiKey(user.user_id, apiKey);
setHasApiKey(true); // Set this immediately
userHasApiKey();
toast({
description: "OpenAI API Key added",
});
setApiKey("********************");
} else {
await deleteUserApiKey(user.user_id);
setHasApiKey(false);
userHasApiKey();
toast({
description: "OpenAI API Key removed",
});
}
}}
className="flex-shrink-0"
>
{!hasApiKey ?<Check className="flex-shrink-0" size={18} /> : <Trash className="flex-shrink-0" size={18} />}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AuthTokenModal;

View file

@ -0,0 +1,36 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
getAssistantAvatar,
getPersonalityImageSrc,
getUserAvatar,
} from "@/lib/utils";
interface ChatAvatarProps {
role: string;
user: IUser;
}
const ChatAvatar: React.FC<ChatAvatarProps> = ({
role,
user,
}) => {
const imageSrc: string =
role === "input"
? getUserAvatar(user.avatar_url)
: `/personality/${user.personality?.key}.jpeg`;
return (
<Avatar className="h-10 w-10">
<AvatarImage
src={imageSrc}
alt="@shadcn"
className="object-contain"
/>
<AvatarFallback className="text-sm">
{user.email.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
);
};
export default ChatAvatar;

View file

@ -0,0 +1,452 @@
"use client";
import React, { useState } from "react";
import { updateUser } from "@/db/users";
import { createClient } from "@/utils/supabase/client";
import HomePageSubtitles from "./../HomePageSubtitles";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react";
import Twemoji from "react-twemoji";
import { createPersonality } from "../../actions";
import { v4 as uuidv4 } from 'uuid';
import { toast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { emotionOptions, r2UrlAudio, voices } from "@/lib/data";
import EmojiComponent from "./EmojiComponent";
interface SettingsDashboardProps {
selectedUser: IUser;
allLanguages: ILanguage[];
}
const formSchema = z.object({
title: z.string().min(2, "Minimum 2 characters").max(50, "Maximum 50 characters"),
description: z.string().min(50, "Minimum 50 characters").max(200, "Maximum 200 characters"),
prompt: z.string().min(100, "Minimum 100 characters").max(1000, "Maximum 1000 characters"),
voice: z.string().min(1, "Voice selection is required"),
voiceCharacteristics: z.object({
features: z.string().min(10, "Minimum 10 characters").max(150, "Maximum 150 characters"),
emotion: z.string()
})
});
type FormData = z.infer<typeof formSchema>;
const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
selectedUser,
allLanguages,
}) => {
const supabase = createClient();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<'personality' | 'voice'>('personality');
const [isSubmitting, setIsSubmitting] = useState(false);
const [languageState, setLanguageState] = useState<string>(
selectedUser.language_code! // Initial value from props
);
const [formData, setFormData] = useState({
title: '',
description: '',
prompt: '',
voice: '',
voiceCharacteristics: {
features: '',
emotion: 'neutral'
}
});
const [touchedFields, setTouchedFields] = useState<Record<string, boolean>>({});
const [formErrors, setFormErrors] = useState<Partial<Record<keyof FormData | 'features', string>>>({});
const [previewingVoice, setPreviewingVoice] = useState<string | null>(null);
const handleBlur = (field: keyof FormData | 'features') => {
// Mark the field as touched
setTouchedFields(prev => ({ ...prev, [field]: true }));
// Validate the field
if (field === 'features') {
validateField(field, formData.voiceCharacteristics.features);
} else {
validateField(field, formData[field] as string);
}
};
const validateField = (field: keyof FormData | 'features', value: string) => {
try {
if (field === 'features') {
formSchema.shape.voiceCharacteristics.shape.features.parse(value);
} else if (field === 'voiceCharacteristics') {
formSchema.shape.voiceCharacteristics.parse(value);
} else {
formSchema.shape[field].parse(value);
}
// Clear error if validation passes
setFormErrors(prev => ({ ...prev, [field]: undefined }));
} catch (error: unknown) {
if (error instanceof z.ZodError) {
const zodError = error as z.ZodError;
setFormErrors(prev => ({ ...prev, [field]: zodError.errors[0].message }));
}
}
};
const handleInputChange = (field: keyof FormData, value: string) => {
const newFormData = { ...formData, [field]: value };
setFormData(newFormData);
// Only validate if the field has been touched before
if (touchedFields[field]) {
validateField(field, value);
}
};
const handleVoiceCharacteristicChange = (characteristic: 'features' | 'emotion', value: string) => {
const newVoiceCharacteristics = {
...formData.voiceCharacteristics,
[characteristic]: value
};
// Validate just this nested field
try {
formSchema.shape.voiceCharacteristics.shape[characteristic].parse(value);
// Clear error if validation passes
setFormErrors(prev => ({ ...prev, [characteristic]: undefined }));
} catch (error: unknown) {
if (error instanceof z.ZodError) {
// Type assertion is needed here
const zodError = error as z.ZodError;
setFormErrors(prev => ({ ...prev, [characteristic]: zodError.errors[0].message }));
}
}
setFormData({
...formData,
voiceCharacteristics: newVoiceCharacteristics
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Set submitting state to true
setIsSubmitting(true);
// Validate the entire form
const result = formSchema.safeParse(formData);
console.log(result);
if (!result.success) {
// Extract and set all validation errors
const errors: Partial<Record<keyof FormData | 'features', string>> = {};
result.error.errors.forEach(err => {
const path = err.path.join('.');
if (path === 'voiceCharacteristics.features') {
errors['features'] = err.message;
} else {
errors[err.path[0] as keyof FormData] = err.message;
}
});
setFormErrors(errors);
setIsSubmitting(false); // Reset submitting state
return;
}
try {
const personality = await createPersonality(selectedUser.user_id, {
title: formData.title,
subtitle: "",
character_prompt: formData.prompt,
oai_voice: formData.voice as OaiVoice,
voice_prompt: formData.voiceCharacteristics.features + "\nThe voice should be " + formData.voiceCharacteristics.emotion,
is_doctor: false,
is_child_voice: false,
is_story: false,
key: formData.title.toLowerCase().replace(/ /g, '_') + "_" + uuidv4(),
creator_id: selectedUser.user_id,
short_description: formData.description
});
if (personality) {
toast({
title: "New AI Character created",
description: "Your character has been created!",
duration: 3000,
});
router.push(`/home`);
}
} catch (error) {
console.error("Error creating personality:", error);
toast({
title: "Error",
description: "Failed to create your character. Please try again.",
variant: "destructive",
duration: 3000,
});
} finally {
setIsSubmitting(false); // Reset submitting state
}
};
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const previewVoice = (voiceId: string) => {
// Stop any currently playing preview
if (audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
}
const audioSampleUrl = `${r2UrlAudio}/${voiceId}.wav`;
setPreviewingVoice(voiceId);
// Create and play audio element
const audio = new Audio(audioSampleUrl);
setAudioElement(audio);
// Play the audio
audio.play().catch(error => {
console.error("Error playing audio:", error);
setPreviewingVoice(null);
});
// Reset the previewing state when audio ends
audio.onended = () => {
setPreviewingVoice(null);
};
// Fallback in case audio doesn't trigger onended
setTimeout(() => {
if (previewingVoice === voiceId) {
setPreviewingVoice(null);
}
}, 10000); // 10 second fallback
};
const Heading = () => {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-4 items-center sm:justify-normal justify-between max-w-screen-sm">
<div className="flex flex-row gap-4 items-center justify-between w-full">
<h1 className="text-3xl font-normal">Create your AI Character</h1>
</div>
</div>
<HomePageSubtitles user={selectedUser} page="create" />
</div>
);
};
return (
<div className="overflow-hidden pb-2 w-full flex-auto flex flex-col pl-1 max-w-screen-sm">
<Heading />
<form onSubmit={handleSubmit} className="space-y-6 mt-8 w-full pr-1">
{currentStep === 'personality' ? <div className="space-y-4">
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
Character Details
</h2>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="E.g., 'Storytelling Assistant'"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
onBlur={() => handleBlur('title')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.title ? "text-red-500" : "text-gray-500"}>
{formErrors.title || "Give your AI character a name or title."}
</span>
<span className="text-gray-500">{formData.title.length}/50</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe what your AI character does and its personality..."
rows={2}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
onBlur={() => handleBlur('description')} />
<p className="text-sm flex justify-between">
<span className={formErrors.description ? "text-red-500" : "text-gray-500"}>
{formErrors.description || "Briefly describe your character's purpose and personality."}
</span>
<span className="text-gray-500">{formData.description.length}/200</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt">Prompt</Label>
<Textarea
id="prompt"
placeholder="Enter specific instructions for how your AI should respond..."
rows={4}
value={formData.prompt}
onChange={(e) => handleInputChange('prompt', e.target.value)}
onBlur={() => handleBlur('prompt')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.prompt ? "text-red-500" : "text-gray-500"}>
{formErrors.prompt || "Detailed instructions that define how your AI responds to users."}
</span>
<span className="text-gray-500">{formData.prompt.length}/1000</span>
</p>
</div>
</div> :
<div className="space-y-4">
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
Voice Details
</h2>
<div className="space-y-2">
<Label htmlFor="voice">Pick a voice</Label>
<p className="text-sm text-gray-500">
Click a voice to preview how it sounds. Select one for your character.
</p>
<div className="grid grid-cols-3 gap-3">
{voices.map((voice) => (
<div
key={voice.id}
className={`
rounded-lg border p-3 transition-all relative
${formData.voice === voice.id
? 'border-2 border-blue-500 shadow-sm ' + voice.color
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
}
`}
onClick={() => {
handleInputChange('voice', voice.id);
previewVoice(voice.id);
}}
>
<div className="flex flex-col">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
<div className="text-2xl mt-0.5">
<EmojiComponent emoji={voice.emoji} />
</div>
<div className="flex flex-col text-center sm:text-left">
<span className="font-medium">{voice.name}</span>
<span className="text-xs text-gray-600">{voice.description}</span>
</div>
</div>
{previewingVoice === voice.id && (
<div className="absolute top-2 right-2">
<div className="animate-pulse text-blue-500">
<Volume2 size={20} />
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="voiceCharacteristics">Characteristics</Label>
<Textarea
id="voiceCharacteristics"
placeholder="e.g., Medium pitch, Normal speed, Clear voice"
className="w-full min-h-16"
rows={2}
value={formData.voiceCharacteristics.features}
onChange={(e) => {
const value = e.target.value;
setFormData(prev => ({
...prev,
voiceCharacteristics: {
...prev.voiceCharacteristics,
features: value
}
}));
// Only validate if touched
if (touchedFields['features']) {
validateField('features', value);
}
}}
onBlur={() => handleBlur('features')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.features ? "text-red-500" : "text-gray-500"}>
{formErrors.features || "Describe the voice characteristics."}
</span>
<span className="text-gray-500">{formData.voiceCharacteristics.features.length}/150</span>
</p>
</div>
<div className="space-y-3">
<Label className="block mb-2">Emotional Tone</Label>
<div className="grid grid-cols-3 gap-3">
{emotionOptions.map((emotion) => (
<div
key={emotion.value}
className={`
rounded-lg border p-3 cursor-pointer transition-all
${formData.voiceCharacteristics.emotion === emotion.value
? 'border-2 border-blue-500 shadow-sm ' + emotion.color
: 'border-gray-200 hover:border-gray-300'
}
`}
onClick={() => handleVoiceCharacteristicChange('emotion', emotion.value)}
>
<div className="flex flex-col items-center text-center">
<EmojiComponent emoji={emotion.icon} />
<span className="text-sm font-medium">{emotion.label}</span>
</div>
</div>
))}
</div>
</div>
</div>}
{currentStep === 'personality' ? (
<Button
onClick={() => setCurrentStep('voice')}
className="ml-auto flex flex-row gap-2 items-center"
>
Add Voice Features <ArrowRight className="w-4 h-4" />
</Button>
) : (
<div className="w-full flex justify-between">
<Button
variant="outline"
className="flex flex-row gap-2 items-center"
onClick={() => setCurrentStep('personality')}
>
<ArrowLeft className="w-4 h-4" /> Back
</Button>
<Button
variant="default"
className="flex flex-row gap-2 items-center"
type="submit"
disabled={
isSubmitting ||
formData.title === '' ||
formData.description === '' ||
formData.prompt === '' ||
formData.voice === '' ||
formData.voiceCharacteristics.features === ''
}
>
{isSubmitting ? "Creating..." : "Create"} {!isSubmitting && <Check className="w-4 h-4" />}
</Button>
</div>
)}
</form>
</div>
);
};
export default SettingsDashboard;

View file

@ -0,0 +1,15 @@
import Twemoji from "react-twemoji";
const EmojiComponent = ({ emoji }: { emoji: string | undefined }) => {
return (
<div className="w-7 h-7 flex items-center justify-center">
<Twemoji
options={{ className: "twemoji w-7 h-7 flex-shrink-0" }}
>
{emoji}
</Twemoji>
</div>
);
};
export default EmojiComponent;

View file

@ -0,0 +1,44 @@
"use client";
import { Button } from "@/components/ui/button";
import { getCreditsRemaining } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import AddCreditsModal from "./Upsell/AddCreditsModal";
const CreditsRemaining: React.FC<{
user: IUser;
languageCode: string;
}> = ({ user, languageCode }) => {
const creditsRemaining = getCreditsRemaining(user);
const hasNoCredits = creditsRemaining <= 0;
if (user.is_premium) {
return null;
}
return (
<div className="flex flex-row items-center gap-4">
<p
className={`text-sm ${hasNoCredits ? "text-gray-400" : "text-gray-400"}`}
>
{creditsRemaining} {"credits remaining"}
</p>
{creditsRemaining <= 50 && (
<AddCreditsModal>
<Button
variant="upsell_link"
className="flex flex-row items-center gap-2 pl-0"
size="sm"
>
<Sparkles size={16} />
{hasNoCredits
? "Upgrade to continue"
: "Get unlimited access"}
</Button>
</AddCreditsModal>
)}
</div>
);
};
export default CreditsRemaining;

View file

@ -0,0 +1,93 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { expressionColors, isExpressionColor } from "@/lib/expressionColors";
import { motion } from "framer-motion";
import { CSSProperties } from "react";
import * as R from "remeda";
export default function Expressions({
values,
}: {
values: Record<string, number>;
}) {
const top3 = R.pipe(
values,
R.entries(),
R.sortBy(R.pathOr([1], 0)),
R.reverse(),
R.take(3)
);
return (
<div
className={
"text-xs p-3 w-full border-t border-border flex flex-col md:flex-row gap-3 bg-stone-50 rounded-b-[7px]"
}
>
{top3.map(([key, value], index) => (
<div className={"w-full overflow-hidden"} key={index}>
<div
className={"flex items-center justify-between gap-1 font-mono pb-1"}
>
{/* <Badge
variant="outline"
className="font-medium truncate text-xs opacity-70 text-white"
style={
}
>
{key}
</Badge> */}
<div className="flex flex-row items-center gap-2 px-2">
<div
className="rounded-full w-4 h-4 opacity-70"
style={
{
backgroundColor: isExpressionColor(key)
? expressionColors[key]
: "var(--bg)",
} as CSSProperties
}
/>
<div className={"font-medium truncate"}>{key}</div>
<div className={"tabular-nums text-xs opacity-40"}>
{Math.round(100 * value)}%
</div>
</div>
{/* */}
</div>
{/* <div
className={"relative h-1"}
style={
{
"--bg": isExpressionColor(key)
? expressionColors[key]
: "var(--bg)",
} as CSSProperties
}
>
<div
className={
"absolute top-0 left-0 size-full rounded-full opacity-10 bg-[var(--bg)]"
}
/>
<motion.div
className={
"absolute top-0 left-0 h-full bg-[var(--bg)] rounded-full"
}
initial={{ width: 0 }}
animate={{
width: `${R.pipe(
value,
R.clamp({ min: 0, max: 1 }),
(value) => `${value * 100}%`
)}`,
}}
/>
</div> */}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Mail } from "lucide-react";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { Separator } from "@/components/ui/separator";
import { FaDiscord, FaGithub } from "react-icons/fa";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
discordInviteLink,
githubPublicLink,
feedbackFormLink,
} from "@/lib/data";
import { useMediaQuery } from "@/hooks/useMediaQuery";
export default function Footer() {
const pathname = usePathname();
const isHome = pathname.includes("/home");
const isMobile = useMediaQuery("(max-width: 768px)");
const isRoot = pathname === "/";
return (
<footer
className={`w-full ${
isHome ? "pb-16" : "pb-2"
} ${
isRoot ? "bg-gradient-to-r bg-purple-900 text-white border-transparent" : "border-gray-200"
} flex flex-col sm:flex-row items-center sm:justify-center border-t-[1px] mx-auto text-center text-xs sm:gap-8 sm:py-1 py-2`}
>
<div className="flex flex-row items-center gap-8">
<a href={feedbackFormLink} target="_blank">
<Button
variant="link"
size="sm"
className="font-normal text-grey-700 text-xs"
aria-label="Mail"
>
<Mail size={18} className="mr-2" />
Send feedback
</Button>
</a>
<Label className={`font-normal text-xs ${isRoot ? "text-gray-100" : "text-gray-500"}`}>
Elato AI © {new Date().getFullYear()} All rights
reserved.
</Label>
</div>
{/* <Separator orientation="vertical" /> */}
<div
className={`flex-row items-center gap-8 ${
isHome && isMobile ? "hidden" : "flex"
}`}
>
<div className="flex flex-row items-center gap-2">
<Link href={githubPublicLink} passHref>
<Button
variant="ghost"
size="icon"
className={`w-7 h-7 ${isRoot ? "text-gray-100" : "text-gray-500"}`}
>
<FaGithub />
</Button>
</Link>
<Link href={discordInviteLink} passHref>
<Button
variant="ghost"
size="icon"
className={`w-7 h-7 ${isRoot ? "text-gray-100" : "text-gray-500"}`}
>
<FaDiscord />
</Button>
</Link>
</div>
<a
href="/privacy"
className={`font-normal underline ${isRoot ? "text-gray-100" : "text-gray-500"} text-xs`}
>
Privacy Policy
</a>
<a
href="/terms"
className={`font-normal underline ${isRoot ? "text-gray-100" : "text-gray-500"} text-xs`}
>
Terms of Service
</a>
</div>
{/* <ThemeSwitcher /> */}
</footer>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { SendHorizonal } from "lucide-react";
import { cn } from "@/lib/utils";
import { businessDemoLink } from "@/lib/data";
interface PreorderButtonProps {
size: "sm" | "lg";
className?: string;
iconOnMobile?: boolean;
}
const GetInTouchButton = ({
size,
className,
iconOnMobile,
}: PreorderButtonProps) => {
return (
<Link href={businessDemoLink} passHref>
<Button
className={cn(
"flex flex-row items-center gap-4 font-medium text-base bg-stone-800 leading-8 rounded-full",
iconOnMobile ? "px-3" : "px-4",
className
)}
size={size}
>
{<SendHorizonal size={18} strokeWidth={3} />}
{!iconOnMobile && <span>Get in touch</span>}
</Button>
</Link>
);
};
export default GetInTouchButton;

View file

@ -0,0 +1,52 @@
"use client";
import { FaGoogle } from "react-icons/fa";
import { Button } from "@/components/ui/button";
import { createClient } from "@/utils/supabase/client";
import { defaultPersonalityId, defaultToyId } from "@/lib/data";
interface GoogleLoginButtonProps {
toy_id?: string;
personality_id?: string;
}
export const loginWithGoogle = async (
toy_id: string,
personality_id: string
) => {
const supabase = createClient();
const redirectTo = `${location.origin}/auth/callback`;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo,
queryParams: {
toy_id,
personality_id,
},
},
});
};
export default function GoogleLoginButton({
toy_id,
personality_id,
}: GoogleLoginButtonProps) {
// console.log("1324355345435", toy_id);
return (
<Button
variant="default"
onClick={() =>
loginWithGoogle(
toy_id ?? defaultToyId,
personality_id ?? defaultPersonalityId
)
}
>
<FaGoogle className="w-4 h-4 mr-4" />
<span>Continue with Google</span>
</Button>
);
}

View file

@ -0,0 +1,45 @@
import CreditsRemaining from "./CreditsRemaining";
interface HomePageSubtitlesProps {
user: IUser;
page: "home" | "settings" | "create";
languageCode?: string;
}
const HomePageSubtitles: React.FC<HomePageSubtitlesProps> = ({
user,
page,
languageCode = "en-US",
}) => {
if (page === "home") {
if (user.user_info.user_type === "doctor") {
return (
<p className="text-sm text-gray-600">
{"Use this playground or your device to engage your patients"}
</p>
);
} else {
return (
<p className="text-sm text-gray-600">
{"Talk to any AI character below"}
</p>
);
}
} else if (page === "settings") {
return (
<p className="text-sm text-gray-600">
{"You can update your settings below"}
</p>
);
} else if (page === "create") {
return (
<p className="text-sm text-gray-600">
{"Customize your character's voice, language, accent and much more"}
</p>
);
}
// if they are a regular user
// return <CreditsRemaining user={user} languageCode={languageCode} />;
};
export default HomePageSubtitles;

View file

@ -0,0 +1,136 @@
"use client";
// install (please try to align the version of installed @nivo packages)
// yarn add @nivo/bar
import { ResponsiveBar } from "@nivo/bar";
import { FC } from "react";
// make sure parent container have a defined height when using
// responsive component, otherwise height will be 0 and
// no chart will be rendered.
// website examples showcase many properties,
// you'll often use just a few of them.
type MyResponsiveBarProps = {
data: BarData[];
filter: string;
};
export const MyResponsiveBar: FC<MyResponsiveBarProps> = ({ data, filter }) => {
// Determine the keys based on the filter
let currentPeriodLabel = "Current Period";
let previousPeriodLabel = "Previous Period";
if (filter === "days") {
currentPeriodLabel = "Today";
previousPeriodLabel = "Yesterday";
} else if (filter === "weeks") {
currentPeriodLabel = "This month";
previousPeriodLabel = "Last month";
}
const keys = [currentPeriodLabel, previousPeriodLabel];
return (
<ResponsiveBar
data={data}
keys={keys}
indexBy="emotion"
margin={{ top: 20, right: 54, bottom: 80, left: 55 }}
padding={0.3}
groupMode="grouped"
valueScale={{ type: "linear" }}
indexScale={{ type: "band", round: true }}
// // colors={{ scheme: "nivo" }}
colors={["#fbbf24", "#c084fc"]}
borderRadius={5}
borderColor={{
from: "color",
modifiers: [["darker", 1.6]],
}}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 5,
tickPadding: 5,
tickRotation: 30,
// legend: "country",
legendPosition: "middle",
legendOffset: 32,
truncateTickAt: 0,
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: "Score (%)",
legendPosition: "middle",
legendOffset: -50,
truncateTickAt: 0,
}}
enableGridY={true}
labelSkipWidth={20}
labelSkipHeight={12}
labelTextColor="white"
// // labelTextColor={{
// // from: "color",
// // modifiers: [["brighter", 3]],
// // }}
legends={[
{
dataFrom: "keys",
anchor: "bottom",
direction: "row",
justify: false,
translateX: 0,
translateY: 80,
itemsSpacing: 0,
itemWidth: 100,
itemHeight: 20,
itemDirection: "left-to-right",
itemOpacity: 1,
symbolSize: 16,
symbolShape: "circle",
effects: [
{
on: "hover",
style: {
itemTextColor: "#000",
},
},
],
},
]}
// role="application"
// ariaLabel="Nivo bar chart demo"
barAriaLabel={(e) =>
`${e.id}: ${e.formattedValue} in emotion: ${e.indexValue}`
}
theme={{
axis: {
legend: {
text: {
fontSize: 12,
fontWeight: 600,
fill: "#4b5563",
},
},
ticks: {
text: {
fontSize: 12,
fontWeight: 500,
fill: "#4b5563",
},
},
},
legends: {
text: {
fontSize: 12,
fontWeight: 600,
fill: "#4b5563",
},
},
}}
/>
);
};

View file

@ -0,0 +1,107 @@
"use client";
// install (please try to align the version of installed @nivo packages)
// yarn add @nivo/heatmap
import { ResponsiveHeatMap } from "@nivo/heatmap";
import { FC } from "react";
// make sure parent container have a defined height when using
// responsive component, otherwise height will be 0 and
// no chart will be rendered.
// website examples showcase many properties,
// you'll often use just a few of them.
type MyResponsiveHeatMapProps = {
data: HeatMapData[];
};
export const MyResponsiveHeatMap: FC<MyResponsiveHeatMapProps> = ({ data }) => (
<ResponsiveHeatMap
data={data}
margin={{ top: 60, right: 10, bottom: 10, left: 70 }}
valueFormat=">-.2s"
axisTop={{
tickSize: 5,
tickPadding: 8,
tickRotation: -45,
legend: "",
legendOffset: 46,
truncateTickAt: 0,
}}
// axisRight={{
// tickSize: 5,
// tickPadding: 5,
// tickRotation: 0,
// // legend: "country",
// // legendPosition: "middle",
// legendOffset: 70,
// truncateTickAt: 0,
// }}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
// legend: "country",
// legendPosition: "middle",
legendOffset: -70,
truncateTickAt: 0,
}}
colors={{
type: "diverging",
scheme: "yellow_green",
minValue: -100000,
maxValue: 100000,
divergeAt: 0.82,
}}
emptyColor="#555555"
cellComponent="circle"
sizeVariation={{
sizes: [0.3, 0.97],
}}
// forceSquare
enableGridX={true}
enableGridY={true}
borderWidth={3}
borderColor="#fdba74"
// borderColor={{
// from: "color",
// modifiers: [["darker", 0.5]],
// }}
// legends={[
// {
// anchor: "bottom",
// translateX: 0,
// translateY: 40,
// length: 400,
// thickness: 15,
// direction: "row",
// tickPosition: "after",
// tickSize: 3,
// tickSpacing: 4,
// tickOverlap: false,
// tickFormat: ">-.2s",
// title: "Value →",
// titleAlign: "start",
// titleOffset: 4,
// },
// ]}
theme={{
axis: {
ticks: {
text: {
fontSize: 12, // Change this to your desired font size
fontWeight: 600,
fill: "#4b5563", // Change this to your desired text color
},
},
legend: {
text: {
fontSize: 12, // Change this to your desired font size
fontWeight: 600,
fill: "#1f2937", // Change this to your desired text color
},
},
},
}}
/>
);

View file

@ -0,0 +1,128 @@
"use client";
import { ResponsiveLine } from "@nivo/line";
import { FC } from "react";
// make sure parent container have a defined height when using
// responsive component, otherwise height will be 0 and
// no chart will be rendered.
// website examples showcase many properties,
// you'll often use just a few of them.
type MyResponsiveLineProps = {
data: LineChartData[];
};
export const MyResponsiveLine: FC<MyResponsiveLineProps> = ({ data }) => (
<ResponsiveLine
curve="monotoneX"
lineWidth={3}
margin={{ top: 20, right: 54, bottom: 68, left: 50 }}
data={data}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
}}
yFormat=" >-.2f"
axisTop={null}
axisRight={null}
axisBottom={{
// format: "%b %d",
tickSize: 5,
tickPadding: 5,
tickRotation: 30,
// legend: "Date",
legendOffset: 36,
legendPosition: "middle",
truncateTickAt: 0,
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: "Score (%)",
legendOffset: -45,
legendPosition: "middle",
truncateTickAt: 0,
}}
enableTouchCrosshair={true}
pointSize={10}
pointColor={{ from: "color", modifiers: [] }}
pointBorderWidth={2}
pointBorderColor={{ from: "serieColor" }}
pointLabel="data.yFormatted"
pointLabelYOffset={-12}
useMesh={true}
colors={[
"#fb7185",
// "#FCCCD4",
"#a8a29e",
// "#e7e5e4",
"#22c55e",
// "#CCFBDD",
]}
legends={[
{
anchor: "bottom",
direction: "row",
justify: false,
translateX: -20,
translateY: 70,
itemsSpacing: 10,
itemDirection: "left-to-right",
itemWidth: 65,
itemHeight: 20,
itemOpacity: 1,
symbolSize: 16,
symbolShape: "circle",
symbolBorderColor: "rgba(0, 0, 0, .9)",
effects: [
{
on: "hover",
style: {
itemBackground: "rgba(0, 0, 0, .03)",
itemOpacity: 1,
},
},
],
},
]}
tooltip={({ point }) => {
const series = data.find((serie) => serie.id === point.serieId);
return (
<div className="bg-white p-2 rounded-lg shadow-md text-sm border">
<strong>{series?.name}</strong>
<br />
{/* <strong>id:</strong> {point.data.xFormatted} */}
{/* <br /> */}
<strong>score:</strong> {point.data.yFormatted}
</div>
);
}}
theme={{
axis: {
legend: {
text: {
fontSize: 12,
fontWeight: 600,
fill: "#4b5563",
},
},
ticks: {
text: {
fontSize: 12,
fontWeight: 500,
fill: "#4b5563",
},
},
},
legends: {
text: {
fontSize: 12,
fontWeight: 600,
fill: "#4b5563",
},
},
}}
/>
);

View file

@ -0,0 +1,125 @@
"use client";
// install (please try to align the version of installed @nivo packages)
// yarn add @nivo/bar
import { ResponsivePie } from "@nivo/pie";
import { FC } from "react";
// make sure parent container have a defined height when using
// responsive component, otherwise height will be 0 and
// no chart will be rendered.
// website examples showcase many properties,
// you'll often use just a few of them.
// const favorit = localFont({
// src: "./fonts/ABCFavorit-Bold.woff2",
// variable: "--font-favorit",
// });
// const fonts = `${favorit.variable}`;
type MyResponsivePieProps = {
data: PieData[];
};
// Define the theme object
const theme = {
legends: {
text: {
fontSize: 12, // Change this value to your desired font size
fontWeight: 600,
// fontFamily: fonts,
},
},
labels: {
text: {
fontSize: 16, // Change this value to your desired font size for the labels inside the pie
fontWeight: 700,
// fontFamily: fonts,
},
},
};
export const MyResponsivePie: FC<MyResponsivePieProps> = ({ data }) => (
<ResponsivePie
data={data}
margin={{ top: 15, right: 5, bottom: 40, left: 5 }}
innerRadius={0.5}
padAngle={0.7}
cornerRadius={3}
activeOuterRadiusOffset={4}
borderWidth={1}
borderColor={{
from: "color",
modifiers: [["darker", 0.2]],
}}
enableArcLinkLabels={false}
arcLinkLabelsSkipAngle={10}
arcLinkLabelsTextColor="#333333"
arcLinkLabelsThickness={2}
arcLinkLabelsColor={{ from: "color" }}
arcLabelsSkipAngle={10}
arcLabelsTextColor="#fff"
arcLabel={(d) => `${d.value.toFixed(2)}%`} // Display value in percentage
// defs={[
// {
// id: "dots",
// type: "patternDots",
// background: "inherit",
// color: "rgba(255, 255, 255, 0.3)",
// size: 4,
// padding: 1,
// stagger: true,
// },
// {
// id: "lines",
// type: "patternLines",
// background: "inherit",
// color: "rgba(255, 255, 255, 0.3)",
// rotation: -45,
// lineWidth: 6,
// spacing: 10,
// },
// ]}
fill={[
{
match: {
id: "Negative",
},
id: "dots",
},
{
match: {
id: "Positive",
},
id: "lines",
},
]}
legends={[
{
anchor: "bottom",
direction: "row",
justify: false,
translateX: 2,
translateY: 40,
itemsSpacing: 4,
itemWidth: 80,
itemHeight: 18,
itemTextColor: "#4b5563",
itemDirection: "left-to-right",
itemOpacity: 1,
symbolSize: 16,
symbolShape: "circle",
effects: [
{
on: "hover",
style: {
itemTextColor: "#000",
},
},
],
},
]}
theme={theme} // Apply the theme here
colors={["#4ade80", "#d6d3d1", "#fb7185"]}
/>
);

View file

@ -0,0 +1,79 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { TrendingUp } from "lucide-react";
import { TrendingDown } from "lucide-react";
interface CardProps {
title: string | null;
value: number | string | null;
delta: number | null;
filter: string;
type: string | null;
}
const TopCard: React.FC<CardProps> = ({
title,
value,
delta,
filter,
type,
}) => {
const isPositiveDelta = delta !== null && delta >= 0;
// get the user data from the selected user and period
const bgColor = type === "top" ? "bg-amber-500" : "bg-violet-500";
const titleColor = type === "top" ? "text-amber-50" : "text-violet-50";
const footerColor = type === "top" ? "text-amber-100" : "text-violet-100";
const getTimePeriod = (filter: string): string => {
if (filter === "weeks") return "last week";
if (filter === "days") return "yesterday";
if (filter === "months") return "last month";
return ""; // Default case, can be adjusted based on your needs
};
return (
<>
<Card className={`${bgColor}`}>
<CardHeader className="pt-4 pb-2 ">
<CardTitle
className={`text-base font-medium ${titleColor}`}
>
<div className="flex justify-between items-center w-full">
{title}
</div>
</CardTitle>
{/* <CardDescription className="text-2xl font-bold text-gray-800">
+ 1.8%
</CardDescription> */}
</CardHeader>
<CardContent className="py-1 text-2xl font-bold text-white">
<p>{value}</p>
</CardContent>
<CardFooter
className={`text-sm ${footerColor} flex items-start`}
>
{delta !== null &&
(isPositiveDelta ? (
<TrendingUp className="h-[20px] text-green-500 mr-1" />
) : (
<TrendingDown className="h-[20px] text-rose-500 mr-1" />
))}
<p>
{delta}% from {getTimePeriod(filter)}
</p>
</CardFooter>
</Card>
</>
);
};
export default TopCard;

View file

@ -0,0 +1,41 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import Typed from "typed.js";
const AnimatedText: React.FC = () => {
const el = useRef(null);
useEffect(() => {
const options = {
strings: [
'<span class="text-blue-500">with interactive storytelling</span>',
'<span class="text-purple-500">for language learning</span>',
'<span class="text-green-500">for bedtime stories</span>',
'<span class="text-orange-500">with educational adventures</span>',
'<span class="text-pink-500">for vocabulary building</span>',
'<span class="text-teal-500">as a reading companion</span>',
],
typeSpeed: 70, // Slightly faster
backSpeed: 30, // Add some backspeed for playfulness
backDelay: 800, // Shorter delay
cursorChar: '🪄', // Child-friendly cursor
fadeOut: true,
fadeOutDelay: 1,
loop: true,
};
const typed = new Typed(el.current, options);
return () => {
typed.destroy();
};
}, []);
return (
<h1 className="text-3xl font-semibold font-borel h-24 min-h-[6rem] flex items-center justify-center">
<span ref={el} className="inline-block" />
</h1>
);
};
export default AnimatedText;

View file

@ -0,0 +1,215 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Pause, Play } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { getPersonalityImageSrc } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
import { voiceSampleUrl } from "@/lib/data";
interface LandingPagePersonality {
key: string;
name: string;
description: string;
}
const ChosenPersonalities: LandingPagePersonality[] = [
{
key: "geo_guide",
name: "Travel guide",
description: "A travel guide who can help you plan your next trip.",
},
{
key: "ironman",
name: "Ironman",
description: "A superhero who can help you with your problems.",
},
{
key: "math_wiz",
name: "Your math tutor",
description: "A math tutor who can help you with your math problems.",
},
{
key: "master_chef",
name: "Master chef",
description: "A master chef who can help you with your cooking.",
},
{
key: "art_guru",
name: "Art teacher",
description: "An art guru who can help you with your art.",
},
{
key: "female_lover",
name: "Romance healer",
description:
"A muse of connection who can help you with your love life.",
},
{
key: "male_lover",
name: "Poet of love",
description: "A poet of love who can help you with your love life.",
},
{
key: "sherlock",
name: "SherlockGPT",
description: "A detective who can help you with your problems.",
},
{
key: "aggie_blood_test_pal",
name: "Medical buddy",
description:
"A medical companion helping kids feel less anxious during procedures.",
},
];
interface CharacterCarouselCardProps {
personality: LandingPagePersonality;
}
const CharacterCarouselCard = ({ personality }: CharacterCarouselCardProps) => {
const [playing, setPlaying] = useState<string | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null
);
const playAudio = (personality: LandingPagePersonality) => {
if (playing === personality.key && audioElement) {
// Pause current audio
audioElement.pause();
setPlaying(null);
setAudioElement(null);
return;
}
// Stop any currently playing audio
if (audioElement) {
audioElement.pause();
}
const audio = new Audio(`${voiceSampleUrl}${personality.key}.wav`);
audio.onended = () => {
setPlaying(null);
setAudioElement(null);
};
audio.play();
setPlaying(personality.key);
setAudioElement(audio);
};
return (
<Card className="flex-shrink-0 w-[270px] border-none bg-gray-50">
<CardContent className="flex flex-row items-start gap-2 p-0 ">
{/* Circular Avatar */}
<div className="w-[100px] h-[100px] relative rounded-lg rounded-tr-none rounded-br-none overflow-hidden flex-shrink-0">
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={100}
className="object-cover w-full h-full"
/>
<Button
variant="link"
size="icon"
className="w-fit absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
onClick={() => playAudio(personality)}
>
{playing === personality.key ? (
<Pause
size={24}
fill="white"
className="text-white"
/>
) : (
<Play
size={24}
fill="white"
className="text-white"
/>
)}
</Button>
</div>
{/* Text and Play Button */}
<div className="flex flex-col gap-1 p-2 opacity rounded-lg">
<div className="flex flex-row items-center">
<h3 className="font-semibold text-sm text-left truncate">
{personality.name}
</h3>
</div>
<p className="text-gray-600 text-xs text-left line-clamp-3">
{personality.description}
</p>
</div>
</CardContent>
</Card>
);
};
const CharacterCarousel = () => {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(true);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftShadow(scrollLeft > 0);
setShowRightShadow(scrollLeft < scrollWidth - clientWidth - 1);
}
};
useEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement) {
scrollElement.addEventListener("scroll", handleScroll);
handleScroll(); // Initial check
return () =>
scrollElement.removeEventListener("scroll", handleScroll);
}
}, []);
return (
<div className="relative max-w-screen-md ml-4 sm:mx-auto">
<div
className={`absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white to-transparent z-10 pointer-events-none transition-opacity duration-300 ${
showLeftShadow ? "opacity-100" : "opacity-0"
}`}
/>
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-hide gap-4"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{ChosenPersonalities.map((personality, index) => (
<CharacterCarouselCard
personality={personality}
key={index}
/>
))}
</div>
<div
className={`absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white to-transparent z-10 pointer-events-none transition-opacity duration-300 ${
showRightShadow ? "opacity-100" : "opacity-0"
}`}
/>
</div>
// <div className="w-full sm:max-w-screen-md mx-auto">
// <div className="overflow-x-auto scrollbar-hide">
// <div className="flex gap-4 p-4">
// {ChosenPersonalities.map((personality, index) => (
// <CharacterCarouselCard
// personality={personality}
// key={index}
// />
// ))}
// </div>
// </div>
// </div>
);
};
export default CharacterCarousel;

View file

@ -0,0 +1,95 @@
"use client";
import { useEffect } from "react";
import Image from "next/image";
import Product1 from "@/public/images/decomposation_view.gif";
import Product2 from "@/public/images/front_view.png";
// Import Swiper
import Swiper from "swiper";
import { Pagination, EffectFade } from "swiper/modules";
// import Swiper and modules styles
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/effect-fade";
import Personalities from "./Personalities";
Swiper.use([Pagination, EffectFade]);
interface CharacterPickerProps {
allPersonalities: IPersonality[];
}
export default function CharacterPicker({
allPersonalities,
}: CharacterPickerProps) {
useEffect(() => {
const character = new Swiper(".character-carousel", {
slidesPerView: 1,
watchSlidesProgress: true,
effect: "fade",
fadeEffect: {
crossFade: true,
},
pagination: {
el: ".character-carousel-pagination",
clickable: true,
},
});
}, []);
return (
<section>
<div className="relative max-w-7xl gap-8 mx-auto text-center flex flex-col items-center justify-center">
{/* Carousel */}
<div
className="w-full md:w-3/5 md:mr-8 mb-8 md:mb-0 flex-shrink-0 h-[450px] shadow-custom"
data-aos="fade-up"
data-aos-anchor="[data-aos-id-6]"
>
<div className="character-carousel swiper-container max-w-sm mx-auto sm:max-w-none h-[450px] rounded-[30px]">
<div className="swiper-wrapper">
{/* corp */}
{/* Card #1 */}
<div className="swiper-slide w-full h-full flex-shrink-0 relative">
<Image
src={Product2}
alt="Products all colors"
sizes="100vw"
fill
style={{
objectPosition: "center",
objectFit: "contain",
}}
/>
</div>
<div className="swiper-slide w-full h-full flex-shrink-0 relative">
<div className="rounded-[30px] overflow-hidden w-full h-full">
<Image
src={Product1}
alt="Product decomposition view"
sizes="100vw"
fill
style={{
objectPosition: "center",
objectFit: "contain",
}}
/>
</div>
</div>
{/* corp */}
{/* no Card #2 */}
</div>
</div>
{/* Bullets */}
<div className="">
<div className="character-carousel-pagination text-center" />
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { motion } from 'framer-motion';
import SheetWrapper from '../Playground/SheetWrapper';
// Example character data - replace with your actual data
const characters = [
{ id: 1, name: 'Assistant', description: 'Helpful AI assistant', image: '/characters/assistant.png' },
{ id: 2, name: 'Storyteller', description: 'Creative storytelling companion', image: '/characters/storyteller.png' },
{ id: 3, name: 'Tutor', description: 'Patient educational guide', image: '/characters/tutor.png' },
{ id: 4, name: 'Comedian', description: 'Witty joke-telling friend', image: '/characters/comedian.png' },
{ id: 5, name: 'Chef', description: 'Culinary expert and recipe advisor', image: '/characters/chef.png' },
{ id: 6, name: 'Fitness Coach', description: 'Motivational exercise companion', image: '/characters/fitness.png' },
// Add more characters as needed
];
interface CharacterShowcaseProps {
allPersonalities: IPersonality[];
}
export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => {
const [hoveredCharacter, setHoveredCharacter] = useState<number | null>(null);
return (
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4 max-w-screen-lg">
<div className="flex flex-col lg:flex-row items-center gap-12">
{/* Character List - On left for desktop, bottom for mobile */}
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
<div className="relative">
{/* Top scroll indicator/halo */}
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-gray-50 to-transparent z-10 pointer-events-none rounded-t-lg"></div>
<div className="h-[500px] mx-auto overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 rounded-lg">
<div className="grid grid-cols-2 gap-4 md:gap-6 p-4">
{allPersonalities.map((personality, index) => (
<SheetWrapper
languageState={'en-US'}
key={index + personality.personality_id!}
personality={personality}
personalityIdState={''}
onPersonalityPicked={() => {}}
disableButtons={true}
/>
))}
</div>
</div>
{/* Bottom scroll indicator/halo */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-50 to-transparent z-10 pointer-events-none rounded-b-lg"></div>
</div>
</div>
{/* Text Content - On right for desktop, top for mobile */}
<div className="order-1 lg:order-2 w-full lg:w-2/5">
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
Meet Our AI Characters
</h2>
<p className="text-lg text-gray-600 mb-6">
Each character comes with specialized knowledge, voice and personality to make
your interactions more engaging.
</p>
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
<h3 className="font-semibold text-blue-800 mb-2">Personalized Experience</h3>
<p className="text-blue-700">
Choose the character that best fits your needs or mood. You can switch between
characters anytime during your conversation.
</p>
</div>
</div>
</div>
</div>
</section>
);
};

View file

@ -0,0 +1,27 @@
import { VoiceSettings } from "./VoiceSettings"
export const CreateCharacterShowcase = () => {
return (
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4 max-w-screen-lg">
<div className="flex flex-col lg:flex-row-reverse items-center gap-12">
{/* Text Content - On right for desktop, top for mobile */}
<div className="order-1 lg:order-2 w-full lg:w-2/5">
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
Create Your Own
</h2>
<p className="text-lg text-gray-600 mb-6">
Create a character that is unique and personalized to your needs.
</p>
</div>
{/* Character List - On left for desktop, bottom for mobile */}
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
<div className="mx-auto px-2 rounded-lg">
<VoiceSettings />
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,31 @@
"use client";
import { motion } from "framer-motion"; // Add this import at the top
import Image from "next/image";
export const DeviceImage = () => {
return (
<div className="relative h-[260px] w-full items-center -mt-8">
<motion.div
animate={{
y: [-5, 5, -5],
}}
transition={{
duration: 4,
ease: "easeInOut",
repeat: Infinity,
}}
className="w-full h-full"
>
<Image
src="/products/box43.png"
alt="Elato Toy"
fill
className="object-contain object-center mr-6 rounded-3xl"
/>
</motion.div>
</div>
);
};
export default DeviceImage;

View file

@ -0,0 +1,16 @@
"use client"
import Twemoji from "react-twemoji";
export const Emoji = ({ emoji }: { emoji: string }) => {
return (
<div className="flex flex-row items-center justify-center w-10 h-10">
<Twemoji options={{
className: "twemoji flex-shrink-0",
style: { fontSize: `${10}px` }
}}>
{emoji}
</Twemoji>
</div>
)
}

View file

@ -0,0 +1,71 @@
"use client";
import Illustration from "@/public/hero_section.svg";
import { Button } from "@/components/ui/button";
import { CalendarCheck, Star } from "lucide-react";
import { FaDiscord } from "react-icons/fa";
import Link from "next/link";
import {
businessDemoLink,
discordInviteLink,
githubPublicLink,
} from "@/lib/data";
import PreorderButton from "../PreorderButton";
export default function EndingSection() {
return (
<section className="py-8 md:py-24">
<div className="max-w-4xl text-center mx-8 md:mx-auto gap-10 flex flex-col">
<h1
className="font-normal text-xl text-gray-600"
>
Bringing creative, personalized stories to toys, plushies and a whole lot
more.
</h1>
<h1 className="text-4xl md:text-5xl mt-8 text-light">
Get your <span className="font-silkscreen font-bold mt-1 px-2">Elato</span> today!
</h1>
</div>
<div className="mt-20 flex flex-col items-center justify-center gap-8">
<div className="flex items-center justify-center gap-8 flex-wrap">
<PreorderButton
size="lg"
buttonText="Buy Now"
className="h-10"
/>
<Link href={businessDemoLink} passHref>
<Button
variant="secondary"
className="flex flex-row bg-white items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full border-2 border-stone-900"
>
<CalendarCheck size={20} />
<span>Book a Demo</span>
</Button>
</Link>
</div>
<div className="flex items-center justify-center gap-8 flex-wrap">
<Link href={githubPublicLink} passHref>
<Button
variant="link"
className="flex flex-row items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full bg-transparent"
>
<Star size={20} className="text-2xl" />
<span>Star us on GitHub</span>
</Button>
</Link>
<Link href={discordInviteLink} passHref>
<Button
variant="link"
className="flex flex-row items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full bg-transparent"
>
<FaDiscord className="text-2xl" />
<span>Join our Discord</span>
</Button>
</Link>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import {
AudioWaveform,
FileSearch2,
Sparkle,
Heart,
MessageSquareHeart,
ChartScatter,
Ratio,
} from "lucide-react";
const features = [
{
icon: <MessageSquareHeart size={32} strokeWidth={1.7} />,
progress: "Released",
name: "Custom AI characters",
description: "Choose from a variety of AI characters to interact with.",
source: "images/feature2.png",
},
{
icon: <ChartScatter size={32} strokeWidth={1.7} />,
progress: "Released",
name: "Emotion Intelligence",
description:
"Understand emotional trends and insights according to real time analytics.",
source: "images/feature1.png",
},
{
icon: <Ratio size={32} strokeWidth={1.7} />,
progress: "Released",
name: "Tiny Size",
description:
"As small as the Apple Watch, you can stack the device on any surface.",
source: "images/feature3.png",
},
{
icon: <AudioWaveform size={32} strokeWidth={1.7} />,
progress: "In development",
name: "Custom Voice Clone",
description: "Customize your AI's voice to match your preference.",
source: "images/feature4.png",
},
{
icon: <FileSearch2 size={32} strokeWidth={1.7} />,
progress: "In development",
name: "Talk to your documents",
description:
"Our RAG implementation allows you to talk to your images, videos and documents.",
source: "images/feature5.png",
},
{
icon: <Sparkle size={32} strokeWidth={1.7} />,
progress: "In development",
name: "Agentic AI",
description:
"Our agentic AI can help you with your daily tasks and reminders.",
source: "images/feature6.png",
},
// More features...
];
export default function FeaturesSection() {
return (
<section className="">
<div className="max-w-6xl mx-auto py-8 px-4 md:py-12">
<div className="max-w-3xl mx-auto text-center pb-12 md:pb-20">
<h2 className="text-3xl font-medium tracking-tighter sm:text-5xl text-center ">
Packed with features
</h2>
<p className="font-light mt-12 text-lg sm:text-xl leading-8 text-stone-800">
We designed our AI platform to be a suitable companion
that you can engage with anytime. Here are some of the
features that we have implemented and are actively
developing.
</p>
</div>
<ul
role="list"
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
{features.map((feature) => (
<li key={feature.source} className="relative">
<div className="bg-white p-[10px] rounded-[30px] shadow-custom_unfocus">
<div className="cursor-pointer overflow-hidden rounded-[25px] relative group">
<img
alt=""
src={feature.source}
className="h-[200px] sm:h-full w-full object-cover transition-all duration-300 ease-in-out group-hover:scale-110 opacity-80"
/>
<div className="absolute top-0 left-0 text-stone-800 flex flex-col items-start">
<div className="inline-block mx-[10px] mt-3 mb-4">
{feature.icon}
</div>
<p className="text-normal md:text-xl font-bold px-3 py-2">
{feature.name}
</p>
<p className="text-sm pl-3 pr-6 py-1">
{feature.description}
</p>
</div>
<div className="absolute bottom-0 left-0 text-stone-800 flex flex-col items-start">
<p className="text-xs font-light truncate ml-3 mb-4 px-2 py-[2px] border-[1px] border-stone-700 rounded-xl">
{feature.progress}
</p>
</div>
{/* <button
type="button"
className="absolute inset-0 focus:outline-none"
>
<span className="sr-only">
View details for {feature.progress}
</span>
</button> */}
</div>
</div>
</li>
))}
</ul>
</div>
</section>
);
}

View file

@ -0,0 +1,159 @@
import { User } from "@supabase/supabase-js";
import AnimatedText from "./AnimatedText";
import { Emoji } from "./Emoji";
/**
* Headlines for the landing page
*
* "No more boring AI conversations with a chatbot",
* "No more boring AI conversations on a screen",
* "No more boring AI chatbots",
* "Experience AI conversations that feel real",
* "No more boring AI chatbot convos",
* "A new home for your AI characters",
* "Hear a joke whenever you need a pick-me-up",
* "Your AI, better at debates than Congress",
* "Think TMZ, but with punchlines.",
* "Say goodbye to boring AI chatbot conversations",
* "The AI Device with the best dad jokes",
* "The AI wearable for toys"
*
*/
const HeaderText = "Elato";
interface FrontPageProps {
user?: User;
allPersonalities: IPersonality[];
}
// const getRandomPersonalities = (
// personalities: IPersonality[],
// count: number
// ) => {
// return [...personalities].sort(() => Math.random() - 0.5).slice(0, count);
// };
// const getBlobShape = (index: number) => {
// const shapes = [
// "40% 60% 70% 30% / 40% 50% 60% 50%",
// "60% 40% 30% 70% / 60% 30% 50% 40%",
// "50% 60% 40% 50% / 40% 60% 50% 60%",
// "30% 70% 60% 40% / 50% 60% 40% 50%",
// "45% 55% 65% 35% / 55% 45% 55% 45%",
// "40% 60% 70% 30% / 40% 50% 60% 50%",
// "60% 40% 30% 70% / 60% 30% 50% 40%",
// "50% 60% 40% 50% / 40% 60% 50% 60%",
// "30% 70% 60% 40% / 50% 60% 40% 50%",
// "45% 55% 65% 35% / 55% 45% 55% 45%",
// "40% 60% 70% 30% / 40% 50% 60% 50%",
// "60% 40% 30% 70% / 60% 30% 50% 40%",
// "50% 60% 40% 50% / 40% 60% 50% 60%",
// "30% 70% 60% 40% / 50% 60% 40% 50%",
// "45% 55% 65% 35% / 55% 45% 55% 45%",
// "40% 60% 70% 30% / 40% 50% 60% 50%",
// "60% 40% 30% 70% / 60% 30% 50% 40%",
// "50% 60% 40% 50% / 40% 60% 50% 60%",
// "30% 70% 60% 40% / 50% 60% 40% 50%",
// "45% 55% 65% 35% / 55% 45% 55% 45%",
// ];
// return shapes[index];
// };
const FrontPage = ({ user }: FrontPageProps) => {
return (
<div className="flex flex-col items-center text-center max-w-screen-md px-4 md:px-6 mx-auto justify-center gap-8 mt-10 sm:mt-24">
<div className="flex flex-col gap-4">
{/* <Badge
className="w-fit flex flex-row gap-2 shadow-md items-center text-sm"
variant="secondary"
>
<Sparkle fill="currentColor" size={12} /> Now Available
for Preorder
</Badge> */}
<h1 className="font-semibold tracking-tight text-5xl/tight sm:text-6xl/tight font-silkscreen">
{HeaderText}
</h1>
{/* <h1 className="mb-4 text-5xl/tight sm:text-6xl/tight font-semibold leading-none tracking-tight dark:text-white">
Real time{" "}
<span className="underline underline-offset-3 decoration-8 decoration-black">
AI conversations
</span>{" "}
in one compact, open-source device
</h1> */}
<h1 className="text-3xl md:text-5xl font-medium max-w-2xl mx-auto relative z-10 mb-6 leading-[1.2] md:leading-[1.3]">
<span className="bg-clip-text font-normal text-transparent bg-gradient-to-r from-amber-500 to-pink-600 drop-shadow-sm">
The <span className="font-extrabold"></span>screen-free storytelling toy <span className="font-extrabold">starring your little one</span></span>
</h1>
{/* <div className="relative">
<h1 className="text-2xl md:text-3xl font-medium text-gray-900 leading-snug max-w-2xl mx-auto px-6 py-4 border-l-4 border-r-4 border-amber-400">
{SubHeaderText}
</h1>
</div> */}
{/* <div className="max-w-4xl text-center mx-8 md:mx-auto flex flex-col gap-4 -mt-4">
<AnimatedText />
</div> */}
</div>
{/* <div className="flex flex-col gap-4">
<DeviceImage />
</div> */}
</div>
);
};
export default FrontPage;
{
/* <TbArrowWaveRightUp
size={64}
strokeWidth={1.5}
className="absolute bottom-10 left-16 transform -translate-x-1/2 -rotate-30 text-gray-600"
/>
{getRandomPersonalities(allPersonalities, NUM_AVATARS).map(
(personality, index) => {
// Calculate position on a circle
const angle = (index * (2 * Math.PI)) / NUM_AVATARS; // Divide circle by number of avatars
const radius = 80; // Radius of the circular arrangement
const centerX = 20; // Center point X (from right)
const centerY = -10; // Center point Y (from top)
// Calculate position using trigonometry
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<div
key={personality.key}
className="absolute"
style={{
right: `${centerX + x}px`,
top: `${centerY + y}px`,
// transform: `rotate(${-10 + index * 15}deg)`,
zIndex: 10 - index,
}}
>
<div
style={{
borderRadius: "50%",
overflow: "hidden",
width: "80px",
height: "80px",
// border: "2px solid white",
// boxShadow:
// "0 4px 6px -1px rgb(0 0 0 / 0.1)",
position: "relative",
}}
className="shadow-md"
>
<Image
src={getPersonalityImageSrc(
personality.key
)}
alt={personality.key}
fill
className="object-cover"
/>
</div>
</div>
);
}
)} */
}

View file

@ -0,0 +1,204 @@
"use client";
import Usecase from "./Usecase";
import TopCard from "../Insights/TopCard";
import { MyResponsiveLine } from "../Insights/LineChart";
import { MyResponsivePie } from "../Insights/PieChart";
import { MyResponsiveBar } from "../Insights/BarChart";
const suggestions =
"Based on the recent data, maintaining a neutral emotional state is predominant. Although there is a slight emergence of negative emotions like disgust, sadness, and anger, they are balanced by an equal presence of joy and surprise. Encourage positive interactions and activities to enhance the joy and neutral emotions further.";
const cardData = {
main_emotion_1: { title: "Joy", value: 28.8, change: 28 },
main_emotion_2: { title: "Suprise", value: 19.2, change: -12 },
change_1: { title: "Anger", value: 12.1, change: 69 },
change_2: { title: "Fear", value: 5.9, change: -52 },
};
const barData = [
{ emotion: "Surprise", Today: 0.28, Yesterday: 0.25 },
{ emotion: "Joy", Today: 0.13, Yesterday: 0.22 },
{ emotion: "Sadness", Today: 0.12, Yesterday: 0.18 },
{ emotion: "Anger", Today: 0.12, Yesterday: 0.12 },
{ emotion: "Neutral", Today: 0.12, Yesterday: 0.1 },
{ emotion: "Fear", Today: 0.12, Yesterday: 0.1 },
{ emotion: "Disgust", Today: 0.12, Yesterday: 0.09 },
];
const lineData = [
{
id: "Negative",
name: "Negative",
data: [
{ x: "2024-09-4", y: 0.23122093090355336 },
{ x: "2024-09-5", y: 0.21122093090355345 },
{ x: "2024-09-6", y: 0.13122093090355345 },
{ x: "2024-09-7", y: 0.18122093090355345 },
{ x: "2024-09-8", y: 0.43122093090355345 },
{ x: "2024-09-9", y: 0.23122093090355345 },
{ x: "2024-09-10", y: 0.13122093090355345 },
],
},
{
id: "Neutral",
name: "Neutral",
data: [
{ x: "2024-09-4", y: 0.2433541552767576 },
{ x: "2024-09-5", y: 0.1433541552767577 },
{ x: "2024-09-6", y: 0.11122093090355345 },
{ x: "2024-09-7", y: 0.20122093090355345 },
{ x: "2024-09-8", y: 0.23122093090355345 },
{ x: "2024-09-9", y: 0.20122093090355345 },
{ x: "2024-09-10", y: 0.23122093090355345 },
],
},
{
id: "Positive",
name: "Positive",
data: [
{ x: "2024-09-4", y: 0.3233541552767576 },
{ x: "2024-09-5", y: 0.2433541552767577 },
{ x: "2024-09-6", y: 0.23122093090355345 },
{ x: "2024-09-7", y: 0.23122093090355345 },
{ x: "2024-09-8", y: 0.13122093090355345 },
{ x: "2024-09-9", y: 0.28122093090355345 },
{ x: "2024-09-10", y: 0.33122093090355345 },
],
},
];
const pieData = [
{ id: "Positive", label: "Positive", value: 0.43 },
{ id: "Neutral", label: "Neutral", value: 0.34 },
{ id: "Negative", label: "Negative", value: 0.23 },
];
export default function InsightsDemo() {
const isEmpty = (data: any) => {
return !data || data.length === 0;
};
const placeholder = (
<div className="my-4 bg-gray-50 text-center w-full h-full rounded-lg flex items-center justify-center">
<p className="text-lg font-medium text-gray-500">
Talk to a character to view your trends
</p>
</div>
);
// console.log(cardData?.["change_1"]);
return (
<div className="">
{/* <div className="text-3xl font-medium mb-8 text-gray-800">Insights</div>
<div className="mt-2 mb-4 text-gray-800">{suggestions}</div> */}
<div className="flex flex-col md:flex-row md:space-x-3">
<div className="w-full">
<h2 className="mb-4 text-lg font-bold text-gray-700">
Daily emotion highlights
</h2>
<div className="flex space-x-3">
<div className="flex-grow">
<TopCard
title={
cardData?.["main_emotion_1"]?.title ??
"Emotion"
}
value={`${cardData?.["main_emotion_1"]?.value ?? "0"}%`}
delta={
cardData?.["main_emotion_1"]?.change ?? 0
}
filter={"days"}
type="top"
/>
</div>
<div className="flex-grow">
<TopCard
title={
cardData?.["main_emotion_2"]?.title ??
"Emotion"
}
value={`${cardData?.["main_emotion_2"]?.value ?? "0"}%`}
delta={
cardData?.["main_emotion_2"]?.change ?? 0
}
filter={"days"}
type="top"
/>
</div>
</div>
</div>
<div className="w-full mt-2 md:mt-0">
<h2 className="mb-4 text-lg font-bold text-gray-700">
Significant emotion shifts
</h2>
<div className="flex space-x-3">
<div className="flex-grow">
<TopCard
title={
cardData?.["change_1"]?.title ?? "Emotion"
}
value={`${cardData?.["change_1"]?.value ?? "0"}%`}
delta={cardData?.["change_1"]?.change ?? 0}
filter={"days"}
type="shift"
/>
</div>
<div className="flex-grow">
<TopCard
title={
cardData?.["change_2"]?.title ?? "Emotion"
}
value={`${cardData?.["change_2"]?.value ?? "0"}%`}
delta={cardData?.["change_2"]?.change ?? 0}
filter={"days"}
type="shift"
/>
</div>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row md:space-x-8 mx-6-">
<div className="w-full order-2 md:order-1 md:flex-grow">
<h2 className="mt-6 text-lg font-bold text-gray-700">
{/* Sentiment Over Time and Forecast */}
Sentiment over time
</h2>
<div className="h-[300px] lg:h-96">
{isEmpty(lineData) ? (
placeholder
) : (
<MyResponsiveLine data={lineData} />
)}
</div>
</div>
<div className="w-full order-1 md:order-2 md:w-72 md:flex-shrink-0">
<h2 className="mt-6 text-lg font-bold text-gray-700">
Daily sentiment proportions
</h2>
<div className="h-[300px] lg:h-96">
{isEmpty(pieData) ? (
placeholder
) : (
<MyResponsivePie data={pieData} />
)}
</div>
</div>
</div>
{/* <div className="w-full">
<h2 className="mt-6 text-lg font-bold text-gray-700">
Emotions Breakdown
</h2>
<div className="h-[350px] lg:h-[450px]">
{isEmpty(barData) ? (
placeholder
) : (
<MyResponsiveBar data={barData} filter={"days"} />
)}
</div>
</div> */}
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import IllustrationInsights from "@/public/insights_section.svg";
import InsightsDemo from "./InsightsDemo";
export default function InsightsDemoSection() {
return (
<section className="">
<div className="relative w-full max-w-[1440px] mx-auto">
<div
className="absolute -top-[85px] pointer-events-none -z-10 opacity-90 w-full h-[650px] bg-cover bg-center bg-no-repeat blur-xl"
style={{
backgroundImage: `url(${IllustrationInsights.src})`,
transform: "scaleX(-1)",
}}
aria-hidden="true"
></div>
</div>
<div className="py-8 px-4 md:py-12">
<div className="max-w-3xl mx-auto text-center pb-12 md:pb-20">
<h2 className="text-3xl font-medium tracking-tighter sm:text-5xl text-center ">
Get trends and insights
</h2>
<p className="font-light mt-12 text-lg sm:text-xl leading-8 text-stone-800">
Our AI platform can analyse human-speech and emotion,
and respond with empathy, offering supportive
conversations and personalized learning assistance.
</p>
</div>
<div className="max-w-[1120px] mx-auto px-6 sm:px-20 py-6 sm:py-12 bg-white shadow-custom_focus rounded-[40px] md:border-[22px] border-[12px] border-zinc-800">
<InsightsDemo />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,37 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { getPersonalityImageSrc } from "@/lib/utils";
const LandingPagePersonalityCard = ({
personality,
}: {
personality: IPersonality;
}) => {
return (
<Card
className={cn(
"p-0 rounded-full cursor-pointer sm:min-w-[180px] min-w-[120px] border-none shadow-none bg-transparent transition-all hover:scale-103 flex flex-col"
)}
// onClick={() => onPersonalityPicked(personality)}
>
<CardContent className="flex-shrink-0 p-0 sm:h-[180px] h-[120px]">
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={180}
height={140}
className="rounded-full w-full h-full object-cover"
/>
</CardContent>
</Card>
);
};
export default LandingPagePersonalityCard;

View file

@ -0,0 +1,45 @@
import { Separator } from "@/components/ui/separator";
import Image from "next/image";
interface LandingPageSectionProps {
title: string;
description: string;
imageSrc: string;
isImageRight?: boolean;
}
const IMAGE_SIZE = 600;
const LandingPageSection: React.FC<LandingPageSectionProps> = ({
title,
description,
imageSrc,
isImageRight,
}) => {
return (
<div
className={`flex flex-col max-w-screen-xl ${
isImageRight ? "md:flex-row" : "md:flex-row-reverse"
} gap-10 justify-between mx-auto mb-28 items-center`}
>
<Image
src={imageSrc}
className="rounded-2xl"
alt="toy"
width={IMAGE_SIZE}
height={IMAGE_SIZE}
style={{
objectFit: "cover",
}}
/>
<div className={`flex flex-col gap-4`}>
<h2 className="text-4xl font-normal">{title}</h2>
<p className="text-xl font-normal text-gray-500">
{description}
</p>
</div>
</div>
);
};
export default LandingPageSection;

View file

@ -0,0 +1,58 @@
import Image from "next/image";
import Link from "next/link";
const IMAGE_SIZE = 150;
const Partners = () => {
return (
<div className="flex flex-col items-center gap-2 w-full">
<div className="flex flex-row w-full gap-10 items-center justify-center opacity-30">
<Link
href="https://antler.co"
target="_blank"
rel="noopener noreferrer"
referrerPolicy="origin"
>
<Image
src={"/antler.png"}
width={IMAGE_SIZE}
height={IMAGE_SIZE}
alt="antler"
style={{
WebkitFilter:
"grayscale(100%)" /* Safari 6.0 - 9.0 */,
filter: "grayscale(100%)",
}}
/>
</Link>
{/* <Separator
className="border-2 text-gray-500 flex-grow"
orientation="vertical"
/> */}
<Link
href="https://microsoft.com"
target="_blank"
rel="noopener noreferrer"
referrerPolicy="origin"
>
<Image
src={"/microsoft.png"}
width={IMAGE_SIZE}
height={IMAGE_SIZE}
alt={"microsoft"}
style={{
WebkitFilter:
"grayscale(100%)" /* Safari 6.0 - 9.0 */,
filter: "grayscale(100%)",
}}
/>
</Link>
</div>
<div className="text-xs hidden sm:block text-gray-500">
Supported by our proud partners
</div>
</div>
);
};
export default Partners;

View file

@ -0,0 +1,54 @@
"use client";
import LandingPagePersonalityCard from "./LandingPagePersonalityCard";
import { useEffect, useRef } from "react";
const Personalities = ({
allPersonalities,
}: {
allPersonalities: IPersonality[];
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
let scrollAmount = 0;
const scrollStep = 1; // Adjust this value for speed
const scrollInterval = 20; // Adjust this value for smoothness
const scroll = () => {
if (
scrollContainer.scrollWidth - scrollContainer.clientWidth ===
scrollAmount
) {
scrollAmount = 0; // Reset scroll
} else {
scrollAmount += scrollStep;
}
scrollContainer.scrollLeft = scrollAmount;
};
const intervalId = setInterval(scroll, scrollInterval);
return () => clearInterval(intervalId);
}, []);
return (
<div className="relative w-full">
<div ref={scrollRef} className="overflow-x-auto scrollbar-hide">
<div className="flex flex-row items-center sm:gap-x-8 gap-x-4 justify-between whitespace-nowrap px-4 py-8">
{allPersonalities.map((personality) => (
<LandingPagePersonalityCard
key={personality.personality_id}
personality={personality}
/>
))}
</div>
</div>
</div>
);
};
export default Personalities;

View file

@ -0,0 +1,58 @@
"use client";
import { useEffect, useRef } from "react";
export default function TikTokEmbed() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
// Clear any existing content first
containerRef.current.innerHTML = '';
// Create a new blockquote element
const blockquote = document.createElement('blockquote');
blockquote.className = 'tiktok-embed';
blockquote.setAttribute('cite', 'https://www.tiktok.com/@elatoai/video/7487016680925744406');
blockquote.setAttribute('data-video-id', '7487016680925744406');
blockquote.style.maxWidth = '605px';
blockquote.style.minWidth = '325px';
blockquote.style.margin = '0 auto';
// Create section element
const section = document.createElement('section');
blockquote.appendChild(section);
// Add the blockquote to the container
containerRef.current.appendChild(blockquote);
// Check if script already exists and remove it
const existingScript = document.querySelector('script[src="https://www.tiktok.com/embed.js"]');
if (existingScript) {
existingScript.remove();
}
// Load the TikTok embed script
const script = document.createElement('script');
script.src = 'https://www.tiktok.com/embed.js';
script.async = true;
document.body.appendChild(script);
// Cleanup function
return () => {
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}
}, []);
return (
<div className="w-full h-full flex items-center justify-center" ref={containerRef}>
{/* TikTok embed will be inserted here by useEffect */}
</div>
);
}

View file

@ -0,0 +1,147 @@
"use client";
import { Button } from "@/components/ui/button";
import { voiceSampleUrl } from "@/lib/data";
import { ArrowRight, PauseIcon, PlayIcon } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
interface ToyPickerProps {
imageSize: number;
buttonText: string;
showHelpText: boolean;
}
interface IDoctorPersonality {
key: string;
name: string;
}
const getDoctorImageSrc = (key: string) => {
return "/personality/" + key + ".jpeg";
};
const DoctorPersonalities: IDoctorPersonality[] = [
{
key: "aggie_blood_test_pal",
name: "Aggie Blood Test Pal",
},
{
key: "santa_claus",
name: "Santa Claus",
},
{
key: "luna_epilepsy_pal",
name: "Luna Epilepsy Pal",
},
];
const ToyPicker: React.FC<ToyPickerProps> = ({ imageSize, buttonText }) => {
const [selectedPersonality, setSelectedPersonality] =
useState<IDoctorPersonality>(DoctorPersonalities[0]);
const [playing, setPlaying] = useState<string | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
null
);
const playAudio = (personality: IDoctorPersonality) => {
if (playing === personality.key && audioElement) {
// Pause current audio
audioElement.pause();
setPlaying(null);
setAudioElement(null);
return;
}
// Stop any currently playing audio
if (audioElement) {
audioElement.pause();
}
const audio = new Audio(`${voiceSampleUrl}${personality.key}.wav`);
audio.onended = () => {
setPlaying(null);
setAudioElement(null);
};
audio.play();
setPlaying(personality.key);
setAudioElement(audio);
};
const onClickSelectedPersonality = (personality: IDoctorPersonality) => {
setSelectedPersonality(personality);
};
return (
<div className="flex flex-col-reverse gap-8 pb-6">
<div className="flex md:mt-7- md:flex-row flex-col gap-8 items-center justify-center">
{DoctorPersonalities.map((personality) => {
const chosen = selectedPersonality?.key === personality.key;
return (
<div
key={personality.key}
className="flex flex-col gap-2 bg-gray-50- rounded-2xl"
>
<div
className={`flex flex-col items-center max-w-[300px] max-h-[300px] rounded-br-2xl gap-2 mb-4 rounded-xl overflow-hidden cursor-pointer transition-colors duration-200 ease-in-out`}
onClick={() =>
onClickSelectedPersonality(personality)
}
>
<Image
src={getDoctorImageSrc(personality.key)}
width={imageSize}
height={imageSize}
alt={personality.name}
className="rounded-2xl transition-transform duration-300 ease-in-out scale-90 transform hover:scale-100 hover:-rotate-2"
/>
</div>
<div className="flex flex-col gap-6 items-center text-center">
<div className={`text-xl font-medium`}>
{personality.name}
</div>
{chosen && (
<>
<Button
onClick={() => {
// play sound
playAudio(personality);
}}
variant="outline"
className="font-medium text-lg flex flex-row gap-2 items-center rounded-full border-stone-800 border-2"
>
<span>
{playing
? "Pause voice"
: buttonText}
</span>
{playing ? (
<PauseIcon
size={18}
fill="currentColor"
/>
) : (
<PlayIcon
size={18}
fill="currentColor"
/>
)}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
{/* {showHelpText && (
<p className="flex self-center text-sm text-gray-600">
(pick your favorite AI character to get started!)
</p>
)} */}
</div>
);
};
export default ToyPicker;

View file

@ -0,0 +1,21 @@
import Image, { StaticImageData } from "next/image";
interface usecaseProps {
usecase: {
image: StaticImageData;
};
}
export default function Usecase({ usecase }: usecaseProps) {
return (
<div className="rounded w-[22rem] border border-transparent">
<div className="flex items-center mb-4 ">
<Image
className="shrink-0 rounded-[30px]"
src={usecase.image}
alt="Usecase Image"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { r2Url } from "@/lib/data";
export default function Usecases() {
const usecases = [
{
title: "For Interactive Storytelling",
description: "Transform ordinary toys into storytelling companions that respond to your child's imagination.",
features: [
"Voice-activated responses",
"Customizable personalities",
"Age-appropriate content",
"Multiple story modes"
],
videoSrc: `${r2Url}/peterrabbit.mp4`,
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/peterrabbit.png`
},
{
title: "For Educational Learning",
description: "Turn everyday objects into educational tools that make learning fun and interactive.",
features: [],
videoSrc: `${r2Url}/paddington.mp4`,
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/paddington.png`
},
{
title: "For Creative Play",
description: "Enhance playtime with responsive objects that encourage creativity and imaginative play.",
features: [
"Character role-playing",
"Collaborative play options",
"Parent monitoring features"
],
videoSrc: `${r2Url}/plant.mp4`,
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/plant.png`
}
];
return (
<section className="py-16 bg-stone-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="space-y-16 md:space-y-24">
{usecases.map((usecase, index) => (
<div
key={index}
className={`flex flex-col ${index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'} gap-16 items-center`}
>
{/* Video Column */}
<div className="w-full md:w-1/2">
<div className="relative aspect-video rounded-2xl overflow-hidden shadow-lg">
<video
className="w-full h-full object-cover"
controls
poster={usecase.poster}
>
<source src={usecase.videoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
</div>
{/* Text Column */}
<div className="w-full md:w-1/2">
<h3 className="text-3xl md:text-4xl font-semibold text-stone-800 mb-4 font-silkscreen">
{usecase.title}
</h3>
<p className="text-xl text-gray-600 mb-6">
{usecase.description}
</p>
<div className="space-y-3">
{/* <h4 className="text-lg font-medium text-stone-800">Key Features:</h4> */}
<ul className="space-y-2">
{usecase.features.map((feature, idx) => (
<li key={idx} className="flex items-start">
<svg className="w-5 h-5 text-emerald-500 mr-2 mt-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-gray-400 text-xl">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,96 @@
'use client'
import React, { useEffect, useRef, useState } from 'react';
import { IoIosShareAlt } from 'react-icons/io';
import { Wand, Heart, MessageCircle, Bookmark } from 'lucide-react';
interface VideoPlayerProps {
sources: string[];
className?: string;
}
const ICON_SIZE = 28;
export default function VideoPlayer({ sources, className = "" }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) return;
// Handle video ended event to switch to the next video
const handleVideoEnded = () => {
// Fade out
if (videoElement) {
videoElement.classList.add('opacity-0');
// Wait for fade out, then change source and fade in
setTimeout(() => {
setCurrentSourceIndex((prevIndex) => (prevIndex + 1) % sources.length);
}, 300);
}
};
videoElement.addEventListener('ended', handleVideoEnded);
// Clean up event listener
return () => {
videoElement.removeEventListener('ended', handleVideoEnded);
};
}, [sources.length]);
// When the current source index changes, load and play the new video
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) return;
videoElement.load();
videoElement.play().catch(error => {
console.error("Error playing video:", error);
});
// Fade in after source change
setTimeout(() => {
videoElement.classList.remove('opacity-0');
}, 50);
}, [currentSourceIndex]);
return (
<div className="relative aspect-[9/16] max-h-[85vh] w-full">
<video
ref={videoRef}
className={`w-full h-full object-cover rounded-xl shadow-lg transition-opacity duration-300 ${className}`}
autoPlay
muted
playsInline
>
<source src={sources[currentSourceIndex]} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* TikTok-style icons */}
<div className="absolute top-2 left-2 flex flex-col gap-4">
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
<Wand size={ICON_SIZE+4} color="white" />
</button>
</div>
<div className="absolute bottom-2 right-2 flex flex-col gap-4">
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
<Heart size={ICON_SIZE} color="rgb(239, 68, 68" fill="rgb(239, 68, 68)" />
</button>
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
<MessageCircle size={ICON_SIZE} color="white" />
</button>
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
<Bookmark size={ICON_SIZE} color="rgb(250, 204, 21)" fill="rgb(250, 204, 21)" />
</button>
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
<IoIosShareAlt size={ICON_SIZE} color="white" />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,119 @@
"use client";
import { Volume2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { emotionOptions, r2UrlAudio, voices } from "@/lib/data";
import EmojiComponent from "../CreateCharacter/EmojiComponent";
import { useState } from "react";
import { Input } from "@/components/ui/input";
export const VoiceSettings = () => {
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [previewingVoice, setPreviewingVoice] = useState<string | null>(null);
const previewVoice = (voiceId: string) => {
// If the same voice is clicked again while playing, pause it
if (previewingVoice === voiceId && audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
setPreviewingVoice(null);
return;
}
// Stop any currently playing preview
if (audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
}
const audioSampleUrl = `${r2UrlAudio}/${voiceId}.wav`;
setPreviewingVoice(voiceId);
// Create and play audio element
const audio = new Audio(audioSampleUrl);
setAudioElement(audio);
// Play the audio
audio.play().catch(error => {
console.error("Error playing audio:", error);
setPreviewingVoice(null);
});
// Reset the previewing state when audio ends
audio.onended = () => {
setPreviewingVoice(null);
};
// Fallback in case audio doesn't trigger onended
setTimeout(() => {
if (previewingVoice === voiceId) {
setPreviewingVoice(null);
}
}, 10000); // 10 second fallback
};
return (<div className="flex flex-col gap-4 w-full">
<div className="space-y-2">
<Label htmlFor="voice">Pick a voice</Label>
<div className="grid grid-cols-2 gap-3">
{voices.map((voice) => (
<div
key={voice.id}
className={`
rounded-lg border p-3 transition-all relative
${"" === voice.id
? 'border-2 border-blue-500 shadow-sm ' + voice.color
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
}
`}
onClick={() => {
previewVoice(voice.id);
}}
>
<div className="flex flex-col">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
<div className="text-2xl mt-0.5">
<EmojiComponent emoji={voice.emoji} />
</div>
<div className="flex flex-col text-center sm:text-left">
<span className="font-medium">{voice.name}</span>
<span className="text-xs text-gray-600">{voice.description}</span>
</div>
{previewingVoice === voice.id && (
<div className="absolute top-2 right-2">
<div className="animate-pulse text-blue-500">
<Volume2 size={20} />
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
</div>
<div className="space-y-3">
<Label className="block mb-2">Emotional Tone</Label>
<div className="grid grid-cols-3 gap-3">
{emotionOptions.map((emotion) => (
<div
key={emotion.value}
className={`
rounded-lg border p-3 cursor-pointer transition-all
${"" === emotion.value
? 'border-2 border-blue-500 shadow-sm ' + emotion.color
: 'border-gray-200 hover:border-gray-300'
}
`}
>
<div className="flex flex-col items-center text-center">
<EmojiComponent emoji={emotion.icon} />
<span className="text-sm font-medium">{emotion.label}</span>
</div>
</div>
))}
</div>
</div>
</div>);
}

View file

@ -0,0 +1,127 @@
import { Button } from "@/components/ui/button";
import { Hospital, Sparkle, ChevronDown, Dog, Bird, Hop, Wand, Plus, Blocks, Gamepad2 } from "lucide-react";
import {
DropdownMenuSeparator,
DropdownMenu,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import { usePathname } from "next/navigation";
const ICON_SIZE = 22;
interface LeftNavbarButtonsProps {
user: IUser | null;
}
export default function LeftNavbarButtons({ user }: LeftNavbarButtonsProps) {
const isDoctor = user?.user_info.user_type === "doctor";
const pathname = usePathname();
let firstWordOfHospital = '';
if (isDoctor) {
const hospitalName = (user?.user_info.user_metadata as IDoctorMetadata).hospital_name;
firstWordOfHospital = hospitalName ? hospitalName.split(' ')[0] : '';
}
const isRoot = pathname === "/";
const isHome = pathname.includes("/home");
const shouldShowHospital = isDoctor && firstWordOfHospital.length && isHome;
return (
<div className="flex flex-row gap-4 sm:gap-10 items-center">
<a className="flex flex-row gap-3 items-center" href="/">
<Wand size={ICON_SIZE} />
<p
className={`flex items-center font-silkscreen text-xl text-stone-800 dark:text-stone-100 relative`}
>
{shouldShowHospital ? (
<>
<span>Elato | <span className="text-cyan-700">{firstWordOfHospital}</span></span>
<span className="absolute -top-3 -right-3 text-cyan-700"><Plus size={12} strokeWidth={3} /></span>
</>
) : (
<span>Elato</span>
)}
</p>
</a>
{/* {!isHome && (
<DropdownMenu
onOpenChange={(open) => {
if (!open) {
// Remove focus from any active element when dropdown closes
document.activeElement instanceof HTMLElement &&
document.activeElement.blur();
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex flex-row gap-2 items-center rounded-full hover:bg-stone-100 dark:hover:bg-stone-800"
>
<span className="font-medium text-md hidden sm:flex">
Use cases
</span>
<ChevronDown size={18} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56 p-2 sm:mt-2 rounded-lg"
side="bottom"
align="start"
>
<DropdownMenuLabel>Use Cases</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a
href="/"
className={`flex flex-row gap-2 w-full items-center justify-between ${
isRoot
? "bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400"
: ""
}`}
>
<div className="flex flex-row gap-2 items-center">
<Gamepad2 size={ICON_SIZE - 6} />
<span>Elato for Hobbyists</span>
</div>
{isRoot && (
<div className="h-2 w-2 rounded-full bg-amber-500" />
)}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="/kids"
className={`flex flex-row gap-2 w-full items-center justify-between ${
!isRoot
? "bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400"
: ""
}`}
>
<div className="flex flex-row gap-2 items-center">
<Blocks
size={ICON_SIZE - 6}
// fill="currentColor"
/>
<span>Elato for Kids</span>
</div>
{!isRoot && (
<div className="h-2 w-2 rounded-full bg-amber-500" />
)}
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)} */}
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { usePathname } from "next/navigation";
export function MobileNav({
items,
}: {
items: SidebarNavItem[];
}) {
const pathname = usePathname();
const primaryItem = (item: SidebarNavItem) => {
return <a
key={item.href}
href={item.href}
className="relative flex flex-col items-center justify-center p-2 text-sm bg-yellow-500 hover:bg-yellow-400 rounded-full -mt-4 shadow-xl hover:shadow-2xl w-16 h-16"
>
{pathname === item.href && (
<div className="absolute inset-0 rounded-full bg-white opacity-20 -z-[1]" />
)}
<div
className={`text-white text-xl`}
>
{item.icon}
</div>
</a>
}
return (
<nav className="fixed bottom-0 left-0 right-0 bg-background border-t md:hidden">
<div className="flex justify-around items-center h-14">
{items.map((item) => (
<a
key={item.href}
href={item.href}
className="relative flex flex-col items-center p-2 text-sm"
>
{pathname === item.href && (
<div className="absolute top-5 left-1/2 w-[35px] h-[45px] -translate-x-1/2 -translate-y-1/2 -rotate-[70deg] rounded-[100%_80%_100%_80%] bg-yellow-100 -z-[1]" />
)}
<div
className={`${pathname === item.href ? "text-yellow-600" : "text-gray-500"}`}
>
{item.icon}
</div>
<span
className={`mt-1 text-xs ${pathname === item.href ? "text-yellow-600 font-medium" : "text-gray-500"}`}
>
{item.title}
</span>
</a>
))}
</div>
</nav>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import NavbarButtons from "./NavbarButtons";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { usePathname } from "next/navigation";
import LeftNavbarButtons from "./LeftNavbarButtons";
export function Navbar({
user,
stars,
}: {
user: IUser | null;
stars: number | null;
}) {
const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
const isMobile = useMediaQuery("(max-width: 768px)");
const isHome = usePathname().includes("/home");
const isProduct = usePathname().includes("/products");
useEffect(() => {
if (typeof window !== "undefined" && isMobile) {
const handleScroll = () => {
const currentScrollY = window.scrollY;
setIsVisible(
currentScrollY <= 0 || currentScrollY < lastScrollY
);
setLastScrollY(currentScrollY);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}
}, [lastScrollY, isMobile]);
return (
<div
className={`backdrop-blur-[3px] flex-none flex items-center sticky top-0 z-50 transition-transform duration-300 h-[80px] ${
isVisible ? "translate-y-0" : "-translate-y-full"
} ${!isHome ? "h-[80px]" : "h-[60px]"}`}
>
{!isHome && (
<div className="fixed h-8 top-0 flex items-center justify-center w-full bg-yellow-100 dark:bg-yellow-900/30 px-4 py-2 text-center font-medium text-yellow-800 dark:text-yellow-200 z-40 gap-2 text-sm">
Starmoon has a new home!
</div>
)}
<nav
className={`mx-auto w-full max-w-[1440px] px-4 flex items-center justify-between ${
!isHome ? "pt-8" : ""
}`}
>
<LeftNavbarButtons user={user} />
<NavbarButtons user={user} stars={stars} isHome={isHome} />
</nav>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { Button } from "@/components/ui/button";
import { User } from "@supabase/supabase-js";
import Link from "next/link";
import { businessDemoLink, githubPublicLink } from "@/lib/data";
import { Separator } from "@/components/ui/separator";
import PreorderButton from "../PreorderButton";
import { NavbarDropdownMenu } from "./NavbarDropdownMenu";
import { FaGithub } from "react-icons/fa";
import PremiumBadge from "../PremiumBadge";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { usePathname } from "next/navigation";
import GetInTouchButton from "../GetInTouch";
import { CalendarCheck } from "lucide-react";
interface NavbarButtonsProps {
user: IUser | null;
stars: number | null;
isHome: boolean;
}
const NavbarButtons: React.FC<NavbarButtonsProps> = ({
user,
stars,
isHome,
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const isHealthcare = usePathname().includes("/healthcare");
return (
<div
className={`flex flex-row sm:gap-2 ${
isHome ? "gap-2" : ""
} items-center font-bold text-sm `}
>
{isHome && user && (
<div className="mr-2">
<PremiumBadge currentUserId={user.user_id} />
</div>
)}
{/* {isHealthcare ? (
<GetInTouchButton size="sm" iconOnMobile={isMobile} />
) : (
<PreorderButton size="sm" buttonText="Buy" className="font-normal" iconOnMobile />
)} */}
{!isHome && (
<Link
href={githubPublicLink}
target="_blank"
rel="noopener noreferrer"
title="Visit our GitHub"
className="ml-4"
// className="bg-nav-bar rounded-full px-3"
>
<Button
size="sm"
variant={isMobile ? "ghost" : "outline"}
className="flex bg-nav-bar border-0 sm:mr-2 sm:border flex-row gap-2 items-center rounded-full"
>
<FaGithub className="text-xl" />
<p className="hidden sm:flex font-normal">GitHub</p>
<Separator
orientation="vertical"
className="hidden sm:flex"
/>
<p className="hidden sm:flex text-xs font-normal">
{stars?.toLocaleString() ?? 498}
</p>
</Button>
</Link>
)}
{!isHome && !isHealthcare && !isMobile && (
<Link href={businessDemoLink} passHref tabIndex={-1}>
<Button
size="sm"
variant="secondary"
className="flex flex-row gap-2 items-center rounded-full bg-nav-bar focus:shadow-none focus-visible:shadow-none"
>
<CalendarCheck size={20} />
<span className="hidden sm:flex font-normal">
Business demo
</span>
</Button>
</Link>
)}
<NavbarDropdownMenu user={user} stars={stars} />
</div>
);
};
export default NavbarButtons;

Some files were not shown because too many files have changed in this diff Show more