diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index e2b8e88..c391127 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -23,3 +23,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page). - **Known weaknesses:** the outline-to-lit sweep beat is short (~300 ms visible); haze cone slightly flat/white; spark boost barely perceptible. - Screenshots: docs/overnight/round2/. +## Round 3: End of Shift (Contact daybreak finale) +- **What:** arriving at Contact turns the night into dawn over 7 s: sky mauve/peach, stars drain, sun lifts and becomes a solid gold disc, warm rim light on road/terrain/districts, warm grade and bloom, bars warm, credit types at sunrise, HUD "END OF SHIFT · 05:48" (the joke: round 1's window ended 03:51), and a synthesized four-voice dawn pad (new mixer `dawnInput`) swells. Scrolling back rewinds in ~1.6 s. html[data-dawn] steps night/first-light/sunrise/day. Reduced motion/static: CSS-only daybreak gradient. +- **Why:** the ending was engine-off but still night with a sun that never moved; the finale is the last impression, and the plumbing (sun, meteors, outroProgress, palette) was already there. +- **Build:** src/journey/dawn.ts, scene/world/dawn-light.ts, audio/dawn-resolve.ts; edits in palette, environment, sun, atmosphere, horizon-air, road/terrain/districts, cinematic-grade, post-processing, adaptive-mixer, soundtrack-engine, world.css. 746 tests green, initial JS +0.7 KB gz, scene chunk +1.2 KB, outro frame times unchanged on hardware GL. +- **Verdict: KEEP with fixes** (Fable): the sun/halo stack washed out to a white blob, so the sun is now gold with a smaller halo; the "DESTINATION" eyebrow was invisible on the peach sky, darkened at sunrise. +- **Known weaknesses:** the "SHIFT OVER" label swap is invisible (HUD arrival label collapsed); the bottom credit is mostly hidden by the Contact panel (pre-existing); horizon still bright at full day; reduced-motion version reads as dusk rather than sunrise; audio checked by code review only. +- Screenshots: docs/overnight/round3/. + diff --git a/docs/overnight/round3/before-contact.webp b/docs/overnight/round3/before-contact.webp new file mode 100644 index 0000000..7397227 Binary files /dev/null and b/docs/overnight/round3/before-contact.webp differ diff --git a/docs/overnight/round3/before-fix-t80.webp b/docs/overnight/round3/before-fix-t80.webp new file mode 100644 index 0000000..1963a12 Binary files /dev/null and b/docs/overnight/round3/before-fix-t80.webp differ diff --git a/docs/overnight/round3/mobile-390.webp b/docs/overnight/round3/mobile-390.webp new file mode 100644 index 0000000..eb44251 Binary files /dev/null and b/docs/overnight/round3/mobile-390.webp differ diff --git a/docs/overnight/round3/reduced-motion.webp b/docs/overnight/round3/reduced-motion.webp new file mode 100644 index 0000000..8babe47 Binary files /dev/null and b/docs/overnight/round3/reduced-motion.webp differ diff --git a/docs/overnight/round3/rewind.webp b/docs/overnight/round3/rewind.webp new file mode 100644 index 0000000..968b5ec Binary files /dev/null and b/docs/overnight/round3/rewind.webp differ diff --git a/docs/overnight/round3/t00.webp b/docs/overnight/round3/t00.webp new file mode 100644 index 0000000..bc6a0ee Binary files /dev/null and b/docs/overnight/round3/t00.webp differ diff --git a/docs/overnight/round3/t15.webp b/docs/overnight/round3/t15.webp new file mode 100644 index 0000000..9202af7 Binary files /dev/null and b/docs/overnight/round3/t15.webp differ diff --git a/docs/overnight/round3/t30.webp b/docs/overnight/round3/t30.webp new file mode 100644 index 0000000..a6569e0 Binary files /dev/null and b/docs/overnight/round3/t30.webp differ diff --git a/docs/overnight/round3/t45.webp b/docs/overnight/round3/t45.webp new file mode 100644 index 0000000..cd88b25 Binary files /dev/null and b/docs/overnight/round3/t45.webp differ diff --git a/docs/overnight/round3/t60.webp b/docs/overnight/round3/t60.webp new file mode 100644 index 0000000..8444c7f Binary files /dev/null and b/docs/overnight/round3/t60.webp differ diff --git a/docs/overnight/round3/t80.webp b/docs/overnight/round3/t80.webp new file mode 100644 index 0000000..ed930b3 Binary files /dev/null and b/docs/overnight/round3/t80.webp differ diff --git a/src/audio/adaptive-mixer.ts b/src/audio/adaptive-mixer.ts index e933374..b600463 100644 --- a/src/audio/adaptive-mixer.ts +++ b/src/audio/adaptive-mixer.ts @@ -27,6 +27,8 @@ export interface AdaptiveMixer { readonly bedInput: GainNode; readonly driveLiftInput: GainNode; readonly driveTextureInput: GainNode; + /** END OF SHIFT: the dawn chord, summed after the mood controls. */ + readonly dawnInput: GainNode; setChapterIntent(intent: ChapterIntent): void; setDriveEnergy(sample: DriveEnergySample): void; setTransportAnchor(anchorTime: number | null): void; @@ -57,6 +59,9 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) const driveTextureInput = context.createGain(); const textureSend = context.createGain(); const sum = context.createGain(); + // The dawn chord joins the sum directly: the output gain (mute, volume) and + // the pulse tap govern it, and the chapter moods leave it alone. + const dawnInput = context.createGain(); const highShelf = context.createBiquadFilter(); const initialProfile = MIX_PROFILES.open; @@ -77,6 +82,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) driveLiftCap.connect(sum); driveTextureInput.connect(textureSend); textureSend.connect(sum); + dawnInput.connect(sum); sum.connect(destination); const ownedNodes: readonly AudioNode[] = [ @@ -88,6 +94,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) driveLiftCap, driveTextureInput, textureSend, + dawnInput, sum, ]; let transportAnchor: number | null = null; @@ -197,6 +204,7 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) bedInput, driveLiftInput, driveTextureInput, + dawnInput, setChapterIntent(intent) { if (disposed) return; const profile = MIX_PROFILES[intent.mood]; diff --git a/src/audio/dawn-resolve.ts b/src/audio/dawn-resolve.ts new file mode 100644 index 0000000..fe3957b --- /dev/null +++ b/src/audio/dawn-resolve.ts @@ -0,0 +1,138 @@ +/** + * END OF SHIFT: the music resolves into a held dawn chord at the destination. + * A four-voice sine pad (A2, E3, A3, E4), each note a pair of sines detuned + * +/-4 cents, opens through a low-pass from 900 to 2600 Hz while it swells in, + * with a filtered-noise breath under it. Preallocated and silent until + * arrival, like the drive texture, so arriving schedules automation only. + */ + +export const DAWN_CHORD_HZ = [110, 164.8, 220, 329.6] as const; +export const DAWN_DETUNE_CENTS = 4; +export const DAWN_PAD_GAIN = 0.09; +export const DAWN_SWELL_SECONDS = 5; +export const DAWN_OPEN_SECONDS = 6; +export const DAWN_RELEASE_SECONDS = 1.6; +const FILTER_CLOSED_HZ = 900; +const FILTER_OPEN_HZ = 2_600; +/** Upper notes sit a little under the root so the chord stays soft. */ +const VOICE_LEVELS = [0.5, 0.36, 0.3, 0.18] as const; +const BREATH_PEAK = 0.022; +const BREATH_REST = 0.006; +const BREATH_PEAK_SECONDS = 2.4; +const NOISE_SECONDS = 2; +const NOISE_SEED = 0x44415752; + +export interface DawnResolve { + /** Parked at the destination (true) or driving again (false). */ + setDawn(on: boolean): void; + dispose(): void; +} + +export type DawnResolveFactory = ( + context: AudioContext, + destination: AudioNode, +) => DawnResolve; + +function holdParameter(parameter: AudioParam, atTime: number): void { + if (typeof parameter.cancelAndHoldAtTime === 'function') { + parameter.cancelAndHoldAtTime(atTime); + return; + } + parameter.cancelScheduledValues(atTime); + parameter.setValueAtTime(parameter.value, atTime); +} + +function createBreathBuffer(context: BaseAudioContext): AudioBuffer { + const length = Math.max(1, Math.round(context.sampleRate * NOISE_SECONDS)); + const buffer = context.createBuffer(1, length, context.sampleRate); + const samples = buffer.getChannelData(0); + let state = NOISE_SEED; + for (let index = 0; index < samples.length; index += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + samples[index] = state / 0xffff_ffff * 2 - 1; + } + return buffer; +} + +export const createDawnResolve: DawnResolveFactory = (context, destination) => { + const now = context.currentTime; + const padGain = context.createGain(); + padGain.gain.setValueAtTime(0, now); + const padFilter = context.createBiquadFilter(); + padFilter.type = 'lowpass'; + padFilter.frequency.setValueAtTime(FILTER_CLOSED_HZ, now); + padFilter.Q.value = 0.6; + padFilter.connect(padGain); + padGain.connect(destination); + + const sources: AudioScheduledSourceNode[] = []; + const ownedNodes: AudioNode[] = [padFilter, padGain]; + DAWN_CHORD_HZ.forEach((frequency, voice) => { + for (const cents of [-DAWN_DETUNE_CENTS, DAWN_DETUNE_CENTS]) { + const oscillator = context.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(frequency, now); + oscillator.detune.setValueAtTime(cents, now); + const level = context.createGain(); + level.gain.setValueAtTime(VOICE_LEVELS[voice] ?? 0.2, now); + oscillator.connect(level); + level.connect(padFilter); + sources.push(oscillator); + ownedNodes.push(oscillator, level); + } + }); + + const breathSource = context.createBufferSource(); + breathSource.buffer = createBreathBuffer(context); + breathSource.loop = true; + const breathBand = context.createBiquadFilter(); + breathBand.type = 'bandpass'; + breathBand.frequency.setValueAtTime(1_400, now); + breathBand.Q.value = 0.7; + const breathGain = context.createGain(); + breathGain.gain.setValueAtTime(0, now); + breathSource.connect(breathBand); + breathBand.connect(breathGain); + breathGain.connect(destination); + sources.push(breathSource); + ownedNodes.push(breathSource, breathBand, breathGain); + + for (const source of sources) source.start(); + + let on = false; + let disposed = false; + return { + setDawn(next) { + if (disposed || next === on) return; + on = next; + const at = context.currentTime; + for (const parameter of [padGain.gain, padFilter.frequency, breathGain.gain, breathBand.frequency]) { + holdParameter(parameter, at); + } + if (on) { + padGain.gain.linearRampToValueAtTime(DAWN_PAD_GAIN, at + DAWN_SWELL_SECONDS); + padFilter.frequency.exponentialRampToValueAtTime(FILTER_OPEN_HZ, at + DAWN_OPEN_SECONDS); + breathGain.gain.linearRampToValueAtTime(BREATH_PEAK, at + BREATH_PEAK_SECONDS); + breathGain.gain.linearRampToValueAtTime(BREATH_REST, at + DAWN_OPEN_SECONDS); + breathBand.frequency.exponentialRampToValueAtTime(3_200, at + DAWN_OPEN_SECONDS); + return; + } + padGain.gain.linearRampToValueAtTime(0, at + DAWN_RELEASE_SECONDS); + padFilter.frequency.exponentialRampToValueAtTime(FILTER_CLOSED_HZ, at + DAWN_RELEASE_SECONDS); + breathGain.gain.linearRampToValueAtTime(0, at + DAWN_RELEASE_SECONDS); + breathBand.frequency.exponentialRampToValueAtTime(1_400, at + DAWN_RELEASE_SECONDS); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const source of sources) { + try { + source.stop(); + } catch { + // A source that has already ended needs no further teardown. + } + } + for (const node of ownedNodes) node.disconnect(); + }, + }; +}; diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index ced25ec..00933d4 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -27,6 +27,11 @@ import { type DriveTexture, type DriveTextureFactory, } from '@/audio/drive-texture'; +import { + createDawnResolve, + type DawnResolve, + type DawnResolveFactory, +} from '@/audio/dawn-resolve'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import type { AudioContextLifecycleState, @@ -73,6 +78,8 @@ export interface SoundtrackEngineDependencies { ): Promise; createMixer: AdaptiveMixerFactory; createDriveTexture: DriveTextureFactory; + /** END OF SHIFT: the held dawn chord at the destination (optional voice). */ + createDawnResolve?: DawnResolveFactory; } export function createSoundtrackEngine( @@ -90,6 +97,12 @@ export function createSoundtrackEngine( } catch { driveTexture = null; } + let dawn: DawnResolve | null = null; + try { + dawn = dependencies.createDawnResolve?.(context, mixer.dawnInput) ?? null; + } catch { + dawn = null; + } const ownedSources = new Set(); const stateListeners = new Set<(state: AudioContextLifecycleState) => void>(); @@ -516,6 +529,8 @@ export function createSoundtrackEngine( mixer.setDriveLiftAvailableAt(null); driveTexture?.dispose(); driveTexture = null; + dawn?.dispose(); + dawn = null; mixer.dispose(); for (const source of [...ownedSources]) source.cancel(); ownedSources.clear(); @@ -560,6 +575,8 @@ export function createSoundtrackEngine( driveTexture?.passBy?.(); // The last exit is the destination: the engine winds down there. driveTexture?.park?.(intent.sectionId === 'contact'); + // ...and the shift ends: the music resolves into a held dawn chord. + dawn?.setDawn(intent.sectionId === 'contact'); } latestChapter = intent; mixer.setChapterIntent(intent); @@ -607,6 +624,7 @@ export function createBrowserSoundtrackEngine( // The mixer feeds a pass-through tap so the scene can read the bass level. createMixer: (context, destination) => createAdaptiveMixer(context, createPulseTap(context, destination)), createDriveTexture, + createDawnResolve, }); let resumeCount = 0; return { diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 9e9c1fd..68d1a00 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useRouteMemory } from '@/composables/useRouteMemory'; import { assetUrl } from '@/content/asset-url'; import { signRouteFor } from '@/journey/sign-routes'; +import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn'; import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types'; import type { SceneHostState } from '@/composables/useSceneController'; import type { MotionPolicy } from '@/journey/motion-policy'; @@ -129,6 +130,11 @@ const audio = useAudioEngine({ const sceneState = shallowRef('idle'); const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active'); const controlsReady = shallowRef(false); +/* END OF SHIFT: parked at the destination the plate's sub-line reads the + shift's end. Client-only, like the 03:12 window, so the server HTML hydrates + unchanged. */ +const hudSubline = computed(() => maintenanceSubline.value + || (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '')); const adaptive = useAdaptiveQuality({ maximum: computed(() => motion.policy.value.maximumTier), sceneState, @@ -270,7 +276,7 @@ onUnmounted(() => removePageListeners()); :exit="exitFor(journey.activeSectionId)" :next-label="nextLabelFor(journey.activeSectionId)" :next-exit="nextExitFor(journey.activeSectionId)" - :subline="maintenanceSubline" + :subline="hudSubline" />