Skip to content

Commit 8b5a71d

Browse files
committed
fix: correct Glide buffered synth playback
1 parent 9aca5b2 commit 8b5a71d

4 files changed

Lines changed: 222 additions & 65 deletions

File tree

js/__tests__/turtle-singer.test.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,145 @@ describe("Singer Class", () => {
159159
});
160160
});
161161

162+
describe("Singer.playGlideBuffer", () => {
163+
let activity;
164+
let turtle;
165+
166+
beforeEach(() => {
167+
turtle = {
168+
singer: {
169+
glideStartTime: 10,
170+
glideBuffer: []
171+
}
172+
};
173+
activity = {
174+
stageDirty: false,
175+
turtles: { ithTurtle: jest.fn().mockReturnValue(turtle) },
176+
logo: { synth: { trigger: jest.fn() } }
177+
};
178+
});
179+
180+
test("preserves note order, offsets, effects, filters, and portamento", () => {
181+
const effects = { doPortamento: true, portamento: 0.125 };
182+
const filters = [{ filterFrequency: 440 }];
183+
turtle.singer.glideBuffer = [
184+
{
185+
target: "piano",
186+
note: "C4",
187+
duration: 0.5,
188+
time: 10,
189+
future: 0,
190+
paramsEffects: effects,
191+
filters: filters
192+
},
193+
{
194+
target: "piano",
195+
note: "E4",
196+
duration: 0.5,
197+
time: 10.5,
198+
future: 0,
199+
paramsEffects: effects,
200+
filters: filters
201+
},
202+
{
203+
target: "piano",
204+
note: "G4",
205+
duration: 0.5,
206+
time: 11,
207+
future: 0.25,
208+
paramsEffects: effects,
209+
filters: filters
210+
}
211+
];
212+
213+
Singer.playGlideBuffer(activity, 3);
214+
215+
expect(activity.logo.synth.trigger).toHaveBeenNthCalledWith(
216+
1,
217+
3,
218+
"C4",
219+
0.5,
220+
"piano",
221+
effects,
222+
filters,
223+
false,
224+
0
225+
);
226+
expect(activity.logo.synth.trigger).toHaveBeenNthCalledWith(
227+
2,
228+
3,
229+
"E4",
230+
0.5,
231+
"piano",
232+
effects,
233+
filters,
234+
true,
235+
0.5
236+
);
237+
expect(activity.logo.synth.trigger).toHaveBeenNthCalledWith(
238+
3,
239+
3,
240+
"G4",
241+
0.5,
242+
"piano",
243+
effects,
244+
filters,
245+
true,
246+
1.25
247+
);
248+
expect(turtle.singer.glideBuffer).toEqual([]);
249+
expect(activity.stageDirty).toBe(true);
250+
});
251+
252+
test("uses same real synth API for voice playback", () => {
253+
turtle.singer.glideBuffer = [
254+
{
255+
target: "vocal",
256+
note: "C4",
257+
duration: 1,
258+
time: 10,
259+
future: 0,
260+
paramsEffects: { doPortamento: true, portamento: 1 / 16 },
261+
filters: null
262+
}
263+
];
264+
265+
Singer.playGlideBuffer(activity, 2);
266+
267+
expect(activity.logo.synth.trigger).toHaveBeenCalledWith(
268+
2,
269+
"C4",
270+
1,
271+
"vocal",
272+
{ doPortamento: true, portamento: 1 / 16 },
273+
null,
274+
false,
275+
0
276+
);
277+
});
278+
279+
test("resets the buffer when triggering throws", () => {
280+
activity.logo.synth.trigger.mockImplementation(() => {
281+
throw new Error("synth failure");
282+
});
283+
turtle.singer.glideBuffer = [
284+
{
285+
target: "piano",
286+
note: "C4",
287+
duration: 1,
288+
time: 10,
289+
future: 0,
290+
paramsEffects: null,
291+
filters: null
292+
}
293+
];
294+
295+
expect(() => Singer.playGlideBuffer(activity, 0)).toThrow("synth failure");
296+
expect(turtle.singer.glideBuffer).toEqual([]);
297+
expect(activity.stageDirty).toBe(true);
298+
});
299+
});
300+
162301
describe("State initialization — note parameters", () => {
163302
let singer;
164303

js/blocks/OrnamentBlocks.js

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -417,28 +417,34 @@ function setupOrnamentBlocks(activity) {
417417
logo.notation.notationBeginSlur(turtle);
418418
}
419419

420-
// Signal the start of a glissando to the singer
420+
const isOuterGlide = tur.singer.glide.length === 1;
421421
tur.singer.inGlide = true;
422-
tur.singer.glideDuration = 0;
423-
tur.singer.glideStartTime = tur.singer.turtleTime;
424-
tur.singer.glideBuffer = [];
422+
if (isOuterGlide) {
423+
tur.singer.glideStartTime = tur.singer.turtleTime;
424+
tur.singer.glideBuffer = [];
425+
}
425426

426-
const listenerName = "_glide_" + turtle;
427+
const listenerName = "_glide_" + turtle + "_" + blk;
427428
logo.setDispatchBlock(blk, turtle, listenerName);
428429

429430
const __listener = event => {
430-
if (tur.singer.justCounting.length === 0) {
431-
logo.notation.notationEndSlur(turtle);
432-
}
433-
434-
// Play the buffered glissando
435-
if (tur.singer.glideBuffer.length > 0) {
436-
Singer.playGlideBuffer(activity, turtle);
431+
const isOuterGlide = tur.singer.glide.length === 1;
432+
try {
433+
if (tur.singer.justCounting.length === 0) {
434+
logo.notation.notationEndSlur(turtle);
435+
}
436+
437+
if (isOuterGlide) {
438+
Singer.playGlideBuffer(activity, turtle);
439+
}
440+
} finally {
441+
tur.singer.glide.pop();
442+
tur.singer.inGlide = tur.singer.glide.length > 0;
443+
if (!tur.singer.inGlide) {
444+
tur.singer.glideBuffer = [];
445+
tur.singer.glideStartTime = 0;
446+
}
437447
}
438-
439-
// Signal the end of the glissando
440-
tur.singer.inGlide = false;
441-
tur.singer.glide.pop();
442448
};
443449

444450
logo.setTurtleListener(turtle, listenerName, __listener);

js/blocks/__tests__/OrnamentBlocks.test.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ describe("setupOrnamentBlocks", () => {
100100
glide: [],
101101
justCounting: [],
102102
glideBuffer: [],
103-
glideDuration: 0,
104-
inGlide: false
103+
inGlide: false,
104+
turtleTime: 0,
105+
glideStartTime: 0
105106
}
106107
};
107108
}
@@ -251,6 +252,7 @@ describe("setupOrnamentBlocks", () => {
251252
expect(turtleObj.singer.glide).toContain(0.1);
252253
expect(turtleObj.singer.inGlide).toBe(true);
253254
expect(turtleObj.singer.glideBuffer).toEqual([]);
255+
expect(Singer.noteCounter).not.toHaveBeenCalled();
254256
expect(logo.notation.notationBeginSlur).toHaveBeenCalledWith(turtleIndex);
255257
expect(logo.setDispatchBlock).toHaveBeenCalledWith(
256258
"blkGlide",
@@ -261,6 +263,17 @@ describe("setupOrnamentBlocks", () => {
261263
expect(result).toEqual([8, 1]);
262264
});
263265

266+
it("should not run a side-effecting clamp while starting a glide", () => {
267+
const glideBlock = createdBlocks["glide"];
268+
const clampFlow = jest.fn();
269+
Singer.noteCounter.mockImplementation(clampFlow);
270+
271+
glideBlock.flow([0.1, "clamp"], logo, 0, "blkGlide");
272+
273+
expect(Singer.noteCounter).not.toHaveBeenCalled();
274+
expect(clampFlow).not.toHaveBeenCalled();
275+
});
276+
264277
it("should default glide argument and call errorMsg if first argument is invalid", () => {
265278
const glideBlock = createdBlocks["glide"];
266279
const turtleIndex = 0;
@@ -291,6 +304,32 @@ describe("setupOrnamentBlocks", () => {
291304
expect(turtleObj.singer.inGlide).toBe(false);
292305
});
293306

307+
it("should keep buffered notes until the outer nested glide ends", () => {
308+
const glideBlock = createdBlocks["glide"];
309+
const turtleObj = activity.turtles.ithTurtle(0);
310+
const listeners = [];
311+
logo.setTurtleListener = jest.fn((turtle, name, listener) => listeners.push(listener));
312+
313+
glideBlock.flow([0.1, "outer"], logo, 0, "outer");
314+
turtleObj.singer.glideBuffer.push({ note: "C4" });
315+
glideBlock.flow([0.2, "inner"], logo, 0, "inner");
316+
317+
expect(logo.setTurtleListener.mock.calls[0][1]).not.toBe(
318+
logo.setTurtleListener.mock.calls[1][1]
319+
);
320+
listeners[1]({});
321+
322+
expect(Singer.playGlideBuffer).not.toHaveBeenCalled();
323+
expect(turtleObj.singer.inGlide).toBe(true);
324+
expect(turtleObj.singer.glideBuffer).toEqual([{ note: "C4" }]);
325+
326+
listeners[0]({});
327+
328+
expect(Singer.playGlideBuffer).toHaveBeenCalledWith(activity, 0);
329+
expect(turtleObj.singer.inGlide).toBe(false);
330+
expect(turtleObj.singer.glideBuffer).toEqual([]);
331+
});
332+
294333
it("should skip notationEndSlur in __listener when justCounting is non-empty", () => {
295334
const glideBlock = createdBlocks["glide"];
296335
const turtleIndex = 0;

js/turtle-singer.js

Lines changed: 20 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ class Singer {
175175
this.glide = [];
176176
this.glideOverride = 0;
177177
this.inGlide = false;
178-
this.glideDuration = 0;
179178
this.glideBuffer = [];
180179
this.glideStartTime = 0;
181180
this.swing = [];
@@ -1887,6 +1886,7 @@ class Singer {
18871886
}
18881887
}
18891888

1889+
const glideTime = tur.singer.turtleTime;
18901890
// Duration is the duration of the note to be played. doWait sets the wait time for the turtle before the next block is executed
18911891
const duration = tur.singer.inGlide ? 0 : noteBeatValue;
18921892
// For the outermost note (when nesting), calculate the time for the next note
@@ -2355,15 +2355,16 @@ class Singer {
23552355
? last(tur.singer.voices)
23562356
: null;
23572357
const target = instrument || voice || DEFAULTVOICE;
2358-
const isVoice = !!voice;
23592358

23602359
if (tur.singer.inGlide) {
23612360
const bufferNote = {
23622361
target: target,
23632362
note: notes[d],
23642363
duration: beatValue,
2365-
time: tur.singer.turtleTime,
2366-
isVoice: isVoice
2364+
time: glideTime,
2365+
future: future,
2366+
paramsEffects: paramsEffects,
2367+
filters: filters
23672368
};
23682369
tur.singer.glideBuffer.push(bufferNote);
23692370
} else {
@@ -2595,53 +2596,25 @@ class Singer {
25952596
const buffer = tur.singer.glideBuffer;
25962597
if (buffer.length === 0) return;
25972598

2598-
const bpm = tur.singer.bpm[tur.singer.bpm.length - 1];
2599-
const portamento = 60 / (bpm * 8); // Standard portamento for glide
2600-
2601-
// The first note in the buffer should be played normally to establish the starting pitch
2602-
// Subsequent notes should be played with portamento.
26032599
const startTime = tur.singer.glideStartTime;
2604-
2605-
buffer.forEach((noteObj, index) => {
2606-
const isFirst = index === 0;
2607-
const noteDuration = noteObj.duration;
2608-
const scheduledTime = noteObj.time;
2609-
2610-
// Calculate the delay relative to Tone.now()
2611-
// turtleTime is absolute in seconds relative to project start.
2612-
// scheduledTime - startTime is the offset within the glide.
2613-
const delay = scheduledTime - startTime;
2614-
2615-
// Trigger the note
2616-
if (noteObj.isVoice) {
2617-
activity.synth.setNote(noteObj.target, noteObj.note, true);
2618-
activity.synth.trigger(
2619-
noteObj.target,
2600+
try {
2601+
buffer.forEach((noteObj, index) => {
2602+
const future = Math.max(0, (noteObj.future || 0) + noteObj.time - startTime);
2603+
activity.logo.synth.trigger(
2604+
turtle,
26202605
noteObj.note,
2621-
noteDuration,
2622-
true,
2623-
true,
2624-
delay
2625-
);
2626-
} else {
2627-
if (!isFirst) {
2628-
activity.synth.setPortamento(noteObj.target, portamento);
2629-
}
2630-
activity.synth.trigger(
2606+
noteObj.duration,
26312607
noteObj.target,
2632-
noteObj.note,
2633-
noteDuration,
2634-
false,
2635-
true,
2636-
delay,
2637-
!isFirst // doPortamento
2608+
noteObj.paramsEffects,
2609+
noteObj.filters,
2610+
index > 0,
2611+
future
26382612
);
2639-
}
2640-
});
2641-
2642-
// Reset buffer and state
2643-
tur.singer.glideBuffer = [];
2644-
activity.stageDirty = true;
2613+
});
2614+
} finally {
2615+
tur.singer.glideBuffer = [];
2616+
activity.stageDirty = true;
2617+
}
26452618
}
26462619
}
26472620

0 commit comments

Comments
 (0)