SOURCE / PINNED RELEASE
Made of little things.
Supersonic RC Revive
- Release
- 1ba42f1ca1d6…
- Author-recorded commit
- baecad10b1cd…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f
Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Director sound channels on Web Audio: sound(n).play/queue/stop, volume
// 0..255, fadeTo/fadeOut, #rateShift in semitones, #startTime in ms and
// #loopCount (0 = forever). Samples decode from inlined bytes (no fetch).
import { assetBytes } from './assets.js';
import { synthesize } from './synth.js';
export interface PlayOptions {
member: string;
loopCount?: number;
rateShift?: number;
startTime?: number;
}
interface Queued extends PlayOptions {}
export class Channel {
private source: AudioBufferSourceNode | null = null;
private readonly gain: GainNode;
private queueList: Queued[] = [];
private startedAt = 0;
private startOffset = 0;
private rate = 1;
current: string | null = null;
private volumeValue = 255;
constructor(private readonly mixer: SoundMixer) {
this.gain = mixer.context.createGain();
this.gain.connect(mixer.output);
this.gain.gain.value = 1;
}
get volume(): number {
return this.volumeValue;
}
set volume(v: number) {
this.volumeValue = v;
this.gain.gain.cancelScheduledValues(this.mixer.context.currentTime);
this.gain.gain.value = v / 255;
}
/** ms into the current member (Director sound.currentTime). */
get currentTime(): number {
if (!this.source) return 0;
return ((this.mixer.context.currentTime - this.startedAt) * this.rate + this.startOffset) * 1000;
}
play(o: PlayOptions): void {
this.queueList = [];
this.start(o);
}
queue(o: PlayOptions): void {
if (!this.source) this.start(o);
else this.queueList.push(o);
}
/** Director getPlaylist(): queued members not yet playing. */
playlistCount(): number {
return this.queueList.length;
}
stop(): void {
this.queueList = [];
this.halt();
}
fadeTo(volume: number, ms: number): void {
const ctx = this.mixer.context;
this.gain.gain.cancelScheduledValues(ctx.currentTime);
this.gain.gain.setValueAtTime(this.gain.gain.value, ctx.currentTime);
this.gain.gain.linearRampToValueAtTime(volume / 255, ctx.currentTime + ms / 1000);
this.volumeValue = volume;
}
fadeOut(ms: number): void {
this.fadeTo(0, ms);
}
private halt(): void {
if (this.source) {
this.source.onended = null;
try {
this.source.stop();
} catch {
// already stopped
}
this.source.disconnect();
}
this.source = null;
this.current = null;
}
private start(o: PlayOptions): void {
this.halt();
const buffer = this.mixer.buffer(o.member);
if (!buffer) return;
const ctx = this.mixer.context;
const src = ctx.createBufferSource();
src.buffer = buffer;
this.rate = Math.pow(2, (o.rateShift ?? 0) / 12);
src.playbackRate.value = this.rate;
src.loop = o.loopCount === 0;
src.connect(this.gain);
const offset = Math.min(Math.max(0, (o.startTime ?? 0) / 1000), Math.max(0, buffer.duration - 0.001));
src.onended = () => {
if (this.source !== src) return;
this.source = null;
this.current = null;
const next = this.queueList.shift();
if (next) this.start(next);
};
src.start(0, offset);
this.source = src;
this.startedAt = ctx.currentTime;
this.startOffset = offset;
this.current = o.member.toLowerCase();
}
}
export class SoundMixer {
readonly context: AudioContext;
readonly output: GainNode;
private readonly buffers = new Map<string, AudioBuffer>();
private readonly channels = new Map<number, Channel>();
private enabled = true;
constructor() {
this.context = new AudioContext();
this.output = this.context.createGain();
this.output.connect(this.context.destination);
}
/**
* Decode every bundled sound ("sounds/<Name>.wav|mp3|…"); names are
* case-insensitive like Director's. A sound the bundle lacks is synthesised.
* Loading again (a new asset pack) stops every channel and replaces the set.
*/
async load(names: string[]): Promise<void> {
const next = new Map<string, AudioBuffer>();
await Promise.all(
names.map(async (name) => {
const bytes = await assetBytes(`sounds/${name}.wav`);
if (!bytes) {
const buffer = synthesize(this.context, name);
if (buffer) next.set(name.toLowerCase(), buffer);
return;
}
try {
next.set(name.toLowerCase(), await this.context.decodeAudioData(bytes));
} catch {
// An undecodable sample just stays silent.
}
}),
);
for (const channel of this.channels.values()) channel.stop();
this.buffers.clear();
for (const [name, buffer] of next) this.buffers.set(name, buffer);
}
buffer(name: string): AudioBuffer | undefined {
return this.buffers.get(name.toLowerCase());
}
/** Duration in ms (Director member.duration). */
duration(name: string): number {
return (this.buffer(name)?.duration ?? 0) * 1000;
}
sound(n: number): Channel {
let ch = this.channels.get(n);
if (!ch) {
ch = new Channel(this);
this.channels.set(n, ch);
}
return ch;
}
/** the soundEnabled */
get soundEnabled(): boolean {
return this.enabled;
}
set soundEnabled(v: boolean) {
this.enabled = v;
this.output.gain.value = v ? 1 : 0;
}
/** Browsers start audio suspended until a user gesture. */
resume(): void {
if (this.context.state === 'suspended') void this.context.resume();
}
}
export const SOUND_NAMES = [
'CarStart', 'CarLoop', 'CarEnd',
'CarTurnLeft01', 'CarTurnLeft02', 'CarTurnLeft03',
'CarTurnRight01', 'CarTurnRight02', 'CarTurnRight03',
'Crash1', 'Crash2', 'crash3', 'crash4', 'crash5', 'crash6', 'crash7', 'crash8',
'Crash1_glass', 'Crash2_glass', 'Crash3_glass',
'Alarm', 'StingWin', 'StingCheckpoint', 'StingLoseLoop', 'StingHiddenTreasure', 'buttonclick',
'SSRMusicSplashScreen', 'musicloop1', 'musicloop2', 'musicloop3', 'musicloop4', 'musicloop5',
];
export type Sound = (n: number) => Channel;
