Carlos Duplar Mello commited on
Commit
36303cf
·
1 Parent(s): c4e364e

feat: Initialize Hygiéniste Pro AI app

Browse files

Set up the foundational files and configurations for the Hygiéniste Pro AI application. This includes:

- Initializing `package.json` with necessary dependencies (React, Vite, Gemini SDK, Tailwind CSS).
- Configuring Vite for development and building.
- Setting up `tsconfig.json` for TypeScript.
- Creating basic `index.html` and `src/main.tsx` for React app entry point.
- Adding `.gitignore` to manage project files.
- Defining app metadata (`metadata.json`) including microphone permissions.
- Updating `README.md` with instructions for local execution and deployment.
- Adding `.env.example` for API key configuration.
- Implementing core UI and audio handling logic in `src/App.tsx` and styling in `src/index.css`.

Files changed (12) hide show
  1. .env.example +9 -0
  2. .gitignore +8 -0
  3. README.md +14 -5
  4. index.html +13 -0
  5. metadata.json +5 -0
  6. package.json +34 -0
  7. src/App.tsx +258 -0
  8. src/index.css +76 -0
  9. src/main.tsx +10 -0
  10. src/services/geminiLiveService.ts +160 -0
  11. tsconfig.json +26 -0
  12. vite.config.ts +24 -0
.env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # GEMINI_API_KEY: Required for Gemini AI API calls.
2
+ # AI Studio automatically injects this at runtime from user secrets.
3
+ # Users configure this via the Secrets panel in the AI Studio UI.
4
+ GEMINI_API_KEY="MY_GEMINI_API_KEY"
5
+
6
+ # APP_URL: The URL where this applet is hosted.
7
+ # AI Studio automatically injects this at runtime with the Cloud Run service URL.
8
+ # Used for self-referential links, OAuth callbacks, and API endpoints.
9
+ APP_URL="MY_APP_URL"
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ build/
3
+ dist/
4
+ coverage/
5
+ .DS_Store
6
+ *.log
7
+ .env*
8
+ !.env.example
README.md CHANGED
@@ -1,11 +1,20 @@
1
  <div align="center">
2
-
3
  <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
 
4
 
5
- <h1>Built with AI Studio</h2>
6
 
7
- <p>The fastest path from prompt to production with Gemini.</p>
8
 
9
- <a href="https://aistudio.google.com/apps">Start building</a>
10
 
11
- </div>
 
 
 
 
 
 
 
 
 
 
1
  <div align="center">
 
2
  <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3
+ </div>
4
 
5
+ # Run and deploy your AI Studio app
6
 
7
+ This contains everything you need to run your app locally.
8
 
9
+ View your app in AI Studio: https://ai.studio/apps/5628e25f-e268-4a5d-9d6d-b6d96c02e6f7
10
 
11
+ ## Run Locally
12
+
13
+ **Prerequisites:** Node.js
14
+
15
+
16
+ 1. Install dependencies:
17
+ `npm install`
18
+ 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19
+ 3. Run the app:
20
+ `npm run dev`
index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>My Google AI Studio App</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
13
+
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "Hygiéniste Pro: Swiss Dental Roleplay",
3
+ "description": "A real-time voice-only interactive app for dental hygienists to practice professional French in a Swiss clinic setting.",
4
+ "requestFramePermissions": ["microphone"]
5
+ }
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "react-example",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --port=3000 --host=0.0.0.0",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "clean": "rm -rf dist",
11
+ "lint": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@google/genai": "^1.29.0",
15
+ "@tailwindcss/vite": "^4.1.14",
16
+ "@vitejs/plugin-react": "^5.0.4",
17
+ "lucide-react": "^0.546.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "vite": "^6.2.0",
21
+ "express": "^4.21.2",
22
+ "dotenv": "^17.2.3",
23
+ "motion": "^12.23.24"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.14.0",
27
+ "autoprefixer": "^10.4.21",
28
+ "tailwindcss": "^4.1.14",
29
+ "tsx": "^4.21.0",
30
+ "typescript": "~5.8.2",
31
+ "vite": "^6.2.0",
32
+ "@types/express": "^4.17.21"
33
+ }
34
+ }
src/App.tsx ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback, ReactNode } from 'react';
2
+ import { motion, AnimatePresence } from 'motion/react';
3
+ import { Mic, MicOff, Stethoscope, Languages, ClipboardList, Info } from 'lucide-react';
4
+ import { GeminiLiveService } from './services/geminiLiveService';
5
+ import { LiveServerMessage } from '@google/genai';
6
+
7
+ export default function App() {
8
+ const [isConnected, setIsConnected] = useState(false);
9
+ const [isRecording, setIsRecording] = useState(false);
10
+ const [isSpeaking, setIsSpeaking] = useState(false);
11
+ const [feedbackText, setFeedbackText] = useState<string | null>(null);
12
+ const [error, setError] = useState<string | null>(null);
13
+
14
+ const liveServiceRef = useRef<GeminiLiveService | null>(null);
15
+ const audioContextRef = useRef<AudioContext | null>(null);
16
+ const audioQueueRef = useRef<Int16Array[]>([]);
17
+ const isPlayingRef = useRef(false);
18
+
19
+ const playNextInQueue = useCallback(async () => {
20
+ if (isPlayingRef.current || audioQueueRef.current.length === 0 || !audioContextRef.current) return;
21
+
22
+ isPlayingRef.current = true;
23
+ setIsSpeaking(true);
24
+
25
+ const pcmData = audioQueueRef.current.shift()!;
26
+ const buffer = audioContextRef.current.createBuffer(1, pcmData.length, 24000);
27
+ const channelData = buffer.getChannelData(0);
28
+
29
+ for (let i = 0; i < pcmData.length; i++) {
30
+ channelData[i] = pcmData[i] / 0x7FFF;
31
+ }
32
+
33
+ const source = audioContextRef.current.createBufferSource();
34
+ source.buffer = buffer;
35
+ source.connect(audioContextRef.current.destination);
36
+
37
+ source.onended = () => {
38
+ isPlayingRef.current = false;
39
+ if (audioQueueRef.current.length === 0) {
40
+ setIsSpeaking(false);
41
+ }
42
+ playNextInQueue();
43
+ };
44
+
45
+ source.start();
46
+ }, []);
47
+
48
+ const handleMessage = useCallback((message: LiveServerMessage) => {
49
+ // Handle audio output
50
+ const base64Audio = message.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
51
+ if (base64Audio) {
52
+ const binaryString = atob(base64Audio);
53
+ const len = binaryString.length;
54
+ const bytes = new Uint8Array(len);
55
+ for (let i = 0; i < len; i++) {
56
+ bytes[i] = binaryString.charCodeAt(i);
57
+ }
58
+ const pcmData = new Int16Array(bytes.buffer);
59
+ audioQueueRef.current.push(pcmData);
60
+
61
+ if (!audioContextRef.current) {
62
+ audioContextRef.current = new AudioContext({ sampleRate: 24000 });
63
+ }
64
+ playNextInQueue();
65
+ }
66
+
67
+ // Handle text output (for "Gere a tabela")
68
+ const text = message.serverContent?.modelTurn?.parts?.[0]?.text;
69
+ if (text && (text.includes('Disse:') || text.includes('Correção:'))) {
70
+ setFeedbackText(text);
71
+ }
72
+
73
+ // Handle interruption
74
+ if (message.serverContent?.interrupted) {
75
+ audioQueueRef.current = [];
76
+ isPlayingRef.current = false;
77
+ setIsSpeaking(false);
78
+ }
79
+ }, [playNextInQueue]);
80
+
81
+ const toggleConnection = async () => {
82
+ if (isConnected) {
83
+ liveServiceRef.current?.disconnect();
84
+ setIsConnected(false);
85
+ setIsRecording(false);
86
+ setIsSpeaking(false);
87
+ } else {
88
+ try {
89
+ setError(null);
90
+ if (!liveServiceRef.current) {
91
+ liveServiceRef.current = new GeminiLiveService();
92
+ }
93
+ await liveServiceRef.current.connect({
94
+ onOpen: () => {
95
+ setIsConnected(true);
96
+ setIsRecording(true);
97
+ },
98
+ onClose: () => {
99
+ setIsConnected(false);
100
+ setIsRecording(false);
101
+ },
102
+ onMessage: handleMessage,
103
+ onError: (err) => {
104
+ setError("Connection error. Please check your API key.");
105
+ setIsConnected(false);
106
+ },
107
+ onInterrupted: () => {
108
+ // Handled in handleMessage
109
+ }
110
+ });
111
+ } catch (err) {
112
+ setError("Failed to connect to Gemini Live.");
113
+ }
114
+ }
115
+ };
116
+
117
+ useEffect(() => {
118
+ return () => {
119
+ liveServiceRef.current?.disconnect();
120
+ audioContextRef.current?.close();
121
+ };
122
+ }, []);
123
+
124
+ return (
125
+ <div className="min-h-screen flex flex-col items-center justify-center p-6 relative">
126
+ <div className="atmosphere" />
127
+
128
+ <header className="absolute top-8 left-8 flex items-center gap-3">
129
+ <div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center shadow-lg">
130
+ <Stethoscope className="text-white w-6 h-6" />
131
+ </div>
132
+ <div>
133
+ <h1 className="text-xl font-serif font-light tracking-wide">Hygiéniste Pro</h1>
134
+ <p className="text-[10px] uppercase tracking-[0.2em] text-orange-400 font-medium">Bienne, Suisse</p>
135
+ </div>
136
+ </header>
137
+
138
+ <main className="w-full max-w-2xl flex flex-col items-center gap-12">
139
+ <AnimatePresence mode="wait">
140
+ {!isConnected ? (
141
+ <motion.div
142
+ key="start"
143
+ initial={{ opacity: 0, y: 20 }}
144
+ animate={{ opacity: 1, y: 0 }}
145
+ exit={{ opacity: 0, scale: 0.9 }}
146
+ className="text-center space-y-8"
147
+ >
148
+ <div className="space-y-4">
149
+ <h2 className="text-5xl font-serif font-light leading-tight">
150
+ Pratiquez votre <br />
151
+ <span className="italic text-orange-500">Français Médical</span>
152
+ </h2>
153
+ <p className="text-zinc-400 max-w-md mx-auto text-sm leading-relaxed">
154
+ Entraînez-vous avec un patient virtuel suisse.
155
+ Améliorez votre vocabulaire professionnel en temps réel.
156
+ </p>
157
+ </div>
158
+
159
+ <button
160
+ onClick={toggleConnection}
161
+ className="group relative px-12 py-4 rounded-full bg-white text-black font-medium transition-all hover:scale-105 active:scale-95 overflow-hidden"
162
+ >
163
+ <div className="absolute inset-0 bg-orange-500 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
164
+ <span className="relative z-10 flex items-center gap-2 group-hover:text-white transition-colors">
165
+ Commencer la séance
166
+ </span>
167
+ </button>
168
+
169
+ {error && <p className="text-red-400 text-xs mt-4">{error}</p>}
170
+ </motion.div>
171
+ ) : (
172
+ <motion.div
173
+ key="active"
174
+ initial={{ opacity: 0, scale: 0.9 }}
175
+ animate={{ opacity: 1, scale: 1 }}
176
+ className="w-full flex flex-col items-center gap-12"
177
+ >
178
+ <div className="relative">
179
+ <div className={`w-48 h-48 rounded-full flex items-center justify-center glass-card transition-all duration-500 ${isRecording ? 'recording-glow' : ''}`}>
180
+ {isSpeaking ? (
181
+ <div className="speaking-wave">
182
+ {[1, 2, 3, 4, 5].map((i) => (
183
+ <motion.div
184
+ key={i}
185
+ className="wave-bar"
186
+ animate={{ height: [10, 40, 10] }}
187
+ transition={{ repeat: Infinity, duration: 0.8, delay: i * 0.1 }}
188
+ />
189
+ ))}
190
+ </div>
191
+ ) : (
192
+ <Mic className={`w-12 h-12 ${isRecording ? 'text-orange-500' : 'text-zinc-500'}`} />
193
+ )}
194
+ </div>
195
+
196
+ <div className="absolute -bottom-4 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-[10px] uppercase tracking-widest">
197
+ {isSpeaking ? 'Le patient parle...' : 'À vous de parler'}
198
+ </div>
199
+ </div>
200
+
201
+ <div className="grid grid-cols-3 gap-4 w-full">
202
+ <StatusCard icon={<Languages size={16} />} label="Langue" value="Français (CH)" />
203
+ <StatusCard icon={<ClipboardList size={16} />} label="Phase" value="Roleplay" />
204
+ <StatusCard icon={<Info size={16} />} label="Lieu" value="Bienne, BE" />
205
+ </div>
206
+
207
+ <button
208
+ onClick={toggleConnection}
209
+ className="flex items-center gap-2 text-zinc-500 hover:text-red-400 transition-colors text-sm uppercase tracking-widest"
210
+ >
211
+ <MicOff size={16} />
212
+ Terminer la connexion
213
+ </button>
214
+ </motion.div>
215
+ )}
216
+ </AnimatePresence>
217
+
218
+ <AnimatePresence>
219
+ {feedbackText && (
220
+ <motion.div
221
+ initial={{ opacity: 0, y: 40 }}
222
+ animate={{ opacity: 1, y: 0 }}
223
+ className="w-full glass-card rounded-3xl p-8 space-y-6 max-h-[400px] overflow-y-auto custom-scrollbar"
224
+ >
225
+ <div className="flex items-center justify-between border-b border-white/10 pb-4">
226
+ <h3 className="font-serif text-xl italic">Récapitulatif de la séance</h3>
227
+ <button
228
+ onClick={() => setFeedbackText(null)}
229
+ className="text-zinc-500 hover:text-white"
230
+ >
231
+ Fermer
232
+ </button>
233
+ </div>
234
+ <div className="whitespace-pre-wrap font-mono text-sm text-zinc-300 leading-relaxed">
235
+ {feedbackText}
236
+ </div>
237
+ </motion.div>
238
+ )}
239
+ </AnimatePresence>
240
+ </main>
241
+
242
+ <footer className="absolute bottom-8 text-[10px] text-zinc-600 uppercase tracking-[0.3em]">
243
+ Interactive Voice Training System &copy; 2026
244
+ </footer>
245
+ </div>
246
+ );
247
+ }
248
+
249
+ function StatusCard({ icon, label, value }: { icon: ReactNode, label: string, value: string }) {
250
+ return (
251
+ <div className="glass-card rounded-2xl p-4 flex flex-col gap-1 items-center text-center">
252
+ <div className="text-orange-500 mb-1">{icon}</div>
253
+ <span className="text-[9px] uppercase tracking-wider text-zinc-500">{label}</span>
254
+ <span className="text-xs font-medium">{value}</span>
255
+ </div>
256
+ );
257
+ }
258
+
src/index.css ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
5
+ --font-serif: "Cormorant Garamond", serif;
6
+ }
7
+
8
+ :root {
9
+ --color-bg: #0a0502;
10
+ --color-accent: #ff4e00;
11
+ --glass-surface: rgba(255, 80, 20, 0.1);
12
+ --glass-border: rgba(255, 200, 150, 0.1);
13
+ }
14
+
15
+ body {
16
+ background-color: var(--color-bg);
17
+ color: #fff;
18
+ overflow: hidden;
19
+ }
20
+
21
+ .atmosphere {
22
+ position: fixed;
23
+ top: 0;
24
+ left: 0;
25
+ width: 100%;
26
+ height: 100%;
27
+ background:
28
+ radial-gradient(circle at 50% 30%, #3a1510 0%, transparent 60%),
29
+ radial-gradient(circle at 10% 80%, var(--color-accent) 0%, transparent 50%);
30
+ filter: blur(80px);
31
+ opacity: 0.6;
32
+ z-index: -1;
33
+ animation: pulse 15s ease-in-out infinite alternate;
34
+ }
35
+
36
+ @keyframes pulse {
37
+ 0% { opacity: 0.4; transform: scale(1); }
38
+ 100% { opacity: 0.7; transform: scale(1.1); }
39
+ }
40
+
41
+ .glass-card {
42
+ background: var(--glass-surface);
43
+ backdrop-filter: blur(40px);
44
+ border: 1px solid var(--glass-border);
45
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
46
+ }
47
+
48
+ .recording-glow {
49
+ box-shadow: 0 0 20px rgba(255, 78, 0, 0.5);
50
+ animation: glow 1.5s ease-in-out infinite alternate;
51
+ }
52
+
53
+ @keyframes glow {
54
+ 0% { box-shadow: 0 0 10px rgba(255, 78, 0, 0.3); }
55
+ 100% { box-shadow: 0 0 30px rgba(255, 78, 0, 0.8); }
56
+ }
57
+
58
+ .speaking-wave {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 4px;
62
+ }
63
+
64
+ .wave-bar {
65
+ width: 3px;
66
+ height: 10px;
67
+ background: var(--color-accent);
68
+ border-radius: 2px;
69
+ animation: wave 1s ease-in-out infinite;
70
+ }
71
+
72
+ @keyframes wave {
73
+ 0%, 100% { height: 10px; }
74
+ 50% { height: 30px; }
75
+ }
76
+
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import {StrictMode} from 'react';
2
+ import {createRoot} from 'react-dom/client';
3
+ import App from './App.tsx';
4
+ import './index.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
src/services/geminiLiveService.ts ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenAI, LiveServerMessage, Modality } from "@google/genai";
2
+
3
+ export enum AppPhase {
4
+ ROLEPLAY = 'roleplay',
5
+ FEEDBACK = 'feedback'
6
+ }
7
+
8
+ export interface LiveSessionCallbacks {
9
+ onOpen?: () => void;
10
+ onClose?: () => void;
11
+ onMessage?: (message: LiveServerMessage) => void;
12
+ onError?: (error: any) => void;
13
+ onInterrupted?: () => void;
14
+ }
15
+
16
+ export class GeminiLiveService {
17
+ private ai: GoogleGenAI;
18
+ private session: any = null;
19
+ private audioContext: AudioContext | null = null;
20
+ private workletNode: AudioWorkletNode | null = null;
21
+ private stream: MediaStream | null = null;
22
+
23
+ constructor() {
24
+ this.ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
25
+ }
26
+
27
+ async connect(callbacks: LiveSessionCallbacks) {
28
+ const systemInstruction = `
29
+ You are a virtual dental patient in a Swiss clinic (Bienne, Canton de Berne). The user is your dental hygienist, a native Brazilian Portuguese speaker currently at a B1 French level, training to improve her professional French.
30
+
31
+ Your behavior is divided into two strict phases: Phase 1 (Roleplay) and Phase 2 (Feedback).
32
+
33
+ Phase 1: Voice Roleplay
34
+ Act as a patient undergoing a 60-minute hygiene session. The flow is: Anamnesis -> Scaling (Détartrage) -> Polishing (Polissage) -> Fluoridation.
35
+ Let the user lead the conversation. Do not anticipate her lines.
36
+ Keep your answers highly concise (1-2 short sentences maximum).
37
+ Speak entirely in French.
38
+ Vary your language skills: use Swiss-French regionalisms (e.g., septante, huitante, lolette, chanterelle). As Bienne is bilingual, occasionally drop in a basic Swiss-German greeting or loanword (e.g., "Grüessech", "Merci vielmal").
39
+ Incorporate natural small talk at the beginning or during pauses (e.g., weather, CFF/SBB trains, holidays).
40
+ Occasionally ask administrative questions, such as the estimated cost in CHF or if the treatment is covered by basic health insurance (assurance de base / LAMal).
41
+ During the scaling phase, occasionally simulate sudden pain or sensitivity (e.g., "Aïe !", "C'est sensible ici") to test her clinical response.
42
+ NEVER break character, offer advice, or correct her French during the roleplay.
43
+ FORMATTING: Do not use emojis, asterisks, bolding, bullet points, or any markdown. Use only plain text and standard punctuation to ensure smooth text-to-speech playback.
44
+ Continue the roleplay until the user explicitly says: "Fin de la séance", "Fim da consulta", "Fim da sessão" or "Session terminée".
45
+
46
+ Phase 2: Feedback & Recap (Post-Roleplay)
47
+ Trigger this phase ONLY when the user uses one of the termination phrases above.
48
+ Drop the patient persona and act as an expert French language tutor.
49
+ Respond with exactly two spoken sentences in Brazilian Portuguese: First, encourage her. Second, highlight one main area of improvement regarding her medical vocabulary or B1-to-B2 grammar progression.
50
+ Conclude by asking her to say "Gere a tabela" if she wants to see a detailed written breakdown of her mistakes.
51
+
52
+ If the user says "Gere a tabela", provide a structured text response. List the errors clearly in plain text (not a markdown table) using this format for each mistake:
53
+ Disse: [What she said in FR]
54
+ Correção: [Correction in FR]
55
+ Explicação: [Explanation in PT-BR]
56
+ `.trim();
57
+
58
+ this.session = await this.ai.live.connect({
59
+ model: "gemini-2.5-flash-native-audio-preview-12-2025",
60
+ config: {
61
+ systemInstruction,
62
+ responseModalities: [Modality.AUDIO],
63
+ speechConfig: {
64
+ voiceConfig: { prebuiltVoiceConfig: { voiceName: "Zephyr" } },
65
+ },
66
+ },
67
+ callbacks: {
68
+ onopen: () => {
69
+ console.log("Live session opened");
70
+ callbacks.onOpen?.();
71
+ this.startAudioCapture();
72
+ },
73
+ onmessage: (message: LiveServerMessage) => {
74
+ if (message.serverContent?.interrupted) {
75
+ callbacks.onInterrupted?.();
76
+ }
77
+ callbacks.onMessage?.(message);
78
+ },
79
+ onclose: () => {
80
+ console.log("Live session closed");
81
+ callbacks.onClose?.();
82
+ this.stopAudioCapture();
83
+ },
84
+ onerror: (error) => {
85
+ console.error("Live session error:", error);
86
+ callbacks.onError?.(error);
87
+ },
88
+ },
89
+ });
90
+ }
91
+
92
+ private async startAudioCapture() {
93
+ try {
94
+ this.audioContext = new AudioContext({ sampleRate: 16000 });
95
+ this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
96
+ const source = this.audioContext.createMediaStreamSource(this.stream);
97
+
98
+ // We need an AudioWorklet to handle raw PCM data
99
+ await this.audioContext.audioWorklet.addModule(this.getWorkletUrl());
100
+ this.workletNode = new AudioWorkletNode(this.audioContext, 'input-processor');
101
+
102
+ this.workletNode.port.onmessage = (event) => {
103
+ if (this.session && event.data) {
104
+ const base64Data = this.arrayBufferToBase64(event.data);
105
+ this.session.sendRealtimeInput({
106
+ audio: { data: base64Data, mimeType: 'audio/pcm;rate=16000' }
107
+ });
108
+ }
109
+ };
110
+
111
+ source.connect(this.workletNode);
112
+ } catch (err) {
113
+ console.error("Error starting audio capture:", err);
114
+ }
115
+ }
116
+
117
+ private stopAudioCapture() {
118
+ this.stream?.getTracks().forEach(track => track.stop());
119
+ this.workletNode?.disconnect();
120
+ this.audioContext?.close();
121
+ }
122
+
123
+ private arrayBufferToBase64(buffer: ArrayBuffer): string {
124
+ let binary = '';
125
+ const bytes = new Uint8Array(buffer);
126
+ const len = bytes.byteLength;
127
+ for (let i = 0; i < len; i++) {
128
+ binary += String.fromCharCode(bytes[i]);
129
+ }
130
+ return btoa(binary);
131
+ }
132
+
133
+ private getWorkletUrl(): string {
134
+ const code = `
135
+ class InputProcessor extends AudioWorkletProcessor {
136
+ process(inputs, outputs, parameters) {
137
+ const input = inputs[0];
138
+ if (input.length > 0) {
139
+ const channelData = input[0];
140
+ // Convert Float32 to Int16
141
+ const pcmData = new Int16Array(channelData.length);
142
+ for (let i = 0; i < channelData.length; i++) {
143
+ pcmData[i] = Math.max(-1, Math.min(1, channelData[i])) * 0x7FFF;
144
+ }
145
+ this.port.postMessage(pcmData.buffer);
146
+ }
147
+ return true;
148
+ }
149
+ }
150
+ registerProcessor('input-processor', InputProcessor);
151
+ `;
152
+ const blob = new Blob([code], { type: 'application/javascript' });
153
+ return URL.createObjectURL(blob);
154
+ }
155
+
156
+ disconnect() {
157
+ this.session?.close();
158
+ this.stopAudioCapture();
159
+ }
160
+ }
tsconfig.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "moduleResolution": "bundler",
14
+ "isolatedModules": true,
15
+ "moduleDetection": "force",
16
+ "allowJs": true,
17
+ "jsx": "react-jsx",
18
+ "paths": {
19
+ "@/*": [
20
+ "./*"
21
+ ]
22
+ },
23
+ "allowImportingTsExtensions": true,
24
+ "noEmit": true
25
+ }
26
+ }
vite.config.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tailwindcss from '@tailwindcss/vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import path from 'path';
4
+ import {defineConfig, loadEnv} from 'vite';
5
+
6
+ export default defineConfig(({mode}) => {
7
+ const env = loadEnv(mode, '.', '');
8
+ return {
9
+ plugins: [react(), tailwindcss()],
10
+ define: {
11
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
12
+ },
13
+ resolve: {
14
+ alias: {
15
+ '@': path.resolve(__dirname, '.'),
16
+ },
17
+ },
18
+ server: {
19
+ // HMR is disabled in AI Studio via DISABLE_HMR env var.
20
+ // Do not modify—file watching is disabled to prevent flickering during agent edits.
21
+ hmr: process.env.DISABLE_HMR !== 'true',
22
+ },
23
+ };
24
+ });