trysem commited on
Commit
b322a42
·
verified ·
1 Parent(s): d0d3dba

Create 4kaudiovis

Browse files
Files changed (1) hide show
  1. 4kaudiovis +517 -0
4kaudiovis ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { Upload, Play, Pause, Image as ImageIcon, Video, Settings2, Loader2, StopCircle } from 'lucide-react';
3
+
4
+ export default function App() {
5
+ const canvasRef = useRef(null);
6
+ const audioRef = useRef(null);
7
+ const audioCtxRef = useRef(null);
8
+ const analyserRef = useRef(null);
9
+ const sourceRef = useRef(null);
10
+ const destRef = useRef(null);
11
+ const reqIdRef = useRef(null);
12
+ const mediaRecorderRef = useRef(null);
13
+ const chunksRef = useRef([]);
14
+
15
+ // State
16
+ const [audioSrc, setAudioSrc] = useState(null);
17
+ const [fileName, setFileName] = useState('');
18
+ const [isPlaying, setIsPlaying] = useState(false);
19
+ const [isExportingVideo, setIsExportingVideo] = useState(false);
20
+ const [exportProgress, setExportProgress] = useState(0);
21
+
22
+ // Settings
23
+ const [vizType, setVizType] = useState('bars'); // 'bars', 'wave', 'circle'
24
+ const [color, setColor] = useState('#00ffcc');
25
+ const [thickness, setThickness] = useState(12);
26
+ const [sensitivity, setSensitivity] = useState(128); // FFT Size divider
27
+
28
+ // Initialize Web Audio API
29
+ const initAudio = useCallback(() => {
30
+ if (!audioCtxRef.current) {
31
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
32
+ audioCtxRef.current = new AudioContext();
33
+ analyserRef.current = audioCtxRef.current.createAnalyser();
34
+ destRef.current = audioCtxRef.current.createMediaStreamDestination();
35
+
36
+ // We only want to create the source once for the audio element
37
+ if (!sourceRef.current && audioRef.current) {
38
+ sourceRef.current = audioCtxRef.current.createMediaElementSource(audioRef.current);
39
+ sourceRef.current.connect(analyserRef.current);
40
+ analyserRef.current.connect(audioCtxRef.current.destination); // to speakers
41
+ analyserRef.current.connect(destRef.current); // to recording destination
42
+ }
43
+ }
44
+
45
+ // Resume context if suspended (browser autoplay policy)
46
+ if (audioCtxRef.current.state === 'suspended') {
47
+ audioCtxRef.current.resume();
48
+ }
49
+ }, []);
50
+
51
+ const handleFileUpload = (e) => {
52
+ const file = e.target.files[0];
53
+ if (file) {
54
+ if (audioSrc) URL.revokeObjectURL(audioSrc);
55
+ const url = URL.createObjectURL(file);
56
+ setAudioSrc(url);
57
+ setFileName(file.name);
58
+ setIsPlaying(false);
59
+ if (audioRef.current) {
60
+ audioRef.current.pause();
61
+ audioRef.current.currentTime = 0;
62
+ }
63
+ }
64
+ };
65
+
66
+ const togglePlay = () => {
67
+ if (!audioSrc) return;
68
+ initAudio();
69
+
70
+ if (isPlaying) {
71
+ audioRef.current.pause();
72
+ } else {
73
+ audioRef.current.play();
74
+ }
75
+ setIsPlaying(!isPlaying);
76
+ };
77
+
78
+ // The Animation Loop
79
+ const draw = useCallback(() => {
80
+ if (!canvasRef.current || !analyserRef.current) {
81
+ reqIdRef.current = requestAnimationFrame(draw);
82
+ return;
83
+ }
84
+
85
+ const canvas = canvasRef.current;
86
+ const ctx = canvas.getContext('2d');
87
+ const width = canvas.width; // Native 4K: 3840
88
+ const height = canvas.height; // Native 4K: 2160
89
+
90
+ // Clear canvas completely to maintain transparency
91
+ ctx.clearRect(0, 0, width, height);
92
+
93
+ // Dynamic FFT size based on sensitivity setting
94
+ analyserRef.current.fftSize = sensitivity > 0 ? (2048 / (sensitivity / 32)) : 2048;
95
+
96
+ const bufferLength = analyserRef.current.frequencyBinCount;
97
+ const dataArray = new Uint8Array(bufferLength);
98
+
99
+ ctx.lineWidth = thickness;
100
+ ctx.strokeStyle = color;
101
+ ctx.fillStyle = color;
102
+ ctx.lineCap = 'round';
103
+ ctx.lineJoin = 'round';
104
+
105
+ if (vizType === 'bars') {
106
+ analyserRef.current.getByteFrequencyData(dataArray);
107
+
108
+ const barWidth = (width / bufferLength) * 2.5;
109
+ let x = 0;
110
+
111
+ for (let i = 0; i < bufferLength; i++) {
112
+ // Boost lower frequencies slightly for visual impact, scale to 4K height
113
+ const barHeight = (dataArray[i] / 255) * height * 0.8;
114
+
115
+ ctx.fillRect(x, height - barHeight, barWidth - 2, barHeight);
116
+ x += barWidth;
117
+ }
118
+ } else if (vizType === 'wave') {
119
+ analyserRef.current.getByteTimeDomainData(dataArray);
120
+
121
+ ctx.beginPath();
122
+ const sliceWidth = width / bufferLength;
123
+ let x = 0;
124
+
125
+ for (let i = 0; i < bufferLength; i++) {
126
+ const v = dataArray[i] / 128.0; // 0 to 2
127
+ const y = v * height / 2;
128
+
129
+ if (i === 0) {
130
+ ctx.moveTo(x, y);
131
+ } else {
132
+ ctx.lineTo(x, y);
133
+ }
134
+ x += sliceWidth;
135
+ }
136
+ ctx.stroke();
137
+ } else if (vizType === 'circle') {
138
+ analyserRef.current.getByteFrequencyData(dataArray);
139
+
140
+ const centerX = width / 2;
141
+ const centerY = height / 2;
142
+ const radius = height / 4;
143
+
144
+ ctx.beginPath();
145
+ const bars = 180; // Limit bars to make a clean circle
146
+ const step = (Math.PI * 2) / bars;
147
+
148
+ for (let i = 0; i < bars; i++) {
149
+ // Average a few bins to smooth out the circular graph
150
+ const dataIndex = Math.floor((i / bars) * (bufferLength / 2));
151
+ const value = dataArray[dataIndex] / 255;
152
+ const barHeight = value * (height / 3);
153
+
154
+ const angle = i * step;
155
+
156
+ const x1 = centerX + Math.cos(angle) * radius;
157
+ const y1 = centerY + Math.sin(angle) * radius;
158
+ const x2 = centerX + Math.cos(angle) * (radius + barHeight);
159
+ const y2 = centerY + Math.sin(angle) * (radius + barHeight);
160
+
161
+ ctx.moveTo(x1, y1);
162
+ ctx.lineTo(x2, y2);
163
+ }
164
+ ctx.stroke();
165
+
166
+ // Inner solid circle
167
+ ctx.beginPath();
168
+ ctx.arc(centerX, centerY, radius - thickness, 0, Math.PI * 2);
169
+ ctx.lineWidth = thickness / 2;
170
+ ctx.stroke();
171
+ }
172
+
173
+ reqIdRef.current = requestAnimationFrame(draw);
174
+ }, [vizType, color, thickness, sensitivity]);
175
+
176
+ // Handle Play/Pause side effects and loop
177
+ useEffect(() => {
178
+ reqIdRef.current = requestAnimationFrame(draw);
179
+ return () => cancelAnimationFrame(reqIdRef.current);
180
+ }, [draw]);
181
+
182
+ const handleAudioEnded = () => {
183
+ setIsPlaying(false);
184
+ if (isExportingVideo) {
185
+ stopVideoExport();
186
+ }
187
+ };
188
+
189
+ // Export Image (PNG)
190
+ const exportImage = () => {
191
+ if (!canvasRef.current) return;
192
+ const link = document.createElement('a');
193
+ link.download = `visualizer_${Date.now()}.png`;
194
+ link.href = canvasRef.current.toDataURL('image/png');
195
+ link.click();
196
+ };
197
+
198
+ // Export 4K Transparent Video
199
+ const startVideoExport = async () => {
200
+ if (!audioSrc || !canvasRef.current || !audioCtxRef.current) {
201
+ alert("Please upload an audio file and press play at least once to initialize.");
202
+ return;
203
+ }
204
+
205
+ setIsExportingVideo(true);
206
+ setExportProgress(0);
207
+ chunksRef.current = [];
208
+
209
+ // Reset audio to start
210
+ audioRef.current.pause();
211
+ audioRef.current.currentTime = 0;
212
+
213
+ // Capture Canvas Stream at 60fps
214
+ const canvasStream = canvasRef.current.captureStream(60);
215
+
216
+ // Get Audio Stream from destination
217
+ const audioStream = destRef.current.stream;
218
+
219
+ // Combine Streams
220
+ const combinedTracks = [...canvasStream.getTracks(), ...audioStream.getAudioTracks()];
221
+ const combinedStream = new MediaStream(combinedTracks);
222
+
223
+ // Setup MediaRecorder for Transparent Video (WebM VP9/VP8)
224
+ let options = { mimeType: 'video/webm; codecs=vp9' };
225
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
226
+ options = { mimeType: 'video/webm; codecs=vp8' }; // Fallback
227
+ }
228
+
229
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
230
+ alert("Your browser does not support transparent video export (WebM with VP8/VP9 codecs). Export will proceed but may not be transparent.");
231
+ options = {}; // Use browser default
232
+ }
233
+
234
+ try {
235
+ mediaRecorderRef.current = new MediaRecorder(combinedStream, options);
236
+ } catch (e) {
237
+ console.error(e);
238
+ alert("Error starting video recorder. See console.");
239
+ setIsExportingVideo(false);
240
+ return;
241
+ }
242
+
243
+ mediaRecorderRef.current.ondataavailable = (e) => {
244
+ if (e.data && e.data.size > 0) {
245
+ chunksRef.current.push(e.data);
246
+ }
247
+ };
248
+
249
+ mediaRecorderRef.current.onstop = () => {
250
+ const blob = new Blob(chunksRef.current, { type: mediaRecorderRef.current.mimeType });
251
+ const url = URL.createObjectURL(blob);
252
+ const link = document.createElement('a');
253
+ link.download = `visualizer_4k_${Date.now()}.webm`;
254
+ link.href = url;
255
+ link.click();
256
+ URL.revokeObjectURL(url);
257
+ setIsExportingVideo(false);
258
+ setExportProgress(0);
259
+ };
260
+
261
+ // Progress timer
262
+ const duration = audioRef.current.duration;
263
+ const progressInterval = setInterval(() => {
264
+ if (audioRef.current && !audioRef.current.paused) {
265
+ setExportProgress((audioRef.current.currentTime / duration) * 100);
266
+ } else {
267
+ clearInterval(progressInterval);
268
+ }
269
+ }, 500);
270
+
271
+ // Start Recording & Playback
272
+ mediaRecorderRef.current.start(100); // collect data every 100ms
273
+ await audioRef.current.play();
274
+ setIsPlaying(true);
275
+ };
276
+
277
+ const stopVideoExport = () => {
278
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
279
+ mediaRecorderRef.current.stop();
280
+ }
281
+ audioRef.current.pause();
282
+ setIsPlaying(false);
283
+ };
284
+
285
+ return (
286
+ <div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-cyan-500/30">
287
+ {/* Header */}
288
+ <header className="border-b border-slate-800 bg-slate-900/50 p-6 flex items-center justify-between">
289
+ <div className="flex items-center gap-3">
290
+ <div className="bg-cyan-500/20 p-2 rounded-lg">
291
+ <Video className="w-6 h-6 text-cyan-400" />
292
+ </div>
293
+ <h1 className="text-xl font-bold tracking-tight text-white">4K Transparent Visualizer</h1>
294
+ </div>
295
+ <div className="text-sm text-slate-400 hidden sm:block">
296
+ All processing is strictly local.
297
+ </div>
298
+ </header>
299
+
300
+ <main className="container mx-auto p-6 grid lg:grid-cols-12 gap-8">
301
+
302
+ {/* Left Column: Controls */}
303
+ <div className="lg:col-span-4 space-y-6">
304
+
305
+ {/* Upload Section */}
306
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
307
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
308
+ <Upload className="w-4 h-4" /> Audio Input
309
+ </h2>
310
+ <label className="block w-full cursor-pointer bg-slate-800 hover:bg-slate-700 transition-colors border-2 border-dashed border-slate-600 rounded-xl p-8 text-center group">
311
+ <input
312
+ type="file"
313
+ accept="audio/*"
314
+ onChange={handleFileUpload}
315
+ className="hidden"
316
+ disabled={isExportingVideo}
317
+ />
318
+ <div className="mx-auto w-12 h-12 bg-slate-900 rounded-full flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
319
+ <Upload className="w-6 h-6 text-cyan-400" />
320
+ </div>
321
+ <p className="font-medium text-slate-300">
322
+ {fileName ? fileName : 'Click to browse audio file'}
323
+ </p>
324
+ <p className="text-xs text-slate-500 mt-2">MP3, WAV, FLAC</p>
325
+ </label>
326
+
327
+ {/* Hidden Audio Element */}
328
+ <audio
329
+ ref={audioRef}
330
+ src={audioSrc}
331
+ onEnded={handleAudioEnded}
332
+ onPlay={() => setIsPlaying(true)}
333
+ onPause={() => setIsPlaying(false)}
334
+ />
335
+
336
+ {/* Playback Controls */}
337
+ {audioSrc && (
338
+ <div className="mt-4 flex gap-3">
339
+ <button
340
+ onClick={togglePlay}
341
+ disabled={isExportingVideo}
342
+ className="flex-1 bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
343
+ >
344
+ {isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
345
+ {isPlaying ? 'Pause' : 'Play Audio'}
346
+ </button>
347
+ </div>
348
+ )}
349
+ </section>
350
+
351
+ {/* Settings Section */}
352
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
353
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
354
+ <Settings2 className="w-4 h-4" /> Visual Settings
355
+ </h2>
356
+
357
+ <div className="space-y-5">
358
+ {/* Type */}
359
+ <div>
360
+ <label className="block text-sm font-medium text-slate-400 mb-2">Style</label>
361
+ <div className="grid grid-cols-3 gap-2">
362
+ {['bars', 'wave', 'circle'].map(type => (
363
+ <button
364
+ key={type}
365
+ onClick={() => setVizType(type)}
366
+ className={`py-2 px-3 rounded-lg text-sm font-medium capitalize transition-all ${
367
+ vizType === type
368
+ ? 'bg-slate-700 text-white shadow-inner border border-slate-600'
369
+ : 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600'
370
+ }`}
371
+ >
372
+ {type}
373
+ </button>
374
+ ))}
375
+ </div>
376
+ </div>
377
+
378
+ {/* Color */}
379
+ <div>
380
+ <label className="block text-sm font-medium text-slate-400 mb-2">Primary Color</label>
381
+ <div className="flex items-center gap-3">
382
+ <input
383
+ type="color"
384
+ value={color}
385
+ onChange={(e) => setColor(e.target.value)}
386
+ className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700"
387
+ />
388
+ <input
389
+ type="text"
390
+ value={color}
391
+ onChange={(e) => setColor(e.target.value)}
392
+ className="flex-1 bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-cyan-500 outline-none uppercase font-mono"
393
+ />
394
+ </div>
395
+ </div>
396
+
397
+ {/* Thickness */}
398
+ <div>
399
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
400
+ <span>Line Thickness</span>
401
+ <span className="text-slate-500">{thickness}px</span>
402
+ </label>
403
+ <input
404
+ type="range"
405
+ min="2" max="64"
406
+ value={thickness}
407
+ onChange={(e) => setThickness(Number(e.target.value))}
408
+ className="w-full accent-cyan-500 cursor-pointer"
409
+ />
410
+ </div>
411
+
412
+ {/* Sensitivity / Scale */}
413
+ <div>
414
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
415
+ <span>Reactivity (FFT)</span>
416
+ </label>
417
+ <input
418
+ type="range"
419
+ min="32" max="256" step="32"
420
+ value={sensitivity}
421
+ onChange={(e) => setSensitivity(Number(e.target.value))}
422
+ className="w-full accent-cyan-500 cursor-pointer"
423
+ />
424
+ </div>
425
+ </div>
426
+ </section>
427
+
428
+ {/* Export Section */}
429
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
430
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">Export Options</h2>
431
+
432
+ <div className="grid grid-cols-2 gap-3">
433
+ <button
434
+ onClick={exportImage}
435
+ disabled={isExportingVideo}
436
+ className="col-span-2 bg-slate-800 hover:bg-slate-700 text-white font-medium py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
437
+ >
438
+ <ImageIcon className="w-4 h-4" />
439
+ Save Snapshot (PNG)
440
+ </button>
441
+
442
+ {isExportingVideo ? (
443
+ <div className="col-span-2 space-y-3">
444
+ <button
445
+ onClick={stopVideoExport}
446
+ className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors"
447
+ >
448
+ <StopCircle className="w-5 h-5" />
449
+ Stop & Save
450
+ </button>
451
+ <div className="w-full bg-slate-950 rounded-full h-2.5 border border-slate-800 overflow-hidden">
452
+ <div className="bg-cyan-500 h-2.5 rounded-full transition-all duration-300" style={{ width: `${exportProgress}%` }}></div>
453
+ </div>
454
+ <p className="text-xs text-center text-slate-400">Recording transparent 4K video... {Math.round(exportProgress)}%</p>
455
+ </div>
456
+ ) : (
457
+ <button
458
+ onClick={startVideoExport}
459
+ disabled={!audioSrc}
460
+ className="col-span-2 bg-gradient-to-r from-indigo-500 to-cyan-500 hover:from-indigo-400 hover:to-cyan-400 text-white font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-cyan-500/20 disabled:opacity-50 disabled:shadow-none"
461
+ >
462
+ <Video className="w-5 h-5" />
463
+ Export 4K Video (WebM)
464
+ </button>
465
+ )}
466
+ </div>
467
+ <p className="text-xs text-slate-500 mt-4 leading-relaxed">
468
+ * Video export records in real-time. Background will be transparent. WebM VP9 format is used for alpha channel support.
469
+ </p>
470
+ </section>
471
+
472
+ </div>
473
+
474
+ {/* Right Column: Preview */}
475
+ <div className="lg:col-span-8 flex flex-col">
476
+ <div className="bg-slate-900 rounded-2xl border border-slate-800 shadow-xl overflow-hidden flex-1 relative flex flex-col">
477
+
478
+ <div className="p-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center z-10">
479
+ <span className="text-sm font-semibold text-slate-300 flex items-center gap-2">
480
+ Live Preview
481
+ <span className="bg-slate-800 text-xs px-2 py-0.5 rounded text-slate-400 border border-slate-700">Native: 3840x2160</span>
482
+ </span>
483
+ <span className="text-xs text-slate-500">Checkerboard denotes transparency</span>
484
+ </div>
485
+
486
+ {/* Checkerboard Background for Transparency check */}
487
+ <div
488
+ className="flex-1 w-full relative flex items-center justify-center p-8"
489
+ style={{
490
+ backgroundImage: 'repeating-linear-gradient(45deg, #0f172a 25%, transparent 25%, transparent 75%, #0f172a 75%, #0f172a), repeating-linear-gradient(45deg, #0f172a 25%, #1e293b 25%, #1e293b 75%, #0f172a 75%, #0f172a)',
491
+ backgroundPosition: '0 0, 10px 10px',
492
+ backgroundSize: '20px 20px'
493
+ }}
494
+ >
495
+ {/* Actual Canvas (Internal 4K resolution, scaled by CSS for preview) */}
496
+ <canvas
497
+ ref={canvasRef}
498
+ width="3840"
499
+ height="2160"
500
+ className="w-full aspect-video object-contain drop-shadow-2xl rounded-lg ring-1 ring-white/10 bg-transparent"
501
+ />
502
+
503
+ {!audioSrc && (
504
+ <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-slate-900/60 backdrop-blur-sm">
505
+ <Loader2 className="w-12 h-12 text-slate-500 animate-spin mb-4 opacity-50" />
506
+ <p className="text-slate-400 font-medium">Awaiting Audio Input</p>
507
+ </div>
508
+ )}
509
+ </div>
510
+
511
+ </div>
512
+ </div>
513
+
514
+ </main>
515
+ </div>
516
+ );
517
+ }