A game can now film itself. A behavior starts and stops a take, and the editor turns it into a video asset with the run's picture and the sound the player hears. The exported game hands the file over as a download, since there is no project to keep it in. - New action nodes `Start recording` (Name, Resolution, FPS) and `Stop recording`. An empty name becomes `Recording <timestamp>`. - Resolution is `Game view`, `720p` or `1080p`. For the fixed sizes the height is set and the width follows the camera. - The picture is the camera only, without hints, overlays or the letterbox. It is drawn into a render texture of the target size, so a smaller window still gives full 1080p. - `PictureReader` reads the texture back through pixel buffers with a fence. The editor never waits on the GPU, and a frame with all three buffers busy is dropped and counted. - Nothing of the take reaches the visible canvas. A first version drew tiles into it and copied them out, and with the low-latency canvas that midway state flickered on screen. - The sound is tapped behind the master limiter in the FX worklet and encoded as Opus. - H.264 is chosen when the browser has it, VP9 otherwise. A small own Matroska writer packs both tracks, no new dependency. It names the frame rate and BT.709 so ffmpeg keeps both. - A second `Start recording` is ignored with a warning in the log. A take goes on through `Load scene` and a restart, pauses with the editor and is saved when the run ends. It stops at 2 GB. - The editor uploads the take on the existing video route and makes the asset once the run is over. The video tab gets a Download button for the converted MP4. - `downloadBlob` in `src/ui` replaces the song editor's own copy. Findings: - `On start` runs before the game screen knows the world. A take therefore fixes its size on the first frame that has a camera. - Without a frame rate in the file, ffmpeg turned 30 fps into 59.94. - The worker's poster JPEG is off in colour for every upload (red 255 comes out as 229, BT.601). Not touched here. Tests: - Unit recording + matroska: 21 green, plus the node catalog cases. The Matroska test reads the file back with its own EBML reader. - e2e 67 (new) on melektaus-atx: ALL PASSED. 1080p30 from a 1280x800 window gives a 1920x1080 MP4 with sound and no dropped frame. Every 4th pixel is checked, a green quarter catches an upside-down picture. A take ended by Esc is saved with the timestamp name. - e2e 60j: the exported game downloads an `.mkv` with EBML magic. - The flicker is gone on the user's machine without `?vsync`. - Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4586 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
/**
|
||
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
|
||
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
|
||
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a
|
||
* `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs, a streamed
|
||
* sound (#349) a `StreamQueue`. Loaded into
|
||
* the context once by the sound library; nothing on the page imports it.
|
||
*/
|
||
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
|
||
import { Crusher } from '../core/soundfx/crusher';
|
||
import { Limiter, type LimiterNumbers } from '../core/soundfx/dynamics';
|
||
import { LFO_SHAPES, type LfoNumbers, Tremolo, Vibrato } from '../core/soundfx/lfo';
|
||
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
|
||
import { PitchPlayer, PitchShifter } from '../core/soundfx/pitchPlayer';
|
||
import { STREAM_RATE, StreamQueue } from '../core/music/streamQueue';
|
||
|
||
/** What the audio thread has and the page's types do not. */
|
||
declare const sampleRate: number;
|
||
declare const currentTime: number;
|
||
declare class AudioWorkletProcessor {
|
||
readonly port: MessagePort;
|
||
}
|
||
declare function registerProcessor<T>(
|
||
name: string,
|
||
processor: new (options: { processorOptions: T }) => object,
|
||
): void;
|
||
|
||
type ParamValues = Record<string, Float32Array>;
|
||
|
||
/** A processor that runs until it is told to stop, then lets go of its port so it can be collected. */
|
||
abstract class Stoppable extends AudioWorkletProcessor {
|
||
protected stopped = false;
|
||
|
||
protected stop(): void {
|
||
this.stopped = true;
|
||
this.port.close();
|
||
}
|
||
}
|
||
|
||
/** An effect between two stages of a voice or a bus; it runs until it is told to stop. */
|
||
abstract class Effect extends Stoppable {
|
||
constructor() {
|
||
super();
|
||
this.port.onmessage = () => this.stop();
|
||
}
|
||
|
||
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean {
|
||
if (this.stopped) return false;
|
||
this.render(inputs[0] ?? [], outputs[0]!, parameters);
|
||
return true;
|
||
}
|
||
|
||
protected abstract render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void;
|
||
}
|
||
|
||
/** A block of a voice, or the part of it from the voice's first sample on. */
|
||
interface Block {
|
||
out: Float32Array[];
|
||
input: Float32Array[];
|
||
parameters: ParamValues;
|
||
}
|
||
|
||
/** The second of the audio clock a voice is first heard at (#332); before it, silence. */
|
||
class Start {
|
||
constructor(private at: number) {}
|
||
|
||
/**
|
||
* The block from the voice's first sample on, the output, the input and every parameter cut
|
||
* there, or null while all of it lies before. Once the voice runs, the block as it is.
|
||
*/
|
||
cut(block: Block): Block | null {
|
||
if (this.at <= currentTime) return block;
|
||
const wait = Math.round((this.at - currentTime) * sampleRate);
|
||
for (const channel of block.out) channel.fill(0, 0, wait);
|
||
if (wait >= (block.out[0]?.length ?? 0)) return null;
|
||
this.at = 0;
|
||
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
|
||
return {
|
||
out: block.out.map((channel) => channel.subarray(wait)),
|
||
input: block.input.map(from),
|
||
parameters: Object.fromEntries(Object.entries(block.parameters).map(([name, v]) => [name, from(v)])),
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A sound of its own, heard from a second of the audio clock on; it tells the page once it has
|
||
* ended, and runs until then or until it is told to stop.
|
||
*/
|
||
abstract class Source extends Stoppable {
|
||
private readonly start: Start;
|
||
|
||
constructor(startAt: number) {
|
||
super();
|
||
this.start = new Start(startAt);
|
||
}
|
||
|
||
process(_inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean {
|
||
if (this.stopped) return false;
|
||
const block = this.start.cut({ out: outputs[0]!, input: [], parameters });
|
||
if (!block) return true;
|
||
if (this.render(block.out, block.parameters)) return true;
|
||
this.port.postMessage('ended');
|
||
return false;
|
||
}
|
||
|
||
/** Fills `out`; false once the sound has ended. */
|
||
protected abstract render(out: Float32Array[], parameters: ParamValues): boolean;
|
||
}
|
||
|
||
export interface PitchOptions {
|
||
/** The file the samples are of: they are sent with the first voice of it only and kept here. */
|
||
file: string;
|
||
channels?: Float32Array[];
|
||
/** The sound's rate over the context's. */
|
||
step: number;
|
||
loop: boolean;
|
||
/** Where a loop runs, in samples of the sound (#331); without them it is the whole sound. */
|
||
loopStart?: number;
|
||
loopEnd?: number;
|
||
/** The second of the audio clock its first sample is heard at (#332); before it, silence. */
|
||
startAt?: number;
|
||
/** Samples of the sound before where it starts, for a clip of a song taken up mid-way (#334). */
|
||
skip?: number;
|
||
}
|
||
|
||
/** Every sound a voice has been played from, for the next voice of it – processors share this scope. */
|
||
const sounds = new Map<string, Float32Array[]>();
|
||
|
||
class PitchProcessor extends Source {
|
||
private readonly player: PitchPlayer;
|
||
|
||
constructor({ processorOptions }: { processorOptions: PitchOptions }) {
|
||
super(processorOptions.startAt ?? 0);
|
||
const { file, channels, step, loop, loopStart, loopEnd, skip } = processorOptions;
|
||
if (channels) sounds.set(file, channels);
|
||
const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop;
|
||
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip);
|
||
this.port.onmessage = (event: MessageEvent) => {
|
||
if (event.data === 'leave') this.player.leaveLoop();
|
||
else this.stop();
|
||
};
|
||
}
|
||
|
||
protected render(out: Float32Array[], parameters: ParamValues): boolean {
|
||
return this.player.render(out, parameters.pitch!);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [{ name: 'pitch', defaultValue: 0 }];
|
||
}
|
||
}
|
||
|
||
registerProcessor('pitch-player', PitchProcessor);
|
||
|
||
/** The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. */
|
||
class ShiftProcessor extends Effect {
|
||
private readonly shifter = new PitchShifter(2, sampleRate);
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
this.shifter.render(input, out, parameters.pitch!);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [{ name: 'pitch', defaultValue: 0 }];
|
||
}
|
||
}
|
||
|
||
registerProcessor('pitch-shifter', ShiftProcessor);
|
||
|
||
export interface StreamOptions {
|
||
channels: number;
|
||
/** The second of the audio clock its first sample is heard at; before it, silence. */
|
||
startAt: number;
|
||
/** What it starts with, so it is there for the first sample; the rest comes by message. */
|
||
first: Float32Array[];
|
||
}
|
||
|
||
/** Samples of the stream read between two reports to the page. */
|
||
const STREAM_REPORT = 4800;
|
||
|
||
/**
|
||
* A streamed sound (#349): plays the samples the page sends it, in order, and tells it how far it has
|
||
* read so it can send more. 'end' says nothing more comes.
|
||
*/
|
||
class StreamProcessor extends Source {
|
||
private readonly queue: StreamQueue;
|
||
private reported = 0;
|
||
|
||
constructor({ processorOptions }: { processorOptions: StreamOptions }) {
|
||
super(processorOptions.startAt);
|
||
this.queue = new StreamQueue(processorOptions.channels, STREAM_RATE / sampleRate);
|
||
this.queue.push(processorOptions.first);
|
||
this.port.onmessage = (event: MessageEvent) => {
|
||
const data: unknown = event.data;
|
||
if (Array.isArray(data)) this.queue.push(data as Float32Array[]);
|
||
else if (data === 'end') this.queue.end();
|
||
else this.stop();
|
||
};
|
||
}
|
||
|
||
protected render(out: Float32Array[]): boolean {
|
||
const going = this.queue.render(out);
|
||
if (this.queue.used - this.reported >= STREAM_REPORT) {
|
||
this.reported = this.queue.used;
|
||
this.port.postMessage({ used: this.queue.used, skipped: this.queue.skipped });
|
||
}
|
||
return going;
|
||
}
|
||
}
|
||
|
||
registerProcessor('stream-player', StreamProcessor);
|
||
|
||
/** A Bitcrusher (#322). */
|
||
class CrushProcessor extends Effect {
|
||
private readonly crusher = new Crusher(sampleRate);
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
this.crusher.render(input, out, parameters.bits!, parameters.rate!, parameters.mix!);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [
|
||
{ name: 'bits', defaultValue: 16 },
|
||
{ name: 'rate', defaultValue: 48000 },
|
||
{ name: 'mix', defaultValue: 0 },
|
||
];
|
||
}
|
||
}
|
||
|
||
registerProcessor('bit-crusher', CrushProcessor);
|
||
|
||
export interface PhaserOptions {
|
||
stages: number;
|
||
}
|
||
|
||
const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const;
|
||
|
||
/** A Phaser (#341). */
|
||
class PhaserProcessor extends Effect {
|
||
private readonly phaser: Phaser;
|
||
|
||
constructor({ processorOptions }: { processorOptions: PhaserOptions }) {
|
||
super();
|
||
this.phaser = new Phaser(sampleRate, processorOptions.stages);
|
||
}
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
this.phaser.render(input, out, parameters as PhaserNumbers);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return PHASER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
|
||
}
|
||
}
|
||
|
||
registerProcessor('phaser', PhaserProcessor);
|
||
|
||
export interface FlangerOptions {
|
||
/** The second of the audio clock its sound starts at: the sweep counts from there. */
|
||
startAt: number;
|
||
}
|
||
|
||
const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const;
|
||
|
||
/** A Flanger (#341); its sweep starts with the voice. */
|
||
class FlangerProcessor extends Effect {
|
||
private readonly flanger = new Flanger(sampleRate);
|
||
private readonly start: Start;
|
||
|
||
constructor({ processorOptions }: { processorOptions: FlangerOptions }) {
|
||
super();
|
||
this.start = new Start(processorOptions.startAt);
|
||
}
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
// Nothing is heard before the voice starts, and the sweep waits for its first sample.
|
||
const block = this.start.cut({ out, input, parameters });
|
||
if (block) this.flanger.render(block.input, block.out, block.parameters as FlangerNumbers);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return FLANGER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
|
||
}
|
||
}
|
||
|
||
registerProcessor('flanger', FlangerProcessor);
|
||
|
||
const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const;
|
||
|
||
/** A Limiter (#326). */
|
||
class LimitProcessor extends Effect {
|
||
private readonly limiter = new Limiter(sampleRate);
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
this.limiter.render(input, out, parameters as LimiterNumbers);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return LIMITER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
|
||
}
|
||
}
|
||
|
||
registerProcessor('limiter', LimitProcessor);
|
||
|
||
export interface LfoOptions {
|
||
/** The shape by its place in `LFO_SHAPES`. */
|
||
shape: number;
|
||
/** The second of the audio clock its voice starts at: the LFO is at phase 0 on that sample. */
|
||
startAt: number;
|
||
/** A Vibrato's average delay in seconds. */
|
||
center: number;
|
||
}
|
||
|
||
/** A Tremolo or a Vibrato (#320); its LFO starts with its voice. */
|
||
class LfoProcessor extends Effect {
|
||
private readonly effect: Tremolo | Vibrato;
|
||
private readonly start: Start;
|
||
|
||
constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) {
|
||
super();
|
||
const shape = LFO_SHAPES[processorOptions.shape]?.value ?? 'sine';
|
||
this.effect = processorOptions.vibrato
|
||
? new Vibrato(shape, sampleRate, processorOptions.center)
|
||
: new Tremolo(shape, sampleRate);
|
||
this.start = new Start(processorOptions.startAt);
|
||
}
|
||
|
||
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
|
||
// Nothing is heard before the voice starts, and the LFO waits for its first sample.
|
||
const block = this.start.cut({ out, input, parameters });
|
||
if (block) this.effect.render(block.input, block.out, block.parameters as LfoNumbers);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [
|
||
{ name: 'rate', defaultValue: 0 },
|
||
{ name: 'depth', defaultValue: 0 },
|
||
];
|
||
}
|
||
}
|
||
|
||
registerProcessor('lfo', LfoProcessor);
|
||
|
||
export interface ChipOptions extends ChipShape {
|
||
/** The second of the audio clock its first sample is heard at; before it, silence. */
|
||
startAt: number;
|
||
}
|
||
|
||
/** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */
|
||
class ChipProcessor extends Source {
|
||
private readonly player: ChipPlayer;
|
||
|
||
constructor({ processorOptions }: { processorOptions: ChipOptions }) {
|
||
super(processorOptions.startAt);
|
||
this.player = new ChipPlayer(processorOptions, sampleRate);
|
||
this.port.onmessage = () => this.stop();
|
||
}
|
||
|
||
protected render(out: Float32Array[], parameters: ParamValues): boolean {
|
||
return this.player.render(out, parameters.frequency!, parameters.duty!, parameters.pitch!);
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [
|
||
{ name: 'frequency', defaultValue: 440 },
|
||
{ name: 'duty', defaultValue: 0.5 },
|
||
{ name: 'pitch', defaultValue: 0 },
|
||
];
|
||
}
|
||
}
|
||
|
||
registerProcessor('chip-player', ChipProcessor);
|
||
|
||
/**
|
||
* Says how many channels reach it at most (#340): whether what goes into the master of a pattern
|
||
* being baked is mono, so its bake is too and goes on the same way.
|
||
*/
|
||
class ChannelProbe extends AudioWorkletProcessor {
|
||
private most = 0;
|
||
|
||
process(inputs: Float32Array[][]): boolean {
|
||
const count = inputs[0]?.length ?? 0;
|
||
if (count > this.most) {
|
||
this.most = count;
|
||
this.port.postMessage(count);
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
registerProcessor('channel-probe', ChannelProbe);
|
||
|
||
/** Frames a recording's sound is handed over in: about a tenth of a second. */
|
||
const RECORD_BLOCK = 4096;
|
||
|
||
/**
|
||
* Hands what reaches it to the page, both sides, `RECORD_BLOCK` frames at a time (#405): the master
|
||
* as it leaves the limiter, for a recording of the game view. Told to stop, it sends what it holds
|
||
* and an empty piece after it, the sign that nothing more comes.
|
||
*/
|
||
class RecordTap extends Stoppable {
|
||
private readonly sides = [new Float32Array(RECORD_BLOCK), new Float32Array(RECORD_BLOCK)];
|
||
private filled = 0;
|
||
|
||
constructor() {
|
||
super();
|
||
this.port.onmessage = () => {
|
||
this.send();
|
||
this.port.postMessage([new Float32Array(0), new Float32Array(0)]);
|
||
this.stop();
|
||
};
|
||
}
|
||
|
||
process(inputs: Float32Array[][]): boolean {
|
||
if (this.stopped) return false;
|
||
const input = inputs[0] ?? [];
|
||
const left = input[0];
|
||
const frames = left?.length ?? 128;
|
||
this.sides.forEach((side, c) => {
|
||
const data = input[c] ?? left;
|
||
if (data) side.set(data, this.filled);
|
||
else side.fill(0, this.filled, this.filled + frames);
|
||
});
|
||
this.filled += frames;
|
||
if (this.filled + frames > RECORD_BLOCK) this.send();
|
||
return true;
|
||
}
|
||
|
||
private send(): void {
|
||
if (this.filled === 0) return;
|
||
const out = this.sides.map((side) => side.slice(0, this.filled));
|
||
this.filled = 0;
|
||
this.port.postMessage(
|
||
out,
|
||
out.map((side) => side.buffer),
|
||
);
|
||
}
|
||
}
|
||
|
||
registerProcessor('record-tap', RecordTap);
|