Skip to content

Commit bb210b0

Browse files
committed
feat: add per-profile voice selection with 5 ElevenLabs voices
Allow choosing between 5 voices (Rachel, Sarah, Lily, Brian, George) when creating or editing an elder profile, with audio preview playback.
1 parent 7418c26 commit bb210b0

9 files changed

Lines changed: 247 additions & 5 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "ElderlyProfile" ADD COLUMN "voiceId" TEXT NOT NULL DEFAULT '21m00Tcm4TlvDq8ikWAM';

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ model ElderlyProfile {
2626
phoneVerified Boolean @default(false)
2727
language String @default("en")
2828
timezone String @default("UTC")
29+
voiceId String @default("21m00Tcm4TlvDq8ikWAM")
2930
emergencyContact String?
3031
emergencyPhone String?
3132
emergencyPhoneVerified Boolean @default(false)

src/app/api/elderly/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
4040
if (!user)
4141
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
4242

43-
const { name, phone, language, timezone, emergencyContact, emergencyPhone } = await req.json();
43+
const { name, phone, language, timezone, emergencyContact, emergencyPhone, voiceId } = await req.json();
4444

4545
if (!name || !phone) {
4646
return NextResponse.json(
@@ -63,6 +63,7 @@ export async function POST(req: NextRequest) {
6363
phone,
6464
language: language || "en",
6565
timezone: timezone || "UTC",
66+
voiceId: voiceId || undefined,
6667
emergencyContact,
6768
emergencyPhone,
6869
managerId: user.id,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCurrentUser } from "@/lib/auth";
3+
import { textToSpeech, ELEVENLABS_VOICES } from "@/lib/elevenlabs";
4+
5+
export async function POST(req: NextRequest) {
6+
const user = await getCurrentUser();
7+
if (!user)
8+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9+
10+
const { voiceId } = await req.json();
11+
12+
if (!voiceId || !ELEVENLABS_VOICES.some((v) => v.id === voiceId)) {
13+
return NextResponse.json({ error: "Invalid voice ID" }, { status: 400 });
14+
}
15+
16+
try {
17+
const audioBuffer = await textToSpeech(
18+
"Hello, this is a reminder from your caregiver. It's time to take your medication.",
19+
voiceId
20+
);
21+
22+
return new NextResponse(new Uint8Array(audioBuffer), {
23+
headers: {
24+
"Content-Type": "audio/mpeg",
25+
"Cache-Control": "public, max-age=86400",
26+
},
27+
});
28+
} catch (error) {
29+
console.error("Voice preview failed:", error);
30+
return NextResponse.json(
31+
{ error: "Failed to generate preview" },
32+
{ status: 500 }
33+
);
34+
}
35+
}

src/app/elderly/[id]/page.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Link from "next/link";
88
import CaregiverList from "@/components/CaregiverList";
99
import AddCaregiverForm from "@/components/AddCaregiverForm";
1010
import PhoneVerification from "@/components/PhoneVerification";
11+
import VoiceSelector from "@/components/VoiceSelector";
1112

1213
interface Caregiver {
1314
id: string;
@@ -38,6 +39,7 @@ interface ElderlyProfile {
3839
phone: string;
3940
phoneVerified: boolean;
4041
language: string;
42+
voiceId: string | null;
4143
emergencyContact: string | null;
4244
emergencyPhone: string | null;
4345
emergencyPhoneVerified: boolean;
@@ -61,6 +63,7 @@ export default function ElderlyDetailPage() {
6163
const [caregiverRefreshKey, setCaregiverRefreshKey] = useState(0);
6264
const [deleting, setDeleting] = useState(false);
6365
const [uploadingAvatar, setUploadingAvatar] = useState(false);
66+
const [savingVoice, setSavingVoice] = useState(false);
6467

6568
const refreshCaregivers = useCallback(() => {
6669
setCaregiverRefreshKey((k) => k + 1);
@@ -142,6 +145,23 @@ export default function ElderlyDetailPage() {
142145
);
143146
}
144147

148+
async function handleVoiceChange(voiceId: string) {
149+
setSavingVoice(true);
150+
try {
151+
const res = await apiFetch(`/api/elderly/${id}`, {
152+
method: "PUT",
153+
headers: { "Content-Type": "application/json" },
154+
body: JSON.stringify({ voiceId }),
155+
});
156+
if (!res.ok) throw new Error("Failed to update voice");
157+
setProfile((prev) => (prev ? { ...prev, voiceId } : prev));
158+
} catch (err) {
159+
setError(err instanceof Error ? err.message : "Failed to update voice");
160+
} finally {
161+
setSavingVoice(false);
162+
}
163+
}
164+
145165
const languageLabel = profile.language === "ar" ? "Arabic" : "English";
146166
const allVerified = profile.phoneVerified && profile.emergencyPhoneVerified;
147167

@@ -276,6 +296,22 @@ export default function ElderlyDetailPage() {
276296
</dl>
277297
</section>
278298

299+
{/* Voice Selection */}
300+
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
301+
<div className="flex items-center justify-between mb-4">
302+
<h2 className="text-lg font-semibold text-gray-900">
303+
Call Voice
304+
</h2>
305+
{savingVoice && (
306+
<span className="text-xs text-gray-500">Saving...</span>
307+
)}
308+
</div>
309+
<VoiceSelector
310+
value={profile.voiceId || "21m00Tcm4TlvDq8ikWAM"}
311+
onChange={handleVoiceChange}
312+
/>
313+
</section>
314+
279315
{/* Caregivers */}
280316
<section className={`rounded-xl border border-gray-200 bg-white p-6 shadow-sm ${!allVerified ? "opacity-50 pointer-events-none" : ""}`}>
281317
<h2 className="text-lg font-semibold text-gray-900 mb-4">

src/components/CreateElderlyForm.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from "react";
44
import { apiFetch } from "@/lib/api";
5+
import VoiceSelector from "./VoiceSelector";
56

67
interface CreateElderlyFormProps {
78
onSuccess: () => void;
@@ -20,6 +21,7 @@ export default function CreateElderlyForm({
2021
);
2122
const [emergencyContact, setEmergencyContact] = useState("");
2223
const [emergencyPhone, setEmergencyPhone] = useState("");
24+
const [voiceId, setVoiceId] = useState("21m00Tcm4TlvDq8ikWAM");
2325
const [loading, setLoading] = useState(false);
2426
const [error, setError] = useState("");
2527

@@ -32,7 +34,7 @@ export default function CreateElderlyForm({
3234
const res = await apiFetch("/api/elderly", {
3335
method: "POST",
3436
headers: { "Content-Type": "application/json" },
35-
body: JSON.stringify({ name, phone, language, timezone, emergencyContact, emergencyPhone }),
37+
body: JSON.stringify({ name, phone, language, timezone, emergencyContact, emergencyPhone, voiceId }),
3638
});
3739

3840
if (!res.ok) {
@@ -143,6 +145,8 @@ export default function CreateElderlyForm({
143145
</select>
144146
</div>
145147

148+
<VoiceSelector value={voiceId} onChange={setVoiceId} />
149+
146150
<div>
147151
<label
148152
htmlFor="emergencyContact"

src/components/VoiceSelector.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"use client";
2+
3+
import { useState, useRef } from "react";
4+
import { apiFetch } from "@/lib/api";
5+
6+
const VOICES = [
7+
{ id: "21m00Tcm4TlvDq8ikWAM", name: "Rachel", gender: "Female" },
8+
{ id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", gender: "Female" },
9+
{ id: "pFZP5JQG7iQjIQuC4Bku", name: "Lily", gender: "Female" },
10+
{ id: "nPczCjzI2devNBz1zQrb", name: "Brian", gender: "Male" },
11+
{ id: "JBFqnCBsd6RMkjVDRZzb", name: "George", gender: "Male" },
12+
] as const;
13+
14+
interface VoiceSelectorProps {
15+
value: string;
16+
onChange: (voiceId: string) => void;
17+
}
18+
19+
export default function VoiceSelector({ value, onChange }: VoiceSelectorProps) {
20+
const [playingId, setPlayingId] = useState<string | null>(null);
21+
const [loadingId, setLoadingId] = useState<string | null>(null);
22+
const audioRef = useRef<HTMLAudioElement | null>(null);
23+
const cacheRef = useRef<Record<string, string>>({});
24+
25+
async function handlePlay(voiceId: string) {
26+
// Stop current playback
27+
if (audioRef.current) {
28+
audioRef.current.pause();
29+
audioRef.current = null;
30+
}
31+
32+
if (playingId === voiceId) {
33+
setPlayingId(null);
34+
return;
35+
}
36+
37+
setLoadingId(voiceId);
38+
39+
try {
40+
let blobUrl = cacheRef.current[voiceId];
41+
42+
if (!blobUrl) {
43+
const res = await apiFetch("/api/voices/preview", {
44+
method: "POST",
45+
headers: { "Content-Type": "application/json" },
46+
body: JSON.stringify({ voiceId }),
47+
});
48+
49+
if (!res.ok) throw new Error("Failed to load preview");
50+
51+
const blob = await res.blob();
52+
blobUrl = URL.createObjectURL(blob);
53+
cacheRef.current[voiceId] = blobUrl;
54+
}
55+
56+
const audio = new Audio(blobUrl);
57+
audio.onended = () => setPlayingId(null);
58+
audioRef.current = audio;
59+
await audio.play();
60+
setPlayingId(voiceId);
61+
} catch (err) {
62+
console.error("Preview failed:", err);
63+
} finally {
64+
setLoadingId(null);
65+
}
66+
}
67+
68+
return (
69+
<div>
70+
<label className="block text-sm font-medium text-gray-700 mb-2">
71+
Voice
72+
</label>
73+
<div className="grid grid-cols-1 gap-2">
74+
{VOICES.map((voice) => {
75+
const isSelected = value === voice.id;
76+
const isPlaying = playingId === voice.id;
77+
const isLoading = loadingId === voice.id;
78+
79+
return (
80+
<div
81+
key={voice.id}
82+
onClick={() => onChange(voice.id)}
83+
className={`flex items-center justify-between rounded-lg border-2 px-3 py-2.5 cursor-pointer transition-colors ${
84+
isSelected
85+
? "border-indigo-500 bg-indigo-50"
86+
: "border-gray-200 hover:border-gray-300 bg-white"
87+
}`}
88+
>
89+
<div className="flex items-center gap-3">
90+
<div
91+
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium ${
92+
isSelected
93+
? "bg-indigo-100 text-indigo-700"
94+
: "bg-gray-100 text-gray-600"
95+
}`}
96+
>
97+
{voice.gender === "Female" ? (
98+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
99+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0" />
100+
</svg>
101+
) : (
102+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
103+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0" />
104+
</svg>
105+
)}
106+
</div>
107+
<div>
108+
<p className={`text-sm font-medium ${isSelected ? "text-indigo-900" : "text-gray-900"}`}>
109+
{voice.name}
110+
</p>
111+
<p className={`text-xs ${isSelected ? "text-indigo-600" : "text-gray-500"}`}>
112+
{voice.gender}
113+
</p>
114+
</div>
115+
</div>
116+
117+
<button
118+
type="button"
119+
onClick={(e) => {
120+
e.stopPropagation();
121+
handlePlay(voice.id);
122+
}}
123+
disabled={isLoading}
124+
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
125+
isPlaying
126+
? "bg-indigo-600 text-white"
127+
: isSelected
128+
? "bg-indigo-200 text-indigo-700 hover:bg-indigo-300"
129+
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
130+
} disabled:opacity-50`}
131+
title={isPlaying ? "Stop" : "Preview voice"}
132+
>
133+
{isLoading ? (
134+
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
135+
) : isPlaying ? (
136+
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
137+
<rect x="6" y="4" width="4" height="16" />
138+
<rect x="14" y="4" width="4" height="16" />
139+
</svg>
140+
) : (
141+
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
142+
<path d="M8 5v14l11-7z" />
143+
</svg>
144+
)}
145+
</button>
146+
</div>
147+
);
148+
})}
149+
</div>
150+
</div>
151+
);
152+
}

src/lib/elevenlabs.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
export async function textToSpeech(text: string): Promise<Buffer> {
1+
export const ELEVENLABS_VOICES = [
2+
{ id: "21m00Tcm4TlvDq8ikWAM", name: "Rachel", gender: "Female" },
3+
{ id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", gender: "Female" },
4+
{ id: "pFZP5JQG7iQjIQuC4Bku", name: "Lily", gender: "Female" },
5+
{ id: "nPczCjzI2devNBz1zQrb", name: "Brian", gender: "Male" },
6+
{ id: "JBFqnCBsd6RMkjVDRZzb", name: "George", gender: "Male" },
7+
] as const;
8+
9+
export const DEFAULT_VOICE_ID = ELEVENLABS_VOICES[0].id;
10+
11+
export async function textToSpeech(text: string, voiceId?: string): Promise<Buffer> {
12+
const voice = voiceId || process.env.ELEVENLABS_VOICE_ID || DEFAULT_VOICE_ID;
213
const response = await fetch(
3-
`https://api.elevenlabs.io/v1/text-to-speech/${process.env.ELEVENLABS_VOICE_ID!}`,
14+
`https://api.elevenlabs.io/v1/text-to-speech/${voice}`,
415
{
516
method: "POST",
617
headers: {

src/lib/voice-call.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function executeReminderCall(reminderId: string, attemptNumber: num
4545
let audioUrl: string | null = null;
4646

4747
try {
48-
const audioBuffer = await textToSpeech(script);
48+
const audioBuffer = await textToSpeech(script, reminder.elderlyProfile.voiceId || undefined);
4949
const audioDir = path.join(process.cwd(), "public", "audio");
5050
await mkdir(audioDir, { recursive: true });
5151
const audioFileName = `reminder-${log.id}.mp3`;

0 commit comments

Comments
 (0)