ysdede commited on
Commit
09b5094
·
1 Parent(s): 016a7d0

- Fixes a runtime error caused by the `enableGraphCapture` flag in recent ONNX Runtime Web builds. See parakeet.js library repo for details.

Browse files
Files changed (2) hide show
  1. package.json +1 -1
  2. src/App.js +307 -315
package.json CHANGED
@@ -7,7 +7,7 @@
7
  "@testing-library/jest-dom": "^6.6.3",
8
  "@testing-library/react": "^16.3.0",
9
  "@testing-library/user-event": "^13.5.0",
10
- "parakeet.js": "^0.0.2",
11
  "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
12
  "react": "^19.1.0",
13
  "react-dom": "^19.1.0",
 
7
  "@testing-library/jest-dom": "^6.6.3",
8
  "@testing-library/react": "^16.3.0",
9
  "@testing-library/user-event": "^13.5.0",
10
+ "parakeet.js": "^0.0.3",
11
  "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
12
  "react": "^19.1.0",
13
  "react-dom": "^19.1.0",
src/App.js CHANGED
@@ -1,315 +1,307 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import { ParakeetModel, getParakeetModel } from 'parakeet.js';
3
- import './App.css';
4
-
5
- export default function App() {
6
- const repoId = 'istupakov/parakeet-tdt-0.6b-v2-onnx';
7
- const [backend, setBackend] = useState('webgpu-hybrid');
8
- const [quant, setQuant] = useState('fp32');
9
- const [preprocessor, setPreprocessor] = useState('nemo128');
10
- const [status, setStatus] = useState('Idle');
11
- const [progress, setProgress] = useState('');
12
- const [progressText, setProgressText] = useState('');
13
- const [progressPct, setProgressPct] = useState(null);
14
- const [text, setText] = useState('');
15
- const [latestMetrics, setLatestMetrics] = useState(null);
16
- const [transcriptions, setTranscriptions] = useState([]);
17
- const [isTranscribing, setIsTranscribing] = useState(false);
18
- const [verboseLog, setVerboseLog] = useState(false);
19
- const [decoderInt8, setDecoderInt8] = useState(true);
20
- const [frameStride, setFrameStride] = useState(1);
21
- const [dumpDetail, setDumpDetail] = useState(false);
22
- const maxCores = navigator.hardwareConcurrency || 8;
23
- const [cpuThreads, setCpuThreads] = useState(Math.max(1, maxCores - 2));
24
- const modelRef = useRef(null);
25
- const fileInputRef = useRef(null);
26
-
27
- // Auto-adjust quant preset when backend changes
28
- useEffect(() => {
29
- if (backend.startsWith('webgpu')) {
30
- setQuant('fp32');
31
- } else if (backend === 'wasm') {
32
- setQuant('int8');
33
- }
34
- }, [backend]);
35
-
36
- async function loadModel() {
37
- setStatus('Loading model…');
38
- setProgress('');
39
- setProgressText('');
40
- setProgressPct(0);
41
- console.time('LoadModel');
42
-
43
- try {
44
- const progressCallback = ({ loaded, total, file }) => {
45
- const pct = total > 0 ? Math.round((loaded / total) * 100) : 0;
46
- setProgressText(`${file}: ${pct}%`);
47
- setProgressPct(pct);
48
- };
49
-
50
- // 1. Download all model files from HuggingFace Hub
51
- const modelUrls = await getParakeetModel(repoId, {
52
- quantization: quant,
53
- preprocessor,
54
- backend, // Pass backend to enable automatic fp32 selection for WebGPU
55
- decoderInt8,
56
- progress: progressCallback
57
- });
58
-
59
- // Show compiling sessions stage
60
- setStatus('Creating sessions…');
61
- setProgressText('Compiling model (this may take ~10 s)…');
62
- setProgressPct(null);
63
-
64
- // 2. Create the model instance with all file URLs
65
- modelRef.current = await ParakeetModel.fromUrls({
66
- ...modelUrls.urls,
67
- filenames: modelUrls.filenames,
68
- backend,
69
- verbose: verboseLog,
70
- decoderOnWasm: decoderInt8, // if we selected int8 decoder, keep it on WASM
71
- decoderInt8,
72
- cpuThreads,
73
- });
74
-
75
- // 3. Warm-up and verify
76
- setStatus('Warming up & verifying…');
77
- setProgressText('Model ready! Upload an audio file to transcribe.');
78
- setProgressPct(null);
79
-
80
- console.timeEnd('LoadModel');
81
- setStatus('Model ready ✔');
82
- setProgressText('');
83
- } catch (e) {
84
- console.error(e);
85
- setStatus(`Failed: ${e.message}`);
86
- setProgress('');
87
- }
88
- }
89
-
90
- async function transcribeFile(e) {
91
- if (!modelRef.current) return alert('Load model first');
92
- const file = e.target.files?.[0];
93
- if (!file) return;
94
-
95
- setIsTranscribing(true);
96
- setStatus(`Transcribing "${file.name}"…`);
97
-
98
- try {
99
- const buf = await file.arrayBuffer();
100
- const audioCtx = new AudioContext({ sampleRate: 16000 });
101
- const decoded = await audioCtx.decodeAudioData(buf);
102
- const pcm = decoded.getChannelData(0);
103
-
104
- console.time(`Transcribe-${file.name}`);
105
- const res = await modelRef.current.transcribe(pcm, 16_000, {
106
- returnTimestamps: true,
107
- returnConfidences: true,
108
- frameStride
109
- });
110
- console.timeEnd(`Transcribe-${file.name}`);
111
-
112
- if (dumpDetail) {
113
- console.log('[Parakeet] Detailed transcription output', res);
114
- }
115
- setLatestMetrics(res.metrics);
116
- // Add to transcriptions list
117
- const newTranscription = {
118
- id: Date.now(),
119
- filename: file.name,
120
- text: res.utterance_text,
121
- timestamp: new Date().toLocaleTimeString(),
122
- duration: pcm.length / 16000, // duration in seconds
123
- wordCount: res.words?.length || 0,
124
- confidence: res.confidence_scores?.token_avg ?? res.confidence_scores?.word_avg ?? null,
125
- metrics: res.metrics
126
- };
127
-
128
- setTranscriptions(prev => [newTranscription, ...prev]);
129
- setText(res.utterance_text); // Show latest transcription
130
- setStatus('Model ready ✔'); // Ready for next file
131
-
132
- } catch (error) {
133
- console.error('Transcription failed:', error);
134
- setStatus('Transcription failed');
135
- alert(`Failed to transcribe "${file.name}": ${error.message}`);
136
- } finally {
137
- setIsTranscribing(false);
138
- // Clear the file input so the same file can be selected again
139
- if (fileInputRef.current) {
140
- fileInputRef.current.value = '';
141
- }
142
- }
143
- }
144
-
145
- function clearTranscriptions() {
146
- setTranscriptions([]);
147
- setText('');
148
- }
149
-
150
- return (
151
- <div className="app">
152
- <h2>🦜 Parakeet.js - HF Spaces Demo</h2>
153
- <p>NVIDIA Parakeet speech recognition for the browser using WebGPU/WASM</p>
154
-
155
- <div className="controls">
156
- <p>
157
- <strong>Model:</strong> {repoId}
158
- </p>
159
- </div>
160
-
161
- <div className="controls">
162
- <label>
163
- Backend:
164
- <select value={backend} onChange={e=>setBackend(e.target.value)}>
165
- <option value="webgpu-hybrid">WebGPU (Hybrid)</option>
166
- <option value="webgpu-strict">WebGPU (Strict)</option>
167
- <option value="wasm">WASM (CPU)</option>
168
- </select>
169
- </label>
170
- {' '}
171
- <label>
172
- Quant:
173
- <select value={quant} onChange={e=>setQuant(e.target.value)}>
174
- <option value="int8">int8 (faster)</option>
175
- <option value="fp32">fp32 (higher quality)</option>
176
- </select>
177
- </label>
178
- {' '}
179
- {backend.startsWith('webgpu') && (
180
- <label style={{ fontSize:'0.9em' }}>
181
- <input type="checkbox" checked={decoderInt8} onChange={e=>setDecoderInt8(e.target.checked)} />
182
- Decoder INT8 on CPU
183
- </label>
184
- )}
185
- {' '}
186
- <label>
187
- Preprocessor:
188
- <select value={preprocessor} onChange={e=>setPreprocessor(e.target.value)}>
189
- <option value="nemo128">nemo128 (default)</option>
190
- </select>
191
- </label>
192
- {' '}
193
- <label>
194
- Stride:
195
- <select value={frameStride} onChange={e=>setFrameStride(Number(e.target.value))}>
196
- <option value={1}>1</option>
197
- <option value={2}>2</option>
198
- <option value={4}>4</option>
199
- </select>
200
- </label>
201
- {' '}
202
- <label>
203
- <input type="checkbox" checked={verboseLog} onChange={e => setVerboseLog(e.target.checked)} />
204
- Verbose Log
205
- </label>
206
- {' '}
207
- <label style={{fontSize:'0.9em'}}>
208
- <input type="checkbox" checked={dumpDetail} onChange={e=>setDumpDetail(e.target.checked)} />
209
- Dump result to console
210
- </label>
211
- {(backend === 'wasm' || decoderInt8) && (
212
- <label style={{fontSize:'0.9em'}}>
213
- Threads:
214
- <input type="number" min="1" max={maxCores} value={cpuThreads} onChange={e=>setCpuThreads(Number(e.target.value))} style={{width:'4rem'}} />
215
- </label>
216
- )}
217
- <button
218
- onClick={loadModel}
219
- disabled={!status.toLowerCase().includes('fail') && status !== 'Idle'}
220
- className="primary"
221
- >
222
- {status === 'Model ready ✔' ? 'Model Loaded' : 'Load Model'}
223
- </button>
224
- </div>
225
-
226
- {typeof SharedArrayBuffer === 'undefined' && backend === 'wasm' && (
227
- <div style={{
228
- marginBottom: '1rem',
229
- padding: '0.5rem',
230
- backgroundColor: '#fff3cd',
231
- border: '1px solid #ffeaa7',
232
- borderRadius: '4px',
233
- fontSize: '0.9em'
234
- }}>
235
- ⚠️ <strong>Performance Note:</strong> SharedArrayBuffer is not available.
236
- WASM will run single-threaded. For better performance, use WebGPU.
237
- </div>
238
- )}
239
-
240
- <div className="controls">
241
- <input
242
- ref={fileInputRef}
243
- type="file"
244
- accept="audio/*"
245
- onChange={transcribeFile}
246
- disabled={status !== 'Model ready ✔' || isTranscribing}
247
- />
248
- {transcriptions.length > 0 && (
249
- <button
250
- onClick={clearTranscriptions}
251
- style={{ marginLeft: '1rem', padding: '0.25rem 0.5rem' }}
252
- >
253
- Clear History
254
- </button>
255
- )}
256
- </div>
257
-
258
- <p>Status: {status}</p>
259
- {progressPct!==null && (
260
- <div className="progress-wrapper">
261
- <div className="progress-bar"><div style={{ width: `${progressPct}%` }} /></div>
262
- <p className="progress-text">{progressText}</p>
263
- </div>
264
- )}
265
-
266
- {/* Latest transcription */}
267
- <div className="controls">
268
- <h3>Latest Transcription:</h3>
269
- <textarea
270
- value={text}
271
- readOnly
272
- className="textarea"
273
- placeholder="Transcribed text will appear here..."
274
- />
275
- </div>
276
-
277
- {/* Latest transcription performace info */}
278
- {latestMetrics && (
279
- <div className="performance">
280
- <strong>RTF:</strong> {latestMetrics.rtf?.toFixed(2)}x &nbsp;|&nbsp; Total: {latestMetrics.total_ms} ms<br/>
281
- Preprocess {latestMetrics.preprocess_ms} ms · Encode {latestMetrics.encode_ms} ms · Decode {latestMetrics.decode_ms} ms · Tokenize {latestMetrics.tokenize_ms} ms
282
- </div>
283
- )}
284
-
285
- {/* Transcription history */}
286
- {transcriptions.length > 0 && (
287
- <div className="history">
288
- <h3>Transcription History ({transcriptions.length} files):</h3>
289
- <div style={{ maxHeight: '400px', overflowY: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
290
- {transcriptions.map((trans) => (
291
- <div className="history-item" key={trans.id}>
292
- <div className="history-meta"><strong>{trans.filename}</strong><span>{trans.timestamp}</span></div>
293
- <div className="history-stats">Duration: {trans.duration.toFixed(1)}s | Words: {trans.wordCount}{trans.confidence && ` | Confidence: ${trans.confidence.toFixed(2)}`}{trans.metrics && ` | RTF: ${trans.metrics.rtf?.toFixed(2)}x`}</div>
294
- <div className="history-text">{trans.text}</div>
295
- </div>
296
- ))}
297
- </div>
298
- </div>
299
- )}
300
-
301
- <div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px', fontSize: '0.9em' }}>
302
- <h4>🔗 Links:</h4>
303
- <p>
304
- <a href="https://github.com/ysdede/parakeet.js" target="_blank" rel="noopener noreferrer">
305
- GitHub Repository
306
- </a>
307
- {' | '}
308
- <a href="https://www.npmjs.com/package/parakeet.js" target="_blank" rel="noopener noreferrer">
309
- npm Package
310
- </a>
311
- </p>
312
- </div>
313
- </div>
314
- );
315
- }
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { ParakeetModel, getParakeetModel } from 'parakeet.js';
3
+ import './App.css';
4
+
5
+ export default function App() {
6
+ const repoId = 'istupakov/parakeet-tdt-0.6b-v2-onnx';
7
+ const [backend, setBackend] = useState('webgpu-hybrid');
8
+ const [encoderQuant, setEncoderQuant] = useState('fp32');
9
+ const [decoderQuant, setDecoderQuant] = useState('int8');
10
+ const [preprocessor, setPreprocessor] = useState('nemo128');
11
+ const [status, setStatus] = useState('Idle');
12
+ const [progress, setProgress] = useState('');
13
+ const [progressText, setProgressText] = useState('');
14
+ const [progressPct, setProgressPct] = useState(null);
15
+ const [text, setText] = useState('');
16
+ const [latestMetrics, setLatestMetrics] = useState(null);
17
+ const [transcriptions, setTranscriptions] = useState([]);
18
+ const [isTranscribing, setIsTranscribing] = useState(false);
19
+ const [verboseLog, setVerboseLog] = useState(false);
20
+ const [frameStride, setFrameStride] = useState(1);
21
+ const [dumpDetail, setDumpDetail] = useState(false);
22
+ const maxCores = navigator.hardwareConcurrency || 8;
23
+ const [cpuThreads, setCpuThreads] = useState(Math.max(1, maxCores - 2));
24
+ const modelRef = useRef(null);
25
+ const fileInputRef = useRef(null);
26
+
27
+ // Auto-adjust quant presets when backend changes
28
+ useEffect(() => {
29
+ if (backend.startsWith('webgpu')) {
30
+ setEncoderQuant('fp32');
31
+ setDecoderQuant('int8');
32
+ } else {
33
+ setEncoderQuant('int8');
34
+ setDecoderQuant('int8');
35
+ }
36
+ }, [backend]);
37
+
38
+ async function loadModel() {
39
+ setStatus('Loading model…');
40
+ setProgress('');
41
+ setProgressText('');
42
+ setProgressPct(0);
43
+ console.time('LoadModel');
44
+
45
+ try {
46
+ const progressCallback = (p) => setProgress(`${p.file}: ${Math.round(p.loaded/p.total*100)}%`);
47
+
48
+ // 1. Download all model files from HuggingFace Hub
49
+ const modelUrls = await getParakeetModel(repoId, {
50
+ encoderQuant,
51
+ decoderQuant,
52
+ preprocessor,
53
+ progress: progressCallback
54
+ });
55
+
56
+ // Show compiling sessions stage
57
+ setStatus('Creating sessions…');
58
+ setProgressText('Compiling model (this may take ~10 s)…');
59
+ setProgressPct(null);
60
+
61
+ // 2. Create the model instance with all file URLs
62
+ modelRef.current = await ParakeetModel.fromUrls({
63
+ ...modelUrls.urls,
64
+ backend,
65
+ });
66
+
67
+ // 3. Warm-up and verify
68
+ setStatus('Warming up & verifying…');
69
+ setProgressText('Model ready! Upload an audio file to transcribe.');
70
+ setProgressPct(null);
71
+
72
+ console.timeEnd('LoadModel');
73
+ setStatus('Model ready ✔');
74
+ setProgressText('');
75
+ } catch (e) {
76
+ console.error(e);
77
+ setStatus(`Failed: ${e.message}`);
78
+ setProgress('');
79
+ }
80
+ }
81
+
82
+ async function transcribeFile(e) {
83
+ if (!modelRef.current) return alert('Load model first');
84
+ const file = e.target.files?.[0];
85
+ if (!file) return;
86
+
87
+ setIsTranscribing(true);
88
+ setStatus(`Transcribing "${file.name}"…`);
89
+
90
+ try {
91
+ const buf = await file.arrayBuffer();
92
+ const audioCtx = new AudioContext({ sampleRate: 16000 });
93
+ const decoded = await audioCtx.decodeAudioData(buf);
94
+ const pcm = decoded.getChannelData(0);
95
+
96
+ console.time(`Transcribe-${file.name}`);
97
+ const res = await modelRef.current.transcribe(pcm, 16_000, {
98
+ returnTimestamps: true,
99
+ returnConfidences: true,
100
+ frameStride
101
+ });
102
+ console.timeEnd(`Transcribe-${file.name}`);
103
+
104
+ if (dumpDetail) {
105
+ console.log('[Parakeet] Detailed transcription output', res);
106
+ }
107
+ setLatestMetrics(res.metrics);
108
+ // Add to transcriptions list
109
+ const newTranscription = {
110
+ id: Date.now(),
111
+ filename: file.name,
112
+ text: res.utterance_text,
113
+ timestamp: new Date().toLocaleTimeString(),
114
+ duration: pcm.length / 16000, // duration in seconds
115
+ wordCount: res.words?.length || 0,
116
+ confidence: res.confidence_scores?.token_avg ?? res.confidence_scores?.word_avg ?? null,
117
+ metrics: res.metrics
118
+ };
119
+
120
+ setTranscriptions(prev => [newTranscription, ...prev]);
121
+ setText(res.utterance_text); // Show latest transcription
122
+ setStatus('Model ready ✔'); // Ready for next file
123
+
124
+ } catch (error) {
125
+ console.error('Transcription failed:', error);
126
+ setStatus('Transcription failed');
127
+ alert(`Failed to transcribe "${file.name}": ${error.message}`);
128
+ } finally {
129
+ setIsTranscribing(false);
130
+ // Clear the file input so the same file can be selected again
131
+ if (fileInputRef.current) {
132
+ fileInputRef.current.value = '';
133
+ }
134
+ }
135
+ }
136
+
137
+ function clearTranscriptions() {
138
+ setTranscriptions([]);
139
+ setText('');
140
+ }
141
+
142
+ return (
143
+ <div className="app">
144
+ <h2>🦜 Parakeet.js - HF Spaces Demo</h2>
145
+ <p>NVIDIA Parakeet speech recognition for the browser using WebGPU/WASM</p>
146
+
147
+ <div className="controls">
148
+ <p>
149
+ <strong>Model:</strong> {repoId}
150
+ </p>
151
+ </div>
152
+
153
+ <div className="controls">
154
+ <label>
155
+ Backend:
156
+ <select value={backend} onChange={e=>setBackend(e.target.value)}>
157
+ <option value="webgpu-hybrid">WebGPU</option>
158
+ <option value="wasm">WASM (CPU)</option>
159
+ </select>
160
+ </label>
161
+ {' '}
162
+ <label>
163
+ Encoder Quant:
164
+ <select value={encoderQuant} onChange={e=>setEncoderQuant(e.target.value)}>
165
+ <option value="int8">int8 (faster)</option>
166
+ <option value="fp32">fp32 (higher quality)</option>
167
+ </select>
168
+ </label>
169
+ {' '}
170
+ <label>
171
+ Decoder Quant:
172
+ <select value={decoderQuant} onChange={e=>setDecoderQuant(e.target.value)}>
173
+ <option value="int8">int8 (faster)</option>
174
+ <option value="fp32">fp32 (higher quality)</option>
175
+ </select>
176
+ </label>
177
+ {' '}
178
+ <label>
179
+ Preprocessor:
180
+ <select value={preprocessor} onChange={e=>setPreprocessor(e.target.value)}>
181
+ <option value="nemo128">nemo128 (default)</option>
182
+ </select>
183
+ </label>
184
+ {' '}
185
+ <label>
186
+ Stride:
187
+ <select value={frameStride} onChange={e=>setFrameStride(Number(e.target.value))}>
188
+ <option value={1}>1</option>
189
+ <option value={2}>2</option>
190
+ <option value={4}>4</option>
191
+ </select>
192
+ </label>
193
+ {' '}
194
+ <label>
195
+ <input type="checkbox" checked={verboseLog} onChange={e => setVerboseLog(e.target.checked)} />
196
+ Verbose Log
197
+ </label>
198
+ {' '}
199
+ <label style={{fontSize:'0.9em'}}>
200
+ <input type="checkbox" checked={dumpDetail} onChange={e=>setDumpDetail(e.target.checked)} />
201
+ Dump result to console
202
+ </label>
203
+ {(backend === 'wasm') && (
204
+ <label style={{fontSize:'0.9em'}}>
205
+ Threads:
206
+ <input type="number" min="1" max={maxCores} value={cpuThreads} onChange={e=>setCpuThreads(Number(e.target.value))} style={{width:'4rem'}} />
207
+ </label>
208
+ )}
209
+ <button
210
+ onClick={loadModel}
211
+ disabled={!status.toLowerCase().includes('fail') && status !== 'Idle'}
212
+ className="primary"
213
+ >
214
+ {status === 'Model ready ✔' ? 'Model Loaded' : 'Load Model'}
215
+ </button>
216
+ </div>
217
+
218
+ {typeof SharedArrayBuffer === 'undefined' && backend === 'wasm' && (
219
+ <div style={{
220
+ marginBottom: '1rem',
221
+ padding: '0.5rem',
222
+ backgroundColor: '#fff3cd',
223
+ border: '1px solid #ffeaa7',
224
+ borderRadius: '4px',
225
+ fontSize: '0.9em'
226
+ }}>
227
+ ⚠️ <strong>Performance Note:</strong> SharedArrayBuffer is not available.
228
+ WASM will run single-threaded. For better performance, use WebGPU.
229
+ </div>
230
+ )}
231
+
232
+ <div className="controls">
233
+ <input
234
+ ref={fileInputRef}
235
+ type="file"
236
+ accept="audio/*"
237
+ onChange={transcribeFile}
238
+ disabled={status !== 'Model ready ✔' || isTranscribing}
239
+ />
240
+ {transcriptions.length > 0 && (
241
+ <button
242
+ onClick={clearTranscriptions}
243
+ style={{ marginLeft: '1rem', padding: '0.25rem 0.5rem' }}
244
+ >
245
+ Clear History
246
+ </button>
247
+ )}
248
+ </div>
249
+
250
+ <p>Status: {status}</p>
251
+ {progressPct!==null && (
252
+ <div className="progress-wrapper">
253
+ <div className="progress-bar"><div style={{ width: `${progressPct}%` }} /></div>
254
+ <p className="progress-text">{progressText}</p>
255
+ </div>
256
+ )}
257
+
258
+ {/* Latest transcription */}
259
+ <div className="controls">
260
+ <h3>Latest Transcription:</h3>
261
+ <textarea
262
+ value={text}
263
+ readOnly
264
+ className="textarea"
265
+ placeholder="Transcribed text will appear here..."
266
+ />
267
+ </div>
268
+
269
+ {/* Latest transcription performace info */}
270
+ {latestMetrics && (
271
+ <div className="performance">
272
+ <strong>RTF:</strong> {latestMetrics.rtf?.toFixed(2)}x &nbsp;|&nbsp; Total: {latestMetrics.total_ms} ms<br/>
273
+ Preprocess {latestMetrics.preprocess_ms} ms · Encode {latestMetrics.encode_ms} ms · Decode {latestMetrics.decode_ms} ms · Tokenize {latestMetrics.tokenize_ms} ms
274
+ </div>
275
+ )}
276
+
277
+ {/* Transcription history */}
278
+ {transcriptions.length > 0 && (
279
+ <div className="history">
280
+ <h3>Transcription History ({transcriptions.length} files):</h3>
281
+ <div style={{ maxHeight: '400px', overflowY: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
282
+ {transcriptions.map((trans) => (
283
+ <div className="history-item" key={trans.id}>
284
+ <div className="history-meta"><strong>{trans.filename}</strong><span>{trans.timestamp}</span></div>
285
+ <div className="history-stats">Duration: {trans.duration.toFixed(1)}s | Words: {trans.wordCount}{trans.confidence && ` | Confidence: ${trans.confidence.toFixed(2)}`}{trans.metrics && ` | RTF: ${trans.metrics.rtf?.toFixed(2)}x`}</div>
286
+ <div className="history-text">{trans.text}</div>
287
+ </div>
288
+ ))}
289
+ </div>
290
+ </div>
291
+ )}
292
+
293
+ <div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px', fontSize: '0.9em' }}>
294
+ <h4>🔗 Links:</h4>
295
+ <p>
296
+ <a href="https://github.com/ysdede/parakeet.js" target="_blank" rel="noopener noreferrer">
297
+ GitHub Repository
298
+ </a>
299
+ {' | '}
300
+ <a href="https://www.npmjs.com/package/parakeet.js" target="_blank" rel="noopener noreferrer">
301
+ npm Package
302
+ </a>
303
+ </p>
304
+ </div>
305
+ </div>
306
+ );
307
+ }