Skip to content

Commit 9317cee

Browse files
authored
Add Beat Sync (#509)
* add beat sync yay * beat sync in avif * add beat sync * fix two typos * removed underscores and removed check for scratch vm i really hope i didnt fuck something up or im going to die * more descriptive names for two blocks * use penguinmod's serialization for autostart, otherwise use tw's. haven't tested fully but it doesnt crash 🤷‍♂️ * it turns out: i am stupid and i forgot to account for base latency. done * turn auto start into a button (thanks gsa :D) * why am i so stupid, bug fixed * thank you gsa :D
1 parent f2f00bd commit 9317cee

3 files changed

Lines changed: 366 additions & 0 deletions

File tree

src/lib/extensions.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ export default [
6060
unstable: true,
6161
unstableReason: "WebGPU is still experimental and not supported by all browsers and does not work when packaged to electron. Check compatibility at webgpu.io."
6262
},
63+
{
64+
name: "Beat Sync",
65+
description: "An extension designed to let you sync anything in your project to a musical beat, with incredible precision.",
66+
code: "Gen1x/beat_sync.js",
67+
banner: "Gen1x/beat_sync.avif",
68+
creator: "G1nX",
69+
},
6370
{
6471
name: "Object",
6572
description: "Handle large JSON files at an extreme speed.",
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
(function(Scratch) {
2+
'use strict';
3+
4+
const isPenguinMod = Scratch.extensions.isPenguinMod;
5+
6+
class BeatSync {
7+
constructor() {
8+
this.bpm = 120;
9+
this.isRunning = false;
10+
this.autoStart = false;
11+
this.beatsPerMeasure = 4;
12+
13+
this.totalBeats = 0;
14+
this.beatPosition = 0;
15+
16+
this.audioCtx = null;
17+
this.startAudioTime = 0;
18+
this.pausedElapsed = 0;
19+
20+
this.vmEventBound = false;
21+
22+
this._loadAutoStart();
23+
}
24+
25+
_loadAutoStart() {
26+
if (!isPenguinMod) {
27+
try {
28+
const vm = Scratch.vm;
29+
const storage = vm.runtime.extensionStorage?.beatSync;
30+
if (storage && typeof storage.autoStart === 'boolean') {
31+
this.autoStart = storage.autoStart;
32+
}
33+
} catch (e) {
34+
}
35+
}
36+
}
37+
38+
_saveAutoStart() {
39+
if (!isPenguinMod) {
40+
try {
41+
const vm = Scratch.vm;
42+
if (!vm.runtime.extensionStorage) {
43+
vm.runtime.extensionStorage = {};
44+
}
45+
if (!vm.runtime.extensionStorage.beatSync) {
46+
vm.runtime.extensionStorage.beatSync = {};
47+
}
48+
vm.runtime.extensionStorage.beatSync.autoStart = this.autoStart;
49+
} catch (e) {
50+
}
51+
}
52+
}
53+
54+
serialize() {
55+
if (isPenguinMod) {
56+
return {
57+
autoStart: this.autoStart
58+
};
59+
}
60+
return {};
61+
}
62+
63+
deserialize(data) {
64+
if (isPenguinMod && data) {
65+
if (typeof data.autoStart === 'boolean') {
66+
this.autoStart = data.autoStart;
67+
}
68+
}
69+
}
70+
71+
getAudioCtx() {
72+
if (this.audioCtx) {
73+
return this.audioCtx;
74+
}
75+
76+
let scratchCtx = null;
77+
try {
78+
const vm = Scratch.vm;
79+
scratchCtx =
80+
vm?.runtime?.audioEngine?.audioContext ||
81+
vm?.runtime?.scratch?.audioEngine?.audioContext ||
82+
vm?.audioEngine?.audioContext ||
83+
null;
84+
} catch (e) {
85+
}
86+
87+
this.audioCtx = scratchCtx || new(window.AudioContext || window.webkitAudioContext)();
88+
if (this.audioCtx.state === 'suspended') this.audioCtx.resume();
89+
return this.audioCtx;
90+
}
91+
92+
getLatencySeconds() {
93+
const ctx = this.getAudioCtx();
94+
return (ctx.baseLatency || 0) + (ctx.outputLatency || 0);
95+
}
96+
97+
getElapsedSeconds() {
98+
if (!this.isRunning) return this.pausedElapsed;
99+
const ctx = this.getAudioCtx();
100+
return this.pausedElapsed + (ctx.currentTime - this.startAudioTime) + this.getLatencySeconds();
101+
}
102+
103+
tick() {
104+
if (!this.isRunning) return;
105+
const elapsed = this.getElapsedSeconds();
106+
const secondsPerBeat = 60 / this.bpm;
107+
this.totalBeats = elapsed / secondsPerBeat;
108+
this.beatPosition = this.totalBeats % 1;
109+
}
110+
111+
setupVMEvents() {
112+
if (this.vmEventBound) return;
113+
this.vmEventBound = true;
114+
115+
const vm = Scratch.vm;
116+
117+
vm.on('BEFORE_EXECUTE', () => {
118+
this.tick();
119+
});
120+
121+
if (isPenguinMod) {
122+
vm.runtime.on('RUNTIME_STEP_START', () => {
123+
this.tick();
124+
});
125+
}
126+
}
127+
128+
updateTime() {
129+
if (!this.isRunning) return;
130+
const elapsed = this.getElapsedSeconds();
131+
const secondsPerBeat = 60 / this.bpm;
132+
this.totalBeats = elapsed / secondsPerBeat;
133+
this.beatPosition = this.totalBeats % 1;
134+
}
135+
136+
getInfo() {
137+
return {
138+
id: 'beatSync',
139+
name: 'Beat Sync',
140+
color1: '#790612',
141+
blocks: [
142+
{
143+
opcode: 'toggleAutoStart',
144+
blockType: Scratch.BlockType.BUTTON,
145+
text: this.autoStart ? 'Auto start toggle: ON' : 'Auto start toggle: OFF'
146+
},
147+
{
148+
opcode: 'setBPM',
149+
blockType: Scratch.BlockType.COMMAND,
150+
text: 'set BPM to [BPM]',
151+
arguments: {
152+
BPM: {
153+
type: Scratch.ArgumentType.NUMBER,
154+
defaultValue: 120
155+
}
156+
}
157+
},
158+
{
159+
opcode: 'setBeatsPerMeasure',
160+
blockType: Scratch.BlockType.COMMAND,
161+
text: 'set beats per measure to [NUM]',
162+
arguments: {
163+
NUM: {
164+
type: Scratch.ArgumentType.NUMBER,
165+
defaultValue: 4
166+
}
167+
}
168+
},
169+
{
170+
opcode: 'startBeat',
171+
blockType: Scratch.BlockType.COMMAND,
172+
text: 'start syncing to beat'
173+
},
174+
{
175+
opcode: 'stopBeat',
176+
blockType: Scratch.BlockType.COMMAND,
177+
text: 'stop syncing to beat'
178+
},
179+
{
180+
opcode: 'resetBeat',
181+
blockType: Scratch.BlockType.COMMAND,
182+
text: 'reset beat to 0'
183+
},
184+
'---',
185+
{
186+
opcode: 'waitUntilNextBeat',
187+
blockType: Scratch.BlockType.COMMAND,
188+
text: 'wait until next beat'
189+
},
190+
'---',
191+
{
192+
opcode: 'getBeatValue',
193+
blockType: Scratch.BlockType.REPORTER,
194+
text: 'beat value (0.0-1.0)'
195+
},
196+
{
197+
opcode: 'getCurrentBeat',
198+
blockType: Scratch.BlockType.REPORTER,
199+
text: 'current beat number'
200+
},
201+
{
202+
opcode: 'getCurrentMeasure',
203+
blockType: Scratch.BlockType.REPORTER,
204+
text: 'current measure number'
205+
},
206+
{
207+
opcode: 'getTimeBetweenBeats',
208+
blockType: Scratch.BlockType.REPORTER,
209+
text: 'time between beats (s)'
210+
},
211+
'---',
212+
{
213+
opcode: 'whenBeatReachesStep',
214+
blockType: Scratch.BlockType.HAT,
215+
text: 'when beat reaches step [STEP]',
216+
isEdgeActivated: true,
217+
arguments: {
218+
STEP: {
219+
type: Scratch.ArgumentType.NUMBER,
220+
defaultValue: 0.5
221+
}
222+
}
223+
},
224+
{
225+
opcode: 'whenFullBeat',
226+
blockType: Scratch.BlockType.HAT,
227+
text: 'when full beat happens',
228+
isEdgeActivated: true
229+
},
230+
{
231+
opcode: 'whenMeasureHappen',
232+
blockType: Scratch.BlockType.HAT,
233+
text: 'when new measure starts',
234+
isEdgeActivated: true
235+
}
236+
],
237+
menus: {}
238+
};
239+
}
240+
241+
setBPM(args) {
242+
if (this.isRunning) {
243+
this.pausedElapsed = this.getElapsedSeconds();
244+
this.startAudioTime = this.getAudioCtx().currentTime;
245+
}
246+
this.bpm = Math.max(1, Number(args.BPM));
247+
}
248+
249+
setBeatsPerMeasure(args) {
250+
this.beatsPerMeasure = Math.max(1, Number(args.NUM));
251+
}
252+
253+
toggleAutoStart() {
254+
this.autoStart = !this.autoStart;
255+
this._saveAutoStart();
256+
Scratch.vm.extensionManager.refreshBlocks('beatSync');
257+
}
258+
259+
startBeat() {
260+
if (!this.isRunning) {
261+
this.isRunning = true;
262+
this.startAudioTime = this.getAudioCtx().currentTime;
263+
}
264+
}
265+
266+
stopBeat() {
267+
if (this.isRunning) {
268+
this.pausedElapsed = this.getElapsedSeconds();
269+
this.isRunning = false;
270+
}
271+
}
272+
273+
resetBeat() {
274+
this.pausedElapsed = 0;
275+
this.totalBeats = 0;
276+
this.beatPosition = 0;
277+
if (this.isRunning) {
278+
this.startAudioTime = this.getAudioCtx().currentTime;
279+
}
280+
}
281+
282+
waitUntilNextBeat() {
283+
this.updateTime();
284+
const startBeat = Math.floor(this.totalBeats);
285+
return new Promise(resolve => {
286+
const poll = () => {
287+
if (Math.floor(this.totalBeats) > startBeat) {
288+
resolve();
289+
} else {
290+
requestAnimationFrame(poll);
291+
}
292+
};
293+
requestAnimationFrame(poll);
294+
});
295+
}
296+
297+
getBeatValue() {
298+
this.updateTime();
299+
return Math.round(this.beatPosition * 100) / 100;
300+
}
301+
302+
getCurrentBeat() {
303+
this.updateTime();
304+
return Math.floor(this.totalBeats);
305+
}
306+
307+
getCurrentMeasure() {
308+
this.updateTime();
309+
return Math.floor(this.totalBeats / this.beatsPerMeasure);
310+
}
311+
312+
getTimeBetweenBeats() {
313+
return 60 / this.bpm;
314+
}
315+
316+
whenBeatReachesStep(args) {
317+
if (!this.isRunning) return false;
318+
this.updateTime();
319+
return this.beatPosition >= (Number(args.STEP) % 1);
320+
}
321+
322+
whenFullBeat() {
323+
if (!this.isRunning) return false;
324+
this.updateTime();
325+
return this.beatPosition < 0.5;
326+
}
327+
328+
whenMeasureHappen() {
329+
if (!this.isRunning) return false;
330+
this.updateTime();
331+
const beatInMeasure = this.totalBeats % this.beatsPerMeasure;
332+
return beatInMeasure < 0.5;
333+
}
334+
}
335+
336+
const extensionInstance = new BeatSync();
337+
338+
const runtime = Scratch.vm.runtime;
339+
340+
extensionInstance.setupVMEvents();
341+
342+
const originalGreenFlag = runtime.greenFlag;
343+
runtime.greenFlag = function() {
344+
extensionInstance.resetBeat();
345+
if (extensionInstance.autoStart) {
346+
extensionInstance.startBeat();
347+
}
348+
return originalGreenFlag.apply(this, arguments);
349+
};
350+
351+
const originalStopAll = runtime.stopAll;
352+
runtime.stopAll = function() {
353+
extensionInstance.stopBeat();
354+
extensionInstance.resetBeat();
355+
return originalStopAll.apply(this, arguments);
356+
};
357+
358+
Scratch.extensions.register(extensionInstance);
359+
})(Scratch);

static/images/Gen1x/Beat-Sync.avif

68.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)