Xenova HF Staff commited on
Commit
40699de
·
verified ·
1 Parent(s): b6920df

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +1987 -17
index.html CHANGED
@@ -1,19 +1,1989 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta
7
+ name="description"
8
+ content="S1-mini by Superwhisper turns raw speech transcripts into polished written text, locally in your browser with WebGPU."
9
+ />
10
+ <meta name="theme-color" content="#0a0a0a" />
11
+ <title>S1 WebGPU Dictation</title>
12
+ <style>
13
+ :root {
14
+ color-scheme: dark;
15
+ font-family:
16
+ Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
17
+ sans-serif;
18
+ --page: #090909;
19
+ --line: rgba(255, 255, 255, 0.14);
20
+ --line-strong: rgba(255, 255, 255, 0.28);
21
+ --text: #f4f4f2;
22
+ --blue: #56a8ff;
23
+ --yellow: #f4b846;
24
+ --green: #55ca82;
25
+ --red: #ff514c;
26
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
27
+ --topbar-height: 78px;
28
+ --footer-height: 48px;
29
+ --chrome-height: calc(var(--topbar-height) + var(--footer-height));
30
+ }
31
+
32
+ * {
33
+ box-sizing: border-box;
34
+ }
35
+
36
+ html {
37
+ height: 100%;
38
+ min-width: 320px;
39
+ overflow: hidden;
40
+ background: var(--page);
41
+ }
42
+
43
+ body {
44
+ display: grid;
45
+ height: 100svh;
46
+ min-height: 0;
47
+ margin: 0;
48
+ overflow: hidden;
49
+ background:
50
+ radial-gradient(circle at 50% -15%, rgba(255, 255, 255, 0.075), transparent 38rem),
51
+ linear-gradient(180deg, #101011 0%, var(--page) 64%);
52
+ color: var(--text);
53
+ grid-template-rows: var(--topbar-height) minmax(0, 1fr) var(--footer-height);
54
+ -webkit-font-smoothing: antialiased;
55
+ }
56
+
57
+ button,
58
+ select {
59
+ font: inherit;
60
+ }
61
+
62
+ button {
63
+ color: inherit;
64
+ }
65
+
66
+ button:focus-visible,
67
+ select:focus-visible,
68
+ summary:focus-visible {
69
+ outline: 2px solid rgba(86, 168, 255, 0.85);
70
+ outline-offset: 3px;
71
+ }
72
+
73
+ .topbar {
74
+ position: relative;
75
+ z-index: 3;
76
+ display: flex;
77
+ width: min(1120px, calc(100% - 48px));
78
+ height: 100%;
79
+ margin: 0 auto;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ }
83
+
84
+ .brand,
85
+ .local-badge,
86
+ .pipeline-note,
87
+ .keyboard-hint {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ }
91
+
92
+ .brand {
93
+ gap: 10px;
94
+ color: #ededeb;
95
+ font-size: 14px;
96
+ font-weight: 620;
97
+ letter-spacing: -0.01em;
98
+ }
99
+
100
+ .brand-mark {
101
+ display: grid;
102
+ width: 27px;
103
+ height: 27px;
104
+ border: 1px solid rgba(255, 255, 255, 0.2);
105
+ border-radius: 9px;
106
+ background: linear-gradient(145deg, #262627, #101011);
107
+ box-shadow:
108
+ inset 0 1px rgba(255, 255, 255, 0.08),
109
+ 0 5px 16px rgba(0, 0, 0, 0.32);
110
+ place-items: center;
111
+ }
112
+
113
+ .brand-mark-bars {
114
+ display: flex;
115
+ height: 12px;
116
+ align-items: center;
117
+ gap: 2px;
118
+ }
119
+
120
+ .brand-mark-bars i {
121
+ display: block;
122
+ width: 2px;
123
+ border-radius: 3px;
124
+ background: #f5f5f3;
125
+ }
126
+
127
+ .brand-mark-bars i:nth-child(1),
128
+ .brand-mark-bars i:nth-child(5) {
129
+ height: 4px;
130
+ opacity: 0.5;
131
+ }
132
+
133
+ .brand-mark-bars i:nth-child(2),
134
+ .brand-mark-bars i:nth-child(4) {
135
+ height: 8px;
136
+ }
137
+
138
+ .brand-mark-bars i:nth-child(3) {
139
+ height: 12px;
140
+ }
141
+
142
+ .local-badge {
143
+ gap: 7px;
144
+ color: #b0b0ae;
145
+ font-size: 12px;
146
+ }
147
+
148
+ .lock-icon {
149
+ width: 14px;
150
+ height: 14px;
151
+ flex: 0 0 auto;
152
+ color: #777778;
153
+ stroke-width: 1.8;
154
+ }
155
+
156
+ .stage {
157
+ position: relative;
158
+ z-index: 2;
159
+ display: flex;
160
+ width: min(760px, calc(100% - 40px));
161
+ height: 100%;
162
+ min-height: 0;
163
+ margin: 0 auto;
164
+ overflow: hidden;
165
+ padding: clamp(14px, 5vh, 52px) 0 clamp(10px, 3vh, 28px);
166
+ flex-direction: column;
167
+ align-items: center;
168
+ transition: padding 750ms var(--ease);
169
+ }
170
+
171
+ .intro-slot {
172
+ display: grid;
173
+ width: 100%;
174
+ overflow: hidden;
175
+ grid-template-rows: 1fr;
176
+ opacity: 1;
177
+ filter: blur(0);
178
+ transform: translateY(0) scale(1);
179
+ transform-origin: 50% 0;
180
+ transition:
181
+ grid-template-rows 750ms var(--ease),
182
+ opacity 420ms ease,
183
+ filter 600ms ease,
184
+ transform 700ms var(--ease);
185
+ }
186
+
187
+ .intro {
188
+ min-height: 0;
189
+ overflow: hidden;
190
+ max-width: 660px;
191
+ margin: 0 auto;
192
+ text-align: center;
193
+ }
194
+
195
+ .eyebrow {
196
+ margin: 0 0 17px;
197
+ color: #9b9b9a;
198
+ font-size: 11px;
199
+ font-weight: 660;
200
+ letter-spacing: 0.14em;
201
+ text-transform: uppercase;
202
+ }
203
+
204
+ h1 {
205
+ margin: 0;
206
+ font-size: clamp(42px, 7vw, 69px);
207
+ font-weight: 590;
208
+ letter-spacing: -0.057em;
209
+ line-height: 0.98;
210
+ }
211
+
212
+ .headline-primary,
213
+ .headline-secondary {
214
+ display: block;
215
+ }
216
+
217
+ .headline-primary {
218
+ color: var(--text);
219
+ }
220
+
221
+ .headline-secondary {
222
+ color: #8a8a8a;
223
+ }
224
+
225
+ .lede {
226
+ max-width: 590px;
227
+ margin: 22px auto 0;
228
+ color: #969696;
229
+ font-size: clamp(14px, 2vw, 16px);
230
+ line-height: 1.6;
231
+ }
232
+
233
+ .recorder-wrap {
234
+ position: relative;
235
+ width: 100%;
236
+ margin-top: clamp(24px, 5vh, 52px);
237
+ transition: margin-top 750ms var(--ease);
238
+ }
239
+
240
+ .recorder-glow {
241
+ position: absolute;
242
+ inset: 8% 8% 16%;
243
+ border-radius: 48px;
244
+ background: rgba(0, 0, 0, 0.7);
245
+ filter: blur(34px);
246
+ opacity: 0.8;
247
+ pointer-events: none;
248
+ }
249
+
250
+ .recorder {
251
+ position: relative;
252
+ overflow: visible;
253
+ border: 1px solid var(--line-strong);
254
+ border-radius: 27px;
255
+ background: rgba(11, 11, 12, 0.95);
256
+ box-shadow:
257
+ inset 0 1px rgba(255, 255, 255, 0.035),
258
+ 0 32px 80px rgba(0, 0, 0, 0.54);
259
+ transition:
260
+ border-color 350ms ease,
261
+ box-shadow 350ms ease;
262
+ animation: panel-reveal 900ms 520ms var(--ease) both;
263
+ }
264
+
265
+ body[data-state="recording"] .recorder {
266
+ border-color: rgba(255, 81, 76, 0.38);
267
+ box-shadow:
268
+ inset 0 1px rgba(255, 255, 255, 0.04),
269
+ 0 28px 86px rgba(0, 0, 0, 0.58),
270
+ 0 0 48px rgba(255, 81, 76, 0.05);
271
+ }
272
+
273
+ .visual {
274
+ position: relative;
275
+ display: grid;
276
+ height: 166px;
277
+ overflow: hidden;
278
+ border-radius: 26px 26px 0 0;
279
+ place-items: center;
280
+ }
281
+
282
+ .visual::after {
283
+ position: absolute;
284
+ inset: 0;
285
+ background: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, 0.19) 100%);
286
+ content: "";
287
+ pointer-events: none;
288
+ }
289
+
290
+ #waveform {
291
+ display: block;
292
+ width: calc(100% - 72px);
293
+ height: 104px;
294
+ opacity: 0.96;
295
+ }
296
+
297
+ .visual-status {
298
+ position: absolute;
299
+ left: 50%;
300
+ bottom: 17px;
301
+ z-index: 2;
302
+ display: flex;
303
+ width: calc(100% - 72px);
304
+ transform: translateX(-50%);
305
+ align-items: center;
306
+ justify-content: center;
307
+ color: #777778;
308
+ font-size: 11px;
309
+ font-variant-numeric: tabular-nums;
310
+ letter-spacing: 0.015em;
311
+ pointer-events: none;
312
+ }
313
+
314
+ .visual-status strong {
315
+ color: #b6b6b5;
316
+ font-weight: 530;
317
+ }
318
+
319
+ .visual-status .separator {
320
+ margin: 0 7px;
321
+ color: #3e3e3f;
322
+ }
323
+
324
+ .download-progress {
325
+ position: absolute;
326
+ left: 36px;
327
+ right: 36px;
328
+ bottom: 0;
329
+ z-index: 3;
330
+ height: 2px;
331
+ overflow: hidden;
332
+ border-radius: 2px;
333
+ background: rgba(255, 255, 255, 0.07);
334
+ opacity: 0;
335
+ transition: opacity 200ms ease;
336
+ }
337
+
338
+ .download-progress.visible {
339
+ opacity: 1;
340
+ }
341
+
342
+ .download-progress > span {
343
+ display: block;
344
+ width: 0%;
345
+ height: 100%;
346
+ border-radius: inherit;
347
+ background: #dfdfdd;
348
+ transition: width 180ms ease;
349
+ }
350
+
351
+ .download-progress.indeterminate > span {
352
+ width: 32%;
353
+ animation: indeterminate 1.25s var(--ease) infinite;
354
+ }
355
+
356
+ @keyframes indeterminate {
357
+ from {
358
+ transform: translateX(-115%);
359
+ }
360
+ to {
361
+ transform: translateX(330%);
362
+ }
363
+ }
364
+
365
+ .controls {
366
+ position: relative;
367
+ display: flex;
368
+ min-height: 76px;
369
+ padding: 13px 17px;
370
+ border-top: 1px solid var(--line);
371
+ border-radius: 0 0 26px 26px;
372
+ background: linear-gradient(180deg, #1c1c1d, #19191a);
373
+ align-items: center;
374
+ justify-content: space-between;
375
+ }
376
+
377
+ .mode-group,
378
+ .actions {
379
+ display: flex;
380
+ align-items: center;
381
+ }
382
+
383
+ .mode-group {
384
+ position: relative;
385
+ gap: 3px;
386
+ }
387
+
388
+ .state-dot {
389
+ width: 9px;
390
+ height: 9px;
391
+ margin: 0 8px;
392
+ border: 1px solid rgba(255, 255, 255, 0.2);
393
+ border-radius: 999px;
394
+ background: #575759;
395
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.025);
396
+ transition:
397
+ background 250ms ease,
398
+ box-shadow 250ms ease;
399
+ }
400
+
401
+ body[data-state="loading"] .state-dot {
402
+ background: var(--yellow);
403
+ box-shadow: 0 0 0 3px rgba(244, 184, 70, 0.09);
404
+ }
405
+
406
+ body[data-state="recording"] .state-dot {
407
+ background: var(--red);
408
+ box-shadow: 0 0 0 3px rgba(255, 81, 76, 0.1);
409
+ animation: dot-pulse 1.5s ease-in-out infinite;
410
+ }
411
+
412
+ body[data-state="transcribing"] .state-dot,
413
+ body[data-state="normalizing"] .state-dot {
414
+ background: var(--blue);
415
+ box-shadow: 0 0 0 3px rgba(86, 168, 255, 0.1);
416
+ }
417
+
418
+ body[data-state="ready"] .state-dot,
419
+ body[data-state="done"] .state-dot {
420
+ background: var(--green);
421
+ box-shadow: 0 0 0 3px rgba(85, 202, 130, 0.09);
422
+ }
423
+
424
+ body[data-state="error"] .state-dot {
425
+ background: var(--red);
426
+ }
427
+
428
+ @keyframes dot-pulse {
429
+ 50% {
430
+ box-shadow: 0 0 0 6px rgba(255, 81, 76, 0.05);
431
+ }
432
+ }
433
+
434
+ .mode-button,
435
+ .text-button,
436
+ .copy-button,
437
+ .primary-button {
438
+ border: 0;
439
+ cursor: pointer;
440
+ }
441
+
442
+ .mode-button {
443
+ display: inline-flex;
444
+ min-height: 43px;
445
+ padding: 0 10px;
446
+ border-radius: 12px;
447
+ background: transparent;
448
+ align-items: center;
449
+ gap: 8px;
450
+ color: #d0d0ce;
451
+ font-size: 14px;
452
+ font-weight: 560;
453
+ transition: background 180ms ease;
454
+ }
455
+
456
+ .mode-button:hover,
457
+ .mode-button[aria-expanded="true"] {
458
+ background: rgba(255, 255, 255, 0.055);
459
+ }
460
+
461
+ .chevron {
462
+ width: 6px;
463
+ height: 6px;
464
+ border-right: 1.5px solid #777778;
465
+ border-bottom: 1.5px solid #777778;
466
+ transform: translateY(-2px) rotate(45deg);
467
+ transition: transform 180ms ease;
468
+ }
469
+
470
+ .mode-button[aria-expanded="true"] .chevron {
471
+ transform: translateY(2px) rotate(225deg);
472
+ }
473
+
474
+ .mode-popover {
475
+ position: absolute;
476
+ left: 10px;
477
+ bottom: calc(100% + 18px);
478
+ z-index: 10;
479
+ width: min(330px, calc(100vw - 64px));
480
+ padding: 9px;
481
+ border: 1px solid rgba(255, 255, 255, 0.19);
482
+ border-radius: 17px;
483
+ background: rgba(24, 24, 25, 0.98);
484
+ box-shadow: 0 22px 50px rgba(0, 0, 0, 0.48);
485
+ opacity: 0;
486
+ transform: translateY(7px) scale(0.985);
487
+ transform-origin: 44px 100%;
488
+ visibility: hidden;
489
+ transition:
490
+ opacity 160ms ease,
491
+ transform 200ms var(--ease),
492
+ visibility 160ms;
493
+ }
494
+
495
+ .mode-popover.open {
496
+ opacity: 1;
497
+ transform: translateY(0) scale(1);
498
+ visibility: visible;
499
+ }
500
+
501
+ .setting-row {
502
+ display: grid;
503
+ min-height: 52px;
504
+ padding: 7px 9px 7px 11px;
505
+ border-radius: 11px;
506
+ grid-template-columns: 1fr auto;
507
+ align-items: center;
508
+ }
509
+
510
+ .setting-row + .setting-row {
511
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
512
+ border-radius: 0;
513
+ }
514
+
515
+ .setting-copy span {
516
+ display: block;
517
+ color: #d6d6d4;
518
+ font-size: 13px;
519
+ font-weight: 540;
520
+ }
521
+
522
+ .setting-copy small {
523
+ display: block;
524
+ margin-top: 3px;
525
+ color: #6f6f70;
526
+ font-size: 10px;
527
+ }
528
+
529
+ .setting-row select {
530
+ min-width: 116px;
531
+ height: 32px;
532
+ padding: 0 27px 0 10px;
533
+ border: 1px solid rgba(255, 255, 255, 0.1);
534
+ border-radius: 9px;
535
+ appearance: none;
536
+ background:
537
+ linear-gradient(45deg, transparent 50%, #777 50%) calc(100% - 13px) 13px / 5px 5px no-repeat,
538
+ linear-gradient(135deg, #777 50%, transparent 50%) calc(100% - 9px) 13px / 5px 5px no-repeat,
539
+ #101011;
540
+ color: #bdbdbc;
541
+ font-size: 11px;
542
+ cursor: pointer;
543
+ }
544
+
545
+ .actions {
546
+ gap: 4px;
547
+ }
548
+
549
+ .text-button,
550
+ .primary-button {
551
+ min-height: 43px;
552
+ border-radius: 12px;
553
+ font-size: 13px;
554
+ font-weight: 580;
555
+ transition:
556
+ background 180ms ease,
557
+ color 180ms ease,
558
+ opacity 180ms ease,
559
+ transform 180ms ease;
560
+ }
561
+
562
+ .text-button {
563
+ padding: 0 14px;
564
+ background: transparent;
565
+ color: #8f8f90;
566
+ }
567
+
568
+ .text-button:hover:not(:disabled) {
569
+ background: rgba(255, 255, 255, 0.05);
570
+ color: #d5d5d4;
571
+ }
572
+
573
+ .primary-button {
574
+ min-width: 116px;
575
+ padding: 0 18px;
576
+ border: 1px solid rgba(255, 255, 255, 0.12);
577
+ background: #eeeeeb;
578
+ box-shadow: inset 0 -1px rgba(0, 0, 0, 0.15);
579
+ color: #111112;
580
+ }
581
+
582
+ .primary-button:hover:not(:disabled) {
583
+ background: #fff;
584
+ transform: translateY(-1px);
585
+ }
586
+
587
+ .primary-button:active:not(:disabled) {
588
+ transform: translateY(0);
589
+ }
590
+
591
+ .primary-button:disabled,
592
+ .text-button:disabled {
593
+ cursor: default;
594
+ opacity: 0.38;
595
+ }
596
+
597
+ body[data-state="recording"] .primary-button {
598
+ border-color: rgba(255, 95, 89, 0.35);
599
+ background: #8a2826;
600
+ color: #fff;
601
+ }
602
+
603
+ body[data-state="recording"] .primary-button:hover:not(:disabled) {
604
+ border-color: rgba(255, 255, 255, 0.18);
605
+ background: #f5f5f2;
606
+ color: #111112;
607
+ }
608
+
609
+ .error-message {
610
+ display: none;
611
+ margin: 14px 24px 0;
612
+ color: #e99c98;
613
+ font-size: 12px;
614
+ line-height: 1.5;
615
+ text-align: center;
616
+ }
617
+
618
+ .error-message.visible {
619
+ display: block;
620
+ }
621
+
622
+ .result-panel {
623
+ width: calc(100% - 38px);
624
+ max-height: 0;
625
+ margin: 14px auto 0;
626
+ overflow: hidden;
627
+ border: 1px solid transparent;
628
+ border-radius: 21px;
629
+ background: rgba(14, 14, 15, 0.94);
630
+ opacity: 0;
631
+ transform: translateY(-10px) scale(0.99);
632
+ transition:
633
+ max-height 550ms var(--ease),
634
+ opacity 250ms ease,
635
+ transform 450ms var(--ease),
636
+ border-color 250ms ease;
637
+ }
638
+
639
+ .result-panel.visible {
640
+ max-height: clamp(120px, calc(50svh - 180px), 340px);
641
+ overflow-y: auto;
642
+ border-color: rgba(255, 255, 255, 0.14);
643
+ opacity: 1;
644
+ transform: translateY(0) scale(1);
645
+ overscroll-behavior: contain;
646
+ scrollbar-color: #3e3e3f transparent;
647
+ scrollbar-width: thin;
648
+ }
649
+
650
+ .result-header {
651
+ display: flex;
652
+ min-height: 52px;
653
+ padding: 0 18px 0 21px;
654
+ border-bottom: 1px solid rgba(255, 255, 255, 0.075);
655
+ align-items: center;
656
+ justify-content: space-between;
657
+ }
658
+
659
+ .result-title {
660
+ display: inline-flex;
661
+ align-items: center;
662
+ gap: 8px;
663
+ color: #949494;
664
+ font-size: 11px;
665
+ font-weight: 620;
666
+ letter-spacing: 0.08em;
667
+ text-transform: uppercase;
668
+ }
669
+
670
+ .spark {
671
+ position: relative;
672
+ width: 10px;
673
+ height: 10px;
674
+ }
675
+
676
+ .spark::before,
677
+ .spark::after {
678
+ position: absolute;
679
+ inset: 0;
680
+ margin: auto;
681
+ border-radius: 999px;
682
+ background: #d7d7d5;
683
+ content: "";
684
+ }
685
+
686
+ .spark::before {
687
+ width: 2px;
688
+ height: 10px;
689
+ }
690
+
691
+ .spark::after {
692
+ width: 10px;
693
+ height: 2px;
694
+ }
695
+
696
+ .copy-button {
697
+ min-width: 63px;
698
+ height: 31px;
699
+ border: 1px solid rgba(255, 255, 255, 0.1);
700
+ border-radius: 9px;
701
+ background: rgba(255, 255, 255, 0.035);
702
+ color: #9b9b9c;
703
+ font-size: 11px;
704
+ transition:
705
+ background 180ms ease,
706
+ color 180ms ease;
707
+ }
708
+
709
+ .copy-button:hover:not(:disabled) {
710
+ background: rgba(255, 255, 255, 0.075);
711
+ color: #e7e7e5;
712
+ }
713
+
714
+ .copy-button:disabled {
715
+ cursor: default;
716
+ opacity: 0.35;
717
+ }
718
+
719
+ .clean-text {
720
+ min-height: 112px;
721
+ padding: 23px 22px 25px;
722
+ color: #efefed;
723
+ font-size: clamp(17px, 2.7vw, 20px);
724
+ font-weight: 410;
725
+ letter-spacing: -0.015em;
726
+ line-height: 1.55;
727
+ white-space: pre-wrap;
728
+ }
729
+
730
+ .clean-text.empty {
731
+ color: #737374;
732
+ font-size: 15px;
733
+ font-style: italic;
734
+ }
735
+
736
+ .clean-text.streaming::after {
737
+ display: inline-block;
738
+ width: 2px;
739
+ height: 1em;
740
+ margin-left: 3px;
741
+ border-radius: 1px;
742
+ background: #a6a6a5;
743
+ content: "";
744
+ vertical-align: -0.12em;
745
+ animation: cursor-blink 0.85s steps(2, end) infinite;
746
+ }
747
+
748
+ @keyframes cursor-blink {
749
+ 50% {
750
+ opacity: 0;
751
+ }
752
+ }
753
+
754
+ .raw-details {
755
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
756
+ }
757
+
758
+ .raw-details summary {
759
+ padding: 13px 22px;
760
+ color: #686869;
761
+ font-size: 11px;
762
+ cursor: pointer;
763
+ list-style: none;
764
+ user-select: none;
765
+ }
766
+
767
+ .raw-details summary::-webkit-details-marker {
768
+ display: none;
769
+ }
770
+
771
+ .raw-details summary::before {
772
+ display: inline-block;
773
+ width: 5px;
774
+ height: 5px;
775
+ margin: 0 9px 2px 1px;
776
+ border-right: 1px solid #626263;
777
+ border-bottom: 1px solid #626263;
778
+ content: "";
779
+ transform: rotate(-45deg);
780
+ transition: transform 160ms ease;
781
+ }
782
+
783
+ .raw-details[open] summary::before {
784
+ transform: translateY(-2px) rotate(45deg);
785
+ }
786
+
787
+ .raw-text {
788
+ margin: 0;
789
+ padding: 2px 22px 20px;
790
+ color: #8d8d8e;
791
+ font-family: inherit;
792
+ font-size: 13px;
793
+ line-height: 1.55;
794
+ white-space: pre-wrap;
795
+ }
796
+
797
+ .under-card {
798
+ display: flex;
799
+ width: calc(100% - 44px);
800
+ max-height: 32px;
801
+ margin: 18px auto 0;
802
+ overflow: hidden;
803
+ align-items: center;
804
+ justify-content: space-between;
805
+ gap: 20px;
806
+ opacity: 1;
807
+ transform: translateY(0);
808
+ transition:
809
+ max-height 500ms var(--ease),
810
+ margin-top 500ms var(--ease),
811
+ opacity 280ms ease,
812
+ transform 500ms var(--ease);
813
+ }
814
+
815
+ body:not(.workspace-active) .under-card {
816
+ animation: reveal-up 800ms 720ms var(--ease) both;
817
+ }
818
+
819
+ .pipeline-note {
820
+ min-width: 0;
821
+ gap: 6px;
822
+ color: #777778;
823
+ font-size: 10.5px;
824
+ white-space: nowrap;
825
+ }
826
+
827
+ .pipeline-note a {
828
+ color: inherit;
829
+ text-decoration: none;
830
+ }
831
+
832
+ .pipeline-note a:hover {
833
+ color: #d0d0ce;
834
+ }
835
+
836
+ .pipeline-primary {
837
+ color: #b9b9b7 !important;
838
+ font-weight: 560;
839
+ }
840
+
841
+ .pipeline-separator {
842
+ color: #484849;
843
+ }
844
+
845
+ .keyboard-hint {
846
+ flex: 0 0 auto;
847
+ gap: 6px;
848
+ color: #4f4f50;
849
+ font-size: 10px;
850
+ }
851
+
852
+ kbd {
853
+ display: inline-grid;
854
+ min-width: 21px;
855
+ height: 19px;
856
+ padding: 0 5px;
857
+ border: 1px solid rgba(255, 255, 255, 0.1);
858
+ border-radius: 5px;
859
+ background: rgba(255, 255, 255, 0.025);
860
+ color: #737374;
861
+ font-family: inherit;
862
+ font-size: 9px;
863
+ place-items: center;
864
+ }
865
+
866
+ .page-footer {
867
+ position: relative;
868
+ z-index: 2;
869
+ display: flex;
870
+ width: min(1120px, calc(100% - 48px));
871
+ height: 100%;
872
+ min-height: 0;
873
+ margin: 0 auto;
874
+ align-items: center;
875
+ justify-content: center;
876
+ color: #3e3e3f;
877
+ font-size: 10px;
878
+ animation: reveal-up 700ms 820ms var(--ease) both;
879
+ }
880
+
881
+ .page-footer a {
882
+ color: #555556;
883
+ text-decoration: none;
884
+ }
885
+
886
+ .page-footer a:hover {
887
+ color: #8b8b8c;
888
+ }
889
+
890
+ .brand {
891
+ animation: reveal-up 650ms 80ms var(--ease) both;
892
+ }
893
+
894
+ .local-badge {
895
+ animation: reveal-up 650ms 160ms var(--ease) both;
896
+ }
897
+
898
+ .eyebrow {
899
+ animation: reveal-up 650ms 140ms var(--ease) both;
900
+ }
901
+
902
+ .headline-primary {
903
+ animation: reveal-up 800ms 220ms var(--ease) both;
904
+ }
905
+
906
+ .headline-secondary {
907
+ animation: reveal-up 800ms 310ms var(--ease) both;
908
+ }
909
+
910
+ .lede {
911
+ animation: reveal-up 800ms 420ms var(--ease) both;
912
+ }
913
+
914
+ @keyframes reveal-up {
915
+ from {
916
+ opacity: 0;
917
+ filter: blur(5px);
918
+ transform: translateY(12px);
919
+ }
920
+ to {
921
+ opacity: 1;
922
+ filter: blur(0);
923
+ transform: translateY(0);
924
+ }
925
+ }
926
+
927
+ @keyframes panel-reveal {
928
+ from {
929
+ opacity: 0;
930
+ transform: translateY(18px) scale(0.985);
931
+ }
932
+ to {
933
+ opacity: 1;
934
+ transform: translateY(0) scale(1);
935
+ }
936
+ }
937
+
938
+ body.workspace-active .stage {
939
+ padding-top: 0;
940
+ padding-bottom: 0;
941
+ }
942
+
943
+ body.workspace-active .intro-slot {
944
+ grid-template-rows: 0fr;
945
+ opacity: 0;
946
+ filter: blur(7px);
947
+ transform: translateY(-22px) scale(0.985);
948
+ pointer-events: none;
949
+ }
950
+
951
+ body.workspace-active .recorder-wrap {
952
+ margin-top: clamp(
953
+ 12px,
954
+ calc((100svh - var(--chrome-height)) / 2 - 145px),
955
+ 290px
956
+ );
957
+ }
958
+
959
+ body.workspace-active .under-card {
960
+ max-height: 0;
961
+ margin-top: 0;
962
+ opacity: 0;
963
+ transform: translateY(-8px);
964
+ pointer-events: none;
965
+ }
966
+
967
+ @media (max-width: 620px) {
968
+ :root {
969
+ --topbar-height: 66px;
970
+ --footer-height: 42px;
971
+ }
972
+
973
+ .topbar {
974
+ width: calc(100% - 32px);
975
+ height: 100%;
976
+ }
977
+
978
+ .local-badge span,
979
+ .keyboard-hint {
980
+ display: none;
981
+ }
982
+
983
+ .stage {
984
+ width: calc(100% - 24px);
985
+ padding: clamp(12px, 3.5vh, 24px) 0 clamp(8px, 2vh, 16px);
986
+ }
987
+
988
+ .lede {
989
+ padding: 0 18px;
990
+ }
991
+
992
+ h1 {
993
+ font-size: clamp(31px, 9.7vw, 44px);
994
+ }
995
+
996
+ .recorder-wrap {
997
+ margin-top: clamp(20px, 3.8vh, 30px);
998
+ }
999
+
1000
+ .recorder {
1001
+ border-radius: 23px;
1002
+ }
1003
+
1004
+ .visual {
1005
+ height: 142px;
1006
+ border-radius: 22px 22px 0 0;
1007
+ }
1008
+
1009
+ #waveform,
1010
+ .visual-status {
1011
+ width: calc(100% - 40px);
1012
+ }
1013
+
1014
+ .download-progress {
1015
+ left: 20px;
1016
+ right: 20px;
1017
+ }
1018
+
1019
+ .controls {
1020
+ min-height: 69px;
1021
+ padding: 10px;
1022
+ border-radius: 0 0 22px 22px;
1023
+ }
1024
+
1025
+ .state-dot {
1026
+ margin: 0 4px;
1027
+ }
1028
+
1029
+ .mode-button {
1030
+ padding: 0 8px;
1031
+ }
1032
+
1033
+ .text-button {
1034
+ padding: 0 9px;
1035
+ }
1036
+
1037
+ .primary-button {
1038
+ min-width: 104px;
1039
+ padding: 0 13px;
1040
+ }
1041
+
1042
+ .result-panel {
1043
+ width: calc(100% - 14px);
1044
+ }
1045
+
1046
+ .under-card {
1047
+ width: calc(100% - 22px);
1048
+ max-height: 54px;
1049
+ justify-content: center;
1050
+ }
1051
+
1052
+ .pipeline-note {
1053
+ flex-wrap: wrap;
1054
+ justify-content: center;
1055
+ line-height: 1.5;
1056
+ white-space: normal;
1057
+ }
1058
+ }
1059
+
1060
+ @media (max-height: 720px) {
1061
+ :root {
1062
+ --footer-height: 36px;
1063
+ }
1064
+
1065
+ .stage {
1066
+ padding-top: clamp(8px, 2.5vh, 18px);
1067
+ padding-bottom: 8px;
1068
+ }
1069
+
1070
+ .eyebrow {
1071
+ margin-bottom: 10px;
1072
+ }
1073
+
1074
+ h1 {
1075
+ font-size: clamp(31px, min(7.6vh, 9.7vw), 52px);
1076
+ }
1077
+
1078
+ .lede {
1079
+ margin-top: 13px;
1080
+ line-height: 1.45;
1081
+ }
1082
+
1083
+ .recorder-wrap {
1084
+ margin-top: clamp(16px, 3vh, 24px);
1085
+ }
1086
+
1087
+ .visual {
1088
+ height: 128px;
1089
+ }
1090
+
1091
+ .controls {
1092
+ min-height: 66px;
1093
+ }
1094
+
1095
+ .result-panel.visible {
1096
+ max-height: clamp(112px, calc(50svh - 165px), 300px);
1097
+ }
1098
+ }
1099
+
1100
+ @media (max-height: 620px) {
1101
+ :root {
1102
+ --footer-height: 0px;
1103
+ }
1104
+
1105
+ .page-footer {
1106
+ display: none;
1107
+ }
1108
+
1109
+ .visual {
1110
+ height: 116px;
1111
+ }
1112
+
1113
+ .controls {
1114
+ min-height: 62px;
1115
+ }
1116
+
1117
+ .under-card {
1118
+ margin-top: 10px;
1119
+ }
1120
+ }
1121
+
1122
+ @media (max-width: 390px) {
1123
+ .state-dot {
1124
+ display: none;
1125
+ }
1126
+
1127
+ .mode-button {
1128
+ font-size: 12px;
1129
+ }
1130
+
1131
+ .text-button,
1132
+ .primary-button {
1133
+ font-size: 12px;
1134
+ }
1135
+
1136
+ .primary-button {
1137
+ min-width: 94px;
1138
+ }
1139
+ }
1140
+
1141
+ @media (prefers-reduced-motion: reduce) {
1142
+ *,
1143
+ *::before,
1144
+ *::after {
1145
+ scroll-behavior: auto !important;
1146
+ animation-duration: 0.001ms !important;
1147
+ animation-iteration-count: 1 !important;
1148
+ transition-duration: 0.001ms !important;
1149
+ }
1150
+ }
1151
+ </style>
1152
+ </head>
1153
+ <body data-state="idle">
1154
+ <header class="topbar">
1155
+ <div class="brand" aria-label="S1-Mini WebGPU">
1156
+ <span class="brand-mark" aria-hidden="true">
1157
+ <span class="brand-mark-bars"><i></i><i></i><i></i><i></i><i></i></span>
1158
+ </span>
1159
+ <span>S1-Mini WebGPU</span>
1160
+ </div>
1161
+ <div class="local-badge" title="Audio and text stay on this device">
1162
+ <svg class="lock-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1163
+ <rect width="18" height="11" x="3" y="11" rx="2" />
1164
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
1165
+ </svg>
1166
+ <span>Private &amp; local</span>
1167
+ </div>
1168
+ </header>
1169
+
1170
+ <main class="stage">
1171
+ <div id="introSlot" class="intro-slot">
1172
+ <section class="intro" aria-labelledby="page-title">
1173
+ <p class="eyebrow">S1-mini by Superwhisper</p>
1174
+ <h1 id="page-title">
1175
+ <span class="headline-primary">Speak naturally.</span>
1176
+ <span class="headline-secondary">S1-mini cleans it up.</span>
1177
+ </h1>
1178
+ <p class="lede">
1179
+ S1-mini turns raw speech transcripts into polished text—removing filler,
1180
+ resolving corrections, and formatting the details. A local speech recognition
1181
+ model captures your words first; everything stays in your browser.
1182
+ </p>
1183
+ </section>
1184
+ </div>
1185
+
1186
+ <section class="recorder-wrap" aria-label="Voice recorder">
1187
+ <div class="recorder-glow" aria-hidden="true"></div>
1188
+ <div class="recorder">
1189
+ <div class="visual">
1190
+ <canvas id="waveform" aria-hidden="true"></canvas>
1191
+ <div class="visual-status" aria-live="polite">
1192
+ <strong id="statusTitle">S1-mini not loaded</strong>
1193
+ <span class="separator" aria-hidden="true">·</span>
1194
+ <span id="statusDetail">downloads once, then stays cached</span>
1195
+ </div>
1196
+ <div
1197
+ id="downloadProgress"
1198
+ class="download-progress"
1199
+ role="progressbar"
1200
+ aria-label="Model download progress"
1201
+ aria-valuemin="0"
1202
+ aria-valuemax="100"
1203
+ aria-valuenow="0"
1204
+ >
1205
+ <span></span>
1206
+ </div>
1207
+ </div>
1208
+
1209
+ <div class="controls">
1210
+ <div class="mode-group">
1211
+ <span class="state-dot" aria-hidden="true"></span>
1212
+ <button
1213
+ id="modeButton"
1214
+ class="mode-button"
1215
+ type="button"
1216
+ aria-expanded="false"
1217
+ aria-controls="modePopover"
1218
+ >
1219
+ <span>S1-mini</span>
1220
+ <span class="chevron" aria-hidden="true"></span>
1221
+ </button>
1222
+
1223
+ <div id="modePopover" class="mode-popover" aria-hidden="true">
1224
+ <label class="setting-row">
1225
+ <span class="setting-copy">
1226
+ <span>Styling</span>
1227
+ <small>Writing register</small>
1228
+ </span>
1229
+ <select id="stylingSelect" aria-label="Styling">
1230
+ <option value="casual">Casual</option>
1231
+ <option value="semi-casual">Semi-casual</option>
1232
+ <option value="semi-formal" selected>Semi-formal</option>
1233
+ <option value="formal">Formal</option>
1234
+ </select>
1235
+ </label>
1236
+ <label class="setting-row">
1237
+ <span class="setting-copy">
1238
+ <span>Structure</span>
1239
+ <small>Paragraph or list</small>
1240
+ </span>
1241
+ <select id="structureSelect" aria-label="Structure">
1242
+ <option value="prose" selected>Prose</option>
1243
+ <option value="lists">Lists</option>
1244
+ </select>
1245
+ </label>
1246
+ <label class="setting-row">
1247
+ <span class="setting-copy">
1248
+ <span>Context</span>
1249
+ <small>Output destination</small>
1250
+ </span>
1251
+ <select id="contextSelect" aria-label="Context">
1252
+ <option value="general" selected>General</option>
1253
+ <option value="email">Email</option>
1254
+ </select>
1255
+ </label>
1256
+ </div>
1257
+ </div>
1258
+
1259
+ <div class="actions">
1260
+ <button id="cancelButton" class="text-button" type="button" hidden>Cancel</button>
1261
+ <button id="primaryButton" class="primary-button" type="button">Load model</button>
1262
+ </div>
1263
+ </div>
1264
+ </div>
1265
+
1266
+ <p id="errorMessage" class="error-message" role="alert"></p>
1267
+
1268
+ <article id="resultPanel" class="result-panel" aria-live="polite">
1269
+ <header class="result-header">
1270
+ <span class="result-title"><span class="spark" aria-hidden="true"></span>S1-mini output</span>
1271
+ <button id="copyButton" class="copy-button" type="button" disabled>Copy</button>
1272
+ </header>
1273
+ <div id="cleanText" class="clean-text"></div>
1274
+ <details class="raw-details">
1275
+ <summary>Raw speech transcript</summary>
1276
+ <pre id="rawText" class="raw-text"></pre>
1277
+ </details>
1278
+ </article>
1279
+
1280
+ <div id="underCard" class="under-card">
1281
+ <div class="pipeline-note">
1282
+ <a class="pipeline-primary" href="https://huggingface.co/superwhisper/s1-mini" target="_blank" rel="noreferrer">S1-mini by Superwhisper</a>
1283
+ <span class="pipeline-separator" aria-hidden="true">·</span>
1284
+ <span>speech recognition by</span>
1285
+ <a href="https://huggingface.co/onnx-community/moonshine-base-ONNX" target="_blank" rel="noreferrer">Moonshine base</a>
1286
+ </div>
1287
+ <div class="keyboard-hint" aria-hidden="true"><kbd>Space</kbd> record <kbd>Esc</kbd> cancel</div>
1288
+ </div>
1289
+ </section>
1290
+ </main>
1291
+
1292
+ <footer class="page-footer">
1293
+ <span>Powered by <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noreferrer">Transformers.js 4.2.0</a> + WebGPU</span>
1294
+ </footer>
1295
+
1296
+ <script type="module">
1297
+ import {
1298
+ pipeline,
1299
+ env,
1300
+ TextStreamer,
1301
+ } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0";
1302
+
1303
+ const ASR_MODEL = "onnx-community/moonshine-base-ONNX";
1304
+ const NORMALIZER_MODEL = "onnx-community/s1-mini-ONNX";
1305
+ const TARGET_SAMPLE_RATE = 16_000;
1306
+ const MAX_RECORDING_SECONDS = 90;
1307
+ const SYSTEM_PROMPT =
1308
+ "You are a text normalizer for speech-to-text transcripts. The input begins " +
1309
+ "with a control line specifying the styling, structure, and context settings; " +
1310
+ "clean the transcript to match those settings and output only the cleaned text.";
1311
+
1312
+ env.allowLocalModels = false;
1313
+ env.useBrowserCache = true;
1314
+
1315
+ const page = document.body;
1316
+ const elements = Object.freeze({
1317
+ introSlot: document.querySelector("#introSlot"),
1318
+ underCard: document.querySelector("#underCard"),
1319
+ waveform: document.querySelector("#waveform"),
1320
+ statusTitle: document.querySelector("#statusTitle"),
1321
+ statusDetail: document.querySelector("#statusDetail"),
1322
+ statusSeparator: document.querySelector(".visual-status .separator"),
1323
+ progress: document.querySelector("#downloadProgress"),
1324
+ progressFill: document.querySelector("#downloadProgress > span"),
1325
+ primary: document.querySelector("#primaryButton"),
1326
+ cancel: document.querySelector("#cancelButton"),
1327
+ modeButton: document.querySelector("#modeButton"),
1328
+ modePopover: document.querySelector("#modePopover"),
1329
+ styling: document.querySelector("#stylingSelect"),
1330
+ structure: document.querySelector("#structureSelect"),
1331
+ context: document.querySelector("#contextSelect"),
1332
+ error: document.querySelector("#errorMessage"),
1333
+ result: document.querySelector("#resultPanel"),
1334
+ cleanText: document.querySelector("#cleanText"),
1335
+ rawText: document.querySelector("#rawText"),
1336
+ copy: document.querySelector("#copyButton"),
1337
+ });
1338
+
1339
+ const STATE_LABELS = Object.freeze({
1340
+ idle: "Load model",
1341
+ loading: "Loading…",
1342
+ ready: "Record",
1343
+ recording: "Stop",
1344
+ transcribing: "Transcribing…",
1345
+ normalizing: "Polishing…",
1346
+ done: "Record again",
1347
+ });
1348
+ const BUSY_STATES = new Set(["loading", "transcribing", "normalizing"]);
1349
+ const RECORDABLE_STATES = new Set(["ready", "done", "error"]);
1350
+
1351
+ let state = "idle";
1352
+ let transcriber = null;
1353
+ let normalizer = null;
1354
+ let loadPromise = null;
1355
+ let capture = null;
1356
+ let timerInterval = null;
1357
+ let recordingLimitTimeout = null;
1358
+ let animationFrame = 0;
1359
+ let canvasScale = 0;
1360
+ let levels = [];
1361
+ let envelopes = [];
1362
+ let displayProgress = 0;
1363
+ let lastCleanText = "";
1364
+
1365
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
1366
+ const canvasContext = elements.waveform.getContext("2d", { alpha: true });
1367
+
1368
+ function setState(next, title, detail = "") {
1369
+ state = next;
1370
+ page.dataset.state = next;
1371
+ elements.statusTitle.textContent = title;
1372
+ elements.statusDetail.textContent = detail;
1373
+ elements.statusDetail.hidden = !detail;
1374
+ elements.statusSeparator.hidden = !detail;
1375
+
1376
+ elements.error.classList.remove("visible");
1377
+ elements.primary.disabled = BUSY_STATES.has(next);
1378
+ elements.cancel.hidden = next !== "recording";
1379
+
1380
+ elements.primary.textContent = next === "error"
1381
+ ? (transcriber && normalizer ? "Record" : "Try again")
1382
+ : (STATE_LABELS[next] ?? "Record");
1383
+ }
1384
+
1385
+ function activateWorkspace() {
1386
+ if (page.classList.contains("workspace-active")) return;
1387
+ page.classList.add("workspace-active");
1388
+ elements.introSlot.setAttribute("aria-hidden", "true");
1389
+ elements.underCard.setAttribute("aria-hidden", "true");
1390
+ elements.introSlot.inert = true;
1391
+ elements.underCard.inert = true;
1392
+ }
1393
+
1394
+ function showError(error, fallback) {
1395
+ console.error(error);
1396
+ const message = error instanceof Error ? error.message : String(error);
1397
+ const friendly = message.includes("Permission denied") || error?.name === "NotAllowedError"
1398
+ ? "Microphone access was denied. Allow it in your browser settings, then try again."
1399
+ : fallback;
1400
+ setState("error", "Something went wrong", "Ready to retry");
1401
+ elements.error.textContent = friendly;
1402
+ elements.error.classList.add("visible");
1403
+ }
1404
+
1405
+ function formatFileName(file = "") {
1406
+ if (file.includes("model_q4f16")) return "model weights";
1407
+ if (file.includes("encoder")) return "speech encoder";
1408
+ if (file.includes("decoder")) return "speech decoder";
1409
+ if (file.includes("tokenizer")) return "tokenizer";
1410
+ if (file.includes("config")) return "configuration";
1411
+ return "model files";
1412
+ }
1413
+
1414
+ function formatBytes(bytes) {
1415
+ if (!Number.isFinite(bytes) || bytes <= 0) return "";
1416
+ const units = ["B", "KB", "MB", "GB"];
1417
+ const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
1418
+ const value = bytes / 1024 ** power;
1419
+ return `${value >= 100 || power === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[power]}`;
1420
+ }
1421
+
1422
+ function makeProgressCallback(modelLabel) {
1423
+ return (event) => {
1424
+ if (event.status === "progress_total") {
1425
+ const percent = Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null;
1426
+ updateProgress(percent);
1427
+ elements.statusTitle.textContent = `Loading ${modelLabel}`;
1428
+ const loaded = formatBytes(event.loaded);
1429
+ const total = formatBytes(event.total);
1430
+ elements.statusDetail.textContent = percent === null
1431
+ ? "model files"
1432
+ : `${Math.round(percent)}%${loaded && total ? ` · ${loaded} / ${total}` : ""}`;
1433
+ } else if (event.status === "initiate" || event.status === "download") {
1434
+ updateProgress(null);
1435
+ elements.statusTitle.textContent = `Loading ${modelLabel}`;
1436
+ elements.statusDetail.textContent = formatFileName(event.file);
1437
+ }
1438
+ };
1439
+ }
1440
+
1441
+ function updateProgress(percent) {
1442
+ elements.progress.classList.add("visible");
1443
+ if (percent === null) {
1444
+ elements.progress.classList.add("indeterminate");
1445
+ elements.progress.removeAttribute("aria-valuenow");
1446
+ return;
1447
+ }
1448
+ elements.progress.classList.remove("indeterminate");
1449
+ displayProgress = percent;
1450
+ elements.progressFill.style.width = `${percent}%`;
1451
+ elements.progress.setAttribute("aria-valuenow", String(Math.round(percent)));
1452
+ }
1453
+
1454
+ function hideProgress() {
1455
+ elements.progress.classList.remove("visible", "indeterminate");
1456
+ displayProgress = 0;
1457
+ elements.progressFill.style.width = "0%";
1458
+ elements.progress.setAttribute("aria-valuenow", "0");
1459
+ }
1460
+
1461
+ async function loadModels() {
1462
+ if (transcriber && normalizer) return;
1463
+ if (loadPromise) return loadPromise;
1464
+
1465
+ loadPromise = (async () => {
1466
+ if (!navigator.gpu) {
1467
+ throw new Error("WebGPU is not available in this browser.");
1468
+ }
1469
+
1470
+ setState("loading", "Preparing WebGPU", "Models are downloaded once and cached");
1471
+ updateProgress(null);
1472
+
1473
+ if (!transcriber) {
1474
+ transcriber = await pipeline("automatic-speech-recognition", ASR_MODEL, {
1475
+ device: "webgpu",
1476
+ progress_callback: makeProgressCallback("speech recognition"),
1477
+ });
1478
+ }
1479
+
1480
+ updateProgress(null);
1481
+ if (!normalizer) {
1482
+ normalizer = await pipeline("text-generation", NORMALIZER_MODEL, {
1483
+ dtype: "q4f16",
1484
+ device: "webgpu",
1485
+ progress_callback: makeProgressCallback("S1-mini"),
1486
+ });
1487
+ }
1488
+
1489
+ hideProgress();
1490
+ setState("ready", "Ready", "Press Record or Space to begin");
1491
+ requestAnimationFrame(activateWorkspace);
1492
+ })();
1493
+
1494
+ try {
1495
+ await loadPromise;
1496
+ } finally {
1497
+ loadPromise = null;
1498
+ }
1499
+ }
1500
+
1501
+ const RECORDER_WORKLET_SOURCE = `
1502
+ class PCMRecorderProcessor extends AudioWorkletProcessor {
1503
+ constructor() {
1504
+ super();
1505
+ this.chunk = new Float32Array(4096);
1506
+ this.offset = 0;
1507
+ this.port.onmessage = (event) => {
1508
+ if (event.data === "flush") this.flush();
1509
+ };
1510
+ }
1511
+
1512
+ flush() {
1513
+ if (this.offset > 0) {
1514
+ const samples = this.chunk.slice(0, this.offset);
1515
+ this.port.postMessage({ type: "chunk", samples }, [samples.buffer]);
1516
+ this.offset = 0;
1517
+ }
1518
+ this.port.postMessage({ type: "flushed" });
1519
+ }
1520
+
1521
+ process(inputs, outputs) {
1522
+ const input = inputs[0];
1523
+ const output = outputs[0];
1524
+ if (output) {
1525
+ for (const channel of output) channel.fill(0);
1526
+ }
1527
+ if (!input || input.length === 0) return true;
1528
+
1529
+ const frameLength = input[0].length;
1530
+ for (let i = 0; i < frameLength; i++) {
1531
+ let mono = 0;
1532
+ for (let channel = 0; channel < input.length; channel++) {
1533
+ mono += input[channel][i] || 0;
1534
+ }
1535
+ this.chunk[this.offset++] = mono / input.length;
1536
+ if (this.offset === this.chunk.length) {
1537
+ const samples = this.chunk;
1538
+ this.port.postMessage({ type: "chunk", samples }, [samples.buffer]);
1539
+ this.chunk = new Float32Array(4096);
1540
+ this.offset = 0;
1541
+ }
1542
+ }
1543
+ return true;
1544
+ }
1545
+ }
1546
+
1547
+ registerProcessor("pcm-recorder", PCMRecorderProcessor);
1548
+ `;
1549
+
1550
+ async function startRecording() {
1551
+ if (!transcriber || !normalizer) {
1552
+ await loadModels();
1553
+ return;
1554
+ }
1555
+
1556
+ if (!navigator.mediaDevices?.getUserMedia || !window.isSecureContext) {
1557
+ throw new Error("Microphone capture requires HTTPS or localhost.");
1558
+ }
1559
+
1560
+ hideResult();
1561
+ closeModePopover();
1562
+
1563
+ let stream = null;
1564
+ let context = null;
1565
+ let workletUrl = null;
1566
+ try {
1567
+ stream = await navigator.mediaDevices.getUserMedia({
1568
+ audio: {
1569
+ channelCount: 1,
1570
+ echoCancellation: true,
1571
+ noiseSuppression: true,
1572
+ autoGainControl: true,
1573
+ },
1574
+ });
1575
+
1576
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
1577
+ context = new AudioContextClass({ latencyHint: "interactive" });
1578
+ await context.resume();
1579
+
1580
+ workletUrl = URL.createObjectURL(
1581
+ new Blob([RECORDER_WORKLET_SOURCE], { type: "text/javascript" }),
1582
+ );
1583
+ await context.audioWorklet.addModule(workletUrl);
1584
+ URL.revokeObjectURL(workletUrl);
1585
+ workletUrl = null;
1586
+
1587
+ const source = context.createMediaStreamSource(stream);
1588
+ const analyser = context.createAnalyser();
1589
+ analyser.fftSize = 2048;
1590
+ analyser.smoothingTimeConstant = 0.72;
1591
+
1592
+ const recorderNode = new AudioWorkletNode(context, "pcm-recorder", {
1593
+ numberOfInputs: 1,
1594
+ numberOfOutputs: 1,
1595
+ outputChannelCount: [1],
1596
+ channelCount: 1,
1597
+ });
1598
+
1599
+ const chunks = [];
1600
+ let resolveFlush;
1601
+ const flushPromise = new Promise((resolve) => {
1602
+ resolveFlush = resolve;
1603
+ });
1604
+
1605
+ recorderNode.port.onmessage = ({ data }) => {
1606
+ if (data.type === "chunk") chunks.push(data.samples);
1607
+ if (data.type === "flushed") resolveFlush?.();
1608
+ };
1609
+
1610
+ source.connect(analyser);
1611
+ analyser.connect(recorderNode);
1612
+ recorderNode.connect(context.destination);
1613
+
1614
+ capture = {
1615
+ stream,
1616
+ context,
1617
+ source,
1618
+ analyser,
1619
+ recorderNode,
1620
+ chunks,
1621
+ waveformData: new Uint8Array(analyser.fftSize),
1622
+ sampleRate: context.sampleRate,
1623
+ startedAt: performance.now(),
1624
+ flush: async () => {
1625
+ recorderNode.port.postMessage("flush");
1626
+ await Promise.race([flushPromise, new Promise((resolve) => setTimeout(resolve, 160))]);
1627
+ },
1628
+ };
1629
+
1630
+ setState("recording", "Listening", "00:00");
1631
+ updateRecordingTimer();
1632
+ timerInterval = setInterval(updateRecordingTimer, 250);
1633
+ recordingLimitTimeout = setTimeout(() => {
1634
+ stopRecording().catch((error) => {
1635
+ showError(error, "The local pipeline could not complete. Check the console for details, then try again.");
1636
+ });
1637
+ }, MAX_RECORDING_SECONDS * 1000);
1638
+ } catch (error) {
1639
+ if (workletUrl) URL.revokeObjectURL(workletUrl);
1640
+ if (!capture) {
1641
+ stream?.getTracks().forEach((track) => track.stop());
1642
+ if (context && context.state !== "closed") await context.close().catch(() => {});
1643
+ }
1644
+ throw error;
1645
+ }
1646
+ }
1647
+
1648
+ function updateRecordingTimer() {
1649
+ if (!capture) return;
1650
+ const seconds = Math.floor((performance.now() - capture.startedAt) / 1000);
1651
+ const minutes = String(Math.floor(seconds / 60)).padStart(2, "0");
1652
+ const remainder = String(seconds % 60).padStart(2, "0");
1653
+ elements.statusDetail.textContent = `${minutes}:${remainder}`;
1654
+ }
1655
+
1656
+ async function finishCapture(discard = false) {
1657
+ if (!capture) return null;
1658
+
1659
+ clearInterval(timerInterval);
1660
+ clearTimeout(recordingLimitTimeout);
1661
+ timerInterval = null;
1662
+ recordingLimitTimeout = null;
1663
+
1664
+ const current = capture;
1665
+ capture = null;
1666
+ await current.flush();
1667
+
1668
+ current.source.disconnect();
1669
+ current.analyser.disconnect();
1670
+ current.recorderNode.disconnect();
1671
+ current.stream.getTracks().forEach((track) => track.stop());
1672
+ await current.context.close();
1673
+
1674
+ if (discard) return null;
1675
+ const totalLength = current.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
1676
+ const samples = new Float32Array(totalLength);
1677
+ let offset = 0;
1678
+ for (const chunk of current.chunks) {
1679
+ samples.set(chunk, offset);
1680
+ offset += chunk.length;
1681
+ }
1682
+
1683
+ return resample(samples, current.sampleRate, TARGET_SAMPLE_RATE);
1684
+ }
1685
+
1686
+ async function resample(samples, sourceRate, targetRate) {
1687
+ if (sourceRate === targetRate) return samples;
1688
+ const frameCount = Math.max(1, Math.ceil(samples.length * targetRate / sourceRate));
1689
+ const OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
1690
+ const offline = new OfflineContext(1, frameCount, targetRate);
1691
+ const sourceBuffer = offline.createBuffer(1, samples.length, sourceRate);
1692
+ sourceBuffer.copyToChannel(samples, 0);
1693
+ const source = offline.createBufferSource();
1694
+ source.buffer = sourceBuffer;
1695
+ source.connect(offline.destination);
1696
+ source.start(0);
1697
+ const rendered = await offline.startRendering();
1698
+ return rendered.getChannelData(0).slice();
1699
+ }
1700
+
1701
+ async function stopRecording() {
1702
+ if (state !== "recording") return;
1703
+
1704
+ setState("transcribing", "Transcribing", "The speech recognition model is listening back");
1705
+ const audio = await finishCapture(false);
1706
+
1707
+ if (!audio || audio.length < TARGET_SAMPLE_RATE * 0.5) {
1708
+ setState("ready", "That was too short", "Try a slightly longer recording");
1709
+ return;
1710
+ }
1711
+
1712
+ const maxAsrTokens = Math.max(6, Math.floor(audio.length / TARGET_SAMPLE_RATE) * 6);
1713
+ const asrOutput = await transcriber(audio, { max_new_tokens: maxAsrTokens });
1714
+ const raw = (asrOutput?.text ?? "").trim();
1715
+
1716
+ if (!raw) {
1717
+ showResult("", "", false);
1718
+ setState("done", "No speech detected", "Ready for another take");
1719
+ return;
1720
+ }
1721
+
1722
+ setState("normalizing", "Polishing", "S1-mini is cleaning the transcript");
1723
+ await normalize(raw);
1724
+ setState("done", "Done", "Cleaned locally on WebGPU");
1725
+ }
1726
+
1727
+ async function normalize(raw) {
1728
+ const control =
1729
+ `[Styling: ${elements.styling.value}] ` +
1730
+ `[Structure: ${elements.structure.value}] ` +
1731
+ `[Context: ${elements.context.value}]`;
1732
+ const messages = [
1733
+ { role: "system", content: SYSTEM_PROMPT },
1734
+ { role: "user", content: `${control}\n${raw}` },
1735
+ ];
1736
+
1737
+ const tokenizedRaw = normalizer.tokenizer(raw);
1738
+ const inputTokens = tokenizedRaw.input_ids?.size ?? tokenizedRaw.input_ids?.dims?.at(-1) ?? 128;
1739
+ const maxNewTokens = Math.min(1024, Math.max(64, Math.ceil(inputTokens * 1.3) + 32));
1740
+
1741
+ let streamed = "";
1742
+ showResult("", raw, true);
1743
+ const streamer = new TextStreamer(normalizer.tokenizer, {
1744
+ skip_prompt: true,
1745
+ skip_special_tokens: true,
1746
+ callback_function: (text) => {
1747
+ streamed += text;
1748
+ elements.cleanText.textContent = streamed;
1749
+ },
1750
+ });
1751
+
1752
+ const output = await normalizer(messages, {
1753
+ max_new_tokens: maxNewTokens,
1754
+ do_sample: false,
1755
+ streamer,
1756
+ tokenizer_encode_kwargs: { enable_thinking: false },
1757
+ });
1758
+
1759
+ const cleaned = (output?.[0]?.generated_text?.at(-1)?.content ?? streamed).trim();
1760
+ showResult(cleaned, raw, false);
1761
+ }
1762
+
1763
+ function showResult(cleaned, raw, streaming) {
1764
+ lastCleanText = cleaned;
1765
+ elements.result.classList.add("visible");
1766
+ elements.cleanText.classList.toggle("streaming", streaming);
1767
+ elements.cleanText.classList.toggle("empty", !streaming && !cleaned);
1768
+ elements.cleanText.textContent = cleaned || (streaming ? "" : "No written text — the recording contained only filler or noise.");
1769
+ elements.rawText.textContent = raw || "No speech detected.";
1770
+ elements.copy.disabled = streaming || !cleaned;
1771
+ }
1772
+
1773
+ function hideResult() {
1774
+ elements.result.classList.remove("visible");
1775
+ elements.cleanText.classList.remove("streaming", "empty");
1776
+ elements.cleanText.textContent = "";
1777
+ elements.rawText.textContent = "";
1778
+ elements.copy.disabled = true;
1779
+ lastCleanText = "";
1780
+ }
1781
+
1782
+ async function cancelRecording() {
1783
+ if (state !== "recording") return;
1784
+ await finishCapture(true);
1785
+ setState("ready", "Cancelled", "Press Record when you’re ready");
1786
+ }
1787
+
1788
+ async function handlePrimaryAction() {
1789
+ try {
1790
+ if (state === "idle" || (state === "error" && (!transcriber || !normalizer))) {
1791
+ await loadModels();
1792
+ } else if (state === "recording") {
1793
+ await stopRecording();
1794
+ } else if (RECORDABLE_STATES.has(state)) {
1795
+ await startRecording();
1796
+ }
1797
+ } catch (error) {
1798
+ if (capture) await finishCapture(true);
1799
+ showError(
1800
+ error,
1801
+ error?.message?.includes("WebGPU")
1802
+ ? "WebGPU is unavailable. Open this page in a current version of Chrome, Edge, or Safari with WebGPU enabled."
1803
+ : "The local pipeline could not complete. Check the console for details, then try again.",
1804
+ );
1805
+ }
1806
+ }
1807
+
1808
+ function toggleModePopover() {
1809
+ const open = !elements.modePopover.classList.contains("open");
1810
+ elements.modePopover.classList.toggle("open", open);
1811
+ elements.modePopover.setAttribute("aria-hidden", String(!open));
1812
+ elements.modeButton.setAttribute("aria-expanded", String(open));
1813
+ }
1814
+
1815
+ function closeModePopover() {
1816
+ elements.modePopover.classList.remove("open");
1817
+ elements.modePopover.setAttribute("aria-hidden", "true");
1818
+ elements.modeButton.setAttribute("aria-expanded", "false");
1819
+ }
1820
+
1821
+ async function copyResult() {
1822
+ if (!lastCleanText) return;
1823
+ try {
1824
+ await navigator.clipboard.writeText(lastCleanText);
1825
+ } catch {
1826
+ const textarea = document.createElement("textarea");
1827
+ textarea.value = lastCleanText;
1828
+ textarea.style.position = "fixed";
1829
+ textarea.style.opacity = "0";
1830
+ page.append(textarea);
1831
+ textarea.select();
1832
+ document.execCommand("copy");
1833
+ textarea.remove();
1834
+ }
1835
+ elements.copy.textContent = "Copied";
1836
+ setTimeout(() => {
1837
+ elements.copy.textContent = "Copy";
1838
+ }, 1400);
1839
+ }
1840
+
1841
+ function resizeCanvas() {
1842
+ const rect = elements.waveform.getBoundingClientRect();
1843
+ const scale = Math.min(devicePixelRatio || 1, 2);
1844
+ const width = Math.round(rect.width * scale);
1845
+ const height = Math.round(rect.height * scale);
1846
+ if (
1847
+ elements.waveform.width !== width ||
1848
+ elements.waveform.height !== height ||
1849
+ canvasScale !== scale
1850
+ ) {
1851
+ elements.waveform.width = width;
1852
+ elements.waveform.height = height;
1853
+ canvasScale = scale;
1854
+ canvasContext.setTransform(scale, 0, 0, scale, 0, 0);
1855
+ }
1856
+
1857
+ const barCount = Math.max(34, Math.min(84, Math.floor(rect.width / 7)));
1858
+ if (levels.length !== barCount) {
1859
+ envelopes = Array.from(
1860
+ { length: barCount },
1861
+ (_, index) => Math.pow(Math.sin((index / (barCount - 1)) * Math.PI), 0.62),
1862
+ );
1863
+ levels = envelopes.map((envelope) => 0.06 + envelope * 0.08);
1864
+ }
1865
+ }
1866
+
1867
+ function startWaveformAnimation() {
1868
+ if (!animationFrame && !document.hidden) {
1869
+ animationFrame = requestAnimationFrame(drawWaveform);
1870
+ }
1871
+ }
1872
+
1873
+ function stopWaveformAnimation() {
1874
+ if (!animationFrame) return;
1875
+ cancelAnimationFrame(animationFrame);
1876
+ animationFrame = 0;
1877
+ }
1878
+
1879
+ function drawWaveform(time = 0) {
1880
+ animationFrame = 0;
1881
+ const width = elements.waveform.clientWidth;
1882
+ const height = elements.waveform.clientHeight;
1883
+ canvasContext.clearRect(0, 0, width, height);
1884
+ if (!width || !height || levels.length === 0) {
1885
+ startWaveformAnimation();
1886
+ return;
1887
+ }
1888
+
1889
+ const isRecording = state === "recording" && capture?.analyser;
1890
+ const isProcessing = state === "transcribing" || state === "normalizing";
1891
+ const isLoading = state === "loading";
1892
+ const audioData = isRecording ? capture.waveformData : null;
1893
+ if (audioData) {
1894
+ capture.analyser.getByteTimeDomainData(audioData);
1895
+ }
1896
+
1897
+ const levelCount = levels.length;
1898
+ const lastIndex = levelCount - 1;
1899
+ const gap = width / levelCount;
1900
+ const barWidth = Math.max(2, Math.min(3.2, gap * 0.44));
1901
+ const center = height / 2;
1902
+
1903
+ for (let i = 0; i < levelCount; i++) {
1904
+ const normalizedPosition = i / lastIndex;
1905
+ const edgeEnvelope = envelopes[i];
1906
+ let target;
1907
+
1908
+ if (audioData) {
1909
+ const from = Math.floor((i / levelCount) * audioData.length);
1910
+ const to = Math.max(from + 1, Math.floor(((i + 1) / levelCount) * audioData.length));
1911
+ let energy = 0;
1912
+ for (let sample = from; sample < to; sample++) {
1913
+ const value = (audioData[sample] - 128) / 128;
1914
+ energy += value * value;
1915
+ }
1916
+ target = Math.min(1, Math.sqrt(energy / (to - from)) * 7.5 + 0.035);
1917
+ } else if (isProcessing) {
1918
+ const sweep = (Math.sin(time * 0.003 - i * 0.27) + 1) / 2;
1919
+ target = 0.08 + edgeEnvelope * (0.12 + sweep * 0.27);
1920
+ } else if (isLoading) {
1921
+ const pulse = (Math.sin(time * 0.0025 - i * 0.19) + 1) / 2;
1922
+ target = 0.06 + edgeEnvelope * (0.08 + pulse * 0.14);
1923
+ } else {
1924
+ const drift = reducedMotion ? 0.35 : (Math.sin(time * 0.0015 + i * 0.67) + 1) / 2;
1925
+ target = 0.045 + edgeEnvelope * (0.07 + drift * 0.09);
1926
+ }
1927
+
1928
+ levels[i] += (target - levels[i]) * (audioData ? 0.34 : 0.08);
1929
+ const barHeight = Math.max(3, levels[i] * height * 0.83);
1930
+ const x = i * gap + (gap - barWidth) / 2;
1931
+ const y = center - barHeight / 2;
1932
+
1933
+ let alpha = 0.28 + edgeEnvelope * 0.48;
1934
+ if (isRecording) alpha = 0.48 + levels[i] * 0.52;
1935
+ if (isLoading && displayProgress > 0) {
1936
+ alpha = normalizedPosition <= displayProgress / 100 ? 0.82 : 0.18;
1937
+ }
1938
+
1939
+ canvasContext.fillStyle = `rgba(245, 245, 242, ${alpha})`;
1940
+ canvasContext.beginPath();
1941
+ canvasContext.roundRect(x, y, barWidth, barHeight, barWidth / 2);
1942
+ canvasContext.fill();
1943
+ }
1944
+
1945
+ startWaveformAnimation();
1946
+ }
1947
+
1948
+ elements.primary.addEventListener("click", handlePrimaryAction);
1949
+ elements.cancel.addEventListener("click", () => cancelRecording().catch((error) => showError(error, "Could not cancel the recording cleanly.")));
1950
+ elements.copy.addEventListener("click", copyResult);
1951
+ elements.modeButton.addEventListener("click", toggleModePopover);
1952
+ document.addEventListener("click", (event) => {
1953
+ if (!event.target.closest?.(".mode-group")) closeModePopover();
1954
+ });
1955
+
1956
+ document.addEventListener("visibilitychange", () => {
1957
+ if (document.hidden) stopWaveformAnimation();
1958
+ else startWaveformAnimation();
1959
+ });
1960
+
1961
+ window.addEventListener("keydown", (event) => {
1962
+ const interactive = event.target.closest?.("button, select, input, textarea, summary");
1963
+ if (event.key === "Escape") {
1964
+ if (state === "recording") cancelRecording().catch(console.error);
1965
+ closeModePopover();
1966
+ return;
1967
+ }
1968
+ if (event.code === "Space" && !event.repeat && !interactive && !elements.modePopover.classList.contains("open")) {
1969
+ event.preventDefault();
1970
+ handlePrimaryAction();
1971
+ }
1972
+ });
1973
+
1974
+ const resizeObserver = new ResizeObserver(resizeCanvas);
1975
+ resizeObserver.observe(elements.waveform);
1976
+ resizeCanvas();
1977
+ startWaveformAnimation();
1978
+
1979
+ window.addEventListener("pagehide", () => {
1980
+ stopWaveformAnimation();
1981
+ resizeObserver.disconnect();
1982
+ if (capture) {
1983
+ capture.stream.getTracks().forEach((track) => track.stop());
1984
+ capture.context.close().catch(() => {});
1985
+ }
1986
+ });
1987
+ </script>
1988
+ </body>
1989
  </html>