App directory #4

This commit is contained in:
Darren Clarke 2023-06-28 12:55:24 +00:00 committed by GitHub
parent 69706053c6
commit 4d743c5e67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 223 additions and 107 deletions

View file

@ -0,0 +1,33 @@
"use client";
import {
SimpleForm,
TextInput,
Create,
PasswordInput,
CreateProps,
} from "react-admin";
import { ProviderKindInput } from "./shared";
// import TextField from "@mui/material/TextField";
/* const TwilioCredentialsInput = () => (
<span>
<TextField name="accountSid" label="Account Sid" />
<TextField name="authToken" label="Auth Token" />
</span>
); */
const ProviderCreate = (props: CreateProps) => (
<Create {...props} title="Create Providers">
<SimpleForm>
<ProviderKindInput />
<TextInput source="name" />
<TextInput source="credentials.accountSid" />
<TextInput source="credentials.apiKeySid" />
<PasswordInput source="credentials.apiKeySecret" />
</SimpleForm>
</Create>
);
export default ProviderCreate;

View file

@ -0,0 +1,31 @@
"use client";
import {
SimpleForm,
TextInput,
PasswordInput,
Edit,
EditProps,
} from "react-admin";
import { ProviderKindInput } from "./shared";
const ProviderTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>Provider {title}</span>;
};
const ProviderEdit = (props: EditProps) => (
<Edit title={<ProviderTitle />} {...props}>
<SimpleForm>
<TextInput disabled source="id" />
<ProviderKindInput disabled />
<TextInput source="name" />
<TextInput source="credentials.accountSid" />
<TextInput source="credentials.apiKeySid" />
<PasswordInput source="credentials.apiKeySecret" />
</SimpleForm>
</Edit>
);
export default ProviderEdit;

View file

@ -0,0 +1,16 @@
"use client";
import { List, Datagrid, DateField, TextField, ListProps } from "react-admin";
const ProviderList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<TextField source="kind" />
<TextField source="name" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
</Datagrid>
</List>
);
export default ProviderList;

View file

@ -0,0 +1,14 @@
"use client";
/* eslint-disable import/no-anonymous-default-export */
import ProviderIcon from "@mui/icons-material/Business";
import ProviderList from "./ProviderList";
import ProviderEdit from "./ProviderEdit";
import ProviderCreate from "./ProviderCreate";
export default {
list: ProviderList,
create: ProviderCreate,
edit: ProviderEdit,
icon: ProviderIcon,
};

View file

@ -0,0 +1,11 @@
"use client";
import { SelectInput } from "react-admin";
export const ProviderKindInput = (props: any) => (
<SelectInput
source="kind"
choices={[{ id: "TWILIO", name: "Twilio" }]}
{...props}
/>
);

View file

@ -0,0 +1,43 @@
.voiceWaveWrapper {
width: 100%;
max-height: 50px;
display: flex;
justify-content: center;
}
.hidden {
display: none;
}
.visible {
display: block;
}
.buttonWrapper {
display: flex;
align-items: center;
justify-content: center;
}
.playerWrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.recordTime {
align-self: center;
width: 66px;
height: 18px;
margin-top: 10px;
font-family: "sans";
font-style: normal;
font-weight: normal;
font-size: 15px;
line-height: 18px;
color: #000;
}
.content {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}

View file

@ -0,0 +1,152 @@
"use client";
import { useInput } from "react-admin";
import React, { useState } from "react";
import dynamic from "next/dynamic";
import MicIcon from "@mui/icons-material/Mic";
import StopIcon from "@mui/icons-material/Stop";
import Button from "@mui/material/Button";
import { useTheme } from "@mui/styles"; // makeStyles,
// import AudioPlayer from "material-ui-audio-player";
import { useStopwatch } from "react-timer-hook";
import style from "./MicInput.module.css";
// import type { ReactMicProps } from "react-mic";
const ReactMic = dynamic<any>(
() => {
throw new Error(
"MIC INPUT FEATURE IS DISABLED"
); /* return import("react-mic").then((mod) => mod.ReactMic); */
},
{ ssr: false }
);
const blobToDataUri = (blob: Blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
return new Promise((resolve) => {
reader.onloadend = () => {
resolve(reader.result);
};
});
};
const dataUriToObj = (dataUri: string) => {
const [prefix, base64] = dataUri.split(",");
const mime = prefix.slice(5, prefix.indexOf(";"));
const result: any = {};
result[mime] = base64;
return result;
};
const blobToResult = async (blob: Blob) => {
const result = dataUriToObj((await blobToDataUri(blob)) as string);
return result;
};
/* const resultToDataUri = (result: Record<string, any>): string => {
if (!result || !result["audio/webm"]) return "";
const base64 = result["audio/webm"];
const r = `data:audio/webm;base64,${base64}`;
return r;
}; */
const MicInput = (props: any) => {
const { seconds, minutes, hours, start, reset, pause } = useStopwatch();
const theme = useTheme();
const {
field: { onChange }, // value
} = useInput(props);
const [record, setRecorder] = useState({ record: false });
// const decodedValue = resultToDataUri(value);
const startRecording = () => {
setRecorder({ record: true });
reset();
start();
};
const stopRecording = () => {
setRecorder({ record: false });
pause();
};
async function onData(recordedBlob: any) {
console.log({ recordedBlob });
}
async function onStop(recordedBlob: any) {
const result = await blobToResult(recordedBlob.blob);
onChange(result);
}
const isRecording = record.record;
// const canPlay = !isRecording && decodedValue;
const duration = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
/*
const useStyles = makeStyles(() => ({
volumeIcon: {
display: "none",
},
mainSlider: {
display: "none",
},
}));
*/
return (
<div className="MuiFormControl-marginDense RaFormInput-input-40">
<div className={style.content}>
<div className={style.voiceWaveWrapper}>
<ReactMic
record={record.record}
className={isRecording ? style.visible : style.hidden}
onStop={onStop}
onData={onData}
// @ts-ignore
strokeColor={theme.palette.primary.main}
backgroundColor="white"
mimeType="audio/webm"
visualSetting="frequencyBars"
/>
</div>
<div>{isRecording ? <p>Recording... {duration}</p> : ""}</div>
<div className={style.buttonWrapper}>
{isRecording ? (
<Button variant="contained" color="primary" onClick={stopRecording}>
<StopIcon />
Stop
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={startRecording}
style={{ marginRight: "20px" }}
>
<MicIcon />
Record
</Button>
)}
</div>
<div className={style.playerWrapper}>
{/* canPlay && (
<AudioPlayer
elevation={0}
src={decodedValue}
variation="secondary"
volume={false}
useStyles={useStyles}
/>
) */}
</div>
</div>
</div>
);
};
export default MicInput;

View file

@ -0,0 +1,54 @@
"use client";
import {
SimpleForm,
Create,
FormDataConsumer,
SelectInput,
BooleanInput,
ReferenceInput,
required,
CreateProps,
} from "react-admin";
import TwilioLanguages from "./twilio-languages";
import {
PromptInput,
VoiceInput,
AvailableNumbersInput,
populateNumber,
} from "./shared";
import MicInput from "./MicInput";
const VoiceLineCreate = (props: CreateProps) => (
<Create {...props} title="Create Voice Line" transform={populateNumber}>
<SimpleForm>
<ReferenceInput
label="Provider"
source="providerId"
reference="voiceProviders"
validate={[required()]}
>
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
</ReferenceInput>
<FormDataConsumer subscription={{ values: true }}>
{AvailableNumbersInput}
</FormDataConsumer>
<SelectInput
source="language"
choices={TwilioLanguages.languages}
validate={[required()]}
/>
<FormDataConsumer subscription={{ values: true }}>
{VoiceInput}
</FormDataConsumer>
<FormDataConsumer subscription={{ values: true }}>
{PromptInput}
</FormDataConsumer>
<BooleanInput source="audioPromptEnabled" />
<MicInput source="promptAudio" />
</SimpleForm>
</Create>
);
export default VoiceLineCreate;

View file

@ -0,0 +1,51 @@
"use client";
import {
SimpleForm,
TextInput,
Edit,
FormDataConsumer,
SelectInput,
BooleanInput,
ReferenceInput,
required,
EditProps,
} from "react-admin";
import TwilioLanguages from "./twilio-languages";
import { VoiceInput, PromptInput } from "./shared";
import MicInput from "./MicInput";
const VoiceLineTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>VoiceLine {title}</span>;
};
const VoiceLineEdit = (props: EditProps) => (
<Edit title={<VoiceLineTitle />} {...props}>
<SimpleForm>
<ReferenceInput
disabled
label="Provider"
source="providerId"
reference="providers"
validate={[required()]}
>
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
</ReferenceInput>
<TextInput disabled source="providerLineSid" />
<TextInput disabled source="number" />
<SelectInput source="language" choices={TwilioLanguages.languages} />
<FormDataConsumer subscription={{ values: true }}>
{VoiceInput}
</FormDataConsumer>
<FormDataConsumer subscription={{ values: true }}>
{PromptInput}
</FormDataConsumer>
<BooleanInput source="audioPromptEnabled" />
<MicInput source="promptAudio" />
</SimpleForm>
</Edit>
);
export default VoiceLineEdit;

View file

@ -0,0 +1,32 @@
"use client";
import {
List,
ListProps,
Datagrid,
DateField,
FunctionField,
TextField,
ReferenceField,
} from "react-admin";
const VoiceLineList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<ReferenceField
label="Provider"
source="providerId"
reference="providers"
>
<FunctionField render={(p: any) => `${p.kind}: ${p.name}`} />
</ReferenceField>
<TextField source="number" />
<TextField source="language" />
<TextField source="voice" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
</Datagrid>
</List>
);
export default VoiceLineList;

View file

@ -0,0 +1,14 @@
"use client";
import VoiceLineIcon from "@mui/icons-material/PhoneCallback";
import VoiceLineList from "./VoiceLineList";
import VoiceLineEdit from "./VoiceLineEdit";
import VoiceLineCreate from "./VoiceLineCreate";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: VoiceLineList,
create: VoiceLineCreate,
edit: VoiceLineEdit,
icon: VoiceLineIcon,
};

View file

@ -0,0 +1,149 @@
/* add css module styles here (optional) */
@import url("https://fonts.googleapis.com/css?family=Lato:400,700&display=swap");
.recorder_library_box,
.recorder_library_box * {
box-sizing: border-box;
padding: 0;
margin: 0;
font-family: "Lato", sans-serif;
}
.recorder_library_box .recorder_box {
width: 100%;
margin: 0 auto;
padding: 30px 0;
}
.recorder_library_box .recorder_box_inner {
min-height: 400px;
background: #212121;
border-radius: 0 0 3px 3px;
display: flex;
flex-direction: column;
}
.recorder_library_box .mic_icon {
width: 60px;
display: flex;
height: 60px;
position: fixed;
justify-content: center;
align-items: center;
background: rgb(245, 0, 87);
border-radius: 50%;
bottom: 65px;
right: 20%;
color: #fff;
font-size: 25px;
}
.recorder_library_box .reco_header {
display: flex;
justify-content: space-between;
background: #bd9f61;
align-items: center;
padding: 20px 20px;
color: #fff;
border-radius: 3px 3px 0 0;
}
.recorder_library_box .reco_header .h2 {
font-weight: 400;
}
.recorder_library_box .reco_header .close_icons {
font-size: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
justify-content: center;
align-items: center;
display: flex;
cursor: pointer;
transition: 0.5s ease all;
}
.recorder_library_box .reco_header .close_icons:hover {
background: rgba(123, 118, 106, 0.21);
}
.recorder_library_box .record_section {
position: relative;
flex: 1;
}
.recorder_library_box .record_section .mic_icon {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 20px;
}
.recorder_library_box .record_section .duration_section {
position: absolute;
left: 50%;
transform: translate(-50%);
bottom: 100px;
}
.recorder_library_box .btn_wrapper {
margin: 20px 30px;
}
.recorder_library_box .btn_wrapper .btn {
border: 0;
outline: 0;
padding: 10px 20px;
border-radius: 20px;
background: #185fec;
color: #fff;
cursor: pointer;
border: 1px solid #185fec;
transition: 0.3s ease all;
}
.recorder_library_box .btn_wrapper .btn:hover {
background: #fff;
color: #185fec;
}
.recorder_library_box .btn_wrapper .clear_btn {
background: #fff;
color: #185fec;
margin-left: 15px;
}
.recorder_library_box .btn_wrapper .clear_btn:hover {
background: #185fec;
color: #fff;
}
.recorder_library_box .duration {
text-align: center;
}
.recorder_library_box .recorder_page_box {
min-height: calc(100vh - 128px);
background: #fff;
}
.recorder_library_box .duration * {
color: #fff;
font-size: 60px;
}
.recorder_library_box .duration_section .help {
color: #fff;
}
.recorder_library_box .record_controller {
position: absolute;
left: 50%;
transform: translate(-50%);
bottom: 0px;
padding: 20px 0;
display: flex;
}
.recorder_library_box .record_controller .icons {
width: 50px;
display: flex;
height: 50px;
justify-content: center;
align-items: center;
border-radius: 50%;
color: #fff;
margin-right: 15px;
font-size: 20px;
}
.recorder_library_box .record_controller .stop {
background: #940505;
}
.recorder_library_box .record_controller .pause {
background: #9c6702;
}

View file

@ -0,0 +1,307 @@
"use client";
/* eslint-disable react/display-name */
import React, { useState, useEffect } from "react";
import PlayIcon from "@mui/icons-material/PlayCircleFilled";
import {
TextInput,
SelectInput,
required,
useTranslate,
useNotify,
ReferenceInput,
ReferenceField,
TextField,
} from "react-admin";
import { IconButton, CircularProgress } from "@mui/material";
import absoluteUrl from "../../../_lib/absolute-url";
import TwilioLanguages from "./twilio-languages";
type TTSProvider = (voice: any, language: any, prompt: any) => Promise<void>;
const tts = async (providerId: any): Promise<TTSProvider> => {
const r = await fetch(
`/api/v1/voice/twilio/text-to-speech-token/${providerId}`
);
const { token } = await r.json();
const twilioClient = await import("twilio-client");
return (voice, language, prompt): Promise<void> =>
new Promise((resolve) => {
if (!voice || !language || !prompt) resolve();
const { Device } = twilioClient;
const device = new Device();
const silence = `${absoluteUrl().origin}/static/silence.mp3`;
device.setup(token, {
codecPrefences: ["opus", "pcmu"],
enableRingingState: false,
fakeLocalDTMF: true,
disableAudioContextSounds: true,
sounds: {
disconnect: silence,
incoming: silence,
outgoing: silence,
},
});
device.on("ready", (device: any) => {
device.connect({ language, voice, prompt });
});
device.on("disconnect", () => resolve());
device.on("error", () => resolve());
});
};
export const TextToSpeechButton = ({ form }: any) => {
const { providerId, language, voice, promptText: prompt } = form.formData;
const [loading, setLoading] = useState<boolean>(false);
const [ttsProvider, setTTSProvider] = useState<
undefined | { provider: TTSProvider }
>(undefined);
const [playText, setPlayText] = useState<
undefined | { func: () => Promise<void> }
>(undefined);
useEffect(() => {
(async () => {
if (providerId) {
setLoading(true);
setTTSProvider({ provider: await tts(providerId) });
setLoading(false);
}
})();
}, [providerId]);
useEffect(() => {
(async () => {
setPlayText({
async func() {
setLoading(true);
if (ttsProvider) await ttsProvider.provider(voice, language, prompt);
setLoading(false);
},
});
})();
}, [prompt, language, voice, ttsProvider, ttsProvider?.provider]);
const disabled = !(providerId && prompt?.length >= 2 && voice && language);
/* TODO add this back to IconButtonwhen we know how to extend MUI theme and appease typescript
variant="contained"
*/
return (
<IconButton onClick={playText?.func} disabled={disabled} color="primary">
{!loading && <PlayIcon />}
{loading && <CircularProgress size={20} />}
</IconButton>
);
};
export const PromptInput = (form: any, ...rest: any[]) => (
<TextInput
source="promptText"
multiline
options={{ fullWidth: true }}
InputProps={{ endAdornment: <TextToSpeechButton form={form} /> }}
{...rest}
/>
);
const validateVoice = (_args: any, values: any) => {
if (!values.language) return "validation.language";
if (!values.voice) return "validation.voice";
// @ts-expect-error
const availableVoices = TwilioLanguages.voices[values.language];
const found =
availableVoices.filter((v: any) => v.id === values.voice).length === 1;
if (!found) return "validation.voice";
return undefined;
};
export const VoiceInput = (form: any, ...rest: any[]) => {
// @ts-expect-error
const voice = TwilioLanguages.voices[form.formData.language] || [];
return (
// @ts-expect-error
<SelectInput
source="voice"
choices={voice}
validate={[required(), validateVoice]}
{...rest}
/>
);
};
let noAvailableNumbers = false;
let availableNumbers: any[] = [];
const getAvailableNumbers = async (providerId: string) => {
try {
const r = await fetch(`/api/v1/voice/providers/${providerId}/freeNumbers`);
availableNumbers = await r.json();
noAvailableNumbers = availableNumbers.length === 0;
return availableNumbers;
} catch (error) {
console.error(
`Could not fetch available numbers for provider ${providerId} - ${error}`
);
return [];
}
};
const sidToNumber = (sid: any) =>
availableNumbers.filter(({ id }) => id === sid).map(({ name }) => name)[0];
export const populateNumber = (data: any) => ({
...data,
number: sidToNumber(data.providerLineSid),
});
const hasNumbers = (
_args: any,
_value: any,
_values: any,
_translate: any,
..._props: any[]
) => {
if (noAvailableNumbers) return "validation.noAvailableNumbers";
return undefined;
};
export const AvailableNumbersInput = (form: any, ...rest: any[]) => {
const {
// @ts-expect-error: non-existent property
meta: { touched, error } = {},
// @ts-expect-error: non-existent property
input: { ...inputProps },
...props
} = rest;
const translate = useTranslate();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [choices, setChoices] = useState({});
// @ts-expect-error: Invalid return type
useEffect(async () => {
if (form && form.formData && form.formData.providerId) {
setLoading(true);
const choices = await getAvailableNumbers(form.formData.providerId);
setChoices({
choices,
helperText: noAvailableNumbers
? translate("validation.noAvailableNumbers")
: "",
});
if (noAvailableNumbers)
notify("validation.noAvailableNumbers", { type: "error" });
setLoading(false);
}
}, [form, notify, translate]);
return (
<>
<SelectInput
label="Number"
source="providerLineSid"
// @ts-expect-error: non-existent property
choices={choices.choices}
disabled={loading}
validate={[hasNumbers, required()]}
// @ts-expect-error: non-existent property
error={Boolean(touched && error) || Boolean(choices.helperText)}
// @ts-expect-error: non-existent property
helperText={choices.helperText}
{...inputProps}
{...props}
/>
{loading && <CircularProgress />}
</>
);
};
/*
const voiceLineName = voiceLine => {
return voiceLine.number
}
const getVoiceLineChoices = async ():Promise<any[]> => {
try {
const r = await fetch(`/api/v1/voice/voice-line`);
const lines = await r.json();
if(lines.data?.length > 0) {
return lines.data.map(voiceLine => ({"id": voiceLine.id, "name": voiceLineName(voiceLine)}))
}
return [];
} catch (error) {
console.error(
`Could not fetch voice lines error: ${error}`
);
return [];
}
}
export const AsyncSelectInput = (choiceLoader: () => Promise<any[]>, label, source, translationEmpty,) => (form, ...rest) => {
const {
meta: { touched, error } = {},
input: { ...inputProps },
...props
} = rest;
const translate = useTranslate();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [choices, setChoices] = useState({choices: []});
useEffect(() => {
(async () => {
setLoading(true);
//const items = await choiceLoader()
const items = [{"id": "testing", "name": "OMG"}]
setChoices({
choices: items,
helperText: items.length === 0
? translate(translationEmpty)
: "",
});
if (items.length === 0) notify(translationEmpty, "error");
setLoading(false);
})()}, [form && form.formData ? form.formData.providerId : undefined]);
const isNotEmpty = () => {
if (choices.choices.length === 0) return translationEmpty;
return undefined;
};
return (
<>
{choices.choices.length > 0 &&
<SelectInput
label={label}
source={source}
choices={choices.choices}
disabled={loading}
validate={[isNotEmpty, required()]}
error={Boolean(touched && error) || Boolean(choices.helperText)}
helperText={choices.helperText}
{...inputProps}
{...props}
/>}
{loading && <CircularProgress />}
</>
)
}
export const VoiceLineSelectInput = AsyncSelectInput(getVoiceLineChoices, "Voice Line", "backendId", "validation.noVoiceLines" )
*/
export const VoiceLineSelectInput = (source: string) => () =>
(
<ReferenceInput
label="Voice Line"
source={source}
reference="voiceLines"
validate={[required()]}
>
<SelectInput optionText="number" />
</ReferenceInput>
);
export const VoiceLineField = (source: string) => () =>
(
<ReferenceField label="Voice Line" source={source} reference="voiceLines">
<TextField source="number" />
</ReferenceField>
);

View file

@ -0,0 +1,65 @@
const languages = {
languages: [
{ id: "arb", name: "Arabic" },
{ id: "cy-GB", name: "Welsh" },
{ id: "da-DK", name: "Danish" },
{ id: "de-DE", name: "German" },
{ id: "en-US", name: "English (US)" },
{ id: "en-AU", name: "English (Australian)" },
{ id: "en-GB", name: "English (British)" },
{ id: "en-GB-WLS", name: "English (Welsh)" },
{ id: "en-IN", name: "English (Indian)" },
{ id: "es-ES", name: "Spanish (Castilian)" },
{ id: "es-MX", name: "Spanish (Mexico)" },
{ id: "es-US", name: "Spanish (Latin American)" },
{ id: "fr-CA", name: "French (Canadian)" },
{ id: "fr-FR", name: "French" },
{ id: "hi-IN", name: "Hindi" },
{ id: "is-IS", name: "Icelandic" },
{ id: "it-IT", name: "Italian" },
{ id: "ja-JP", name: "Japanese" },
{ id: "ko-KR", name: "Korean" },
{ id: "nb-NO", name: "Norwegian" },
{ id: "nl-NL", name: "Dutch" },
{ id: "pl-PL", name: "Polish" },
{ id: "pt-BR", name: "Portuguese (Brazilian)" },
{ id: "pt-PT", name: "Portuguese (European)" },
{ id: "ro-RO", name: "Romanian" },
{ id: "ru-RU", name: "Russian" },
{ id: "sv-SE", name: "Swedish" },
{ id: "tr-TR", name: "Turkish" },
{ id: "zh-CN", name: "Chinese (Mandarin)" },
],
voices: {
arb: [{ id: "Polly.Zeina", name: "Zeina" }],
"cy-GB": [{ id: "Polly.Gwyneth", name: "Gwyneth" }],
"da-DK": [{ id: "Polly.Naja", name: "Naja" }],
"de-DE": [{ id: "Polly.Marlene", name: "Marlene" }],
"en-US": [{ id: "Polly.Salli", name: "Salli" }],
"en-AU": [{ id: "Polly.Nicole", name: "Nicole" }],
"en-GB": [{ id: "Polly.Amy", name: "Amy" }],
"en-GB-WLS": [{ id: "Polly.Geraint", name: "Geraint" }],
"en-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
"es-ES": [{ id: "Polly.Conchita", name: "Conchita" }],
"es-MX": [{ id: "Polly.Mia", name: "Mia" }],
"es-US": [{ id: "Polly.Penelope", name: "Penelope" }],
"fr-CA": [{ id: "Polly.Chantal", name: "Chantal" }],
"fr-FR": [{ id: "Polly.Celine", name: "Celine" }],
"hi-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
"is-IS": [{ id: "Polly.Dora", name: "Dora" }],
"it-IT": [{ id: "Polly.Carla", name: "Carla" }],
"ja-JP": [{ id: "Polly.Mizuki", name: "Mizuki" }],
"ko-KR": [{ id: "Polly.Seoyeon", name: "Seoyeon" }],
"nb-NO": [{ id: "Polly.Liv", name: "Liv" }],
"nl-NL": [{ id: "Polly.Lotte", name: "Lotte" }],
"pl-PL": [{ id: "Polly.Ewa", name: "Ewa" }],
"pt-BR": [{ id: "Polly.Vitoria", name: "Vitoria" }],
"pt-PT": [{ id: "Polly.Ines", name: "Ines" }],
"ro-RO": [{ id: "Polly.Carmen", name: "Carmen" }],
"ru-RU": [{ id: "Polly.Tatyana", name: "Tatyana" }],
"sv-SE": [{ id: "Polly.Astrid", name: "Astrid" }],
"tr-TR": [{ id: "Polly.Filiz", name: "Filiz" }],
"zh-CN": [{ id: "Polly.Zhiyu", name: "Zhiyu" }],
},
};
export default languages;