Skip to content

Commit 9b60503

Browse files
ctothclaude
andcommitted
Smooth all spatial audio motion with velocity-driven tweening
GMCP delivers movement as whole-metre position snaps, so sources and the listener teleported around the head; near a source one strafe leapt ~115 degrees in a single audio frame. Interpolate everything instead: - VectorTweener: keyed, single-rAF-loop glides; duration from the server's velocity (clamped 80-600ms), mid-flight retargeting, nlerp mode for orientation axes, injectable clock/scheduler for tests. - Client.Spatial handler tweens listener/entity positions, nlerps forward vectors over 150ms; scene snapshots and enter/leave still snap. - Media sound positions tween per sound; LiveKit panners snap on attach and ramp on sync; FOA encode/level gains and ambisonic distance gain ramp via setTargetAtTime (30ms tau) instead of stepping. - spatialStore.patchEntity supports per-frame partial updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CyEqCPCmxQYQHamSH1m2A
1 parent b331234 commit 9b60503

13 files changed

Lines changed: 767 additions & 33 deletions

src/audio/AmbisonicRenderer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Cacophony, AudioNode as CacophonyAudioNode, Playback } from 'cacophony';
22
import Omnitone, { type FOARenderer } from 'omnitone/build/omnitone.min.esm.js';
33

4+
import { smoothParamTo } from './audioParamSmoothing';
5+
46
const IDENTITY_ROTATION = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
57
type AmbisonicInputMode = 'stereo-upmix' | 'foa-passthrough';
68

@@ -69,7 +71,8 @@ export class AmbisonicRenderer {
6971
return;
7072
}
7173
const clamped = Number.isFinite(gain) ? Math.min(1, Math.max(0, gain)) : 1;
72-
this.distanceGain.gain.value = clamped;
74+
const currentTime = (this.cacophony.context as unknown as BaseAudioContext).currentTime;
75+
smoothParamTo(this.distanceGain.gain, clamped, currentTime);
7376
}
7477

7578
cleanup(): void {

src/audio/LiveKitSpatialAudioBridge.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { LiveKitSpatialAudioBridge } from "./LiveKitSpatialAudioBridge";
3+
import { SPATIAL_PARAM_TAU_S } from "./audioParamSmoothing";
34
import { SPATIAL_DISTANCE_MODEL } from "./distanceModel";
45

56
const MockMediaStream = vi.fn();
@@ -26,6 +27,10 @@ function createAudioParam(value = 0) {
2627
value = nextValue;
2728
return undefined as never;
2829
}),
30+
setTargetAtTime: vi.fn((nextValue: number) => {
31+
value = nextValue;
32+
return undefined as never;
33+
}),
2934
};
3035
}
3136

@@ -160,7 +165,7 @@ describe("LiveKitSpatialAudioBridge", () => {
160165
expect(cacophony.resume).toHaveBeenCalledOnce();
161166
});
162167

163-
it("updates an existing panner position from the spatial lookup", () => {
168+
it("snaps the initial panner position, then ramps position updates from the spatial lookup", () => {
164169
const remoteTrack = track(1);
165170
const { cacophony, panner } = createCacophony();
166171
const positions: Record<string, [number, number, number]> = {
@@ -169,12 +174,16 @@ describe("LiveKitSpatialAudioBridge", () => {
169174
const bridge = new LiveKitSpatialAudioBridge(cacophony, (participantId) => positions[participantId]);
170175

171176
bridge.attachParticipantTrack("player-2", remoteTrack);
177+
178+
expect(panner.positionX.setValueAtTime).toHaveBeenCalledWith(1, 7);
179+
expect(panner.positionX.setTargetAtTime).not.toHaveBeenCalled();
180+
172181
positions["player-2"] = [4, 5, 6];
173182
bridge.syncParticipant("player-2");
174183

175-
expect(panner.positionX.setValueAtTime).toHaveBeenLastCalledWith(4, 7);
176-
expect(panner.positionY.setValueAtTime).toHaveBeenLastCalledWith(5, 7);
177-
expect(panner.positionZ.setValueAtTime).toHaveBeenLastCalledWith(6, 7);
184+
expect(panner.positionX.setTargetAtTime).toHaveBeenLastCalledWith(4, 7, SPATIAL_PARAM_TAU_S);
185+
expect(panner.positionY.setTargetAtTime).toHaveBeenLastCalledWith(5, 7, SPATIAL_PARAM_TAU_S);
186+
expect(panner.positionZ.setTargetAtTime).toHaveBeenLastCalledWith(6, 7, SPATIAL_PARAM_TAU_S);
178187
});
179188

180189
it("primes Chromium decode with a muted media element and tears it down on detach", () => {

src/audio/LiveKitSpatialAudioBridge.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
Position,
88
} from "cacophony";
99

10+
import { smoothParamTo } from "./audioParamSmoothing";
1011
import { SPATIAL_DISTANCE_MODEL } from "./distanceModel";
1112

1213
export type SpatialPositionLookup = (participantId: string) => Position | null | undefined;
@@ -67,7 +68,7 @@ export class LiveKitSpatialAudioBridge {
6768
panner.connect(outputGain);
6869
outputGain.connect(this.cacophony.globalGainNode);
6970

70-
this.applyPosition(panner, this.positionFor(participantId));
71+
this.applyPosition(panner, this.positionFor(participantId), { snap: true });
7172
this.entries.set(participantId, {
7273
downmixNodes: nodes,
7374
outputGain,
@@ -180,11 +181,30 @@ export class LiveKitSpatialAudioBridge {
180181
return 2;
181182
}
182183

183-
private applyPosition(panner: CacophonyPannerNode, [x, y, z]: Position): void {
184+
/**
185+
* Aim the participant's panner. The first placement (attach) snaps so a new
186+
* voice does not audibly fly in from the origin; subsequent syncs ramp with a
187+
* short time constant to de-zipper the per-frame steps the position tweener
188+
* delivers through the spatial store.
189+
*/
190+
private applyPosition(
191+
panner: CacophonyPannerNode,
192+
[x, y, z]: Position,
193+
options?: { snap?: boolean },
194+
): void {
184195
const time = this.cacophony.context.currentTime;
185-
panner.positionX.setValueAtTime(x, time);
186-
panner.positionY.setValueAtTime(y, time);
187-
panner.positionZ.setValueAtTime(z, time);
196+
const axes = [
197+
[panner.positionX, x],
198+
[panner.positionY, y],
199+
[panner.positionZ, z],
200+
] as const;
201+
for (const [param, value] of axes) {
202+
if (options?.snap) {
203+
param.setValueAtTime(value, time);
204+
} else {
205+
smoothParamTo(param, value, time);
206+
}
207+
}
188208
}
189209

190210
private disconnectEntry(entry: SpatialAudioEntry): void {

src/audio/MediaService.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { usePreferences } from '../stores/preferencesStore';
1010
import { AmbisonicRenderer } from './AmbisonicRenderer';
1111
import { PositionalFoaRenderer } from './PositionalFoaRenderer';
1212
import { distanceBetween, inverseDistanceGain, SPATIAL_DISTANCE_MODEL } from './distanceModel';
13+
import { VectorTweener } from './vectorTween';
1314
import { EffectChain } from './effects/EffectChain';
1415
import { MediaEffects } from './effects/MediaEffects';
1516
import type { EffectSpec } from './effects/types';
@@ -146,6 +147,8 @@ export interface ExtendedSound extends Sound {
146147

147148
interface MediaServiceOptions {
148149
manageFocus?: boolean;
150+
/** Override the sound-position tweener (tests inject a manually-clocked one). */
151+
motion?: VectorTweener;
149152
}
150153

151154
export class MediaService {
@@ -154,6 +157,8 @@ export class MediaService {
154157
defaultUrl = '';
155158

156159
private readonly cleanedSounds = new WeakSet<ExtendedSound>();
160+
/** Glides server-sent sound positions (keyed by sound) instead of snapping. */
161+
private readonly motion: VectorTweener;
157162
private readonly effects: MediaEffects;
158163
private readonly mediaSession = new MediaSessionController();
159164
private readonly preloadedSoundKeys = new Set<string>();
@@ -168,6 +173,7 @@ export class MediaService {
168173
this.cacophony = cacophony;
169174
this.effects = new MediaEffects(this.cacophony);
170175
this.manageFocus = options.manageFocus ?? true;
176+
this.motion = options.motion ?? new VectorTweener();
171177

172178
this.setGlobalVolume(usePreferences.getState().sound.volume);
173179
if (this.manageFocus && typeof window !== 'undefined') {
@@ -644,6 +650,7 @@ export class MediaService {
644650
}
645651

646652
private releaseSound(sound: ExtendedSound, key?: string): void {
653+
this.motion.cancel(sound);
647654
if (sound === this.currentMusic) {
648655
this.currentMusic = undefined;
649656
this.mediaSession.clear();
@@ -879,10 +886,14 @@ export class MediaService {
879886
}
880887

881888
if (data.position?.length) {
882-
sound.mediaPosition = [data.position[0], data.position[1], data.position[2]];
883-
sound.position = sound.mediaPosition;
884-
this.updateAmbisonicDistance(sound as ExtendedSound);
885-
this.updatePositionalSpatial(sound as ExtendedSound);
889+
const target: Position = [data.position[0], data.position[1], data.position[2]];
890+
// First placement snaps; later updates glide from the current position.
891+
this.motion.tween(sound, sound.mediaPosition, target, (value) => {
892+
sound.mediaPosition = [value[0], value[1], value[2]];
893+
sound.position = sound.mediaPosition;
894+
this.updateAmbisonicDistance(sound);
895+
this.updatePositionalSpatial(sound);
896+
});
886897
}
887898

888899
if (data.start !== undefined) {

src/audio/PositionalFoaRenderer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Cacophony, AudioNode as CacophonyAudioNode, Playback } from 'cacophony';
22
import { encodeMonoToFoaSN3D, type FoaDecoder } from 'cacophony';
33

4+
import { smoothParamTo } from './audioParamSmoothing';
45
import { sourceBearing, type Vec3 } from './foaBearing';
56

67
/** One encoder bank: four ACN [W,Y,Z,X] gains fed by one signal, aimed at the
@@ -148,13 +149,17 @@ export class PositionalFoaRenderer {
148149
this.setBearing(azimuth, elevation);
149150
}
150151

151-
/** Set every bank's encode gains from an azimuth (CCW, +left) and elevation (+up). */
152+
/** Set every bank's encode gains from an azimuth (CCW, +left) and elevation (+up).
153+
* Writes are smoothed (short exponential ramps) so a bearing change glides
154+
* instead of clicking — encode gains are pure crossfade weights, so ramping
155+
* them is safe. */
152156
setBearing(azimuthRad: number, elevationRad: number): void {
157+
const currentTime = this.context.currentTime;
153158
for (const bank of this.banks) {
154159
const coeffs = encodeMonoToFoaSN3D(1, azimuthRad + bank.azOffset, elevationRad); // [W,Y,Z,X]
155160
for (let i = 0; i < 4; i++) {
156161
const v = coeffs[i];
157-
bank.gains[i].gain.value = Number.isFinite(v) ? v : i === 0 ? 1 : 0;
162+
smoothParamTo(bank.gains[i].gain, Number.isFinite(v) ? v : i === 0 ? 1 : 0, currentTime);
158163
}
159164
}
160165
}
@@ -173,7 +178,7 @@ export class PositionalFoaRenderer {
173178

174179
private applyLevel(): void {
175180
if (this.levelGain) {
176-
this.levelGain.gain.value = this.makeup * this.distanceAttenuation;
181+
smoothParamTo(this.levelGain.gain, this.makeup * this.distanceAttenuation, this.context.currentTime);
177182
}
178183
}
179184

src/audio/audioParamSmoothing.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Shared de-zipper for spatial AudioParam writes. Position tweening delivers
3+
* per-frame steps; smoothing each write with a short exponential ramp
4+
* (setTargetAtTime) removes the residual staircase and the clicks that raw
5+
* `param.value =` assignments produce on gain and panner params.
6+
*/
7+
8+
/** Time constant (s): ~63% of the way per tau, settled within ~3·tau (≈90ms). */
9+
export const SPATIAL_PARAM_TAU_S = 0.03;
10+
11+
/**
12+
* Structural subset of AudioParam accepted here, so tests (and cacophony's
13+
* wrapper types) can pass plain `{ value }` objects.
14+
*/
15+
export interface SmoothableParam {
16+
value: number;
17+
setTargetAtTime?(value: number, startTime: number, timeConstant: number): unknown;
18+
}
19+
20+
/**
21+
* Ramp `param` toward `value` with an exponential approach starting at
22+
* `currentTime`. Falls back to a direct assignment when the param cannot
23+
* schedule (mock nodes, detached contexts).
24+
*/
25+
export function smoothParamTo(
26+
param: SmoothableParam,
27+
value: number,
28+
currentTime: number | undefined,
29+
tauS: number = SPATIAL_PARAM_TAU_S,
30+
): void {
31+
if (typeof param.setTargetAtTime === 'function' && Number.isFinite(currentTime)) {
32+
param.setTargetAtTime(value, currentTime as number, tauS);
33+
} else {
34+
param.value = value;
35+
}
36+
}

0 commit comments

Comments
 (0)