ysdede commited on
Commit
b45e28e
·
verified ·
1 Parent(s): 2bf1a88

Upload App.js

Browse files
Files changed (1) hide show
  1. src/App.js +316 -316
src/App.js CHANGED
@@ -1,316 +1,316 @@
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 = 'ysdede/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?.overall_log_prob || 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="nemo80">nemo80 (smaller)</option>
190
- <option value="nemo128">nemo128 (default)</option>
191
- </select>
192
- </label>
193
- {' '}
194
- <label>
195
- Stride:
196
- <select value={frameStride} onChange={e=>setFrameStride(Number(e.target.value))}>
197
- <option value={1}>1</option>
198
- <option value={2}>2</option>
199
- <option value={4}>4</option>
200
- </select>
201
- </label>
202
- {' '}
203
- <label>
204
- <input type="checkbox" checked={verboseLog} onChange={e => setVerboseLog(e.target.checked)} />
205
- Verbose Log
206
- </label>
207
- {' '}
208
- <label style={{fontSize:'0.9em'}}>
209
- <input type="checkbox" checked={dumpDetail} onChange={e=>setDumpDetail(e.target.checked)} />
210
- Dump result to console
211
- </label>
212
- {(backend === 'wasm' || decoderInt8) && (
213
- <label style={{fontSize:'0.9em'}}>
214
- Threads:
215
- <input type="number" min="1" max={maxCores} value={cpuThreads} onChange={e=>setCpuThreads(Number(e.target.value))} style={{width:'4rem'}} />
216
- </label>
217
- )}
218
- <button
219
- onClick={loadModel}
220
- disabled={!status.toLowerCase().includes('fail') && status !== 'Idle'}
221
- className="primary"
222
- >
223
- {status === 'Model ready ✔' ? 'Model Loaded' : 'Load Model'}
224
- </button>
225
- </div>
226
-
227
- {typeof SharedArrayBuffer === 'undefined' && backend === 'wasm' && (
228
- <div style={{
229
- marginBottom: '1rem',
230
- padding: '0.5rem',
231
- backgroundColor: '#fff3cd',
232
- border: '1px solid #ffeaa7',
233
- borderRadius: '4px',
234
- fontSize: '0.9em'
235
- }}>
236
- ⚠️ <strong>Performance Note:</strong> SharedArrayBuffer is not available.
237
- WASM will run single-threaded. For better performance, use WebGPU.
238
- </div>
239
- )}
240
-
241
- <div className="controls">
242
- <input
243
- ref={fileInputRef}
244
- type="file"
245
- accept="audio/*"
246
- onChange={transcribeFile}
247
- disabled={status !== 'Model ready ✔' || isTranscribing}
248
- />
249
- {transcriptions.length > 0 && (
250
- <button
251
- onClick={clearTranscriptions}
252
- style={{ marginLeft: '1rem', padding: '0.25rem 0.5rem' }}
253
- >
254
- Clear History
255
- </button>
256
- )}
257
- </div>
258
-
259
- <p>Status: {status}</p>
260
- {progressPct!==null && (
261
- <div className="progress-wrapper">
262
- <div className="progress-bar"><div style={{ width: `${progressPct}%` }} /></div>
263
- <p className="progress-text">{progressText}</p>
264
- </div>
265
- )}
266
-
267
- {/* Latest transcription */}
268
- <div className="controls">
269
- <h3>Latest Transcription:</h3>
270
- <textarea
271
- value={text}
272
- readOnly
273
- className="textarea"
274
- placeholder="Transcribed text will appear here..."
275
- />
276
- </div>
277
-
278
- {/* Latest transcription performace info */}
279
- {latestMetrics && (
280
- <div className="performance">
281
- <strong>RTF:</strong> {latestMetrics.rtf?.toFixed(2)}x &nbsp;|&nbsp; Total: {latestMetrics.total_ms} ms<br/>
282
- Preprocess {latestMetrics.preprocess_ms} ms · Encode {latestMetrics.encode_ms} ms · Decode {latestMetrics.decode_ms} ms · Tokenize {latestMetrics.tokenize_ms} ms
283
- </div>
284
- )}
285
-
286
- {/* Transcription history */}
287
- {transcriptions.length > 0 && (
288
- <div className="history">
289
- <h3>Transcription History ({transcriptions.length} files):</h3>
290
- <div style={{ maxHeight: '400px', overflowY: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
291
- {transcriptions.map((trans) => (
292
- <div className="history-item" key={trans.id}>
293
- <div className="history-meta"><strong>{trans.filename}</strong><span>{trans.timestamp}</span></div>
294
- <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>
295
- <div className="history-text">{trans.text}</div>
296
- </div>
297
- ))}
298
- </div>
299
- </div>
300
- )}
301
-
302
- <div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px', fontSize: '0.9em' }}>
303
- <h4>🔗 Links:</h4>
304
- <p>
305
- <a href="https://github.com/ysdede/parakeet.js" target="_blank" rel="noopener noreferrer">
306
- GitHub Repository
307
- </a>
308
- {' | '}
309
- <a href="https://www.npmjs.com/package/parakeet.js" target="_blank" rel="noopener noreferrer">
310
- npm Package
311
- </a>
312
- </p>
313
- </div>
314
- </div>
315
- );
316
- }
 
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 = 'ysdede/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="nemo80">nemo80 (smaller)</option>
190
+ <option value="nemo128">nemo128 (default)</option>
191
+ </select>
192
+ </label>
193
+ {' '}
194
+ <label>
195
+ Stride:
196
+ <select value={frameStride} onChange={e=>setFrameStride(Number(e.target.value))}>
197
+ <option value={1}>1</option>
198
+ <option value={2}>2</option>
199
+ <option value={4}>4</option>
200
+ </select>
201
+ </label>
202
+ {' '}
203
+ <label>
204
+ <input type="checkbox" checked={verboseLog} onChange={e => setVerboseLog(e.target.checked)} />
205
+ Verbose Log
206
+ </label>
207
+ {' '}
208
+ <label style={{fontSize:'0.9em'}}>
209
+ <input type="checkbox" checked={dumpDetail} onChange={e=>setDumpDetail(e.target.checked)} />
210
+ Dump result to console
211
+ </label>
212
+ {(backend === 'wasm' || decoderInt8) && (
213
+ <label style={{fontSize:'0.9em'}}>
214
+ Threads:
215
+ <input type="number" min="1" max={maxCores} value={cpuThreads} onChange={e=>setCpuThreads(Number(e.target.value))} style={{width:'4rem'}} />
216
+ </label>
217
+ )}
218
+ <button
219
+ onClick={loadModel}
220
+ disabled={!status.toLowerCase().includes('fail') && status !== 'Idle'}
221
+ className="primary"
222
+ >
223
+ {status === 'Model ready ✔' ? 'Model Loaded' : 'Load Model'}
224
+ </button>
225
+ </div>
226
+
227
+ {typeof SharedArrayBuffer === 'undefined' && backend === 'wasm' && (
228
+ <div style={{
229
+ marginBottom: '1rem',
230
+ padding: '0.5rem',
231
+ backgroundColor: '#fff3cd',
232
+ border: '1px solid #ffeaa7',
233
+ borderRadius: '4px',
234
+ fontSize: '0.9em'
235
+ }}>
236
+ ⚠️ <strong>Performance Note:</strong> SharedArrayBuffer is not available.
237
+ WASM will run single-threaded. For better performance, use WebGPU.
238
+ </div>
239
+ )}
240
+
241
+ <div className="controls">
242
+ <input
243
+ ref={fileInputRef}
244
+ type="file"
245
+ accept="audio/*"
246
+ onChange={transcribeFile}
247
+ disabled={status !== 'Model ready ✔' || isTranscribing}
248
+ />
249
+ {transcriptions.length > 0 && (
250
+ <button
251
+ onClick={clearTranscriptions}
252
+ style={{ marginLeft: '1rem', padding: '0.25rem 0.5rem' }}
253
+ >
254
+ Clear History
255
+ </button>
256
+ )}
257
+ </div>
258
+
259
+ <p>Status: {status}</p>
260
+ {progressPct!==null && (
261
+ <div className="progress-wrapper">
262
+ <div className="progress-bar"><div style={{ width: `${progressPct}%` }} /></div>
263
+ <p className="progress-text">{progressText}</p>
264
+ </div>
265
+ )}
266
+
267
+ {/* Latest transcription */}
268
+ <div className="controls">
269
+ <h3>Latest Transcription:</h3>
270
+ <textarea
271
+ value={text}
272
+ readOnly
273
+ className="textarea"
274
+ placeholder="Transcribed text will appear here..."
275
+ />
276
+ </div>
277
+
278
+ {/* Latest transcription performace info */}
279
+ {latestMetrics && (
280
+ <div className="performance">
281
+ <strong>RTF:</strong> {latestMetrics.rtf?.toFixed(2)}x &nbsp;|&nbsp; Total: {latestMetrics.total_ms} ms<br/>
282
+ Preprocess {latestMetrics.preprocess_ms} ms · Encode {latestMetrics.encode_ms} ms · Decode {latestMetrics.decode_ms} ms · Tokenize {latestMetrics.tokenize_ms} ms
283
+ </div>
284
+ )}
285
+
286
+ {/* Transcription history */}
287
+ {transcriptions.length > 0 && (
288
+ <div className="history">
289
+ <h3>Transcription History ({transcriptions.length} files):</h3>
290
+ <div style={{ maxHeight: '400px', overflowY: 'auto', border: '1px solid #ddd', borderRadius: '4px' }}>
291
+ {transcriptions.map((trans) => (
292
+ <div className="history-item" key={trans.id}>
293
+ <div className="history-meta"><strong>{trans.filename}</strong><span>{trans.timestamp}</span></div>
294
+ <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>
295
+ <div className="history-text">{trans.text}</div>
296
+ </div>
297
+ ))}
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ <div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px', fontSize: '0.9em' }}>
303
+ <h4>🔗 Links:</h4>
304
+ <p>
305
+ <a href="https://github.com/ysdede/parakeet.js" target="_blank" rel="noopener noreferrer">
306
+ GitHub Repository
307
+ </a>
308
+ {' | '}
309
+ <a href="https://www.npmjs.com/package/parakeet.js" target="_blank" rel="noopener noreferrer">
310
+ npm Package
311
+ </a>
312
+ </p>
313
+ </div>
314
+ </div>
315
+ );
316
+ }