feat: coordinate adaptive audio intent

This commit is contained in:
2026-09-04 15:34:32 +03:00
parent 7632c8b9c0
commit 5250a3174c
6 changed files with 812 additions and 15 deletions

View File

@@ -0,0 +1,298 @@
import { describe, expect, it } from 'vitest';
import {
createAudioIntentCoordinator,
type AudioIntentSample,
} from '@/audio/audio-intent-coordinator';
import type { SectionId } from '@/content/types';
const sectionIds = [
'start', 'profile', 'experience', 'infrastructure',
'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[];
const baseSample = (
overrides: Partial<AudioIntentSample> = {},
): AudioIntentSample => ({
now: 0,
visible: true,
scrollY: 0,
activeSectionId: 'start',
acceleration: 0,
sectionActivation: {
layoutRevision: 1,
activeHeadingOffsetVh: -0.1,
nextHeadingOffsetVh: 0.8,
},
navigation: {
epoch: 1,
source: 'passive-scroll',
phase: 'idle',
destinationId: null,
signDestinationId: null,
},
...overrides,
});
describe('audio intent coordinator', () => {
it('commits once only after 6vh hysteresis and 400 ms dwell', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({
now: 10,
scrollY: 10,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 1,
activeHeadingOffsetVh: -0.059,
nextHeadingOffsetVh: 0.8,
},
}))).toMatchObject({ chapter: null, needsFrame: false });
expect(coordinator.sample(baseSample({ now: 100, scrollY: 10, activeSectionId: 'profile' })))
.toMatchObject({ chapter: null, needsFrame: true });
expect(coordinator.sample(baseSample({ now: 499, scrollY: 10, activeSectionId: 'profile' })))
.toMatchObject({ chapter: null });
expect(coordinator.sample(baseSample({ now: 500, scrollY: 10, activeSectionId: 'profile' })).chapter)
.toMatchObject({ sectionId: 'profile', mood: 'open', generation: 1 });
expect(coordinator.sample(baseSample({ now: 900, scrollY: 10, activeSectionId: 'profile' })).chapter)
.toBeNull();
});
it('clears a passive candidate on reversal or structural remeasurement', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({ now: 0, scrollY: 10, activeSectionId: 'profile' }));
expect(coordinator.sample(baseSample({ now: 250, scrollY: 9, activeSectionId: 'start' })).needsFrame)
.toBe(false);
coordinator.sample(baseSample({ now: 300, scrollY: 12, activeSectionId: 'profile' }));
expect(coordinator.sample(baseSample({
now: 800,
scrollY: 12,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
}))).toMatchObject({ chapter: null, needsFrame: false });
});
it('commits only the latest settled explicit destination', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'contact',
signDestinationId: null,
},
}));
coordinator.sample(baseSample({
activeSectionId: 'toolkit',
navigation: {
epoch: 3,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'toolkit',
signDestinationId: null,
},
}));
expect(coordinator.sample(baseSample({
now: 600,
activeSectionId: 'toolkit',
navigation: {
epoch: 3,
source: 'explicit-navigation',
phase: 'settled',
destinationId: 'toolkit',
signDestinationId: null,
},
}))).toMatchObject({
chapter: { sectionId: 'toolkit', mood: 'resolve', generation: 1 },
});
});
it('dead-bands tiny input and caps programmatic travel below lift entry', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({ now: 0, acceleration: 0.07 })).drive).toBeNull();
expect(coordinator.sample(baseSample({ now: 40, acceleration: 0.7 })).drive)
.toEqual({ energy: expect.closeTo((0.7 - 0.08) / 0.92), source: 'physical' });
expect(coordinator.sample(baseSample({
now: 80,
acceleration: 1,
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'contact',
signDestinationId: null,
},
})).drive).toEqual({ energy: 0.35, source: 'navigation' });
expect(coordinator.sample(baseSample({ now: 120, acceleration: 0 })).drive)
.toEqual({ energy: 0, source: 'idle' });
});
it('rate-limits small drive changes but emits exact threshold, source, and zero crossings', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
const physicalAcceleration = (energy: number): number => 0.08 + energy * 0.92;
expect(coordinator.sample(baseSample({ now: 0, acceleration: physicalAcceleration(0.3) })).drive)
.toEqual({ energy: expect.closeTo(0.3), source: 'physical' });
expect(coordinator.sample(baseSample({ now: 10, acceleration: physicalAcceleration(0.31) })).drive)
.toBeNull();
expect(coordinator.sample(baseSample({ now: 11, acceleration: physicalAcceleration(0.32) })).drive)
.toEqual({ energy: expect.closeTo(0.32), source: 'physical' });
expect(coordinator.sample(baseSample({ now: 12, acceleration: physicalAcceleration(0.62) })).drive)
.toEqual({ energy: expect.closeTo(0.62), source: 'physical' });
expect(coordinator.sample(baseSample({
now: 13,
acceleration: 0.2,
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'contact',
signDestinationId: null,
},
})).drive).toEqual({ energy: 0.2, source: 'navigation' });
expect(coordinator.sample(baseSample({ now: 14, acceleration: 0 })).drive)
.toEqual({ energy: 0, source: 'idle' });
});
it('cannot turn a layout-only active-section shift into a passive commit', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({
now: 100,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
}))).toMatchObject({ chapter: null, needsFrame: false });
expect(coordinator.sample(baseSample({
now: 600,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
})).chapter).toBeNull();
expect(coordinator.sample(baseSample({
now: 650,
scrollY: 2,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
})).needsFrame).toBe(true);
expect(coordinator.sample(baseSample({
now: 1_050,
scrollY: 2,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
})).chapter).toMatchObject({ sectionId: 'profile' });
});
it('defers a genuine settlement across remeasurement but ignores old settled state', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'contact',
signDestinationId: null,
},
}));
expect(coordinator.sample(baseSample({
now: 100,
activeSectionId: 'contact',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: null,
},
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
},
}))).toMatchObject({ chapter: null, needsFrame: true });
expect(coordinator.sample(baseSample({
now: 116,
activeSectionId: 'contact',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: null,
},
navigation: {
epoch: 2,
source: 'explicit-navigation',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
},
})).chapter).toMatchObject({ sectionId: 'contact' });
});
it('starts one final dwell after wheel events advance passive epochs', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
const passive = (epoch: number) => ({
epoch,
source: 'passive-scroll' as const,
phase: 'idle' as const,
destinationId: null,
signDestinationId: null,
});
coordinator.sample(baseSample({
now: 100,
scrollY: 24,
activeSectionId: 'profile',
navigation: passive(2),
}));
expect(coordinator.sample(baseSample({
now: 180,
scrollY: 48,
activeSectionId: 'profile',
navigation: passive(3),
})).needsFrame).toBe(true);
expect(coordinator.sample(baseSample({
now: 580,
scrollY: 48,
activeSectionId: 'profile',
navigation: passive(3),
})).chapter).toMatchObject({ sectionId: 'profile' });
});
it('suppresses a navigation that settled while hidden', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
const settled = {
epoch: 2,
source: 'explicit-navigation',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
} as const;
coordinator.reset({
navigation: settled,
scrollY: 0,
sectionActivation: baseSample().sectionActivation,
});
expect(coordinator.sample(baseSample({
now: 1_000,
activeSectionId: 'contact',
navigation: settled,
})).chapter).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { defineComponent, h, readonly, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { resolveChapterIntent } from '@/audio/journey-music-state';
import {
useAudioIntent,
type AudioIntentBinding,
} from '@/composables/useAudioIntent';
import type { JourneySectionsBinding } from '@/composables/useJourneySections';
import type { SectionId } from '@/content/types';
import type { NavigationState } from '@/journey/navigation-controller';
import type { JourneySnapshot } from '@/journey/types';
const sectionIds = [
'start', 'profile', 'experience', 'infrastructure',
'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[];
describe('useAudioIntent', () => {
it('seeds the first measured deep-link section before audio activation', () => {
const snapshot = shallowRef<JourneySnapshot>({
scrollY: 7_200,
routeProgress: 1,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
activeSectionId: 'contact',
chapter: 'destination',
sign: null,
arrived: true,
sectionActivation: {
layoutRevision: 4,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: null,
},
});
const navigation = shallowRef<NavigationState>({
epoch: 1,
source: 'initial',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
});
let frame: ((now: number, value: JourneySnapshot) => boolean) | null = null;
const journey: JourneySectionsBinding = {
snapshot: readonly(snapshot),
requestFrame: vi.fn(),
subscribeFrame(listener) {
frame = listener;
return () => { frame = null; };
},
};
let binding: AudioIntentBinding | null = null;
const wrapper = mount(defineComponent({
setup() {
binding = useAudioIntent({ canonicalIds: sectionIds, journey, navigation });
return () => h('div');
},
}));
expect(frame).not.toBeNull();
frame!(16, snapshot.value);
expect(binding!.chapter.value).toEqual(resolveChapterIntent('contact'));
wrapper.unmount();
});
});