Added 60dB integration#44
Conversation
|
Someone is attempting to deploy a commit to the BA Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Code Review
This pull request introduces support for the 60db text-to-speech engine alongside ElevenLabs, abstracting speech synthesis and voice fetching into a unified provider interface and updating the UI to allow runtime switching. Key feedback includes securing the API key inputs by using type="password" instead of a shared isFocused state, wrapping req.json() in a try block to prevent server crashes on malformed requests, splitting a useEffect hook in ChatPage to avoid stale closures, avoiding direct state mutation when sorting voices, and adding an API key fallback for ElevenLabs voice fetching in production.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| <label className="absolute text-sm p-5 text-gray-500" htmlFor="60db-key"> | ||
| {isFocused ? "" : "60db..."} | ||
| </label> | ||
| <input | ||
| id="60db-key" | ||
| name="60db-key" | ||
| defaultValue={SIXTYDB_KEY ? JSON.parse(SIXTYDB_KEY) : ""} | ||
| onFocus={() => setIsFocused(true)} | ||
| onBlur={() => { | ||
| setIsFocused(false); | ||
| }} | ||
| className={`${ | ||
| isFocused | ||
| ? `text-white transition ease-in-out duration-700` | ||
| : `text-transparent transition ease-in-out` | ||
| } w-2/4 lg:w-52 p-4 border-x-2 border-b-2 border-t-0 bg-transparent focus:outline-none focus:border-blue-500`} | ||
| /> |
There was a problem hiding this comment.
The shared isFocused state causes a security and UX issue: focusing on the new 60db-key input reveals the API keys for OpenAI and ElevenLabs because they all share the same isFocused state to toggle text-transparent / text-white classes.
Using type="password" is the standard, secure way to handle API keys. It natively masks the input, supports password managers, and allows using standard HTML placeholders, completely eliminating the need for the custom isFocused state, absolute labels, and complex CSS transitions. (Note: The other API key inputs should also be updated to type="password" for consistency and security).
| <label className="absolute text-sm p-5 text-gray-500" htmlFor="60db-key"> | |
| {isFocused ? "" : "60db..."} | |
| </label> | |
| <input | |
| id="60db-key" | |
| name="60db-key" | |
| defaultValue={SIXTYDB_KEY ? JSON.parse(SIXTYDB_KEY) : ""} | |
| onFocus={() => setIsFocused(true)} | |
| onBlur={() => { | |
| setIsFocused(false); | |
| }} | |
| className={`${ | |
| isFocused | |
| ? `text-white transition ease-in-out duration-700` | |
| : `text-transparent transition ease-in-out` | |
| } w-2/4 lg:w-52 p-4 border-x-2 border-b-2 border-t-0 bg-transparent focus:outline-none focus:border-blue-500`} | |
| /> | |
| <input | |
| id="60db-key" | |
| name="60db-key" | |
| type="password" | |
| placeholder="60db API Key" | |
| defaultValue={SIXTYDB_KEY ? JSON.parse(SIXTYDB_KEY) : ""} | |
| className="w-2/4 lg:w-52 p-4 border-x-2 border-b-2 border-t-0 bg-transparent text-white focus:outline-none focus:border-blue-500" | |
| /> |
| export async function POST(req: Request) { | ||
| const { apiKey, message, voice } = await req.json(); | ||
| const { | ||
| provider = "elevenlabs", | ||
| apiKey, | ||
| message, | ||
| voice | ||
| }: { provider?: Provider; apiKey?: string; message: string; voice: string } = await req.json(); | ||
|
|
||
| const elevenlabs = new ElevenLabsClient({ | ||
| apiKey: isProduction ? apiKey : process.env.ELEVENLABS_API_KEY | ||
| }); | ||
| // Pick the right key: the user's in production, the env key in development. | ||
| const key = isProduction | ||
| ? apiKey | ||
| : provider === "sixtydb" | ||
| ? process.env.SIXTYDB_API_KEY | ||
| : process.env.ELEVENLABS_API_KEY; | ||
|
|
||
| const options = { | ||
| apiKey: key, | ||
| text: message, | ||
| voice, | ||
| stability: DEFAULT_STABILITY, | ||
| similarity: DEFAULT_SIMILARITY | ||
| }; | ||
|
|
||
| try { | ||
| const audio = await elevenlabs.generate({ | ||
| voice, | ||
| model_id: "eleven_turbo_v2", | ||
| voice_settings: { similarity_boost: 0.5, stability: 0.5 }, | ||
| text: message | ||
| // stream: true, | ||
| }); | ||
| const audio = | ||
| provider === "sixtydb" | ||
| ? await sixtyDbSynthesize(options) | ||
| : await elevenLabsSynthesize(options); | ||
|
|
||
| return new Response(audio as any, { | ||
| headers: { "Content-Type": "audio/mpeg" } | ||
| }); | ||
| } catch (error: any) { | ||
| console.error(error); | ||
| return Response.json(error, { status: error.statusCode }); | ||
| return Response.json(error?.message ?? "Speech synthesis failed.", { | ||
| status: error?.statusCode ?? 500 | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Move the request body parsing (await req.json()) inside the try block. If the request is sent with an empty or malformed JSON body, calling req.json() outside the try block will throw an unhandled exception and result in a generic 500 server crash. Wrapping it inside the try block ensures any parsing errors are caught and handled gracefully.
export async function POST(req: Request) {
try {
const {
provider = "elevenlabs",
apiKey,
message,
voice
}: { provider?: Provider; apiKey?: string; message: string; voice: string } = await req.json();
// Pick the right key: the user's in production, the env key in development.
const key = isProduction
? apiKey
: provider === "sixtydb"
? process.env.SIXTYDB_API_KEY
: process.env.ELEVENLABS_API_KEY;
const options = {
apiKey: key,
text: message,
voice,
stability: DEFAULT_STABILITY,
similarity: DEFAULT_SIMILARITY
};
const audio =
provider === "sixtydb"
? await sixtyDbSynthesize(options)
: await elevenLabsSynthesize(options);
return new Response(audio as any, {
headers: { "Content-Type": "audio/mpeg" }
});
} catch (error: any) {
console.error(error);
return Response.json(error?.message ?? "Speech synthesis failed.", {
status: error?.statusCode ?? 500
});
}
}| useEffect(() => { | ||
| getVoices() | ||
| .then((voices) => { | ||
| setVoices(voices ?? []); | ||
| setVoicesLoading(true); | ||
| getVoices(provider, ttsKey) | ||
| .then((result) => { | ||
| const list = result ?? []; | ||
| setVoices(list); | ||
| if (list.length === 0) { | ||
| setSelectedVoice(""); | ||
| } else if (!list.some((voice) => voice.voice_id === selectedVoice)) { | ||
| setSelectedVoice(list[0].voice_id); | ||
| } | ||
| }) | ||
| .catch((error) => { | ||
| console.error("Error fetching voices:", error); | ||
| }); | ||
| }, []); | ||
| setVoices([]); | ||
| }) | ||
| .finally(() => setVoicesLoading(false)); | ||
| // selectedVoice is intentionally omitted to avoid a refetch loop. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [provider, elevenLabsKey, sixtyDbKey]); |
There was a problem hiding this comment.
Split the voice fetching and selected voice reconciliation into two separate useEffect hooks. This avoids disabling the react-hooks/exhaustive-deps lint rule, prevents potential stale closure bugs, and follows React best practices by separating concerns.
| useEffect(() => { | |
| getVoices() | |
| .then((voices) => { | |
| setVoices(voices ?? []); | |
| setVoicesLoading(true); | |
| getVoices(provider, ttsKey) | |
| .then((result) => { | |
| const list = result ?? []; | |
| setVoices(list); | |
| if (list.length === 0) { | |
| setSelectedVoice(""); | |
| } else if (!list.some((voice) => voice.voice_id === selectedVoice)) { | |
| setSelectedVoice(list[0].voice_id); | |
| } | |
| }) | |
| .catch((error) => { | |
| console.error("Error fetching voices:", error); | |
| }); | |
| }, []); | |
| setVoices([]); | |
| }) | |
| .finally(() => setVoicesLoading(false)); | |
| // selectedVoice is intentionally omitted to avoid a refetch loop. | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [provider, elevenLabsKey, sixtyDbKey]); | |
| useEffect(() => { | |
| setVoicesLoading(true); | |
| getVoices(provider, ttsKey) | |
| .then((result) => { | |
| setVoices(result ?? []); | |
| }) | |
| .catch((error) => { | |
| console.error("Error fetching voices:", error); | |
| setVoices([]); | |
| }) | |
| .finally(() => setVoicesLoading(false)); | |
| }, [provider, elevenLabsKey, sixtyDbKey]); | |
| useEffect(() => { | |
| if (voices.length === 0) { | |
| setSelectedVoice(""); | |
| } else if (!voices.some((voice) => voice.voice_id === selectedVoice)) { | |
| setSelectedVoice(voices[0].voice_id); | |
| } | |
| }, [voices, selectedVoice, setSelectedVoice]); |
| voices | ||
| .sort((a, b) => a.name!.localeCompare(b.name!)) | ||
| .sort((a, b) => a.name.localeCompare(b.name)) |
There was a problem hiding this comment.
Avoid mutating React state directly. voices.sort() sorts the array in-place, which mutates the voices prop (which is a state variable in the parent component). Copy the array before sorting it (e.g., using [...voices].sort()).
| voices | |
| .sort((a, b) => a.name!.localeCompare(b.name!)) | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| [...voices] | |
| .sort((a, b) => a.name.localeCompare(b.name)) |
| // ElevenLabs voices are global presets — load them from the env key so | ||
| // they're available on first render regardless of mode. | ||
| return await getElevenLabsVoices(process.env.ELEVENLABS_API_KEY); |
There was a problem hiding this comment.
In production, if the developer has not set a global ELEVENLABS_API_KEY on the server, fetching ElevenLabs voices will fail or return empty if it requires authentication. Fall back to the user's provided apiKey to ensure the voice list loads correctly.
| // ElevenLabs voices are global presets — load them from the env key so | |
| // they're available on first render regardless of mode. | |
| return await getElevenLabsVoices(process.env.ELEVENLABS_API_KEY); | |
| // ElevenLabs voices are global presets — load them from the env key so | |
| // they're available on first render regardless of mode. | |
| return await getElevenLabsVoices(process.env.ELEVENLABS_API_KEY || apiKey); |
What I did
Added 60db as a second, interchangeable text-to-speech engine alongside ElevenLabs, wired through a single provider abstraction so
the UI, chat flow, and audio playback stay identical regardless of which engine is active.
/myvoices and POST /tts-synthesize via fetch (Bearer auth) — no new dependency.
server-side and served as audio/mpeg, so client playback is untouched.
single 0–1 scale (rescaled to 60db's 0–100 internally); selected voice auto-reconciles when the engine changes.
(same model as the existing keys).
Closes: #
How to test