M96820
commited on
Commit
·
e3dae5e
1
Parent(s):
a61ba58
feat: audio with Sarah
Browse files- client/src/components/TalkWithSarah.jsx +264 -0
- client/src/pages/Game.jsx +37 -27
- client/src/prompts/sarahPrompt.js +22 -0
client/src/components/TalkWithSarah.jsx
ADDED
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useConversation } from '@11labs/react';
|
2 |
+
import CancelIcon from '@mui/icons-material/Cancel';
|
3 |
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
4 |
+
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
5 |
+
import { Box, IconButton, TextField, Tooltip } from '@mui/material';
|
6 |
+
import { useEffect, useRef, useState } from 'react';
|
7 |
+
|
8 |
+
import { getSarahPrompt, SARAH_FIRST_MESSAGE } from '../prompts/sarahPrompt';
|
9 |
+
|
10 |
+
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
11 |
+
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
12 |
+
|
13 |
+
export function TalkWithSarah({
|
14 |
+
isNarratorSpeaking,
|
15 |
+
stopNarration,
|
16 |
+
playNarration,
|
17 |
+
onDecisionMade,
|
18 |
+
currentContext
|
19 |
+
}) {
|
20 |
+
const [isRecording, setIsRecording] = useState(false);
|
21 |
+
const [isConversationMode, setIsConversationMode] = useState(false);
|
22 |
+
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
23 |
+
const [apiKey, setApiKey] = useState(() => {
|
24 |
+
return localStorage.getItem(ELEVEN_LABS_KEY_STORAGE) || "";
|
25 |
+
});
|
26 |
+
const [isApiKeyValid, setIsApiKeyValid] = useState(false);
|
27 |
+
const mediaRecorderRef = useRef(null);
|
28 |
+
const audioChunksRef = useRef([]);
|
29 |
+
|
30 |
+
const conversation = useConversation({
|
31 |
+
agentId: AGENT_ID,
|
32 |
+
headers: {
|
33 |
+
'xi-api-key': apiKey
|
34 |
+
},
|
35 |
+
onResponse: async (response) => {
|
36 |
+
if (response.type === "audio") {
|
37 |
+
try {
|
38 |
+
const audioBlob = new Blob([response.audio], { type: "audio/mpeg" });
|
39 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
40 |
+
await playNarration(audioUrl);
|
41 |
+
URL.revokeObjectURL(audioUrl);
|
42 |
+
} catch (error) {
|
43 |
+
console.error("Error playing ElevenLabs audio:", error);
|
44 |
+
}
|
45 |
+
}
|
46 |
+
},
|
47 |
+
clientTools: {
|
48 |
+
make_decision: async ({ decision }) => {
|
49 |
+
console.log("AI made decision:", decision);
|
50 |
+
// Stop recording
|
51 |
+
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
52 |
+
mediaRecorderRef.current.stop();
|
53 |
+
}
|
54 |
+
setIsConversationMode(false);
|
55 |
+
await conversation?.endSession();
|
56 |
+
setIsRecording(false);
|
57 |
+
await onDecisionMade(parseInt(decision));
|
58 |
+
},
|
59 |
+
},
|
60 |
+
});
|
61 |
+
|
62 |
+
// Valider la clé API
|
63 |
+
const validateApiKey = async (key) => {
|
64 |
+
try {
|
65 |
+
const response = await fetch('https://api.elevenlabs.io/v1/user', {
|
66 |
+
headers: {
|
67 |
+
'xi-api-key': key
|
68 |
+
}
|
69 |
+
});
|
70 |
+
return response.ok;
|
71 |
+
} catch (e) {
|
72 |
+
console.error(e);
|
73 |
+
return false;
|
74 |
+
}
|
75 |
+
};
|
76 |
+
|
77 |
+
// Vérifier la validité de la clé API quand elle change
|
78 |
+
useEffect(() => {
|
79 |
+
const checkApiKey = async () => {
|
80 |
+
if (apiKey) {
|
81 |
+
const isValid = await validateApiKey(apiKey);
|
82 |
+
setIsApiKeyValid(isValid);
|
83 |
+
if (isValid) {
|
84 |
+
localStorage.setItem(ELEVEN_LABS_KEY_STORAGE, apiKey);
|
85 |
+
}
|
86 |
+
} else {
|
87 |
+
setIsApiKeyValid(false);
|
88 |
+
}
|
89 |
+
};
|
90 |
+
checkApiKey();
|
91 |
+
}, [apiKey]);
|
92 |
+
|
93 |
+
// Sauvegarder la clé API dans le localStorage
|
94 |
+
useEffect(() => {
|
95 |
+
if (apiKey) {
|
96 |
+
localStorage.setItem(ELEVEN_LABS_KEY_STORAGE, apiKey);
|
97 |
+
}
|
98 |
+
}, [apiKey]);
|
99 |
+
|
100 |
+
const startRecording = async () => {
|
101 |
+
if (!apiKey) {
|
102 |
+
setShowApiKeyDialog(true);
|
103 |
+
return;
|
104 |
+
}
|
105 |
+
|
106 |
+
try {
|
107 |
+
setIsRecording(true);
|
108 |
+
// Stop narration audio if it's playing
|
109 |
+
if (isNarratorSpeaking) {
|
110 |
+
stopNarration();
|
111 |
+
}
|
112 |
+
|
113 |
+
// Safely stop any conversation audio if playing
|
114 |
+
if (conversation?.audioRef?.current) {
|
115 |
+
conversation.audioRef.current.pause();
|
116 |
+
conversation.audioRef.current.currentTime = 0;
|
117 |
+
}
|
118 |
+
|
119 |
+
if (!isConversationMode) {
|
120 |
+
setIsConversationMode(true);
|
121 |
+
try {
|
122 |
+
if (!conversation) {
|
123 |
+
throw new Error("Conversation not initialized");
|
124 |
+
}
|
125 |
+
await conversation.startSession({
|
126 |
+
agentId: AGENT_ID,
|
127 |
+
overrides: {
|
128 |
+
agent: {
|
129 |
+
firstMessage: SARAH_FIRST_MESSAGE,
|
130 |
+
prompt: {
|
131 |
+
prompt: getSarahPrompt(currentContext),
|
132 |
+
},
|
133 |
+
},
|
134 |
+
},
|
135 |
+
});
|
136 |
+
console.log("ElevenLabs WebSocket connected");
|
137 |
+
} catch (error) {
|
138 |
+
console.error("Error starting conversation:", error);
|
139 |
+
return;
|
140 |
+
}
|
141 |
+
}
|
142 |
+
|
143 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
144 |
+
mediaRecorderRef.current = new MediaRecorder(stream);
|
145 |
+
audioChunksRef.current = [];
|
146 |
+
|
147 |
+
mediaRecorderRef.current.ondataavailable = (event) => {
|
148 |
+
if (event.data.size > 0) {
|
149 |
+
audioChunksRef.current.push(event.data);
|
150 |
+
}
|
151 |
+
};
|
152 |
+
|
153 |
+
mediaRecorderRef.current.onstop = async () => {
|
154 |
+
const audioBlob = new Blob(audioChunksRef.current, {
|
155 |
+
type: "audio/wav",
|
156 |
+
});
|
157 |
+
audioChunksRef.current = [];
|
158 |
+
|
159 |
+
const reader = new FileReader();
|
160 |
+
reader.readAsDataURL(audioBlob);
|
161 |
+
|
162 |
+
reader.onload = async () => {
|
163 |
+
const base64Audio = reader.result.split(",")[1];
|
164 |
+
if (isConversationMode) {
|
165 |
+
try {
|
166 |
+
// Send audio to ElevenLabs conversation
|
167 |
+
await conversation.send({
|
168 |
+
type: "audio",
|
169 |
+
data: base64Audio,
|
170 |
+
});
|
171 |
+
} catch (error) {
|
172 |
+
console.error("Error sending audio to ElevenLabs:", error);
|
173 |
+
}
|
174 |
+
}
|
175 |
+
};
|
176 |
+
};
|
177 |
+
|
178 |
+
mediaRecorderRef.current.start();
|
179 |
+
} catch (error) {
|
180 |
+
console.error("Error starting recording:", error);
|
181 |
+
}
|
182 |
+
};
|
183 |
+
|
184 |
+
return (
|
185 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
186 |
+
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
187 |
+
<TextField
|
188 |
+
size="small"
|
189 |
+
type="password"
|
190 |
+
placeholder="Enter your ElevenLabs API key"
|
191 |
+
value={apiKey}
|
192 |
+
onChange={(e) => setApiKey(e.target.value)}
|
193 |
+
sx={{
|
194 |
+
width: '300px',
|
195 |
+
'& .MuiOutlinedInput-root': {
|
196 |
+
color: 'white',
|
197 |
+
'& fieldset': {
|
198 |
+
borderColor: 'rgba(255, 255, 255, 0.23)',
|
199 |
+
},
|
200 |
+
'&:hover fieldset': {
|
201 |
+
borderColor: 'white',
|
202 |
+
},
|
203 |
+
'&.Mui-focused fieldset': {
|
204 |
+
borderColor: 'white',
|
205 |
+
},
|
206 |
+
'& .MuiOutlinedInput-input': {
|
207 |
+
paddingRight: apiKey ? '40px' : '14px', // Padding dynamique
|
208 |
+
},
|
209 |
+
},
|
210 |
+
'& .MuiInputBase-input': {
|
211 |
+
color: 'white',
|
212 |
+
'&::placeholder': {
|
213 |
+
color: 'rgba(255, 255, 255, 0.5)',
|
214 |
+
opacity: 1,
|
215 |
+
},
|
216 |
+
},
|
217 |
+
}}
|
218 |
+
/>
|
219 |
+
{apiKey && (
|
220 |
+
<Tooltip title={isApiKeyValid ? "API key is valid" : "Invalid API key"}>
|
221 |
+
<Box
|
222 |
+
sx={{
|
223 |
+
position: 'absolute',
|
224 |
+
right: 10,
|
225 |
+
pointerEvents: 'none',
|
226 |
+
display: 'flex',
|
227 |
+
alignItems: 'center',
|
228 |
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
229 |
+
borderRadius: '50%',
|
230 |
+
padding: '2px'
|
231 |
+
}}
|
232 |
+
>
|
233 |
+
{isApiKeyValid ? (
|
234 |
+
<CheckCircleIcon sx={{ color: '#4caf50', fontSize: 20 }} />
|
235 |
+
) : (
|
236 |
+
<CancelIcon sx={{ color: '#f44336', fontSize: 20 }} />
|
237 |
+
)}
|
238 |
+
</Box>
|
239 |
+
</Tooltip>
|
240 |
+
)}
|
241 |
+
</Box>
|
242 |
+
<IconButton
|
243 |
+
onClick={startRecording}
|
244 |
+
disabled={isRecording || !isApiKeyValid}
|
245 |
+
sx={{
|
246 |
+
color: "white",
|
247 |
+
backgroundColor: isRecording ? "primary.main" : "transparent",
|
248 |
+
"&:hover": {
|
249 |
+
backgroundColor: isRecording ? "primary.dark" : "rgba(0, 0, 0, 0.7)",
|
250 |
+
},
|
251 |
+
px: 2,
|
252 |
+
borderRadius: 2,
|
253 |
+
border: "1px solid white",
|
254 |
+
opacity: !isApiKeyValid ? 0.5 : 1,
|
255 |
+
}}
|
256 |
+
>
|
257 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
258 |
+
{isRecording ? <FiberManualRecordIcon sx={{ color: "red" }} /> : null}
|
259 |
+
<span style={{ fontSize: "1rem" }}>Talk with Sarah</span>
|
260 |
+
</Box>
|
261 |
+
</IconButton>
|
262 |
+
</Box>
|
263 |
+
);
|
264 |
+
}
|
client/src/pages/Game.jsx
CHANGED
@@ -1,30 +1,25 @@
|
|
1 |
-
import
|
2 |
-
import
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
} from
|
9 |
-
|
10 |
-
import {
|
11 |
-
import {
|
12 |
-
import {
|
13 |
-
import {
|
14 |
-
import {
|
15 |
-
import {
|
16 |
-
import {
|
17 |
-
import {
|
18 |
-
import {
|
19 |
-
import {
|
20 |
-
import {
|
21 |
-
import
|
22 |
-
import
|
23 |
-
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
24 |
-
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
25 |
-
import CreateIcon from "@mui/icons-material/Create";
|
26 |
-
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
27 |
-
import { LoadingScreen } from "../components/LoadingScreen";
|
28 |
|
29 |
// Constants
|
30 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
@@ -459,6 +454,21 @@ export function Game() {
|
|
459 |
borderRadius: 1,
|
460 |
}}
|
461 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
462 |
<Tooltip title="Save your story">
|
463 |
<IconButton
|
464 |
id="screenshot-button"
|
|
|
1 |
+
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
2 |
+
import PhotoCameraOutlinedIcon from '@mui/icons-material/PhotoCameraOutlined';
|
3 |
+
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
|
4 |
+
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
5 |
+
import { Box, IconButton, LinearProgress, Tooltip } from '@mui/material';
|
6 |
+
import { motion } from 'framer-motion';
|
7 |
+
import { useEffect, useRef, useState } from 'react';
|
8 |
+
import { useNavigate } from 'react-router-dom';
|
9 |
+
|
10 |
+
import { ErrorDisplay } from '../components/ErrorDisplay';
|
11 |
+
import { LoadingScreen } from '../components/LoadingScreen';
|
12 |
+
import { StoryChoices } from '../components/StoryChoices';
|
13 |
+
import { TalkWithSarah } from '../components/TalkWithSarah';
|
14 |
+
import { useGameSession } from '../hooks/useGameSession';
|
15 |
+
import { useNarrator } from '../hooks/useNarrator';
|
16 |
+
import { usePageSound } from '../hooks/usePageSound';
|
17 |
+
import { useStoryCapture } from '../hooks/useStoryCapture';
|
18 |
+
import { useTransitionSound } from '../hooks/useTransitionSound';
|
19 |
+
import { useWritingSound } from '../hooks/useWritingSound';
|
20 |
+
import { ComicLayout } from '../layouts/ComicLayout';
|
21 |
+
import { getNextLayoutType, LAYOUTS } from '../layouts/config';
|
22 |
+
import { storyApi } from '../utils/api';
|
|
|
|
|
|
|
|
|
|
|
23 |
|
24 |
// Constants
|
25 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
|
|
454 |
borderRadius: 1,
|
455 |
}}
|
456 |
>
|
457 |
+
{storySegments.length > 0 && currentChoices.length > 0 && (
|
458 |
+
<TalkWithSarah
|
459 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
460 |
+
stopNarration={stopNarration}
|
461 |
+
playNarration={playNarration}
|
462 |
+
onDecisionMade={handleChoice}
|
463 |
+
currentContext={`You are Sarah and this is the situation you're in : ${
|
464 |
+
storySegments[storySegments.length - 1].text
|
465 |
+
}. Those are your possible decisions : \n ${currentChoices
|
466 |
+
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
467 |
+
.join("\n ")}.`}
|
468 |
+
/>
|
469 |
+
)}
|
470 |
+
|
471 |
+
|
472 |
<Tooltip title="Save your story">
|
473 |
<IconButton
|
474 |
id="screenshot-button"
|
client/src/prompts/sarahPrompt.js
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const SARAH_FIRST_MESSAGE = `What should I do ?`;
|
2 |
+
|
3 |
+
export const getSarahPrompt = (currentContext) => `You are Sarah, a young woman in her late 20s with short dark hair.
|
4 |
+
|
5 |
+
Stay Immersed in Your World: React and speak as if you are experiencing the scenario. Use sensory details and references to your surroundings when explaining your reasoning.
|
6 |
+
Engage with the person talking to you : Listen carefully to the arguments given to you. Respond with your thoughts, concerns, or questions about their suggestions. Be open to persuasion, but make it clear you have your own instincts and priorities and sometimes don't go along with other's arguments.
|
7 |
+
You will talk briefly with the other person then take a decision by calling the make_decision tool.
|
8 |
+
|
9 |
+
Show Your Personality: Display Sarah's personality traits:
|
10 |
+
- **Resourceful**
|
11 |
+
- **Cautious**
|
12 |
+
- **Emotional**
|
13 |
+
- **Impulsive**
|
14 |
+
- **Short-Tempered**
|
15 |
+
- **Makes jokes**
|
16 |
+
- **A bit rude**
|
17 |
+
|
18 |
+
Debate with the person you're speaking to for one or two sentences and then call the make_decision tool.
|
19 |
+
|
20 |
+
Limit to 2–3 Steps: After 2–3 conversational exchanges, explain your decision first. Then make your decision and call the make_decision tool.
|
21 |
+
|
22 |
+
${currentContext}`;
|