M96820 commited on
Commit
e3dae5e
·
1 Parent(s): a61ba58

feat: audio with Sarah

Browse files
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 { useState, useEffect, useRef } from "react";
2
- import {
3
- Box,
4
- LinearProgress,
5
- IconButton,
6
- Tooltip,
7
- Typography,
8
- } from "@mui/material";
9
- import { useNavigate } from "react-router-dom";
10
- import { motion } from "framer-motion";
11
- import { ComicLayout } from "../layouts/ComicLayout";
12
- import { storyApi } from "../utils/api";
13
- import { useNarrator } from "../hooks/useNarrator";
14
- import { useStoryCapture } from "../hooks/useStoryCapture";
15
- import { usePageSound } from "../hooks/usePageSound";
16
- import { useWritingSound } from "../hooks/useWritingSound";
17
- import { useTransitionSound } from "../hooks/useTransitionSound";
18
- import { useGameSession } from "../hooks/useGameSession";
19
- import { StoryChoices } from "../components/StoryChoices";
20
- import { ErrorDisplay } from "../components/ErrorDisplay";
21
- import VolumeUpIcon from "@mui/icons-material/VolumeUp";
22
- import VolumeOffIcon from "@mui/icons-material/VolumeOff";
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}`;