|
3 | 3 |
|
4 | 4 | const isPenguinMod = Scratch.extensions.isPenguinMod; |
5 | 5 |
|
| 6 | + function scheduleFrame(cb) { |
| 7 | + if (document.hidden) { |
| 8 | + return setTimeout(cb, 16); |
| 9 | + } else { |
| 10 | + return requestAnimationFrame(cb); |
| 11 | + } |
| 12 | + } |
| 13 | + |
6 | 14 | class BeatSync { |
7 | 15 | constructor() { |
8 | 16 | this.bpm = 120; |
|
18 | 26 | this.pausedElapsed = 0; |
19 | 27 |
|
20 | 28 | this.vmEventBound = false; |
| 29 | + this._bgTickHandle = null; |
21 | 30 |
|
22 | 31 | this.loadAutoStart(); |
| 32 | + |
| 33 | + this._startBgTick(); |
| 34 | + document.addEventListener('visibilitychange', () => { |
| 35 | + if (document.hidden) { |
| 36 | + this._startBgTick(); |
| 37 | + } |
| 38 | + }); |
| 39 | + } |
| 40 | + |
| 41 | + _startBgTick() { |
| 42 | + if (this._bgTickHandle !== null) return; |
| 43 | + const loop = () => { |
| 44 | + this._bgTickHandle = null; |
| 45 | + this.tick(); |
| 46 | + if (document.hidden) { |
| 47 | + this._bgTickHandle = setTimeout(loop, 16); |
| 48 | + } |
| 49 | + }; |
| 50 | + this._bgTickHandle = setTimeout(loop, 16); |
23 | 51 | } |
24 | 52 |
|
25 | 53 | loadAutoStart() { |
|
30 | 58 | if (storage && typeof storage.autoStart === 'boolean') { |
31 | 59 | this.autoStart = storage.autoStart; |
32 | 60 | } |
33 | | - } catch (e) { |
34 | | - } |
| 61 | + } catch (e) {} |
35 | 62 | } |
36 | 63 | } |
37 | 64 |
|
|
46 | 73 | vm.runtime.extensionStorage.beatSync = {}; |
47 | 74 | } |
48 | 75 | vm.runtime.extensionStorage.beatSync.autoStart = this.autoStart; |
49 | | - } catch (e) { |
50 | | - } |
| 76 | + } catch (e) {} |
51 | 77 | } |
52 | 78 | } |
53 | 79 |
|
54 | 80 | serialize() { |
55 | 81 | if (isPenguinMod) { |
56 | | - return { |
57 | | - autoStart: this.autoStart |
58 | | - }; |
| 82 | + return { autoStart: this.autoStart }; |
59 | 83 | } |
60 | 84 | return {}; |
61 | 85 | } |
|
81 | 105 | vm?.runtime?.scratch?.audioEngine?.audioContext || |
82 | 106 | vm?.audioEngine?.audioContext || |
83 | 107 | null; |
84 | | - } catch (e) { |
85 | | - } |
| 108 | + } catch (e) {} |
86 | 109 |
|
87 | | - this.audioCtx = scratchCtx || new(window.AudioContext || window.webkitAudioContext)(); |
| 110 | + this.audioCtx = scratchCtx || new (window.AudioContext || window.webkitAudioContext)(); |
88 | 111 | if (this.audioCtx.state === 'suspended') this.audioCtx.resume(); |
89 | 112 | return this.audioCtx; |
90 | 113 | } |
|
113 | 136 | this.vmEventBound = true; |
114 | 137 |
|
115 | 138 | const vm = Scratch.vm; |
116 | | - |
| 139 | + |
117 | 140 | vm.on('BEFORE_EXECUTE', () => { |
118 | 141 | this.tick(); |
119 | 142 | }); |
|
143 | 166 | opcode: 'toggleAutoStart', |
144 | 167 | blockType: Scratch.BlockType.BUTTON, |
145 | 168 | text: this.autoStart ? 'Auto start toggle: ON' : 'Auto start toggle: OFF' |
146 | | - }, |
| 169 | + }, |
147 | 170 | { |
148 | 171 | opcode: 'setBPM', |
149 | 172 | blockType: Scratch.BlockType.COMMAND, |
|
187 | 210 | blockType: Scratch.BlockType.COMMAND, |
188 | 211 | text: 'wait until next beat' |
189 | 212 | }, |
| 213 | + { |
| 214 | + opcode: 'waitUntilBeat', |
| 215 | + blockType: Scratch.BlockType.COMMAND, |
| 216 | + text: 'wait until beat [BEAT]', |
| 217 | + arguments: { |
| 218 | + BEAT: { |
| 219 | + type: Scratch.ArgumentType.NUMBER, |
| 220 | + defaultValue: 16 |
| 221 | + } |
| 222 | + } |
| 223 | + }, |
| 224 | + { |
| 225 | + opcode: 'waitUntilBeatPosition', |
| 226 | + blockType: Scratch.BlockType.COMMAND, |
| 227 | + text: 'wait until beat position reaches [STEP]', |
| 228 | + arguments: { |
| 229 | + STEP: { |
| 230 | + type: Scratch.ArgumentType.NUMBER, |
| 231 | + defaultValue: 0.5 |
| 232 | + } |
| 233 | + } |
| 234 | + }, |
190 | 235 | '---', |
191 | 236 | { |
192 | 237 | opcode: 'getBeatValue', |
|
203 | 248 | blockType: Scratch.BlockType.REPORTER, |
204 | 249 | text: 'current measure number' |
205 | 250 | }, |
| 251 | + { |
| 252 | + opcode: 'getBeatInMeasure', |
| 253 | + blockType: Scratch.BlockType.REPORTER, |
| 254 | + text: 'beat within measure' |
| 255 | + }, |
206 | 256 | { |
207 | 257 | opcode: 'getTimeBetweenBeats', |
208 | 258 | blockType: Scratch.BlockType.REPORTER, |
209 | 259 | text: 'time between beats (s)' |
210 | 260 | }, |
| 261 | + { |
| 262 | + opcode: 'getElapsedTime', |
| 263 | + blockType: Scratch.BlockType.REPORTER, |
| 264 | + text: 'elapsed time (s)' |
| 265 | + }, |
211 | 266 | '---', |
| 267 | + { |
| 268 | + opcode: 'onBeat', |
| 269 | + blockType: Scratch.BlockType.HAT, |
| 270 | + text: 'on beat [BEAT]', |
| 271 | + isEdgeActivated: true, |
| 272 | + arguments: { |
| 273 | + BEAT: { |
| 274 | + type: Scratch.ArgumentType.NUMBER, |
| 275 | + defaultValue: 8 |
| 276 | + } |
| 277 | + } |
| 278 | + }, |
| 279 | + { |
| 280 | + opcode: 'everyNBeats', |
| 281 | + blockType: Scratch.BlockType.HAT, |
| 282 | + text: 'every [N] beats', |
| 283 | + isEdgeActivated: true, |
| 284 | + arguments: { |
| 285 | + N: { |
| 286 | + type: Scratch.ArgumentType.NUMBER, |
| 287 | + defaultValue: 2 |
| 288 | + } |
| 289 | + } |
| 290 | + }, |
212 | 291 | { |
213 | 292 | opcode: 'whenBeatReachesStep', |
214 | 293 | blockType: Scratch.BlockType.HAT, |
|
221 | 300 | } |
222 | 301 | } |
223 | 302 | }, |
| 303 | + { |
| 304 | + opcode: 'whenBeatBetween', |
| 305 | + blockType: Scratch.BlockType.HAT, |
| 306 | + text: 'when beat position between [A] and [B]', |
| 307 | + isEdgeActivated: true, |
| 308 | + arguments: { |
| 309 | + A: { |
| 310 | + type: Scratch.ArgumentType.NUMBER, |
| 311 | + defaultValue: 0 |
| 312 | + }, |
| 313 | + B: { |
| 314 | + type: Scratch.ArgumentType.NUMBER, |
| 315 | + defaultValue: 0.25 |
| 316 | + } |
| 317 | + } |
| 318 | + }, |
224 | 319 | { |
225 | 320 | opcode: 'whenFullBeat', |
226 | 321 | blockType: Scratch.BlockType.HAT, |
|
232 | 327 | blockType: Scratch.BlockType.HAT, |
233 | 328 | text: 'when new measure starts', |
234 | 329 | isEdgeActivated: true |
| 330 | + }, |
| 331 | + { |
| 332 | + opcode: 'whenNthBeatInMeasure', |
| 333 | + blockType: Scratch.BlockType.HAT, |
| 334 | + text: 'when beat [N] in measure starts', |
| 335 | + isEdgeActivated: true, |
| 336 | + arguments: { |
| 337 | + N: { |
| 338 | + type: Scratch.ArgumentType.NUMBER, |
| 339 | + defaultValue: 3 |
| 340 | + } |
| 341 | + } |
| 342 | + }, |
| 343 | + { |
| 344 | + opcode: 'whenBeatStarted', |
| 345 | + blockType: Scratch.BlockType.HAT, |
| 346 | + text: 'when syncing starts', |
| 347 | + isEdgeActivated: true |
| 348 | + }, |
| 349 | + { |
| 350 | + opcode: 'whenBeatStopped', |
| 351 | + blockType: Scratch.BlockType.HAT, |
| 352 | + text: 'when syncing stops', |
| 353 | + isEdgeActivated: true |
235 | 354 | } |
236 | 355 | ], |
237 | 356 | menus: {} |
|
258 | 377 |
|
259 | 378 | startBeat() { |
260 | 379 | if (!this.isRunning) { |
| 380 | + this._wasRunning = false; |
261 | 381 | this.isRunning = true; |
262 | 382 | const ctx = this.getAudioCtx(); |
263 | 383 | this.startAudioTime = ctx.currentTime; |
|
268 | 388 | if (this.isRunning) { |
269 | 389 | this.pausedElapsed = this.getElapsedSeconds(); |
270 | 390 | this.isRunning = false; |
| 391 | + this._wasStopped = true; |
271 | 392 | } |
272 | 393 | } |
273 | 394 |
|
|
290 | 411 | if (Math.floor(this.totalBeats) > startBeat) { |
291 | 412 | resolve(); |
292 | 413 | } else { |
293 | | - requestAnimationFrame(poll); |
| 414 | + scheduleFrame(poll); |
294 | 415 | } |
295 | 416 | }; |
296 | | - requestAnimationFrame(poll); |
| 417 | + scheduleFrame(poll); |
| 418 | + }); |
| 419 | + } |
| 420 | + |
| 421 | + waitUntilBeat(args) { |
| 422 | + const target = Number(args.BEAT); |
| 423 | + return new Promise(resolve => { |
| 424 | + const poll = () => { |
| 425 | + this.updateTime(); |
| 426 | + if (this.totalBeats >= target) { |
| 427 | + resolve(); |
| 428 | + } else { |
| 429 | + scheduleFrame(poll); |
| 430 | + } |
| 431 | + }; |
| 432 | + scheduleFrame(poll); |
| 433 | + }); |
| 434 | + } |
| 435 | + |
| 436 | + waitUntilBeatPosition(args) { |
| 437 | + const target = Number(args.STEP) % 1; |
| 438 | + this.updateTime(); |
| 439 | + const startBeat = Math.floor(this.totalBeats); |
| 440 | + return new Promise(resolve => { |
| 441 | + const poll = () => { |
| 442 | + this.updateTime(); |
| 443 | + const currentBeat = Math.floor(this.totalBeats); |
| 444 | + if (currentBeat > startBeat && this.beatPosition >= target) { |
| 445 | + resolve(); |
| 446 | + } else if (currentBeat === startBeat && this.beatPosition >= target && this.beatPosition < target + 0.5) { |
| 447 | + resolve(); |
| 448 | + } else { |
| 449 | + scheduleFrame(poll); |
| 450 | + } |
| 451 | + }; |
| 452 | + scheduleFrame(poll); |
297 | 453 | }); |
298 | 454 | } |
299 | 455 |
|
|
312 | 468 | return Math.floor(this.totalBeats / this.beatsPerMeasure); |
313 | 469 | } |
314 | 470 |
|
| 471 | + getBeatInMeasure() { |
| 472 | + this.updateTime(); |
| 473 | + return Math.floor(this.totalBeats % this.beatsPerMeasure) + 1; |
| 474 | + } |
| 475 | + |
315 | 476 | getTimeBetweenBeats() { |
316 | 477 | return 60 / this.bpm; |
317 | 478 | } |
318 | 479 |
|
| 480 | + getElapsedTime() { |
| 481 | + this.updateTime(); |
| 482 | + return Math.round(this.getElapsedSeconds() * 1000) / 1000; |
| 483 | + } |
| 484 | + |
| 485 | + onBeat(args) { |
| 486 | + if (!this.isRunning) return false; |
| 487 | + this.updateTime(); |
| 488 | + const target = Number(args.BEAT); |
| 489 | + return this.totalBeats >= target && this.totalBeats < target + 0.5; |
| 490 | + } |
| 491 | + |
| 492 | + everyNBeats(args) { |
| 493 | + if (!this.isRunning) return false; |
| 494 | + this.updateTime(); |
| 495 | + const n = Math.max(1, Number(args.N)); |
| 496 | + return (this.totalBeats % n) < 0.5; |
| 497 | + } |
| 498 | + |
319 | 499 | whenBeatReachesStep(args) { |
320 | 500 | if (!this.isRunning) return false; |
321 | 501 | this.updateTime(); |
322 | 502 | return this.beatPosition >= (Number(args.STEP) % 1); |
323 | 503 | } |
324 | 504 |
|
| 505 | + whenBeatBetween(args) { |
| 506 | + if (!this.isRunning) return false; |
| 507 | + this.updateTime(); |
| 508 | + const a = Number(args.A) % 1; |
| 509 | + const b = Number(args.B) % 1; |
| 510 | + if (a <= b) { |
| 511 | + return this.beatPosition >= a && this.beatPosition < b; |
| 512 | + } else { |
| 513 | + return this.beatPosition >= a || this.beatPosition < b; |
| 514 | + } |
| 515 | + } |
| 516 | + |
325 | 517 | whenFullBeat() { |
326 | 518 | if (!this.isRunning) return false; |
327 | 519 | this.updateTime(); |
|
335 | 527 | return beatInMeasure < 0.5; |
336 | 528 | } |
337 | 529 |
|
| 530 | + whenNthBeatInMeasure(args) { |
| 531 | + if (!this.isRunning) return false; |
| 532 | + this.updateTime(); |
| 533 | + const n = Math.max(1, Number(args.N)); |
| 534 | + const beatInMeasure = this.totalBeats % this.beatsPerMeasure; |
| 535 | + return beatInMeasure >= (n - 1) && beatInMeasure < (n - 1) + 0.5; |
| 536 | + } |
| 537 | + |
| 538 | + whenBeatStarted() { |
| 539 | + return this.isRunning; |
| 540 | + } |
| 541 | + |
| 542 | + whenBeatStopped() { |
| 543 | + return !this.isRunning; |
| 544 | + } |
| 545 | + |
338 | 546 | onGreenFlag() { |
339 | 547 | this.resetBeat(); |
340 | 548 | if (this.autoStart) { |
|
0 commit comments