piclez commited on
Commit
5145fa0
·
1 Parent(s): 1b5f7e2

feat: add push-to-talk frontend with red eye UI

Browse files
Files changed (1) hide show
  1. static/index.html +199 -0
static/index.html ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, user-scalable=no" />
6
+ <title>HAL</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html, body {
10
+ height: 100%;
11
+ background: #000;
12
+ color: #888;
13
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
14
+ overflow: hidden;
15
+ -webkit-user-select: none;
16
+ user-select: none;
17
+ -webkit-tap-highlight-color: transparent;
18
+ }
19
+ body {
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ gap: 48px;
25
+ padding: 24px;
26
+ }
27
+ .eye {
28
+ width: 320px;
29
+ height: 320px;
30
+ border-radius: 50%;
31
+ background: radial-gradient(circle at 50% 50%,
32
+ #fff2ee 0%,
33
+ #ff5a3a 10%,
34
+ #ff2a1a 30%,
35
+ #8a0a00 70%,
36
+ #1a0000 100%);
37
+ box-shadow:
38
+ 0 0 60px 10px rgba(255, 42, 26, 0.55),
39
+ 0 0 140px 40px rgba(255, 42, 26, 0.25),
40
+ inset 0 0 40px rgba(0, 0, 0, 0.6);
41
+ cursor: pointer;
42
+ transition: filter 0.3s ease, box-shadow 0.3s ease;
43
+ animation: breathe 4s ease-in-out infinite;
44
+ touch-action: none;
45
+ }
46
+ @keyframes breathe {
47
+ 0%, 100% { opacity: 0.75; transform: scale(0.98); }
48
+ 50% { opacity: 1.0; transform: scale(1.02); }
49
+ }
50
+ @keyframes breathe-fast {
51
+ 0%, 100% { opacity: 0.85; transform: scale(0.99); }
52
+ 50% { opacity: 1.0; transform: scale(1.04); }
53
+ }
54
+ .eye.listening {
55
+ animation: breathe-fast 1.2s ease-in-out infinite;
56
+ box-shadow:
57
+ 0 0 90px 20px rgba(255, 42, 26, 0.8),
58
+ 0 0 200px 60px rgba(255, 42, 26, 0.4),
59
+ inset 0 0 40px rgba(0, 0, 0, 0.6);
60
+ }
61
+ .eye.thinking {
62
+ animation: none;
63
+ filter: saturate(0.6) brightness(0.6);
64
+ box-shadow:
65
+ 0 0 30px 4px rgba(255, 42, 26, 0.3),
66
+ inset 0 0 40px rgba(0, 0, 0, 0.7);
67
+ }
68
+ .eye.speaking {
69
+ animation: breathe 2s ease-in-out infinite;
70
+ filter: brightness(1.15);
71
+ box-shadow:
72
+ 0 0 100px 24px rgba(255, 42, 26, 0.9),
73
+ 0 0 240px 70px rgba(255, 42, 26, 0.45),
74
+ inset 0 0 40px rgba(0, 0, 0, 0.6);
75
+ }
76
+ .log {
77
+ min-height: 3em;
78
+ max-width: 680px;
79
+ width: 100%;
80
+ text-align: center;
81
+ font-size: 13px;
82
+ line-height: 1.6;
83
+ color: #777;
84
+ white-space: pre-wrap;
85
+ }
86
+ .log .you { color: #888; }
87
+ .log .hal { color: #c74a3a; }
88
+ .hint {
89
+ position: fixed;
90
+ bottom: 24px;
91
+ font-size: 12px;
92
+ color: #444;
93
+ letter-spacing: 0.08em;
94
+ text-transform: uppercase;
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <div id="eye" class="eye" aria-label="Hold to speak"></div>
100
+ <div id="log" class="log"></div>
101
+ <div class="hint">Hold the eye to speak</div>
102
+
103
+ <script>
104
+ const eye = document.getElementById('eye');
105
+ const log = document.getElementById('log');
106
+ let recorder = null;
107
+ let chunks = [];
108
+ let busy = false;
109
+
110
+ function setState(state) {
111
+ eye.classList.remove('listening', 'thinking', 'speaking');
112
+ if (state !== 'idle') eye.classList.add(state);
113
+ }
114
+
115
+ function updateLog(userText, halText) {
116
+ log.innerHTML =
117
+ '<div class="you">You: ' + escapeHtml(userText || '—') + '</div>' +
118
+ '<div class="hal">HAL: ' + escapeHtml(halText || '—') + '</div>';
119
+ }
120
+
121
+ function escapeHtml(s) {
122
+ return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
123
+ }
124
+
125
+ async function startRecording() {
126
+ if (busy) return;
127
+ try {
128
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
129
+ recorder = new MediaRecorder(stream);
130
+ chunks = [];
131
+ recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
132
+ recorder.start();
133
+ setState('listening');
134
+ } catch (err) {
135
+ console.error('mic error', err);
136
+ setState('idle');
137
+ }
138
+ }
139
+
140
+ async function stopAndSend() {
141
+ if (!recorder || recorder.state !== 'recording') return;
142
+ busy = true;
143
+ await new Promise(resolve => {
144
+ recorder.onstop = resolve;
145
+ recorder.stop();
146
+ });
147
+ recorder.stream.getTracks().forEach(t => t.stop());
148
+
149
+ const mime = recorder.mimeType || 'audio/webm';
150
+ const ext = mime.includes('mp4') ? 'mp4' : mime.includes('ogg') ? 'ogg' : 'webm';
151
+ const blob = new Blob(chunks, { type: mime });
152
+ recorder = null;
153
+
154
+ const form = new FormData();
155
+ form.append('audio', blob, 'audio.' + ext);
156
+
157
+ setState('thinking');
158
+ try {
159
+ const res = await fetch('/api/talk', { method: 'POST', body: form, credentials: 'same-origin' });
160
+
161
+ if (res.status === 204) {
162
+ setState('idle');
163
+ busy = false;
164
+ return;
165
+ }
166
+ if (!res.ok) {
167
+ console.error('server error', res.status, await res.text());
168
+ setState('idle');
169
+ busy = false;
170
+ return;
171
+ }
172
+
173
+ const userText = decodeURIComponent(res.headers.get('X-User-Transcript') || '');
174
+ const halText = decodeURIComponent(res.headers.get('X-Hal-Transcript') || '');
175
+ updateLog(userText, halText);
176
+
177
+ const audioBlob = await res.blob();
178
+ const audio = new Audio(URL.createObjectURL(audioBlob));
179
+ audio.onended = () => { setState('idle'); busy = false; };
180
+ audio.onerror = () => { setState('idle'); busy = false; };
181
+ setState('speaking');
182
+ audio.play();
183
+ } catch (err) {
184
+ console.error('request error', err);
185
+ setState('idle');
186
+ busy = false;
187
+ }
188
+ }
189
+
190
+ eye.addEventListener('mousedown', e => { e.preventDefault(); startRecording(); });
191
+ eye.addEventListener('mouseup', e => { e.preventDefault(); stopAndSend(); });
192
+ eye.addEventListener('mouseleave', () => { if (recorder && recorder.state === 'recording') stopAndSend(); });
193
+ eye.addEventListener('touchstart', e => { e.preventDefault(); startRecording(); }, { passive: false });
194
+ eye.addEventListener('touchend', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
195
+ eye.addEventListener('touchcancel', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
196
+ eye.addEventListener('contextmenu', e => e.preventDefault());
197
+ </script>
198
+ </body>
199
+ </html>