thecollabagepatch commited on
Commit
46f9035
·
1 Parent(s): cb691fe

adding web tester to the mix

Browse files
Files changed (4) hide show
  1. Dockerfile +2 -0
  2. app.py +10 -2
  3. documentation.html +8 -0
  4. magentaRT_rt_tester.html +628 -0
Dockerfile CHANGED
@@ -150,6 +150,8 @@ COPY --chown=appuser:appuser documentation.html /home/appuser/app/documentation.
150
 
151
  COPY --chown=appuser:appuser lil_demo_540p.mp4 /home/appuser/app/lil_demo_540p.mp4
152
 
 
 
153
  # Create docs directory and copy documentation files
154
  COPY --chown=appuser:appuser docs/ /home/appuser/app/docs/
155
 
 
150
 
151
  COPY --chown=appuser:appuser lil_demo_540p.mp4 /home/appuser/app/lil_demo_540p.mp4
152
 
153
+ COPY --chown=appuser:appuser magentaRT_rt_tester.html /home/appuser/app/magentaRT_rt_tester.html
154
+
155
  # Create docs directory and copy documentation files
156
  COPY --chown=appuser:appuser docs/ /home/appuser/app/docs/
157
 
app.py CHANGED
@@ -49,7 +49,7 @@ except Exception:
49
  from magenta_rt import system, audio as au
50
  import numpy as np
51
  from fastapi import FastAPI, UploadFile, File, Form, Body, HTTPException, Response, Request, WebSocket, WebSocketDisconnect, Query
52
- from fastapi.responses import JSONResponse, FileResponse
53
  import tempfile, io, base64, math, threading
54
  from fastapi.middleware.cors import CORSMiddleware
55
  from contextlib import contextmanager
@@ -1605,4 +1605,12 @@ def read_root():
1605
 
1606
  @app.get("/lil_demo_540p.mp4")
1607
  def demo_video():
1608
- return FileResponse(Path(__file__).parent / "lil_demo_540p.mp4", media_type="video/mp4")
 
 
 
 
 
 
 
 
 
49
  from magenta_rt import system, audio as au
50
  import numpy as np
51
  from fastapi import FastAPI, UploadFile, File, Form, Body, HTTPException, Response, Request, WebSocket, WebSocketDisconnect, Query
52
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
53
  import tempfile, io, base64, math, threading
54
  from fastapi.middleware.cors import CORSMiddleware
55
  from contextlib import contextmanager
 
1605
 
1606
  @app.get("/lil_demo_540p.mp4")
1607
  def demo_video():
1608
+ return FileResponse(Path(__file__).parent / "lil_demo_540p.mp4", media_type="video/mp4")
1609
+
1610
+ @app.get("/tester", response_class=HTMLResponse)
1611
+ def tester():
1612
+ html_path = Path(__file__).parent / "magentaRT_rt_tester.html"
1613
+ return HTMLResponse(
1614
+ html_path.read_text(encoding="utf-8"),
1615
+ headers={"Cache-Control": "no-store"} # avoid sticky caches while iterating
1616
+ )
documentation.html CHANGED
@@ -173,6 +173,14 @@
173
  </details>
174
  </section>
175
 
 
 
 
 
 
 
 
 
176
  <div class="demo-placeholder">
177
  <h3>📱 App Demo Video</h3>
178
  <video controls preload="metadata" playsinline style="width:100%; border-radius:8px; max-width:540px; display:block; margin:0 auto">
 
173
  </details>
174
  </section>
175
 
176
+ <p style="text-align:center; margin-top:12px;">
177
+ <a class="btn" href="/tester" target="_blank" style="
178
+ display:inline-block; padding:10px 14px; border-radius:8px;
179
+ background:#111; color:#eee; text-decoration:none; border:1px solid #444;">
180
+ Open Realtime Web Tester
181
+ </a>
182
+ </p>
183
+
184
  <div class="demo-placeholder">
185
  <h3>📱 App Demo Video</h3>
186
  <video controls preload="metadata" playsinline style="width:100%; border-radius:8px; max-width:540px; display:block; margin:0 auto">
magentaRT_rt_tester.html ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <title>MagentaRT Realtime Tester (rt-mode)</title>
7
+ <style>
8
+ :root { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
9
+ body { margin: 0; padding: 24px; background: #0b0b0f; color: #e6e6ea; }
10
+ h1 { font-size: 20px; margin: 0 0 12px; }
11
+ .card { background: #15151c; border: 1px solid #232334; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
12
+ label { display: block; font-size: 12px; color: #b0b0bb; margin: 8px 0 6px; }
13
+ input[type="text"], textarea, input[type="number"] { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid #2a2a3a; background: #0f0f14; color: #e6e6ea; }
14
+ textarea { min-height: 72px; resize: vertical; }
15
+ .row { display: grid; grid-template-columns: repeat(12, 1fr); gap: 12px; }
16
+ .col-6 { grid-column: span 6; }
17
+ .col-4 { grid-column: span 4; }
18
+ .col-3 { grid-column: span 3; }
19
+ .col-2 { grid-column: span 2; }
20
+ .col-12 { grid-column: span 12; }
21
+ .btn { appearance: none; padding: 10px 14px; border-radius: 10px; border: 1px solid #2a2a3a; background: #20202a; color: #fff; cursor: pointer; }
22
+ .btn:hover { background: #2a2a36; }
23
+ .btn[disabled] { opacity: 0.5; cursor: not-allowed; }
24
+ .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
25
+ .small { font-size: 12px; color: #a6a6b3; }
26
+ .range-row { display: grid; grid-template-columns: 100px 1fr 60px; gap: 10px; align-items: center; }
27
+ input[type="range"] { width: 100%; }
28
+ .log { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; background: #0f0f14; border: 1px solid #2a2a3a; padding: 10px; border-radius: 8px; min-height: 160px; white-space: pre-wrap; overflow: auto; }
29
+ .ok { color: #9fe870; }
30
+ .warn { color: #ffda6b; }
31
+ .err { color: #ff8080; }
32
+ .badge { padding: 2px 6px; border-radius: 999px; background: #2a2a3a; font-size: 12px; }
33
+ .sep { height: 1px; background: #212133; margin: 12px 0; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <h1>MagentaRT Realtime Tester <span class="badge">rt-mode</span></h1>
38
+
39
+ <div class="card">
40
+ <div class="row">
41
+ <div class="col-12"><div class="small">Model selection</div></div>
42
+ <div class="col-6">
43
+ <label>Checkpoint repo (HF)</label>
44
+ <input id="selRepo" type="text" placeholder="e.g., thepatch/magenta-ft" />
45
+ </div>
46
+ <div class="col-3">
47
+ <label>Checkpoint step</label>
48
+ <input id="selStep" type="number" min="0" step="1" placeholder="e.g., 1863001" />
49
+ </div>
50
+ <div class="col-3">
51
+ <label>Base size</label>
52
+ <input id="selSize" type="text" placeholder="large" value="large" />
53
+ </div>
54
+ <div class="col-12 controls">
55
+ <label class="small"><input id="chkBase" type="checkbox" /> Use base model (no checkpoint)</label>
56
+ <label class="small"><input id="selPrewarm" type="checkbox" checked /> Prewarm before returning</label>
57
+ <label class="small"><input id="selStopActive" type="checkbox" checked /> Stop any active jam</label>
58
+ <button id="btnSelectModel" class="btn">Select model & warm up</button>
59
+ <span id="selStatus" class="small"></span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div id="healthBanner" class="small" style="margin:8px 0 12px;"></div>
65
+
66
+ <div class="card">
67
+ <div class="row">
68
+ <div class="col-12">
69
+ <label>WebSocket URL</label>
70
+ <input id="wsUrl" type="text" value="wss://thecollabagepatch-magenta-retry.hf.space/ws/jam" />
71
+ </div>
72
+ <div class="col-12 controls">
73
+ <button id="btnStart" class="btn">Start</button>
74
+ <button id="btnStop" class="btn" disabled>Stop</button>
75
+ <button id="btnPing" class="btn">Ping</button>
76
+ <label class="small"><input id="chkBinary" type="checkbox" /> Receive binary WAV frames</label>
77
+ <label class="small"><input id="chkAutoUpdate" type="checkbox" checked /> Auto-update on slider change (150ms debounce)</label>
78
+ <label class="small"><input id="chkLogAudio" type="checkbox" /> Log chunk sizes</label>
79
+ <label class="small"><input id="chkRealtime" type="checkbox" checked /> Ask server to pace real-time</label>
80
+ <label class="small"><input id="chkBootstrap" type="checkbox" checked /> Bootstrap fast (ASAP → flip to realtime)</label>
81
+ <label class="small">Pre-roll chunks: <input id="numPreroll" type="number" min="0" max="12" step="1" value="3" style="width:60px"></label>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="card">
87
+ <div class="row">
88
+ <div class="col-6">
89
+ <div class="range-row">
90
+ <label>Temperature</label>
91
+ <input id="rngTemp" type="range" min="0.10" max="2.00" step="0.01" value="1.10" />
92
+ <input id="numTemp" type="number" min="0.10" max="2.00" step="0.01" value="1.10" />
93
+ </div>
94
+ <div class="range-row">
95
+ <label>Guidance</label>
96
+ <input id="rngGuid" type="range" min="0.0" max="8.0" step="0.1" value="1.10" />
97
+ <input id="numGuid" type="number" min="0.0" max="8.0" step="0.1" value="1.10" />
98
+ </div>
99
+ <div class="range-row">
100
+ <label>TopK</label>
101
+ <input id="rngTopk" type="range" min="1" max="256" step="1" value="40" />
102
+ <input id="numTopk" type="number" min="1" max="256" step="1" value="40" />
103
+ </div>
104
+ <div class="range-row">
105
+ <label>Volume</label>
106
+ <input id="rngVol" type="range" min="0" max="1" step="0.01" value="1" />
107
+ <input id="numVol" type="number" min="0" max="1" step="0.01" value="1" />
108
+ </div>
109
+ <div class="sep"></div>
110
+ <button id="btnUpdate" class="btn">Send Update Now</button>
111
+ </div>
112
+ <div class="col-6">
113
+ <label>Styles (comma-separated or prompt text)</label>
114
+ <textarea id="txtStyles" placeholder="e.g., acid house, techno">warmup</textarea>
115
+ <label>Style weights (comma-separated, optional)</label>
116
+ <input id="txtStyleWeights" type="text" placeholder="e.g., 1.0, 0.5" />
117
+ <label class="small"><input id="chkUseMixStyle" type="checkbox" /> Use current mix as style</label>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="card" id="finetuneControls" style="display:none;">
123
+ <div class="row">
124
+ <div class="col-12"><div class="small">In-distribution steering</div></div>
125
+
126
+ <div class="col-6">
127
+ <div class="range-row">
128
+ <label>Mean</label>
129
+ <input id="rngMean" type="range" min="0.0" max="2.0" step="0.01" value="1.00" />
130
+ <input id="numMean" type="number" min="0.0" max="2.0" step="0.01" value="1.00" />
131
+ </div>
132
+
133
+ <div class="sep"></div>
134
+
135
+ <div class="range-row">
136
+ <label>Centroid 1</label>
137
+ <input id="rngC1" type="range" min="0.0" max="2.0" step="0.01" value="0.00" />
138
+ <input id="numC1" type="number" min="0.0" max="2.0" step="0.01" value="0.00" />
139
+ </div>
140
+ <div class="range-row">
141
+ <label>Centroid 2</label>
142
+ <input id="rngC2" type="range" min="0.0" max="2.0" step="0.01" value="0.00" />
143
+ <input id="numC2" type="number" min="0.0" max="2.0" step="0.01" value="0.00" />
144
+ </div>
145
+ <div class="range-row">
146
+ <label>Centroid 3</label>
147
+ <input id="rngC3" type="range" min="0.0" max="2.0" step="0.01" value="0.00" />
148
+ <input id="numC3" type="number" min="0.0" max="2.0" step="0.01" value="0.00" />
149
+ </div>
150
+ <div class="range-row">
151
+ <label>Centroid 4</label>
152
+ <input id="rngC4" type="range" min="0.0" max="2.0" step="0.01" value="0.00" />
153
+ <input id="numC4" type="number" min="0.0" max="2.0" step="0.01" value="0.00" />
154
+ </div>
155
+ <div class="range-row">
156
+ <label>Centroid 5</label>
157
+ <input id="rngC5" type="range" min="0.0" max="2.0" step="0.01" value="0.00" />
158
+ <input id="numC5" type="number" min="0.0" max="2.0" step="0.01" value="0.00" />
159
+ </div>
160
+ </div>
161
+
162
+ <div class="col-6">
163
+ <p class="small">
164
+ These steer the style embedding toward your finetune distribution.
165
+ <br/>Mean defaults to 1.0 (on); centroids default to 0.0 (off).
166
+ <br/>Adjust live while jamming.
167
+ </p>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+
173
+ <div class="card">
174
+ <div class="row">
175
+ <div class="col-6">
176
+ <div class="small">Audio Status</div>
177
+ <div id="status">stopped</div>
178
+ </div>
179
+ <div class="col-6">
180
+ <div class="small">Queue</div>
181
+ <div id="queue">0 buffers, 0.00s scheduled</div>
182
+ </div>
183
+ <div class="col-12">
184
+ <label>Log</label>
185
+ <div id="log" class="log"></div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <script>
191
+ (() => {
192
+ const $ = (id) => document.getElementById(id);
193
+ const wsUrl = $("wsUrl");
194
+ const btnStart = $("btnStart");
195
+ const btnStop = $("btnStop");
196
+ const btnPing = $("btnPing");
197
+ const btnUpdate = $("btnUpdate");
198
+ const chkBinary = $("chkBinary");
199
+ const chkAutoUpdate = $("chkAutoUpdate");
200
+ const chkLogAudio = $("chkLogAudio");
201
+ const rngTemp = $("rngTemp"), numTemp = $("numTemp");
202
+ const rngGuid = $("rngGuid"), numGuid = $("numGuid");
203
+ const rngTopk = $("rngTopk"), numTopk = $("numTopk");
204
+ const rngVol = $("rngVol"), numVol = $("numVol");
205
+ const txtStyles = $("txtStyles");
206
+ const txtStyleWeights = $("txtStyleWeights");
207
+ const chkUseMixStyle = $("chkUseMixStyle");
208
+ const statusEl = $("status");
209
+ const queueEl = $("queue");
210
+ const logEl = $("log");
211
+
212
+ const rngMean = $("rngMean"), numMean = $("numMean");
213
+ const rngC1 = $("rngC1"), numC1 = $("numC1");
214
+ const rngC2 = $("rngC2"), numC2 = $("numC2");
215
+ const rngC3 = $("rngC3"), numC3 = $("numC3");
216
+ const rngC4 = $("rngC4"), numC4 = $("numC4");
217
+ const rngC5 = $("rngC5"), numC5 = $("numC5");
218
+
219
+ const XFADE_MS = 25; // crossfade length
220
+
221
+ let pending = []; // decoded AudioBuffers waiting to be scheduled
222
+ let playing = false; // have we started playback?
223
+ const START_CUSHION = 0.12; // already used
224
+
225
+ const fade = XFADE_MS / 1000;
226
+
227
+ function scheduleAudioBuffer(abuf) {
228
+ // Overlap-add crossfade scheduling (same as your scheduleWavBytes but taking a decoded buffer)
229
+ const src = ctx.createBufferSource();
230
+ const g = ctx.createGain();
231
+ src.buffer = abuf;
232
+ src.connect(g); g.connect(gain);
233
+
234
+ if (nextTime < ctx.currentTime + 0.05) nextTime = ctx.currentTime + START_CUSHION;
235
+ const startAt = nextTime;
236
+ const dur = abuf.duration;
237
+
238
+ // Overlap by 'fade' so there’s no dip
239
+ nextTime = startAt + Math.max(0, dur - fade);
240
+
241
+ g.gain.setValueAtTime(0.0, startAt);
242
+ g.gain.linearRampToValueAtTime(1.0, startAt + fade);
243
+ g.gain.setValueAtTime(1.0, startAt + Math.max(0, dur - fade));
244
+ g.gain.linearRampToValueAtTime(0.0, startAt + dur);
245
+
246
+ src.start(startAt);
247
+ scheduled.push({ src, when: startAt, dur });
248
+ updateQueueUI();
249
+ src.onended = () => { scheduled = scheduled.filter(s => s.src !== src); updateQueueUI(); };
250
+ }
251
+
252
+ function beginPlaybackFromPending() {
253
+ if (playing) return;
254
+ playing = true;
255
+ nextTime = ctx.currentTime + START_CUSHION;
256
+ while (pending.length) {
257
+ const abuf = pending.shift();
258
+ scheduleAudioBuffer(abuf);
259
+ }
260
+ // Flip server pacing to realtime after we’ve started, if bootstrapping was enabled
261
+ if ($("chkBootstrap").checked) {
262
+ try { ws?.send(JSON.stringify({ type: "update", pace: "realtime" })); } catch {}
263
+ }
264
+ }
265
+
266
+ // Audio chain
267
+ let AudioCtx = window.AudioContext || window.webkitAudioContext;
268
+ let ctx = null;
269
+ let gain = null;
270
+ let nextTime = 0;
271
+ let scheduled = []; // [{src, when, dur}]
272
+ let ws = null;
273
+ let connected = false;
274
+ let autoUpdateTimer = null;
275
+
276
+ function log(msg, cls) {
277
+ const line = document.createElement("div");
278
+ line.textContent = msg;
279
+ if (cls) line.className = cls;
280
+ logEl.appendChild(line);
281
+ logEl.scrollTop = logEl.scrollHeight;
282
+ }
283
+
284
+ function setStatus(txt) {
285
+ statusEl.textContent = txt;
286
+ }
287
+
288
+ function updateQueueUI() {
289
+ const total = scheduled.reduce((acc, s) => acc + s.dur, 0);
290
+ queueEl.textContent = `${scheduled.length} buffers, ${total.toFixed(2)}s scheduled`;
291
+ }
292
+
293
+ function clearSchedule() {
294
+ scheduled.forEach(s => { try { s.src.stop(); } catch(e){} });
295
+ scheduled = [];
296
+ pending = [];
297
+ playing = false;
298
+ nextTime = 0;
299
+ updateQueueUI();
300
+ }
301
+
302
+ function base64ToArrayBuffer(b64) {
303
+ const bin = atob(b64);
304
+ const len = bin.length;
305
+ const buf = new ArrayBuffer(len);
306
+ const view = new Uint8Array(buf);
307
+ for (let i = 0; i < len; i++) view[i] = bin.charCodeAt(i);
308
+ return buf;
309
+ }
310
+
311
+
312
+ async function scheduleWavBytes(arrayBuffer) {
313
+ if (!ctx) return;
314
+ try {
315
+ const abuf = await ctx.decodeAudioData(arrayBuffer);
316
+ if (!abuf) return;
317
+
318
+ const need = parseInt($("numPreroll").value || "0", 10);
319
+ if (!playing) {
320
+ pending.push(abuf);
321
+ queueEl.textContent = `${pending.length} pending, 0.00s scheduled`;
322
+ // start once we hit the threshold
323
+ if (pending.length >= need) beginPlaybackFromPending();
324
+ return;
325
+ }
326
+
327
+ // already playing → schedule immediately
328
+ scheduleAudioBuffer(abuf);
329
+ } catch (e) {
330
+ log("decode error: " + e.message, "err");
331
+ }
332
+ }
333
+
334
+ function currentParams() {
335
+ return {
336
+ temperature: parseFloat(numTemp.value),
337
+ topk: parseInt(numTopk.value, 10),
338
+ guidance_weight: parseFloat(numGuid.value),
339
+ styles: txtStyles.value,
340
+ style_weights: txtStyleWeights.value,
341
+ use_current_mix_as_style: !!chkUseMixStyle.checked,
342
+
343
+ // NEW:
344
+ mean: parseFloat(numMean.value),
345
+ centroid_weights: centroidWeightsCSV(),
346
+ };
347
+ }
348
+
349
+ function sendUpdate() {
350
+ if (!ws || ws.readyState !== 1) return;
351
+ const msg = { type: "update", ...currentParams() };
352
+ ws.send(JSON.stringify(msg));
353
+ log("→ update " + JSON.stringify(msg), "small");
354
+ }
355
+
356
+ function debouncedUpdate() {
357
+ if (!chkAutoUpdate.checked) return;
358
+ if (autoUpdateTimer) clearTimeout(autoUpdateTimer);
359
+ autoUpdateTimer = setTimeout(sendUpdate, 150);
360
+ }
361
+
362
+ function linkRangeNumber(range, number, cb) {
363
+ const sync = (fromRange) => {
364
+ if (fromRange) number.value = range.value;
365
+ else range.value = number.value;
366
+ cb?.();
367
+ };
368
+ range.addEventListener("input", () => { sync(true); debouncedUpdate(); });
369
+ number.addEventListener("input", () => { sync(false); debouncedUpdate(); });
370
+ sync(true);
371
+ }
372
+
373
+ function centroidWeightsCSV() {
374
+ const vals = [numC1, numC2, numC3, numC4, numC5].map(n => {
375
+ const v = parseFloat(n.value);
376
+ return Number.isFinite(v) ? v : 0;
377
+ });
378
+ // Trim trailing zeros to avoid sending long tails of 0.00s
379
+ let end = vals.length;
380
+ while (end > 0 && Math.abs(vals[end-1]) < 1e-9) end--;
381
+ return vals.slice(0, end).join(",");
382
+ }
383
+
384
+ // Wire sliders
385
+ linkRangeNumber(rngTemp, numTemp);
386
+ linkRangeNumber(rngGuid, numGuid);
387
+ linkRangeNumber(rngTopk, numTopk);
388
+ linkRangeNumber(rngVol, numVol, () => { if (gain) gain.gain.value = parseFloat(numVol.value); });
389
+
390
+ linkRangeNumber(rngMean, numMean);
391
+ linkRangeNumber(rngC1, numC1);
392
+ linkRangeNumber(rngC2, numC2);
393
+ linkRangeNumber(rngC3, numC3);
394
+ linkRangeNumber(rngC4, numC4);
395
+ linkRangeNumber(rngC5, numC5);
396
+
397
+ async function start() {
398
+ if (connected) return;
399
+ if (!AudioCtx) { alert("Web Audio API not supported"); return; }
400
+ pending = [];
401
+ playing = false;
402
+ ctx = ctx || new AudioCtx();
403
+ await ctx.resume();
404
+ gain = ctx.createGain();
405
+ gain.gain.value = parseFloat(numVol.value);
406
+ gain.connect(ctx.destination);
407
+ clearSchedule();
408
+
409
+ ws = new WebSocket(wsUrl.value);
410
+ ws.binaryType = chkBinary.checked ? "arraybuffer" : "blob"; // arraybuffer for raw, blob for JSON string frames
411
+ setStatus("connecting...");
412
+ log("connecting " + wsUrl.value);
413
+
414
+ ws.onopen = () => {
415
+ connected = true;
416
+ btnStart.disabled = true;
417
+ btnStop.disabled = false;
418
+ setStatus("connected");
419
+
420
+ const msg = {
421
+ type: "start",
422
+ mode: "rt",
423
+ binary_audio: !!chkBinary.checked,
424
+ params: {
425
+ ...currentParams(),
426
+ pace: $("chkBootstrap").checked
427
+ ? "asap" // build pre-roll as fast as possible, then we’ll flip
428
+ : ($("chkRealtime").checked ? "realtime" : "asap")
429
+ }
430
+ };
431
+ ws.send(JSON.stringify(msg));
432
+ log("→ start " + JSON.stringify(msg), "ok");
433
+ nextTime = ctx.currentTime + 0.12;
434
+ };
435
+
436
+ ws.onmessage = async (ev) => {
437
+ try {
438
+ if (typeof ev.data === "string") {
439
+ // JSON (e.g., chunk with base64, status, errors)
440
+ const msg = JSON.parse(ev.data);
441
+ if (msg.type === "chunk" && msg.audio_base64) {
442
+ const buf = base64ToArrayBuffer(msg.audio_base64);
443
+ if (chkLogAudio.checked) log(`chunk (b64) ${buf.byteLength} bytes`, "small");
444
+ scheduleWavBytes(buf);
445
+ } else if (msg.type === "chunk_meta") {
446
+ // meta for previous binary frame
447
+ } else if (msg.type === "status") {
448
+ log("status: " + JSON.stringify(msg), "small");
449
+ } else if (msg.type === "started") {
450
+ log("started: " + JSON.stringify(msg), "ok");
451
+ } else if (msg.type === "error") {
452
+ log("error: " + msg.error, "err");
453
+ } else {
454
+ log("msg: " + JSON.stringify(msg), "small");
455
+ }
456
+ } else if (ev.data instanceof Blob) {
457
+ // Could be binary WAV frame if server sent bytes, or a JSON blob
458
+ const ab = await ev.data.arrayBuffer();
459
+ // Try to sniff if this is JSON (starts with '{')
460
+ if (ab.byteLength > 0 && new Uint8Array(ab, 0, 1)[0] === 0x7b) {
461
+ const txt = new TextDecoder().decode(ab);
462
+ const msg = JSON.parse(txt);
463
+ if (msg.type === "chunk_meta") {
464
+ // ignore for now
465
+ } else {
466
+ log("blob-json: " + txt, "small");
467
+ }
468
+ } else {
469
+ if (chkLogAudio.checked) log(`chunk (bin) ${ab.byteLength} bytes`, "small");
470
+ scheduleWavBytes(ab);
471
+ }
472
+ } else if (ev.data instanceof ArrayBuffer) {
473
+ const ab = ev.data;
474
+ if (chkLogAudio.checked) log(`chunk (ab) ${ab.byteLength} bytes`, "small");
475
+ scheduleWavBytes(ab);
476
+ }
477
+ } catch (e) {
478
+ log("onmessage error: " + e.message, "err");
479
+ }
480
+ };
481
+
482
+ ws.onclose = () => {
483
+ connected = false;
484
+ btnStart.disabled = false;
485
+ btnStop.disabled = true;
486
+ setStatus("closed");
487
+ log("connection closed", "warn");
488
+ };
489
+
490
+ ws.onerror = (e) => {
491
+ log("ws error", "err");
492
+ };
493
+ }
494
+
495
+ function stop() {
496
+ if (!connected) return;
497
+ try {
498
+ ws?.send(JSON.stringify({ type: "stop" }));
499
+ } catch {}
500
+ ws?.close();
501
+ connected = false;
502
+ btnStart.disabled = false;
503
+ btnStop.disabled = true;
504
+ setStatus("stopped");
505
+ clearSchedule();
506
+ log("stopped", "warn");
507
+ }
508
+
509
+ btnStart.addEventListener("click", start);
510
+ btnStop.addEventListener("click", stop);
511
+ btnPing.addEventListener("click", () => { try { ws?.send(JSON.stringify({ type: "ping"})); } catch {} });
512
+ btnUpdate.addEventListener("click", sendUpdate);
513
+ // --- Health check & UI gating ---
514
+ const banner = document.getElementById("healthBanner");
515
+ async function checkHealthAndGate() {
516
+ try {
517
+ const r = await fetch("/health", {cache: "no-store"});
518
+ const data = await r.json().catch(() => ({}));
519
+ if (!r.ok || data.ok === false) {
520
+ const msg = data?.message || "Service unavailable";
521
+ banner.innerHTML = `<div style="padding:10px;border-radius:8px;background:#3a1010;border:1px solid #712020">
522
+ <strong>Not ready:</strong> ${msg}<br><small>Status: ${data?.status || r.status}</small>
523
+ </div>`;
524
+ // disable controls
525
+ ["btnStart","btnSelectModel"].forEach(id => { const el = $(id); if (el) el.disabled = true; });
526
+ } else {
527
+ banner.innerHTML = `<div style="padding:10px;border-radius:8px;background:#0f2d17;border:1px solid #1f6a37">
528
+ <strong>Ready</strong> — mode: ${data.mode}, warmed: ${data.warmed}
529
+ </div>`;
530
+ ["btnStart","btnSelectModel"].forEach(id => { const el = $(id); if (el) el.disabled = false; });
531
+ }
532
+ } catch (e) {
533
+ banner.innerHTML = `<div style="padding:10px;border-radius:8px;background:#3a2a10;border:1px solid #715820">
534
+ Health check failed.
535
+ </div>`;
536
+ }
537
+ }
538
+
539
+ // --- Model/asset helpers (with base toggle) ---
540
+ const selRepo = $("selRepo");
541
+ const selStep = $("selStep");
542
+ const selSize = $("selSize");
543
+ const selPrewarm = $("selPrewarm");
544
+ const selStopActive = $("selStopActive");
545
+ const btnSelectModel = $("btnSelectModel");
546
+ const selStatus = $("selStatus");
547
+ const chkBase = $("chkBase");
548
+
549
+ function updateBaseToggleUI() {
550
+ const base = !!(chkBase && chkBase.checked);
551
+ if (selRepo) { selRepo.disabled = base; selRepo.parentElement.style.opacity = base ? 0.5 : 1; }
552
+ if (selStep) { selStep.disabled = base; selStep.parentElement.style.opacity = base ? 0.5 : 1; }
553
+ }
554
+ if (chkBase) {
555
+ chkBase.addEventListener("change", updateBaseToggleUI);
556
+ updateBaseToggleUI();
557
+ }
558
+
559
+ // Default ws URL to current origin if empty/hardcoded
560
+ try {
561
+ const defWs = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws/jam";
562
+ if (!wsUrl.value || /hf\.space/.test(wsUrl.value)) wsUrl.value = defWs;
563
+ } catch {}
564
+
565
+ async function refreshFinetuneControls() {
566
+ try {
567
+ const r = await fetch("/model/config", { cache: "no-store" });
568
+ if (!r.ok) throw new Error("config status " + r.status);
569
+ const cfg = await r.json();
570
+ // Use in-memory flags from backend
571
+ const show = !!(cfg.mean_loaded || cfg.centroids_loaded);
572
+ const el = document.getElementById("finetuneControls");
573
+ if (el) el.style.display = show ? "block" : "none";
574
+ if (!show) log("finetune assets not detected — hiding steering controls", "small");
575
+ } catch (e) {
576
+ const el = document.getElementById("finetuneControls");
577
+ if (el) el.style.display = "none";
578
+ log("config fetch failed: " + e.message, "warn");
579
+ }
580
+ }
581
+
582
+ async function selectModel() {
583
+ selStatus.textContent = "selecting...";
584
+ try {
585
+ const useBase = !!(chkBase && chkBase.checked);
586
+ const payload = {
587
+ size: (selSize?.value || undefined),
588
+ prewarm: !!(selPrewarm && selPrewarm.checked),
589
+ stop_active: !!(selStopActive && selStopActive.checked)
590
+ };
591
+ if (useBase) {
592
+ // Signal stock model: step="none" as per backend's selector
593
+ payload["step"] = "none";
594
+ } else {
595
+ if (selRepo?.value) payload["repo_id"] = selRepo.value;
596
+ if (selStep?.value) payload["step"] = parseInt(selStep.value, 10);
597
+ }
598
+ const resp = await fetch("/model/select", {
599
+ method: "POST",
600
+ headers: { "Content-Type": "application/json" },
601
+ body: JSON.stringify(payload)
602
+ });
603
+ const data = await resp.json().catch(() => ({}));
604
+ if (!resp.ok || data.ok === false) {
605
+ const msg = data?.error || (resp.status + " " + resp.statusText);
606
+ selStatus.innerHTML = '<span class="err">error: ' + msg + '</span>';
607
+ log("model/select failed: " + msg, "err");
608
+ return;
609
+ }
610
+ selStatus.innerHTML = '<span class="ok">selected' + (data.warmup_done ? " + warmed" : "") + "</span>";
611
+ log("model/select ok: " + JSON.stringify(data), "ok");
612
+ await refreshFinetuneControls();
613
+ } catch (e) {
614
+ selStatus.innerHTML = '<span class="err">error: ' + e.message + "</span>";
615
+ log("model/select exception: " + e.message, "err");
616
+ }
617
+ }
618
+
619
+ if (btnSelectModel) btnSelectModel.addEventListener("click", selectModel);
620
+
621
+ // Run once on load
622
+ checkHealthAndGate();
623
+ refreshFinetuneControls();
624
+
625
+ })();
626
+ </script>
627
+ </body>
628
+ </html>