Meter each track and the song, and limit the output at -1 dBFS (#359)
A song could clip at the output without anyone noticing: a track and a sound item at +12 dB each add up to +24 dB. The Song tab now shows how loud each track and the whole song are, and a fixed limiter behind the master keeps every sample under -1 dBFS in the editor and in the game. - Each track head has a stereo meter up its right edge. The head is 190 px wide instead of 176. - Out sits in the corner above the heads, with the loudest peak since Play in dB. In the tab header it made the row too wide for a 1280 px window, and the inspector lost its fields. - The meters read AnalyserNodes beside the buses, one per side after a channel splitter, so a peak panned hard is not halved. They are tapped only while the song plays and its tab is shown. - A clip lamp lights past 0 dBFS and stays through Stop. The next Play or a click on the meter puts it out. - The meter of a track that mute or solo keeps from being heard is grey. - Music always plays through a bus of its own, with or without a sound FX. That bus is what Out measures. - The limiter is the one from #326 and needs the FX worklet. Until that is loaded the master goes straight out. - It is 5 ms late. latency() counts that in, so the playhead and the alignment of a recorded take follow it. - Unit tests build SoundLibrary without the limiter, as their wiring checks follow every node of a voice. - Out shows "-inf" while nothing was loud. The UI font has no infinity sign, which the font test found. Tests: - Unit meter-view 8 new, sound-library 112 (2 new), 3 counter-tests each red - e2e 65 (melektaus-atx): ALL PASSED, 55 checks (3 new). Out wrote +16.6 dB, behind the limiter the loudest sample was 0.8913 - Gate (melektaus-atx): 272 files, 4209 tests, one red in the font test. Fixed after it, then the font and meter tests ran green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc666016db
commit
490fcba88f
12 files changed
+810
-137
No files matched your search
@@ -1039,6 +1039,7 @@ export class Editor implements PlayHost {
|
|||||||
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
||||||
if (this.tab === 'pattern') this.patternEditor.tick();
|
if (this.tab === 'pattern') this.patternEditor.tick();
|
||||||
if (this.tab === 'song') this.songEditor.tick();
|
if (this.tab === 'song') this.songEditor.tick();
|
||||||
|
else this.songEditor.hideMeters();
|
||||||
if (this.tab === 'sound') this.soundEditor.tick();
|
if (this.tab === 'sound') this.soundEditor.tick();
|
||||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||||
// to do it, and its states move just the same.
|
// to do it, and its states move just the same.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ export function songHooks({ state, editor }: HookContext) {
|
|||||||
clock: () => soundLibrary.clock(),
|
clock: () => soundLibrary.clock(),
|
||||||
stats: () => soundLibrary.stats(),
|
stats: () => soundLibrary.stats(),
|
||||||
buses: () => soundLibrary.busLevels(),
|
buses: () => soundLibrary.busLevels(),
|
||||||
|
/** The level meters (#359): each track's by its id and Out, in dB, and how many buses are tapped. */
|
||||||
|
meters: () => {
|
||||||
|
const asset = findAssetOf(state.project, songEditor.currentAssetId, 'song');
|
||||||
|
return {
|
||||||
|
tracks: Object.fromEntries((asset?.tracks ?? []).map((t) => [t.id, timeline.meterOf(t.id)])),
|
||||||
|
out: timeline.outShown(),
|
||||||
|
metered: soundLibrary.metered(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/** The loudest sample on each side behind the limiter at the output, null without it. */
|
||||||
|
guard: () => soundLibrary.guardPeaks(),
|
||||||
|
latency: () => soundLibrary.latency(),
|
||||||
|
meterAt: (track: number) => timeline.meterPoint(track),
|
||||||
|
outAt: () => timeline.outPoint(),
|
||||||
/** Timeline or Routing (#350), and where their switches are. */
|
/** Timeline or Routing (#350), and where their switches are. */
|
||||||
shown: () => songEditor.view,
|
shown: () => songEditor.view,
|
||||||
viewAt: (view: 'timeline' | 'routing') =>
|
viewAt: (view: 'timeline' | 'routing') =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
songMeter,
|
songMeter,
|
||||||
songSequence,
|
songSequence,
|
||||||
songTempo,
|
songTempo,
|
||||||
|
trackBus,
|
||||||
} from '../../core/music/song';
|
} from '../../core/music/song';
|
||||||
import { embeddedIn } from '../../core/music/embedded';
|
import { embeddedIn } from '../../core/music/embedded';
|
||||||
import { roundAt } from '../../core/music/ghosts';
|
import { roundAt } from '../../core/music/ghosts';
|
||||||
@@ -82,6 +83,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
readonly recordButton: Button;
|
readonly recordButton: Button;
|
||||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||||
readonly meter = new TakeMeter();
|
readonly meter = new TakeMeter();
|
||||||
|
/** The page's clock in seconds when the meters were stepped last, null while they are not. */
|
||||||
|
private meteredAt: number | null = null;
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly timelineButton: Button;
|
readonly timelineButton: Button;
|
||||||
readonly routingButton: Button;
|
readonly routingButton: Button;
|
||||||
@@ -242,6 +245,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
claimTransport(this.halt);
|
claimTransport(this.halt);
|
||||||
this.playingId = asset.id;
|
this.playingId = asset.id;
|
||||||
this.told = this.settingsOf(asset);
|
this.told = this.settingsOf(asset);
|
||||||
|
this.canvas.clearMeters();
|
||||||
this.music.setBuses(songBuses(this.state.project, asset));
|
this.music.setBuses(songBuses(this.state.project, asset));
|
||||||
void this.music.play(songSequence(this.state.project, asset), {
|
void this.music.play(songSequence(this.state.project, asset), {
|
||||||
tempo: songTempo(asset.props),
|
tempo: songTempo(asset.props),
|
||||||
@@ -261,6 +265,8 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
const take = this.take;
|
const take = this.take;
|
||||||
this.take = null;
|
this.take = null;
|
||||||
this.meter.set(0);
|
this.meter.set(0);
|
||||||
|
this.hideMeters();
|
||||||
|
this.canvas.hushMeters();
|
||||||
if (take && options.discard) {
|
if (take && options.discard) {
|
||||||
take.recorder.cancel();
|
take.recorder.cancel();
|
||||||
this.canvas.take = null;
|
this.canvas.take = null;
|
||||||
@@ -311,6 +317,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
this.told = this.settingsOf(asset);
|
this.told = this.settingsOf(asset);
|
||||||
this.showTake(take, false);
|
this.showTake(take, false);
|
||||||
this.syncPlayButton();
|
this.syncPlayButton();
|
||||||
|
this.canvas.clearMeters();
|
||||||
ui().focus.focus(this.canvas);
|
ui().focus.focus(this.canvas);
|
||||||
this.music.setBuses(songBuses(this.state.project, asset));
|
this.music.setBuses(songBuses(this.state.project, asset));
|
||||||
await this.music.play(songSequence(this.state.project, asset), {
|
await this.music.play(songSequence(this.state.project, asset), {
|
||||||
@@ -329,6 +336,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
|
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
|
||||||
tick(): void {
|
tick(): void {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
|
this.stepMeters();
|
||||||
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
||||||
const take = this.take;
|
const take = this.take;
|
||||||
if (take) {
|
if (take) {
|
||||||
@@ -342,6 +350,27 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
this.canvas.invalidate();
|
this.canvas.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The meters of the tracks and Out a frame on, from the loudest samples on their buses since. */
|
||||||
|
private stepMeters(): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || asset.id !== this.playingId) return;
|
||||||
|
const now = performance.now() / 1000;
|
||||||
|
const dt = this.meteredAt === null ? 0 : now - this.meteredAt;
|
||||||
|
this.meteredAt = now;
|
||||||
|
const names = new Map(asset.tracks.map((track) => [trackBus(asset.id, track.id), track.id]));
|
||||||
|
const peaks = soundLibrary.busPeaks([...names.keys(), this.music.outBus]);
|
||||||
|
const tracks = new Map<string, [number, number]>();
|
||||||
|
for (const [name, id] of names) tracks.set(id, peaks.get(name) ?? [0, 0]);
|
||||||
|
this.canvas.stepMeters(tracks, peaks.get(this.music.outBus) ?? [0, 0], now, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets the taps of the meters go: the tab is not seen, or nothing plays. */
|
||||||
|
hideMeters(): void {
|
||||||
|
if (this.meteredAt === null) return;
|
||||||
|
this.meteredAt = null;
|
||||||
|
soundLibrary.unmeter();
|
||||||
|
}
|
||||||
|
|
||||||
protected override closed(assetId: string): void {
|
protected override closed(assetId: string): void {
|
||||||
if (assetId === this.playingId) this.stop();
|
if (assetId === this.playingId) this.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
songClockProps,
|
songClockProps,
|
||||||
} from '../../core/music/embedded';
|
} from '../../core/music/embedded';
|
||||||
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
||||||
|
import { songRoutes, trackGates } from '../../core/music/routing';
|
||||||
import type { LoopRange } from '../../core/music/sequence';
|
import type { LoopRange } from '../../core/music/sequence';
|
||||||
import {
|
import {
|
||||||
itemAsset,
|
itemAsset,
|
||||||
@@ -72,6 +73,16 @@ import {
|
|||||||
} from '../music/timeView';
|
} from '../music/timeView';
|
||||||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
import { drawMeter } from './meterDraw';
|
||||||
|
import {
|
||||||
|
gainToDb,
|
||||||
|
hushMeter,
|
||||||
|
meterClipped,
|
||||||
|
peakText,
|
||||||
|
QUIET_METER,
|
||||||
|
type StereoMeter,
|
||||||
|
stepMeter,
|
||||||
|
} from './meterView';
|
||||||
import { ownFxEntries } from './ownFx';
|
import { ownFxEntries } from './ownFx';
|
||||||
import { removeTrack } from './songActions';
|
import { removeTrack } from './songActions';
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +116,7 @@ import {
|
|||||||
laneTick,
|
laneTick,
|
||||||
loopSeams,
|
loopSeams,
|
||||||
nearestTrack,
|
nearestTrack,
|
||||||
|
OUT_METER_BOX,
|
||||||
peakOver,
|
peakOver,
|
||||||
SLIDERS_MIN_HEIGHT,
|
SLIDERS_MIN_HEIGHT,
|
||||||
sliderShare,
|
sliderShare,
|
||||||
@@ -112,6 +124,7 @@ import {
|
|||||||
type SongView,
|
type SongView,
|
||||||
TRACK_HEIGHT,
|
TRACK_HEIGHT,
|
||||||
trackAtY,
|
trackAtY,
|
||||||
|
trackMeterBox,
|
||||||
trackTop,
|
trackTop,
|
||||||
zoomTracks,
|
zoomTracks,
|
||||||
} from './songView';
|
} from './songView';
|
||||||
@@ -244,6 +257,13 @@ export class SongTimeline extends Widget {
|
|||||||
playhead: number | null = null;
|
playhead: number | null = null;
|
||||||
/** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */
|
/** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */
|
||||||
take: TakeView | null = null;
|
take: TakeView | null = null;
|
||||||
|
/** The level meter of each track (#359), by its id. */
|
||||||
|
private readonly meters = new Map<string, StereoMeter>();
|
||||||
|
/** What the song gives after its sound FX, and the loudest peak of it since Play, in dB. */
|
||||||
|
private out: StereoMeter = QUIET_METER;
|
||||||
|
private loudest = -Infinity;
|
||||||
|
private readonly outLabel = crispText({ text: 'Out', style: textStyle('small') });
|
||||||
|
private readonly outPeak = crispText({ text: '', style: textStyle('small') });
|
||||||
onTogglePlay: () => void = () => {};
|
onTogglePlay: () => void = () => {};
|
||||||
/** Where playing starts was moved: a playing song jumps there. */
|
/** Where playing starts was moved: a playing song jumps there. */
|
||||||
onStart: (tick: number) => void = () => {};
|
onStart: (tick: number) => void = () => {};
|
||||||
@@ -258,12 +278,22 @@ export class SongTimeline extends Widget {
|
|||||||
this.focusable = true;
|
this.focusable = true;
|
||||||
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
||||||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||||||
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
|
this.addChild(
|
||||||
|
this.bg,
|
||||||
|
this.lanesLayer,
|
||||||
|
this.headsLayer,
|
||||||
|
this.ruler,
|
||||||
|
this.hint,
|
||||||
|
this.outLabel,
|
||||||
|
this.outPeak,
|
||||||
|
);
|
||||||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||||||
this.lanesLayer.mask = this.clips.lanes;
|
this.lanesLayer.mask = this.clips.lanes;
|
||||||
this.headsLayer.mask = this.clips.heads;
|
this.headsLayer.mask = this.clips.heads;
|
||||||
this.ruler.mask = this.clips.ruler;
|
this.ruler.mask = this.clips.ruler;
|
||||||
this.hint.eventMode = 'none';
|
this.hint.eventMode = 'none';
|
||||||
|
this.outLabel.eventMode = 'none';
|
||||||
|
this.outPeak.eventMode = 'none';
|
||||||
this.itemLabels.eventMode = 'none';
|
this.itemLabels.eventMode = 'none';
|
||||||
this.headLabels.eventMode = 'none';
|
this.headLabels.eventMode = 'none';
|
||||||
|
|
||||||
@@ -541,6 +571,65 @@ export class SongTimeline extends Widget {
|
|||||||
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The meters of these tracks and Out a frame on (#359), with the loudest sample on each side since. */
|
||||||
|
stepMeters(
|
||||||
|
tracks: ReadonlyMap<string, readonly [number, number]>,
|
||||||
|
out: readonly [number, number],
|
||||||
|
now: number,
|
||||||
|
dt: number,
|
||||||
|
): void {
|
||||||
|
for (const [id, both] of tracks)
|
||||||
|
this.meters.set(id, stepMeter(this.meters.get(id) ?? QUIET_METER, both, now, dt));
|
||||||
|
this.out = stepMeter(this.out, out, now, dt);
|
||||||
|
this.loudest = Math.max(this.loudest, gainToDb(Math.max(out[0], out[1])));
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing plays: the meters go dark, their clip lamps stay lit and so does the loudest peak. */
|
||||||
|
hushMeters(): void {
|
||||||
|
for (const [id, meter] of this.meters) this.meters.set(id, hushMeter(meter));
|
||||||
|
this.out = hushMeter(this.out);
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMeters(): void {
|
||||||
|
this.meters.clear();
|
||||||
|
this.clearOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearOut(): void {
|
||||||
|
this.out = QUIET_METER;
|
||||||
|
this.loudest = -Infinity;
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Out shows (test hook). */
|
||||||
|
outShown(): { meter: StereoMeter; loudest: number; clipped: boolean } {
|
||||||
|
return { meter: this.out, loudest: this.loudest, clipped: meterClipped(this.out) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The middle of the Out meter (test hook). */
|
||||||
|
outPoint(): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
const box = OUT_METER_BOX;
|
||||||
|
return { x: at.x + box.x + box.w / 2, y: at.y + box.y + box.h / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the meter of a track shows (test hook). */
|
||||||
|
meterOf(trackId: string): StereoMeter {
|
||||||
|
return this.meters.get(trackId) ?? QUIET_METER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The middle of a track's meter (test hook). */
|
||||||
|
meterPoint(track: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
const box = trackMeterBox(this.view.trackHeight);
|
||||||
|
return {
|
||||||
|
x: at.x + box.x + box.w / 2,
|
||||||
|
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The middle of a part of a track's head, or a share across its slider. */
|
/** The middle of a part of a track's head, or a share across its slider. */
|
||||||
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
|
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
|
||||||
const at = this.getGlobalPosition();
|
const at = this.getGlobalPosition();
|
||||||
@@ -653,6 +742,11 @@ export class SongTimeline extends Widget {
|
|||||||
const g = this.inLanes(p);
|
const g = this.inLanes(p);
|
||||||
const tick = xToTick(this.view, g.x);
|
const tick = xToTick(this.view, g.x);
|
||||||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||||||
|
// A click on the corner puts out the clip lamp of Out and its loudest peak.
|
||||||
|
if (area === 'corner') {
|
||||||
|
if (e.button === 0) this.clearOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||||||
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||||||
@@ -677,7 +771,7 @@ export class SongTimeline extends Widget {
|
|||||||
kind: 'cursor',
|
kind: 'cursor',
|
||||||
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
||||||
};
|
};
|
||||||
} else if (area === 'lanes') this.pressLanes(asset, e, g);
|
} else this.pressLanes(asset, e, g);
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,6 +784,17 @@ export class SongTimeline extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
// A click on the meter puts out its clip lamp.
|
||||||
|
if (x >= trackMeterBox(this.view.trackHeight).x - 2) {
|
||||||
|
const meter = this.meters.get(track.id);
|
||||||
|
if (meter)
|
||||||
|
this.meters.set(track.id, [
|
||||||
|
{ ...meter[0], clipped: false },
|
||||||
|
{ ...meter[1], clipped: false },
|
||||||
|
]);
|
||||||
|
this.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||||||
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
|
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
|
||||||
else if (part === 'mute' || part === 'solo') {
|
else if (part === 'mute' || part === 'solo') {
|
||||||
@@ -1301,6 +1406,7 @@ export class SongTimeline extends Widget {
|
|||||||
this.itemsG.clear();
|
this.itemsG.clear();
|
||||||
this.overlay.clear();
|
this.overlay.clear();
|
||||||
this.headsG.clear();
|
this.headsG.clear();
|
||||||
|
this.outLabel.visible = this.outPeak.visible = asset !== null;
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
||||||
this.syncTexts(this.headTexts, this.headLabels, []);
|
this.syncTexts(this.headTexts, this.headLabels, []);
|
||||||
@@ -1315,7 +1421,8 @@ export class SongTimeline extends Widget {
|
|||||||
const tracks = this.shownTracks(asset);
|
const tracks = this.shownTracks(asset);
|
||||||
this.drawLanes(asset, tracks, lw, lh, length);
|
this.drawLanes(asset, tracks, lw, lh, length);
|
||||||
this.drawItems(asset, lw, lh);
|
this.drawItems(asset, lw, lh);
|
||||||
this.drawHeads(tracks);
|
this.drawHeads(asset, tracks);
|
||||||
|
this.drawOut();
|
||||||
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
||||||
length,
|
length,
|
||||||
loop: this.shownLoop(),
|
loop: this.shownLoop(),
|
||||||
@@ -1538,10 +1645,17 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The heads: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
|
/**
|
||||||
private drawHeads(tracks: readonly SongTrack[]): void {
|
* The heads: name, mute and solo, the volume and pan sliders, and the meter (#359), grey on a track
|
||||||
|
* mute or solo silences. A track is added from a head's menu.
|
||||||
|
*/
|
||||||
|
private drawHeads(asset: SongAsset, tracks: readonly SongTrack[]): void {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
const labels: Placed[] = [];
|
const labels: Placed[] = [];
|
||||||
|
const routes = songRoutes({ tracks, graph: asset.graph });
|
||||||
|
const gates = trackGates(tracks, routes);
|
||||||
|
const heard = (id: string) =>
|
||||||
|
routes.some((route) => route.from === id && gates.get(id)?.passes(route.to));
|
||||||
tracks.forEach((track, i) => {
|
tracks.forEach((track, i) => {
|
||||||
const y = trackTop(this.view, i);
|
const y = trackTop(this.view, i);
|
||||||
const lit = track.id === this.pickedTrack;
|
const lit = track.id === this.pickedTrack;
|
||||||
@@ -1560,6 +1674,8 @@ export class SongTimeline extends Widget {
|
|||||||
{ text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute },
|
{ text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute },
|
||||||
{ text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo },
|
{ text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo },
|
||||||
);
|
);
|
||||||
|
const meter = trackMeterBox(th);
|
||||||
|
drawMeter(this.headsG, { ...meter, y: y + meter.y }, this.meterOf(track.id), true, !heard(track.id));
|
||||||
if (th < SLIDERS_MIN_HEIGHT) return;
|
if (th < SLIDERS_MIN_HEIGHT) return;
|
||||||
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
||||||
this.headsG
|
this.headsG
|
||||||
@@ -1585,6 +1701,18 @@ export class SongTimeline extends Widget {
|
|||||||
this.syncTexts(this.headTexts, this.headLabels, labels);
|
this.syncTexts(this.headTexts, this.headLabels, labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Out in the corner (#359): its meter and the loudest peak since Play, red past 0 dB. */
|
||||||
|
private drawOut(): void {
|
||||||
|
const box = OUT_METER_BOX;
|
||||||
|
drawMeter(this.bg, box, this.out, false);
|
||||||
|
const middle = box.y + box.h / 2;
|
||||||
|
this.outLabel.position.set(8, Math.round(middle - this.outLabel.height / 2));
|
||||||
|
const text = `${peakText(this.loudest)} dB`;
|
||||||
|
if (this.outPeak.text !== text) this.outPeak.text = text;
|
||||||
|
this.outPeak.style = this.loudest > 0 ? textStyle('warning') : textStyle('small');
|
||||||
|
this.outPeak.position.set(box.x + box.w + 4, Math.round(middle - this.outPeak.height / 2));
|
||||||
|
}
|
||||||
|
|
||||||
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
this.headsG
|
this.headsG
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Graphics } from 'pixi.js';
|
||||||
|
import { Theme } from '../../ui';
|
||||||
|
import { METER_WARM_DB, meterShare, meterZone, type MeterZone, type StereoMeter } from './meterView';
|
||||||
|
import type { Box } from './songView';
|
||||||
|
|
||||||
|
const ZONE_COLORS: Readonly<Record<MeterZone, number>> = { ok: 0x4fae6a, warm: 0xe0c040, hot: 0xd04040 };
|
||||||
|
/** Pixels at the far end of a meter that light while it clipped. */
|
||||||
|
const LAMP = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a meter into `box` (#359): a bar for each side, side by side up a vertical one and one under
|
||||||
|
* the other along a horizontal one; green, yellow from -6 dBFS, red past 0, a line where the peak
|
||||||
|
* holds, and the far end red while it clipped. `grey` for a track that is not heard.
|
||||||
|
*/
|
||||||
|
export function drawMeter(g: Graphics, box: Box, meter: StereoMeter, vertical: boolean, grey = false): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
g.rect(box.x, box.y, box.w, box.h).fill(c.viewport).stroke({ width: 1, color: c.border, alignment: 1 });
|
||||||
|
const across = vertical ? box.w - 2 : box.h - 2;
|
||||||
|
const along = (vertical ? box.h : box.w) - 2 - LAMP - 1;
|
||||||
|
const thick = Math.floor((across - 1) / 2);
|
||||||
|
const warm = meterShare(METER_WARM_DB);
|
||||||
|
const full = meterShare(0);
|
||||||
|
const tint = (zone: MeterZone) => (grey ? c.textMuted : ZONE_COLORS[zone]);
|
||||||
|
meter.forEach((side, i) => {
|
||||||
|
const offset = 1 + i * (thick + 1);
|
||||||
|
const bar = (from: number, to: number, color: number) => {
|
||||||
|
const a = Math.round(from * along);
|
||||||
|
const b = Math.round(to * along);
|
||||||
|
if (b <= a) return;
|
||||||
|
if (vertical) g.rect(box.x + offset, box.y + box.h - 1 - b, thick, b - a).fill(color);
|
||||||
|
else g.rect(box.x + 1 + a, box.y + offset, b - a, thick).fill(color);
|
||||||
|
};
|
||||||
|
const share = meterShare(side.level);
|
||||||
|
bar(0, Math.min(share, warm), tint('ok'));
|
||||||
|
bar(warm, Math.min(share, full), tint('warm'));
|
||||||
|
bar(full, share, tint('hot'));
|
||||||
|
if (Number.isFinite(side.peak)) {
|
||||||
|
const peak = meterShare(side.peak);
|
||||||
|
bar(Math.max(0, peak - 1 / along), peak, tint(meterZone(side.peak)));
|
||||||
|
}
|
||||||
|
if (!side.clipped) return;
|
||||||
|
if (vertical) g.rect(box.x + offset, box.y + 1, thick, LAMP).fill(ZONE_COLORS.hot);
|
||||||
|
else g.rect(box.x + box.w - 1 - LAMP, box.y + offset, LAMP, thick).fill(ZONE_COLORS.hot);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { clamp } from '../../ui';
|
||||||
|
|
||||||
|
/** The scale of a level meter (#359) in dBFS: what is quieter shows nothing, past 0 it clips. */
|
||||||
|
export const METER_MIN_DB = -48;
|
||||||
|
export const METER_MAX_DB = 6;
|
||||||
|
/** From here on a meter is yellow; past 0 dBFS it is red. */
|
||||||
|
export const METER_WARM_DB = -6;
|
||||||
|
/** Seconds the loudest peak stays put before it falls. */
|
||||||
|
export const PEAK_HOLD = 1.5;
|
||||||
|
/** dB a second a level and a peak fall. */
|
||||||
|
export const METER_FALL = 24;
|
||||||
|
|
||||||
|
/** One side of a meter: what it shows now, the loudest peak and when it came, and whether it clipped. */
|
||||||
|
export interface MeterSide {
|
||||||
|
level: number;
|
||||||
|
peak: number;
|
||||||
|
peakAt: number;
|
||||||
|
clipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUIET_SIDE: MeterSide = { level: -Infinity, peak: -Infinity, peakAt: 0, clipped: false };
|
||||||
|
|
||||||
|
export function gainToDb(gain: number): number {
|
||||||
|
return gain > 0 ? 20 * Math.log10(gain) : -Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far up the meter a level is, 0 to 1. */
|
||||||
|
export function meterShare(db: number): number {
|
||||||
|
return clamp((db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB), 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeterZone = 'ok' | 'warm' | 'hot';
|
||||||
|
|
||||||
|
export function meterZone(db: number): MeterZone {
|
||||||
|
return db > 0 ? 'hot' : db >= METER_WARM_DB ? 'warm' : 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A side `dt` seconds on, at `now`, with `gain` the loudest sample since: the level jumps up to it
|
||||||
|
* and falls back slowly, the peak holds `PEAK_HOLD` seconds and then falls the same way. A sample
|
||||||
|
* past full scale lights the clip lamp, and it stays lit until it is cleared.
|
||||||
|
*/
|
||||||
|
export function stepSide(side: MeterSide, gain: number, now: number, dt: number): MeterSide {
|
||||||
|
const db = gainToDb(gain);
|
||||||
|
const fall = (from: number) => {
|
||||||
|
const to = from - METER_FALL * Math.max(0, dt);
|
||||||
|
return to < METER_MIN_DB ? -Infinity : to;
|
||||||
|
};
|
||||||
|
const level = Math.max(db, fall(side.level));
|
||||||
|
const fresh = db >= side.peak;
|
||||||
|
const held = now - side.peakAt < PEAK_HOLD;
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
peak: fresh ? db : held ? side.peak : Math.max(db, fall(side.peak)),
|
||||||
|
peakAt: fresh ? now : side.peakAt,
|
||||||
|
clipped: side.clipped || gain > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both sides of a meter, left and right. */
|
||||||
|
export type StereoMeter = readonly [MeterSide, MeterSide];
|
||||||
|
|
||||||
|
export const QUIET_METER: StereoMeter = [QUIET_SIDE, QUIET_SIDE];
|
||||||
|
|
||||||
|
export function stepMeter(
|
||||||
|
meter: StereoMeter,
|
||||||
|
gains: readonly [number, number],
|
||||||
|
now: number,
|
||||||
|
dt: number,
|
||||||
|
): StereoMeter {
|
||||||
|
return [stepSide(meter[0], gains[0], now, dt), stepSide(meter[1], gains[1], now, dt)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What it shows goes; only the clip lamp stays as it was. */
|
||||||
|
export function hushMeter(meter: StereoMeter): StereoMeter {
|
||||||
|
return [
|
||||||
|
{ ...QUIET_SIDE, clipped: meter[0].clipped },
|
||||||
|
{ ...QUIET_SIDE, clipped: meter[1].clipped },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meterClipped(meter: StereoMeter): boolean {
|
||||||
|
return meter[0].clipped || meter[1].clipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "-3.2", "+1.5", "-inf": a peak as the Out meter writes it, in letters the UI font has. */
|
||||||
|
export function peakText(db: number): string {
|
||||||
|
if (!Number.isFinite(db)) return '-inf';
|
||||||
|
const shown = Math.round(db * 10) / 10;
|
||||||
|
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)}`;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export interface SongView extends TimeView {
|
|||||||
trackHeight: number;
|
trackHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HEAD_WIDTH = 176;
|
export const HEAD_WIDTH = 190;
|
||||||
export const TRACK_HEIGHT = 52;
|
export const TRACK_HEIGHT = 52;
|
||||||
export const MIN_TRACK_HEIGHT = 28;
|
export const MIN_TRACK_HEIGHT = 28;
|
||||||
export const MAX_TRACK_HEIGHT = 320;
|
export const MAX_TRACK_HEIGHT = 320;
|
||||||
@@ -169,6 +169,14 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
|||||||
pan: { x: 116, y: 30, w: 52, h: 12 },
|
pan: { x: 116, y: 30, w: 52, h: 12 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The Out meter of the song (#359), in the corner left of the ruler, above the heads. */
|
||||||
|
export const OUT_METER_BOX: Box = { x: 34, y: 9, w: 100, h: 13 };
|
||||||
|
|
||||||
|
/** The level meter of a track (#359): up the right edge of its head, as high as the track. */
|
||||||
|
export function trackMeterBox(trackHeight: number): Box {
|
||||||
|
return { x: 177, y: 3, w: 9, h: Math.max(1, trackHeight - 6) };
|
||||||
|
}
|
||||||
|
|
||||||
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
|
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
|
||||||
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
|
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
|
||||||
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
|
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from
|
|||||||
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
|
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
|
||||||
import type { SendTap } from '../core/graph/Graph';
|
import type { SendTap } from '../core/graph/Graph';
|
||||||
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
|
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
|
||||||
|
import { limiterLatency } from '../core/soundfx/dynamics';
|
||||||
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
||||||
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||||
@@ -63,6 +64,12 @@ const MAX_SEND_DELAY = 1;
|
|||||||
|
|
||||||
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
|
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
|
||||||
const FADE = 0.01;
|
const FADE = 0.01;
|
||||||
|
|
||||||
|
/** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */
|
||||||
|
const METER_SPAN = 2048;
|
||||||
|
/** 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_RELEASE = 0.1;
|
||||||
/**
|
/**
|
||||||
* The most ways that play through the pitch worklet at once, a phase vocoder each (#317). Measured in
|
* The most ways that play through the pitch worklet at once, a phase vocoder each (#317). Measured in
|
||||||
* Chrome on the Windows runner: 12 take half of the audio thread, 24 all of it, and past that every
|
* Chrome on the Windows runner: 12 take half of the audio thread, 24 all of it, and past that every
|
||||||
@@ -266,6 +273,15 @@ interface Pending {
|
|||||||
export class SoundLibrary implements AudioSink {
|
export class SoundLibrary implements AudioSink {
|
||||||
private base = DEFAULT_BASE;
|
private base = DEFAULT_BASE;
|
||||||
private context: AudioContext | null = null;
|
private context: AudioContext | null = null;
|
||||||
|
/**
|
||||||
|
* Where the master goes out (#359): through a limiter that keeps every sample at `GUARD_DB` or
|
||||||
|
* below, once the worklet is there; straight to the speakers until then. Late by the limiter.
|
||||||
|
*/
|
||||||
|
private guard: { input: GainNode; limiter: AudioWorkletNode | null; latency: number } | null = null;
|
||||||
|
private guardMeter: BusMeter | null = null;
|
||||||
|
|
||||||
|
/** `guarded` false leaves the limiter out: for checks that follow every node of a voice. */
|
||||||
|
constructor(private readonly guarded = true) {}
|
||||||
private offline: OfflineAudioContext | null = null;
|
private offline: OfflineAudioContext | null = null;
|
||||||
private readonly buffers = new Map<string, AudioBuffer>();
|
private readonly buffers = new Map<string, AudioBuffer>();
|
||||||
/**
|
/**
|
||||||
@@ -502,6 +518,72 @@ export class SoundLibrary implements AudioSink {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loudest sample on each side of these buses over the last moment (#359), as they leave them
|
||||||
|
* after level and pan, 1 at full scale. A bus is tapped the first time it is asked after, one not
|
||||||
|
* there is left out. The taps stay until `unmeter`.
|
||||||
|
*/
|
||||||
|
busPeaks(names: readonly string[]): Map<string, [number, number]> {
|
||||||
|
const found = new Map<string, [number, number]>();
|
||||||
|
for (const name of names) {
|
||||||
|
const bus = this.buses.get(name);
|
||||||
|
if (!bus) continue;
|
||||||
|
found.set(name, readMeter((bus.meter ??= this.tapMeter(bus.pan))));
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loudest sample on each side as it leaves the limiter at the output (#359), tapped the first
|
||||||
|
* time it is asked (test hook); null while there is no limiter.
|
||||||
|
*/
|
||||||
|
guardPeaks(): [number, number] | null {
|
||||||
|
const guard = this.guard;
|
||||||
|
if (!guard?.limiter) return null;
|
||||||
|
return readMeter((this.guardMeter ??= this.tapMeter(guard.limiter)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets every level meter go. */
|
||||||
|
unmeter(): void {
|
||||||
|
for (const bus of this.buses.values()) this.dropMeter(bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many buses are tapped for a meter (test hook). */
|
||||||
|
metered(): number {
|
||||||
|
return [...this.buses.values()].filter((bus) => bus.meter).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tapMeter(from: AudioNode): BusMeter {
|
||||||
|
const context = this.audio();
|
||||||
|
const split = context.createChannelSplitter(2);
|
||||||
|
const sides = [0, 1].map((channel) => {
|
||||||
|
const side = context.createAnalyser();
|
||||||
|
side.fftSize = METER_SPAN;
|
||||||
|
split.connect(side, channel);
|
||||||
|
return side;
|
||||||
|
});
|
||||||
|
from.connect(split);
|
||||||
|
return { split, sides, samples: new Float32Array(METER_SPAN) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropMeter(bus: Bus): void {
|
||||||
|
const meter = bus.meter;
|
||||||
|
if (!meter) return;
|
||||||
|
bus.meter = null;
|
||||||
|
try {
|
||||||
|
bus.pan.disconnect(meter.split);
|
||||||
|
} catch {
|
||||||
|
// Already cut with the rest of the bus.
|
||||||
|
}
|
||||||
|
meter.split.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cuts the pan of a bus from where it went; its meter stays on. */
|
||||||
|
private cutPan(bus: Bus): void {
|
||||||
|
bus.pan.disconnect();
|
||||||
|
if (bus.meter) bus.pan.connect(bus.meter.split);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where the voices of a bus go in; the master for `''` (#325). */
|
/** Where the voices of a bus go in; the master for `''` (#325). */
|
||||||
private busInput(name: string): AudioNode {
|
private busInput(name: string): AudioNode {
|
||||||
return this.bus(name || MASTER_MIX).input;
|
return this.bus(name || MASTER_MIX).input;
|
||||||
@@ -529,6 +611,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
fx: null,
|
fx: null,
|
||||||
chain: null,
|
chain: null,
|
||||||
leaving: 0,
|
leaving: 0,
|
||||||
|
meter: 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));
|
||||||
this.buses.set(name, made);
|
this.buses.set(name, made);
|
||||||
@@ -536,7 +619,9 @@ export class SoundLibrary implements AudioSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private outOf(into: string | null): AudioNode {
|
private outOf(into: string | null): AudioNode {
|
||||||
return into ? this.bus(into).input : this.audio().destination;
|
if (into) return this.bus(into).input;
|
||||||
|
const context = this.audio();
|
||||||
|
return this.guard?.input ?? context.destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
|
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
|
||||||
@@ -544,7 +629,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
|
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
|
||||||
if (bus.into === target && !bus.sends) return;
|
if (bus.into === target && !bus.sends) return;
|
||||||
this.dropSends(bus);
|
this.dropSends(bus);
|
||||||
bus.pan.disconnect();
|
this.cutPan(bus);
|
||||||
bus.pan.connect(this.outOf(target));
|
bus.pan.connect(this.outOf(target));
|
||||||
bus.into = target;
|
bus.into = target;
|
||||||
}
|
}
|
||||||
@@ -567,7 +652,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (bus.sends) this.dropSends(bus);
|
if (bus.sends) this.dropSends(bus);
|
||||||
else bus.pan.disconnect();
|
else this.cutPan(bus);
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
bus.sends = sends.map(({ to, gain, tap }) => {
|
bus.sends = sends.map(({ to, gain, tap }) => {
|
||||||
const delay = context.createDelay(MAX_SEND_DELAY);
|
const delay = context.createDelay(MAX_SEND_DELAY);
|
||||||
@@ -840,6 +925,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dropBus(name: string, bus: Bus): void {
|
private dropBus(name: string, bus: Bus): void {
|
||||||
|
this.dropMeter(bus);
|
||||||
this.dropSends(bus);
|
this.dropSends(bus);
|
||||||
this.unchain(bus);
|
this.unchain(bus);
|
||||||
bus.input.disconnect();
|
bus.input.disconnect();
|
||||||
@@ -1267,14 +1353,18 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return this.audio();
|
return this.audio();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seconds between the audio clock and what is heard; 0 where the browser does not say. */
|
/**
|
||||||
|
* Seconds between the audio clock and what is heard: the limiter at the output (#359) and the
|
||||||
|
* device, where the browser says.
|
||||||
|
*/
|
||||||
latency(): number {
|
latency(): number {
|
||||||
const context = this.context;
|
const context = this.context;
|
||||||
if (!context) return 0;
|
if (!context) return 0;
|
||||||
|
const guard = this.guard?.latency ?? 0;
|
||||||
// Older Safari and Firefox have no `outputLatency`, some no `baseLatency` either.
|
// Older Safari and Firefox have no `outputLatency`, some no `baseLatency` either.
|
||||||
const out = context.outputLatency;
|
const out = context.outputLatency;
|
||||||
if (Number.isFinite(out) && out > 0) return out;
|
if (Number.isFinite(out) && out > 0) return guard + out;
|
||||||
return Number.isFinite(context.baseLatency) ? context.baseLatency : 0;
|
return guard + (Number.isFinite(context.baseLatency) ? context.baseLatency : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1857,9 +1947,38 @@ export class SoundLibrary implements AudioSink {
|
|||||||
|
|
||||||
private audio(): AudioContext {
|
private audio(): AudioContext {
|
||||||
if (this.context) return this.context;
|
if (this.context) return this.context;
|
||||||
this.context = new AudioContext();
|
const context = (this.context = new AudioContext());
|
||||||
|
if (this.guarded) this.guardOut(context);
|
||||||
if (this.mix.length > 0) void this.applyMix();
|
if (this.mix.length > 0) void this.applyMix();
|
||||||
return this.context;
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private guardOut(context: AudioContext): void {
|
||||||
|
const guard: NonNullable<SoundLibrary['guard']> = {
|
||||||
|
input: context.createGain(),
|
||||||
|
limiter: null,
|
||||||
|
latency: 0,
|
||||||
|
};
|
||||||
|
this.guard = guard;
|
||||||
|
guard.input.connect(context.destination);
|
||||||
|
void this.fxWorklet().then((loaded) => {
|
||||||
|
if (!loaded || this.guard !== guard) return;
|
||||||
|
let limiter: AudioWorkletNode;
|
||||||
|
try {
|
||||||
|
limiter = new AudioWorkletNode(context, 'limiter', {
|
||||||
|
outputChannelCount: [2],
|
||||||
|
channelCount: 2,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
parameterData: { threshold: GUARD_DB, ceiling: GUARD_DB, release: GUARD_RELEASE, autoGain: 0 },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guard.input.disconnect();
|
||||||
|
guard.input.connect(limiter).connect(context.destination);
|
||||||
|
guard.limiter = limiter;
|
||||||
|
guard.latency = limiterLatency(context.sampleRate);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
|
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
|
||||||
@@ -1967,6 +2086,15 @@ interface Bus {
|
|||||||
chain: BusChain | null;
|
chain: BusChain | null;
|
||||||
/** Set while it is to be let go a moment later, unless it is used again first. */
|
/** Set while it is to be let go a moment later, unless it is used again first. */
|
||||||
leaving: number;
|
leaving: number;
|
||||||
|
/** Its level meter (#359), while one is asked after. */
|
||||||
|
meter: BusMeter | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tap after the pan of a bus: an analyser for each side, so a peak panned hard is not halved. */
|
||||||
|
interface BusMeter {
|
||||||
|
split: ChannelSplitterNode;
|
||||||
|
sides: AnalyserNode[];
|
||||||
|
samples: Float32Array<ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */
|
/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */
|
||||||
@@ -2514,6 +2642,17 @@ function workletNodes(
|
|||||||
return { input: node, output: node, knobs, stop: () => stopProcessor(node) };
|
return { input: node, output: node, knobs, stop: () => stopProcessor(node) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The loudest sample of each side over the stretch a meter looks back over, 1 at full scale. */
|
||||||
|
function readMeter(meter: BusMeter): [number, number] {
|
||||||
|
const [left, right] = meter.sides.map((side) => {
|
||||||
|
side.getFloatTimeDomainData(meter.samples);
|
||||||
|
let peak = 0;
|
||||||
|
for (const sample of meter.samples) peak = Math.max(peak, Math.abs(sample));
|
||||||
|
return peak;
|
||||||
|
});
|
||||||
|
return [left!, right!];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops a processor and lets go of its port. A port that is listened to is never collected, and
|
* Stops a processor and lets go of its port. A port that is listened to is never collected, and
|
||||||
* with it everything its listener reaches: the voice and every node of it, still on its bus.
|
* with it everything its listener reaches: the voice and every node of it, still on its bus.
|
||||||
|
|||||||
+20
-21
@@ -41,10 +41,11 @@ export class Music {
|
|||||||
/** The tracks last set, and when the chains of their sound FX are there (#346). */
|
/** The tracks last set, and when the chains of their sound FX are there (#346). */
|
||||||
private buses: readonly BusLevel[] = [];
|
private buses: readonly BusLevel[] = [];
|
||||||
private busesReady: Promise<void> = Promise.resolve();
|
private busesReady: Promise<void> = Promise.resolve();
|
||||||
/** The bus the sound FX of the pattern or song is on (#352), into `output`; one per tab. */
|
/**
|
||||||
private readonly fxBus = `music:${++musicCount}`;
|
* The bus all the pattern or song gives goes through, into `output`, one per tab: its sound FX is
|
||||||
/** Where what goes to the output lands: `fxBus` while there is a sound FX, else `output`. */
|
* on it (#352), and the Out meter of the Song tab measures it (#359).
|
||||||
private landing = '';
|
*/
|
||||||
|
readonly outBus = `music:${++musicCount}`;
|
||||||
private fxReady: Promise<void> = Promise.resolve();
|
private fxReady: Promise<void> = Promise.resolve();
|
||||||
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
||||||
readonly planned: PlannedNote[] = [];
|
readonly planned: PlannedNote[] = [];
|
||||||
@@ -68,7 +69,7 @@ export class Music {
|
|||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
this.planned.length = 0;
|
this.planned.length = 0;
|
||||||
this.clips.length = 0;
|
this.clips.length = 0;
|
||||||
this.landing = this.heardOn();
|
this.heardOn();
|
||||||
await Promise.all([this.prepare(sequence), this.busesReady, this.fxReady]);
|
await Promise.all([this.prepare(sequence), this.busesReady, this.fxReady]);
|
||||||
if (run !== this.run) return;
|
if (run !== this.run) return;
|
||||||
this.scheduler = new Scheduler(
|
this.scheduler = new Scheduler(
|
||||||
@@ -77,7 +78,7 @@ export class Music {
|
|||||||
latency: () => soundLibrary.latency(),
|
latency: () => soundLibrary.latency(),
|
||||||
ahead: () =>
|
ahead: () =>
|
||||||
Math.max(
|
Math.max(
|
||||||
soundLibrary.busLatency(this.landing),
|
soundLibrary.busLatency(this.outBus),
|
||||||
...this.buses.map((bus) => soundLibrary.busLatency(bus.name)),
|
...this.buses.map((bus) => soundLibrary.busLatency(bus.name)),
|
||||||
),
|
),
|
||||||
play: (note) => this.playOne(note),
|
play: (note) => this.playOne(note),
|
||||||
@@ -97,27 +98,25 @@ export class Music {
|
|||||||
/** A change heard from the next window on; a note new to it loads what it needs meanwhile. */
|
/** A change heard from the next window on; a note new to it loads what it needs meanwhile. */
|
||||||
edit(sequence: Sequence): void {
|
edit(sequence: Sequence): void {
|
||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
this.landing = this.heardOn();
|
this.heardOn();
|
||||||
void this.prepare(sequence);
|
void this.prepare(sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where what goes to the output of the pattern or song is heard: through its sound FX on a bus of
|
* Where what goes to the output of the pattern or song is heard: its own bus, set to its sound FX
|
||||||
* its own, set as it is now, else straight on `output`. A key of the roll is played there too.
|
* as it is now and on into `output`. A key of the roll is played there too.
|
||||||
*/
|
*/
|
||||||
heardOn(): string {
|
heardOn(): string {
|
||||||
const fx = this.fx();
|
const fx = this.fx();
|
||||||
if (!fx) {
|
this.fxReady = soundLibrary.setBuses([
|
||||||
soundLibrary.releaseBuses([this.fxBus]);
|
{ name: this.outBus, gain: 1, pan: 0, into: this.output(), ...(fx && { fx }) },
|
||||||
return this.output();
|
]);
|
||||||
}
|
return this.outBus;
|
||||||
this.fxReady = soundLibrary.setBuses([{ name: this.fxBus, gain: 1, pan: 0, into: this.output(), fx }]);
|
|
||||||
return this.fxBus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lets the bus of its sound FX go while nothing plays: after a key of the roll. */
|
/** Lets its own bus go while nothing plays: after a key of the roll. */
|
||||||
idle(): void {
|
idle(): void {
|
||||||
if (!this.scheduler) soundLibrary.releaseBuses([this.fxBus]);
|
if (!this.scheduler) soundLibrary.releaseBuses([this.outBus]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +124,7 @@ export class Music {
|
|||||||
* (#346). What goes to the output of the song goes to `output` (#350). A start waits for the chains.
|
* (#346). What goes to the output of the song goes to `output` (#350). A start waits for the chains.
|
||||||
*/
|
*/
|
||||||
setBuses(levels: readonly BusLevel[]): void {
|
setBuses(levels: readonly BusLevel[]): void {
|
||||||
const output = (this.landing = this.heardOn());
|
const output = this.heardOn();
|
||||||
this.buses = levels.map(({ sends, ...level }) =>
|
this.buses = levels.map(({ sends, ...level }) =>
|
||||||
sends
|
sends
|
||||||
? { ...level, sends: sends.map((send) => ({ ...send, to: send.to ?? output })) }
|
? { ...level, sends: sends.map((send) => ({ ...send, to: send.to ?? output })) }
|
||||||
@@ -137,7 +136,7 @@ export class Music {
|
|||||||
/** Its buses go once what is on them has faded, unless the next start uses them again. */
|
/** Its buses go once what is on them has faded, unless the next start uses them again. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.halt();
|
this.halt();
|
||||||
soundLibrary.releaseBuses([...this.buses.map((bus) => bus.name), this.fxBus]);
|
soundLibrary.releaseBuses([...this.buses.map((bus) => bus.name), this.outBus]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private halt(): void {
|
private halt(): void {
|
||||||
@@ -151,7 +150,7 @@ export class Music {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private playOne(note: ScheduledNote): number {
|
private playOne(note: ScheduledNote): number {
|
||||||
const heard = { ...note, bus: note.bus || this.landing };
|
const heard = { ...note, bus: note.bus || this.outBus };
|
||||||
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, heard, '');
|
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, heard, '');
|
||||||
this.planned.push({ ...note, voice, planned: soundLibrary.clock() });
|
this.planned.push({ ...note, voice, planned: soundLibrary.clock() });
|
||||||
if (this.planned.length > 256) this.planned.shift();
|
if (this.planned.length > 256) this.planned.shift();
|
||||||
@@ -159,7 +158,7 @@ export class Music {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private playClip(clip: ScheduledClip): number {
|
private playClip(clip: ScheduledClip): number {
|
||||||
const heard = { ...clip, bus: clip.bus || this.landing };
|
const heard = { ...clip, bus: clip.bus || this.outBus };
|
||||||
const voice = playClip({ project: this.project(), audio: soundLibrary }, heard);
|
const voice = playClip({ project: this.project(), audio: soundLibrary }, heard);
|
||||||
this.clips.push({ ...clip, voice, planned: soundLibrary.clock() });
|
this.clips.push({ ...clip, voice, planned: soundLibrary.clock() });
|
||||||
if (this.clips.length > 256) this.clips.shift();
|
if (this.clips.length > 256) this.clips.shift();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* The level meters of the Song tab (#359): the dB scale, how a level and its peak rise and fall, and
|
||||||
|
* the clip lamp that stays lit.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
gainToDb,
|
||||||
|
hushMeter,
|
||||||
|
METER_FALL,
|
||||||
|
meterClipped,
|
||||||
|
meterShare,
|
||||||
|
meterZone,
|
||||||
|
PEAK_HOLD,
|
||||||
|
peakText,
|
||||||
|
QUIET_METER,
|
||||||
|
QUIET_SIDE,
|
||||||
|
stepMeter,
|
||||||
|
stepSide,
|
||||||
|
} from '../../src/editor/song/meterView';
|
||||||
|
|
||||||
|
describe('meter scale', () => {
|
||||||
|
it('runs from -48 to +6 dBFS, full scale at 0 dB', () => {
|
||||||
|
expect(gainToDb(1)).toBe(0);
|
||||||
|
expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
|
||||||
|
expect(gainToDb(0)).toBe(-Infinity);
|
||||||
|
expect(meterShare(-48)).toBe(0);
|
||||||
|
expect(meterShare(-Infinity)).toBe(0);
|
||||||
|
expect(meterShare(6)).toBe(1);
|
||||||
|
expect(meterShare(0)).toBeCloseTo(48 / 54, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is green, yellow from -6 dB and red past 0 dB', () => {
|
||||||
|
expect(meterZone(-6.1)).toBe('ok');
|
||||||
|
expect(meterZone(-6)).toBe('warm');
|
||||||
|
expect(meterZone(0)).toBe('warm');
|
||||||
|
expect(meterZone(0.1)).toBe('hot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a peak to a tenth of a dB', () => {
|
||||||
|
expect(peakText(-Infinity)).toBe('-inf');
|
||||||
|
expect(peakText(-3.24)).toBe('-3.2');
|
||||||
|
expect(peakText(1.46)).toBe('+1.5');
|
||||||
|
expect(peakText(-0.01)).toBe('0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a side of a meter', () => {
|
||||||
|
it('jumps up at once and falls back slowly', () => {
|
||||||
|
const up = stepSide(QUIET_SIDE, 1, 0, 0.016);
|
||||||
|
expect(up.level).toBe(0);
|
||||||
|
const later = stepSide(up, 0, 0.5, 0.5);
|
||||||
|
expect(later.level).toBeCloseTo(-METER_FALL * 0.5, 6);
|
||||||
|
const louder = stepSide(later, 0.5, 0.516, 0.016);
|
||||||
|
expect(louder.level).toBeCloseTo(-6.02, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds its peak, then lets it fall', () => {
|
||||||
|
const hit = stepSide(QUIET_SIDE, 1, 10, 0.016);
|
||||||
|
const held = stepSide(hit, 0.01, 10 + PEAK_HOLD - 0.1, 0.016);
|
||||||
|
expect(held.peak).toBe(0);
|
||||||
|
const falling = stepSide(held, 0.01, 10 + PEAK_HOLD + 0.1, 0.2);
|
||||||
|
expect(falling.peak).toBeCloseTo(-METER_FALL * 0.2, 6);
|
||||||
|
const gone = stepSide(falling, 0, 20, 3);
|
||||||
|
expect(gone.peak).toBe(-Infinity);
|
||||||
|
expect(gone.level).toBe(-Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights the clip lamp past full scale and keeps it lit', () => {
|
||||||
|
expect(stepSide(QUIET_SIDE, 1, 0, 0).clipped).toBe(false);
|
||||||
|
const clipped = stepSide(QUIET_SIDE, 1.01, 0, 0);
|
||||||
|
expect(clipped.clipped).toBe(true);
|
||||||
|
expect(stepSide(clipped, 0, 30, 30).clipped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the two sides apart: a sound panned hard clips one side only', () => {
|
||||||
|
const meter = stepMeter(QUIET_METER, [2, 0], 0, 0);
|
||||||
|
expect(meter[0].clipped).toBe(true);
|
||||||
|
expect(meter[1].clipped).toBe(false);
|
||||||
|
expect(meter[0].level).toBeCloseTo(6.02, 2);
|
||||||
|
expect(meter[1].level).toBe(-Infinity);
|
||||||
|
expect(meterClipped(meter)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes dark when nothing plays, its clip lamp still lit', () => {
|
||||||
|
const hushed = hushMeter(stepMeter(QUIET_METER, [2, 0.5], 0, 0));
|
||||||
|
expect(hushed.map((side) => [side.level, side.peak])).toEqual([
|
||||||
|
[-Infinity, -Infinity],
|
||||||
|
[-Infinity, -Infinity],
|
||||||
|
]);
|
||||||
|
expect(hushed.map((side) => side.clipped)).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,6 +156,8 @@ const convolvers: { buffer: unknown }[] = [];
|
|||||||
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
|
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
|
||||||
/** The curve of every WaveShaper made, in order. */
|
/** The curve of every WaveShaper made, in order. */
|
||||||
const shapers: { curve: Float32Array | null }[] = [];
|
const shapers: { curve: Float32Array | null }[] = [];
|
||||||
|
/** Every AnalyserNode made (#359), with the one sample it hands out among silence. */
|
||||||
|
const analysers: { fftSize: number; sample: number }[] = [];
|
||||||
let contextState = 'running';
|
let contextState = 'running';
|
||||||
|
|
||||||
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
|
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
|
||||||
@@ -269,6 +271,18 @@ function fakeContext() {
|
|||||||
return panner;
|
return panner;
|
||||||
},
|
},
|
||||||
createChannelSplitter: () => wired('splitter', {}),
|
createChannelSplitter: () => wired('splitter', {}),
|
||||||
|
createAnalyser: () => {
|
||||||
|
const analyser = wired('analyser', {
|
||||||
|
fftSize: 2048,
|
||||||
|
sample: 0,
|
||||||
|
getFloatTimeDomainData: (into: Float32Array) => {
|
||||||
|
into.fill(0);
|
||||||
|
into[into.length >> 1] = analyser.sample;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
analysers.push(analyser);
|
||||||
|
return analyser;
|
||||||
|
},
|
||||||
createConstantSource: () => {
|
createConstantSource: () => {
|
||||||
const clock = wired('clock', {
|
const clock = wired('clock', {
|
||||||
offset: param(1),
|
offset: param(1),
|
||||||
@@ -328,6 +342,7 @@ beforeEach(() => {
|
|||||||
disconnects.length = 0;
|
disconnects.length = 0;
|
||||||
clocks.length = 0;
|
clocks.length = 0;
|
||||||
shapers.length = 0;
|
shapers.length = 0;
|
||||||
|
analysers.length = 0;
|
||||||
compressors.length = 0;
|
compressors.length = 0;
|
||||||
convolvers.length = 0;
|
convolvers.length = 0;
|
||||||
panners.length = 0;
|
panners.length = 0;
|
||||||
@@ -453,7 +468,7 @@ async function arrive(): Promise<void> {
|
|||||||
|
|
||||||
describe('the sound library', () => {
|
describe('the sound library', () => {
|
||||||
it('fetches and decodes a file once, however often it is played', async () => {
|
it('fetches and decodes a file once, however often it is played', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
await play(library);
|
await play(library);
|
||||||
expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 });
|
expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 });
|
||||||
@@ -462,7 +477,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays the stretch a sound is cut to, made from its file without fetching it again (#343)', async () => {
|
it('plays the stretch a sound is cut to, made from its file without fetching it again (#343)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
// The file is 1200 samples at 800 Hz: 1.5 s. The cut keeps 0.5 s to 1 s.
|
// The file is 1200 samples at 800 Hz: 1.5 s. The cut keeps 0.5 s to 1 s.
|
||||||
const cut = `${FILE}#0.5~1~0.005~0.005~linear~linear`;
|
const cut = `${FILE}#0.5~1~0.005~0.005~linear~linear`;
|
||||||
await play(library, cut);
|
await play(library, cut);
|
||||||
@@ -478,6 +493,29 @@ describe('the sound library', () => {
|
|||||||
expect(library.stats().playing).toBe(1);
|
expect(library.stats().playing).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends the master out through a limiter at -1 dBFS once the worklet is there, late by it (#359)', async () => {
|
||||||
|
let load: () => void = () => {};
|
||||||
|
loadModule = () => new Promise((resolve) => (load = resolve));
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
await play(library);
|
||||||
|
// Straight out until the worklet is there: nothing waits for it.
|
||||||
|
expect(wires).toEqual(expect.arrayContaining(['gain > destination', 'panner > gain']));
|
||||||
|
expect(library.latency()).toBe(0);
|
||||||
|
expect(library.guardPeaks()).toBeNull();
|
||||||
|
load();
|
||||||
|
await settle();
|
||||||
|
const limiter = worklets.find((w) => w.kind === 'limiter');
|
||||||
|
expect(Object.fromEntries([...limiter!.parameters].map(([name, p]) => [name, p.value]))).toEqual({
|
||||||
|
threshold: -1,
|
||||||
|
ceiling: -1,
|
||||||
|
release: 0.1,
|
||||||
|
autoGain: 0,
|
||||||
|
});
|
||||||
|
expect(wires.slice(-2)).toEqual(['gain > limiter', 'limiter > destination']);
|
||||||
|
expect(library.latency()).toBeCloseTo(0.005, 12);
|
||||||
|
expect(library.stats().playing).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('loads ahead of the first play without opening the device, so that play starts at once', async () => {
|
it('loads ahead of the first play without opening the device, so that play starts at once', async () => {
|
||||||
const opened: string[] = [];
|
const opened: string[] = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -498,7 +536,7 @@ describe('the sound library', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.preload([FILE, OTHER, '']);
|
library.preload([FILE, OTHER, '']);
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -512,7 +550,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('remembers what it last started, so a muted check can see it at all', async () => {
|
it('remembers what it last started, so a muted check can see it at all', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.play(FILE, 0.25, true);
|
library.play(FILE, 0.25, true);
|
||||||
await settle();
|
await settle();
|
||||||
for (const resolve of decodes.splice(0)) resolve();
|
for (const resolve of decodes.splice(0)) resolve();
|
||||||
@@ -532,14 +570,14 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => {
|
it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => {
|
||||||
await play(new SoundLibrary());
|
await play(new SoundLibrary(false));
|
||||||
expect(wires).toEqual(['source > gain', ...INTO_MASTER]);
|
expect(wires).toEqual(['source > gain', ...INTO_MASTER]);
|
||||||
expect(sources[0]?.playbackRate.value).toBe(1);
|
expect(sources[0]?.playbackRate.value).toBe(1);
|
||||||
expect(modules).toEqual([]);
|
expect(modules).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a voice through its sound FX in order (#311)', async () => {
|
it('sends a voice through its sound FX in order (#311)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const fx: Chain = {
|
const fx: Chain = {
|
||||||
pitch: 12,
|
pitch: 12,
|
||||||
stages: [
|
stages: [
|
||||||
@@ -556,7 +594,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays a pitched way through the worklet at the length of the sound, its samples sent once (#317)', async () => {
|
it('plays a pitched way through the worklet at the length of the sound, its samples sent once (#317)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||||||
await play(library, FILE, {}, { pitch: -3.5, stages: [] });
|
await play(library, FILE, {}, { pitch: -3.5, stages: [] });
|
||||||
@@ -579,7 +617,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends a voice through a Bitcrusher on the worklet, which it loads first, and stops it with the voice (#322)', async () => {
|
it('sends a voice through a Bitcrusher on the worklet, which it loads first, and stops it with the voice (#322)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const crush: Chain = { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 0.5 }] };
|
const crush: Chain = { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 0.5 }] };
|
||||||
const voice = await play(library, FILE, {}, crush);
|
const voice = await play(library, FILE, {}, crush);
|
||||||
expect(modules).toHaveLength(1);
|
expect(modules).toHaveLength(1);
|
||||||
@@ -594,7 +632,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('moves the numbers of a Bitcrusher while it plays, its rate in octaves (#322)', async () => {
|
it('moves the numbers of a Bitcrusher while it plays, its rate in octaves (#322)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -615,7 +653,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends a voice through a Phaser and a Flanger on the worklet, the sweep from its start (#341)', async () => {
|
it('sends a voice through a Phaser and a Flanger on the worklet, the sweep from its start (#341)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const amounts = (values: Record<string, number>) =>
|
const amounts = (values: Record<string, number>) =>
|
||||||
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }]));
|
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }]));
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
@@ -665,7 +703,7 @@ describe('the sound library', () => {
|
|||||||
it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => {
|
it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => {
|
||||||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
await play(library, FILE, {}, { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
||||||
expect(worklets).toHaveLength(0);
|
expect(worklets).toHaveLength(0);
|
||||||
expect(wires).toEqual(['source > gain', 'gain > gain', ...INTO_MASTER]);
|
expect(wires).toEqual(['source > gain', 'gain > gain', ...INTO_MASTER]);
|
||||||
@@ -675,7 +713,7 @@ describe('the sound library', () => {
|
|||||||
it('a play that needs the worklet waits for it to load, as it would for its bytes', async () => {
|
it('a play that needs the worklet waits for it to load, as it would for its bytes', async () => {
|
||||||
let loaded = () => {};
|
let loaded = () => {};
|
||||||
loadModule = () => new Promise((resolve) => (loaded = resolve));
|
loadModule = () => new Promise((resolve) => (loaded = resolve));
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
await play(library, FILE, {}, { pitch: 7, stages: [] });
|
||||||
expect(library.stats()).toMatchObject({ started: 1 });
|
expect(library.stats()).toMatchObject({ started: 1 });
|
||||||
@@ -688,7 +726,7 @@ describe('the sound library', () => {
|
|||||||
it('without the worklet a pitched way plays at its own pitch rather than not at all', async () => {
|
it('without the worklet a pitched way plays at its own pitch rather than not at all', async () => {
|
||||||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, {}, { plan: open(), params: {} });
|
const voice = await play(library, FILE, {}, { plan: open(), params: {} });
|
||||||
expect(worklets).toHaveLength(0);
|
expect(worklets).toHaveLength(0);
|
||||||
expect(sources).toHaveLength(2);
|
expect(sources).toHaveLength(2);
|
||||||
@@ -698,7 +736,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('mixes two sounds by their shares, a source per way, started and stopped together (#316)', async () => {
|
it('mixes two sounds by their shares, a source per way, started and stopped together (#316)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(
|
await play(
|
||||||
library,
|
library,
|
||||||
FILE,
|
FILE,
|
||||||
@@ -737,7 +775,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a pitched voice that ends by itself leaves the count when the worklet says so', async () => {
|
it('a pitched voice that ends by itself leaves the count when the worklet says so', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
||||||
worklets[0]!.port.onmessage!({ data: 'something else' });
|
worklets[0]!.port.onmessage!({ data: 'something else' });
|
||||||
expect(library.stats().playing).toBe(1);
|
expect(library.stats().playing).toBe(1);
|
||||||
@@ -746,7 +784,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('gives two ways at the same pitch a source each, so each can glide on its own (#315)', async () => {
|
it('gives two ways at the same pitch a source each, so each can glide on its own (#315)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(
|
await play(
|
||||||
library,
|
library,
|
||||||
FILE,
|
FILE,
|
||||||
@@ -777,7 +815,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => {
|
it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, {}, both());
|
const voice = await play(library, FILE, {}, both());
|
||||||
// The voice, the Mix, share of a, share of b: the shares hold nothing of their own.
|
// The voice, the Mix, share of a, share of b: the shares hold nothing of their own.
|
||||||
const [, , shareA, shareB] = gains;
|
const [, , shareA, shareB] = gains;
|
||||||
@@ -802,7 +840,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stops that source with the voice, cut or ended by itself', async () => {
|
it('stops that source with the voice, cut or ended by itself', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, both());
|
await play(library, FILE, {}, both());
|
||||||
library.stop(FILE);
|
library.stop(FILE);
|
||||||
expect(clocks[0]!.stopped).toBe(false);
|
expect(clocks[0]!.stopped).toBe(false);
|
||||||
@@ -819,7 +857,7 @@ describe('the sound library', () => {
|
|||||||
|
|
||||||
describe('a parameter changed while the voice plays (#315)', () => {
|
describe('a parameter changed while the voice plays (#315)', () => {
|
||||||
it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => {
|
it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
||||||
// Made in the order: the voice, the Mix, share of a, the gain on b, share of b.
|
// Made in the order: the voice, the Mix, share of a, the gain on b, share of b.
|
||||||
const [, , shareA, loud, shareB] = gains;
|
const [, , shareA, loud, shareB] = gains;
|
||||||
@@ -844,7 +882,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reaches the one voice it is given – a detached one too – and none that has ended', async () => {
|
it('reaches the one voice it is given – a detached one too – and none that has ended', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voices = [
|
const voices = [
|
||||||
await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }),
|
await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }),
|
||||||
await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }),
|
await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} }),
|
||||||
@@ -864,7 +902,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts a voice still waiting on its bytes with the values handed in meanwhile', async () => {
|
it('starts a voice still waiting on its bytes with the values handed in meanwhile', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(
|
const voice = await play(
|
||||||
library,
|
library,
|
||||||
FILE,
|
FILE,
|
||||||
@@ -880,7 +918,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs a timed change evenly on the audio clock: semitones and decibels in their own steps', async () => {
|
it('runs a timed change evenly on the audio clock: semitones and decibels in their own steps', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
||||||
const [, , shareA, loud, shareB] = gains;
|
const [, , shareA, loud, shareB] = gains;
|
||||||
library.tune(voice, { depth: 12, loud: -6, blend: 0.5 }, 1.5);
|
library.tune(voice, { depth: 12, loud: -6, blend: 0.5 }, 1.5);
|
||||||
@@ -899,7 +937,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs a timed change along an easing as a curve, each number in its own steps', async () => {
|
it('runs a timed change along an easing as a curve, each number in its own steps', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
||||||
const loud = gains[3]!;
|
const loud = gains[3]!;
|
||||||
const from = loud.value;
|
const from = loud.value;
|
||||||
@@ -914,7 +952,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a step holds the value for the time and then jumps', async () => {
|
it('a step holds the value for the time and then jumps', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
const voice = await play(library, FILE, { owner: 'me' }, { plan: open(), params: {} });
|
||||||
library.tune(voice, { depth: 12 }, 2, 'step');
|
library.tune(voice, { depth: 12 }, 2, 'step');
|
||||||
expect(worklets[0]!.pitch.ramps).toEqual([['set', 12, 2]]);
|
expect(worklets[0]!.pitch.ramps).toEqual([['set', 12, 2]]);
|
||||||
@@ -922,7 +960,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts a timed change to a voice still waiting on its bytes when the voice starts', async () => {
|
it('starts a timed change to a voice still waiting on its bytes when the voice starts', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, { hold: true, owner: 'me' }, { plan: open(), params: {} });
|
const voice = await play(library, FILE, { hold: true, owner: 'me' }, { plan: open(), params: {} });
|
||||||
library.tune(voice, { depth: -64 }, 1.28);
|
library.tune(voice, { depth: -64 }, 1.28);
|
||||||
// A quick change after a timed one keeps its place behind it.
|
// A quick change after a timed one keeps its place behind it.
|
||||||
@@ -937,7 +975,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
|
it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, { hold: true });
|
await play(library, FILE, { hold: true });
|
||||||
library.stop(FILE);
|
library.stop(FILE);
|
||||||
for (const resolve of decodes.splice(0)) resolve();
|
for (const resolve of decodes.splice(0)) resolve();
|
||||||
@@ -946,7 +984,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('but a stop before a play does not: an earlier silence is not a standing veto', async () => {
|
it('but a stop before a play does not: an earlier silence is not a standing veto', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
// Exactly what ending a run does – and the next run has to be able to play that sound again,
|
// Exactly what ending a run does – and the next run has to be able to play that sound again,
|
||||||
// even though its bytes are still unread at that point.
|
// even though its bytes are still unread at that point.
|
||||||
library.stop('');
|
library.stop('');
|
||||||
@@ -956,7 +994,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stopping everything silences every voice, and only the named file otherwise', async () => {
|
it('stopping everything silences every voice, and only the named file otherwise', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE);
|
await play(library, FILE);
|
||||||
await play(library, OTHER);
|
await play(library, OTHER);
|
||||||
library.stop(FILE);
|
library.stop(FILE);
|
||||||
@@ -969,14 +1007,14 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a voice that ends by itself leaves the count, without being stopped', async () => {
|
it('a voice that ends by itself leaves the count, without being stopped', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
sources[0]?.onended?.();
|
sources[0]?.onended?.();
|
||||||
expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 });
|
expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a file that cannot be loaded fails once and is never asked for again', async () => {
|
it('a file that cannot be loaded fails once and is never asked for again', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
|
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 }));
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
expect(await library.load(FILE)).toBeNull();
|
expect(await library.load(FILE)).toBeNull();
|
||||||
@@ -999,7 +1037,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fades a stopped voice out instead of cutting it, then lets its nodes go', async () => {
|
it('fades a stopped voice out instead of cutting it, then lets its nodes go', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
library.stop(FILE);
|
library.stop(FILE);
|
||||||
const level = gains[0]!;
|
const level = gains[0]!;
|
||||||
@@ -1015,7 +1053,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cuts at once where the clock stands still, so a paused run cannot sound in the next', async () => {
|
it('cuts at once where the clock stands still, so a paused run cannot sound in the next', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
contextState = 'suspended';
|
contextState = 'suspended';
|
||||||
library.stopAll();
|
library.stopAll();
|
||||||
@@ -1024,7 +1062,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('wires an echo: the sound goes on, the line feeds back through the damping, each at its share of wet', async () => {
|
it('wires an echo: the sound goes on, the line feeds back through the damping, each at its share of wet', async () => {
|
||||||
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] });
|
await play(new SoundLibrary(false), FILE, {}, { pitch: 0, stages: [echo(0.4)] });
|
||||||
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
|
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
|
||||||
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
|
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
|
||||||
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
|
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
|
||||||
@@ -1048,7 +1086,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
|
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
|
||||||
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4, true)] });
|
await play(new SoundLibrary(false), FILE, {}, { pitch: 0, stages: [echo(0.4, true)] });
|
||||||
expect(wires).toEqual(
|
expect(wires).toEqual(
|
||||||
expect.arrayContaining(['gain > merger', 'merger > delay', 'filter > splitter', 'splitter > merger']),
|
expect.arrayContaining(['gain > merger', 'merger > delay', 'filter > splitter', 'splitter > merger']),
|
||||||
);
|
);
|
||||||
@@ -1057,7 +1095,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rings on after its sound has ended, still reached, and goes when the echoes have faded', async () => {
|
it('rings on after its sound has ended, still reached, and goes when the echoes have faded', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||||||
expect(library.tail(voice)).toBe(0.25);
|
expect(library.tail(voice)).toBe(0.25);
|
||||||
sources[0]!.onended!();
|
sources[0]!.onended!();
|
||||||
@@ -1074,7 +1112,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a dry voice ends with its sound, as before', async () => {
|
it('a dry voice ends with its sound, as before', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await play(library);
|
const voice = await play(library);
|
||||||
expect(library.tail(voice)).toBe(0);
|
expect(library.tail(voice)).toBe(0);
|
||||||
sources[0]!.onended!();
|
sources[0]!.onended!();
|
||||||
@@ -1083,7 +1121,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('counts a tail again when a change makes it longer', async () => {
|
it('counts a tail again when a change makes it longer', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -1109,7 +1147,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('counts an echo synced to beats by the tempo of the voice, never past the line (#332)', async () => {
|
it('counts an echo synced to beats by the tempo of the voice, never past the line (#332)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const synced = (quarters: number): FxPlan => ({
|
const synced = (quarters: number): FxPlan => ({
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -1133,7 +1171,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => {
|
it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||||||
library.stop(FILE, true);
|
library.stop(FILE, true);
|
||||||
const [level, fade] = gains;
|
const [level, fade] = gains;
|
||||||
@@ -1150,7 +1188,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a stop without letting ring cuts a ringing voice short, and asking again changes nothing', async () => {
|
it('a stop without letting ring cuts a ringing voice short, and asking again changes nothing', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
|
||||||
library.stop(FILE, true);
|
library.stop(FILE, true);
|
||||||
library.stop(FILE, true);
|
library.stop(FILE, true);
|
||||||
@@ -1161,7 +1199,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a voice without echoes is simply faded out when asked to let ring', async () => {
|
it('a voice without echoes is simply faded out when asked to let ring', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
library.stop(FILE, true);
|
library.stop(FILE, true);
|
||||||
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
|
expect(library.stats()).toMatchObject({ playing: 0, stopped: 1 });
|
||||||
@@ -1169,7 +1207,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stops one voice by its number, also one still waiting on its bytes', async () => {
|
it('stops one voice by its number, also one still waiting on its bytes', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const first = await play(library);
|
const first = await play(library);
|
||||||
const second = await play(library);
|
const second = await play(library);
|
||||||
library.stopVoice(first);
|
library.stopVoice(first);
|
||||||
@@ -1183,7 +1221,7 @@ describe('the sound library', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('moves the numbers of an echo while it plays, the damping evenly in octaves', async () => {
|
it('moves the numbers of an echo while it plays, the damping evenly in octaves', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -1229,7 +1267,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('plays its layers side by side as one voice, each way its own file behind its own volume', async () => {
|
it('plays its layers side by side as one voice, each way its own file behind its own volume', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
||||||
expect(voice).toBe(1);
|
expect(voice).toBe(1);
|
||||||
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 2 });
|
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 2 });
|
||||||
@@ -1241,7 +1279,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a release fades out the ways that end with the note and lets a one-shot play on', async () => {
|
it('a release fades out the ways that end with the note and lets a one-shot play on', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
const voice = await note(library, [sample(FILE), sample(OTHER, { gate: false })]);
|
||||||
library.release(voice);
|
library.release(voice);
|
||||||
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||||||
@@ -1255,7 +1293,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a note with a duration is released that long after it starts, its fade set on the audio clock at once (#332)', async () => {
|
it('a note with a duration is released that long after it starts, its fade set on the audio clock at once (#332)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await note(library, [sample(FILE)], 0.5);
|
await note(library, [sample(FILE)], 0.5);
|
||||||
expect(gains[1]!.ramps).toEqual([
|
expect(gains[1]!.ramps).toEqual([
|
||||||
['set', 0.5, 0.5],
|
['set', 0.5, 0.5],
|
||||||
@@ -1269,7 +1307,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a note planned ahead starts on that second of the audio clock and is released from there (#332)', async () => {
|
it('a note planned ahead starts on that second of the audio clock and is released from there (#332)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0.5, { at: 2 });
|
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0.5, { at: 2 });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -1286,7 +1324,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
|
it('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const limit: FxStagePlan = {
|
const limit: FxStagePlan = {
|
||||||
kind: 'limit',
|
kind: 'limit',
|
||||||
threshold: { value: -6 },
|
threshold: { value: -6 },
|
||||||
@@ -1311,7 +1349,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
|
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan;
|
const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan;
|
||||||
library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 1.25 });
|
library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 1.25 });
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1320,7 +1358,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a note planned to end later leaves its loop then, not now (#332)', async () => {
|
it('a note planned to end later leaves its loop then, not now (#332)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await note(library, [sample(FILE, { loop: true, gate: false })], 1);
|
await note(library, [sample(FILE, { loop: true, gate: false })], 1);
|
||||||
expect(sources[0]!.loop).toBe(true);
|
expect(sources[0]!.loop).toBe(true);
|
||||||
clockTo(0.99);
|
clockTo(0.99);
|
||||||
@@ -1330,7 +1368,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a note released before its bytes arrived still starts, and is released at once', async () => {
|
it('a note released before its bytes arrived still starts, and is released at once', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = library.playNote(
|
const voice = library.playNote(
|
||||||
'instrument:i',
|
'instrument:i',
|
||||||
[sample(FILE), sample(OTHER, { gate: false })],
|
[sample(FILE), sample(OTHER, { gate: false })],
|
||||||
@@ -1348,13 +1386,13 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loops a way between its loop start and end', async () => {
|
it('loops a way between its loop start and end', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await note(library, [sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 1 })]);
|
await note(library, [sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 1 })]);
|
||||||
expect(sources[0]).toMatchObject({ loop: true, loopStart: 0.25, loopEnd: 1 });
|
expect(sources[0]).toMatchObject({ loop: true, loopStart: 0.25, loopEnd: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hands a pitched way its loop in samples of the sound', async () => {
|
it('hands a pitched way its loop in samples of the sound', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const pitched = { ...sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 0 }), pitch: [{ value: 3 }] };
|
const pitched = { ...sample(FILE, { loop: true, loopStart: 0.25, loopEnd: 0 }), pitch: [{ value: 3 }] };
|
||||||
await note(library, [pitched]);
|
await note(library, [pitched]);
|
||||||
// 800 samples a second, 1200 of them: from 200 to the end.
|
// 800 samples a second, 1200 of them: from 200 to the end.
|
||||||
@@ -1366,7 +1404,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a release lets a loop that does not end with the note leave its loop and play out', async () => {
|
it('a release lets a loop that does not end with the note leave its loop and play out', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await note(library, [
|
const voice = await note(library, [
|
||||||
sample(FILE, { loop: true, loopEnd: 1, gate: false }),
|
sample(FILE, { loop: true, loopEnd: 1, gate: false }),
|
||||||
sample(OTHER, { loop: true, loopEnd: 1 }),
|
sample(OTHER, { loop: true, loopEnd: 1 }),
|
||||||
@@ -1384,7 +1422,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('tells the pitch worklet to leave its loop on a release', async () => {
|
it('tells the pitch worklet to leave its loop on a release', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] };
|
const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] };
|
||||||
const voice = await note(library, [pitched]);
|
const voice = await note(library, [pitched]);
|
||||||
library.release(voice);
|
library.release(voice);
|
||||||
@@ -1392,7 +1430,7 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays nothing where no way has a file', () => {
|
it('plays nothing where no way has a file', () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);
|
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);
|
||||||
expect(library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0)).toBe(0);
|
expect(library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0)).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -1425,7 +1463,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
const driver = () => clocks[0]!;
|
const driver = () => clocks[0]!;
|
||||||
|
|
||||||
it('starts its curve with the note: straight up in the attack, then easing towards the sustain', async () => {
|
it('starts its curve with the note: straight up in the attack, then easing towards the sustain', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await note(library, way(true));
|
await note(library, way(true));
|
||||||
expect(library.stats().last?.envelopes).toBe(1);
|
expect(library.stats().last?.envelopes).toBe(1);
|
||||||
expect(driver().started).toBe(true);
|
expect(driver().started).toBe(true);
|
||||||
@@ -1438,7 +1476,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a gated source plays on through the release, and the note ends with it', async () => {
|
it('a gated source plays on through the release, and the note ends with it', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await note(library, way(true));
|
const voice = await note(library, way(true));
|
||||||
library.release(voice);
|
library.release(voice);
|
||||||
// Released at 0, in the attack still: from 0, falling to silence in 0.3 s.
|
// Released at 0, in the attack still: from 0, falling to silence in 0.3 s.
|
||||||
@@ -1461,7 +1499,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a note ending mid-decay releases from the level its curve has by then', async () => {
|
it('a note ending mid-decay releases from the level its curve has by then', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await note(library, way(true), 0.2);
|
await note(library, way(true), 0.2);
|
||||||
const level = 0.5 + 0.5 * Math.exp((-0.1 * Math.log(1000)) / 0.2);
|
const level = 0.5 + 0.5 * Math.exp((-0.1 * Math.log(1000)) / 0.2);
|
||||||
expect(driver().offset.ramps).toEqual([
|
expect(driver().offset.ramps).toEqual([
|
||||||
@@ -1475,7 +1513,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a one-shot under an Envelope alone plays through, its curve held', async () => {
|
it('a one-shot under an Envelope alone plays through, its curve held', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = await note(library, way(false));
|
const voice = await note(library, way(false));
|
||||||
library.release(voice);
|
library.release(voice);
|
||||||
expect(driver().offset.glides).toEqual([0.5]);
|
expect(driver().offset.glides).toEqual([0.5]);
|
||||||
@@ -1486,7 +1524,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Stop sound lets it fall away in its release with "let ring", and cuts it without', async () => {
|
it('Stop sound lets it fall away in its release with "let ring", and cuts it without', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const ringing = await note(library, way(false));
|
const ringing = await note(library, way(false));
|
||||||
library.stopVoice(ringing, true);
|
library.stopVoice(ringing, true);
|
||||||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||||||
@@ -1507,7 +1545,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('one that only moves a cutoff ends nothing: the gated source fades with the note as before', async () => {
|
it('one that only moves a cutoff ends nothing: the gated source fades with the note as before', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const sweep: FxStagePlan = {
|
const sweep: FxStagePlan = {
|
||||||
kind: 'pass',
|
kind: 'pass',
|
||||||
filter: 'lowpass',
|
filter: 'lowpass',
|
||||||
@@ -1535,7 +1573,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
...way(true, false),
|
...way(true, false),
|
||||||
stages: [{ kind: 'envelope', envelope: { ...PLANNED, sustain: { value: 0 } } }],
|
stages: [{ kind: 'envelope', envelope: { ...PLANNED, sustain: { value: 0 } } }],
|
||||||
@@ -1554,7 +1592,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
...way(true, false),
|
...way(true, false),
|
||||||
pitch: [
|
pitch: [
|
||||||
@@ -1587,7 +1625,7 @@ describe('a Chip (#329)', () => {
|
|||||||
const pitched: FxPlan = { pitch: [{ value: 3 }], stages: [], chip: CHIP };
|
const pitched: FxPlan = { pitch: [{ value: 3 }], stages: [], chip: CHIP };
|
||||||
|
|
||||||
it('plays a note without any file on a worklet of its own, its frequency, duty and pitch knobs', async () => {
|
it('plays a note without any file on a worklet of its own, its frequency, duty and pitch knobs', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 0.5 });
|
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 0.5 });
|
||||||
await settle();
|
await settle();
|
||||||
expect(modules).toHaveLength(1);
|
expect(modules).toHaveLength(1);
|
||||||
@@ -1602,7 +1640,7 @@ describe('a Chip (#329)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ends with its note, faded out, or by itself once it has slid below its lowest frequency', async () => {
|
it('ends with its note, faded out, or by itself once it has slid below its lowest frequency', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const released = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
const released = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||||||
await settle();
|
await settle();
|
||||||
library.release(released);
|
library.release(released);
|
||||||
@@ -1616,7 +1654,7 @@ describe('a Chip (#329)', () => {
|
|||||||
|
|
||||||
it('is silent without the worklet, and ends at once', async () => {
|
it('is silent without the worklet, and ends at once', async () => {
|
||||||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||||||
await settle();
|
await settle();
|
||||||
expect(worklets).toHaveLength(0);
|
expect(worklets).toHaveLength(0);
|
||||||
@@ -1628,7 +1666,7 @@ describe('a Chip (#329)', () => {
|
|||||||
|
|
||||||
describe('clips and tracks of a song (#334)', () => {
|
describe('clips and tracks of a song (#334)', () => {
|
||||||
it('starts a clip at its second, as far into its sound as it says, round and round', async () => {
|
it('starts a clip at its second, as far into its sound as it says, round and round', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
// The sound is 1.5 s long: 2 s in is half a second into its second round.
|
// The sound is 1.5 s long: 2 s in is half a second into its second round.
|
||||||
library.playClip(FILE, { at: 2, offset: 2, duration: 3, volume: 0.5, bus: '' });
|
library.playClip(FILE, { at: 2, offset: 2, duration: 3, volume: 0.5, bus: '' });
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1638,7 +1676,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays a clip turned up past unity as loud as +12 dB, and no louder', async () => {
|
it('plays a clip turned up past unity as loud as +12 dB, and no louder', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 2, bus: '' });
|
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 2, bus: '' });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -1650,7 +1688,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fades a clip out at its end and lets it go, a source only once', async () => {
|
it('fades a clip out at its end and lets it go, a source only once', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' });
|
library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -1667,7 +1705,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts a pitched clip in its worklet as many samples in as the offset is', async () => {
|
it('starts a pitched clip in its worklet as many samples in as the offset is', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan;
|
const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan;
|
||||||
library.playClip(
|
library.playClip(
|
||||||
FILE,
|
FILE,
|
||||||
@@ -1681,7 +1719,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends a song's voices through the bus of their track: its input, level, then its pan, then out", async () => {
|
it("sends a song's voices through the bus of their track: its input, level, then its pan, then out", async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.setBus('song:s:t', 0.5, -0.25);
|
library.setBus('song:s:t', 0.5, -0.25);
|
||||||
library.playNote(
|
library.playNote(
|
||||||
'instrument:i',
|
'instrument:i',
|
||||||
@@ -1717,7 +1755,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('says how loud each stretch of a loaded sound is, and nothing before its bytes are there', async () => {
|
it('says how loud each stretch of a loaded sound is, and nothing before its bytes are there', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
expect(library.peaks(FILE)).toBeNull();
|
expect(library.peaks(FILE)).toBeNull();
|
||||||
void library.load(FILE);
|
void library.load(FILE);
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1728,7 +1766,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays and draws a reversed sound from its end (#321)', async () => {
|
it('plays and draws a reversed sound from its end (#321)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const reversed = `${FILE}#0~0~0~0~linear~linear~reverse`;
|
const reversed = `${FILE}#0~0~0~0~linear~linear~reverse`;
|
||||||
void library.load(FILE);
|
void library.load(FILE);
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1775,7 +1813,7 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('hears each playing through a master of its own, silent until it starts, its tracks into it', async () => {
|
it('hears each playing through a master of its own, silent until it starts, its tracks into it', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const struck: ScheduledNote[] = [];
|
const struck: ScheduledNote[] = [];
|
||||||
const id = library.playSequence(
|
const id = library.playSequence(
|
||||||
'song:s',
|
'song:s',
|
||||||
@@ -1821,7 +1859,7 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.playSequence(
|
library.playSequence(
|
||||||
'pattern:p',
|
'pattern:p',
|
||||||
sequence(() => 77, { length: 10, buses: [] }),
|
sequence(() => 77, { length: 10, buses: [] }),
|
||||||
@@ -1835,14 +1873,14 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a playing without notes or clips is none', () => {
|
it('a playing without notes or clips is none', () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const empty = sequence(() => 1, { sequence: { notes: [], clips: [] } });
|
const empty = sequence(() => 1, { sequence: { notes: [], clips: [] } });
|
||||||
expect(library.playSequence('song:s', empty, '')).toBe(0);
|
expect(library.playSequence('song:s', empty, '')).toBe(0);
|
||||||
expect(library.busLevels()).toEqual([]);
|
expect(library.busLevels()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nothing that reads the clock wakes it while the toolbar holds it', () => {
|
it('nothing that reads the clock wakes it while the toolbar holds it', () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
library.time();
|
library.time();
|
||||||
let resumes = 0;
|
let resumes = 0;
|
||||||
(library as unknown as { context: { resume: () => Promise<void> } }).context.resume = () => {
|
(library as unknown as { context: { resume: () => Promise<void> } }).context.resume = () => {
|
||||||
@@ -1907,7 +1945,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('sends all of a playing through one chain on its master, however many notes it plays', async () => {
|
it('sends all of a playing through one chain on its master, however many notes it plays', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const { id } = playing(library, { fx: voice(crush) });
|
const { id } = playing(library, { fx: voice(crush) });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -1921,7 +1959,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shifts the pitch of a bus a frame late, and starts its notes as much earlier', async () => {
|
it('shifts the pitch of a bus a frame late, and starts its notes as much earlier', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const { id, struck } = playing(library, { fx: voice(up) });
|
const { id, struck } = playing(library, { fx: voice(up) });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -1941,7 +1979,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('holds the other way of a Mix back as far as its Pitch makes one late, so the two stay together', async () => {
|
it('holds the other way of a Mix back as far as its Pitch makes one late, so the two stay together', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const doubled: FxPlan = { mix: { value: 0.5 }, a: up, b: { pitch: [], stages: [] }, stages: [] };
|
const doubled: FxPlan = { mix: { value: 0.5 }, a: up, b: { pitch: [], stages: [] }, stages: [] };
|
||||||
const { id } = playing(library, { fx: voice(doubled) });
|
const { id } = playing(library, { fx: voice(doubled) });
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1950,7 +1988,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts each note as much earlier as the buses it is heard through are late, and no more', async () => {
|
it('starts each note as much earlier as the buses it is heard through are late, and no more', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const { id, struck } = playing(library, {
|
const { id, struck } = playing(library, {
|
||||||
fx: voice(up),
|
fx: voice(up),
|
||||||
buses: [
|
buses: [
|
||||||
@@ -1979,7 +2017,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hands values to the sound FX of a playing by its number, and nothing else by it', async () => {
|
it('hands values to the sound FX of a playing by its number, and nothing else by it', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const loud: FxPlan = {
|
const loud: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
|
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
|
||||||
@@ -1994,7 +2032,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the chains of a playing go after its last voice, once what they hold has been heard', async () => {
|
it('lets the chains of a playing go after its last voice, once what they hold has been heard', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
playing(library, { fx: voice(up) });
|
playing(library, { fx: voice(up) });
|
||||||
await settle();
|
await settle();
|
||||||
await arrive();
|
await arrive();
|
||||||
@@ -2007,8 +2045,28 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('meters a track after its pan, each side apart, and keeps the tap when it goes elsewhere (#359)', async () => {
|
||||||
|
const library = new SoundLibrary(false);
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0 }]);
|
||||||
|
wires.length = 0;
|
||||||
|
expect(library.busPeaks(['song:s:t', 'song:s:gone'])).toEqual(new Map([['song:s:t', [0, 0]]]));
|
||||||
|
expect(wires).toEqual(['splitter > analyser', 'splitter > analyser', 'panner > splitter']);
|
||||||
|
analysers[0]!.sample = -1.5;
|
||||||
|
analysers[1]!.sample = 0.25;
|
||||||
|
expect(library.busPeaks(['song:s:t']).get('song:s:t')).toEqual([1.5, 0.25]);
|
||||||
|
expect(analysers).toHaveLength(2);
|
||||||
|
wires.length = 0;
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, into: 'song:s:u' }]);
|
||||||
|
expect(wires).toEqual(expect.arrayContaining(['panner > splitter']));
|
||||||
|
expect(library.metered()).toBe(1);
|
||||||
|
disconnects.length = 0;
|
||||||
|
library.unmeter();
|
||||||
|
expect(library.metered()).toBe(0);
|
||||||
|
expect(disconnects).toEqual(['panner', 'splitter']);
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
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) };
|
||||||
await library.setBuses([track]);
|
await library.setBuses([track]);
|
||||||
// As the Song tab hands them: a new voice of the same FX every time.
|
// As the Song tab hands them: a new voice of the same FX every time.
|
||||||
@@ -2028,7 +2086,7 @@ describe('a sound FX on a bus (#346)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the buses of the Song tab go after a stop, unless the next play takes them up first', async () => {
|
it('lets the buses of the Song tab go after a stop, unless the next play takes them up first', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||||||
library.releaseBuses(['song:s:t']);
|
library.releaseBuses(['song:s:t']);
|
||||||
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||||||
@@ -2051,7 +2109,7 @@ describe('the sends of the tracks of a song (#350)', () => {
|
|||||||
library.busLevels().find((bus) => bus.name === name)?.sends;
|
library.busLevels().find((bus) => bus.name === name)?.sends;
|
||||||
|
|
||||||
it('taps each send where its mode says: after the pan, after the sound FX, or where it comes in', async () => {
|
it('taps each send where its mode says: after the pan, after the sound FX, or where it comes in', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setBuses([
|
await library.setBuses([
|
||||||
out,
|
out,
|
||||||
{
|
{
|
||||||
@@ -2079,7 +2137,7 @@ describe('the sends of the tracks of a song (#350)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('holds back the ways that pass a Pitch by, so all of a track is heard as late as its latest way', async () => {
|
it('holds back the ways that pass a Pitch by, so all of a track is heard as late as its latest way', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setBuses([
|
await library.setBuses([
|
||||||
out,
|
out,
|
||||||
{
|
{
|
||||||
@@ -2123,7 +2181,7 @@ describe('the sends of the tracks of a song (#350)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('glides the level of a send that only changed its level, and makes the sends anew that go elsewhere', async () => {
|
it('glides the level of a send that only changed its level, and makes the sends anew that go elsewhere', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const track = { name: 'song:s:t', gain: 1, pan: 0 };
|
const track = { name: 'song:s:t', gain: 1, pan: 0 };
|
||||||
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 1, tap: 'post' as const }] }]);
|
await library.setBuses([out, { ...track, sends: [{ to: 'song:s:o', gain: 1, tap: 'post' as const }] }]);
|
||||||
const made = delays.length;
|
const made = delays.length;
|
||||||
@@ -2146,7 +2204,7 @@ describe('the sends of the tracks of a song (#350)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends the tracks of a game’s playing into one another and into its master', () => {
|
it('sends the tracks of a game’s playing into one another and into its master', () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const id = library.playSequence(
|
const id = library.playSequence(
|
||||||
'song:s',
|
'song:s',
|
||||||
{
|
{
|
||||||
@@ -2191,7 +2249,7 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
library.busLevels().map(({ name, gain, into }) => ({ name, gain, into }));
|
library.busLevels().map(({ name, gain, into }) => ({ name, gain, into }));
|
||||||
|
|
||||||
it('makes nothing before there is a context, then the master and every bus into the one it names', async () => {
|
it('makes nothing before there is a context, then the master and every bus into the one it names', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setMix(mix);
|
await library.setMix(mix);
|
||||||
expect(wires).toEqual([]);
|
expect(wires).toEqual([]);
|
||||||
await play(library);
|
await play(library);
|
||||||
@@ -2204,7 +2262,7 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('plays a voice on the bus it is given, and a playing too', async () => {
|
it('plays a voice on the bus it is given, and a playing too', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setMix(mix);
|
await library.setMix(mix);
|
||||||
library.play(FILE, 1, false, '', DRY_VOICE, 'mix:b');
|
library.play(FILE, 1, false, '', DRY_VOICE, 'mix:b');
|
||||||
await settle();
|
await settle();
|
||||||
@@ -2227,7 +2285,7 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends a bus on into another one when the mix says so, and lets one go whose asset is gone', async () => {
|
it('sends a bus on into another one when the mix says so, and lets one go whose asset is gone', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setMix(mix);
|
await library.setMix(mix);
|
||||||
await play(library);
|
await play(library);
|
||||||
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, into: 'mix' }]);
|
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, into: 'mix' }]);
|
||||||
@@ -2238,7 +2296,7 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => {
|
it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await library.setMix(mix);
|
await library.setMix(mix);
|
||||||
await play(library);
|
await play(library);
|
||||||
library.setMixVolume('mix:b', 0.8, 1.5);
|
library.setMixVolume('mix:b', 0.8, 1.5);
|
||||||
@@ -2254,7 +2312,7 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('changes the sound FX of a bus of the mix, and says so only for one that is there', async () => {
|
it('changes the sound FX of a bus of the mix, and says so only for one that is there', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [{ kind: 'crush', bits: { value: 8, param: 'b' }, rate: { value: 8000 }, mix: { value: 1 } }],
|
stages: [{ kind: 'crush', bits: { value: 8, param: 'b' }, rate: { value: 8000 }, mix: { value: 1 } }],
|
||||||
@@ -2350,7 +2408,7 @@ describe('the stages of #318 to #328', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('holds back the other way of a Mix as far as a Compressor makes one late, on a voice and a bus (#326)', async () => {
|
it('holds back the other way of a Mix as far as a Compressor makes one late, on a voice and a bus (#326)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const compress: FxStagePlan = {
|
const compress: FxStagePlan = {
|
||||||
kind: 'compress',
|
kind: 'compress',
|
||||||
threshold: { value: -24 },
|
threshold: { value: -24 },
|
||||||
@@ -2407,7 +2465,7 @@ describe('the stages of #318 to #328', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('starts the LFO of a Vibrato with its voice and delays around its center, the voice that much earlier (#320)', async () => {
|
it('starts the LFO of a Vibrato with its voice and delays around its center, the voice that much earlier (#320)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const vibrato: FxStagePlan = {
|
const vibrato: FxStagePlan = {
|
||||||
kind: 'vibrato',
|
kind: 'vibrato',
|
||||||
shape: 'square',
|
shape: 'square',
|
||||||
@@ -2468,7 +2526,7 @@ describe('the stages of #318 to #328', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads the sound a Reverb takes as its impulse response with the voice, and rings as long (#328)', async () => {
|
it('loads the sound a Reverb takes as its impulse response with the voice, and rings as long (#328)', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -2539,7 +2597,7 @@ describe('Wet (#354)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a wet from a parameter glides both shares while the voice plays', async () => {
|
it('a wet from a parameter glides both shares while the voice plays', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const plan: FxPlan = {
|
const plan: FxPlan = {
|
||||||
pitch: [],
|
pitch: [],
|
||||||
stages: [
|
stages: [
|
||||||
@@ -2563,7 +2621,7 @@ describe('Wet (#354)', () => {
|
|||||||
|
|
||||||
describe('what has ended is let go', () => {
|
describe('what has ended is let go', () => {
|
||||||
it('closes the port of a pitched way once it ends or is stopped, so its voice can be collected', async () => {
|
it('closes the port of a pitched way once it ends or is stopped, so its voice can be collected', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
await play(library, FILE, {}, { pitch: 3, stages: [] });
|
||||||
await play(library, FILE, {}, { pitch: 5, stages: [] });
|
await play(library, FILE, {}, { pitch: 5, stages: [] });
|
||||||
const [ended, stopped] = worklets;
|
const [ended, stopped] = worklets;
|
||||||
@@ -2577,7 +2635,7 @@ describe('what has ended is let go', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('takes a voice without echoes off its bus as soon as its sound has ended', async () => {
|
it('takes a voice without echoes off its bus as soon as its sound has ended', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
await play(library);
|
await play(library);
|
||||||
const before = disconnects.length;
|
const before = disconnects.length;
|
||||||
sources[0]!.onended!();
|
sources[0]!.onended!();
|
||||||
@@ -2586,7 +2644,7 @@ describe('what has ended is let go', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cuts the oldest pitched voices past twelve, one already let go before one still held', async () => {
|
it('cuts the oldest pitched voices past twelve, one already let go before one still held', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary(false);
|
||||||
const oneShot: FxPlan = {
|
const oneShot: FxPlan = {
|
||||||
pitch: [{ value: 3 }],
|
pitch: [{ value: 3 }],
|
||||||
stages: [],
|
stages: [],
|
||||||
|
|||||||
@@ -1203,6 +1203,75 @@ export async function song(t) {
|
|||||||
JSON.stringify({ ownRows, letGo, backAgain }),
|
JSON.stringify({ ownRows, letGo, backAgain }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 65q. Level meters (#359): a sound item and its track each turned up 12 dB clip. The meter of
|
||||||
|
// its track and Out go past 0 dB and light their clip lamps, while the limiter at the output
|
||||||
|
// keeps every sample under -1 dBFS. The lamps stay lit after Stop while the taps go; a click
|
||||||
|
// on a meter puts its lamp out.
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
const hotItem = (await model()).items.find((item) => item.kind === 'sound');
|
||||||
|
const hotRow = (await model()).tracks.findIndex((track) => track.id === hotItem.track);
|
||||||
|
await mutate(`
|
||||||
|
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||||||
|
// Every track up, since 65l sends the first through the second at -6 dB.
|
||||||
|
for (const track of song.tracks) Object.assign(track, { mute: false, solo: false, volume: 12 });
|
||||||
|
song.items.find((i) => i.id === ${JSON.stringify(hotItem.id)}).volume = 12;`);
|
||||||
|
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||||
|
await sleep(200);
|
||||||
|
p = await json(`__gm.song.rulerAt(${hotItem.tick})`);
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await press('Space');
|
||||||
|
const hot = await waitFor(
|
||||||
|
() => json('__gm.song.meters()'),
|
||||||
|
(m) => m.out.clipped && m.tracks[hotItem.track]?.some((side) => side.clipped),
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
const hotTrack = hot.tracks[hotItem.track];
|
||||||
|
check(
|
||||||
|
'a track and Out past 0 dB: their meters rise above 0 dB and light the clip lamp, each track and Out tapped',
|
||||||
|
hot.out.clipped &&
|
||||||
|
hot.out.loudest > 0 &&
|
||||||
|
hotTrack?.some((side) => side.clipped) &&
|
||||||
|
hot.metered === (await model()).tracks.length + 1,
|
||||||
|
JSON.stringify(hot),
|
||||||
|
);
|
||||||
|
// Every frame for a few seconds, so the short blip is not missed between two asks.
|
||||||
|
const guarded = await evaluate(`new Promise((done) => {
|
||||||
|
const until = performance.now() + 6000;
|
||||||
|
let most = 0;
|
||||||
|
const step = () => {
|
||||||
|
const sides = __gm.song.guard();
|
||||||
|
if (sides) most = Math.max(most, ...sides);
|
||||||
|
if (performance.now() < until) requestAnimationFrame(step);
|
||||||
|
else done({ most, loudest: __gm.song.meters().out.loudest });
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
})`);
|
||||||
|
check(
|
||||||
|
'behind the limiter at the output no sample of it passes -1 dBFS',
|
||||||
|
guarded.most > 0.3 && guarded.most <= 10 ** (-1 / 20) + 1e-4 && guarded.loudest > 0,
|
||||||
|
JSON.stringify(guarded),
|
||||||
|
);
|
||||||
|
await shot('03-meters');
|
||||||
|
await press('Space');
|
||||||
|
await sleep(150);
|
||||||
|
const after = await json('__gm.song.meters()');
|
||||||
|
p = await json(`__gm.song.meterAt(${hotRow})`);
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await click(...Object.values(await json('__gm.song.outAt()')));
|
||||||
|
await sleep(100);
|
||||||
|
const cleared = await json('__gm.song.meters()');
|
||||||
|
check(
|
||||||
|
'Stop lets the taps go and the bars fall, the clip lamps stay lit until a click on the meter',
|
||||||
|
after.metered === 0 &&
|
||||||
|
after.out.clipped &&
|
||||||
|
after.tracks[hotItem.track].some((side) => side.clipped) &&
|
||||||
|
after.tracks[hotItem.track].every((side) => side.level === null) &&
|
||||||
|
!cleared.out.clipped &&
|
||||||
|
cleared.out.loudest === null &&
|
||||||
|
cleared.tracks[hotItem.track].every((side) => !side.clipped),
|
||||||
|
JSON.stringify({ after, cleared }),
|
||||||
|
);
|
||||||
|
|
||||||
// 65j. The step takes back everything it made.
|
// 65j. The step takes back everything it made.
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
Reference in new issue
Block a user