Upload index.tsx
Browse files
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 |
+
}
|