Back to Supersonic RC Revive
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.

src/game/audio.ts
// 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;