Skip to content

Commit 1377ef3

Browse files
committed
fix: implement mobile-safe speaker fade-in
- Use setValueAtTime + linearRampToValueAtTime instead of cancelAndHoldAtTime - Add 20ms scheduling epsilon to prevent "in the past" errors on mobile - Start new speakers at NEARLY_ZERO and fade to target volume over 2s - Make fade-in duration configurable via newSpeakerFadeInDurationMs
1 parent f629bc7 commit 1377ef3

File tree

4 files changed

+259
-5
lines changed

4 files changed

+259
-5
lines changed

src/speaker/speaker_engine.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ describe("SpeakerEngine - repeatLoopOnLoopPoint", () => {
427427
offset: 0,
428428
pan: mockSpeakerTrack.loopConfig.pan,
429429
times: expect.any(Number),
430+
isNewSpeaker: false, // This is a track repetition, not a new speaker
430431
});
431432
});
432433

@@ -841,3 +842,182 @@ describe("SpeakerEngine - repeatLoopOnLoopPoint", () => {
841842
expect(secondCall.offset).toBe(0); // 25 % 10 = 5
842843
});
843844
});
845+
846+
describe("SpeakerTrack - New Speaker Fade-In", () => {
847+
let mockAudioContext: IAudioContext;
848+
let mockSpeakerData: ISpeakerData;
849+
let mockConfig: SpeakerConfig;
850+
851+
beforeEach(() => {
852+
// Create mock audio context
853+
mockAudioContext = {
854+
currentTime: 0,
855+
createBufferSource: jest.fn(() => ({
856+
buffer: null,
857+
loop: false,
858+
start: jest.fn(),
859+
stop: jest.fn(),
860+
onended: null,
861+
connect: jest.fn(),
862+
disconnect: jest.fn(),
863+
})),
864+
createGain: jest.fn(() => ({
865+
gain: {
866+
value: 0,
867+
cancelAndHoldAtTime: jest.fn(),
868+
exponentialRampToValueAtTime: jest.fn(),
869+
linearRampToValueAtTime: jest.fn(),
870+
setValueAtTime: jest.fn(),
871+
},
872+
connect: jest.fn(),
873+
disconnect: jest.fn(),
874+
})),
875+
createStereoPanner: jest.fn(() => ({
876+
pan: { value: 0 },
877+
connect: jest.fn(),
878+
disconnect: jest.fn(),
879+
})),
880+
createBuffer: jest.fn(() => ({
881+
duration: 10,
882+
sampleRate: 44100,
883+
numberOfChannels: 2,
884+
length: 441000,
885+
getChannelData: jest.fn(() => new Float32Array(441000)),
886+
})),
887+
destination: {},
888+
} as any;
889+
890+
// Create mock speaker data
891+
mockSpeakerData = {
892+
id: 1,
893+
maxvolume: 0.8,
894+
minvolume: 0.1,
895+
uri: "test-audio.mp3",
896+
varianturis: [],
897+
attenuation_distance: 50,
898+
shape: {
899+
type: "Feature",
900+
geometry: {
901+
type: "Polygon",
902+
coordinates: [
903+
[
904+
[0, 0],
905+
[1, 0],
906+
[1, 1],
907+
[0, 1],
908+
[0, 0],
909+
],
910+
],
911+
},
912+
properties: {},
913+
} as any,
914+
};
915+
916+
// Create mock config with new speaker fade-in duration
917+
mockConfig = {
918+
mode: "progressive-sync-basePlusMax5Random",
919+
newSpeakerFadeInDurationMs: 2000, // 2 seconds
920+
};
921+
});
922+
923+
it("should use configured fade-in duration", () => {
924+
const speakerTrack = new SpeakerTrack({
925+
data: mockSpeakerData,
926+
config: mockConfig,
927+
audioContext: mockAudioContext,
928+
groupId: 1,
929+
});
930+
931+
// Mock gain node
932+
const mockGainNode = {
933+
gain: {
934+
value: 0,
935+
cancelAndHoldAtTime: jest.fn(),
936+
exponentialRampToValueAtTime: jest.fn(),
937+
linearRampToValueAtTime: jest.fn(),
938+
setValueAtTime: jest.fn(),
939+
},
940+
};
941+
(speakerTrack as any).gainNode = mockGainNode;
942+
(speakerTrack as any).calculatedVolume = 0.5;
943+
944+
// Call fadeInNewSpeaker
945+
speakerTrack.fadeInNewSpeaker();
946+
947+
// Verify it uses configured 2000ms (2 seconds)
948+
expect(mockGainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.05, 0); // NEARLY_ZERO at current time
949+
expect(mockGainNode.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
950+
0.5, // target volume
951+
0 + 2 + 0.02 // current time + 2 seconds + epsilon
952+
);
953+
});
954+
955+
it("should use default fade-in duration when not configured", () => {
956+
// Create config without newSpeakerFadeInDurationMs
957+
const configWithoutFadeIn = {
958+
mode: "progressive-sync-basePlusMax5Random" as const,
959+
};
960+
961+
const speakerTrack = new SpeakerTrack({
962+
data: mockSpeakerData,
963+
config: configWithoutFadeIn,
964+
audioContext: mockAudioContext,
965+
groupId: 1,
966+
});
967+
968+
// Mock gain node
969+
const mockGainNode = {
970+
gain: {
971+
value: 0,
972+
cancelAndHoldAtTime: jest.fn(),
973+
exponentialRampToValueAtTime: jest.fn(),
974+
linearRampToValueAtTime: jest.fn(),
975+
setValueAtTime: jest.fn(),
976+
},
977+
};
978+
(speakerTrack as any).gainNode = mockGainNode;
979+
(speakerTrack as any).calculatedVolume = 0.5;
980+
981+
// Call fadeInNewSpeaker
982+
speakerTrack.fadeInNewSpeaker();
983+
984+
// Verify it uses default 2000ms (2 seconds)
985+
expect(mockGainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.05, 0); // NEARLY_ZERO at current time
986+
expect(mockGainNode.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
987+
0.5, // target volume
988+
0 + 2 + 0.02 // current time + 2 seconds + epsilon
989+
);
990+
});
991+
992+
it("should start gain at 0 and fade to target volume", () => {
993+
const speakerTrack = new SpeakerTrack({
994+
data: mockSpeakerData,
995+
config: mockConfig,
996+
audioContext: mockAudioContext,
997+
groupId: 1,
998+
});
999+
1000+
// Mock gain node
1001+
const mockGainNode = {
1002+
gain: {
1003+
value: 0,
1004+
cancelAndHoldAtTime: jest.fn(),
1005+
exponentialRampToValueAtTime: jest.fn(),
1006+
linearRampToValueAtTime: jest.fn(),
1007+
setValueAtTime: jest.fn(),
1008+
},
1009+
};
1010+
(speakerTrack as any).gainNode = mockGainNode;
1011+
(speakerTrack as any).calculatedVolume = 0.8;
1012+
1013+
// Call fadeInNewSpeaker
1014+
speakerTrack.fadeInNewSpeaker();
1015+
1016+
// Verify it fades from current volume to target volume
1017+
expect(mockGainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.05, 0); // NEARLY_ZERO at current time
1018+
expect(mockGainNode.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
1019+
0.8, // target volume (calculatedVolume)
1020+
0 + 2 + 0.02 // current time + 2 seconds + epsilon
1021+
);
1022+
});
1023+
});

src/speaker/speaker_engine.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ export class SpeakerEngine extends EventEmitter<{
631631
fadeInDuration: isContinued ? 0 : FADE_IN_DURATION_SECONDS,
632632
times: 1,
633633
pan: 0,
634+
isNewSpeaker: false, // Base track is not a new speaker
634635
});
635636
if (!isContinued) {
636637
track.on("trackFinished", this.onLoopPointBound);
@@ -947,6 +948,7 @@ export class SpeakerEngine extends EventEmitter<{
947948
fadeInDuration: 0, // No fade since it's continuing
948949
pan: panPosition,
949950
isReverse,
951+
isNewSpeaker: false, // This is a continuation, not a new speaker
950952
});
951953
continue;
952954
}
@@ -1063,6 +1065,7 @@ export class SpeakerEngine extends EventEmitter<{
10631065
times: Math.ceil(baseTrackDuration / duration),
10641066
pan: panPosition,
10651067
isReverse,
1068+
isNewSpeaker: true,
10661069
});
10671070
};
10681071
newSpeaker.on("loaded", onLoaded);
@@ -1077,6 +1080,7 @@ export class SpeakerEngine extends EventEmitter<{
10771080
fadeInDuration: FADE_IN_DURATION_SECONDS,
10781081
pan: panPosition,
10791082
isReverse,
1083+
isNewSpeaker: true,
10801084
});
10811085
} else {
10821086
this.playingTracks[i] = null;
@@ -1118,6 +1122,7 @@ export class SpeakerEngine extends EventEmitter<{
11181122
pan: speaker.loopConfig.pan,
11191123
times: 1,
11201124
isReverse: speaker.loopConfig.isReverse,
1125+
isNewSpeaker: false, // This is a fade-out scenario, not a new speaker
11211126
});
11221127
if (speaker.bufferSourcePlaying) speaker.fadeOutAndStopBufferSource();
11231128
}
@@ -1223,6 +1228,7 @@ export class SpeakerEngine extends EventEmitter<{
12231228
pan: track.loopConfig.pan,
12241229
times: newTimes,
12251230
isReverse: track.loopConfig.isReverse,
1231+
isNewSpeaker: false, // This is a track repetition, not a new speaker
12261232
});
12271233
} else {
12281234
this.emit("repeatingTrack", {
@@ -1237,6 +1243,7 @@ export class SpeakerEngine extends EventEmitter<{
12371243
pan: track.loopConfig.pan,
12381244
times: baseTrackDuration / track.loopConfig.duration,
12391245
isReverse: track.loopConfig.isReverse,
1246+
isNewSpeaker: false, // This is a track repetition, not a new speaker
12401247
});
12411248
}
12421249
}
@@ -1413,6 +1420,7 @@ export class SpeakerEngine extends EventEmitter<{
14131420
times: Math.ceil(baseTrackDuration / duration),
14141421
pan: panPosition,
14151422
isReverse,
1423+
isNewSpeaker: true,
14161424
});
14171425
};
14181426
speaker.on("loaded", onLoaded);
@@ -1425,6 +1433,7 @@ export class SpeakerEngine extends EventEmitter<{
14251433
fadeInDuration: FADE_IN_DURATION_SECONDS,
14261434
pan: panPosition,
14271435
isReverse,
1436+
isNewSpeaker: true,
14281437
});
14291438
}
14301439
}

src/speaker/speaker_track.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,15 @@ export class SpeakerTrack extends EventEmitter<{
288288
fadeInDuration,
289289
pan,
290290
isReverse = false,
291+
isNewSpeaker = false,
291292
}: {
292293
duration: number;
293294
offset: number;
294295
times: number;
295296
fadeInDuration: number;
296297
pan: number;
297298
isReverse?: boolean;
299+
isNewSpeaker?: boolean;
298300
}) {
299301
this.loopConfig.duration = duration;
300302
this.loopConfig.times = times;
@@ -373,13 +375,17 @@ export class SpeakerTrack extends EventEmitter<{
373375

374376
if (!this.gainNode) {
375377
this.gainNode = this.audioContext.createGain();
376-
const MIN_AUDIBLE = 0.05;
377-
const initial = Number.isFinite(this.calculatedVolume)
378-
? Math.max(MIN_AUDIBLE, this.calculatedVolume)
379-
: MIN_AUDIBLE;
380-
this.gainNode.gain.value = initial;
381378
}
382379

380+
const MIN_AUDIBLE = 0.05;
381+
const initial = Number.isFinite(this.calculatedVolume)
382+
? Math.max(MIN_AUDIBLE, this.calculatedVolume)
383+
: MIN_AUDIBLE;
384+
385+
// For new speakers, always start at NEARLY_ZERO to enable fade-in
386+
// For existing speakers, start at calculated volume
387+
this.gainNode.gain.value = isNewSpeaker ? NEARLY_ZERO : initial;
388+
383389
// connections:
384390
this.bufferSource.connect(this.gainNode);
385391
// TEMP: bypass panning to test if StereoPannerNode churn contributes to dropouts
@@ -407,6 +413,14 @@ export class SpeakerTrack extends EventEmitter<{
407413

408414
this.startBufferSource(this.audioContext.currentTime, offset || 0);
409415

416+
// Apply new speaker fade-in if this is a new speaker
417+
if (isNewSpeaker) {
418+
console.debug(
419+
`Speaker ${this.data.id} starting as new speaker (audio context state: ${this.audioContext.state})`
420+
);
421+
this.fadeInNewSpeaker();
422+
}
423+
410424
const startedAtContextTime = this.startedAtContextTime;
411425

412426
this.bufferSource.onended = () => {
@@ -481,6 +495,54 @@ export class SpeakerTrack extends EventEmitter<{
481495
}, FADE_DURATION_SECONDS * 1000);
482496
}
483497

498+
/**
499+
* Start a graceful fade-in for a new speaker
500+
* This applies a gain-node level fade-in over the specified duration
501+
*/
502+
fadeInNewSpeaker() {
503+
if (!this.gainNode) {
504+
throw new Error("Gain node not found");
505+
}
506+
507+
// Check audio context state before attempting fade-in
508+
if (this.audioContext.state === "suspended") {
509+
console.warn(
510+
`Speaker ${this.data.id} fade-in aborted: Audio context is suspended`
511+
);
512+
return;
513+
}
514+
515+
// Get fade-in duration from config, default to 2 seconds
516+
const fadeInDurationMs = this.config?.newSpeakerFadeInDurationMs ?? 2000;
517+
const fadeInDurationSeconds = fadeInDurationMs / 1000;
518+
519+
// Calculate target volume
520+
const MIN_AUDIBLE = 0.05;
521+
const targetVolume = Number.isFinite(this.calculatedVolume)
522+
? Math.max(MIN_AUDIBLE, this.calculatedVolume)
523+
: MIN_AUDIBLE;
524+
525+
// Fade in from current volume (NEARLY_ZERO) to target volume
526+
// Mobile-safe: avoid cancelAndHoldAtTime; explicitly set starting value and schedule a linear ramp with a tiny epsilon
527+
const now = this.audioContext.currentTime;
528+
const EPSILON_S = 0.02; // 20ms scheduling guard
529+
this.gainNode.gain.setValueAtTime(NEARLY_ZERO, now);
530+
this.gainNode.gain.linearRampToValueAtTime(
531+
targetVolume,
532+
now + fadeInDurationSeconds + EPSILON_S
533+
);
534+
535+
console.debug(
536+
`Speaker ${
537+
this.data.id
538+
} fading in from ${NEARLY_ZERO} to ${targetVolume.toFixed(
539+
3
540+
)} over ${fadeInDurationSeconds}s (audio context state: ${
541+
this.audioContext.state
542+
})`
543+
);
544+
}
545+
484546
startBufferSource(when: number, offset: number) {
485547
if (this.bufferSource) {
486548
this.bufferSource.start(when, offset);

src/types/roundware.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,7 @@ export type SpeakerConfig = {
8484

8585
// always-on speakers - these speakers will always play when available (in range)
8686
alwaysOnWhenAvailable?: number[]; // array of speaker IDs that should always play when available
87+
88+
// new speaker fade-in configuration
89+
newSpeakerFadeInDurationMs?: number; // default: 2000 (2 seconds)
8790
};

0 commit comments

Comments
 (0)