feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord
This commit is contained in:
@@ -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
138
src/audio/dawn-resolve.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user