-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscreen.js
More file actions
353 lines (321 loc) · 14.4 KB
/
Copy pathscreen.js
File metadata and controls
353 lines (321 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
// SlopSniffer browser agent.
//
// Runs invisibly inside SlopSmith. Watches playback via the
// window.slopsmith event bus + highway getters, and POSTs a compact
// state snapshot to the SlopSniffer backend, which assembles the
// RockSniffer JSON served on :9938 for OBS widgets.
//
// We drive state transitions off window.slopsmith events (song:play /
// song:pause / song:ended / screen:changed) rather than monkey-patching
// playSong/showScreen. Those events are emitted by SlopSmith core
// itself (static/app.js), so they're the canonical signal and avoid the
// playSong wrapper-chain race CLAUDE.md warns about. We still POLL
// highway.getTime() for the live timer and read highway.getSongInfo()
// defensively (it can be {} for a moment after song:play).
(function () {
'use strict';
// RockSniffer currentState machine (matches routes.py):
// 1 = in menus, 4 = song playing.
var STATE_MENU = 1;
var STATE_PLAYING = 4;
var INGEST_URL = '/api/plugins/slopsniffer/state';
// Local mirror of what we last sent, so we don't spam the backend
// with identical posts every tick. We post on a timer (for the
// moving songTimer) and immediately on state-change events.
var _currentState = STATE_MENU;
var _lastFilename = '';
var _lastSongInfoComplete = false;
var _pollTimer = null;
// ── Scoring state (v0.2.0) ──────────────────────────────────────
// Populated from notedetect's note:hit / note:miss events on the
// window.slopsmith bus. Per-song: reset when a new song starts so
// accuracy/streaks reflect the current play-through (matching how
// RockSniffer scores a single run), NOT a cross-song running total.
// A pause does NOT reset -- it's the same song.
//
// notedetect emits: window.slopsmith.emit(hit?'note:hit':'note:miss', judgment)
// and emit() wraps the judgment in a CustomEvent, so the judgment is
// in e.detail. We only need the hit/miss split for counting; the
// judgment's chord/score/strings fields aren't needed for these
// aggregate stats.
var _score = {
hits: 0,
misses: 0,
currentHitStreak: 0,
highestHitStreak: 0,
currentMissStreak: 0
};
// The filename whose play-through the current _score counters
// describe. Used to decide reset-vs-keep when song:play fires (a new
// song resets; a resume keeps).
var _scoredFilename = '';
function _resetScore() {
_score.hits = 0;
_score.misses = 0;
_score.currentHitStreak = 0;
_score.highestHitStreak = 0;
_score.currentMissStreak = 0;
}
function _onNoteHit() {
_score.hits += 1;
_score.currentHitStreak += 1;
_score.currentMissStreak = 0;
if (_score.currentHitStreak > _score.highestHitStreak) {
_score.highestHitStreak = _score.currentHitStreak;
}
// Don't _postNow() per note -- notes arrive far faster than the
// widgets poll (~1 Hz), and the 250ms timer already carries the
// latest score. Posting per note would be a needless flood.
}
function _onNoteMiss() {
_score.misses += 1;
_score.currentMissStreak += 1;
_score.currentHitStreak = 0;
}
// Derive the RockSniffer noteData block from the running counters.
// Accuracy is hits / total-judged * 100 (0.0 when nothing judged yet,
// so the widget shows 0.00% cleanly before the first note).
function _scoreFields() {
var total = _score.hits + _score.misses;
var accuracy = total > 0 ? (_score.hits / total) * 100.0 : 0.0;
return {
accuracy: accuracy,
totalNotes: total,
totalNotesHit: _score.hits,
totalNotesMissed: _score.misses,
currentHitStreak: _score.currentHitStreak,
highestHitStreak: _score.highestHitStreak,
currentMissStreak: _score.currentMissStreak
};
}
function _slopsmith() {
return (typeof window !== 'undefined') ? window.slopsmith : null;
}
function _highway() {
return (typeof window !== 'undefined') ? window.highway : null;
}
// Read the current song's metadata from the highway. May be {} for a
// beat after song:play (CLAUDE.md pitfall #1), so callers must treat
// an empty/title-less result as "not ready yet" and retry.
function _songInfo() {
var hw = _highway();
if (!hw || typeof hw.getSongInfo !== 'function') return {};
try {
return hw.getSongInfo() || {};
} catch (e) {
return {};
}
}
function _songTime() {
var hw = _highway();
if (!hw || typeof hw.getTime !== 'function') return 0;
try {
var t = hw.getTime();
return (typeof t === 'number' && isFinite(t)) ? t : 0;
} catch (e) {
return 0;
}
}
// Build the POST body from highway state. The backend fills in album
// art (by filename) and, in v0.2.0, scoring. We send what the
// browser already knows so the JSON matches exactly what SlopSmith
// is displaying.
function _buildSnapshot() {
var info = _songInfo();
var arrangements = Array.isArray(info.arrangements) ? info.arrangements : [];
// arrangement_index identifies the active arrangement;
// arrangements is the full switcher list.
var arrIdx = (typeof info.arrangement_index === 'number') ? info.arrangement_index : 0;
return {
currentState: _currentState,
// The filename is NOT in getSongInfo()'s live return (despite
// the docs listing it) -- so we capture it from the playSong()
// call argument instead (see _wire). We store it DECODED
// (human-readable); the backend re-encodes once for the
// /api/song/<filename>/art lookup, matching how SlopSmith's own
// frontend builds that URL.
filename: _lastFilename || '',
songName: info.title || '',
artistName: info.artist || '',
// albumName / albumYear are NOT in the song_info payload at all
// -- the backend resolves them from meta_db by filename. We
// send blanks here; the widget degrades gracefully if the
// backend can't resolve them either.
albumName: info.album || info.albumName || '',
albumYear: info.year || info.albumYear || 0,
songLength: (typeof info.duration === 'number') ? info.duration : 0,
numArrangements: arrangements.length,
arrangements: arrangements,
arrangementID: String(arrIdx),
songTimer: _songTime(),
// Scoring (v0.2.0) -- from notedetect note:hit/note:miss.
// Sent every snapshot so the moving timer post also carries
// the latest score. The backend maps these into the
// RockSniffer memoryReadout.noteData block.
score: _scoreFields()
};
}
function _post(body) {
try {
fetch(INGEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
// keepalive so a post fired during teardown (song end /
// navigation) still completes.
keepalive: true
}).catch(function () { /* backend transient -- next tick retries */ });
} catch (e) {
/* swallow -- never let telemetry break playback */
}
}
function _postNow() {
_post(_buildSnapshot());
}
// While playing, poll so the moving songTimer reaches the widgets
// (~4 Hz is plenty; the widgets themselves poll at ~900ms-1s). Also
// re-checks getSongInfo so a late-populating title (the CLAUDE.md
// race) gets sent once it lands.
function _startPolling() {
_stopPolling();
_pollTimer = setInterval(function () {
if (_currentState !== STATE_PLAYING) return;
var info = _songInfo();
// If metadata only just became available, remember the
// filename so a subsequent screen change still has it.
if (info.filename) _lastFilename = info.filename;
if (!_lastSongInfoComplete && info.title) {
_lastSongInfoComplete = true;
}
_postNow();
}, 250);
}
function _stopPolling() {
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
// ── Event handlers ──────────────────────────────────────────────
// window.slopsmith.emit wraps payloads in a CustomEvent, so handlers
// receive an Event and the payload (if any) is in e.detail. We don't
// need the payload here -- we read live state from the highway.
function _onPlay() {
_currentState = STATE_PLAYING;
var info = _songInfo();
// NOTE: filename is captured by the playSong wrapper (_wire), not
// from getSongInfo() -- the live getSongInfo() return doesn't
// include it. _lastFilename is already set by the time play fires.
_lastSongInfoComplete = !!info.title;
// Reset scoring only when this is a *different* song than the one
// we were last scoring -- song:play also fires on resume-from-pause,
// and we must not wipe the run's accuracy/streaks just because the
// user paused and resumed. _scoredFilename tracks the song the
// current counters belong to.
if (_lastFilename && _lastFilename !== _scoredFilename) {
_resetScore();
_scoredFilename = _lastFilename;
}
_postNow();
_startPolling();
}
function _onPauseOrEnd() {
// Pause and natural end both stop the moving timer. We keep
// currentState PLAYING on a mere pause so the widget keeps
// showing the song (RockSniffer does the same -- a paused song
// is still "the current song"); only a screen change back to
// menus drops us to STATE_MENU. So pause just stops polling and
// posts one final frozen-timer snapshot.
_postNow();
_stopPolling();
}
function _onScreenChanged(e) {
// Leaving the player screen => back in menus. song:ended fires
// for natural end, but the user can also navigate away mid-song;
// screen:changed is the reliable "no longer playing" signal.
var id = (e && e.detail && e.detail.id) ? e.detail.id : null;
if (id && id !== 'player') {
_currentState = STATE_MENU;
_lastFilename = '';
_lastSongInfoComplete = false;
// Clear scoring on the way back to menus so the next song
// starts from zero even if filename capture is flaky (belt &
// braces with the new-song reset in _onPlay). The idle
// snapshot then reports zeros, which is correct for "no song".
_resetScore();
_scoredFilename = '';
_stopPolling();
_postNow();
}
}
// Capture the current song's filename. getSongInfo() does NOT expose
// it in the live build, but it's the first argument to playSong() and
// it rides along on the arrangement:changed event payload. We store it
// DECODED so the backend can re-encode once for the art lookup and so
// the text outputs are human-readable.
function _captureFilename(raw) {
if (!raw) return;
var decoded = raw;
try { decoded = decodeURIComponent(raw); } catch (e) { /* already decoded */ }
_lastFilename = decoded;
}
function _wrapPlaySong() {
if (typeof window === 'undefined' || typeof window.playSong !== 'function') {
return false;
}
// Idempotency guard: never double-wrap (e.g. if _wire retries).
if (window.playSong.__slopsniffer_wrapped) return true;
var original = window.playSong;
var wrapped = function (filename /*, arrangement */) {
// Capture BEFORE calling through, so the filename is set by the
// time the song:play event (fired from inside) reaches _onPlay.
_captureFilename(filename);
// Always call and await the original so we don't break the
// wrapper chain for other plugins (notedetect, 3dhighway, ...).
return original.apply(this, arguments);
};
wrapped.__slopsniffer_wrapped = true;
window.playSong = wrapped;
return true;
}
function _onArrangementChanged(e) {
// Carries { index, filename } -- a reliable backup filename source
// (and keeps us correct if the user switches arrangements).
if (e && e.detail && e.detail.filename) {
_captureFilename(e.detail.filename);
}
}
function _wire() {
var sm = _slopsmith();
if (!sm || typeof sm.on !== 'function') {
// slopsmith bus not ready yet -- retry shortly. (Plugin load
// order: app.js defines window.slopsmith before plugins load,
// but guard anyway per CLAUDE.md's runtime-check guidance.)
setTimeout(_wire, 100);
return;
}
sm.on('song:play', _onPlay);
sm.on('song:pause', _onPauseOrEnd);
sm.on('song:ended', _onPauseOrEnd);
sm.on('screen:changed', _onScreenChanged);
sm.on('arrangement:changed', _onArrangementChanged);
// Scoring (v0.2.0): notedetect publishes per-note judgments on the
// same bus. If notedetect isn't installed, these simply never
// fire and the score stays at zeros -- graceful degradation, no
// dependency on the plugin being present.
sm.on('note:hit', _onNoteHit);
sm.on('note:miss', _onNoteMiss);
// Wrap playSong to capture the filename. If it's not defined yet,
// retry on the same cadence as the bus check.
if (!_wrapPlaySong()) {
var tries = 0;
var iv = setInterval(function () {
if (_wrapPlaySong() || ++tries > 50) clearInterval(iv);
}, 100);
}
// Post an initial idle snapshot so the backend (and any widget
// already open in OBS) reflects "in menus" immediately on load.
_postNow();
}
_wire();
})();