Compare commits
2
Commits
70402aa19a
...
b5e071ca68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e071ca68 | ||
|
|
dd2096e0df |
No files matched your search
@@ -984,6 +984,7 @@ export class Editor implements PlayHost {
|
|||||||
play: this.play,
|
play: this.play,
|
||||||
follows: followsAgent,
|
follows: followsAgent,
|
||||||
stores,
|
stores,
|
||||||
|
heard: () => soundLibrary.heard(),
|
||||||
},
|
},
|
||||||
wait,
|
wait,
|
||||||
),
|
),
|
||||||
@@ -993,7 +994,11 @@ export class Editor implements PlayHost {
|
|||||||
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
link.onChange = () => this.syncAgentBadge();
|
link.onChange = () => {
|
||||||
|
this.syncAgentBadge();
|
||||||
|
// The buses are tapped for what the agent hears only while it is switched on (#436).
|
||||||
|
soundLibrary.watch(link.on);
|
||||||
|
};
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { noteName } from '../../core/instrument/notes';
|
||||||
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
|
import { clipsOf } from '../../core/model/sound';
|
||||||
|
import { MASTER_MIX } from '../../core/music/mixer';
|
||||||
|
import { playedSoundOf } from '../../core/properties/playedSound';
|
||||||
|
import type { World } from '../../runtime/World';
|
||||||
|
import type { HeardSound, HeardVoice } from '../../viewport/SoundLibrary';
|
||||||
|
|
||||||
|
/** Below this a bus counts as silent: far under anything a speaker gives out. */
|
||||||
|
export const SILENT_DB = -90;
|
||||||
|
/** The most voices an answer lists; the rest are counted. */
|
||||||
|
export const MAX_VOICES = 40;
|
||||||
|
|
||||||
|
export const STALLED_HINT =
|
||||||
|
'The browser holds the audio clock while the game runs, so nothing is heard and notes stay planned. ' +
|
||||||
|
'The browser starts it only once the user has clicked or pressed a key in the editor page, and not ' +
|
||||||
|
'without a sound output: ask the user.';
|
||||||
|
|
||||||
|
const INSTRUMENT = 'instrument:';
|
||||||
|
const PLAYING_BUS = /^play:(\d+)(?::(.*))?$/;
|
||||||
|
/** The bus of a track of a song played on its own: `play:<n>:song:<song>:<track>`. */
|
||||||
|
const SONG_TRACK = /^song:([^:]+):([^:#]+)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `get_runtime` says of the sound (#436): the level of the master and of every bus that is
|
||||||
|
* heard by itself, the voices sounding now with what they play and who asked, the patterns, songs
|
||||||
|
* and compositions playing, and what is not heard for lack of its bytes. Names stand in for ids.
|
||||||
|
*/
|
||||||
|
export function audioView(heard: HeardSound, project: Project, world: World): object {
|
||||||
|
const names = new SoundNames(project, heard, world);
|
||||||
|
const sounding = heard.voices.filter((voice) => voice.startsIn === 0);
|
||||||
|
const planned = heard.voices.length - sounding.length;
|
||||||
|
// A pause holds the clock; its state is the one it had while the game ran.
|
||||||
|
const { state, time } = heard.clock;
|
||||||
|
return {
|
||||||
|
clock: { state, time: Math.round(time * 1000) / 1000 },
|
||||||
|
...(state === 'suspended' && { hint: STALLED_HINT }),
|
||||||
|
window: Math.round(heard.window * 100) / 100,
|
||||||
|
levels: heard.buses
|
||||||
|
.filter((bus) => shownBus(bus.name))
|
||||||
|
.sort((a, b) => Number(b.name === MASTER_MIX) - Number(a.name === MASTER_MIX))
|
||||||
|
.map(({ name, peak, rms }) => levelRow(names.bus(name), peak, rms)),
|
||||||
|
voices: sounding.slice(0, MAX_VOICES).map((voice) => ({
|
||||||
|
...names.voice(voice),
|
||||||
|
...(voice.released && { released: true }),
|
||||||
|
...(voice.ringing && { ringing: true }),
|
||||||
|
})),
|
||||||
|
...(sounding.length > MAX_VOICES && { moreVoices: sounding.length - MAX_VOICES }),
|
||||||
|
...(planned > 0 && { planned }),
|
||||||
|
playing: heard.playings.map((playing) => ({
|
||||||
|
...names.playing(playing.name),
|
||||||
|
...(names.by(playing.owner) && { by: names.by(playing.owner) }),
|
||||||
|
...(!playing.playing && { ended: true }),
|
||||||
|
...(playing.fading !== null && { fading: true }),
|
||||||
|
...(playing.baked && { baked: true }),
|
||||||
|
})),
|
||||||
|
...(heard.waiting.length > 0 && { waiting: heard.waiting.map((voice) => names.voice(voice)) }),
|
||||||
|
...(heard.failed.length > 0 && { failed: heard.failed.map((file) => names.file(file)) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buses worth a level of their own: the master, those of the mix, the master of each playing
|
||||||
|
* and the tracks of a song. What lies inside a track – its items, its own bus for them – is heard on
|
||||||
|
* the track.
|
||||||
|
*/
|
||||||
|
function shownBus(name: string): boolean {
|
||||||
|
if (name === MASTER_MIX || name.startsWith('mix:')) return true;
|
||||||
|
const playing = PLAYING_BUS.exec(name);
|
||||||
|
return playing !== null && (playing[2] === undefined || SONG_TRACK.test(playing[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelRow(bus: string, peak: readonly number[], rms: readonly number[]): object {
|
||||||
|
const loudest = decibels(Math.max(...peak));
|
||||||
|
if (loudest < SILENT_DB) return { bus, silent: true };
|
||||||
|
const power = rms.reduce((sum, side) => sum + side * side, 0) / rms.length;
|
||||||
|
return { bus, peak: round(loudest), rms: round(decibels(Math.sqrt(power))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decibels(gain: number): number {
|
||||||
|
return gain > 0 ? 20 * Math.log10(gain) : -Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(db: number): number {
|
||||||
|
return Number.isFinite(db) ? Math.round(db * 10) / 10 : SILENT_DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names for what the sink knows by file, by bus and by the id of an entity. */
|
||||||
|
class SoundNames {
|
||||||
|
private readonly files = new Map<string, Record<string, string>>();
|
||||||
|
private readonly playings: ReadonlyMap<number, string>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly project: Project,
|
||||||
|
heard: HeardSound,
|
||||||
|
private readonly world: World,
|
||||||
|
) {
|
||||||
|
for (const asset of project.assets) {
|
||||||
|
if (asset.type === 'sound') {
|
||||||
|
this.files.set(asset.file, { sound: asset.name });
|
||||||
|
for (const clip of clipsOf(asset))
|
||||||
|
this.files.set(playedSoundOf({ asset, clip }), { sound: `${asset.name} / ${clip.name}` });
|
||||||
|
}
|
||||||
|
if ((asset.type === 'pattern' || asset.type === 'song') && asset.bake)
|
||||||
|
this.files.set(asset.bake.file, { bake: `${asset.type} ${asset.name}` });
|
||||||
|
}
|
||||||
|
this.playings = new Map(heard.playings.map(({ id, name }) => [id, this.label(name)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
voice(voice: HeardVoice): object {
|
||||||
|
const by = this.by(voice.owner);
|
||||||
|
return {
|
||||||
|
...this.file(voice.file, voice.note),
|
||||||
|
bus: this.bus(voice.bus),
|
||||||
|
...(by && { by }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a file plays: a sound or a clip of one, a note of an instrument, the bake of a pattern or song. */
|
||||||
|
file(file: string, note?: number): Record<string, string> {
|
||||||
|
if (file.startsWith(INSTRUMENT)) {
|
||||||
|
const id = file.slice(INSTRUMENT.length);
|
||||||
|
return {
|
||||||
|
instrument: findAsset(this.project, id)?.name ?? id,
|
||||||
|
...(note !== undefined && { note: noteName(note) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.files.get(file) ?? { file };
|
||||||
|
}
|
||||||
|
|
||||||
|
bus(name: string): string {
|
||||||
|
if (name === '' || name === MASTER_MIX) return 'master';
|
||||||
|
if (name.startsWith('mix:')) {
|
||||||
|
const id = name.slice('mix:'.length);
|
||||||
|
return findAsset(this.project, id)?.name ?? id;
|
||||||
|
}
|
||||||
|
const playing = PLAYING_BUS.exec(name);
|
||||||
|
if (!playing) return name;
|
||||||
|
const of = this.playings.get(Number(playing[1])) ?? `playing ${playing[1]}`;
|
||||||
|
const track = SONG_TRACK.exec(playing[2] ?? '');
|
||||||
|
if (!track) return of;
|
||||||
|
const song = findAsset(this.project, track[1]!);
|
||||||
|
const named = song?.type === 'song' ? song.tracks.find((t) => t.id === track[2]) : undefined;
|
||||||
|
return `${of}, track ${named?.name ?? track[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
playing(name: string): Record<string, string> {
|
||||||
|
const [kind, id] = splitName(name);
|
||||||
|
return kind ? { [kind]: findAsset(this.project, id)?.name ?? id } : { name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The object a voice's behavior sits on, or the behavior itself; '' for no owner. */
|
||||||
|
by(owner: string): string {
|
||||||
|
if (owner === '') return '';
|
||||||
|
const entity = this.world.entities.find((e) => e.id === owner);
|
||||||
|
if (!entity) return owner;
|
||||||
|
return entity.parent?.name ?? entity.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private label(name: string): string {
|
||||||
|
const [kind, id] = splitName(name);
|
||||||
|
return kind ? `${kind} ${findAsset(this.project, id)?.name ?? id}` : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `pattern:<id>`, `song:<id>` or `composition:<id>` as kind and id; no kind for anything else. */
|
||||||
|
function splitName(name: string): [string, string] {
|
||||||
|
const at = name.indexOf(':');
|
||||||
|
const kind = at > 0 ? name.slice(0, at) : '';
|
||||||
|
return ['pattern', 'song', 'composition'].includes(kind) ? [kind, name.slice(at + 1)] : ['', name];
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import type { StepTarget } from './commandCatalog';
|
|||||||
import { screenshot } from './screenshot';
|
import { screenshot } from './screenshot';
|
||||||
import type { FileAssetType } from './storedFiles';
|
import type { FileAssetType } from './storedFiles';
|
||||||
import type { FileStore } from '../../core/store/FileStore';
|
import type { FileStore } from '../../core/store/FileStore';
|
||||||
|
import type { HeardSound } from '../../viewport/SoundLibrary';
|
||||||
|
|
||||||
/** What the agent's tools reach of the editor beyond its state. */
|
/** What the agent's tools reach of the editor beyond its state. */
|
||||||
export interface EditorParts {
|
export interface EditorParts {
|
||||||
@@ -32,6 +33,8 @@ export interface EditorParts {
|
|||||||
follows(): boolean;
|
follows(): boolean;
|
||||||
/** Where the files of each kind are kept (#431). */
|
/** Where the files of each kind are kept (#431). */
|
||||||
stores: Record<FileAssetType, FileStore>;
|
stores: Record<FileAssetType, FileStore>;
|
||||||
|
/** What is heard now (#436). */
|
||||||
|
heard(): HeardSound;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long the user has to answer an agent's question before it counts as a no. */
|
/** How long the user has to answer an agent's question before it counts as a no. */
|
||||||
@@ -92,7 +95,14 @@ export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost {
|
|||||||
const run = parts.play.agentRun();
|
const run = parts.play.agentRun();
|
||||||
if (!run) return null;
|
if (!run) return null;
|
||||||
const { world, input, shared } = run;
|
const { world, input, shared } = run;
|
||||||
return { world, shared, project: state.project, heldKeys: input.heldKeys, paused: state.paused };
|
return {
|
||||||
|
world,
|
||||||
|
shared,
|
||||||
|
project: state.project,
|
||||||
|
heldKeys: input.heldKeys,
|
||||||
|
paused: state.paused,
|
||||||
|
audio: parts.heard(),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
takesLong: wait,
|
takesLong: wait,
|
||||||
files: (type) => parts.stores[type].list(),
|
files: (type) => parts.stores[type].list(),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { Entity } from '../../runtime/Entity';
|
|||||||
import type { World } from '../../runtime/World';
|
import type { World } from '../../runtime/World';
|
||||||
import { cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
import { cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||||
import { collectHolderItems } from '../../viewport/items';
|
import { collectHolderItems } from '../../viewport/items';
|
||||||
|
import type { HeardSound } from '../../viewport/SoundLibrary';
|
||||||
|
import { audioView } from './audioView';
|
||||||
import { exact } from './playInput';
|
import { exact } from './playInput';
|
||||||
|
|
||||||
/** The running game as `get_runtime` reads it (#429). */
|
/** The running game as `get_runtime` reads it (#429). */
|
||||||
@@ -19,6 +21,8 @@ export interface RuntimeFacts {
|
|||||||
heldKeys: ReadonlySet<string>;
|
heldKeys: ReadonlySet<string>;
|
||||||
/** Paused from the toolbar; the game's own pause is the world's. */
|
/** Paused from the toolbar; the game's own pause is the world's. */
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
|
/** What is heard (#436); left out where the run plays to nobody. */
|
||||||
|
audio?: HeardSound;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many console lines an answer carries unless the agent asks for more. */
|
/** How many console lines an answer carries unless the agent asks for more. */
|
||||||
@@ -66,6 +70,7 @@ export function runtimeView(
|
|||||||
mouse: { ...world.pointer, x: round(world.pointer.x), y: round(world.pointer.y) },
|
mouse: { ...world.pointer, x: round(world.pointer.x), y: round(world.pointer.y) },
|
||||||
objects: picked.map((entity) => entityRow(entity, project, named.length > 0)),
|
objects: picked.map((entity) => entityRow(entity, project, named.length > 0)),
|
||||||
variables: sharedRows(facts.shared, project),
|
variables: sharedRows(facts.shared, project),
|
||||||
|
...(facts.audio && { audio: audioView(facts.audio, project, world) }),
|
||||||
console: lines > 0 ? world.log.slice(-lines) : [],
|
console: lines > 0 ? world.log.slice(-lines) : [],
|
||||||
consoleLines: world.log.length,
|
consoleLines: world.log.length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
{
|
{
|
||||||
"name": "get_runtime",
|
"name": "get_runtime",
|
||||||
"title": "Read the running game",
|
"title": "Read the running game",
|
||||||
"description": "The game as it runs now: game time, whether it is paused, the camera, the keys held and the mouse, every object with its id, type and position in world units (behaviors with their variables), the values of the Variables assets, and the last console lines. Name objects (ids or names) to get all their properties.",
|
"description": "The game as it runs now: game time, whether it is paused, the camera, the keys held and the mouse, every object with its id, type and position in world units (behaviors with their variables), the values of the Variables assets, the sound (audio), and the last console lines. Under audio: the audio clock (with a hint when it stands still while the game runs: then nothing is heard); the level of the master, of each bus of the mix, of each pattern, song or composition playing and of the tracks of a song, as peak and RMS in dB over the last window seconds (silent below -90 dB; held while the game is paused); the voices sounding now (a sound, or an instrument with its note), each with its bus and the object it belongs to (by); planned, the notes already set to start; the patterns, songs and compositions playing; waiting, the voices whose bytes have not arrived; and failed, the files that would not load. Name objects (ids or names) to get all their properties.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export interface NoteTiming {
|
|||||||
tempo?: number;
|
tempo?: number;
|
||||||
/** The bus it is heard on: a track of a song (#334) or one of the mix (#325); the master where left out. */
|
/** The bus it is heard on: a track of a song (#334) or one of the mix (#325); the master where left out. */
|
||||||
bus?: string;
|
bus?: string;
|
||||||
|
/** The note it is, for whoever asks what is heard (#436). */
|
||||||
|
note?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When and how a clip of a song plays (#334); see `playClip`. */
|
/** When and how a clip of a song plays (#334); see `playClip`. */
|
||||||
|
|||||||
@@ -349,14 +349,18 @@ export function playNote(
|
|||||||
): number {
|
): number {
|
||||||
const asset = findAssetOf(project, instrumentId, 'instrument');
|
const asset = findAssetOf(project, instrumentId, 'instrument');
|
||||||
if (!asset) return 0;
|
if (!asset) return 0;
|
||||||
const built = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project)).layers;
|
const note = heldNote(struck.note);
|
||||||
|
const built = instrumentBuildFor(asset, note, instrumentSourcesOf(project)).layers;
|
||||||
if (built.length === 0) return 0;
|
if (built.length === 0) return 0;
|
||||||
const layers = fx ? built.map((layer) => throughScopedFx(fx.plan, layer, FX_SCOPE)) : built;
|
const layers = fx ? built.map((layer) => throughScopedFx(fx.plan, layer, FX_SCOPE)) : built;
|
||||||
const velocity = Number.isFinite(struck.velocity) ? Math.max(0, Math.min(1, struck.velocity)) : 1;
|
const velocity = Number.isFinite(struck.velocity) ? Math.max(0, Math.min(1, struck.velocity)) : 1;
|
||||||
const level = Math.max(0, Math.min(1, struck.volume * velocity));
|
const level = Math.max(0, Math.min(1, struck.volume * velocity));
|
||||||
const duration = Number.isFinite(struck.duration) ? Math.max(0, struck.duration) : 0;
|
const duration = Number.isFinite(struck.duration) ? Math.max(0, struck.duration) : 0;
|
||||||
const values = { ...params, [VELOCITY_PARAM]: velocity, ...scoped(fx?.params ?? {}) };
|
const values = { ...params, [VELOCITY_PARAM]: velocity, ...scoped(fx?.params ?? {}) };
|
||||||
return audio.playNote(instrumentVoiceName(instrumentId), layers, values, level, owner, duration, timing);
|
return audio.playNote(instrumentVoiceName(instrumentId), layers, values, level, owner, duration, {
|
||||||
|
...timing,
|
||||||
|
note,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the parameters of a note's own sound FX go by (#346): no name of a parameter has a colon. */
|
/** Where the parameters of a note's own sound FX go by (#346): no name of a parameter has a colon. */
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ const MAX_SEND_DELAY = 1;
|
|||||||
|
|
||||||
/** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */
|
/** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */
|
||||||
const METER_SPAN = 2048;
|
const METER_SPAN = 2048;
|
||||||
|
/**
|
||||||
|
* Samples what the agent hears looks back over (#436): the most an analyser holds, about 0.7 s, so
|
||||||
|
* the gap between two notes does not read as silence.
|
||||||
|
*/
|
||||||
|
const WATCH_SPAN = 32768;
|
||||||
/** The loudest the limiter at the output lets a sample be (#359), and how fast it lets go again. */
|
/** The loudest the limiter at the output lets a sample be (#359), and how fast it lets go again. */
|
||||||
const GUARD_DB = -1;
|
const GUARD_DB = -1;
|
||||||
const GUARD_RELEASE = 0.1;
|
const GUARD_RELEASE = 0.1;
|
||||||
@@ -157,6 +162,8 @@ interface Voice {
|
|||||||
owner: string;
|
owner: string;
|
||||||
/** The bus it is heard on, `''` straight out. */
|
/** The bus it is heard on, `''` straight out. */
|
||||||
bus: string;
|
bus: string;
|
||||||
|
/** The note it is, for a note of an instrument (#436). */
|
||||||
|
note?: number;
|
||||||
/** When it started on the audio clock, what its Envelopes count from. */
|
/** When it started on the audio clock, what its Envelopes count from. */
|
||||||
at: number;
|
at: number;
|
||||||
sources: Source[];
|
sources: Source[];
|
||||||
@@ -252,6 +259,8 @@ interface Start {
|
|||||||
bus?: string;
|
bus?: string;
|
||||||
/** The copy `file` streams from (#349); its bytes are not loaded then, its head is. */
|
/** The copy `file` streams from (#349); its bytes are not loaded then, its head is. */
|
||||||
stream?: StreamFile;
|
stream?: StreamFile;
|
||||||
|
/** The note it is, for a note of an instrument (#436). */
|
||||||
|
note?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,6 +269,10 @@ interface Start {
|
|||||||
*/
|
*/
|
||||||
interface Pending {
|
interface Pending {
|
||||||
id: number;
|
id: number;
|
||||||
|
/** What it plays and who asked, as its voice will have them (#436). */
|
||||||
|
file: string;
|
||||||
|
owner: string;
|
||||||
|
note?: number;
|
||||||
bus: string;
|
bus: string;
|
||||||
fx: Layered;
|
fx: Layered;
|
||||||
timed: { params: FxParams; time: number; ease: EaseShare }[];
|
timed: { params: FxParams; time: number; ease: EaseShare }[];
|
||||||
@@ -298,6 +311,13 @@ export class SoundLibrary implements AudioSink {
|
|||||||
*/
|
*/
|
||||||
private guard: { input: GainNode; limiter: AudioWorkletNode | null; latency: number } | null = null;
|
private guard: { input: GainNode; limiter: AudioWorkletNode | null; latency: number } | null = null;
|
||||||
private guardMeter: BusMeter | null = null;
|
private guardMeter: BusMeter | null = null;
|
||||||
|
/** Whether every bus is tapped for what the agent hears (#436). */
|
||||||
|
private watching = false;
|
||||||
|
/**
|
||||||
|
* The state of the audio clock as a pause took hold (#436): one the browser had suspended already
|
||||||
|
* stays so after the pause, and nothing was heard before it either.
|
||||||
|
*/
|
||||||
|
private heldFrom = 'none';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `guarded` false leaves the limiter out: for checks that follow every node of a voice. With
|
* `guarded` false leaves the limiter out: for checks that follow every node of a voice. With
|
||||||
@@ -509,7 +529,20 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
|
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
|
||||||
const at = timing.at ?? 0;
|
const at = timing.at ?? 0;
|
||||||
const bus = timing.bus ?? '';
|
const bus = timing.bus ?? '';
|
||||||
return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at, bus });
|
const note = timing.note;
|
||||||
|
return this.begin({
|
||||||
|
id: 0,
|
||||||
|
file: name,
|
||||||
|
files,
|
||||||
|
volume,
|
||||||
|
loop: false,
|
||||||
|
owner,
|
||||||
|
fx,
|
||||||
|
duration,
|
||||||
|
at,
|
||||||
|
bus,
|
||||||
|
...(note !== undefined && { note }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -716,23 +749,23 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return [...this.buses.values()].filter((bus) => bus.meter).length;
|
return [...this.buses.values()].filter((bus) => bus.meter).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tapMeter(from: AudioNode): BusMeter {
|
private tapMeter(from: AudioNode, span = METER_SPAN): BusMeter {
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
const split = context.createChannelSplitter(2);
|
const split = context.createChannelSplitter(2);
|
||||||
const sides = [0, 1].map((channel) => {
|
const sides = [0, 1].map((channel) => {
|
||||||
const side = context.createAnalyser();
|
const side = context.createAnalyser();
|
||||||
side.fftSize = METER_SPAN;
|
side.fftSize = span;
|
||||||
split.connect(side, channel);
|
split.connect(side, channel);
|
||||||
return side;
|
return side;
|
||||||
});
|
});
|
||||||
from.connect(split);
|
from.connect(split);
|
||||||
return { split, sides, samples: new Float32Array(METER_SPAN) };
|
return { split, sides, samples: new Float32Array(span) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private dropMeter(bus: Bus): void {
|
private dropMeter(bus: Bus, which: 'meter' | 'watch' = 'meter'): void {
|
||||||
const meter = bus.meter;
|
const meter = bus[which];
|
||||||
if (!meter) return;
|
if (!meter) return;
|
||||||
bus.meter = null;
|
bus[which] = null;
|
||||||
try {
|
try {
|
||||||
bus.pan.disconnect(meter.split);
|
bus.pan.disconnect(meter.split);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -741,10 +774,63 @@ export class SoundLibrary implements AudioSink {
|
|||||||
meter.split.disconnect();
|
meter.split.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cuts the pan of a bus from where it went; its meter stays on. */
|
/** Cuts the pan of a bus from where it went; its meters stay on. */
|
||||||
private cutPan(bus: Bus): void {
|
private cutPan(bus: Bus): void {
|
||||||
bus.pan.disconnect();
|
bus.pan.disconnect();
|
||||||
if (bus.meter) bus.pan.connect(bus.meter.split);
|
if (bus.meter) bus.pan.connect(bus.meter.split);
|
||||||
|
if (bus.watch) bus.pan.connect(bus.watch.split);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taps every bus for what the agent hears (#436), those made from now on as well, or lets the taps
|
||||||
|
* go: on only while the agent is switched on, so the editor pays nothing for it otherwise.
|
||||||
|
*/
|
||||||
|
watch(on: boolean): void {
|
||||||
|
if (this.watching === on) return;
|
||||||
|
this.watching = on;
|
||||||
|
for (const bus of this.buses.values()) {
|
||||||
|
if (!on) this.dropMeter(bus, 'watch');
|
||||||
|
else bus.watch ??= this.tapMeter(bus.pan, WATCH_SPAN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is heard now (#436): every voice and playing, the voices still waiting on their bytes, the
|
||||||
|
* files that would not load, and the level of each bus tapped by `watch` over the last
|
||||||
|
* `WATCH_SPAN` samples, after its level and pan – held where a pause holds the clock.
|
||||||
|
*/
|
||||||
|
heard(): HeardSound {
|
||||||
|
const now = this.context?.currentTime ?? 0;
|
||||||
|
const voiceOf = (voice: Voice | Pending): HeardVoice => ({
|
||||||
|
id: voice.id,
|
||||||
|
file: voice.file,
|
||||||
|
owner: voice.owner,
|
||||||
|
bus: voice.bus,
|
||||||
|
...(voice.note !== undefined && { note: voice.note }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
window: this.context ? WATCH_SPAN / this.context.sampleRate : 0,
|
||||||
|
clock: { state: this.held ? this.heldFrom : this.clockState(), time: now, held: this.held },
|
||||||
|
voices: this.voices.map((voice) => ({
|
||||||
|
...voiceOf(voice),
|
||||||
|
startsIn: Math.max(0, voice.at - now),
|
||||||
|
released: voice.released,
|
||||||
|
ringing: voice.ringing,
|
||||||
|
})),
|
||||||
|
waiting: this.pending.filter((waiting) => !waiting.stopped).map(voiceOf),
|
||||||
|
failed: [...this.failed],
|
||||||
|
playings: this.playbacks().map(({ id, name, owner, playing, fading, baked }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
owner,
|
||||||
|
playing,
|
||||||
|
fading,
|
||||||
|
baked,
|
||||||
|
})),
|
||||||
|
buses: [...this.buses].flatMap(([name, bus]) =>
|
||||||
|
bus.watch ? [{ name, ...readLevels(bus.watch) }] : [],
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the voices of a bus go in; the master for `''` (#325). */
|
/** Where the voices of a bus go in; the master for `''` (#325). */
|
||||||
@@ -776,8 +862,10 @@ export class SoundLibrary implements AudioSink {
|
|||||||
chain: null,
|
chain: null,
|
||||||
leaving: 0,
|
leaving: 0,
|
||||||
meter: null,
|
meter: null,
|
||||||
|
watch: null,
|
||||||
};
|
};
|
||||||
made.input.connect(made.gain).connect(made.pan).connect(this.outOf(target));
|
made.input.connect(made.gain).connect(made.pan).connect(this.outOf(target));
|
||||||
|
if (this.watching) made.watch = this.tapMeter(made.pan, WATCH_SPAN);
|
||||||
this.buses.set(name, made);
|
this.buses.set(name, made);
|
||||||
return made;
|
return made;
|
||||||
}
|
}
|
||||||
@@ -909,10 +997,15 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const names = new Set(this.mix.map((bus) => bus.name));
|
const names = new Set(this.mix.map((bus) => bus.name));
|
||||||
const waits: Promise<void>[] = [];
|
const waits: Promise<void>[] = [];
|
||||||
for (const { name, volume, into, sends, fx } of this.mix) {
|
for (const { name, volume, into, sends, fx } of this.mix) {
|
||||||
|
const made = !this.buses.has(name);
|
||||||
const bus = this.bus(name, into);
|
const bus = this.bus(name, into);
|
||||||
if (sends) this.setSends(bus, sends);
|
if (sends) this.setSends(bus, sends);
|
||||||
else this.route(name, bus, into);
|
else this.route(name, bus, into);
|
||||||
this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
|
const level = this.mixVolumes.get(name) ?? volume;
|
||||||
|
// A bus made now is at its level at once: made with the context, it carries the first sound of
|
||||||
|
// a run, which would otherwise be heard through a glide down from unity (found by #436).
|
||||||
|
if (made) this.setLevel(bus, level);
|
||||||
|
else this.glideLevel(bus, level);
|
||||||
waits.push(this.setFx(bus, fx));
|
waits.push(this.setFx(bus, fx));
|
||||||
}
|
}
|
||||||
const gone = [...this.buses.keys()].filter(
|
const gone = [...this.buses.keys()].filter(
|
||||||
@@ -923,6 +1016,13 @@ export class SoundLibrary implements AudioSink {
|
|||||||
await Promise.all(waits);
|
await Promise.all(waits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The level of a bus at `gain` from now on, without a glide. */
|
||||||
|
private setLevel(bus: Bus, gain: number): void {
|
||||||
|
const to = Math.max(0, gain);
|
||||||
|
bus.gain.gain.value = to;
|
||||||
|
bus.set = { ...bus.set, gain: to };
|
||||||
|
}
|
||||||
|
|
||||||
/** A quick glide of the level of a bus to `gain`. */
|
/** A quick glide of the level of a bus to `gain`. */
|
||||||
private glideLevel(bus: Bus, gain: number, time = 0, ease: EaseShare = 'linear'): void {
|
private glideLevel(bus: Bus, gain: number, time = 0, ease: EaseShare = 'linear'): void {
|
||||||
const to = Math.max(0, gain);
|
const to = Math.max(0, gain);
|
||||||
@@ -1122,6 +1222,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
|
|
||||||
private dropBus(name: string, bus: Bus): void {
|
private dropBus(name: string, bus: Bus): void {
|
||||||
this.dropMeter(bus);
|
this.dropMeter(bus);
|
||||||
|
this.dropMeter(bus, 'watch');
|
||||||
this.dropSends(bus);
|
this.dropSends(bus);
|
||||||
this.unchain(bus);
|
this.unchain(bus);
|
||||||
bus.input.disconnect();
|
bus.input.disconnect();
|
||||||
@@ -1553,6 +1654,9 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const askedAt = this.cancelsFor(start.file, start.owner);
|
const askedAt = this.cancelsFor(start.file, start.owner);
|
||||||
const waiting: Pending = {
|
const waiting: Pending = {
|
||||||
id,
|
id,
|
||||||
|
file: start.file,
|
||||||
|
owner: start.owner,
|
||||||
|
...(start.note !== undefined && { note: start.note }),
|
||||||
bus: start.bus ?? '',
|
bus: start.bus ?? '',
|
||||||
fx: { ...start.fx, params: { ...start.fx.params } },
|
fx: { ...start.fx, params: { ...start.fx.params } },
|
||||||
timed: [],
|
timed: [],
|
||||||
@@ -1726,6 +1830,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
* wakes the clock – a pattern or song reading it every few milliseconds would undo the pause.
|
* wakes the clock – a pattern or song reading it every few milliseconds would undo the pause.
|
||||||
*/
|
*/
|
||||||
setSuspended(suspended: boolean): void {
|
setSuspended(suspended: boolean): void {
|
||||||
|
if (suspended && !this.held) this.heldFrom = this.clockState();
|
||||||
this.held = suspended;
|
this.held = suspended;
|
||||||
const context = this.live();
|
const context = this.live();
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
@@ -2200,6 +2305,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
clip: asked,
|
clip: asked,
|
||||||
bus = '',
|
bus = '',
|
||||||
stream,
|
stream,
|
||||||
|
note,
|
||||||
}: Start): void {
|
}: Start): void {
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
// A context starts suspended where the page has not been interacted with; without this the
|
// A context starts suspended where the page has not been interacted with; without this the
|
||||||
@@ -2267,6 +2373,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
file,
|
file,
|
||||||
owner,
|
owner,
|
||||||
bus,
|
bus,
|
||||||
|
...(note !== undefined && { note }),
|
||||||
at: when,
|
at: when,
|
||||||
sources,
|
sources,
|
||||||
ending: new Set(),
|
ending: new Set(),
|
||||||
@@ -2934,6 +3041,8 @@ interface Bus {
|
|||||||
leaving: number;
|
leaving: number;
|
||||||
/** Its level meter (#359), while one is asked after. */
|
/** Its level meter (#359), while one is asked after. */
|
||||||
meter: BusMeter | null;
|
meter: BusMeter | null;
|
||||||
|
/** What the agent hears of it (#436), while the agent is switched on. */
|
||||||
|
watch: BusMeter | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tap after the pan of a bus: an analyser for each side, so a peak panned hard is not halved. */
|
/** A tap after the pan of a bus: an analyser for each side, so a peak panned hard is not halved. */
|
||||||
@@ -3061,6 +3170,64 @@ function readMeter(meter: BusMeter): [number, number] {
|
|||||||
return [left!, right!];
|
return [left!, right!];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loudest sample and the RMS of each side over the stretch a meter looks back over (#436), 1 at
|
||||||
|
* full scale.
|
||||||
|
*/
|
||||||
|
function readLevels(meter: BusMeter): { peak: [number, number]; rms: [number, number] } {
|
||||||
|
const [left, right] = meter.sides.map((side) => {
|
||||||
|
side.getFloatTimeDomainData(meter.samples);
|
||||||
|
let peak = 0;
|
||||||
|
let squares = 0;
|
||||||
|
for (const sample of meter.samples) {
|
||||||
|
peak = Math.max(peak, Math.abs(sample));
|
||||||
|
squares += sample * sample;
|
||||||
|
}
|
||||||
|
return { peak, rms: Math.sqrt(squares / meter.samples.length) };
|
||||||
|
});
|
||||||
|
return { peak: [left!.peak, right!.peak], rms: [left!.rms, right!.rms] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One voice as `heard` gives it (#436): a sound by its file, a note by `instrument:<id>` and its note. */
|
||||||
|
export interface HeardVoice {
|
||||||
|
id: number;
|
||||||
|
file: string;
|
||||||
|
/** The behavior it belongs to, `''` for none. */
|
||||||
|
owner: string;
|
||||||
|
/** Its bus, `''` the master. */
|
||||||
|
bus: string;
|
||||||
|
note?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `SoundLibrary.heard` gives (#436). */
|
||||||
|
export interface HeardSound {
|
||||||
|
/** Seconds the levels look back over; 0 before there is any sound. */
|
||||||
|
window: number;
|
||||||
|
/**
|
||||||
|
* The audio clock: `running`, `suspended` or `none` before there is any sound – while a pause holds
|
||||||
|
* it, as it was when the pause took hold –, the second it is at, and whether a pause holds it.
|
||||||
|
* Suspended, nothing is heard.
|
||||||
|
*/
|
||||||
|
clock: { state: string; time: number; held: boolean };
|
||||||
|
/** Voices started, a planned one `startsIn` seconds from now. */
|
||||||
|
voices: (HeardVoice & { startsIn: number; released: boolean; ringing: boolean })[];
|
||||||
|
/** Voices waiting on their bytes. */
|
||||||
|
waiting: HeardVoice[];
|
||||||
|
/** Files that would not load. */
|
||||||
|
failed: string[];
|
||||||
|
/** Patterns, songs and compositions playing, by `pattern:<id>`, `song:<id>`, `composition:<id>`. */
|
||||||
|
playings: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
playing: boolean;
|
||||||
|
fading: number | null;
|
||||||
|
baked: boolean;
|
||||||
|
}[];
|
||||||
|
/** The tapped buses by their name in the sink: `mix`, `mix:<id>`, `play:<n>`, `play:<n>:<track>`. */
|
||||||
|
buses: { name: string; peak: [number, number]; rms: [number, number] }[];
|
||||||
|
}
|
||||||
|
|
||||||
export const soundLibrary = new SoundLibrary();
|
export const soundLibrary = new SoundLibrary();
|
||||||
|
|
||||||
/** A composition fits an audio item to the length of its sound once the bytes are here (#418). */
|
/** A composition fits an audio item to the length of its sound once the bytes are here (#418). */
|
||||||
|
|||||||
@@ -236,7 +236,15 @@ describe('Play pattern', () => {
|
|||||||
expect([asked?.owner, asked?.play.loop]).toEqual(['', null]);
|
expect([asked?.owner, asked?.play.loop]).toEqual(['', null]);
|
||||||
asked!.play.strike({ at: 3, duration: 0.25, note: 60, velocity: 0.5, instrument: f.lead.id, tempo: 90 });
|
asked!.play.strike({ at: 3, duration: 0.25, note: 60, velocity: 0.5, instrument: f.lead.id, tempo: 90 });
|
||||||
expect(audio.notes).toEqual([
|
expect(audio.notes).toEqual([
|
||||||
{ name: `instrument:${f.lead.id}`, volume: 0.25, owner: '', duration: 0.25, at: 3, tempo: 90 },
|
{
|
||||||
|
name: `instrument:${f.lead.id}`,
|
||||||
|
volume: 0.25,
|
||||||
|
owner: '',
|
||||||
|
duration: 0.25,
|
||||||
|
at: 3,
|
||||||
|
tempo: 90,
|
||||||
|
note: 60,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ describe('Play song', () => {
|
|||||||
tempo: 90,
|
tempo: 90,
|
||||||
});
|
});
|
||||||
expect(audio.notes).toEqual([
|
expect(audio.notes).toEqual([
|
||||||
{ name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus },
|
{ name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus, note: 60 },
|
||||||
]);
|
]);
|
||||||
expect(audio.clips).toEqual([
|
expect(audio.clips).toEqual([
|
||||||
{ file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } },
|
{ file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } },
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { AgentView } from '../../src/editor/agent/agentView';
|
|||||||
import { frameWithin } from '../../src/core/time';
|
import { frameWithin } from '../../src/core/time';
|
||||||
import { type PlayDriver, pointOf, sendInput } from '../../src/editor/agent/playInput';
|
import { type PlayDriver, pointOf, sendInput } from '../../src/editor/agent/playInput';
|
||||||
import { cameraHint, NO_CAMERA_HINT } from '../../src/editor/agent/runtimeView';
|
import { cameraHint, NO_CAMERA_HINT } from '../../src/editor/agent/runtimeView';
|
||||||
|
import type { HeardSound } from '../../src/viewport/SoundLibrary';
|
||||||
|
import { STALLED_HINT } from '../../src/editor/agent/audioView';
|
||||||
import {
|
import {
|
||||||
graphEditor,
|
graphEditor,
|
||||||
makeBehavior,
|
makeBehavior,
|
||||||
@@ -352,6 +354,96 @@ describe('get_runtime', () => {
|
|||||||
);
|
);
|
||||||
expect(text(await runTool('get_runtime', {}, hostFor(f, started, driver, false)))).toMatch(/not running/);
|
expect(text(await runTool('get_runtime', {}, hostFor(f, started, driver, false)))).toMatch(/not running/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says what is heard: levels in dB by bus name, voices and playings by asset, the rest left out (#436)', async () => {
|
||||||
|
const { f, started, driver } = game();
|
||||||
|
const made = (type: 'sound' | 'instrument' | 'song' | 'bus', name: string) => {
|
||||||
|
const command = new CreateAssetCommand(type, name);
|
||||||
|
f.stack.execute(command);
|
||||||
|
return command.asset;
|
||||||
|
};
|
||||||
|
const sound = made('sound', 'Jump');
|
||||||
|
if (sound.type === 'sound') sound.file = 'jump.wav';
|
||||||
|
const instrument = made('instrument', 'Lead');
|
||||||
|
const song = made('song', 'Theme');
|
||||||
|
const track = song.type === 'song' ? song.tracks[0]! : null;
|
||||||
|
const bus = made('bus', 'Music');
|
||||||
|
const owner = started.world.entities.find((entity) => entity.type === 'behavior')!.id;
|
||||||
|
const trackBus = `play:7:song:${song.id}:${track!.id}`;
|
||||||
|
const quiet = { released: false, ringing: false, startsIn: 0 };
|
||||||
|
const level = (peak: number, rms: number): Pick<HeardSound['buses'][number], 'peak' | 'rms'> => ({
|
||||||
|
peak: [peak, peak / 2],
|
||||||
|
rms: [rms, rms],
|
||||||
|
});
|
||||||
|
const heard: HeardSound = {
|
||||||
|
window: 32768 / 48000,
|
||||||
|
clock: { state: 'running', time: 12.34567, held: false },
|
||||||
|
voices: [
|
||||||
|
{ id: 1, file: 'jump.wav', owner, bus: '', ...quiet },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
file: `instrument:${instrument.id}`,
|
||||||
|
owner: '',
|
||||||
|
bus: trackBus,
|
||||||
|
note: 64,
|
||||||
|
...quiet,
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
file: `instrument:${instrument.id}`,
|
||||||
|
owner: '',
|
||||||
|
bus: trackBus,
|
||||||
|
note: 65,
|
||||||
|
...quiet,
|
||||||
|
startsIn: 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
waiting: [{ id: 4, file: 'boom.wav', owner: '', bus: `mix:${bus.id}` }],
|
||||||
|
failed: ['boom.wav'],
|
||||||
|
playings: [{ id: 7, name: `song:${song.id}`, owner, playing: true, fading: null, baked: false }],
|
||||||
|
buses: [
|
||||||
|
{ name: trackBus, ...level(0.5, 0.1) },
|
||||||
|
{ name: `${trackBus}:#items`, ...level(0.5, 0.1) },
|
||||||
|
{ name: 'mix', ...level(1, 0.01) },
|
||||||
|
{ name: `mix:${bus.id}`, ...level(0, 0) },
|
||||||
|
{ name: 'play:7', ...level(0.5, 0.1) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const host = hostFor(f, started, driver);
|
||||||
|
const facts = host.runtime!()!;
|
||||||
|
host.runtime = () => ({ ...facts, audio: heard });
|
||||||
|
|
||||||
|
const view = (await runTool('get_runtime', {}, host)).structuredContent as { audio: object };
|
||||||
|
expect(view.audio).toEqual({
|
||||||
|
clock: { state: 'running', time: 12.346 },
|
||||||
|
window: 0.68,
|
||||||
|
levels: [
|
||||||
|
{ bus: 'master', peak: 0, rms: -40 },
|
||||||
|
{ bus: `song Theme, track ${track!.name}`, peak: -6, rms: -20 },
|
||||||
|
{ bus: 'Music', silent: true },
|
||||||
|
{ bus: 'song Theme', peak: -6, rms: -20 },
|
||||||
|
],
|
||||||
|
voices: [
|
||||||
|
{ sound: 'Jump', bus: 'master', by: 'Box' },
|
||||||
|
{ instrument: 'Lead', note: 'E4', bus: `song Theme, track ${track!.name}`, released: true },
|
||||||
|
],
|
||||||
|
planned: 1,
|
||||||
|
playing: [{ song: 'Theme', by: 'Box' }],
|
||||||
|
waiting: [{ file: 'boom.wav', bus: 'Music' }],
|
||||||
|
failed: [{ file: 'boom.wav' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// A clock the browser holds while the game runs means nothing is heard, paused since or not.
|
||||||
|
const audioWith = async (clock: HeardSound['clock']) => {
|
||||||
|
host.runtime = () => ({ ...facts, audio: { ...heard, clock } });
|
||||||
|
return ((await runTool('get_runtime', {}, host)).structuredContent as { audio: { hint?: string } })
|
||||||
|
.audio;
|
||||||
|
};
|
||||||
|
expect(await audioWith({ state: 'running', time: 3, held: true })).not.toHaveProperty('hint');
|
||||||
|
expect((await audioWith({ state: 'suspended', time: 0, held: false })).hint).toBe(STALLED_HINT);
|
||||||
|
expect((await audioWith({ state: 'suspended', time: 0, held: true })).hint).toBe(STALLED_HINT);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the camera', () => {
|
describe('the camera', () => {
|
||||||
|
|||||||
@@ -2342,6 +2342,80 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
expect(disconnects).toEqual(['panner', 'splitter']);
|
expect(disconnects).toEqual(['panner', 'splitter']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('taps every bus for the agent while it watches, a later one too, with its peak and RMS (#436)', async () => {
|
||||||
|
const library = new SoundLibrary(false);
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0 }]);
|
||||||
|
expect(library.heard().buses).toEqual([]);
|
||||||
|
library.watch(true);
|
||||||
|
expect(analysers.map((side) => side.fftSize)).toEqual([32768, 32768, 32768, 32768]);
|
||||||
|
await library.setBuses([
|
||||||
|
{ name: 'song:s:t', gain: 1, pan: 0 },
|
||||||
|
{ name: 'song:s:u', gain: 1, pan: 0 },
|
||||||
|
]);
|
||||||
|
expect(library.heard().buses.map((bus) => bus.name)).toEqual(['mix', 'song:s:t', 'song:s:u']);
|
||||||
|
// The fake holds one sample in the middle of the stretch and silence round it.
|
||||||
|
analysers[2]!.sample = -0.5;
|
||||||
|
analysers[3]!.sample = 0.25;
|
||||||
|
const track = library.heard().buses[1]!;
|
||||||
|
expect(track).toMatchObject({ name: 'song:s:t', peak: [0.5, 0.25] });
|
||||||
|
expect(track.rms[0]).toBeCloseTo(0.5 / Math.sqrt(32768), 9);
|
||||||
|
expect(track.rms[1]).toBeCloseTo(0.25 / Math.sqrt(32768), 9);
|
||||||
|
expect(library.heard().window).toBeCloseTo(32768 / 48000);
|
||||||
|
// The Song tab's meter is another tap: letting it go leaves the agent's.
|
||||||
|
library.busPeaks(['song:s:t']);
|
||||||
|
library.unmeter();
|
||||||
|
expect(library.heard().buses).toHaveLength(3);
|
||||||
|
// Moved elsewhere, a bus keeps its tap.
|
||||||
|
wires.length = 0;
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, into: 'song:s:u' }]);
|
||||||
|
expect(wires.filter((wire) => wire === 'panner > splitter')).toHaveLength(1);
|
||||||
|
disconnects.length = 0;
|
||||||
|
library.watch(false);
|
||||||
|
expect(library.heard().buses).toEqual([]);
|
||||||
|
expect(disconnects.filter((node) => node === 'splitter')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what plays: each voice with its owner, bus and note, those planned, waiting or failed (#436)', async () => {
|
||||||
|
const library = new SoundLibrary(false);
|
||||||
|
await play(library, FILE, { owner: 'o1' });
|
||||||
|
const gate = {
|
||||||
|
pitch: [],
|
||||||
|
stages: [],
|
||||||
|
source: { file: FILE, volume: 1, loop: true, loopStart: 0, loopEnd: 0, gate: true },
|
||||||
|
} as FxPlan;
|
||||||
|
library.playNote('instrument:i', [gate], {}, 1, '', 0, { at: 5, bus: 'song:s:t', note: 64 });
|
||||||
|
library.play(OTHER, 1, false, 'o2');
|
||||||
|
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
await library.load('gone.wav');
|
||||||
|
const heard = library.heard();
|
||||||
|
expect(heard.voices).toEqual([
|
||||||
|
{ id: 1, file: FILE, owner: 'o1', bus: '', startsIn: 0, released: false, ringing: false },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
file: 'instrument:i',
|
||||||
|
owner: '',
|
||||||
|
bus: 'song:s:t',
|
||||||
|
note: 64,
|
||||||
|
startsIn: 5,
|
||||||
|
released: false,
|
||||||
|
ringing: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(heard.waiting).toEqual([{ id: 3, file: OTHER, owner: 'o2', bus: '' }]);
|
||||||
|
expect(heard.failed).toEqual(['gone.wav']);
|
||||||
|
library.release(2);
|
||||||
|
expect(library.heard().voices[1]).toMatchObject({ released: true });
|
||||||
|
// Paused, the clock is told as it ran before: held by the pause, or by the browser already.
|
||||||
|
expect(heard.clock).toEqual({ state: 'running', time: 0, held: false });
|
||||||
|
library.setSuspended(true);
|
||||||
|
contextState = 'suspended';
|
||||||
|
expect(library.heard().clock).toEqual({ state: 'running', time: 0, held: true });
|
||||||
|
library.setSuspended(false);
|
||||||
|
library.setSuspended(true);
|
||||||
|
expect(library.heard().clock).toEqual({ state: 'suspended', time: 0, held: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => {
|
it('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => {
|
||||||
const library = new SoundLibrary(false);
|
const library = new SoundLibrary(false);
|
||||||
const track = { name: 'song:s:t', gain: 1, pan: 0, fx: voice(crush) };
|
const track = { name: 'song:s:t', gain: 1, pan: 0, fx: voice(crush) };
|
||||||
@@ -2539,6 +2613,22 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
expect(wires.filter((wire) => wire.endsWith('> destination'))).toEqual(['panner > destination']);
|
expect(wires.filter((wire) => wire.endsWith('> destination'))).toEqual(['panner > destination']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes a bus at its level, so the first sound of a run on a quiet bus is not heard gliding down (#436)', async () => {
|
||||||
|
const library = new SoundLibrary(false);
|
||||||
|
await library.setMix(mix);
|
||||||
|
await play(library);
|
||||||
|
expect(library.busLevels().map(({ name, level }) => [name, level])).toEqual([
|
||||||
|
['mix', 0.5],
|
||||||
|
['mix:a', 1],
|
||||||
|
['mix:b', 0.25],
|
||||||
|
]);
|
||||||
|
expect(gains.filter((gain) => gain.glides.length > 0)).toEqual([]);
|
||||||
|
// One there already glides to a new level, as before.
|
||||||
|
await library.setMix([bus('mix', 0.25, null), mix[1]!, mix[2]!]);
|
||||||
|
expect(library.busLevels()[0]).toMatchObject({ gain: 0.25, level: 0.5 });
|
||||||
|
expect(gains.some((gain) => gain.glides.includes(0.25))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the mix where the first thing heard is a tab of a song, which names its bus before there is a context', async () => {
|
it('keeps the mix where the first thing heard is a tab of a song, which names its bus before there is a context', async () => {
|
||||||
const library = new SoundLibrary(false);
|
const library = new SoundLibrary(false);
|
||||||
await library.setMix(mix);
|
await library.setMix(mix);
|
||||||
|
|||||||
@@ -637,6 +637,69 @@ export async function mcp(t) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 71h4. What the agent hears (#436): the sound of 71g9 loops twice from the start of the game, once
|
||||||
|
// on the master and once on a bus at volume 0. Held at a game time, get_runtime names both voices,
|
||||||
|
// the master is heard, the quiet bus is silent, and the levels stay as they are while it is paused.
|
||||||
|
const noisy = await callTool('run_commands', {
|
||||||
|
label: 'noise',
|
||||||
|
steps: [
|
||||||
|
{ command: 'add_file_asset', args: { type: 'sound', file: wav.file }, as: 'tone' },
|
||||||
|
{ command: 'create_asset', args: { type: 'bus', name: `${TOKEN_NAME} quiet` }, as: 'quiet' },
|
||||||
|
{ command: 'set_asset_property', args: { asset: '$quiet', key: 'volume', value: 0 } },
|
||||||
|
{ command: 'create_asset', args: { type: 'behavior', name: `${TOKEN_NAME} noise` }, as: 'noise' },
|
||||||
|
{ command: 'place_asset', args: { in: sceneId, asset: '$noise', x: 0, y: 0, parent: playerId } },
|
||||||
|
{ command: 'add_node', args: { asset: '$noise', node: 'OnStart' }, as: 'start' },
|
||||||
|
{
|
||||||
|
command: 'add_node',
|
||||||
|
args: { asset: '$noise', node: 'PlaySound', params: { sound: '$tone', loop: 'true' } },
|
||||||
|
as: 'loud',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'add_node',
|
||||||
|
args: {
|
||||||
|
asset: '$noise',
|
||||||
|
node: 'PlaySound',
|
||||||
|
params: { sound: '$tone', loop: 'true', bus: '$quiet' },
|
||||||
|
},
|
||||||
|
as: 'hushed',
|
||||||
|
},
|
||||||
|
...['$loud', '$hushed'].map((node) => ({
|
||||||
|
command: 'connect',
|
||||||
|
args: { asset: '$noise', from: { node: '$start', port: 'exec' }, to: { node, port: 'exec' } },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
const quietName = `${TOKEN_NAME} quiet`;
|
||||||
|
const heldAt = await callTool('play', { action: 'play', until: 0.6 });
|
||||||
|
const heardFirst = (await callTool('get_runtime', { console: 0 }))?.structuredContent?.audio;
|
||||||
|
await sleep(400);
|
||||||
|
const heardLater = (await callTool('get_runtime', { console: 0 }))?.structuredContent?.audio;
|
||||||
|
await callTool('play', { action: 'stop' });
|
||||||
|
await waitUntil('__gm.state.mode === "edit"', { label: 'the noisy game stopped' });
|
||||||
|
await callTool('undo');
|
||||||
|
await settle();
|
||||||
|
const levelOf = (audio, bus) => audio?.levels?.find((row) => row.bus === bus);
|
||||||
|
const toneVoices = (heardFirst?.voices ?? []).filter((voice) => voice.sound !== undefined);
|
||||||
|
check(
|
||||||
|
'get_runtime says what is heard: both voices by sound and bus, the master loud, a bus at 0 silent, held while paused',
|
||||||
|
noisy?.isError === false &&
|
||||||
|
heldAt?.structuredContent?.paused === true &&
|
||||||
|
toneVoices.length === 2 &&
|
||||||
|
toneVoices.some((voice) => voice.bus === 'master') &&
|
||||||
|
toneVoices.some((voice) => voice.bus === quietName) &&
|
||||||
|
heardFirst.window > 0.6 &&
|
||||||
|
levelOf(heardFirst, 'master')?.peak > -12 &&
|
||||||
|
levelOf(heardFirst, quietName)?.silent === true &&
|
||||||
|
JSON.stringify(heardLater?.levels) === JSON.stringify(heardFirst?.levels),
|
||||||
|
JSON.stringify({
|
||||||
|
text: noisy?.content?.[0]?.text?.slice(0, 300),
|
||||||
|
heldAt: heldAt?.structuredContent ?? heldAt?.content,
|
||||||
|
heardFirst,
|
||||||
|
laterLevels: heardLater?.levels,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 71i. One Ctrl+Z of the user takes the agent's whole step back.
|
// 71i. One Ctrl+Z of the user takes the agent's whole step back.
|
||||||
await press('z', { ctrl: true });
|
await press('z', { ctrl: true });
|
||||||
await settle();
|
await settle();
|
||||||
|
|||||||
+3
-1
@@ -56,7 +56,9 @@ const INSTRUCTIONS = [
|
|||||||
'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked',
|
'The editor follows your edits: after run_commands the user sees the graph, scene or asset it worked',
|
||||||
'on, with what it touched selected. Use open and select to show anything else.',
|
'on, with what it touched selected. Use open and select to show anything else.',
|
||||||
'Test what you built yourself: play, then play_input sends keys and mouse clicks into the game,',
|
'Test what you built yourself: play, then play_input sends keys and mouse clicks into the game,',
|
||||||
'and get_runtime reads where the objects are, their variables and the console. To look at one',
|
'and get_runtime reads where the objects are, their variables and the console, and under audio',
|
||||||
|
'what you would hear: the voices sounding and the level of each bus, silent where nothing reaches',
|
||||||
|
'it. Check there that music and sounds are really heard. To look at one',
|
||||||
'moment, play with until holds the game at that game time, as does a last play_input step',
|
'moment, play with until holds the game at that game time, as does a last play_input step',
|
||||||
'{"pause": true}; a call that only reads comes too late for it, the game runs on meanwhile. A game needs a',
|
'{"pause": true}; a call that only reads comes too late for it, the game runs on meanwhile. A game needs a',
|
||||||
'camera in its scene to show anything; get_view says when it has none.',
|
'camera in its scene to show anything; get_view says when it has none.',
|
||||||
|
|||||||
Reference in new issue
Block a user