jorisvaneyghen commited on
Commit
7c8abd3
·
1 Parent(s): a102371

move styles/js to separate files; icons & manifest

Browse files
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
- short_description: Upload a song, detect beats and bars, then loop bars
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
@@ -16,4 +16,10 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
16
  ```
17
  python app.py
18
  ```
19
- open in browser: http://localhost:5000/
 
 
 
 
 
 
 
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
+ short_description: Upload a song, detect beats and bars, then loop any part
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
16
  ```
17
  python app.py
18
  ```
19
+ open in browser: http://localhost:5000/
20
+
21
+ ## Todo's
22
+ - Use wasm for logits to bars
23
+ - Upbeat (0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5)
24
+ - Countdown option
25
+ - About info popup
apple-touch-icon.png ADDED
css/styles.css ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Chrome, Safari, Edge, Opera */
2
+ input::-webkit-outer-spin-button,
3
+ input::-webkit-inner-spin-button {
4
+ -webkit-appearance: none;
5
+ margin: 0;
6
+ }
7
+
8
+ /* Firefox */
9
+ input[type=number] {
10
+ -moz-appearance: textfield;
11
+ }
12
+
13
+ * {
14
+ margin: 0;
15
+ padding: 0;
16
+ box-sizing: border-box;
17
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
18
+ }
19
+
20
+ body {
21
+ background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
22
+ color: white;
23
+ min-height: 100vh;
24
+ padding: 20px;
25
+ }
26
+
27
+ .container {
28
+ max-width: 1200px;
29
+ margin: 0 auto;
30
+ }
31
+
32
+ header {
33
+ text-align: center;
34
+ margin-bottom: 30px;
35
+ padding: 20px;
36
+ background: rgba(0, 0, 0, 0.3);
37
+ border-radius: 15px;
38
+ backdrop-filter: blur(10px);
39
+ }
40
+
41
+ h1 {
42
+ font-size: 2.5rem;
43
+ margin-bottom: 10px;
44
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
45
+ }
46
+
47
+ .subtitle {
48
+ font-size: 1.2rem;
49
+ opacity: 0.9;
50
+ }
51
+
52
+ .app-grid {
53
+ display: grid;
54
+ grid-template-columns: 1fr 1fr;
55
+ gap: 20px;
56
+ }
57
+
58
+ @media (max-width: 900px) {
59
+ .app-grid {
60
+ grid-template-columns: 1fr;
61
+ }
62
+ }
63
+
64
+ .card {
65
+ background: rgba(255, 255, 255, 0.1);
66
+ border-radius: 15px;
67
+ padding: 20px;
68
+ backdrop-filter: blur(10px);
69
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
70
+ border: 1px solid rgba(255, 255, 255, 0.1);
71
+ }
72
+
73
+ .card h2 {
74
+ margin-bottom: 15px;
75
+ padding-bottom: 10px;
76
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
77
+ }
78
+
79
+ .upload-area {
80
+ border: 2px dashed rgba(255, 255, 255, 0.3);
81
+ border-radius: 10px;
82
+ padding: 40px 20px;
83
+ text-align: center;
84
+ cursor: pointer;
85
+ transition: all 0.3s ease;
86
+ margin-bottom: 20px;
87
+ }
88
+
89
+ .upload-area:hover, .upload-area.dragover {
90
+ background: rgba(255, 255, 255, 0.1);
91
+ border-color: rgba(255, 255, 255, 0.5);
92
+ }
93
+
94
+ .upload-area.disabled {
95
+ opacity: 0.5;
96
+ cursor: not-allowed;
97
+ pointer-events: none;
98
+ }
99
+
100
+ .upload-icon {
101
+ font-size: 48px;
102
+ margin-bottom: 10px;
103
+ }
104
+
105
+ .file-input {
106
+ display: none;
107
+ }
108
+
109
+ .loading {
110
+ margin-top: 20px;
111
+ }
112
+
113
+ .progress-bar {
114
+ height: 20px;
115
+ background: rgba(255, 255, 255, 0.1);
116
+ border-radius: 10px;
117
+ overflow: hidden;
118
+ margin-bottom: 10px;
119
+ }
120
+
121
+ .progress-fill {
122
+ height: 100%;
123
+ width: 0%;
124
+ background: linear-gradient(90deg, #4CAF50, #45a049);
125
+ border-radius: 10px;
126
+ transition: width 0.3s ease;
127
+ }
128
+
129
+ .progress-text {
130
+ display: flex;
131
+ justify-content: space-between;
132
+ font-size: 0.9rem;
133
+ }
134
+
135
+ .cancel-button {
136
+ background: #f44336;
137
+ color: white;
138
+ border: none;
139
+ padding: 8px 15px;
140
+ border-radius: 5px;
141
+ cursor: pointer;
142
+ margin-top: 10px;
143
+ transition: background 0.3s;
144
+ }
145
+
146
+ .cancel-button:hover {
147
+ background: #d32f2f;
148
+ }
149
+
150
+ .server-status {
151
+ padding: 10px;
152
+ border-radius: 5px;
153
+ margin-bottom: 15px;
154
+ text-align: center;
155
+ font-weight: bold;
156
+ }
157
+
158
+ .server-online {
159
+ background: rgba(76, 175, 80, 0.3);
160
+ border: 1px solid rgba(76, 175, 80, 0.5);
161
+ }
162
+
163
+ .server-offline {
164
+ background: rgba(255, 152, 0, 0.3);
165
+ border: 1px solid rgba(255, 152, 0, 0.5);
166
+ }
167
+
168
+ .form-group {
169
+ margin-bottom: 15px;
170
+ }
171
+
172
+ label {
173
+ display: block;
174
+ margin-bottom: 5px;
175
+ font-weight: bold;
176
+ }
177
+
178
+ input[type="number"] {
179
+ width: 100%;
180
+ padding: 10px;
181
+ border-radius: 5px;
182
+ border: 1px solid rgba(255, 255, 255, 0.2);
183
+ background: rgba(255, 255, 255, 0.1);
184
+ color: white;
185
+ }
186
+
187
+ input[type="checkbox"] {
188
+ margin-right: 8px;
189
+ }
190
+
191
+ .bar-selection {
192
+ display: grid;
193
+ grid-template-columns: 1fr 1fr;
194
+ gap: 15px;
195
+ margin-bottom: 15px;
196
+ }
197
+
198
+ .bar-selection .form-group {
199
+ margin-bottom: 0;
200
+ }
201
+
202
+ #player-container {
203
+ height: auto;
204
+ }
205
+
206
+ .bar-selection input {
207
+ flex: 1;
208
+ }
209
+
210
+ .results {
211
+ display: none;
212
+ margin-top: 20px;
213
+ }
214
+
215
+ .results-grid {
216
+ display: grid;
217
+ grid-template-columns: 1fr 1fr;
218
+ gap: 15px;
219
+ }
220
+
221
+ .result-item {
222
+ background: rgba(0, 0, 0, 0.2);
223
+ padding: 10px;
224
+ border-radius: 5px;
225
+ }
226
+
227
+ .result-label {
228
+ font-size: 0.9rem;
229
+ opacity: 0.8;
230
+ }
231
+
232
+ .result-value {
233
+ font-size: 1.1rem;
234
+ font-weight: bold;
235
+ }
236
+
237
+ .bars-list {
238
+ max-height: 200px;
239
+ overflow-y: auto;
240
+ margin-top: 10px;
241
+ background: rgba(0, 0, 0, 0.2);
242
+ border-radius: 5px;
243
+ padding: 10px;
244
+ }
245
+
246
+ .bar-item {
247
+ display: flex;
248
+ justify-content: space-between;
249
+ padding: 5px 0;
250
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
251
+ }
252
+
253
+ .bar-item:last-child {
254
+ border-bottom: none;
255
+ }
256
+
257
+
258
+ .section-player {
259
+ margin-top: 20px;
260
+ padding-top: 15px;
261
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
262
+ }
263
+
264
+ .action-button {
265
+ background: #2196F3;
266
+ color: white;
267
+ border: none;
268
+ padding: 10px 20px;
269
+ border-radius: 5px;
270
+ cursor: pointer;
271
+ font-weight: bold;
272
+ transition: background 0.3s;
273
+ margin-top: 10px;
274
+ }
275
+
276
+ .action-button:hover {
277
+ background: #1976D2;
278
+ }
279
+
280
+ .action-button:disabled {
281
+ background: rgba(255, 255, 255, 0.2);
282
+ cursor: not-allowed;
283
+ }
284
+
285
+ .init-progress {
286
+ margin-top: 20px;
287
+ padding: 15px;
288
+ background: rgba(0, 0, 0, 0.2);
289
+ border-radius: 10px;
290
+ }
291
+
292
+ .init-complete {
293
+ background: rgba(76, 175, 80, 0.2);
294
+ border: 1px solid rgba(76, 175, 80, 0.5);
295
+ padding: 10px;
296
+ border-radius: 5px;
297
+ text-align: center;
298
+ margin-top: 15px;
299
+ display: none;
300
+ }
301
+
302
+ /*Styles AudioStretchPlayer*/
303
+ .audio-stretch-player {
304
+ width: 100%;
305
+ height: 100%;
306
+ border-radius: 10px;
307
+ padding: 15px;
308
+ backdrop-filter: blur(10px);
309
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
310
+ border: 1px solid rgba(255, 255, 255, 0.1);
311
+ color: white;
312
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
313
+ display: flex;
314
+ flex-direction: column;
315
+ box-sizing: border-box;
316
+ overflow: visible;
317
+ }
318
+
319
+ .player-header {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 12px;
323
+ margin-bottom: 15px;
324
+ padding-bottom: 12px;
325
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
326
+ flex-shrink: 0;
327
+ }
328
+
329
+ .play-stop-btn {
330
+ background: #2196F3;
331
+ color: white;
332
+ border: none;
333
+ border-radius: 50%;
334
+ width: 40px;
335
+ height: 40px;
336
+ cursor: pointer;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ transition: background 0.3s;
341
+ font-size: 16px;
342
+ flex-shrink: 0;
343
+ }
344
+
345
+ .play-stop-btn:hover {
346
+ background: #1976D2;
347
+ }
348
+
349
+ .playback-slider-container {
350
+ flex: 1;
351
+ display: flex;
352
+ align-items: center;
353
+ gap: 8px;
354
+ min-width: 0;
355
+ }
356
+
357
+ .playback-slider {
358
+ flex: 1;
359
+ height: 4px;
360
+ border-radius: 2px;
361
+ background: rgba(255, 255, 255, 0.2);
362
+ outline: none;
363
+ -webkit-appearance: none;
364
+ min-width: 0;
365
+ }
366
+
367
+ .playback-slider::-webkit-slider-thumb {
368
+ -webkit-appearance: none;
369
+ width: 14px;
370
+ height: 14px;
371
+ border-radius: 50%;
372
+ background: #2196F3;
373
+ cursor: pointer;
374
+ border: 2px solid white;
375
+ }
376
+
377
+ .playback-slider::-moz-range-thumb {
378
+ width: 14px;
379
+ height: 14px;
380
+ border-radius: 50%;
381
+ background: #2196F3;
382
+ cursor: pointer;
383
+ border: 2px solid white;
384
+ }
385
+
386
+ .time-display {
387
+ font-size: 0.8rem;
388
+ color: rgba(255, 255, 255, 0.8);
389
+ min-width: 45px;
390
+ text-align: center;
391
+ flex-shrink: 0;
392
+ }
393
+
394
+
395
+ .controls-panel {
396
+ flex: 1;
397
+ display: flex;
398
+ flex-direction: column; /* <-- stack rows vertically */
399
+ align-items: flex-start;
400
+ gap: 12px; /* spacing between rows */
401
+ width: 100%;
402
+ }
403
+
404
+ .control-row {
405
+ display: flex;
406
+ flex-direction: column; /* Label on line 1, slider+number on line 2 */
407
+ gap: 6px;
408
+ width: 100%;
409
+ }
410
+
411
+ .control-label {
412
+ font-size: 0.8rem;
413
+ color: rgba(255, 255, 255, 0.8);
414
+ }
415
+
416
+ .control-input-line {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 10px;
420
+ width: 100%;
421
+ }
422
+
423
+ .control-slider {
424
+ flex: 1;
425
+ height: 4px;
426
+ border-radius: 2px;
427
+ background: rgba(255, 255, 255, 0.2);
428
+ outline: none;
429
+ -webkit-appearance: none;
430
+ min-width: 0;
431
+ }
432
+
433
+ .control-input {
434
+ min-width: 70px;
435
+ max-width: 70px;
436
+ flex-shrink: 0;
437
+ }
438
+
439
+ .control-slider.blue::-webkit-slider-thumb {
440
+ background: #2196F3;
441
+ }
442
+
443
+ .control-slider.red::-webkit-slider-thumb {
444
+ background: #FF9800;
445
+ }
446
+
447
+ .control-slider.blue::-moz-range-thumb {
448
+ background: #2196F3;
449
+ }
450
+
451
+ .control-slider.red::-moz-range-thumb {
452
+ background: #FF9800;
453
+ }
454
+
455
+ .control-input:focus {
456
+ outline: none;
457
+ border-color: #2196F3;
458
+ }
459
+
460
+
461
+ .hidden {
462
+ display: none !important;
463
+ }
464
+
465
+ .file-input {
466
+ display: none;
467
+ }
favicon-96x96.png ADDED
favicon.ico ADDED
favicon.svg ADDED
icon512_rounded.png ADDED
index.html CHANGED
@@ -4,348 +4,24 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Loop Maestro</title>
 
 
 
 
 
 
7
  <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
8
- <style>
9
-
10
- /* Chrome, Safari, Edge, Opera */
11
- input::-webkit-outer-spin-button,
12
- input::-webkit-inner-spin-button {
13
- -webkit-appearance: none;
14
- margin: 0;
15
- }
16
-
17
- /* Firefox */
18
- input[type=number] {
19
- -moz-appearance: textfield;
20
- }
21
-
22
- * {
23
- margin: 0;
24
- padding: 0;
25
- box-sizing: border-box;
26
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
27
- }
28
-
29
- body {
30
- background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
31
- color: white;
32
- min-height: 100vh;
33
- padding: 20px;
34
- }
35
-
36
- .container {
37
- max-width: 1200px;
38
- margin: 0 auto;
39
- }
40
-
41
- header {
42
- text-align: center;
43
- margin-bottom: 30px;
44
- padding: 20px;
45
- background: rgba(0, 0, 0, 0.3);
46
- border-radius: 15px;
47
- backdrop-filter: blur(10px);
48
- }
49
-
50
- h1 {
51
- font-size: 2.5rem;
52
- margin-bottom: 10px;
53
- text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
54
- }
55
-
56
- .subtitle {
57
- font-size: 1.2rem;
58
- opacity: 0.9;
59
- }
60
-
61
- .app-grid {
62
- display: grid;
63
- grid-template-columns: 1fr 1fr;
64
- gap: 20px;
65
- }
66
-
67
- @media (max-width: 900px) {
68
- .app-grid {
69
- grid-template-columns: 1fr;
70
- }
71
- }
72
-
73
- .card {
74
- background: rgba(255, 255, 255, 0.1);
75
- border-radius: 15px;
76
- padding: 20px;
77
- backdrop-filter: blur(10px);
78
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
79
- border: 1px solid rgba(255, 255, 255, 0.1);
80
- }
81
-
82
- .card h2 {
83
- margin-bottom: 15px;
84
- padding-bottom: 10px;
85
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
86
- }
87
-
88
- .upload-area {
89
- border: 2px dashed rgba(255, 255, 255, 0.3);
90
- border-radius: 10px;
91
- padding: 40px 20px;
92
- text-align: center;
93
- cursor: pointer;
94
- transition: all 0.3s ease;
95
- margin-bottom: 20px;
96
- }
97
-
98
- .upload-area:hover, .upload-area.dragover {
99
- background: rgba(255, 255, 255, 0.1);
100
- border-color: rgba(255, 255, 255, 0.5);
101
- }
102
-
103
- .upload-area.disabled {
104
- opacity: 0.5;
105
- cursor: not-allowed;
106
- pointer-events: none;
107
- }
108
-
109
- .upload-icon {
110
- font-size: 48px;
111
- margin-bottom: 10px;
112
- }
113
-
114
- .file-input {
115
- display: none;
116
- }
117
-
118
- .loading {
119
- margin-top: 20px;
120
- }
121
-
122
- .progress-bar {
123
- height: 20px;
124
- background: rgba(255, 255, 255, 0.1);
125
- border-radius: 10px;
126
- overflow: hidden;
127
- margin-bottom: 10px;
128
- }
129
-
130
- .progress-fill {
131
- height: 100%;
132
- width: 0%;
133
- background: linear-gradient(90deg, #4CAF50, #45a049);
134
- border-radius: 10px;
135
- transition: width 0.3s ease;
136
- }
137
-
138
- .progress-text {
139
- display: flex;
140
- justify-content: space-between;
141
- font-size: 0.9rem;
142
- }
143
-
144
- .cancel-button {
145
- background: #f44336;
146
- color: white;
147
- border: none;
148
- padding: 8px 15px;
149
- border-radius: 5px;
150
- cursor: pointer;
151
- margin-top: 10px;
152
- transition: background 0.3s;
153
- }
154
-
155
- .cancel-button:hover {
156
- background: #d32f2f;
157
- }
158
-
159
- .server-status {
160
- padding: 10px;
161
- border-radius: 5px;
162
- margin-bottom: 15px;
163
- text-align: center;
164
- font-weight: bold;
165
- }
166
-
167
- .server-online {
168
- background: rgba(76, 175, 80, 0.3);
169
- border: 1px solid rgba(76, 175, 80, 0.5);
170
- }
171
-
172
- .server-offline {
173
- background: rgba(255, 152, 0, 0.3);
174
- border: 1px solid rgba(255, 152, 0, 0.5);
175
- }
176
-
177
- .controls {
178
- display: flex;
179
- gap: 10px;
180
- margin-top: 15px;
181
- }
182
-
183
- .form-group {
184
- margin-bottom: 15px;
185
- }
186
-
187
- label {
188
- display: block;
189
- margin-bottom: 5px;
190
- font-weight: bold;
191
- }
192
-
193
- input[type="number"] {
194
- width: 100%;
195
- padding: 10px;
196
- border-radius: 5px;
197
- border: 1px solid rgba(255, 255, 255, 0.2);
198
- background: rgba(255, 255, 255, 0.1);
199
- color: white;
200
- }
201
-
202
- input[type="checkbox"] {
203
- margin-right: 8px;
204
- }
205
-
206
- .bar-selection {
207
- display: grid;
208
- grid-template-columns: 1fr 1fr;
209
- gap: 15px;
210
- margin-bottom: 15px;
211
- }
212
-
213
- .bar-selection .form-group {
214
- margin-bottom: 0;
215
- }
216
-
217
- #player-container {
218
- height: auto;
219
- }
220
-
221
- .bar-selection input {
222
- flex: 1;
223
- }
224
-
225
- .results {
226
- display: none;
227
- margin-top: 20px;
228
- }
229
-
230
- .results-grid {
231
- display: grid;
232
- grid-template-columns: 1fr 1fr;
233
- gap: 15px;
234
- }
235
-
236
- .result-item {
237
- background: rgba(0, 0, 0, 0.2);
238
- padding: 10px;
239
- border-radius: 5px;
240
- }
241
-
242
- .result-label {
243
- font-size: 0.9rem;
244
- opacity: 0.8;
245
- }
246
-
247
- .result-value {
248
- font-size: 1.1rem;
249
- font-weight: bold;
250
- }
251
-
252
- .bars-list {
253
- max-height: 200px;
254
- overflow-y: auto;
255
- margin-top: 10px;
256
- background: rgba(0, 0, 0, 0.2);
257
- border-radius: 5px;
258
- padding: 10px;
259
- }
260
-
261
- .bar-item {
262
- display: flex;
263
- justify-content: space-between;
264
- padding: 5px 0;
265
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
266
- }
267
-
268
- .bar-item:last-child {
269
- border-bottom: none;
270
- }
271
-
272
- .beat-marker {
273
- position: absolute;
274
- bottom: 0;
275
- width: 3px;
276
- height: 30px;
277
- background: #4CAF50;
278
- }
279
-
280
- .downbeat-marker {
281
- position: absolute;
282
- bottom: 0;
283
- width: 3px;
284
- height: 50px;
285
- background: #FF9800;
286
- }
287
-
288
- .section-player {
289
- margin-top: 20px;
290
- padding-top: 15px;
291
- border-top: 1px solid rgba(255, 255, 255, 0.2);
292
- }
293
-
294
- .checkbox-group {
295
- display: flex;
296
- align-items: center;
297
- margin-top: 10px;
298
- }
299
-
300
- .action-button {
301
- background: #2196F3;
302
- color: white;
303
- border: none;
304
- padding: 10px 20px;
305
- border-radius: 5px;
306
- cursor: pointer;
307
- font-weight: bold;
308
- transition: background 0.3s;
309
- margin-top: 10px;
310
- }
311
-
312
- .action-button:hover {
313
- background: #1976D2;
314
- }
315
-
316
- .action-button:disabled {
317
- background: rgba(255, 255, 255, 0.2);
318
- cursor: not-allowed;
319
- }
320
-
321
- .init-progress {
322
- margin-top: 20px;
323
- padding: 15px;
324
- background: rgba(0, 0, 0, 0.2);
325
- border-radius: 10px;
326
- }
327
-
328
- .init-complete {
329
- background: rgba(76, 175, 80, 0.2);
330
- border: 1px solid rgba(76, 175, 80, 0.5);
331
- padding: 10px;
332
- border-radius: 5px;
333
- text-align: center;
334
- margin-top: 15px;
335
- display: none;
336
- }
337
- </style>
338
  </head>
339
  <body>
340
  <div class="container">
341
  <header>
342
  <h1>Loop Maestro</h1>
343
- <p class="subtitle">Upload a song, detect beats and bars, then loop bars</p>
344
  </header>
345
 
346
  <div class="app-grid">
347
  <div class="card">
348
- <h2>Upload & Play</h2>
349
 
350
  <div id="serverStatus" class="server-status" style="display: none;">
351
  Checking server status...
@@ -460,368 +136,7 @@
460
 
461
  <script type="module">
462
  import AudioStretchPlayer from '/js/AudioStretchPlayer.js';
463
-
464
-
465
- class BeatDetector {
466
- constructor() {
467
- this.beatSession = null;
468
- this.melSession = null;
469
- this.sampleRate = 22050;
470
- this.chunkSize = 1500;
471
- this.borderSize = 6;
472
- this.serverUrl = ''; // Update this to your server URL
473
- this.isCancelled = false;
474
- }
475
-
476
- async init(progressCallback = null) {
477
- try {
478
- if (progressCallback) await progressCallback(10, "Loading beat detection model...");
479
-
480
- // Load beat and mel spectrogram models (no postprocessor needed)
481
- this.beatSession = await ort.InferenceSession.create(
482
- './beat_model.onnx',
483
- {executionProviders: ['wasm']}
484
- );
485
-
486
- if (progressCallback) await progressCallback(50, "Loading spectrogram model...");
487
-
488
- this.melSession = await ort.InferenceSession.create(
489
- './log_mel_spec.onnx',
490
- {executionProviders: ['wasm']}
491
- );
492
-
493
- if (progressCallback) await progressCallback(100, "Models loaded successfully!");
494
-
495
- console.log("ONNX models loaded successfully");
496
- return true;
497
- } catch (error) {
498
- console.error("Failed to load models:", error);
499
- if (progressCallback) await progressCallback(0, "Failed to load models");
500
- return false;
501
- }
502
- }
503
-
504
- cancel() {
505
- this.isCancelled = true;
506
- }
507
-
508
- resetCancellation() {
509
- this.isCancelled = false;
510
- }
511
-
512
- // Audio preprocessing using the log_mel_spec.onnx model
513
- async preprocessAudio(audioBuffer) {
514
- const originalSampleRate = audioBuffer.sampleRate;
515
- console.info('originalSampleRate :', originalSampleRate);
516
- // Get data from both channels
517
- const channel0 = audioBuffer.getChannelData(0);
518
- const channel1 = audioBuffer.getChannelData(1);
519
-
520
- // Calculate mean of both channels
521
- let audioData = new Float32Array(channel0.length);
522
- for (let i = 0; i < channel0.length; i++) {
523
- audioData[i] = (channel0[i] + channel1[i]) / 2;
524
- }
525
-
526
- // Use the ONNX model to compute log mel spectrogram
527
- return await this.computeLogMelSpectrogramONNX(audioData);
528
- }
529
-
530
- async computeLogMelSpectrogramONNX(audioData) {
531
- if (!this.melSession) {
532
- throw new Error("Log Mel Spectrogram model not initialized");
533
- }
534
-
535
- // Prepare input tensor without batch dimension
536
- const inputTensor = new ort.Tensor('float32', audioData, [audioData.length]);
537
-
538
- try {
539
- // Run inference
540
- const results = await this.melSession.run({
541
- 'input': inputTensor
542
- });
543
-
544
- // Extract the log mel spectrogram from the output
545
- const outputName = Object.keys(results)[0];
546
- const logMelOutput = results[outputName];
547
-
548
- // Convert 2D output to array format
549
- const spectrogram = [];
550
- const numFrames = logMelOutput.dims[0];
551
- const numMels = logMelOutput.dims[1];
552
-
553
- for (let i = 0; i < numFrames; i++) {
554
- const frame = [];
555
- for (let j = 0; j < numMels; j++) {
556
- frame.push(logMelOutput.data[i * numMels + j]);
557
- }
558
- spectrogram.push(frame);
559
- }
560
-
561
- console.log(`Log mel spectrogram computed: ${spectrogram.length} frames, ${spectrogram[0].length} mel bands`);
562
- return spectrogram;
563
- } catch (error) {
564
- console.error("Error computing log mel spectrogram:", error);
565
- throw error;
566
- }
567
- }
568
-
569
- splitIntoChunks(spectrogram, chunkSize, borderSize, avoidShortEnd = true) {
570
- const chunks = [];
571
- const starts = [];
572
-
573
- // Generate start positions similar to Python's np.arange
574
- let startPositions = [];
575
- for (let i = -borderSize; i < spectrogram.length - borderSize; i += chunkSize - 2 * borderSize) {
576
- startPositions.push(i);
577
- }
578
-
579
- // Adjust last start position if avoidShortEnd is true and piece is long enough
580
- if (avoidShortEnd && spectrogram.length > chunkSize - 2 * borderSize && startPositions.length > 0) {
581
- startPositions[startPositions.length - 1] = spectrogram.length - (chunkSize - borderSize);
582
- }
583
-
584
- // Process each start position
585
- for (const start of startPositions) {
586
- const chunkStart = Math.max(0, start);
587
- const chunkEnd = Math.min(spectrogram.length, start + chunkSize);
588
-
589
- // Extract the chunk
590
- let chunk = spectrogram.slice(chunkStart, chunkEnd);
591
-
592
- // Calculate padding needed (similar to Python's zeropad)
593
- const leftPad = Math.max(0, -start);
594
- const rightPad = Math.max(0, Math.min(borderSize, start + chunkSize - spectrogram.length));
595
-
596
- // Apply padding if needed
597
- if (leftPad > 0 || rightPad > 0) {
598
- const paddedChunk = [];
599
-
600
- // Add left padding
601
- for (let i = 0; i < leftPad; i++) {
602
- paddedChunk.push(new Array(128).fill(0)); // Assuming 128 bins like in your Python code
603
- }
604
-
605
- // Add the actual chunk data
606
- paddedChunk.push(...chunk);
607
-
608
- // Add right padding
609
- for (let i = 0; i < rightPad; i++) {
610
- paddedChunk.push(new Array(128).fill(0));
611
- }
612
-
613
- chunks.push(paddedChunk);
614
- } else {
615
- chunks.push(chunk);
616
- }
617
-
618
- starts.push(start);
619
- }
620
-
621
- return {chunks, starts};
622
- }
623
-
624
- async processAudio(audioBuffer, progressCallback = null) {
625
- if (!this.beatSession) {
626
- throw new Error("Beat model not initialized");
627
- }
628
-
629
- // Reset cancellation flag
630
- this.resetCancellation();
631
-
632
- // Preprocess audio using ONNX model
633
- if (progressCallback) await progressCallback(0, "Detecting beats ...");
634
- const spectrogram = await this.preprocessAudio(audioBuffer);
635
-
636
- // Check for cancellation
637
- if (this.isCancelled) throw new Error("Processing cancelled");
638
-
639
- const {chunks, starts} = this.splitIntoChunks(spectrogram, this.chunkSize, this.borderSize);
640
-
641
- // Store predictions for each chunk
642
- const predChunks = [];
643
-
644
- // Process each chunk with progress updates
645
- for (let i = 0; i < chunks.length; i++) {
646
- // Check for cancellation
647
- if (this.isCancelled) throw new Error("Processing cancelled");
648
-
649
- const chunk = chunks[i];
650
- const start = starts[i];
651
-
652
- // Convert to tensor format
653
- const inputTensor = new ort.Tensor('float32',
654
- this.flattenArray(chunk),
655
- [1, chunk.length, 128]
656
- );
657
-
658
- // Run inference
659
- const results = await this.beatSession.run({
660
- 'input': inputTensor
661
- });
662
-
663
- await new Promise(resolve => setTimeout(resolve, 0));
664
-
665
- // Update progress
666
- const progress = Math.floor( ((i+1) / chunks.length) * 95);
667
- if (progressCallback) await progressCallback(progress, `Detecting beats ... ${i + 1}/${chunks.length}...`);
668
-
669
- // Extract predictions
670
- const beatPred = Array.from(results.beat.data);
671
- const downbeatPred = Array.from(results.downbeat.data);
672
-
673
- // Store chunk predictions
674
- predChunks.push({
675
- beat: beatPred,
676
- downbeat: downbeatPred
677
- });
678
- }
679
-
680
- if (this.isCancelled) throw new Error("Processing cancelled");
681
-
682
- if (progressCallback) await progressCallback(95, "Post processing beats ...");
683
- // Aggregate predictions
684
- const aggregated = this.aggregatePrediction(
685
- predChunks,
686
- starts,
687
- spectrogram.length,
688
- this.chunkSize,
689
- this.borderSize,
690
- 'keep_first'
691
- );
692
-
693
- if (this.isCancelled) throw new Error("Processing cancelled");
694
-
695
- if (progressCallback) await progressCallback(100, "Complete!");
696
-
697
- return {
698
- prediction_beat: aggregated.beat,
699
- prediction_downbeat: aggregated.downbeat,
700
- };
701
- }
702
-
703
- // Use Python server for postprocessing
704
- async logits_to_bars(beatLogits, downbeatLogits, min_bpm, max_bpm, beats_per_bar) {
705
- try {
706
- const response = await fetch(`${this.serverUrl}/logits_to_bars`, {
707
- method: 'POST',
708
- headers: {
709
- 'Content-Type': 'application/json',
710
- },
711
- body: JSON.stringify({
712
- beat_logits: beatLogits,
713
- downbeat_logits: downbeatLogits,
714
- min_bpm: min_bpm,
715
- max_bpm: max_bpm,
716
- beats_per_bar: beats_per_bar,
717
- })
718
- });
719
-
720
- if (!response.ok) {
721
- throw new Error(`Server returned ${response.status}: ${response.statusText}`);
722
- }
723
-
724
- const result = await response.json();
725
-
726
- if (result.error) {
727
- throw new Error(`Server error: ${result.error}`);
728
- }
729
-
730
- console.log(`Server postprocessing results: ${result.bars ? Object.keys(result.bars).length : 0} bars`);
731
-
732
- // Return bars along with estimated_bpm and detected_beats_per_bar
733
- return {
734
- bars: result.bars || {},
735
- estimated_bpm: result.estimated_bpm || null,
736
- detected_beats_per_bar: result.detected_beats_per_bar || null
737
- };
738
- } catch (error) {
739
- console.error("Error in server postprocessing:", error);
740
- // Return empty object as fallback
741
- return {
742
- bars: {},
743
- estimated_bpm: null,
744
- detected_beats_per_bar: null
745
- };
746
- }
747
- }
748
-
749
- // Check server status
750
- async checkServerStatus() {
751
- try {
752
- const response = await fetch(`${this.serverUrl}/health`, {
753
- method: 'GET',
754
- headers: {
755
- 'Content-Type': 'application/json',
756
- }
757
- });
758
-
759
- return response.ok;
760
- } catch (error) {
761
- console.warn("Server health check failed:", error);
762
- return false;
763
- }
764
- }
765
-
766
- aggregatePrediction(predChunks, starts, fullSize, chunkSize, borderSize, overlapMode) {
767
- let processedChunks = predChunks;
768
-
769
- // Remove borders if borderSize > 0
770
- if (borderSize > 0) {
771
- processedChunks = predChunks.map(pchunk => ({
772
- beat: pchunk.beat.slice(borderSize, -borderSize),
773
- downbeat: pchunk.downbeat.slice(borderSize, -borderSize)
774
- }));
775
- }
776
-
777
- // Initialize arrays with very low values (equivalent to -1000.0 in Python)
778
- const piecePredictionBeat = new Array(fullSize).fill(-1000.0);
779
- const piecePredictionDownbeat = new Array(fullSize).fill(-1000.0);
780
-
781
- // Prepare iteration based on overlap mode
782
- let chunksToProcess = processedChunks;
783
- let startsToProcess = starts;
784
-
785
- if (overlapMode === "keep_first") {
786
- // Process in reverse order so earlier predictions overwrite later ones
787
- chunksToProcess = [...processedChunks].reverse();
788
- startsToProcess = [...starts].reverse();
789
- }
790
-
791
- // Aggregate predictions
792
- for (let i = 0; i < chunksToProcess.length; i++) {
793
- const start = startsToProcess[i];
794
- const pchunk = chunksToProcess[i];
795
-
796
- const effectiveStart = start + borderSize;
797
- const effectiveEnd = start + chunkSize - borderSize;
798
-
799
- // Copy predictions to the appropriate positions
800
- for (let j = 0; j < pchunk.beat.length; j++) {
801
- const pos = effectiveStart + j;
802
- if (pos < fullSize) {
803
- piecePredictionBeat[pos] = pchunk.beat[j];
804
- piecePredictionDownbeat[pos] = pchunk.downbeat[j];
805
- }
806
- }
807
- }
808
-
809
- return {
810
- beat: piecePredictionBeat,
811
- downbeat: piecePredictionDownbeat
812
- };
813
- }
814
-
815
- flattenArray(arr) {
816
- const flat = [];
817
- for (let i = 0; i < arr.length; i++) {
818
- for (let j = 0; j < arr[i].length; j++) {
819
- flat.push(arr[i][j]);
820
- }
821
- }
822
- return flat;
823
- }
824
- }
825
 
826
  class BeatDetectionApp {
827
  constructor() {
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Loop Maestro</title>
7
+ <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96"/>
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
9
+ <link rel="shortcut icon" href="/favicon.ico"/>
10
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
11
+ <link rel="manifest" href="/site.webmanifest"/>
12
+ <link rel="stylesheet" href="css/styles.css"/>
13
  <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </head>
15
  <body>
16
  <div class="container">
17
  <header>
18
  <h1>Loop Maestro</h1>
19
+ <p class="subtitle">Upload a song, detect beats and bars, then loop any part</p>
20
  </header>
21
 
22
  <div class="app-grid">
23
  <div class="card">
24
+ <h2>Upload & Loop</h2>
25
 
26
  <div id="serverStatus" class="server-status" style="display: none;">
27
  Checking server status...
 
136
 
137
  <script type="module">
138
  import AudioStretchPlayer from '/js/AudioStretchPlayer.js';
139
+ import BeatDetector from '/js/BeatDetector.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  class BeatDetectionApp {
142
  constructor() {
js/AudioStretchPlayer.js CHANGED
@@ -54,175 +54,6 @@ export default class AudioStretchPlayer {
54
 
55
  createHTML() {
56
  this.container.innerHTML = `
57
- <style>
58
- .audio-stretch-player {
59
- width: 100%;
60
- height: 100%;
61
- border-radius: 10px;
62
- padding: 15px;
63
- backdrop-filter: blur(10px);
64
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
65
- border: 1px solid rgba(255, 255, 255, 0.1);
66
- color: white;
67
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
68
- display: flex;
69
- flex-direction: column;
70
- box-sizing: border-box;
71
- overflow: visible;
72
- }
73
-
74
- .player-header {
75
- display: flex;
76
- align-items: center;
77
- gap: 12px;
78
- margin-bottom: 15px;
79
- padding-bottom: 12px;
80
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
81
- flex-shrink: 0;
82
- }
83
-
84
- .play-stop-btn {
85
- background: #2196F3;
86
- color: white;
87
- border: none;
88
- border-radius: 50%;
89
- width: 40px;
90
- height: 40px;
91
- cursor: pointer;
92
- display: flex;
93
- align-items: center;
94
- justify-content: center;
95
- transition: background 0.3s;
96
- font-size: 16px;
97
- flex-shrink: 0;
98
- }
99
-
100
- .play-stop-btn:hover {
101
- background: #1976D2;
102
- }
103
-
104
- .playback-slider-container {
105
- flex: 1;
106
- display: flex;
107
- align-items: center;
108
- gap: 8px;
109
- min-width: 0;
110
- }
111
-
112
- .playback-slider {
113
- flex: 1;
114
- height: 4px;
115
- border-radius: 2px;
116
- background: rgba(255, 255, 255, 0.2);
117
- outline: none;
118
- -webkit-appearance: none;
119
- min-width: 0;
120
- }
121
-
122
- .playback-slider::-webkit-slider-thumb {
123
- -webkit-appearance: none;
124
- width: 14px;
125
- height: 14px;
126
- border-radius: 50%;
127
- background: #2196F3;
128
- cursor: pointer;
129
- border: 2px solid white;
130
- }
131
-
132
- .playback-slider::-moz-range-thumb {
133
- width: 14px;
134
- height: 14px;
135
- border-radius: 50%;
136
- background: #2196F3;
137
- cursor: pointer;
138
- border: 2px solid white;
139
- }
140
-
141
- .time-display {
142
- font-size: 0.8rem;
143
- color: rgba(255, 255, 255, 0.8);
144
- min-width: 45px;
145
- text-align: center;
146
- flex-shrink: 0;
147
- }
148
-
149
-
150
- .controls-panel {
151
- flex: 1;
152
- display: flex;
153
- flex-direction: column; /* <-- stack rows vertically */
154
- align-items: flex-start;
155
- gap: 12px; /* spacing between rows */
156
- width: 100%;
157
- }
158
-
159
- .control-row {
160
- display: flex;
161
- flex-direction: column; /* Label on line 1, slider+number on line 2 */
162
- gap: 6px;
163
- width: 100%;
164
- }
165
-
166
- .control-label {
167
- font-size: 0.8rem;
168
- color: rgba(255, 255, 255, 0.8);
169
- }
170
-
171
- .control-input-line {
172
- display: flex;
173
- align-items: center;
174
- gap: 10px;
175
- width: 100%;
176
- }
177
-
178
- .control-slider {
179
- flex: 1;
180
- height: 4px;
181
- border-radius: 2px;
182
- background: rgba(255, 255, 255, 0.2);
183
- outline: none;
184
- -webkit-appearance: none;
185
- min-width: 0;
186
- }
187
-
188
- .control-input {
189
- min-width: 70px;
190
- max-width: 70px;
191
- flex-shrink: 0;
192
- }
193
-
194
- .control-slider.blue::-webkit-slider-thumb {
195
- background: #2196F3;
196
- }
197
-
198
- .control-slider.red::-webkit-slider-thumb {
199
- background: #FF9800;
200
- }
201
-
202
- .control-slider.blue::-moz-range-thumb {
203
- background: #2196F3;
204
- }
205
-
206
- .control-slider.red::-moz-range-thumb {
207
- background: #FF9800;
208
- }
209
-
210
- .control-input:focus {
211
- outline: none;
212
- border-color: #2196F3;
213
- }
214
-
215
-
216
- .hidden {
217
- display: none !important;
218
- }
219
-
220
- .file-input {
221
- display: none;
222
- }
223
- </style>
224
- </head>
225
- <body>
226
  <div class="audio-stretch-player">
227
  <div class="player-header">
228
  <button class="play-stop-btn" id="playstop">
 
54
 
55
  createHTML() {
56
  this.container.innerHTML = `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  <div class="audio-stretch-player">
58
  <div class="player-header">
59
  <button class="play-stop-btn" id="playstop">
js/BeatDetector.js ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default class BeatDetector {
2
+ constructor() {
3
+ this.beatSession = null;
4
+ this.melSession = null;
5
+ this.sampleRate = 22050;
6
+ this.chunkSize = 1500;
7
+ this.borderSize = 6;
8
+ this.serverUrl = ''; // Update this to your server URL
9
+ this.isCancelled = false;
10
+ }
11
+
12
+ async init(progressCallback = null) {
13
+ try {
14
+ if (progressCallback) await progressCallback(10, "Loading beat detection model...");
15
+
16
+
17
+ // Load beat and mel spectrogram models (no postprocessor needed)
18
+ this.beatSession = await ort.InferenceSession.create(
19
+ './beat_model.onnx',
20
+ {executionProviders: ['wasm']}
21
+ );
22
+
23
+ if (progressCallback) await progressCallback(50, "Loading spectrogram model...");
24
+
25
+ this.melSession = await ort.InferenceSession.create(
26
+ './log_mel_spec.onnx',
27
+ {executionProviders: ['wasm']}
28
+ );
29
+
30
+ if (progressCallback) await progressCallback(100, "Models loaded successfully!");
31
+
32
+ console.log("ONNX models loaded successfully");
33
+ return true;
34
+ } catch (error) {
35
+ console.error("Failed to load models:", error);
36
+ if (progressCallback) await progressCallback(0, "Failed to load models");
37
+ return false;
38
+ }
39
+ }
40
+
41
+ cancel() {
42
+ this.isCancelled = true;
43
+ }
44
+
45
+ resetCancellation() {
46
+ this.isCancelled = false;
47
+ }
48
+
49
+ // Audio preprocessing using the log_mel_spec.onnx model
50
+ async preprocessAudio(audioBuffer) {
51
+ const originalSampleRate = audioBuffer.sampleRate;
52
+ console.info('originalSampleRate :', originalSampleRate);
53
+ // Get data from both channels
54
+ const channel0 = audioBuffer.getChannelData(0);
55
+ const channel1 = audioBuffer.getChannelData(1);
56
+
57
+ // Calculate mean of both channels
58
+ let audioData = new Float32Array(channel0.length);
59
+ for (let i = 0; i < channel0.length; i++) {
60
+ audioData[i] = (channel0[i] + channel1[i]) / 2;
61
+ }
62
+
63
+ // Use the ONNX model to compute log mel spectrogram
64
+ return await this.computeLogMelSpectrogramONNX(audioData);
65
+ }
66
+
67
+ async computeLogMelSpectrogramONNX(audioData) {
68
+ if (!this.melSession) {
69
+ throw new Error("Log Mel Spectrogram model not initialized");
70
+ }
71
+
72
+ // Prepare input tensor without batch dimension
73
+ const inputTensor = new ort.Tensor('float32', audioData, [audioData.length]);
74
+
75
+ try {
76
+ // Run inference
77
+ const results = await this.melSession.run({
78
+ 'input': inputTensor
79
+ });
80
+
81
+ // Extract the log mel spectrogram from the output
82
+ const outputName = Object.keys(results)[0];
83
+ const logMelOutput = results[outputName];
84
+
85
+ // Convert 2D output to array format
86
+ const spectrogram = [];
87
+ const numFrames = logMelOutput.dims[0];
88
+ const numMels = logMelOutput.dims[1];
89
+
90
+ for (let i = 0; i < numFrames; i++) {
91
+ const frame = [];
92
+ for (let j = 0; j < numMels; j++) {
93
+ frame.push(logMelOutput.data[i * numMels + j]);
94
+ }
95
+ spectrogram.push(frame);
96
+ }
97
+
98
+ console.log(`Log mel spectrogram computed: ${spectrogram.length} frames, ${spectrogram[0].length} mel bands`);
99
+ return spectrogram;
100
+ } catch (error) {
101
+ console.error("Error computing log mel spectrogram:", error);
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ splitIntoChunks(spectrogram, chunkSize, borderSize, avoidShortEnd = true) {
107
+ const chunks = [];
108
+ const starts = [];
109
+
110
+ // Generate start positions similar to Python's np.arange
111
+ let startPositions = [];
112
+ for (let i = -borderSize; i < spectrogram.length - borderSize; i += chunkSize - 2 * borderSize) {
113
+ startPositions.push(i);
114
+ }
115
+
116
+ // Adjust last start position if avoidShortEnd is true and piece is long enough
117
+ if (avoidShortEnd && spectrogram.length > chunkSize - 2 * borderSize && startPositions.length > 0) {
118
+ startPositions[startPositions.length - 1] = spectrogram.length - (chunkSize - borderSize);
119
+ }
120
+
121
+ // Process each start position
122
+ for (const start of startPositions) {
123
+ const chunkStart = Math.max(0, start);
124
+ const chunkEnd = Math.min(spectrogram.length, start + chunkSize);
125
+
126
+ // Extract the chunk
127
+ let chunk = spectrogram.slice(chunkStart, chunkEnd);
128
+
129
+ // Calculate padding needed (similar to Python's zeropad)
130
+ const leftPad = Math.max(0, -start);
131
+ const rightPad = Math.max(0, Math.min(borderSize, start + chunkSize - spectrogram.length));
132
+
133
+ // Apply padding if needed
134
+ if (leftPad > 0 || rightPad > 0) {
135
+ const paddedChunk = [];
136
+
137
+ // Add left padding
138
+ for (let i = 0; i < leftPad; i++) {
139
+ paddedChunk.push(new Array(128).fill(0)); // Assuming 128 bins like in your Python code
140
+ }
141
+
142
+ // Add the actual chunk data
143
+ paddedChunk.push(...chunk);
144
+
145
+ // Add right padding
146
+ for (let i = 0; i < rightPad; i++) {
147
+ paddedChunk.push(new Array(128).fill(0));
148
+ }
149
+
150
+ chunks.push(paddedChunk);
151
+ } else {
152
+ chunks.push(chunk);
153
+ }
154
+
155
+ starts.push(start);
156
+ }
157
+
158
+ return {chunks, starts};
159
+ }
160
+
161
+ async processAudio(audioBuffer, progressCallback = null) {
162
+ if (!this.beatSession) {
163
+ throw new Error("Beat model not initialized");
164
+ }
165
+
166
+ // Reset cancellation flag
167
+ this.resetCancellation();
168
+
169
+ // Preprocess audio using ONNX model
170
+ if (progressCallback) await progressCallback(0, "Detecting beats ...");
171
+ const spectrogram = await this.preprocessAudio(audioBuffer);
172
+
173
+ // Check for cancellation
174
+ if (this.isCancelled) throw new Error("Processing cancelled");
175
+
176
+ const {chunks, starts} = this.splitIntoChunks(spectrogram, this.chunkSize, this.borderSize);
177
+
178
+ // Store predictions for each chunk
179
+ const predChunks = [];
180
+
181
+ // Process each chunk with progress updates
182
+ for (let i = 0; i < chunks.length; i++) {
183
+ // Check for cancellation
184
+ if (this.isCancelled) throw new Error("Processing cancelled");
185
+
186
+ const chunk = chunks[i];
187
+ const start = starts[i];
188
+
189
+ // Convert to tensor format
190
+ const inputTensor = new ort.Tensor('float32',
191
+ this.flattenArray(chunk),
192
+ [1, chunk.length, 128]
193
+ );
194
+
195
+ // Run inference
196
+ const results = await this.beatSession.run({
197
+ 'input': inputTensor
198
+ });
199
+
200
+ await new Promise(resolve => setTimeout(resolve, 0));
201
+
202
+ // Update progress
203
+ const progress = Math.floor( ((i+1) / chunks.length) * 95);
204
+ if (progressCallback) await progressCallback(progress, `Detecting beats ... ${i + 1}/${chunks.length}...`);
205
+
206
+ // Extract predictions
207
+ const beatPred = Array.from(results.beat.data);
208
+ const downbeatPred = Array.from(results.downbeat.data);
209
+
210
+ // Store chunk predictions
211
+ predChunks.push({
212
+ beat: beatPred,
213
+ downbeat: downbeatPred
214
+ });
215
+ }
216
+
217
+ if (this.isCancelled) throw new Error("Processing cancelled");
218
+
219
+ if (progressCallback) await progressCallback(95, "Post processing beats ...");
220
+ // Aggregate predictions
221
+ const aggregated = this.aggregatePrediction(
222
+ predChunks,
223
+ starts,
224
+ spectrogram.length,
225
+ this.chunkSize,
226
+ this.borderSize,
227
+ 'keep_first'
228
+ );
229
+
230
+ if (this.isCancelled) throw new Error("Processing cancelled");
231
+
232
+ if (progressCallback) await progressCallback(100, "Complete!");
233
+
234
+ return {
235
+ prediction_beat: aggregated.beat,
236
+ prediction_downbeat: aggregated.downbeat,
237
+ };
238
+ }
239
+
240
+ // Use Python server for postprocessing
241
+ async logits_to_bars(beatLogits, downbeatLogits, min_bpm, max_bpm, beats_per_bar) {
242
+ try {
243
+ const response = await fetch(`${this.serverUrl}/logits_to_bars`, {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/json',
247
+ },
248
+ body: JSON.stringify({
249
+ beat_logits: beatLogits,
250
+ downbeat_logits: downbeatLogits,
251
+ min_bpm: min_bpm,
252
+ max_bpm: max_bpm,
253
+ beats_per_bar: beats_per_bar,
254
+ })
255
+ });
256
+
257
+ if (!response.ok) {
258
+ throw new Error(`Server returned ${response.status}: ${response.statusText}`);
259
+ }
260
+
261
+ const result = await response.json();
262
+
263
+ if (result.error) {
264
+ throw new Error(`Server error: ${result.error}`);
265
+ }
266
+
267
+ console.log(`Server postprocessing results: ${result.bars ? Object.keys(result.bars).length : 0} bars`);
268
+
269
+ // Return bars along with estimated_bpm and detected_beats_per_bar
270
+ return {
271
+ bars: result.bars || {},
272
+ estimated_bpm: result.estimated_bpm || null,
273
+ detected_beats_per_bar: result.detected_beats_per_bar || null
274
+ };
275
+ } catch (error) {
276
+ console.error("Error in server postprocessing:", error);
277
+ // Return empty object as fallback
278
+ return {
279
+ bars: {},
280
+ estimated_bpm: null,
281
+ detected_beats_per_bar: null
282
+ };
283
+ }
284
+ }
285
+
286
+ // Check server status
287
+ async checkServerStatus() {
288
+ try {
289
+ const response = await fetch(`${this.serverUrl}/health`, {
290
+ method: 'GET',
291
+ headers: {
292
+ 'Content-Type': 'application/json',
293
+ }
294
+ });
295
+
296
+ return response.ok;
297
+ } catch (error) {
298
+ console.warn("Server health check failed:", error);
299
+ return false;
300
+ }
301
+ }
302
+
303
+ aggregatePrediction(predChunks, starts, fullSize, chunkSize, borderSize, overlapMode) {
304
+ let processedChunks = predChunks;
305
+
306
+ // Remove borders if borderSize > 0
307
+ if (borderSize > 0) {
308
+ processedChunks = predChunks.map(pchunk => ({
309
+ beat: pchunk.beat.slice(borderSize, -borderSize),
310
+ downbeat: pchunk.downbeat.slice(borderSize, -borderSize)
311
+ }));
312
+ }
313
+
314
+ // Initialize arrays with very low values (equivalent to -1000.0 in Python)
315
+ const piecePredictionBeat = new Array(fullSize).fill(-1000.0);
316
+ const piecePredictionDownbeat = new Array(fullSize).fill(-1000.0);
317
+
318
+ // Prepare iteration based on overlap mode
319
+ let chunksToProcess = processedChunks;
320
+ let startsToProcess = starts;
321
+
322
+ if (overlapMode === "keep_first") {
323
+ // Process in reverse order so earlier predictions overwrite later ones
324
+ chunksToProcess = [...processedChunks].reverse();
325
+ startsToProcess = [...starts].reverse();
326
+ }
327
+
328
+ // Aggregate predictions
329
+ for (let i = 0; i < chunksToProcess.length; i++) {
330
+ const start = startsToProcess[i];
331
+ const pchunk = chunksToProcess[i];
332
+
333
+ const effectiveStart = start + borderSize;
334
+ const effectiveEnd = start + chunkSize - borderSize;
335
+
336
+ // Copy predictions to the appropriate positions
337
+ for (let j = 0; j < pchunk.beat.length; j++) {
338
+ const pos = effectiveStart + j;
339
+ if (pos < fullSize) {
340
+ piecePredictionBeat[pos] = pchunk.beat[j];
341
+ piecePredictionDownbeat[pos] = pchunk.downbeat[j];
342
+ }
343
+ }
344
+ }
345
+
346
+ return {
347
+ beat: piecePredictionBeat,
348
+ downbeat: piecePredictionDownbeat
349
+ };
350
+ }
351
+
352
+ flattenArray(arr) {
353
+ const flat = [];
354
+ for (let i = 0; i < arr.length; i++) {
355
+ for (let j = 0; j < arr[i].length; j++) {
356
+ flat.push(arr[i][j]);
357
+ }
358
+ }
359
+ return flat;
360
+ }
361
+ }
site.webmanifest ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "theme_color": "#54264d",
3
+ "background_color": "#ca5a39",
4
+ "icons": [
5
+ {
6
+ "purpose": "maskable",
7
+ "sizes": "192x192",
8
+ "src": "/web-app-manifest-192x192.png",
9
+ "type": "image/png"
10
+ },
11
+ {
12
+ "src": "/web-app-manifest-512x512.png",
13
+ "sizes": "512x512",
14
+ "type": "image/png",
15
+ "purpose": "maskable"
16
+ },
17
+ {
18
+ "purpose": "any",
19
+ "sizes": "512x512",
20
+ "src": "icon512_rounded.png",
21
+ "type": "image/png"
22
+ }
23
+ ],
24
+ "orientation": "any",
25
+ "display": "standalone",
26
+ "dir": "auto",
27
+ "lang": "en-US",
28
+ "name": "Loop Maestro",
29
+ "short_name": "Loop Maestro",
30
+ "start_url": "https://jorisvaneyghen-loop-maestro.hf.space/",
31
+ "scope": "https://jorisvaneyghen-loop-maestro.hf.space/",
32
+ "description": "Upload a song, detect beats and bars, then loop bars",
33
+ "id": "https://jorisvaneyghen-loop-maestro.hf.space/"
34
+ }
web-app-manifest-192x192.png ADDED
web-app-manifest-512x512.png ADDED