levante 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,681 @@
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.0">
6
+ <title>Levante — Recording Companion</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ background: #0a0a14; color: #e0e0e0;
11
+ font-family: system-ui, -apple-system, sans-serif;
12
+ min-height: 100vh; overflow: hidden;
13
+ }
14
+
15
+ /* --- Pill (collapsed) --- */
16
+ #pill {
17
+ display: inline-flex; align-items: center; gap: 8px;
18
+ padding: 8px 16px; border-radius: 20px;
19
+ cursor: pointer; user-select: none;
20
+ margin: 12px; transition: all 0.3s;
21
+ }
22
+ #pill.recording { background: rgba(26,10,10,0.95); border: 1px solid #ff4444; }
23
+ #pill.paused { background: rgba(26,20,10,0.95); border: 1px solid #f0a030; }
24
+ #pill.stopped { background: rgba(20,20,20,0.95); border: 1px solid #666; }
25
+
26
+ .dot {
27
+ width: 8px; height: 8px; border-radius: 50%;
28
+ }
29
+ .recording .dot { background: #ff4444; box-shadow: 0 0 8px #ff4444; animation: pulse 1.5s infinite; }
30
+ .paused .dot { background: #f0a030; }
31
+ .stopped .dot { background: #666; }
32
+
33
+ .pill-label { font-size: 12px; font-weight: 600; }
34
+ .recording .pill-label { color: #ff6666; }
35
+ .paused .pill-label { color: #f0a030; }
36
+ .stopped .pill-label { color: #888; }
37
+
38
+ .pill-timer { font-size: 11px; color: #666; font-variant-numeric: tabular-nums; }
39
+ .pill-separator { color: #333; }
40
+ .pill-snippet { font-size: 11px; color: #999; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
41
+
42
+ /* --- Expanded view --- */
43
+ #expanded { display: none; }
44
+ body.expanded #pill { display: none; }
45
+ body.expanded #expanded { display: flex; flex-direction: column; height: 100vh; }
46
+
47
+ .header {
48
+ display: flex; align-items: center; gap: 8px;
49
+ padding: 10px 14px; background: #111; border-bottom: 1px solid #222;
50
+ flex-shrink: 0;
51
+ }
52
+ .engine-badge {
53
+ font-size: 10px; padding: 2px 6px; border-radius: 3px; margin-left: 8px;
54
+ }
55
+ .engine-badge.webspeech { background: #1a2a1a; color: #4a4; }
56
+ .engine-badge.whisper { background: #1a1a2a; color: #66f; }
57
+
58
+ .mode-badge {
59
+ font-size: 10px; padding: 2px 6px; border-radius: 3px; margin-left: 8px;
60
+ background: #2a2a1a; color: #aa4; display: none;
61
+ }
62
+ body.paused .mode-badge { display: inline; }
63
+
64
+ .controls { margin-left: auto; display: flex; gap: 6px; }
65
+ .controls button {
66
+ padding: 4px 12px; border: none; border-radius: 4px;
67
+ font-size: 11px; cursor: pointer; font-family: inherit;
68
+ }
69
+ .btn-pause { background: #ff4444; color: white; }
70
+ .btn-resume { background: #22aa44; color: white; display: none; }
71
+ .btn-stop { background: #222; color: #888; border: 1px solid #333; }
72
+ .btn-save { background: #00e5ff; color: #000; font-weight: 600; display: none; }
73
+
74
+ body.recording .btn-pause { display: inline; }
75
+ body.recording .btn-resume { display: none; }
76
+ body.paused .btn-pause { display: none; }
77
+ body.paused .btn-resume { display: inline; }
78
+ body.stopped .btn-pause, body.stopped .btn-resume, body.stopped .btn-stop { display: none; }
79
+ body.stopped .btn-save { display: inline; }
80
+
81
+ /* --- Transcript --- */
82
+ .transcript {
83
+ flex: 1; overflow-y: auto; padding: 12px 14px;
84
+ font-size: 12px; line-height: 1.8;
85
+ }
86
+ .segment {
87
+ display: flex; gap: 0; margin-bottom: 6px; align-items: flex-start;
88
+ position: relative;
89
+ }
90
+ .seg-cursor-zone {
91
+ width: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
92
+ padding-top: 3px; cursor: pointer; opacity: 0; transition: opacity 0.15s;
93
+ }
94
+ .segment:hover .seg-cursor-zone { opacity: 1; }
95
+ .seg-cursor-zone.active { opacity: 1; }
96
+ .seg-cursor-zone svg { width: 14px; height: 14px; }
97
+ .seg-cursor-zone .cursor-icon { color: #555; }
98
+ .seg-cursor-zone:hover .cursor-icon { color: #00e5ff; }
99
+ .seg-cursor-zone.active .cursor-icon { color: #00e5ff; }
100
+ .seg-time {
101
+ color: #00e5ff; font-size: 10px; min-width: 40px; padding-top: 3px;
102
+ font-variant-numeric: tabular-nums; flex-shrink: 0; margin-right: 10px;
103
+ }
104
+ .seg-text { color: #999; }
105
+ .seg-text.current { color: #ccc; border-left: 2px solid #00e5ff; padding-left: 8px; }
106
+ .seg-text[contenteditable="true"] { cursor: text; outline: none; }
107
+ .seg-text[contenteditable="true"]:focus { color: #eee; }
108
+
109
+ .seg-tags { margin-top: 3px; display: flex; gap: 4px; flex-wrap: wrap; }
110
+ .tag {
111
+ font-size: 9px; padding: 2px 6px; border-radius: 3px; cursor: pointer;
112
+ }
113
+ .tag.assertion { background: #1a3a2a; color: #4ade80; }
114
+ .tag.expectation { background: #1a2a3a; color: #60a5fa; }
115
+ .tag.generalize { background: #2a1a3a; color: #c084fc; }
116
+
117
+ mark.assertion { background: rgba(74,222,128,0.25); border-bottom: 2px solid #4ade80; color: #a5f3c4; padding: 1px 2px; border-radius: 2px; }
118
+ mark.expectation { background: rgba(96,165,250,0.25); border-bottom: 2px solid #60a5fa; color: #bfdbfe; padding: 1px 2px; border-radius: 2px; }
119
+ mark.generalize { background: rgba(192,132,252,0.25); border-bottom: 2px solid #c084fc; color: #e0c3fc; padding: 1px 2px; border-radius: 2px; }
120
+
121
+ .interim { color: #555; font-style: italic; padding: 4px 14px; font-size: 12px; }
122
+
123
+ #tag-popup {
124
+ display: none; position: fixed;
125
+ padding: 4px 6px; background: #1a1a2e; border-radius: 6px; border: 1px solid #333;
126
+ z-index: 100; gap: 4px;
127
+ }
128
+ #tag-popup.visible { display: inline-flex; }
129
+ #tag-popup button {
130
+ padding: 3px 10px; border-radius: 4px; font-size: 10px; cursor: pointer;
131
+ border: 1px solid; font-family: inherit;
132
+ }
133
+ #tag-popup .tag-assertion { background: #1a3a2a; color: #4ade80; border-color: #2a5a3a; }
134
+ #tag-popup .tag-expectation { background: #1a2a3a; color: #60a5fa; border-color: #2a3a5a; }
135
+ #tag-popup .tag-generalize { background: #2a1a3a; color: #c084fc; border-color: #3a2a5a; }
136
+
137
+ .footer {
138
+ padding: 8px 14px; border-top: 1px solid #1a1a2e;
139
+ font-size: 10px; color: #444; text-align: center; flex-shrink: 0;
140
+ }
141
+ .warning { color: #f0a030; font-size: 10px; padding: 4px 14px; display: none; }
142
+ .warning.visible { display: block; }
143
+
144
+ /* --- Jump to bottom --- */
145
+ #jump-bottom {
146
+ display: none; position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%);
147
+ background: #1a1a2e; border: 1px solid #333; border-radius: 16px;
148
+ padding: 5px 14px; font-size: 10px; color: #888; cursor: pointer;
149
+ z-index: 50; transition: all 0.2s; gap: 4px; align-items: center;
150
+ font-family: inherit; box-shadow: 0 2px 8px rgba(0,0,0,0.4);
151
+ }
152
+ #jump-bottom:hover { background: #222; color: #ccc; border-color: #00e5ff; }
153
+ #jump-bottom.visible { display: inline-flex; }
154
+ #jump-bottom svg { width: 12px; height: 12px; }
155
+
156
+ /* --- Codegen closed notification --- */
157
+ #codegen-closed-banner {
158
+ display: none; padding: 12px 16px; background: #1a1a2e; border-bottom: 1px solid #333;
159
+ flex-shrink: 0; text-align: center;
160
+ }
161
+ #codegen-closed-banner.visible { display: block; }
162
+ #codegen-closed-banner .banner-text {
163
+ font-size: 12px; color: #e0e0e0; margin-bottom: 8px; line-height: 1.5;
164
+ }
165
+ #codegen-closed-banner .banner-actions { display: flex; gap: 8px; justify-content: center; }
166
+ #codegen-closed-banner .banner-actions button {
167
+ padding: 6px 16px; border: none; border-radius: 4px; font-size: 11px; cursor: pointer; font-family: inherit;
168
+ }
169
+ .btn-review { background: #222; color: #ccc; border: 1px solid #444 !important; }
170
+ .btn-review:hover { background: #333; }
171
+ .btn-save-close { background: #00e5ff; color: #000; font-weight: 600; }
172
+ .btn-save-close:hover { background: #00c8e0; }
173
+
174
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
175
+ </style>
176
+ </head>
177
+ <body class="recording">
178
+
179
+ <!-- Collapsed pill -->
180
+ <div id="pill" class="recording" onclick="toggleExpand()">
181
+ <div class="dot"></div>
182
+ <span class="pill-label">REC</span>
183
+ <span class="pill-timer">00:00</span>
184
+ <span class="pill-separator">|</span>
185
+ <span class="pill-snippet"></span>
186
+ </div>
187
+
188
+ <!-- Expanded view -->
189
+ <div id="expanded">
190
+ <div class="header" onclick="toggleExpand()">
191
+ <div class="dot"></div>
192
+ <span class="pill-label">REC</span>
193
+ <span class="pill-timer">00:00</span>
194
+ <span class="engine-badge webspeech">Web Speech</span>
195
+ <span class="mode-badge">EDIT MODE</span>
196
+ <div class="controls" onclick="event.stopPropagation()">
197
+ <button class="btn-pause" onclick="sendControl('pause')">Pause</button>
198
+ <button class="btn-resume" onclick="doResume()">Resume</button>
199
+ <button class="btn-stop" onclick="sendControl('stop')">Stop</button>
200
+ <button class="btn-save" onclick="doSave()">Save &amp; Close</button>
201
+ </div>
202
+ </div>
203
+ <div id="codegen-closed-banner">
204
+ <div class="banner-text">
205
+ Browser recording ended. Transcription is paused.<br>
206
+ Review and edit your transcript, or save to continue.
207
+ </div>
208
+ <div class="banner-actions">
209
+ <button class="btn-review" onclick="dismissBanner()">Review transcript</button>
210
+ <button class="btn-save-close" onclick="doSave()">Save &amp; Close</button>
211
+ </div>
212
+ </div>
213
+ <div style="position:relative;flex:1;display:flex;flex-direction:column;overflow:hidden;">
214
+ <div class="transcript" id="transcript"></div>
215
+ <button id="jump-bottom" onclick="scrollToBottom()">
216
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
217
+ Jump to bottom
218
+ </button>
219
+ </div>
220
+ <div class="interim" id="interim"></div>
221
+ <div class="warning" id="warning">Web Speech API restarting frequently. Consider switching to Whisper.</div>
222
+ <div class="footer">
223
+ <span class="footer-recording">Ctrl+Shift+R to pause/resume | Text is read-only during recording</span>
224
+ <span class="footer-paused" style="display:none">Click text to edit | Highlight text to tag | Resume continues from cursor</span>
225
+ <span class="footer-stopped" style="display:none">Final review — edit text and tags before saving</span>
226
+ </div>
227
+ </div>
228
+
229
+ <!-- Tag popup -->
230
+ <div id="tag-popup">
231
+ <button class="tag-assertion" onclick="applyTag('assertion')">assertion</button>
232
+ <button class="tag-expectation" onclick="applyTag('expectation')">expectation</button>
233
+ <button class="tag-generalize" onclick="applyTag('generalize')">generalize</button>
234
+ </div>
235
+
236
+ <script>
237
+ let ws;
238
+ let state = { status: 'recording', elapsed: 0, segments: [], engine: 'webspeech' };
239
+ let isExpanded = false;
240
+ let recognition = null;
241
+ let recognitionStartTime = 0;
242
+ let restartCount = 0;
243
+ let restartWindowStart = Date.now();
244
+ let currentCursorSegmentId = null;
245
+ let timerInterval = null;
246
+
247
+ const $pill = document.getElementById('pill');
248
+ const $transcript = document.getElementById('transcript');
249
+ const $interim = document.getElementById('interim');
250
+ const $warning = document.getElementById('warning');
251
+ const $tagPopup = document.getElementById('tag-popup');
252
+ const $snippet = document.querySelector('.pill-snippet');
253
+
254
+ function connectWs() {
255
+ const port = location.port;
256
+ ws = new WebSocket(`ws://localhost:${port}`);
257
+
258
+ ws.onmessage = (e) => {
259
+ try {
260
+ const msg = JSON.parse(e.data);
261
+ if (msg.type === 'state:update') {
262
+ const prev = state.status;
263
+ state = msg.state;
264
+ updateUI();
265
+ if (prev !== state.status) {
266
+ if (state.status === 'paused' || state.status === 'stopped') stopRecognition();
267
+ else if (state.status === 'recording') startRecognition();
268
+ }
269
+ } else if (msg.type === 'transcript:interim') {
270
+ if (state.status === 'recording') $interim.textContent = msg.text;
271
+ } else if (msg.type === 'codegen:closed') {
272
+ // Browser recording ended — show review banner
273
+ stopRecognition();
274
+ if (!isExpanded) { isExpanded = true; updateUI(); }
275
+ document.getElementById('codegen-closed-banner').classList.add('visible');
276
+ }
277
+ } catch {}
278
+ };
279
+
280
+ ws.onclose = () => setTimeout(connectWs, 2000);
281
+ ws.onerror = () => {};
282
+ }
283
+
284
+ function sendMsg(msg) {
285
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
286
+ }
287
+
288
+ function sendControl(type) {
289
+ sendMsg({ type: `control:${type}` });
290
+ }
291
+
292
+ function updateUI() {
293
+ const s = state.status;
294
+ document.body.className = s + (isExpanded ? ' expanded' : '');
295
+ $pill.className = s;
296
+
297
+ const label = s === 'recording' ? 'REC' : s === 'paused' ? 'PAUSED' : 'STOPPED';
298
+ document.querySelectorAll('.pill-label').forEach(el => el.textContent = label);
299
+ document.querySelectorAll('.pill-timer').forEach(el => el.textContent = fmt(state.elapsed));
300
+
301
+ const badge = document.querySelector('.engine-badge');
302
+ badge.className = `engine-badge ${state.engine}`;
303
+ badge.textContent = state.engine === 'webspeech' ? 'Web Speech' : 'Whisper';
304
+
305
+ if (state.segments.length > 0) {
306
+ $snippet.textContent = state.segments[state.segments.length - 1].text;
307
+ }
308
+
309
+ document.querySelector('.footer-recording').style.display = s === 'recording' ? '' : 'none';
310
+ document.querySelector('.footer-paused').style.display = s === 'paused' ? '' : 'none';
311
+ document.querySelector('.footer-stopped').style.display = s === 'stopped' ? '' : 'none';
312
+
313
+ renderSegments();
314
+ if (s !== 'recording') $interim.textContent = '';
315
+ }
316
+
317
+ function renderSegments() {
318
+ // Skip re-render if user is actively editing (would destroy cursor position)
319
+ if (document.activeElement && document.activeElement.hasAttribute('contenteditable')) return;
320
+
321
+ const editable = state.status === 'paused' || state.status === 'stopped';
322
+ const segs = state.segments;
323
+ const wasAtBottom = $transcript.scrollHeight - $transcript.scrollTop - $transcript.clientHeight < 30;
324
+
325
+ const cursorIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="cursor-icon"><path d="M5 12h14"/><path d="M12 5l-4 4"/><path d="M12 5l4 4"/></svg>`;
326
+
327
+ $transcript.innerHTML = segs.map((seg, i) => {
328
+ const isCurrent = i === segs.length - 1 && state.status === 'recording';
329
+ const isCursorHere = currentCursorSegmentId === seg.id;
330
+ let textHtml = escHtml(seg.text);
331
+
332
+ if (seg.tags && seg.tags.length > 0) {
333
+ for (const t of seg.tags) {
334
+ const escaped = escHtml(t.text);
335
+ textHtml = textHtml.replace(escaped, `<mark class="${t.tag}">${escaped}</mark>`);
336
+ }
337
+ }
338
+
339
+ const tagBadges = (seg.tags || []).map(t =>
340
+ `<span class="tag ${t.tag}" data-seg-id="${seg.id}" data-tag-text="${escHtml(t.text)}">${t.tag}: ${escHtml(t.text)}</span>`
341
+ ).join('');
342
+
343
+ const cursorZone = editable
344
+ ? `<div class="seg-cursor-zone${isCursorHere ? ' active' : ''}" data-cursor-seg="${seg.id}" title="Insert new segments after this line">${cursorIcon}</div>`
345
+ : '';
346
+
347
+ return `
348
+ <div class="segment" data-id="${seg.id}">
349
+ ${cursorZone}
350
+ <span class="seg-time">${fmt(seg.start)}</span>
351
+ <div>
352
+ <span class="seg-text${isCurrent ? ' current' : ''}"
353
+ ${editable ? `contenteditable="true" data-seg-id="${seg.id}"` : ''}
354
+ ${editable ? `onblur="onSegEdit(this)"` : ''}
355
+ onclick="trackCursor('${seg.id}')">${textHtml}</span>
356
+ ${tagBadges ? `<div class="seg-tags">${tagBadges}</div>` : ''}
357
+ </div>
358
+ </div>
359
+ `;
360
+ }).join('');
361
+
362
+ if (wasAtBottom && state.status === 'recording') {
363
+ $transcript.scrollTop = $transcript.scrollHeight;
364
+ }
365
+ }
366
+
367
+ function startTimer() {
368
+ if (timerInterval) return;
369
+ timerInterval = setInterval(() => {
370
+ if (state.status === 'recording') {
371
+ state.elapsed += 0.1;
372
+ document.querySelectorAll('.pill-timer').forEach(el => el.textContent = fmt(state.elapsed));
373
+ }
374
+ }, 100);
375
+ }
376
+
377
+ function toggleExpand() {
378
+ isExpanded = !isExpanded;
379
+ updateUI();
380
+ }
381
+
382
+ function doResume() {
383
+ sendMsg({ type: 'control:resume', cursorSegmentId: currentCursorSegmentId });
384
+ }
385
+
386
+ function doSave() {
387
+ sendMsg({ type: 'save-and-exit' });
388
+ setTimeout(() => window.close(), 500);
389
+ }
390
+
391
+ function dismissBanner() {
392
+ document.getElementById('codegen-closed-banner').classList.remove('visible');
393
+ }
394
+
395
+ function trackCursor(segId) {
396
+ currentCursorSegmentId = segId;
397
+ // Update active state on cursor zones
398
+ document.querySelectorAll('.seg-cursor-zone').forEach(el => {
399
+ el.classList.toggle('active', el.dataset.cursorSeg === segId);
400
+ });
401
+ }
402
+
403
+ // Delegated click for cursor zone icons
404
+ $transcript.addEventListener('click', (e) => {
405
+ const zone = e.target.closest('.seg-cursor-zone[data-cursor-seg]');
406
+ if (zone) {
407
+ e.stopPropagation();
408
+ trackCursor(zone.dataset.cursorSeg);
409
+ return;
410
+ }
411
+ });
412
+
413
+ // Jump to bottom button
414
+ const $jumpBottom = document.getElementById('jump-bottom');
415
+
416
+ function scrollToBottom() {
417
+ $transcript.scrollTo({ top: $transcript.scrollHeight, behavior: 'smooth' });
418
+ }
419
+
420
+ $transcript.addEventListener('scroll', () => {
421
+ const atBottom = $transcript.scrollHeight - $transcript.scrollTop - $transcript.clientHeight < 60;
422
+ $jumpBottom.classList.toggle('visible', !atBottom && $transcript.scrollHeight > $transcript.clientHeight);
423
+ });
424
+
425
+ function onSegEdit(el) {
426
+ const id = el.dataset.segId;
427
+ const text = el.innerText.trim();
428
+ sendMsg({ type: 'transcript:edit', id, text });
429
+ }
430
+
431
+ let tagSelection = null;
432
+
433
+ document.addEventListener('mouseup', () => {
434
+ const sel = window.getSelection();
435
+ if (!sel || sel.isCollapsed || (state.status !== 'paused' && state.status !== 'stopped')) {
436
+ $tagPopup.classList.remove('visible');
437
+ return;
438
+ }
439
+
440
+ const text = sel.toString().trim();
441
+ if (!text) { $tagPopup.classList.remove('visible'); return; }
442
+
443
+ const segEl = sel.anchorNode?.parentElement?.closest?.('.segment');
444
+ if (!segEl) { $tagPopup.classList.remove('visible'); return; }
445
+
446
+ const segId = segEl.dataset.id;
447
+ const range = sel.getRangeAt(0);
448
+ const rect = range.getBoundingClientRect();
449
+
450
+ tagSelection = { segId, text };
451
+ $tagPopup.style.left = `${rect.left}px`;
452
+ $tagPopup.style.top = `${rect.bottom + 4}px`;
453
+ $tagPopup.classList.add('visible');
454
+ });
455
+
456
+ function applyTag(tagType) {
457
+ if (!tagSelection) return;
458
+ const seg = state.segments.find(s => s.id === tagSelection.segId);
459
+ if (!seg) return;
460
+
461
+ const tags = [...(seg.tags || []), { text: tagSelection.text, tag: tagType }];
462
+ sendMsg({ type: 'transcript:tag', id: tagSelection.segId, tags });
463
+
464
+ $tagPopup.classList.remove('visible');
465
+ tagSelection = null;
466
+ window.getSelection()?.removeAllRanges();
467
+ }
468
+
469
+ // Delegated click handler for tag removal (avoids inline onclick with user text)
470
+ $transcript.addEventListener('click', (e) => {
471
+ const tagEl = e.target.closest('.tag[data-seg-id]');
472
+ if (!tagEl) return;
473
+ const segId = tagEl.dataset.segId;
474
+ const tagText = tagEl.dataset.tagText;
475
+ const seg = state.segments.find(s => s.id === segId);
476
+ if (!seg) return;
477
+ const tags = (seg.tags || []).filter(t => t.text !== tagText);
478
+ sendMsg({ type: 'transcript:tag', id: segId, tags });
479
+ });
480
+
481
+ // --- MediaRecorder-based audio capture (works in background) ---
482
+ let mediaRecorder = null;
483
+ let audioStream = null;
484
+ let mediaChunkQueue = [];
485
+ let mediaRecorderActive = false;
486
+ const CHUNK_INTERVAL_MS = 3000; // 3-second chunks for near-realtime
487
+
488
+ async function initMediaRecorder() {
489
+ try {
490
+ audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
491
+ console.log('Microphone access granted for background recording');
492
+ return true;
493
+ } catch (err) {
494
+ console.warn('getUserMedia failed:', err);
495
+ return false;
496
+ }
497
+ }
498
+
499
+ function startMediaRecording() {
500
+ if (!audioStream || mediaRecorderActive) return;
501
+ try {
502
+ mediaRecorder = new MediaRecorder(audioStream, { mimeType: 'audio/webm;codecs=opus' });
503
+ } catch {
504
+ try {
505
+ mediaRecorder = new MediaRecorder(audioStream);
506
+ } catch (e) {
507
+ console.warn('MediaRecorder not supported:', e);
508
+ return;
509
+ }
510
+ }
511
+
512
+ mediaRecorderActive = true;
513
+
514
+ mediaRecorder.ondataavailable = (e) => {
515
+ if (e.data.size > 0 && ws && ws.readyState === 1) {
516
+ // Send audio chunk as binary to server for Whisper processing
517
+ e.data.arrayBuffer().then(buf => {
518
+ ws.send(buf);
519
+ });
520
+ }
521
+ };
522
+
523
+ mediaRecorder.onstop = () => {
524
+ mediaRecorderActive = false;
525
+ };
526
+
527
+ mediaRecorder.start(CHUNK_INTERVAL_MS);
528
+ }
529
+
530
+ function stopMediaRecording() {
531
+ if (mediaRecorder && mediaRecorderActive) {
532
+ try { mediaRecorder.stop(); } catch {}
533
+ mediaRecorderActive = false;
534
+ }
535
+ }
536
+
537
+ // --- Speech Recognition (Web Speech API - foreground only) ---
538
+ let recognitionSuspended = false;
539
+
540
+ function initRecognition() {
541
+ const SR = window.webkitSpeechRecognition || window.SpeechRecognition;
542
+ if (!SR) {
543
+ fetch('/api/capabilities').then(r => r.json()).then(cap => {
544
+ if (cap.hasOpenAIKey) {
545
+ sendMsg({ type: 'engine:set', engine: 'whisper' });
546
+ // Use MediaRecorder for background-capable Whisper transcription
547
+ initMediaRecorder().then(ok => {
548
+ if (ok && state.status === 'recording') startMediaRecording();
549
+ });
550
+ } else {
551
+ $warning.textContent = 'No speech recognition available. Audio will be recorded for post-hoc transcription.';
552
+ $warning.classList.add('visible');
553
+ }
554
+ }).catch(() => {});
555
+ return;
556
+ }
557
+
558
+ recognition = new SR();
559
+ recognition.continuous = true;
560
+ recognition.interimResults = true;
561
+ recognition.lang = 'en-US';
562
+
563
+ recognition.onresult = (event) => {
564
+ let interim = '';
565
+ for (let i = event.resultIndex; i < event.results.length; i++) {
566
+ const result = event.results[i];
567
+ if (result.isFinal) {
568
+ const text = result[0].transcript.trim();
569
+ if (text) {
570
+ const elapsed = state.elapsed;
571
+ sendMsg({
572
+ type: 'transcript:segment',
573
+ segment: {
574
+ text,
575
+ start: recognitionStartTime,
576
+ end: elapsed,
577
+ tags: [],
578
+ edited: false,
579
+ },
580
+ });
581
+ recognitionStartTime = elapsed;
582
+ }
583
+ } else {
584
+ interim += result[0].transcript;
585
+ }
586
+ }
587
+ if (interim) {
588
+ $interim.textContent = interim;
589
+ sendMsg({ type: 'transcript:interim', text: interim });
590
+ }
591
+ };
592
+
593
+ recognition.onend = () => {
594
+ if (state.status === 'recording' && !recognitionSuspended) {
595
+ const now = Date.now();
596
+ if (now - restartWindowStart > 30000) {
597
+ restartCount = 0;
598
+ restartWindowStart = now;
599
+ }
600
+ restartCount++;
601
+ if (restartCount > 5) {
602
+ $warning.classList.add('visible');
603
+ }
604
+ try { recognition.start(); } catch {}
605
+ }
606
+ };
607
+
608
+ recognition.onerror = (e) => {
609
+ if (e.error === 'not-allowed') {
610
+ $warning.textContent = 'Microphone permission denied. Enable it in browser settings.';
611
+ $warning.classList.add('visible');
612
+ }
613
+ };
614
+
615
+ // Also initialize MediaRecorder as background fallback
616
+ initMediaRecorder();
617
+ }
618
+
619
+ // Visibility/focus handling: Web Speech API dies in background tabs
620
+ document.addEventListener('visibilitychange', () => {
621
+ // Only manage STT when actively recording — never start anything when paused/stopped
622
+ if (state.status !== 'recording') return;
623
+
624
+ if (document.hidden) {
625
+ // Window going to background — Web Speech API will stop working
626
+ recognitionSuspended = true;
627
+ if (recognition) {
628
+ try { recognition.stop(); } catch {}
629
+ }
630
+ // Activate MediaRecorder as background fallback
631
+ if (audioStream && !mediaRecorderActive) {
632
+ sendMsg({ type: 'engine:set', engine: 'whisper' });
633
+ startMediaRecording();
634
+ }
635
+ } else {
636
+ // Window regained focus — restart Web Speech API
637
+ recognitionSuspended = false;
638
+ // Switch back to Web Speech if available
639
+ if (recognition) {
640
+ stopMediaRecording();
641
+ sendMsg({ type: 'engine:set', engine: 'webspeech' });
642
+ recognitionStartTime = state.elapsed;
643
+ try { recognition.start(); } catch {}
644
+ }
645
+ }
646
+ });
647
+
648
+ function startRecognition() {
649
+ if (!recognition) return;
650
+ recognitionStartTime = state.elapsed;
651
+ $interim.textContent = '';
652
+ recognitionSuspended = false;
653
+ try { recognition.start(); } catch {}
654
+ }
655
+
656
+ function stopRecognition() {
657
+ if (!recognition) return;
658
+ recognitionSuspended = true;
659
+ try { recognition.stop(); } catch {}
660
+ stopMediaRecording();
661
+ $interim.textContent = '';
662
+ }
663
+
664
+ function fmt(sec) {
665
+ const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
666
+ return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
667
+ }
668
+
669
+ function escHtml(s) {
670
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
671
+ }
672
+
673
+ connectWs();
674
+ startTimer();
675
+ initRecognition();
676
+ isExpanded = true;
677
+ updateUI();
678
+ setTimeout(startRecognition, 500);
679
+ </script>
680
+ </body>
681
+ </html>