add pitch shift credit @RomanLut

This commit is contained in:
akdeb 2025-06-03 16:03:42 +01:00
parent 1254ca3ebc
commit 757acff0a6
11 changed files with 457 additions and 247 deletions

View file

@ -6,6 +6,7 @@
#include "AudioTools/AudioCodecs/CodecOpus.h" #include "AudioTools/AudioCodecs/CodecOpus.h"
#include <WebSocketsClient.h> #include <WebSocketsClient.h>
#include "Audio.h" #include "Audio.h"
#include "PitchShift.h"
// WEBSOCKET // WEBSOCKET
SemaphoreHandle_t wsMutex; SemaphoreHandle_t wsMutex;
@ -23,6 +24,7 @@ unsigned long speakingStartTime = 0;
// AUDIO SETTINGS // AUDIO SETTINGS
int currentVolume = 70; int currentVolume = 70;
float currentPitchFactor = 1.0f;
const int CHANNELS = 1; // Mono const int CHANNELS = 1; // Mono
const int BITS_PER_SAMPLE = 16; // 16-bit audio const int BITS_PER_SAMPLE = 16; // 16-bit audio
@ -55,9 +57,17 @@ BufferPrint bufferPrint(audioBuffer);
OpusAudioDecoder opusDecoder; //access guarded by wsmutex OpusAudioDecoder opusDecoder; //access guarded by wsmutex
BufferRTOS<uint8_t> audioBuffer(AUDIO_BUFFER_SIZE, AUDIO_CHUNK_SIZE); //producer: networkTask, consumer: audioStreamTask. Thread safe in single producer->single consumer scenario. BufferRTOS<uint8_t> audioBuffer(AUDIO_BUFFER_SIZE, AUDIO_CHUNK_SIZE); //producer: networkTask, consumer: audioStreamTask. Thread safe in single producer->single consumer scenario.
I2SStream i2s; //access from audioStreamTask only I2SStream i2s; //access from audioStreamTask only
// OLD with no pitch shift
VolumeStream volume(i2s); //access from audioStreamTask only VolumeStream volume(i2s); //access from audioStreamTask only
QueueStream<uint8_t> queue(audioBuffer); //access from audioStreamTask only QueueStream<uint8_t> queue(audioBuffer); //access from audioStreamTask only
StreamCopy copier(volume, queue); StreamCopy copier(volume, queue);
// NEW for pitch shift (lossy)
PitchShiftFixedOutput pitchShift(i2s);
VolumeStream volumePitch(pitchShift); //access from audioStreamTask only
StreamCopy pitchCopier(volumePitch, queue);
AudioInfo info(SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE); AudioInfo info(SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE);
volatile bool i2sOutputFlushScheduled = false; volatile bool i2sOutputFlushScheduled = false;
@ -78,7 +88,7 @@ void transitionToSpeaking() {
digitalWrite(I2S_SD_OUT, HIGH); digitalWrite(I2S_SD_OUT, HIGH);
speakingStartTime = millis(); speakingStartTime = millis();
webSocket.enableHeartbeat(30000, 15000, 3); // webSocket.enableHeartbeat(30000, 15000, 3);
Serial.println("Transitioned to speaking mode"); Serial.println("Transitioned to speaking mode");
} }
@ -97,7 +107,7 @@ void transitionToListening() {
deviceState = LISTENING; deviceState = LISTENING;
digitalWrite(I2S_SD_OUT, LOW); digitalWrite(I2S_SD_OUT, LOW);
webSocket.disableHeartbeat(); // webSocket.disableHeartbeat();
} }
// audioStreamTask -> copier.copy() (conditional on webSocket.isConnected()) // audioStreamTask -> copier.copy() (conditional on webSocket.isConnected())
@ -131,24 +141,34 @@ void audioStreamTask(void *parameter) {
config.port_no = I2S_PORT_OUT; config.port_no = I2S_PORT_OUT;
config.copyFrom(info); config.copyFrom(info);
i2s.begin(config); i2s.begin(config);
// Initialize both volume streams once
auto vcfg = volume.defaultConfig(); auto vcfg = volume.defaultConfig();
vcfg.copyFrom(config); vcfg.copyFrom(info);
vcfg.allow_boost = true; vcfg.allow_boost = true;
volume.begin(vcfg); volume.begin(vcfg);
auto vcfgPitch = volumePitch.defaultConfig();
vcfgPitch.copyFrom(info);
vcfgPitch.allow_boost = true;
volumePitch.begin(vcfgPitch);
while (1) { while (1) {
if ( i2sOutputFlushScheduled) { if ( i2sOutputFlushScheduled) {
i2sOutputFlushScheduled = false; i2sOutputFlushScheduled = false;
i2s.flush(); i2s.flush();
volume.flush(); volume.flush();
volumePitch.flush();
queue.flush(); queue.flush();
//audioBuffer.reset(); todo: read untill empty
} }
if (webSocket.isConnected() && deviceState == SPEAKING) { if (webSocket.isConnected() && deviceState == SPEAKING) {
copier.copy(); if (currentPitchFactor != 1.0f) {
pitchCopier.copy();
} else {
copier.copy();
}
} }
else { else {
//we should always read from audioBuffer, otherwise writing thread can stuck //we should always read from audioBuffer, otherwise writing thread can stuck
@ -260,9 +280,23 @@ void webSocketEvent(WStype_t type, uint8_t *payload, size_t length)
// auth messages // auth messages
if (strcmp((char*)type.c_str(), "auth") == 0) { if (strcmp((char*)type.c_str(), "auth") == 0) {
currentVolume = doc["volume_control"].as<int>(); currentVolume = doc["volume_control"].as<int>();
currentPitchFactor = doc["pitch_factor"].as<float>();
bool is_ota = doc["is_ota"].as<bool>(); bool is_ota = doc["is_ota"].as<bool>();
bool is_reset = doc["is_reset"].as<bool>(); bool is_reset = doc["is_reset"].as<bool>();
volume.setVolume(currentVolume / 100.0f); // Set initial volume (e.g., 70/100 = 0.7)
// Update volumes on both streams
volume.setVolume(currentVolume / 100.0f);
volumePitch.setVolume(currentVolume / 100.0f);
// Only initialize pitch shift if needed
if (currentPitchFactor != 1.0f) {
auto pcfg = pitchShift.defaultConfig();
pcfg.copyFrom(info);
pcfg.pitch_shift = currentPitchFactor;
pcfg.buffer_size = 512;
pitchShift.begin(pcfg);
}
if (is_ota) { if (is_ota) {
Serial.println("OTA update received"); Serial.println("OTA update received");
@ -339,8 +373,9 @@ void websocketSetup(String server_domain, int port, String path)
webSocket.setExtraHeaders(headers.c_str()); webSocket.setExtraHeaders(headers.c_str());
webSocket.onEvent(webSocketEvent); webSocket.onEvent(webSocketEvent);
webSocket.setReconnectInterval(1000); webSocket.setReconnectInterval(1000);
webSocket.disableHeartbeat();
webSocket.enableHeartbeat(30000, 15000, 3); // 30s ping interval, 15s timeout, 3 retries // webSocket.enableHeartbeat(30000, 15000, 3); // 30s ping interval, 15s timeout, 3 retries
#ifdef DEV_MODE #ifdef DEV_MODE
webSocket.begin(server_domain.c_str(), port, path.c_str()); webSocket.begin(server_domain.c_str(), port, path.c_str());
@ -366,4 +401,4 @@ void networkTask(void *parameter) {
vTaskDelay(1); vTaskDelay(1);
} }
} }

View file

@ -30,6 +30,11 @@ extern I2SStream i2s;
extern VolumeStream volume; extern VolumeStream volume;
extern QueueStream<uint8_t> queue; extern QueueStream<uint8_t> queue;
extern StreamCopy copier; extern StreamCopy copier;
// NEW for pitch shift
extern VolumeStream volumePitch;
extern StreamCopy pitchCopier;
extern AudioInfo info; extern AudioInfo info;
extern volatile bool i2sOutputFlushScheduled; extern volatile bool i2sOutputFlushScheduled;
@ -48,4 +53,4 @@ unsigned long getSpeakingDuration();
void audioStreamTask(void *parameter); void audioStreamTask(void *parameter);
// AUDIO INPUT // AUDIO INPUT
void micTask(void *parameter); void micTask(void *parameter);

View file

@ -32,11 +32,11 @@ bool factory_reset_status = false;
*/ */
#ifdef DEV_MODE #ifdef DEV_MODE
const char *ws_server = "10.2.1.136"; const char *ws_server = "10.2.1.132";
const uint16_t ws_port = 8000; const uint16_t ws_port = 8000;
const char *ws_path = "/"; const char *ws_path = "/";
// Backend server details // Backend server details
const char *backend_server = "10.2.1.136"; const char *backend_server = "10.2.1.132";
const uint16_t backend_port = 3000; const uint16_t backend_port = 3000;
#elif defined(PROD_MODE) #elif defined(PROD_MODE)

View file

@ -0,0 +1,54 @@
#include "PitchShift.h"
#define GRAINSIZE 1024ul
static int16_t buf1[GRAINSIZE];
static int16_t buf2[GRAINSIZE];
static int16_t* buf = buf1;
static int16_t* buf_ = buf2;
static unsigned long readAddress = 0;
static unsigned long writeAddress = 0;
bool PitchShiftFixedOutput::begin(PitchShiftInfo info) {
TRACED();
cfg = info;
AudioOutput::setAudioInfo(info);
this->pitchMul = (uint32_t)(info.pitch_shift * 256.0f + 0.5f);
//this->secondaryOffset = (uint32_t)( (1.0f - (info.pitch_shift - (int)(info.pitch_shift))) * GRAINSIZE + 0.5f);
this->secondaryOffset = GRAINSIZE - ((( this->pitchMul * GRAINSIZE ) >> 8 ) % GRAINSIZE);
return true;
}
int16_t PitchShiftFixedOutput::pitchShift(int16_t value) {
buf_[writeAddress] = value;
int ii1 = (writeAddress * this->pitchMul) >> 8;
int output1 = buf[ii1 % GRAINSIZE];
int ii2 = ii1 + secondaryOffset;
int output2 = buf[ii2 % GRAINSIZE];
unsigned long f = 0;
if ( writeAddress >= GRAINSIZE*3/4 )
{
f = GRAINSIZE;
}
else if ( writeAddress >= GRAINSIZE/4 )
{
f = (writeAddress - GRAINSIZE/4) * 2;
}
int output = ( output1 * (GRAINSIZE - f ) + output2 * f ) /GRAINSIZE;
writeAddress++;
if (writeAddress >= GRAINSIZE)
{
writeAddress = 0; // loop around to beginning of grain
readAddress = 0;
buf_ = buf;
buf = buf == buf1 ? buf2 : buf1;
}
return output;
}

View file

@ -0,0 +1,44 @@
#pragma once
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "AudioTools.h"
//pitch shift effect with interpolaion fixed to 1.5 frequency factor, fixed delay, int16_t, 1 channel
class PitchShiftFixedOutput : public AudioOutput {
public:
PitchShiftFixedOutput(Print &out) { p_out = &out; }
PitchShiftInfo defaultConfig() {
PitchShiftInfo result;
result.bits_per_sample = sizeof(int16_t) * 8;
return result;
}
bool begin(PitchShiftInfo info);
size_t write(const uint8_t *data, size_t len) override {
size_t result = 0;
int16_t *p_in = (int16_t *)data;
int sample_count = len / sizeof(int16_t);
for (int j = 0; j < sample_count; j += 1) {
int16_t value = p_in[j];
int16_t out_value = pitchShift(value);
result += p_out->write((uint8_t *)&out_value, sizeof(int16_t));
}
return result;
}
void end() {}
protected:
Print *p_out = nullptr;
int16_t pitchShift(int16_t value);
uint32_t pitchMul;
unsigned long secondaryOffset;
};

View file

@ -17,6 +17,8 @@ import { useRouter } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { emotionOptions, r2UrlAudio, voices } from "@/lib/data"; import { emotionOptions, r2UrlAudio, voices } from "@/lib/data";
import EmojiComponent from "./EmojiComponent"; import EmojiComponent from "./EmojiComponent";
import { PitchFactors } from "@/lib/utils";
import { Slider } from "@/components/ui/slider";
interface SettingsDashboardProps { interface SettingsDashboardProps {
selectedUser: IUser; selectedUser: IUser;
@ -30,7 +32,8 @@ const formSchema = z.object({
voice: z.string().min(1, "Voice selection is required"), voice: z.string().min(1, "Voice selection is required"),
voiceCharacteristics: z.object({ voiceCharacteristics: z.object({
features: z.string().min(10, "Minimum 10 characters").max(150, "Maximum 150 characters"), features: z.string().min(10, "Minimum 10 characters").max(150, "Maximum 150 characters"),
emotion: z.string() emotion: z.string(),
pitchFactor: z.number().min(0.75).max(1.5),
}) })
}); });
@ -57,7 +60,8 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
voice: '', voice: '',
voiceCharacteristics: { voiceCharacteristics: {
features: '', features: '',
emotion: 'neutral' emotion: 'neutral',
pitchFactor: 1.0,
} }
}); });
@ -110,30 +114,33 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
} }
}; };
const handleVoiceCharacteristicChange = (characteristic: 'features' | 'emotion', value: string) => { const handleVoiceCharacteristicChange = (characteristic: 'features' | 'emotion' | 'pitchFactor', value: string | number) => {
const newVoiceCharacteristics = { const newVoiceCharacteristics = {
...formData.voiceCharacteristics, ...formData.voiceCharacteristics,
[characteristic]: value [characteristic]: characteristic === 'pitchFactor' ? Number(value) : value
}; };
// Validate just this nested field // Validate just this nested field
try { try {
formSchema.shape.voiceCharacteristics.shape[characteristic].parse(value); if (characteristic === 'pitchFactor') {
// Clear error if validation passes formSchema.shape.voiceCharacteristics.shape.pitchFactor.parse(newVoiceCharacteristics[characteristic]);
setFormErrors(prev => ({ ...prev, [characteristic]: undefined })); } else {
} catch (error: unknown) { formSchema.shape.voiceCharacteristics.shape[characteristic].parse(newVoiceCharacteristics[characteristic]);
if (error instanceof z.ZodError) { }
// Type assertion is needed here // Clear error if validation passes
const zodError = error as z.ZodError; setFormErrors(prev => ({ ...prev, [characteristic]: undefined }));
setFormErrors(prev => ({ ...prev, [characteristic]: zodError.errors[0].message })); } catch (error: unknown) {
if (error instanceof z.ZodError) {
const zodError = error as z.ZodError;
setFormErrors(prev => ({ ...prev, [characteristic]: zodError.errors[0].message }));
}
} }
}
setFormData({
setFormData({ ...formData,
...formData, voiceCharacteristics: newVoiceCharacteristics
voiceCharacteristics: newVoiceCharacteristics });
}); };
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@ -174,7 +181,8 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
is_story: false, is_story: false,
key: formData.title.toLowerCase().replace(/ /g, '_') + "_" + uuidv4(), key: formData.title.toLowerCase().replace(/ /g, '_') + "_" + uuidv4(),
creator_id: selectedUser.user_id, creator_id: selectedUser.user_id,
short_description: formData.description short_description: formData.description,
pitch_factor: formData.voiceCharacteristics.pitchFactor
}); });
if (personality) { if (personality) {
@ -248,204 +256,240 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
}; };
return ( return (
<div className="overflow-hidden pb-2 w-full flex-auto flex flex-col pl-1 max-w-screen-sm"> <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">
<form onSubmit={handleSubmit} className="space-y-6 mt-8 w-full pr-1">
{currentStep === 'personality' ?
{currentStep === 'personality' ? <div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2"> {/* Voice Picker */}
Character Details <div className="space-y-2">
</h2> <Label htmlFor="voice">Pick a voice</Label>
<div className="space-y-2"> <p className="text-sm text-gray-500">
<Label htmlFor="title">Title</Label> Click a voice to preview how it sounds.
<Input </p>
id="title"
placeholder="E.g., 'Storytelling Assistant'" <div className="grid grid-cols-3 gap-3">
value={formData.title} {voices.map((voice) => (
onChange={(e) => handleInputChange('title', e.target.value)} <div
onBlur={() => handleBlur('title')} key={voice.id}
/> className={`
<p className="text-sm flex justify-between"> relative rounded-lg border p-3 transition-all
<span className={formErrors.title ? "text-red-500" : "text-gray-500"}> ${formData.voice === voice.id
{formErrors.title || "Give your AI character a name or title."} ? 'border-2 border-blue-500 shadow-sm ' + voice.color
</span> : 'border-gray-200 hover:border-gray-300 cursor-pointer'
<span className="text-gray-500">{formData.title.length}/50</span> }
</p> `}
</div> onClick={() => {
<div className="space-y-2"> handleInputChange('voice', voice.id);
<Label htmlFor="description">Description</Label> previewVoice(voice.id);
<Textarea }}
id="description" >
placeholder="Describe what your AI character does and its personality..." <div className="flex flex-col">
rows={2} <div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
value={formData.description} <div className="text-2xl mt-0.5">
onChange={(e) => handleInputChange('description', e.target.value)} <EmojiComponent emoji={voice.emoji} />
onBlur={() => handleBlur('description')} /> </div>
<p className="text-sm flex justify-between"> <div className="flex flex-col text-center sm:text-left">
<span className={formErrors.description ? "text-red-500" : "text-gray-500"}> <span className="font-medium">{voice.name}</span>
{formErrors.description || "Briefly describe your character's purpose and personality."} <span className="text-xs text-gray-600">{voice.description}</span>
</span> </div>
<span className="text-gray-500">{formData.description.length}/200</span> </div>
</p>
</div> {previewingVoice === voice.id && (
<div className="space-y-2"> <div className="absolute top-2 right-2">
<Label htmlFor="prompt">Prompt</Label> <div className="animate-pulse text-blue-500">
<Textarea <Volume2 size={20} />
id="prompt" </div>
placeholder="Enter specific instructions for how your AI should respond..." </div>
rows={4} )}
value={formData.prompt} </div>
onChange={(e) => handleInputChange('prompt', e.target.value)} </div>
onBlur={() => handleBlur('prompt')} ))}
/> </div>
<p className="text-sm flex justify-between"> </div>
<span className={formErrors.prompt ? "text-red-500" : "text-gray-500"}>
{formErrors.prompt || "Detailed instructions that define how your AI responds to users."} <div className="space-y-2">
</span> <Label htmlFor="title">Title</Label>
<span className="text-gray-500">{formData.prompt.length}/1000</span> <Input
</p> id="title"
</div> placeholder="AI Hulk"
</div> : value={formData.title}
<div className="space-y-4"> onChange={(e) => handleInputChange('title', e.target.value)}
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2"> onBlur={() => handleBlur('title')}
Voice Details />
</h2> <p className="text-sm flex justify-between">
<div className="space-y-2"> <span className={formErrors.title ? "text-red-500" : "text-gray-500"}>
<Label htmlFor="voice">Pick a voice</Label> {formErrors.title}
<p className="text-sm text-gray-500"> </span>
Click a voice to preview how it sounds. Select one for your character. <span className="text-gray-500">{formData.title.length}/50</span>
</p> </p>
<div className="grid grid-cols-3 gap-3"> </div>
{voices.map((voice) => ( <div className="space-y-2">
<div <Label htmlFor="description">Description</Label>
key={voice.id} <Textarea
className={` id="description"
rounded-lg border p-3 transition-all relative placeholder="Describe what your AI character does and its personality..."
${formData.voice === voice.id rows={2}
? 'border-2 border-blue-500 shadow-sm ' + voice.color value={formData.description}
: 'border-gray-200 hover:border-gray-300 cursor-pointer' onChange={(e) => handleInputChange('description', e.target.value)}
} onBlur={() => handleBlur('description')} />
`} <p className="text-sm flex justify-between">
onClick={() => { <span className={formErrors.description ? "text-red-500" : "text-gray-500"}>
handleInputChange('voice', voice.id); {formErrors.description}
previewVoice(voice.id); </span>
}} <span className="text-gray-500">{formData.description.length}/200</span>
> </p>
<div className="flex flex-col"> </div>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3"> <div className="space-y-2">
<div className="text-2xl mt-0.5"> <Label htmlFor="prompt">Prompt</Label>
<EmojiComponent emoji={voice.emoji} /> <Textarea
</div> id="prompt"
<div className="flex flex-col text-center sm:text-left"> placeholder="Enter specific instructions for how your AI should respond..."
<span className="font-medium">{voice.name}</span> rows={4}
<span className="text-xs text-gray-600">{voice.description}</span> value={formData.prompt}
</div> onChange={(e) => handleInputChange('prompt', e.target.value)}
</div> onBlur={() => handleBlur('prompt')}
/>
{previewingVoice === voice.id && ( <p className="text-sm flex justify-between">
<div className="absolute top-2 right-2"> <span className={formErrors.prompt ? "text-red-500" : "text-gray-500"}>
<div className="animate-pulse text-blue-500"> {formErrors.prompt}
<Volume2 size={20} /> </span>
</div> <span className="text-gray-500">{formData.prompt.length}/1000</span>
</div> </p>
)} </div>
</div> :
<div className="space-y-6">
{/* Pitch Slider */}
<div className="flex flex-col gap-4 -pt-6 pb-4">
<Label htmlFor="pitchFactor">Voice Pitch</Label>
<p className="text-sm text-gray-500">
Slide to adjust voice depth on your device
</p>
<div className="space-y-6">
<Slider
id="pitchFactor"
min={0.75}
max={1.5}
step={0.25}
value={[formData.voiceCharacteristics.pitchFactor]}
onValueChange={(value: number[]) => {
handleVoiceCharacteristicChange('pitchFactor', value[0]);
}}
className="w-full"
/>
<div className="flex justify-between text-sm">
{PitchFactors.map((item, idx) => (
<div key={idx} className="flex flex-col items-center gap-1">
<EmojiComponent emoji={item.emoji} />
<span className="font-medium">{item.label}</span>
<span className="text-xs hidden sm:block text-gray-500">{item.desc}</span>
</div> </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> </div>
)}
</form>
{/* Voice Characteristics Textarea */}
<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,
},
}));
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}
</span>
<span className="text-gray-500">
{formData.voiceCharacteristics.features.length}/150
</span>
</p>
</div> </div>
{/* Emotional Tone Picker */}
<div className="space-y-4 pb-2">
<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"
>
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>
); );
}; };

View file

@ -10,6 +10,13 @@ export const getOpenGraphMetadata = (title: string) => {
}; };
}; };
export const PitchFactors = [
{ emoji: "🧟‍♂️", label: "Super Deep", desc: "Like Hulk" },
{ emoji: "👤", label: "Normal", desc: "Regular voice" },
{ emoji: "👧", label: "Higher", desc: "Kid-like voice" },
{ emoji: "🐿️", label: "Squeaky", desc: "Like Alvin" },
];
// code in the form: aabbccddeeff // code in the form: aabbccddeeff
export const isValidMacAddress = (macAddress: string): boolean => { export const isValidMacAddress = (macAddress: string): boolean => {
// Check if macAddress is a valid MAC address with colon separators // Check if macAddress is a valid MAC address with colon separators

View file

@ -60,17 +60,17 @@ declare global {
type UserInfo = type UserInfo =
| { | {
user_type: "user"; user_type: "user";
user_metadata: IUserMetadata; user_metadata: IUserMetadata;
} }
| { | {
user_type: "doctor"; user_type: "doctor";
user_metadata: IDoctorMetadata; user_metadata: IDoctorMetadata;
} }
| { | {
user_type: "business"; user_type: "business";
user_metadata: IBusinessMetadata; user_metadata: IBusinessMetadata;
}; };
interface IBusinessMetadata {} interface IBusinessMetadata {}
@ -100,7 +100,15 @@ declare global {
type TTSModel = "FISH" | "AZURE"; type TTSModel = "FISH" | "AZURE";
type OaiVoice = 'ash' | 'alloy' | 'echo' | 'shimmer' | 'ballad' | 'coral' | 'sage' | 'verse'; type OaiVoice =
| "ash"
| "alloy"
| "echo"
| "shimmer"
| "ballad"
| "coral"
| "sage"
| "verse";
// characters <-> personalities table // characters <-> personalities table
interface IPersonality { interface IPersonality {
@ -116,6 +124,7 @@ declare global {
character_prompt: string; character_prompt: string;
voice_prompt: string; voice_prompt: string;
creator_id: string | null; creator_id: string | null;
pitch_factor: number;
} }
type PersonalityFilter = "is_child_voice" | "is_doctor" | "is_story"; type PersonalityFilter = "is_child_voice" | "is_doctor" | "is_story";

View file

@ -116,9 +116,10 @@ wss.on("connection", async (ws: WSWebSocket, payload: IPayload) => {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "auth", type: "auth",
volume_control: user.device?.volume ?? 100, volume_control: user.device?.volume ?? 20,
is_ota: user.device?.is_ota ?? false, is_ota: user.device?.is_ota ?? false,
is_reset: user.device?.is_reset ?? false, is_reset: user.device?.is_reset ?? false,
pitch_factor: user.personality?.pitch_factor ?? 1,
}), }),
); );

View file

@ -1,9 +1,9 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from "@supabase/supabase-js";
declare global { declare global {
interface IConversation { interface IConversation {
conversation_id: string; conversation_id: string;
role: 'user' | 'assistant'; role: "user" | "assistant";
content: string; content: string;
user_id: string; user_id: string;
is_sensitive: boolean; is_sensitive: boolean;
@ -32,7 +32,15 @@ declare global {
is_child_voice: boolean; is_child_voice: boolean;
is_story: boolean; is_story: boolean;
key: string; key: string;
oai_voice: 'ash' | 'alloy' | 'echo' | 'shimmer' | 'ballad' | 'coral' | 'sage' | 'verse'; oai_voice:
| "ash"
| "alloy"
| "echo"
| "shimmer"
| "ballad"
| "coral"
| "sage"
| "verse";
voice_description: string; voice_description: string;
title: string; title: string;
subtitle: string; subtitle: string;
@ -40,6 +48,7 @@ declare global {
character_prompt: string; character_prompt: string;
voice_prompt: string; voice_prompt: string;
creator_id: string | null; creator_id: string | null;
pitch_factor: number;
} }
interface ILanguage { interface ILanguage {
@ -61,15 +70,15 @@ declare global {
type UserInfo = type UserInfo =
| { | {
user_type: 'user'; user_type: "user";
user_metadata: IUserMetadata; user_metadata: IUserMetadata;
} }
| { | {
user_type: 'doctor'; user_type: "doctor";
user_metadata: IDoctorMetadata; user_metadata: IDoctorMetadata;
} }
| { | {
user_type: 'business'; user_type: "business";
user_metadata: IBusinessMetadata; user_metadata: IBusinessMetadata;
}; };

View file

@ -0,0 +1,2 @@
ALTER TABLE "public"."personalities"
ADD COLUMN "pitch_factor" real DEFAULT 1.0 NOT NULL;