Skip to content

Commit d09973a

Browse files
authored
perf: replace setTimeout scheduling with Tone.Transport for playback (#7703)
* perf: use Tone.Transport.schedule in runFromBlock * perf: strip instrumentation from turtle-singer and synthutils * fix: start Transport before block execution, update mock * test: add coverage for Transport scheduling and stop cleanup * fix: handle Transport-scheduled block interruption * chore: add static clear() to Transport mock * fix: remove delay===0 guard in setTimeout fallback * refactor: extract Logo._hasTransport helper * test: invoke Transport schedule callback and verify state updates * refactor: remove redundant _hasTransport check in clearTurtleRun * refactor: cleanup prepRun loop, delayParameters, test mocks * fix: guard Transport methods, lazy init _transportTime * refactor: route all Transport calls through synthutils transport wrapper * fix: null stale delayTimeout in setTimeout callback
1 parent 37fafb2 commit d09973a

5 files changed

Lines changed: 396 additions & 33 deletions

File tree

js/__tests__/logo.test.js

Lines changed: 264 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,47 @@ global.Synth = jest.fn().mockImplementation(() => ({
3636
stopSound: jest.fn(),
3737
disposeAllInstruments: jest.fn(),
3838
changeInTemperament: false,
39-
recorder: null
39+
recorder: null,
40+
transport: {
41+
get isAvailable() {
42+
return typeof global.Tone !== "undefined" && !!global.Tone.Transport;
43+
},
44+
start() {
45+
if (this.isAvailable) global.Tone.Transport.start();
46+
},
47+
stop() {
48+
if (this.isAvailable) global.Tone.Transport.stop();
49+
},
50+
cancel() {
51+
if (this.isAvailable && typeof global.Tone.Transport.cancel === "function") {
52+
global.Tone.Transport.cancel();
53+
}
54+
},
55+
clear(id) {
56+
if (this.isAvailable && typeof global.Tone.Transport.clear === "function") {
57+
global.Tone.Transport.clear(id);
58+
}
59+
},
60+
schedule(callback, time) {
61+
if (this.isAvailable && typeof global.Tone.Transport.schedule === "function") {
62+
return global.Tone.Transport.schedule(callback, time);
63+
}
64+
return null;
65+
},
66+
get seconds() {
67+
if (this.isAvailable) return global.Tone.Transport.seconds;
68+
return 0;
69+
},
70+
set seconds(v) {
71+
if (this.isAvailable) global.Tone.Transport.seconds = v;
72+
},
73+
getSecondsAtTime(time) {
74+
if (this.isAvailable && typeof global.Tone.Transport.getSecondsAtTime === "function") {
75+
return global.Tone.Transport.getSecondsAtTime(time);
76+
}
77+
return this.seconds;
78+
}
79+
}
4080
}));
4181
global.Singer = {
4282
setSynthVolume: jest.fn(),
@@ -435,14 +475,28 @@ describe("Logo Class", () => {
435475
});
436476

437477
describe("doStopTurtles", () => {
438-
test("sets stopTurtle to true", () => {
439-
logo.sounds = [];
440-
logo.synth = {
478+
function mockSynth() {
479+
return {
441480
stop: jest.fn(),
442481
stopSound: jest.fn(),
443482
disposeAllInstruments: jest.fn(),
444-
recorder: null
483+
recorder: null,
484+
transport: {
485+
get isAvailable() {
486+
return false;
487+
},
488+
cancel: jest.fn(),
489+
get seconds() {
490+
return 0;
491+
},
492+
set seconds(v) {}
493+
}
445494
};
495+
}
496+
497+
test("sets stopTurtle to true", () => {
498+
logo.sounds = [];
499+
logo.synth = mockSynth();
446500

447501
logo.doStopTurtles();
448502

@@ -451,12 +505,7 @@ describe("Logo Class", () => {
451505

452506
test("marks all turtles as stopped", () => {
453507
logo.sounds = [];
454-
logo.synth = {
455-
stop: jest.fn(),
456-
stopSound: jest.fn(),
457-
disposeAllInstruments: jest.fn(),
458-
recorder: null
459-
};
508+
logo.synth = mockSynth();
460509

461510
logo.doStopTurtles();
462511

@@ -466,12 +515,7 @@ describe("Logo Class", () => {
466515
test("stops all sounds", () => {
467516
const mockSound = { stop: jest.fn() };
468517
logo.sounds = [mockSound];
469-
logo.synth = {
470-
stop: jest.fn(),
471-
stopSound: jest.fn(),
472-
disposeAllInstruments: jest.fn(),
473-
recorder: null
474-
};
518+
logo.synth = mockSynth();
475519

476520
logo.doStopTurtles();
477521

@@ -481,12 +525,7 @@ describe("Logo Class", () => {
481525

482526
test("clears step queue", () => {
483527
logo.sounds = [];
484-
logo.synth = {
485-
stop: jest.fn(),
486-
stopSound: jest.fn(),
487-
disposeAllInstruments: jest.fn(),
488-
recorder: null
489-
};
528+
logo.synth = mockSynth();
490529
logo.stepQueue = { 0: [1, 2, 3] };
491530

492531
logo.doStopTurtles();
@@ -496,12 +535,7 @@ describe("Logo Class", () => {
496535

497536
test("executes ONSTOP plugin hooks", () => {
498537
logo.sounds = [];
499-
logo.synth = {
500-
stop: jest.fn(),
501-
stopSound: jest.fn(),
502-
disposeAllInstruments: jest.fn(),
503-
recorder: null
504-
};
538+
logo.synth = mockSynth();
505539
logo.evalOnStopList = {
506540
firstHook: "code-first",
507541
secondHook: "code-second"
@@ -719,6 +753,8 @@ describe("Logo comprehensive method coverage", () => {
719753
x: 0,
720754
y: 0,
721755
companionTurtle: null,
756+
_transportTime: null,
757+
_transportEventId: null,
722758
doShowImage: jest.fn(),
723759
doShowURL: jest.fn(),
724760
doShowText: jest.fn()
@@ -915,6 +951,80 @@ describe("Logo comprehensive method coverage", () => {
915951
expect(logo.runFromBlockNow).toHaveBeenCalledWith(logo, 0, 3, 1, "x");
916952
});
917953

954+
test("runFromBlock uses Transport schedule for non-zero delay when available", () => {
955+
const originalTone = global.Tone;
956+
const getSecondsAtTimeMock = jest.fn(t => t + 1);
957+
let scheduledCallback = null;
958+
const scheduleSpy = jest.fn((fn, time) => {
959+
scheduledCallback = fn;
960+
return "evt-1";
961+
});
962+
global.Tone = {
963+
...originalTone,
964+
Transport: {
965+
start: jest.fn(),
966+
stop: jest.fn(),
967+
schedule: scheduleSpy,
968+
cancel: jest.fn(),
969+
getSecondsAtTime: getSecondsAtTimeMock,
970+
get seconds() {
971+
return 0;
972+
},
973+
set seconds(v) {}
974+
}
975+
};
976+
977+
logo.runFromBlockNow = jest.fn();
978+
logo.turtleDelay = 0;
979+
logo.stopTurtle = false;
980+
turtle0.waitTime = 200;
981+
turtle0._transportTime = 10;
982+
turtle0.delayParameters = null;
983+
turtle0._transportEventId = null;
984+
985+
logo.runFromBlock(logo, 0, 3, 1, "x");
986+
987+
expect(scheduleSpy).toHaveBeenCalledWith(expect.any(Function), 10 + 200 / 1000);
988+
expect(logo.runFromBlockNow).not.toHaveBeenCalled();
989+
expect(turtle0._transportEventId).toBe("evt-1");
990+
991+
// Invoke the scheduled callback and verify state updates
992+
scheduledCallback(5.5);
993+
expect(getSecondsAtTimeMock).toHaveBeenCalledWith(5.5);
994+
expect(turtle0._transportTime).toBe(6.5);
995+
expect(turtle0._transportEventId).toBeNull();
996+
expect(turtle0.delayParameters).toBeNull();
997+
expect(logo.runFromBlockNow).toHaveBeenCalledWith(logo, 0, 3, 1, "x");
998+
999+
global.Tone = originalTone;
1000+
turtle0._transportTime = null;
1001+
turtle0._transportEventId = null;
1002+
turtle0.delayParameters = null;
1003+
});
1004+
1005+
test("runFromBlock falls back to setTimeout when Transport is unavailable", () => {
1006+
const originalTone = global.Tone;
1007+
global.Tone = { ...originalTone, Transport: undefined };
1008+
timeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation(fn => {
1009+
fn();
1010+
return 5;
1011+
});
1012+
1013+
logo.runFromBlockNow = jest.fn();
1014+
logo.turtleDelay = 0;
1015+
logo.stopTurtle = false;
1016+
turtle0.waitTime = 200;
1017+
1018+
logo.runFromBlock(logo, 0, 3, 1, "x");
1019+
1020+
expect(timeoutSpy).toHaveBeenCalled();
1021+
expect(logo.runFromBlockNow).toHaveBeenCalledWith(logo, 0, 3, 1, "x");
1022+
1023+
global.Tone = originalTone;
1024+
timeoutSpy.mockRestore();
1025+
timeoutSpy = null;
1026+
});
1027+
9181028
test("runFromBlockNow executes flow block and queues next block", () => {
9191029
logo.runFromBlock = jest.fn();
9201030
logo.blockList = [
@@ -1184,13 +1294,59 @@ describe("Logo comprehensive method coverage", () => {
11841294
stop: jest.fn(),
11851295
stopSound: jest.fn(),
11861296
disposeAllInstruments: jest.fn(),
1187-
recorder: { state: "recording", stop: jest.fn() }
1297+
recorder: { state: "recording", stop: jest.fn() },
1298+
transport: {
1299+
get isAvailable() {
1300+
return typeof global.Tone !== "undefined" && !!global.Tone.Transport;
1301+
},
1302+
cancel() {
1303+
if (this.isAvailable && typeof global.Tone.Transport.cancel === "function") {
1304+
global.Tone.Transport.cancel();
1305+
}
1306+
},
1307+
get seconds() {
1308+
if (this.isAvailable) return global.Tone.Transport.seconds;
1309+
return 0;
1310+
},
1311+
set seconds(v) {
1312+
if (this.isAvailable) global.Tone.Transport.seconds = v;
1313+
}
1314+
}
11881315
};
11891316
logo._restoreConnections = jest.fn();
11901317
mockActivity.showBlocksAfterRun = true;
11911318

1319+
const originalTone = global.Tone;
1320+
const cancelSpy = jest.fn();
1321+
let transportSeconds = 42;
1322+
global.Tone = {
1323+
...originalTone,
1324+
Transport: {
1325+
start: jest.fn(),
1326+
stop: jest.fn(),
1327+
cancel: cancelSpy,
1328+
getSecondsAtTime: jest.fn(() => 0),
1329+
get seconds() {
1330+
return transportSeconds;
1331+
},
1332+
set seconds(v) {
1333+
transportSeconds = v;
1334+
}
1335+
}
1336+
};
1337+
turtle0._transportTime = 15;
1338+
turtle1._transportTime = 25;
1339+
turtle0._transportEventId = "evt-1";
1340+
turtle1._transportEventId = "evt-2";
1341+
11921342
logo.doStopTurtles();
11931343

1344+
expect(cancelSpy).toHaveBeenCalled();
1345+
expect(turtle0._transportTime).toBeNull();
1346+
expect(turtle1._transportTime).toBeNull();
1347+
expect(turtle0._transportEventId).toBeNull();
1348+
expect(turtle1._transportEventId).toBeNull();
1349+
expect(transportSeconds).toBe(0);
11941350
expect(turtle0.singer.killAllVoices).toHaveBeenCalled();
11951351
expect(logo.synth.stopSound).toHaveBeenCalledWith("0", "flute");
11961352
expect(logo.synth.stopSound).toHaveBeenCalledWith("1", "piano");
@@ -1199,6 +1355,10 @@ describe("Logo comprehensive method coverage", () => {
11991355
expect(doStopVideoCam).toHaveBeenCalledWith("cam-1", logo.setCameraID);
12001356
expect(mockActivity.blocks.showBlocks).toHaveBeenCalled();
12011357
expect(document.getElementById).toHaveBeenCalledWith("stop");
1358+
1359+
turtle0._transportTime = null;
1360+
turtle1._transportTime = null;
1361+
global.Tone = originalTone;
12021362
clearIntervalSpy.mockRestore();
12031363
});
12041364

@@ -1285,6 +1445,80 @@ describe("Logo comprehensive method coverage", () => {
12851445
clearTimeoutSpy.mockRestore();
12861446
});
12871447

1448+
test("clearTurtleRun cancels Transport-scheduled event and resumes execution", () => {
1449+
const clearSpy = jest.fn();
1450+
const originalTone = global.Tone;
1451+
global.Tone = {
1452+
...originalTone,
1453+
Transport: {
1454+
start: jest.fn(),
1455+
stop: jest.fn(),
1456+
cancel: jest.fn(),
1457+
clear: clearSpy,
1458+
getSecondsAtTime: jest.fn(() => 42),
1459+
get seconds() {
1460+
return 42;
1461+
},
1462+
set seconds(v) {}
1463+
}
1464+
};
1465+
turtle0.delayTimeout = null;
1466+
turtle0._transportEventId = "evt-3";
1467+
turtle0.delayParameters = { blk: 7, flow: 0, arg: null };
1468+
turtle0._transportTime = 50;
1469+
logo.runFromBlockNow = jest.fn();
1470+
1471+
logo.clearTurtleRun(0);
1472+
1473+
expect(clearSpy).toHaveBeenCalledWith("evt-3");
1474+
expect(turtle0._transportEventId).toBeNull();
1475+
expect(turtle0._transportTime).toBe(42);
1476+
expect(logo.runFromBlockNow).toHaveBeenCalledWith(logo, 0, 7, 0, null);
1477+
expect(turtle0.delayParameters).toBeNull();
1478+
1479+
global.Tone = originalTone;
1480+
});
1481+
1482+
test("clearTurtleRun is no-op when no pending delay or Transport event", () => {
1483+
turtle0.delayTimeout = null;
1484+
turtle0._transportEventId = null;
1485+
logo.runFromBlockNow = jest.fn();
1486+
1487+
logo.clearTurtleRun(0);
1488+
1489+
expect(logo.runFromBlockNow).not.toHaveBeenCalled();
1490+
});
1491+
1492+
test("runFromBlock lazily initializes _transportTime when null", () => {
1493+
const originalTone = global.Tone;
1494+
global.Tone = {
1495+
...originalTone,
1496+
Transport: {
1497+
start: jest.fn(),
1498+
stop: jest.fn(),
1499+
schedule: jest.fn(() => "evt-1"),
1500+
cancel: jest.fn(),
1501+
getSecondsAtTime: jest.fn(() => 0),
1502+
get seconds() {
1503+
return 5;
1504+
},
1505+
set seconds(v) {}
1506+
}
1507+
};
1508+
logo.runFromBlockNow = jest.fn();
1509+
logo.turtleDelay = 0;
1510+
logo.stopTurtle = false;
1511+
turtle0.waitTime = 200;
1512+
turtle0._transportTime = null;
1513+
1514+
logo.runFromBlock(logo, 0, 3, 1, "x");
1515+
1516+
expect(turtle0._transportTime).toBe(5);
1517+
expect(logo.runFromBlockNow).not.toHaveBeenCalled();
1518+
1519+
global.Tone = originalTone;
1520+
});
1521+
12881522
test("setDispatchBlock adds clamp signal for normal and backward traversal", () => {
12891523
turtle0.endOfClampSignals = {};
12901524
logo.blockList = [

0 commit comments

Comments
 (0)