mgokg commited on
Commit
714715b
·
verified ·
1 Parent(s): 39787a4

Upload index.tsx

Browse files
Files changed (1) hide show
  1. index.tsx +308 -0
index.tsx ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* tslint:disable */
2
+ /**
3
+ * @license
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {GoogleGenAI, LiveServerMessage, Modality, Session} from '@google/genai';
8
+ import {LitElement, css, html} from 'lit';
9
+ import {customElement, state} from 'lit/decorators.js';
10
+ import {createBlob, decode, decodeAudioData} from './utils';
11
+ import './visual-3d';
12
+
13
+ @customElement('gdm-live-audio')
14
+ export class GdmLiveAudio extends LitElement {
15
+ @state() isRecording = false;
16
+ @state() status = '';
17
+ @state() error = '';
18
+
19
+ private client: GoogleGenAI;
20
+ private session: Session;
21
+ private inputAudioContext = new (window.AudioContext ||
22
+ window.webkitAudioContext)({sampleRate: 16000});
23
+ private outputAudioContext = new (window.AudioContext ||
24
+ window.webkitAudioContext)({sampleRate: 24000});
25
+ @state() inputNode = this.inputAudioContext.createGain();
26
+ @state() outputNode = this.outputAudioContext.createGain();
27
+ private nextStartTime = 0;
28
+ private mediaStream: MediaStream;
29
+ private sourceNode: AudioBufferSourceNode;
30
+ private scriptProcessorNode: ScriptProcessorNode;
31
+ private sources = new Set<AudioBufferSourceNode>();
32
+
33
+ static styles = css`
34
+ #status {
35
+ position: absolute;
36
+ bottom: 5vh;
37
+ left: 0;
38
+ right: 0;
39
+ z-index: 10;
40
+ text-align: center;
41
+ }
42
+
43
+ .controls {
44
+ z-index: 10;
45
+ position: absolute;
46
+ bottom: 10vh;
47
+ left: 0;
48
+ right: 0;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ flex-direction: column;
53
+ gap: 10px;
54
+
55
+ button {
56
+ outline: none;
57
+ border: 1px solid rgba(255, 255, 255, 0.2);
58
+ color: white;
59
+ border-radius: 12px;
60
+ background: rgba(255, 255, 255, 0.1);
61
+ width: 64px;
62
+ height: 64px;
63
+ cursor: pointer;
64
+ font-size: 24px;
65
+ padding: 0;
66
+ margin: 0;
67
+
68
+ &:hover {
69
+ background: rgba(255, 255, 255, 0.2);
70
+ }
71
+ }
72
+
73
+ button[disabled] {
74
+ display: none;
75
+ }
76
+ }
77
+ `;
78
+
79
+ constructor() {
80
+ super();
81
+ this.initClient();
82
+ }
83
+
84
+ private initAudio() {
85
+ this.nextStartTime = this.outputAudioContext.currentTime;
86
+ }
87
+
88
+ private async initClient() {
89
+ this.initAudio();
90
+
91
+ this.client = new GoogleGenAI({
92
+ apiKey: process.env.GEMINI_API_KEY,
93
+ });
94
+
95
+ this.outputNode.connect(this.outputAudioContext.destination);
96
+
97
+ this.initSession();
98
+ }
99
+
100
+ private async initSession() {
101
+ const model = 'gemini-2.5-flash-preview-native-audio-dialog';
102
+
103
+ try {
104
+ this.session = await this.client.live.connect({
105
+ model: model,
106
+ callbacks: {
107
+ onopen: () => {
108
+ this.updateStatus('Opened');
109
+ },
110
+ onmessage: async (message: LiveServerMessage) => {
111
+ const audio =
112
+ message.serverContent?.modelTurn?.parts[0]?.inlineData;
113
+
114
+ if (audio) {
115
+ this.nextStartTime = Math.max(
116
+ this.nextStartTime,
117
+ this.outputAudioContext.currentTime,
118
+ );
119
+
120
+ const audioBuffer = await decodeAudioData(
121
+ decode(audio.data),
122
+ this.outputAudioContext,
123
+ 24000,
124
+ 1,
125
+ );
126
+ const source = this.outputAudioContext.createBufferSource();
127
+ source.buffer = audioBuffer;
128
+ source.connect(this.outputNode);
129
+ source.addEventListener('ended', () =>{
130
+ this.sources.delete(source);
131
+ });
132
+
133
+ source.start(this.nextStartTime);
134
+ this.nextStartTime = this.nextStartTime + audioBuffer.duration;
135
+ this.sources.add(source);
136
+ }
137
+
138
+ const interrupted = message.serverContent?.interrupted;
139
+ if(interrupted) {
140
+ for(const source of this.sources.values()) {
141
+ source.stop();
142
+ this.sources.delete(source);
143
+ }
144
+ this.nextStartTime = 0;
145
+ }
146
+ },
147
+ onerror: (e: ErrorEvent) => {
148
+ this.updateError(e.message);
149
+ },
150
+ onclose: (e: CloseEvent) => {
151
+ this.updateStatus('Close:' + e.reason);
152
+ },
153
+ },
154
+ config: {
155
+ responseModalities: [Modality.AUDIO],
156
+ speechConfig: {
157
+ voiceConfig: {prebuiltVoiceConfig: {voiceName: 'Orus'}},
158
+ languageCode: 'de-DE'
159
+ },
160
+ },
161
+ });
162
+ } catch (e) {
163
+ console.error(e);
164
+ }
165
+ }
166
+
167
+ private updateStatus(msg: string) {
168
+ this.status = msg;
169
+ }
170
+
171
+ private updateError(msg: string) {
172
+ this.error = msg;
173
+ }
174
+
175
+ private async startRecording() {
176
+ if (this.isRecording) {
177
+ return;
178
+ }
179
+
180
+ this.inputAudioContext.resume();
181
+
182
+ this.updateStatus('Requesting microphone access...');
183
+
184
+ try {
185
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
186
+ audio: true,
187
+ video: false,
188
+ });
189
+
190
+ this.updateStatus('Microphone access granted. Starting capture...');
191
+
192
+ this.sourceNode = this.inputAudioContext.createMediaStreamSource(
193
+ this.mediaStream,
194
+ );
195
+ this.sourceNode.connect(this.inputNode);
196
+
197
+ const bufferSize = 256;
198
+ this.scriptProcessorNode = this.inputAudioContext.createScriptProcessor(
199
+ bufferSize,
200
+ 1,
201
+ 1,
202
+ );
203
+
204
+ this.scriptProcessorNode.onaudioprocess = (audioProcessingEvent) => {
205
+ if (!this.isRecording) return;
206
+
207
+ const inputBuffer = audioProcessingEvent.inputBuffer;
208
+ const pcmData = inputBuffer.getChannelData(0);
209
+
210
+ this.session.sendRealtimeInput({media: createBlob(pcmData)});
211
+ };
212
+
213
+ this.sourceNode.connect(this.scriptProcessorNode);
214
+ this.scriptProcessorNode.connect(this.inputAudioContext.destination);
215
+
216
+ this.isRecording = true;
217
+ this.updateStatus('🔴 Recording... Capturing PCM chunks.');
218
+ } catch (err) {
219
+ console.error('Error starting recording:', err);
220
+ this.updateStatus(`Error: ${err.message}`);
221
+ this.stopRecording();
222
+ }
223
+ }
224
+
225
+ private stopRecording() {
226
+ if (!this.isRecording && !this.mediaStream && !this.inputAudioContext)
227
+ return;
228
+
229
+ this.updateStatus('Stopping recording...');
230
+
231
+ this.isRecording = false;
232
+
233
+ if (this.scriptProcessorNode && this.sourceNode && this.inputAudioContext) {
234
+ this.scriptProcessorNode.disconnect();
235
+ this.sourceNode.disconnect();
236
+ }
237
+
238
+ this.scriptProcessorNode = null;
239
+ this.sourceNode = null;
240
+
241
+ if (this.mediaStream) {
242
+ this.mediaStream.getTracks().forEach((track) => track.stop());
243
+ this.mediaStream = null;
244
+ }
245
+
246
+ this.updateStatus('Recording stopped. Click Start to begin again.');
247
+ }
248
+
249
+ private reset() {
250
+ this.session?.close();
251
+ this.initSession();
252
+ this.updateStatus('Session cleared.');
253
+ }
254
+
255
+ render() {
256
+ return html`
257
+ <div>
258
+ <div class="controls">
259
+ <button
260
+ id="resetButton"
261
+ @click=${this.reset}
262
+ ?disabled=${this.isRecording}>
263
+ <svg
264
+ xmlns="http://www.w3.org/2000/svg"
265
+ height="40px"
266
+ viewBox="0 -960 960 960"
267
+ width="40px"
268
+ fill="#ffffff">
269
+ <path
270
+ d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z" />
271
+ </svg>
272
+ </button>
273
+ <button
274
+ id="startButton"
275
+ @click=${this.startRecording}
276
+ ?disabled=${this.isRecording}>
277
+ <svg
278
+ viewBox="0 0 100 100"
279
+ width="32px"
280
+ height="32px"
281
+ fill="#c80000"
282
+ xmlns="http://www.w3.org/2000/svg">
283
+ <circle cx="50" cy="50" r="50" />
284
+ </svg>
285
+ </button>
286
+ <button
287
+ id="stopButton"
288
+ @click=${this.stopRecording}
289
+ ?disabled=${!this.isRecording}>
290
+ <svg
291
+ viewBox="0 0 100 100"
292
+ width="32px"
293
+ height="32px"
294
+ fill="#000000"
295
+ xmlns="http://www.w3.org/2000/svg">
296
+ <rect x="0" y="0" width="100" height="100" rx="15" />
297
+ </svg>
298
+ </button>
299
+ </div>
300
+
301
+ <div id="status"> ${this.error} </div>
302
+ <gdm-live-audio-visuals-3d
303
+ .inputNode=${this.inputNode}
304
+ .outputNode=${this.outputNode}></gdm-live-audio-visuals-3d>
305
+ </div>
306
+ `;
307
+ }
308
+ }