feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord

This commit is contained in:
2026-10-11 05:32:08 +03:00
parent c9e6ac0f29
commit b6e22a1299
37 changed files with 756 additions and 44 deletions

View File

@@ -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];

138
src/audio/dawn-resolve.ts Normal file
View File

@@ -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();
},
};
};

View File

@@ -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<AudioBuffer>;
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<OwnedSource>();
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 {