feat google custom voice (#338)

* feat google custom voice

* google custom voice

* wip

* wip

* wip
This commit is contained in:
Hoan Luu Huu
2023-10-30 20:28:34 -04:00
committed by GitHub
parent a382f21f86
commit e2157ce50e
7 changed files with 440 additions and 17 deletions
+10
View File
@@ -205,6 +205,15 @@ export const ELEVENLABS_MODEL_OPTIONS = [
{ name: "Multilingual v1", value: "eleven_multilingual_v1" },
{ name: "English v1", value: "eleven_monolingual_v1" },
];
// Google Custom Voice reported usage options
export const DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = "REALTIME";
export const GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = [
{ name: "REPORTED_USAGE_UNSPECIFIED", value: "REPORTED_USAGE_UNSPECIFIED" },
{ name: "REALTIME", value: "REALTIME" },
{ name: "OFFLINE", value: "OFFLINE" },
];
/** Password Length options */
export const PASSWORD_MIN = 8;
@@ -352,3 +361,4 @@ export const API_PRICE = `${API_BASE_URL}/Prices`;
export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
export const API_SIGNIN = `${API_BASE_URL}/signin`;
export const API_GOOGLE_CUSTOM_VOICES = `${API_BASE_URL}/GoogleCustomVoices`;
+32
View File
@@ -33,6 +33,7 @@ import {
API_SUBSCRIPTIONS,
API_CHANGE_PASSWORD,
API_SIGNIN,
API_GOOGLE_CUSTOM_VOICES,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -94,6 +95,8 @@ import type {
LanguageOption,
VoiceOption,
GetLanguages,
GoogleCustomVoice,
GoogleCustomVoicesQuery,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -501,6 +504,13 @@ export const postChangepassword = (payload: Partial<ChangePassword>) => {
export const postSignIn = (payload: Partial<SignIn>) => {
return postFetch<SignIn, Partial<SignIn>>(API_SIGNIN, payload);
};
export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
return postFetch<SidResponse, Partial<GoogleCustomVoice>>(
API_GOOGLE_CUSTOM_VOICES,
payload
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
@@ -630,6 +640,17 @@ export const putActivationCode = (
payload
);
};
export const putGoogleCustomVoice = (
sid: string,
payload: Partial<GoogleCustomVoice>
) => {
return putFetch<EmptyResponse, Partial<GoogleCustomVoice>>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}`,
payload
);
};
/** Named wrappers for `deleteFetch` */
export const deleteUser = (sid: string) => {
@@ -719,6 +740,10 @@ export const deleteClient = (sid: string) => {
export const deleteRecord = (url: string) => {
return deleteFetch<EmptyResponse>(url);
};
export const deleteGoogleCustomVoice = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_GOOGLE_CUSTOM_VOICES}/${sid}`);
};
/** Named wrappers for `getFetch` */
export const getUser = (sid: string) => {
@@ -771,6 +796,13 @@ export const getAvailability = (domain: string) => {
);
};
export const getGoogleCustomVoices = (
query: Partial<GoogleCustomVoicesQuery>
) => {
const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query);
return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`);
};
/** Wrappers for APIs that can have a mock dev server response */
export const getMe = () => {
+15
View File
@@ -372,6 +372,14 @@ export interface RecentCall {
recording_url?: string;
}
export interface GoogleCustomVoice {
google_custom_voice_sid?: string;
speech_credential_sid?: string;
name: string;
reported_usage: string;
model: string;
}
export interface SpeechCredential {
speech_credential_sid: string;
service_provider_sid: null | string;
@@ -528,6 +536,13 @@ export interface CallQuery extends PageQuery {
answered?: string;
}
export interface GoogleCustomVoicesQuery {
speech_credential_sid?: string;
label?: string;
account_sid?: string;
service_provider_sid: string;
}
export interface PagedResponse<Type> {
page_size: number;
total: number;
@@ -63,6 +63,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const navigate = useNavigate();
const { synthesis, recognizers } = useSpeechVendors();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useApiData<Application[]>("Applications");
const [applicationName, setApplicationName] = useState("");
@@ -674,6 +675,10 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
);
})}
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
synthesis={synthesis}
ttsVendor={[synthVendor, setSynthVendor]}
@@ -701,6 +706,10 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}}
>
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
synthesis={synthesis}
ttsVendor={[
@@ -1,5 +1,9 @@
import React, { useEffect, useState } from "react";
import { postSpeechServiceLanguages, postSpeechServiceVoices } from "src/api";
import {
getGoogleCustomVoices,
postSpeechServiceLanguages,
postSpeechServiceVoices,
} from "src/api";
import { SpeechCredential } from "src/api/types";
import { Selector } from "src/components/forms";
import { SelectorOption } from "src/components/forms/selector";
@@ -30,6 +34,8 @@ import {
VoiceLanguage,
} from "src/vendor/types";
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
credentials: SpeechCredential[] | undefined;
synthesis: SynthesisVendors | undefined;
ttsVendor: [
@@ -53,6 +59,8 @@ type SpeechProviderSelectionProbs = {
};
export const SpeechProviderSelection = ({
accountSid,
serviceProviderSid,
credentials,
synthesis,
ttsVendor: [synthVendor, setSynthVendor],
@@ -136,8 +144,33 @@ export const SpeechProviderSelection = ({
setSynthesisLanguageOptions(json);
}
});
} else if (synthVendor === VENDOR_GOOGLE) {
getGoogleCustomVoices({
...(synthLabel && { label: synthLabel }),
account_sid: accountSid,
service_provider_sid: serviceProviderSid,
}).then(({ json }) => {
const customVOices = json.map((v) => ({
name: `${v.name} (Custom)`,
value: `custom_${v.google_custom_voice_sid}`,
}));
options = synthesis[synthVendor as keyof SynthesisVendors]
.filter((lang: VoiceLanguage) => {
return lang.code === synthLang;
})
.flatMap((lang: VoiceLanguage) =>
lang.voices.map((voice: Voice) => ({
name: voice.name,
value: voice.value,
}))
) as Voice[];
setSynthesisVoiceOptions([...customVOices, ...options]);
if (customVOices.length > 0) {
setSynthVoice(customVOices[0].value);
}
});
}
}, [synthVendor, synthesis, synthLabel]);
}, [synthVendor, synthesis, synthLabel, accountSid, serviceProviderSid]);
useEffect(() => {
if (credentials) {
@@ -1,19 +1,24 @@
import React, { Fragment, useEffect, useState } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import { Section, Tooltip } from "src/components";
import { Icons, Section, Tooltip } from "src/components";
import {
FileUpload,
Selector,
Passwd,
AccountSelect,
Checkzone,
Message,
} from "src/components/forms";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
deleteGoogleCustomVoice,
getGoogleCustomVoices,
postGoogleCustomVoice,
postSpeechService,
putGoogleCustomVoice,
putSpeechService,
useServiceProviderData,
} from "src/api";
@@ -39,17 +44,25 @@ import {
getObscuredSecret,
isUserAccountScope,
isNotBlank,
hasLength,
} from "src/utils";
import { getObscuredGoogleServiceKey } from "./utils";
import { CredentialStatus } from "./status";
import type { RegionVendors, GoogleServiceKey, Vendor } from "src/vendor/types";
import type { Account, SpeechCredential, UseApiDataMap } from "src/api/types";
import type {
Account,
GoogleCustomVoice,
SpeechCredential,
UseApiDataMap,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import {
DEFAULT_ELEVENLABS_MODEL,
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
DISABLE_CUSTOM_SPEECH,
ELEVENLABS_MODEL_OPTIONS,
GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
} from "src/api/constants";
type SpeechServiceFormProps = {
@@ -117,6 +130,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [onPremNuanceSttUrl, setOnPremNuanceSttUrl] = useState("");
const [cobaltServerUri, setCobaltServerUri] = useState("");
const [label, setLabel] = useState("");
const [useCustomVoicesCheck, setUseCustomVoicesCheck] = useState(false);
const [customVoices, setCustomVoices] = useState<GoogleCustomVoice[]>([]);
const [customVoicesMessage, setCustomVoicesMessage] = useState("");
const handleFile = (file: File) => {
const handleError = () => {
@@ -144,6 +160,62 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
});
};
const handlePutGoogleCustomVoices = () => {
if (!credential || !credential.data) {
return;
}
if (useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) => {
if (v.google_custom_voice_sid) {
const sid = v.google_custom_voice_sid;
delete v.google_custom_voice_sid;
return putGoogleCustomVoice(sid, v);
} else {
return postGoogleCustomVoice({
...v,
speech_credential_sid: credential.data?.speech_credential_sid,
});
}
})
)
.then(() => {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(
`${ROUTE_INTERNAL_SPEECH}/${credential?.data?.speech_credential_sid}/edit`
);
})
.catch((error) => {
toastError(error.msg);
});
} else if (useCustomVoicesCheck && customVoices.length > 0) {
Promise.all(
customVoices.map((v) => {
if (v.google_custom_voice_sid) {
return deleteGoogleCustomVoice(v.google_custom_voice_sid);
}
})
)
.then(() => {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(
`${ROUTE_INTERNAL_SPEECH}/${credential?.data?.speech_credential_sid}/edit`
);
})
.catch((error) => {
toastError(error.msg);
});
} else {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(
`${ROUTE_INTERNAL_SPEECH}/${credential.data.speech_credential_sid}/edit`
);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -162,9 +234,6 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
use_for_tts: ttsCheck ? 1 : 0,
use_for_stt: sttCheck ? 1 : 0,
label: label || null,
...(vendor === VENDOR_AWS && {
aws_region: region || null,
}),
...(vendor === VENDOR_MICROSOFT && {
region: region || null,
use_custom_tts:
@@ -218,11 +287,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
)
.then(() => {
if (credential && credential.data) {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(
`${ROUTE_INTERNAL_SPEECH}/${credential.data.speech_credential_sid}/edit`
);
if (credential.data.vendor === VENDOR_GOOGLE) {
handlePutGoogleCustomVoices();
} else {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(
`${ROUTE_INTERNAL_SPEECH}/${credential.data.speech_credential_sid}/edit`
);
}
}
})
.catch((error) => {
@@ -247,10 +320,25 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}),
riva_server_uri: vendor == VENDOR_NVIDIA ? rivaServerUri : null,
})
.then(() => {
toastSuccess("Speech credential created successfully");
navigate(ROUTE_INTERNAL_SPEECH);
setAccountFilter(accountSid);
.then(({ json }) => {
if (vendor === VENDOR_GOOGLE && useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) =>
postGoogleCustomVoice({
...v,
speech_credential_sid: json.sid,
})
)
).then(() => {
toastSuccess("Speech credential created successfully");
navigate(ROUTE_INTERNAL_SPEECH);
setAccountFilter(accountSid);
});
} else {
toastSuccess("Speech credential created successfully");
navigate(ROUTE_INTERNAL_SPEECH);
setAccountFilter(accountSid);
}
})
.catch((error) => {
toastError(error.msg);
@@ -402,8 +490,34 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTtsModelId(credential.data.model_id);
}
}
if (credential?.data?.vendor === VENDOR_GOOGLE) {
// let try to check if there is custom voices
getGoogleCustomVoices({
speech_credential_sid: credential.data.speech_credential_sid,
}).then(({ json }) => {
setCustomVoices(json);
setUseCustomVoicesCheck(json.length > 0);
});
}
}, [credential]);
const updateCustomVoices = (
index: number,
key: string,
value: typeof customVoices[number][keyof GoogleCustomVoice]
) => {
setCustomVoices((prev) =>
prev.map((g, i) =>
i === index
? {
...g,
[key]: value,
}
: g
)
);
};
return (
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
@@ -647,6 +761,166 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</pre>
</fieldset>
)}
{ttsCheck && vendor === VENDOR_GOOGLE && (
<fieldset>
<label htmlFor="use_custom_voice" className="chk">
<input
id="use_custom_voice"
name="use_custom_voice"
type="checkbox"
onChange={(e) => {
if (customVoices.length === 0) {
setCustomVoices([
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
]);
}
setUseCustomVoicesCheck(e.target.checked);
}}
checked={useCustomVoicesCheck}
/>
<div>Use custom voices</div>
</label>
{useCustomVoicesCheck && (
<fieldset>
<label htmlFor="sip_gateways">Custom Voices</label>
<MXS>
<em>At least one Custom voice is required.</em>
</MXS>
{customVoicesMessage && (
<Message message={customVoicesMessage} />
)}
{hasLength(customVoices) &&
customVoices.map((v, i) => (
<div key={`custom_voice_${i}`} className="customVoice">
<div>
<div>
<label htmlFor="custom_voice_name">
Name / Reported Usage
</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Assigned Name"
required
value={v.name}
onChange={(e) => {
updateCustomVoices(i, "name", e.target.value);
}}
/>
</div>
<div>
<Selector
id={"google_custom_voices_reported_usage"}
name={"google_custom_voices_reported_usage"}
value={v.reported_usage}
options={GOOGLE_CUSTOM_VOICES_REPORTED_USAGE}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
e.target.value
);
}}
/>
</div>
</div>
<div>
<div>
<label htmlFor="custom_voice_name">Model</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Model"
required
value={v.model}
style={{ maxWidth: "100%" }}
onChange={(e) => {
updateCustomVoices(
i,
"model",
e.target.value
);
}}
/>
</div>
</div>
<button
className="btnty"
title="Delete custom voice"
type="button"
onClick={() => {
setCustomVoicesMessage("");
if (customVoices.length === 1) {
setCustomVoicesMessage(
"You must provide at least one custom voice."
);
return;
}
if (v.google_custom_voice_sid) {
deleteGoogleCustomVoice(
v.google_custom_voice_sid
).finally(() => {
credential?.refetch();
});
}
setCustomVoices((prev) =>
prev.filter((_, idx) => idx !== i)
);
}}
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
))}
<ButtonGroup left>
<button
className="btnty"
type="button"
title="Add Voice"
onClick={() => {
setCustomVoicesMessage("");
setCustomVoices((prev) => [
...prev,
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
]);
}}
>
<Icon subStyle="teal">
<Icons.Plus />
</Icon>
</button>
</ButtonGroup>
</fieldset>
)}
</fieldset>
)}
</>
)}
{vendor === VENDOR_NUANCE && (
+50
View File
@@ -294,3 +294,53 @@ fieldset {
.bucket_tag {
@extend .lcr;
}
.customVoice {
padding: ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
max-width: ui-vars.$width-mobile;
position: relative;
> div {
display: grid;
grid-gap: ui-vars.$px02;
align-items: center;
&:nth-child(1) {
grid-template-columns: [col] 100%;
}
&:nth-child(2) {
grid-template-columns: [col] calc(40% - #{ui-vars.$px02 * 2}) [col] 60%;
margin-top: ui-vars.$px02;
}
&:nth-child(3) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(4) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
}
> button {
position: absolute;
right: 0;
bottom: 50%;
transform: translate3d(50%, 50%, 0);
@include mixins.small() {
top: auto;
bottom: auto;
transform: none;
position: relative;
margin-top: ui-vars.$px02;
display: flex;
margin-left: auto;
}
}
}