Share the parts the sound devices and timelines repeated (#85)
feuerwerk / gate (push) Successful in 5m5s
feuerwerk / suite (push) Failing after 18m39s

Round 12 of the clean-up. Duplicate code had grown from 33 clones
(249 lines) after round 11 to 140 clones (1263 lines), almost all of
it in the panels and faces of the sound FX devices (#379 to #401).
It is down to 57 clones (416 lines) now, and src is 816 lines
shorter.

- deviceParts.ts holds what the device panels built by hand: the
  head, rows of knobs, the screen style and the row layout for
  groups of knobs. Twelve panels use it. panel.knobs stays because
  the debug hooks read it.
- DeviceFace is the base of the twelve faces. It finds the node and
  does wired, preview, commit, set, hide, reload and audition.
  compressorWired lives there as paramWired.
- TextPool replaces ten copies of "make texts as needed, hide the
  rest" in the wave and video tabs, the rulers, the piano roll and
  the song and composition timelines. clipStrip.ts is the clip label
  strip the wave and video tabs share.
- core/util/bytes.ts has the concat that ogg, matroska and avcColour
  each wrote out.
- Dead code is gone: clear on the video players (never called since
  #402), posterFileOf and videoTimeAt (only tests called them) and
  easeOut (the same as glide(t, 3)). Values only used in their own
  file lose their export.
- Comments over 125 columns are wrapped, and comments that told the
  history ("as before", "used to") say what is, in src and in the
  e2e modules.
- Left alone on purpose: AnimationEditor and CompositionEditor share
  only the play button, and the curve panels are built differently.
- Chip now places its boxes through put, which rounds. They were
  whole numbers already. The three row layouts are byte-identical
  before and after.
- README lists the video and composition assets and the Videos and
  Compositions pages.

Tests:
- Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests.
- e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks.
- TextPool without keeping its texts and framesToRoom without making
  room both go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 20:40:05 +02:00
1 parent df6dbe97c5
commit 0701fe3856
94 files changed
+1602 -2380

No files matched your search

+3 -1
View File
@@ -19,7 +19,7 @@ way in.
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct, - An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct,
collider, rigidbody, joint, container, clip area, texture, vector art, pixel art, palette, collider, rigidbody, joint, container, clip area, texture, vector art, pixel art, palette,
animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, sound FX, animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, sound FX,
instrument, pattern, song, bus, particles) with properties and no position. instrument, pattern, song, bus, video, composition, particles) with properties and no position.
- A **scene object** places an asset: position, sparse **overrides** of its properties, an optional - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional
parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑.
- A **container** is a reusable group edited once and placed many times; its content is resolved - A **container** is a reusable group edited once and placed many times; its content is resolved
@@ -79,12 +79,14 @@ CI. Details, the GPU requirement and the detached runner are in
| [Instruments](https://git.nockal.com/meleki/feuerwerk/wiki/Instruments) | Instruments as node graphs, Chips and presets, the keys of the Instrument tab, `Play note` | | [Instruments](https://git.nockal.com/meleki/feuerwerk/wiki/Instruments) | Instruments as node graphs, Chips and presets, the keys of the Instrument tab, `Play note` |
| [Patterns](https://git.nockal.com/meleki/feuerwerk/wiki/Patterns) | Patterns and the piano roll, playing one in a loop or from a game | | [Patterns](https://git.nockal.com/meleki/feuerwerk/wiki/Patterns) | Patterns and the piano roll, playing one in a loop or from a game |
| [Songs](https://git.nockal.com/meleki/feuerwerk/wiki/Songs) | Songs, their tracks and items, routing, recording, playing one from a game | | [Songs](https://git.nockal.com/meleki/feuerwerk/wiki/Songs) | Songs, their tracks and items, routing, recording, playing one from a game |
| [Videos](https://git.nockal.com/meleki/feuerwerk/wiki/Videos) | Video assets and clips, wearing one on a sprite, recording the game view or the editor |
| [Animations](https://git.nockal.com/meleki/feuerwerk/wiki/Animations) | Animation assets and the Animation tab | | [Animations](https://git.nockal.com/meleki/feuerwerk/wiki/Animations) | Animation assets and the Animation tab |
| [Curves](https://git.nockal.com/meleki/feuerwerk/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` | | [Curves](https://git.nockal.com/meleki/feuerwerk/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` |
| [Shaders](https://git.nockal.com/meleki/feuerwerk/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions | | [Shaders](https://git.nockal.com/meleki/feuerwerk/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions |
| [Particles](https://git.nockal.com/meleki/feuerwerk/wiki/Particles) | Particle graphs: a particle as a function of its age | | [Particles](https://git.nockal.com/meleki/feuerwerk/wiki/Particles) | Particle graphs: a particle as a function of its age |
| [State machines](https://git.nockal.com/meleki/feuerwerk/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` | | [State machines](https://git.nockal.com/meleki/feuerwerk/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` |
| [Containers](https://git.nockal.com/meleki/feuerwerk/wiki/Containers) | Templates, instances, screens | | [Containers](https://git.nockal.com/meleki/feuerwerk/wiki/Containers) | Templates, instances, screens |
| [Compositions](https://git.nockal.com/meleki/feuerwerk/wiki/Compositions) | Objects, videos and sounds laid out in time, the Composition tab, playing one in a game |
| [Tiles](https://git.nockal.com/meleki/feuerwerk/wiki/Tiles) | The tile asset, plain tiles and the two autotile kinds, the pictures as animation steps, the Tile tab | | [Tiles](https://git.nockal.com/meleki/feuerwerk/wiki/Tiles) | The tile asset, plain tiles and the two autotile kinds, the pictures as animation steps, the Tile tab |
| [Tilemaps](https://git.nockal.com/meleki/feuerwerk/wiki/Tilemaps) | The tilemap asset, painting in the scene view, layers, what it looks like in the game | | [Tilemaps](https://git.nockal.com/meleki/feuerwerk/wiki/Tilemaps) | The tilemap asset, painting in the scene view, layers, what it looks like in the game |
| [Plugins](https://git.nockal.com/meleki/feuerwerk/wiki/Plugins) | Extending the editor: the plugin file, how it is loaded, switching one on and off, API v1 | | [Plugins](https://git.nockal.com/meleki/feuerwerk/wiki/Plugins) | Extending the editor: the plugin file, how it is loaded, switching one on and off, API v1 |
+3 -3
View File
@@ -86,9 +86,9 @@ export function addCompositionItem(
/** /**
* Copied items put back into a composition: their objects where the copied ones stood, the items * Copied items put back into a composition: their objects where the copied ones stood, the items
* timed as `pastedItems` says, the clips that came with them moved by as much – all moved on together * and the clips that came with them as `pastedItemsWithClips` places them. Null when the copy holds
* where one of those clips would lie over a clip already there. Null when the copy holds anything but items – a scene's objects have * anything but items – a scene's objects have no time to land at. `itemIds` are the new items', to
* no time to land at. `itemIds` are the new items', to pick them afterwards. * pick them afterwards.
*/ */
export function pasteCompositionItems( export function pasteCompositionItems(
project: Project, project: Project,
+1 -1
View File
@@ -106,7 +106,7 @@ export interface SpriteChange {
* container becomes a project asset. Otherwise it stays with its composition while that uses it, else * container becomes a project asset. Otherwise it stays with its composition while that uses it, else
* goes to the first composition that does; every other composition using it gets a copy of its own. * goes to the first composition that does; every other composition using it gets a copy of its own.
*/ */
export function settleCompositionSprites(project: Project): SpriteChange { function settleCompositionSprites(project: Project): SpriteChange {
const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() }; const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() };
const sprites = project.assets.filter(isCompositionSprite); const sprites = project.assets.filter(isCompositionSprite);
if (sprites.length === 0) return change; if (sprites.length === 0) return change;
-3
View File
@@ -375,9 +375,6 @@ export const ACID_ROWS = {
glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05), glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05),
} satisfies Record<string, FlatDef<'number'>>; } satisfies Record<string, FlatDef<'number'>>;
/** The numbers an Acid takes as its note plays: the rest it fixes when the note starts. */
export const ACID_MOVING = ['cutoff', 'resonance', 'envMod'] as const;
export const InstrumentAcid: InstrumentNodeDef = { export const InstrumentAcid: InstrumentNodeDef = {
type: INSTRUMENT_ACID, type: INSTRUMENT_ACID,
label: 'Acid', label: 'Acid',
+5 -2
View File
@@ -98,7 +98,10 @@ export function itemSounds(project: Project, comp: CompositionAsset, item: Compo
return item.audio !== undefined || itemVideo(project, comp, item) !== null; return item.audio !== undefined || itemVideo(project, comp, item) !== null;
} }
/** The fades of an item in whole frames as they work: the fade in no longer than the item, the fade out no longer than the rest. */ /**
* The fades of an item in whole frames as they work: the fade in no longer than the item, the fade
* out no longer than the rest.
*/
export interface ItemFades { export interface ItemFades {
fadeIn: number; fadeIn: number;
fadeOut: number; fadeOut: number;
@@ -333,7 +336,7 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram
} }
/** How far ahead of where a composition stands the items about to begin are written out, in seconds. */ /** How far ahead of where a composition stands the items about to begin are written out, in seconds. */
export const AHEAD_SECONDS = 1; const AHEAD_SECONDS = 1;
/** /**
* The items that begin within `seconds` after `frame` – once it loops, those at the start too when * The items that begin within `seconds` after `frame` – once it loops, those at the start too when
+29 -42
View File
@@ -205,23 +205,8 @@ export function movedClips(
} }
/** /**
* Copied items under the ids `ids` gives them, moved so the earliest starts at `at`: on their tracks, * Copied clips placed as `placing` places what is pasted, then moved on frame by frame as long as
* or, given `row`, the topmost on that track and the others as far below as they were, as far as * one would lie over a clip of `clips` or over another of the copy – clips of a track never overlap.
* there are tracks – a song's paste. An item whose track is gone counts as on the first.
*/
export function pastedItems(
items: readonly CompositionItem[],
ids: ReadonlyMap<string, string>,
at: number,
trackIds: readonly string[],
row?: number,
): CompositionItem[] {
return pasted(items, ids, at, trackIds, row);
}
/**
* Copied clips placed as `pastedItems` places items, then moved on frame by frame as long as one
* would lie over a clip of `clips` or over another of the copy – clips of a track never overlap.
*/ */
export function pastedClips( export function pastedClips(
copied: readonly CompositionClip[], copied: readonly CompositionClip[],
@@ -232,19 +217,15 @@ export function pastedClips(
lengthOf: (clip: CompositionClip) => number, lengthOf: (clip: CompositionClip) => number,
row?: number, row?: number,
): CompositionClip[] { ): CompositionClip[] {
const placed = pasted(copied, ids, at, trackIds, row); const place = placing(copied, at, trackIds, row);
if (placed.length === 0) return []; const placed = copied.map((c) => place(c, ids));
// Past the end of every clip there is room, so the search ends there at the latest. const by = framesToRoom(placed, clips, lengthOf);
const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c)))); return placed.map((c) => ({ ...c, start: c.start + by }));
for (let by = 0; ; by++) {
const moved = placed.map((c) => ({ ...c, start: c.start + by }));
if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return moved;
}
} }
/** /**
* Copied items placed as `pastedItems` places them, the copied clips that came with them moved by * Copied items placed as `placing` places them, the copied clips that came with them moved by as
* as much; then both moved on frame by frame as long as one of the clips would lie over a clip of * much; then both moved on frame by frame as long as one of the clips would lie over a clip of
* `clips` – clips of a track never overlap. * `clips` – clips of a track never overlap.
*/ */
export function pastedItemsWithClips( export function pastedItemsWithClips(
@@ -260,26 +241,32 @@ export function pastedItemsWithClips(
const place = placing(items, at, trackIds, row); const place = placing(items, at, trackIds, row);
const placedItems = items.map((i) => place(i, ids)); const placedItems = items.map((i) => place(i, ids));
const placedClips = copied.map((c) => place(c, ids)); const placedClips = copied.map((c) => place(c, ids));
const by = framesToRoom(placedClips, clips, lengthOf);
return {
items: placedItems.map((i) => ({ ...i, start: i.start + by })),
clips: placedClips.map((c) => ({ ...c, start: c.start + by })),
};
}
/** The frames `placed` moves on until none of it lies over a clip of `clips` or over another of it. */
function framesToRoom(
placed: readonly CompositionClip[],
clips: readonly CompositionClip[],
lengthOf: (clip: CompositionClip) => number,
): number {
// Past the end of every clip there is room, so the search ends there at the latest.
const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c)))); const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c))));
for (let by = 0; ; by++) { for (let by = 0; ; by++) {
const moved = placedClips.map((c) => ({ ...c, start: c.start + by })); const moved = placed.map((c) => ({ ...c, start: c.start + by }));
if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return by;
return { items: placedItems.map((i) => ({ ...i, start: i.start + by })), clips: moved };
} }
} }
function pasted<T extends Placed>( /**
items: readonly T[], * How a paste moves what was copied, under the ids `ids` gives it: the earliest of `items` to `at`,
ids: ReadonlyMap<string, string>, * on their tracks, or, given `row`, the topmost on that track and the others as far below as they
at: number, * were, as far as there are tracks – a song's paste. One whose track is gone counts as on the first.
trackIds: readonly string[], */
row?: number,
): T[] {
const place = placing(items, at, trackIds, row);
return items.map((i) => place(i, ids));
}
/** How a paste moves what was copied: the earliest of `items` to `at`, their rows as `pastedItems` says. */
function placing( function placing(
items: readonly Placed[], items: readonly Placed[],
at: number, at: number,
+1 -1
View File
@@ -64,7 +64,7 @@ const UNNAMING: ReadonlySet<AssetType> = new Set(['sound', 'texture', 'pixelart'
* Every text the project holds outside the assets that cannot name a sound. Where a sound is played * Every text the project holds outside the assets that cannot name a sound. Where a sound is played
* whole – a sound field, an item of a song, a Sample, a Reverb's impulse – its id is one of them; a * whole – a sound field, an item of a song, a Sample, a Reverb's impulse – its id is one of them; a
* clip has an id of its own. Looked at as data, not field by field, so no place is missed: one that * clip has an id of its own. Looked at as data, not field by field, so no place is missed: one that
* only looks like a reference loads the file as before. * only looks like a reference just loads a file nothing plays.
*/ */
function namedStrings(project: Project): Set<string> { function namedStrings(project: Project): Set<string> {
const seen = new Set<string>(); const seen = new Set<string>();
-14
View File
@@ -17,11 +17,6 @@ export function isVideoFile(name: string): boolean {
return FILE_PATTERN.test(name); return FILE_PATTERN.test(name);
} }
/** The still the worker cut from a video (#402), lying beside it. */
export function posterFileOf(file: string): string {
return file.replace(/\.mp4$/, '.jpg');
}
/** What a texture field names when it names a video: the whole video, or one of its clips. */ /** What a texture field names when it names a video: the whole video, or one of its clips. */
export interface VideoRef { export interface VideoRef {
asset: VideoAsset; asset: VideoAsset;
@@ -193,12 +188,3 @@ export function drivenTimeOf(
if (play.loop) return play.start + (run % length); if (play.loop) return play.start + (run % length);
return play.start + Math.min(run, Math.max(0, length - LAST_PICTURE)); return play.start + Math.min(run, Math.max(0, length - LAST_PICTURE));
} }
/** Where in its file a player is after `elapsed` seconds of playing (#402). */
export function videoTimeAt(play: Pick<VideoPlay, 'start' | 'end' | 'loop'>, elapsed: number): number {
const length = play.end - play.start;
const run = Math.max(0, elapsed);
if (!Number.isFinite(length) || length <= 0) return play.start + run;
if (!play.loop) return play.start + Math.min(run, length);
return play.start + (run % length);
}
+5 -14
View File
@@ -4,6 +4,7 @@
* (`preSkip`) and the last page how many there are, so a decoder gives back exactly what went in, * (`preSkip`) and the last page how many there are, so a decoder gives back exactly what went in,
* to the sample. * to the sample.
*/ */
import { concatBytes } from '../util/bytes';
/** Opus always counts at 48 kHz, whatever the rate of what went in. */ /** Opus always counts at 48 kHz, whatever the rate of what went in. */
export const OPUS_RATE = 48000; export const OPUS_RATE = 48000;
@@ -101,7 +102,7 @@ export function oggOpus(stream: OpusStream): Uint8Array {
if (i === stream.packets.length - 1) page(held, end, 0x04); if (i === stream.packets.length - 1) page(held, end, 0x04);
}); });
if (stream.packets.length === 0) page([], end, 0x04); if (stream.packets.length === 0) page([], end, 0x04);
return concat(pages); return concatBytes(pages);
} }
function oggPage( function oggPage(
@@ -115,7 +116,7 @@ function oggPage(
for (let left = packet.length; left >= 255; left -= 255) lacing.push(255); for (let left = packet.length; left >= 255; left -= 255) lacing.push(255);
lacing.push(packet.length % 255); lacing.push(packet.length % 255);
} }
const body = concat(packets); const body = concatBytes(packets);
const page = new Uint8Array(27 + lacing.length + body.length); const page = new Uint8Array(27 + lacing.length + body.length);
const view = new DataView(page.buffer); const view = new DataView(page.buffer);
page.set(ascii('OggS')); page.set(ascii('OggS'));
@@ -179,7 +180,7 @@ export class OggOpusReader {
private next = 0; private next = 0;
push(bytes: Uint8Array): void { push(bytes: Uint8Array): void {
let data = this.held.length > 0 ? concat([this.held, bytes]) : bytes; let data = this.held.length > 0 ? concatBytes([this.held, bytes]) : bytes;
let at = 0; let at = 0;
while (data.length - at >= 27) { while (data.length - at >= 27) {
if (text(data.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`); if (text(data.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
@@ -209,7 +210,7 @@ export class OggOpusReader {
this.open.push(page.subarray(from, from + value)); this.open.push(page.subarray(from, from + value));
from += value; from += value;
if (value < 255) { if (value < 255) {
this.packet(concat(this.open)); this.packet(concatBytes(this.open));
this.open = []; this.open = [];
} }
} }
@@ -254,13 +255,3 @@ function ascii(value: string): Uint8Array {
function text(bytes: Uint8Array): string { function text(bytes: Uint8Array): string {
return String.fromCharCode(...bytes); return String.fromCharCode(...bytes);
} }
function concat(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+3 -3
View File
@@ -1,8 +1,8 @@
/** /**
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song, * The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song,
* video with sound, composition and bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one * video with sound, composition and bus, and the master, its links the sends from one to the next.
* that comes gets a node of its own and the links most others of its type have, one that goes takes * Its nodes follow the assets: one that comes gets a node of its own and the links most others of
* its node along. * its type have, one that goes takes its node along.
*/ */
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { newId } from '../ids'; import { newId } from '../ids';
+2 -2
View File
@@ -2,8 +2,8 @@
* Sounds streamed rather than decoded whole (#349). A sound with `stream` on is played from a copy as * Sounds streamed rather than decoded whole (#349). A sound with `stream` on is played from a copy as
* Opus in Ogg, made in the browser. Every clip of every sound has a copy of its own cut, so no clip * Opus in Ogg, made in the browser. Every clip of every sound has a copy of its own cut, so no clip
* needs the whole file: streamed where its sound streams, decoded where it does not. A bake (#340) is * needs the whole file: streamed where its sound streams, decoded where it does not. A bake (#340) is
* Opus already and always streams. What a game plays is still named as before – the file, or the cut * Opus already and always streams. A game names what it plays by the file, or by the cut a clip is,
* a clip is – and the tables here say which of those names to play from which copy. * and the tables here say which of those names to play from which copy.
*/ */
import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset'; import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
+4 -3
View File
@@ -140,9 +140,10 @@ import { Theme } from '../../ui';
* produces fractions, and the field rounds the value away rather than hiding it. Without * produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick, * `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick,
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means * `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
* "from the asset" (#352); `uploadedOnly` keeps drawn pictures out * "from the asset" (#352); `uploadedOnly` keeps drawn pictures out of a texture picker (#186),
* of a texture picker (#186), `moving` lets videos and their clips in (#402). `renderLayers` makes a layer row pick from the project's render layers * `moving` lets videos and their clips in (#402). `renderLayers` makes a layer row pick from the
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer. * project's render layers instead of its collision layers (#302), with "Inherit" as the first
* choice of a single layer.
*/ */
type Extras<K extends FlatKind> = K extends 'number' type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number } ? { min?: number; max?: number; step?: number; decimals?: number }
+1 -1
View File
@@ -52,7 +52,7 @@ export function textSize(props: PropertyBag, font = ''): { w: number; h: number
} }
/** Estimated from the content and font size, without measuring the text. */ /** Estimated from the content and font size, without measuring the text. */
export function estimatedTextSize(props: PropertyBag): { w: number; h: number } { function estimatedTextSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
+5 -2
View File
@@ -22,7 +22,10 @@ export const VIDEO_ROWS: readonly PropertyDef[] = [
*/ */
export const VIDEO_CLOCK_KEY = 'videoClock'; export const VIDEO_CLOCK_KEY = 'videoClock';
export const VIDEO_TIME_KEY = 'videoTime'; export const VIDEO_TIME_KEY = 'videoTime';
/** How loud a driven video is heard through this sprite, 0–1, on top of the video's own Volume; a composition's fades set it. */ /**
* How loud a driven video is heard through this sprite, 0–1, on top of the video's own Volume; a
* composition's fades set it.
*/
export const VIDEO_LEVEL_KEY = 'videoVolume'; export const VIDEO_LEVEL_KEY = 'videoVolume';
/** /**
* The bus of the mix the video's sound goes into through this sprite: empty along the routing of the * The bus of the mix the video's sound goes into through this sprite: empty along the routing of the
@@ -30,7 +33,7 @@ export const VIDEO_LEVEL_KEY = 'videoVolume';
*/ */
export const VIDEO_BUS_KEY = 'videoBus'; export const VIDEO_BUS_KEY = 'videoBus';
export const VIDEO_CLOCK_OPTIONS = [ const VIDEO_CLOCK_OPTIONS = [
{ value: 'free', label: 'Free' }, { value: 'free', label: 'Free' },
{ value: 'driven', label: 'Driven' }, { value: 'driven', label: 'Driven' },
]; ];
+3 -12
View File
@@ -8,6 +8,7 @@
* Only the SPS changes; the frames stay as they were. With `avc: {format: 'avc'}` the SPS is in the * Only the SPS changes; the frames stay as they were. With `avc: {format: 'avc'}` the SPS is in the
* encoder's `description` (the `avcC`) alone, so that is all there is to rewrite. * encoder's `description` (the `avcC`) alone, so that is all there is to rewrite.
*/ */
import { concatBytes } from '../util/bytes';
import type { MatroskaColour } from './matroska'; import type { MatroskaColour } from './matroska';
/** What `video_format` says when the SPS had none: "unspecified". */ /** What `video_format` says when the SPS had none: "unspecified". */
@@ -34,7 +35,7 @@ export function avcConfigWithColour(config: Uint8Array, colour: MatroskaColour):
at += 2 + length; at += 2 + length;
} }
parts.push(config.subarray(at)); parts.push(config.subarray(at));
return concat(parts); return concatBytes(parts);
} }
/** The SPS of an `avcC`, as NAL units with their header byte. */ /** The SPS of an `avcC`, as NAL units with their header byte. */
@@ -92,7 +93,7 @@ export function spsWithColour(nal: Uint8Array, colour: MatroskaColour): Uint8Arr
// No chroma location, timing, HRD, picture structure or bitstream restriction. // No chroma location, timing, HRD, picture structure or bitstream restriction.
else out.bits(0, 6); else out.bits(0, 6);
out.bit(1); out.bit(1);
return concat([nal.subarray(0, 1), escape(out.finish())]); return concatBytes([nal.subarray(0, 1), escape(out.finish())]);
} }
/** What the `video_signal_type` of an SPS says; null where it has none. */ /** What the `video_signal_type` of an SPS says; null where it has none. */
@@ -267,13 +268,3 @@ class BitWriter {
return Uint8Array.from(this.out); return Uint8Array.from(this.out);
} }
} }
function concat(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export const EDITOR_RECORDING_RESOLUTIONS: readonly {
{ value: '4k', label: '4K', box: { w: 3840, h: 2160 } }, { value: '4k', label: '4K', box: { w: 3840, h: 2160 } },
]; ];
export const EDITOR_RECORDING_DEFAULTS: EditorRecordingSettings = { const EDITOR_RECORDING_DEFAULTS: EditorRecordingSettings = {
name: '', name: '',
resolution: 'screen', resolution: 'screen',
fps: 60, fps: 60,
+6 -15
View File
@@ -5,10 +5,11 @@
* length – so the clusters can leave the writer long before. * length – so the clusters can leave the writer long before.
*/ */
import { OPUS_RATE } from '../music/ogg'; import { OPUS_RATE } from '../music/ogg';
import { concatBytes } from '../util/bytes';
export const AVC_CODEC = 'V_MPEG4/ISO/AVC'; export const AVC_CODEC = 'V_MPEG4/ISO/AVC';
export const VP9_CODEC = 'V_VP9'; export const VP9_CODEC = 'V_VP9';
export const OPUS_CODEC = 'A_OPUS'; const OPUS_CODEC = 'A_OPUS';
/** Milliseconds per tick of a block's time: the usual 1 ms, so a cluster spans 32 s at most. */ /** Milliseconds per tick of a block's time: the usual 1 ms, so a cluster spans 32 s at most. */
const TIMESTAMP_SCALE = 1_000_000; const TIMESTAMP_SCALE = 1_000_000;
@@ -225,7 +226,7 @@ export class MatroskaWriter {
header[0] = 0x80 | block.track; header[0] = 0x80 | block.track;
new DataView(header.buffer).setInt16(1, block.ms - cluster.ms); new DataView(header.buffer).setInt16(1, block.ms - cluster.ms);
header[3] = block.key ? 0x80 : 0; header[3] = block.key ? 0x80 : 0;
const element = concat([ const element = concatBytes([
idBytes(ID.simpleBlock), idBytes(ID.simpleBlock),
sizeBytes(header.length + block.data.length), sizeBytes(header.length + block.data.length),
header, header,
@@ -301,7 +302,7 @@ function headOf(head: MatroskaHead, clusterBytes: number): Uint8Array {
); );
const tracks = master(ID.tracks, entries); const tracks = master(ID.tracks, entries);
const segmentSize = info.length + tracks.length + clusterBytes; const segmentSize = info.length + tracks.length + clusterBytes;
return concat([ebml, idBytes(ID.segment), sizeBytes(segmentSize, 8), info, tracks]); return concatBytes([ebml, idBytes(ID.segment), sizeBytes(segmentSize, 8), info, tracks]);
} }
/** An element id as it is written: its own marker bits are part of the number. */ /** An element id as it is written: its own marker bits are part of the number. */
@@ -332,11 +333,11 @@ function sizeLength(size: number): number {
} }
function element(id: number, data: Uint8Array): Uint8Array { function element(id: number, data: Uint8Array): Uint8Array {
return concat([idBytes(id), sizeBytes(data.length), data]); return concatBytes([idBytes(id), sizeBytes(data.length), data]);
} }
function master(id: number, children: readonly Uint8Array[]): Uint8Array { function master(id: number, children: readonly Uint8Array[]): Uint8Array {
return element(id, concat(children)); return element(id, concatBytes(children));
} }
function uint(id: number, value: number): Uint8Array { function uint(id: number, value: number): Uint8Array {
@@ -362,13 +363,3 @@ function text(id: number, value: string): Uint8Array {
function binary(id: number, value: Uint8Array): Uint8Array { function binary(id: number, value: Uint8Array): Uint8Array {
return element(id, value); return element(id, value);
} }
function concat(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+1 -1
View File
@@ -290,7 +290,7 @@ const PACE_SETTLE_MS = 1000;
/** How much of its time a recording is judged over at once. */ /** How much of its time a recording is judged over at once. */
const PACE_SPAN_MS = 2000; const PACE_SPAN_MS = 2000;
/** The share of frames a recording may miss over a span before it is said to fall behind. */ /** The share of frames a recording may miss over a span before it is said to fall behind. */
export const PACE_MOST_MISSED_WHILE = 0.1; const PACE_MOST_MISSED_WHILE = 0.1;
/** The share of frames a whole recording may miss, once settled, before its end says so. */ /** The share of frames a whole recording may miss, once settled, before its end says so. */
const PACE_MOST_MISSED = 0.05; const PACE_MOST_MISSED = 0.05;
+6 -3
View File
@@ -45,8 +45,8 @@ export interface AcidKnobs {
const LEAST_Q_DB = 0.5; const LEAST_Q_DB = 0.5;
const MOST_Q_DB = 17.5; const MOST_Q_DB = 17.5;
/** Octaves the envelope opens the filter by at full env mod, and the highest it opens it to. */ /** Octaves the envelope opens the filter by at full env mod, and the highest it opens it to. */
export const ENV_MOD_OCTAVES = 5; const ENV_MOD_OCTAVES = 5;
export const TOP_HZ = 18000; const TOP_HZ = 18000;
/** How much of the sweep the sustain holds at full: at sustain 1 the cutoff stays 60 % of the way up. */ /** How much of the sweep the sustain holds at full: at sustain 1 the cutoff stays 60 % of the way up. */
export const SUSTAIN_SWEEP = 0.6; export const SUSTAIN_SWEEP = 0.6;
/** /**
@@ -216,7 +216,10 @@ function blep(p: number, step: number): number {
return 0; return 0;
} }
/** How loud the low-pass passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB: what a device draws. */ /**
* How loud the low-pass passes `hz` with its cutoff at `fc` and its resonance at `resonance`, in dB:
* what a device draws.
*/
export function acidResponseDb(hz: number, fc: number, resonance: number): number { export function acidResponseDb(hz: number, fc: number, resonance: number): number {
const w = hz / Math.max(LOWEST_HZ, fc); const w = hz / Math.max(LOWEST_HZ, fc);
const q = acidQ(resonance); const q = acidQ(resonance);
+10
View File
@@ -0,0 +1,10 @@
/** The parts one after another, in one array. */
export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+9 -15
View File
@@ -40,18 +40,19 @@ import { findAssetOf } from '../../core/model/Project';
import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video'; import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video';
import { VIDEO_TIME_KEY } from '../../core/properties/videos'; import { VIDEO_TIME_KEY } from '../../core/properties/videos';
import { import {
crispText,
drawBand, drawBand,
Menu, Menu,
type MenuEntry, type MenuEntry,
pressCount, pressCount,
type Size, type Size,
Theme, TextPool,
textStyle, textStyle,
Theme,
truncate, truncate,
ui, ui,
Widget, Widget,
zoomFactor, zoomFactor,
crispText,
} from '../../ui'; } from '../../ui';
import { createField, type Field } from '../inspector/fieldFactory'; import { createField, type Field } from '../inspector/fieldFactory';
import { type EditorState, trackSelection } from '../state'; import { type EditorState, trackSelection } from '../state';
@@ -145,7 +146,7 @@ export class TimelineCanvas extends Widget {
private readonly labels = new Map<string, Text>(); private readonly labels = new Map<string, Text>();
private readonly fields = new Map<string, ValueField>(); private readonly fields = new Map<string, ValueField>();
private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null; private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null;
private readonly rulerTexts: Text[] = []; private readonly rulerTexts = new TextPool(this.rulerLayer);
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
/** Right of the header column. */ /** Right of the header column. */
private readonly clip = new Graphics(); private readonly clip = new Graphics();
@@ -895,25 +896,18 @@ export class TimelineCanvas extends Widget {
const x1 = HEADER_WIDTH + frameToX(this.view, count); const x1 = HEADER_WIDTH + frameToX(this.view, count);
this.ruler.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 }); this.ruler.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 });
} }
let used = 0; const marks: { frame: number; x: number }[] = [];
const first = frameAt(this.view, 0); const first = frameAt(this.view, 0);
for (let frame = Math.floor(first / step) * step; ; frame += step) { for (let frame = Math.floor(first / step) * step; ; frame += step) {
const x = HEADER_WIDTH + frameToX(this.view, frame); const x = HEADER_WIDTH + frameToX(this.view, frame);
if (x > this.w) break; if (x > this.w) break;
if (x < HEADER_WIDTH) continue; if (x < HEADER_WIDTH) continue;
this.ruler.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted); this.ruler.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted);
let text = this.rulerTexts[used]; marks.push({ frame, x });
if (!text) {
text = crispText({ style: textStyle('small') });
this.rulerTexts.push(text);
this.rulerLayer.addChild(text);
}
text.visible = true;
if (text.text !== String(frame)) text.text = String(frame);
text.position.set(x + 3, 3);
used += 1;
} }
for (let i = used; i < this.rulerTexts.length; i++) this.rulerTexts[i]!.visible = false; this.rulerTexts
.show(marks.map((m) => String(m.frame)))
.forEach((text, i) => text.position.set(marks[i]!.x + 3, 3));
} }
private drawKeys(lanes: readonly Lane[]): void { private drawKeys(lanes: readonly Lane[]): void {
+1 -1
View File
@@ -64,7 +64,7 @@ export function copyObjectIds(state: EditorState, holderId: string, ids: readonl
* Copies animation clips or items of a composition: with an item that is an object among them as * Copies animation clips or items of a composition: with an item that is an object among them as
* objects, the audio items (#418) going along; false when none of them is there. * objects, the audio items (#418) going along; false when none of them is there.
*/ */
export function copyCompositionClips( function copyCompositionClips(
state: EditorState, state: EditorState,
assetId: string, assetId: string,
ids: readonly string[], ids: readonly string[],
+13 -23
View File
@@ -3,7 +3,6 @@ import {
type FederatedPointerEvent, type FederatedPointerEvent,
type FederatedWheelEvent, type FederatedWheelEvent,
Graphics, Graphics,
type Text,
type TextStyle, type TextStyle,
} from 'pixi.js'; } from 'pixi.js';
import { import {
@@ -72,6 +71,7 @@ import {
Menu, Menu,
onDragChange, onDragChange,
type Size, type Size,
TextPool,
textStyle, textStyle,
Theme, Theme,
truncate, truncate,
@@ -191,10 +191,10 @@ export class CompositionTimeline extends Widget {
private readonly clipLayer = new Container(); private readonly clipLayer = new Container();
private readonly headLayer = new Container(); private readonly headLayer = new Container();
private readonly rulerLayer = new Container(); private readonly rulerLayer = new Container();
private readonly itemTexts: Text[] = []; private readonly itemTexts = new TextPool(this.itemLayer);
private readonly clipTexts: Text[] = []; private readonly clipTexts = new TextPool(this.clipLayer, CLIP_LABEL);
private readonly headTexts: Text[] = []; private readonly headTexts = new TextPool(this.headLayer, textStyle('body'));
private readonly rulerTexts: Text[] = []; private readonly rulerTexts = new TextPool(this.rulerLayer);
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
private view: CompositionView = { ...DEFAULT_VIEW }; private view: CompositionView = { ...DEFAULT_VIEW };
private readonly timeTween = axisTween( private readonly timeTween = axisTween(
@@ -1226,10 +1226,10 @@ export class CompositionTimeline extends Widget {
const local = (p: Vec2) => ({ x: p.x + HEAD_WIDTH, y: p.y + RULER_HEIGHT }); const local = (p: Vec2) => ({ x: p.x + HEAD_WIDTH, y: p.y + RULER_HEIGHT });
if (g?.kind === 'band' && g.active) drawBand(this.over, local(g.start), local(g.current)); if (g?.kind === 'band' && g.active) drawBand(this.over, local(g.start), local(g.current));
} }
syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small')); syncTexts(this.itemTexts, items, textStyle('small'));
syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL); syncTexts(this.clipTexts, clips, CLIP_LABEL);
syncTexts(this.headTexts, this.headLayer, heads, textStyle('body')); syncTexts(this.headTexts, heads, textStyle('body'));
syncTexts(this.rulerTexts, this.rulerLayer, ruler, textStyle('small')); syncTexts(this.rulerTexts, ruler, textStyle('small'));
const hint = !asset const hint = !asset
? '' ? ''
: asset.items.length === 0 && asset.clips.length === 0 : asset.items.length === 0 && asset.clips.length === 0
@@ -1596,18 +1596,8 @@ function audioOf(kind: CompositionAudioKind, asset: string): NonNullable<Composi
} }
/** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */ /** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */
function syncTexts(pool: Text[], layer: Container, wanted: readonly Placed[], style: TextStyle): void { function syncTexts(pool: TextPool, wanted: readonly Placed[], style: TextStyle): void {
wanted.forEach((place, i) => { pool
let text = pool[i]; .show(wanted.map((place) => truncate(place.text, style, place.w)))
if (!text) { .forEach((text, i) => text.position.set(wanted[i]!.x, wanted[i]!.y));
text = crispText({ style });
pool.push(text);
layer.addChild(text);
}
const shown = truncate(place.text, text.style, place.w);
if (text.text !== shown) text.text = shown;
text.position.set(place.x, place.y);
text.visible = true;
});
for (let i = wanted.length; i < pool.length; i++) pool[i]!.visible = false;
} }
+2 -2
View File
@@ -366,8 +366,8 @@ export function graphHooks({ state, editor }: HookContext) {
]), ]),
), ),
idle: [...panel.idle], idle: [...panel.idle],
wave: panel.waveButtons.find((b) => b.picked)?.wave ?? null, wave: panel.waveButtons.find((b) => b.picked)?.value ?? null,
waves: Object.fromEntries(panel.waveButtons.map((b) => [b.wave, screenCentre(b)])), waves: Object.fromEntries(panel.waveButtons.map((b) => [b.value, screenCentre(b)])),
follow: { on: panel.follow.lit, at: screenCentre(panel.follow) }, follow: { on: panel.follow.lit, at: screenCentre(panel.follow) },
waveScreen: panel.waveScreen.says.text, waveScreen: panel.waveScreen.says.text,
pitch: trace && { pitch: trace && {
+5 -2
View File
@@ -102,7 +102,10 @@ export function compositionSignature(
return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track'; return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track';
} }
/** A track: its name; the timeline's menu adds, moves and deletes tracks. Clips: what they play and from when; the timeline's menu removes them. */ /**
* A track: its name; the timeline's menu adds, moves and deletes tracks. Clips: what they play and
* from when; the timeline's menu removes them.
*/
export function buildCompositionSection( export function buildCompositionSection(
host: InspectorHost, host: InspectorHost,
assetId: string, assetId: string,
@@ -185,7 +188,7 @@ export function refreshCompositionSection(
} }
/** The item an object of a composition stands for; undefined for any other object. */ /** The item an object of a composition stands for; undefined for any other object. */
export function compositionItemOf( function compositionItemOf(
project: Project, project: Project,
holderId: string, holderId: string,
objectId: string, objectId: string,
+8 -15
View File
@@ -1,7 +1,7 @@
import { Container, Graphics, type Text } from 'pixi.js'; import { Container, Graphics } from 'pixi.js';
import type { LoopRange } from '../../core/music/sequence'; import type { LoopRange } from '../../core/music/sequence';
import { type Meter, ticksPerBar } from '../../core/music/time'; import { type Meter, ticksPerBar } from '../../core/music/time';
import { crispText, textStyle, Theme } from '../../ui'; import { TextPool, Theme } from '../../ui';
import { barLabelStep, LOOP_STRIP, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView'; import { barLabelStep, LOOP_STRIP, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView';
export interface RulerMarks { export interface RulerMarks {
@@ -22,7 +22,7 @@ export interface RulerMarks {
export class TimeRuler extends Container { export class TimeRuler extends Container {
private readonly g = new Graphics(); private readonly g = new Graphics();
private readonly labels = new Container(); private readonly labels = new Container();
private readonly texts: Text[] = []; private readonly texts = new TextPool(this.labels);
constructor() { constructor() {
super(); super();
@@ -52,7 +52,7 @@ export class TimeRuler extends Container {
const bar = ticksPerBar(meter); const bar = ticksPerBar(meter);
const every = barLabelStep(view, meter); const every = barLabelStep(view, meter);
let used = 0; const numbers: { text: string; x: number }[] = [];
// The grid is no business of the ruler: bars and beats only. // The grid is no business of the ruler: bars and beats only.
for (const line of timeLines(view, meter, Number.MAX_SAFE_INTEGER, width)) { for (const line of timeLines(view, meter, Number.MAX_SAFE_INTEGER, width)) {
if (line.x < 0) continue; if (line.x < 0) continue;
@@ -63,18 +63,11 @@ export class TimeRuler extends Container {
}); });
const number = line.tick / bar; const number = line.tick / bar;
if (!tall || number % every !== 0) continue; if (!tall || number % every !== 0) continue;
let text = this.texts[used]; numbers.push({ text: String(number + 1), x: line.x + 3 });
if (!text) {
text = crispText({ style: textStyle('small') });
this.texts.push(text);
this.labels.addChild(text);
}
text.visible = true;
if (text.text !== String(number + 1)) text.text = String(number + 1);
text.position.set(line.x + 3, LOOP_STRIP + 2);
used += 1;
} }
for (let i = used; i < this.texts.length; i++) this.texts[i]!.visible = false; this.texts
.show(numbers.map((n) => n.text))
.forEach((text, i) => text.position.set(numbers[i]!.x, LOOP_STRIP + 2));
const start = tickToX(view, marks.start); const start = tickToX(view, marks.start);
if (start >= -5 && start <= width + 5) if (start >= -5 && start <= width + 5)
+5 -19
View File
@@ -1,10 +1,4 @@
import { import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
type Text,
} from 'pixi.js';
import { SetLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands'; import { SetLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands';
import type { GhostNote, Ghosts } from '../../core/music/ghosts'; import type { GhostNote, Ghosts } from '../../core/music/ghosts';
import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes';
@@ -26,6 +20,7 @@ import {
drawBand, drawBand,
Menu, Menu,
type Size, type Size,
TextPool,
textStyle, textStyle,
Theme, Theme,
tooltips, tooltips,
@@ -164,7 +159,7 @@ export class PianoRoll extends Widget {
private readonly keysLayer = new Container(); private readonly keysLayer = new Container();
private readonly keysG = new Graphics(); private readonly keysG = new Graphics();
private readonly keyLabels = new Container(); private readonly keyLabels = new Container();
private readonly keyTexts: Text[] = []; private readonly keyTexts = new TextPool(this.keyLabels, DARK_LABEL);
private readonly ruler = new TimeRuler(); private readonly ruler = new TimeRuler();
private readonly laneLayer = new Container(); private readonly laneLayer = new Container();
private readonly laneG = new Graphics(); private readonly laneG = new Graphics();
@@ -1188,19 +1183,10 @@ export class PianoRoll extends Widget {
private syncKeyLabels(rows: readonly { note: number; y: number }[]): void { private syncKeyLabels(rows: readonly { note: number; y: number }[]): void {
const rh = this.view.rowHeight; const rh = this.view.rowHeight;
rows.forEach(({ note, y }, i) => { this.keyTexts.show(rows.map(({ note }) => this.keyLabel(note))).forEach((text, i) => {
let text = this.keyTexts[i]; const { note, y } = rows[i]!;
if (!text) {
text = crispText({ style: DARK_LABEL });
this.keyTexts.push(text);
this.keyLabels.addChild(text);
}
const label = this.keyLabel(note);
if (text.text !== label) text.text = label;
text.style = isSharp(note) ? textStyle('small') : DARK_LABEL; text.style = isSharp(note) ? textStyle('small') : DARK_LABEL;
text.visible = true;
text.position.set(4, y + (rh - text.height) / 2); text.position.set(4, y + (rh - text.height) / 2);
}); });
for (let i = rows.length; i < this.keyTexts.length; i++) this.keyTexts[i]!.visible = false;
} }
} }
+3 -2
View File
@@ -10,8 +10,9 @@ const INFO_WIDTH = 220;
/** /**
* The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song, video * The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song, video
* with sound, composition and bus and the master, the links between them where each one goes. Edited as the routing of a song is * with sound, composition and bus and the master, the links between them where each one goes.
* (#350); a click on a node shows its asset in the inspector, a double click opens it. * Edited as the routing of a song is (#350); a click on a node shows its asset in the inspector, a
* double click opens it.
*/ */
export class RoutingEditor extends VStack { export class RoutingEditor extends VStack {
readonly canvas: GraphCanvas; readonly canvas: GraphCanvas;
+14 -27
View File
@@ -1,10 +1,4 @@
import { import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
type Text,
} from 'pixi.js';
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { SetLoopCommand } from '../../core/commands/patternCommands'; import { SetLoopCommand } from '../../core/commands/patternCommands';
@@ -50,8 +44,10 @@ import {
onDragChange, onDragChange,
pressCount, pressCount,
type Size, type Size,
TextPool,
textStyle, textStyle,
Theme, Theme,
truncate,
ui, ui,
Widget, Widget,
zoomFactor, zoomFactor,
@@ -232,11 +228,11 @@ export class SongTimeline extends Widget {
private readonly itemsG = new Graphics(); private readonly itemsG = new Graphics();
private readonly overlay = new Graphics(); private readonly overlay = new Graphics();
private readonly itemLabels = new Container(); private readonly itemLabels = new Container();
private readonly itemTexts: Text[] = []; private readonly itemTexts = new TextPool(this.itemLabels);
private readonly headsLayer = new Container(); private readonly headsLayer = new Container();
private readonly headsG = new Graphics(); private readonly headsG = new Graphics();
private readonly headLabels = new Container(); private readonly headLabels = new Container();
private readonly headTexts: Text[] = []; private readonly headTexts = new TextPool(this.headLabels);
private readonly ruler = new TimeRuler(); private readonly ruler = new TimeRuler();
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
@@ -1441,8 +1437,8 @@ export class SongTimeline extends Widget {
this.headsG.clear(); this.headsG.clear();
this.outLabel.visible = this.outPeak.visible = asset !== null; this.outLabel.visible = this.outPeak.visible = asset !== null;
if (!asset) { if (!asset) {
this.syncTexts(this.itemTexts, this.itemLabels, []); this.itemTexts.show([]);
this.syncTexts(this.headTexts, this.headLabels, []); this.headTexts.show([]);
this.ruler.visible = false; this.ruler.visible = false;
this.hint.text = 'No song selected – pick one in the Assets panel or create one.'; this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
@@ -1557,7 +1553,7 @@ export class SongTimeline extends Widget {
} }
const take = this.take && this.drawTake(this.take, lw); const take = this.take && this.drawTake(this.take, lw);
if (take) labels.push(take); if (take) labels.push(take);
this.syncTexts(this.itemTexts, this.itemLabels, labels); this.syncTexts(this.itemTexts, labels);
} }
/** The take growing on its track as it is recorded, red, with what came in so far. */ /** The take growing on its track as it is recorded, red, with what came in so far. */
@@ -1731,7 +1727,7 @@ export class SongTimeline extends Widget {
.fill({ color: c.accent, alpha: 0.8 }); .fill({ color: c.accent, alpha: 0.8 });
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 }); this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 });
}); });
this.syncTexts(this.headTexts, this.headLabels, labels); this.syncTexts(this.headTexts, labels);
} }
/** Out in the corner (#359): its meter and the loudest peak since Play, red past 0 dB. */ /** Out in the corner (#359): its meter and the loudest peak since Play, red past 0 dB. */
@@ -1755,19 +1751,11 @@ export class SongTimeline extends Widget {
} }
/** Puts texts at their places, cut short with "…" where they would run past their width. */ /** Puts texts at their places, cut short with "…" where they would run past their width. */
private syncTexts(pool: Text[], parent: Container, labels: readonly Placed[]): void { private syncTexts(pool: TextPool, labels: readonly Placed[]): void {
labels.forEach((label, i) => { const styles = labels.map((label) => (label.dark ? DARK_LABEL : textStyle('small')));
let text = pool[i]; pool.show(labels.map((label, i) => truncate(label.text, styles[i]!, label.w))).forEach((text, i) => {
if (!text) { const label = labels[i]!;
text = crispText({ style: textStyle('small') }); if (text.style !== styles[i]) text.style = styles[i]!;
pool.push(text);
parent.addChild(text);
}
text.style = label.dark ? DARK_LABEL : textStyle('small');
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.w && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = true;
if (label.h === undefined) text.position.set(label.x, label.y); if (label.h === undefined) text.position.set(label.x, label.y);
else else
text.position.set( text.position.set(
@@ -1775,6 +1763,5 @@ export class SongTimeline extends Widget {
Math.round(label.y + (label.h - text.height) / 2), Math.round(label.y + (label.h - text.height) / 2),
); );
}); });
for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false;
} }
} }
+24 -83
View File
@@ -1,20 +1,15 @@
import { import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
Container,
type FederatedPointerEvent,
type FederatedWheelEvent,
Graphics,
type Text,
} from 'pixi.js';
import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands'; import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
import type { SoundAsset, SoundClip } from '../../core/model/Asset'; import type { SoundAsset, SoundClip } from '../../core/model/Asset';
import type { PropertyBag, Vec2 } from '../../core/model/Properties'; import type { PropertyBag, Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project'; import { findAssetOf } from '../../core/model/Project';
import { clipsOf, cutRange, type FadeCurve, fadeGain } from '../../core/model/sound'; import { clipsOf, cutRange, type FadeCurve, fadeGain } from '../../core/model/sound';
import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds'; import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds';
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui'; import { crispText, type Size, TextPool, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { axisTween } from '../viewTween'; import { axisTween } from '../viewTween';
import { type ClipName, showClipNames } from './clipStrip';
import { import {
clampWave, clampWave,
type ClipBand, type ClipBand,
@@ -64,9 +59,6 @@ const LABEL_SPACING = 70;
const LEAST_SPAN = 0.001; const LEAST_SPAN = 0.001;
/** Pixels a press has to travel before it spans a new stretch. */ /** Pixels a press has to travel before it spans a new stretch. */
const DRAG_START = 3; const DRAG_START = 3;
/** The name on the picked clip's band, dark on its light fill. */
const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
/** Line pieces a fade's curve is drawn with at most. */ /** Line pieces a fade's curve is drawn with at most. */
const RAMP_STEPS = 32; const RAMP_STEPS = 32;
/** The share of the area's width the wave is drawn past either side of it, for a pan to move (#360). */ /** The share of the area's width the wave is drawn past either side of it, for a pan to move (#360). */
@@ -123,7 +115,7 @@ export class WaveCanvas extends Widget {
private readonly bg = new Graphics(); private readonly bg = new Graphics();
private readonly scaleG = new Graphics(); private readonly scaleG = new Graphics();
private readonly scaleLabels = new Container(); private readonly scaleLabels = new Container();
private readonly scaleTexts: Text[] = []; private readonly scaleTexts = new TextPool(this.scaleLabels);
private readonly waveG = new Graphics(); private readonly waveG = new Graphics();
/** What `waveG` was drawn for: a pan within `seconds` only moves it. */ /** What `waveG` was drawn for: a pan within `seconds` only moves it. */
private drawn: { private drawn: {
@@ -136,15 +128,14 @@ export class WaveCanvas extends Widget {
seconds: CutSpan | null; seconds: CutSpan | null;
} | null = null; } | null = null;
private waveDraws = 0; private waveDraws = 0;
private rulerTexts = 0;
private readonly overlay = new Graphics(); private readonly overlay = new Graphics();
private readonly labels = new Container(); private readonly labels = new Container();
private readonly stripLabels = new Container(); private readonly stripLabels = new Container();
private readonly stripTexts: Text[] = []; private readonly stripTexts = new TextPool(this.stripLabels);
/** Wave, cut and ruler, kept to the widget: a handle panned out of view must not draw beside it. */ /** Wave, cut and ruler, kept to the widget: a handle panned out of view must not draw beside it. */
private readonly content = new Container(); private readonly content = new Container();
private readonly clip = new Graphics(); private readonly clip = new Graphics();
private readonly texts: Text[] = []; private readonly rulerTexts = new TextPool(this.labels);
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 }; private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
/** How many times its full height the wave is drawn. */ /** How many times its full height the wave is drawn. */
@@ -540,9 +531,9 @@ export class WaveCanvas extends Widget {
if (!wave || !asset) { if (!wave || !asset) {
this.waveG.clear(); this.waveG.clear();
this.drawn = null; this.drawn = null;
this.syncLabels([]); this.rulerTexts.show([]);
this.syncScale([]); this.scaleTexts.show([]);
this.syncStrip([]); this.stripTexts.show([]);
return; return;
} }
const mid = WAVE_TOP + wh / 2; const mid = WAVE_TOP + wh / 2;
@@ -604,7 +595,7 @@ export class WaveCanvas extends Widget {
* a ruler label was given a new text (test hook). * a ruler label was given a new text (test hook).
*/ */
drawInfo(): { waveDraws: number; shift: number; rulerTexts: number } { drawInfo(): { waveDraws: number; shift: number; rulerTexts: number } {
return { waveDraws: this.waveDraws, shift: this.waveG.x, rulerTexts: this.rulerTexts }; return { waveDraws: this.waveDraws, shift: this.waveG.x, rulerTexts: this.rulerTexts.changes };
} }
/** /**
@@ -656,7 +647,9 @@ export class WaveCanvas extends Widget {
this.overlay.rect(x, RULER_HEIGHT - 8, 1, 7).fill({ color: c.textMuted, alpha: 0.8 }); this.overlay.rect(x, RULER_HEIGHT - 8, 1, 7).fill({ color: c.textMuted, alpha: 0.8 });
placed.push({ text: rulerLabel(seconds, step), x: x + 3 }); placed.push({ text: rulerLabel(seconds, step), x: x + 3 });
} }
this.syncLabels(placed); this.rulerTexts
.show(placed.map((label) => label.text))
.forEach((text, i) => text.position.set(placed[i]!.x, 6));
} }
/** A band per clip with its name, the picked one lit, and the one a drag spans. */ /** A band per clip with its name, the picked one lit, and the one a drag spans. */
@@ -664,7 +657,7 @@ export class WaveCanvas extends Widget {
const c = Theme.colors; const c = Theme.colors;
const picked = this.litClipId(); const picked = this.litClipId();
const names = new Map(clipsOf(asset).map((clip) => [clip.id, clip.name])); const names = new Map(clipsOf(asset).map((clip) => [clip.id, clip.name]));
const placed: { text: string; x: number; room: number; lit: boolean }[] = []; const placed: ClipName[] = [];
for (const { id, span } of this.bands(asset, wave)) { for (const { id, span } of this.bands(asset, wave)) {
const a = secondsToX(this.view, span.start); const a = secondsToX(this.view, span.start);
const b = secondsToX(this.view, span.end); const b = secondsToX(this.view, span.end);
@@ -679,11 +672,12 @@ export class WaveCanvas extends Widget {
placed.push({ placed.push({
text: name, text: name,
x: Math.max(a, 0) + 4, x: Math.max(a, 0) + 4,
y: RULER_HEIGHT,
room: Math.min(b, this.areaWidth) - Math.max(a, 0) - 8, room: Math.min(b, this.areaWidth) - Math.max(a, 0) - 8,
lit, lit,
}); });
} }
this.syncStrip(placed); showClipNames(this.stripTexts, placed, STRIP_HEIGHT);
} }
/** The clip the strip lights: the one a drag spans ('' for a new one), else the picked one. */ /** The clip the strip lights: the one a drag spans ('' for a new one), else the picked one. */
@@ -781,66 +775,13 @@ export class WaveCanvas extends Widget {
for (const { offset } of marks) for (const { offset } of marks)
for (const y of [mid - offset, mid + offset]) for (const y of [mid - offset, mid + offset])
this.scaleG.rect(SCALE_WIDTH - 6, Math.round(y), 5, 1).fill({ color: c.textMuted, alpha: 0.8 }); this.scaleG.rect(SCALE_WIDTH - 6, Math.round(y), 5, 1).fill({ color: c.textMuted, alpha: 0.8 });
this.syncScale(marks.map(({ db, offset }) => ({ text: String(db), y: mid - offset }))); this.scaleTexts
} .show(marks.map(({ db }) => String(db)))
.forEach((text, i) =>
private syncScale(placed: readonly { text: string; y: number }[]): void { text.position.set(
placed.forEach((label, i) => { Math.round(SCALE_WIDTH - 8 - text.width),
let text = this.scaleTexts[i]; Math.round(mid - marks[i]!.offset - text.height / 2),
if (!text) { ),
text = crispText({ style: textStyle('small') }); );
this.scaleTexts.push(text);
this.scaleLabels.addChild(text);
}
text.visible = true;
if (text.text !== label.text) text.text = label.text;
text.position.set(Math.round(SCALE_WIDTH - 8 - text.width), Math.round(label.y - text.height / 2));
});
for (let i = placed.length; i < this.scaleTexts.length; i++) this.scaleTexts[i]!.visible = false;
}
/** A clip's name inside its band, cut short with "…" where the band is narrower. */
private syncStrip(placed: readonly { text: string; x: number; room: number; lit: boolean }[]): void {
placed.forEach((label, i) => {
let text = this.stripTexts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.stripTexts.push(text);
this.stripLabels.addChild(text);
}
const style = label.lit ? LIT_LABEL : textStyle('small');
if (text.style !== style) text.style = style;
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = text.width <= label.room;
text.position.set(Math.round(label.x), Math.round(RULER_HEIGHT + (STRIP_HEIGHT - text.height) / 2));
});
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
}
/**
* The ruler's labels. A label already showing a second keeps it: a pan shifts which one is first,
* and a text set anew is drawn anew (#360).
*/
private syncLabels(placed: readonly { text: string; x: number }[]): void {
const shown = new Map(this.texts.filter((t) => t.visible).map((t) => [t.text, t]));
const kept = placed.map((label) => shown.get(label.text));
const spare = this.texts.filter((t) => !kept.includes(t));
for (const text of spare) text.visible = false;
placed.forEach((label, i) => {
let text = kept[i] ?? spare.pop();
if (!text) {
text = crispText({ style: textStyle('small') });
this.texts.push(text);
this.labels.addChild(text);
}
text.visible = true;
if (text.text !== label.text) {
text.text = label.text;
this.rulerTexts++;
}
text.position.set(label.x, 6);
});
} }
} }
+29
View File
@@ -0,0 +1,29 @@
import { textStyle, type TextPool, truncate } from '../../ui';
/** The name on the picked clip's band, dark on its light fill. */
const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
/** A clip's name in its band: where it starts, the room the band leaves it, and whether it is lit. */
export interface ClipName {
text: string;
x: number;
y: number;
room: number;
lit: boolean;
}
/**
* The clips' names inside their bands in a strip `height` tall, cut short with "…" where a band is
* narrower. The Sound tab's (#357) and the Video tab's (#402).
*/
export function showClipNames(pool: TextPool, names: readonly ClipName[], height: number): void {
const styles = names.map((name) => (name.lit ? LIT_LABEL : textStyle('small')));
const shown = names.map((name, i) => truncate(name.text, styles[i]!, name.room));
pool.show(shown).forEach((text, i) => {
const name = names[i]!;
if (text.style !== styles[i]) text.style = styles[i]!;
text.visible = shown[i] !== '';
text.position.set(Math.round(name.x), Math.round(name.y + (height - text.height) / 2));
});
}
+5 -41
View File
@@ -1,42 +1,23 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { acidWave, InstrumentAcid } from '../../core/instrument/nodes'; import { acidWave, InstrumentAcid } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project';
import type { AcidWave } from '../../core/soundfx/acid'; import type { AcidWave } from '../../core/soundfx/acid';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { type AcidHost, AcidPanel } from './AcidPanel'; import { type AcidHost, AcidPanel } from './AcidPanel';
import { type AcidKey, acidValues } from './acidView'; import { type AcidKey, acidValues } from './acidView';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { NodeParamDrag } from './nodeParamDrag';
/** /**
* An Acid node drawn as its device (#421): its filter drawn from its settings, every one of them * An Acid node drawn as its device (#421): its filter drawn from its settings, every one of them
* worked right in the graph. It listens to nothing, so it has nothing to move between changes. * worked right in the graph. It listens to nothing, so it has nothing to move between changes.
*/ */
export class AcidFace implements NodeFace, AcidHost { export class AcidFace extends DeviceFace implements NodeFace, AcidHost {
readonly widget: AcidPanel; readonly widget: AcidPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, InstrumentAcid.type, 'acid-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new AcidPanel(this); this.widget = new AcidPanel(this);
this.drag = new NodeParamDrag(state, 'acid-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentAcid.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -48,25 +29,8 @@ export class AcidFace implements NodeFace, AcidHost {
}); });
} }
tick(): boolean {
return false;
}
hide(): void {}
preview(key: AcidKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: AcidKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickWave(wave: AcidWave): void { pickWave(wave: AcidWave): void {
const node = this.node(); const node = this.node();
if (node && acidWave(node.params) !== wave) if (node && acidWave(node.params) !== wave) this.set('wave', wave);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave));
} }
} }
+46 -102
View File
@@ -3,10 +3,10 @@
* way of the TB-303, a button for each wave, a dark screen that draws the filter where it rests and * way of the TB-303, a button for each wave, a dark screen that draws the filter where it rests and
* at the top of a note's sweep in acid green, and a chrome knob for each number. * at the top of a note's sweep in acid green, and a chrome knob for each number.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { ACID_ROWS, ACID_WAVES } from '../../core/instrument/nodes'; import { ACID_ROWS, ACID_WAVES } from '../../core/instrument/nodes';
import type { AcidWave } from '../../core/soundfx/acid'; import type { AcidWave } from '../../core/soundfx/acid';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { import {
ACID_GROUPS, ACID_GROUPS,
ACID_KEYS, ACID_KEYS,
@@ -24,9 +24,24 @@ import {
RESPONSE_LOW_HZ, RESPONSE_LOW_HZ,
RESPONSE_TOP_DB, RESPONSE_TOP_DB,
} from './acidView'; } from './acidView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; import {
import { type Box, KNOB_CAPTION } from './hardwareView'; bezelText,
import { PickButton, type PickLook } from './ReverbPanel'; DeviceKnobs,
drawBezel,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
PickButton,
type PickLook,
placeEngraving,
placeGroups,
type PlateLook,
put,
screenText,
} from './deviceParts';
import type { Box } from './hardwareView';
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
export interface AcidHost { export interface AcidHost {
@@ -66,19 +81,9 @@ const SCOPE = {
text: 0xb8ff3c, text: 0xb8ff3c,
dim: 0x6f9a52, dim: 0x6f9a52,
}; };
const IDLE_ALPHA = 0.4;
/** The row of text at the top of the screen. */ /** The row of text at the top of the screen. */
const TEXT_ROW = 14; const TEXT_ROW = 14;
function scopeStyle(fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = 9;
style.fontWeight = 'bold';
return style;
}
/** The screen: the filter's curve at rest and at the top of a sweep, the room between them lit faintly. */ /** The screen: the filter's curve at rest and at the top of a sweep, the room between them lit faintly. */
class AcidScreen extends Widget { class AcidScreen extends Widget {
private readonly g = new Graphics(); private readonly g = new Graphics();
@@ -90,11 +95,9 @@ class AcidScreen extends Widget {
constructor() { constructor() {
super(); super();
this.rest = crispText({ text: '', style: scopeStyle(SCOPE.dim) }); this.addChild(this.g);
this.top = crispText({ text: '', style: scopeStyle(SCOPE.text) }); this.rest = screenText(this, 9, SCOPE.dim);
this.top.anchor.set(1, 0); this.top = screenText(this, 9, SCOPE.text, 1);
for (const t of [this.rest, this.top]) t.eventMode = 'none';
this.addChild(this.g, this.rest, this.top);
} }
show(trace: AcidTrace, texts: { rest: string; top: string }): void { show(trace: AcidTrace, texts: { rest: string; top: string }): void {
@@ -149,11 +152,11 @@ export class AcidPanel extends Widget {
private readonly bezel = new Graphics(); private readonly bezel = new Graphics();
private readonly rules = new Graphics(); private readonly rules = new Graphics();
readonly screen = new AcidScreen(); readonly screen = new AcidScreen();
readonly knobs = {} as Record<AcidKey, Knob>; readonly knobs: Record<AcidKey, Knob>;
readonly waveButtons: PickButton<AcidWave>[]; readonly waveButtons: PickButton<AcidWave>[];
private readonly captions = {} as Record<AcidKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<AcidKey>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** Where the sustain holds the filter, on the bezel (checks). */ /** Where the sustain holds the filter, on the bezel (checks). */
readonly held: Text; readonly held: Text;
@@ -166,46 +169,21 @@ export class AcidPanel extends Widget {
this.minHeight = ACID_PANEL.h; this.minHeight = ACID_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.screen); this.addChild(this.plate, this.rules, this.bezel, this.screen);
this.title = engrave(this, 'ACID', 16); this.title = engrave(this, 'ACID', 16);
const nameStyle = textStyle('small').clone(); this.screenName = bezelText(this, 'FILTER OVER A NOTE', BEZEL_TEXT);
nameStyle.fill = BEZEL_TEXT; this.held = bezelText(this, '', BEZEL_TEXT, true);
nameStyle.fontSize = 9;
nameStyle.fontWeight = 'bold';
nameStyle.letterSpacing = 1;
this.screenName = crispText({ text: 'FILTER OVER A NOTE', style: nameStyle });
this.screenName.eventMode = 'none';
this.held = crispText({ text: '', style: nameStyle });
this.held.anchor.set(1, 0);
this.held.eventMode = 'none';
this.addChild(this.screenName, this.held);
this.waveButtons = ACID_WAVES.map((wave) => { this.waveButtons = ACID_WAVES.map((wave) => {
const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked)); const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked));
this.addChild(button); this.addChild(button);
return button; return button;
}); });
this.groupNames = ACID_GROUPS.map((group) => engrave(this, group.label, 9)); this.groupNames = engraveGroups(this, ACID_GROUPS);
for (const key of ACID_KEYS) { this.dials = new DeviceKnobs(
const row = ACID_ROWS[key]; this,
const look = ACID_KNOBS[key]; ACID_KEYS,
const knob = new Knob('', row.default, { (key) => ({ row: ACID_ROWS[key], look: ACID_KNOBS[key], size: ACID_LAYOUT.knobs[key].size }),
min: row.min ?? 0, host,
max: row.max ?? 0, );
step: row.step ?? 1, this.knobs = this.dials.knobs;
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: ACID_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
...(look.bipolar && { bipolar: true }),
...(look.format && { format: look.format }),
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -213,14 +191,7 @@ export class AcidPanel extends Widget {
} }
sync(shown: AcidShown): void { sync(shown: AcidShown): void {
for (const key of ACID_KEYS) { this.dials.sync((key) => shown.values[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(shown.values[key]);
knob.setEnabled(!shown.wired(key));
const alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
knob.alpha = alpha;
this.captions[key].ink.alpha = alpha;
}
for (const button of this.waveButtons) button.pick(button.value === shown.wave); for (const button of this.waveButtons) button.pick(button.value === shown.wave);
this.idle = shown.idle; this.idle = shown.idle;
const trace = acidTrace(shown.values); const trace = acidTrace(shown.values);
@@ -235,22 +206,9 @@ export class AcidPanel extends Widget {
this.screen.resize(L.screen.w, L.screen.h); this.screen.resize(L.screen.w, L.screen.h);
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.held.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.held.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
this.waveButtons.forEach((button, i) => { this.waveButtons.forEach((button, i) => put(button, L.waves[i]!));
const box = L.waves[i]!; this.dials.place((key) => L.knobs[key]);
button.position.set(box.x, box.y); placeGroups(this.groupNames, L.groups);
button.resize(box.w, box.h);
});
for (const key of ACID_KEYS) {
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -258,24 +216,10 @@ export class AcidPanel extends Widget {
const L = ACID_LAYOUT; const L = ACID_LAYOUT;
drawPlate(this.plate.clear(), ACID_PANEL, SILVER, L.screws); drawPlate(this.plate.clear(), ACID_PANEL, SILVER, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x151615);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x151615, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
} }
} }
+7 -43
View File
@@ -1,14 +1,10 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { chipWave, InstrumentChip } from '../../core/instrument/nodes'; import { chipWave, InstrumentChip } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project';
import type { ChipWave } from '../../core/soundfx/chip'; import type { ChipWave } from '../../core/soundfx/chip';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type ChipHost, ChipPanel } from './ChipPanel'; import { type ChipHost, ChipPanel } from './ChipPanel';
import { type ChipKey, chipValues } from './chipView'; import { chipValues } from './chipView';
import { NodeParamDrag } from './nodeParamDrag'; import { DeviceFace } from './deviceFace';
/** "follow note": on unless it was turned off. */ /** "follow note": on unless it was turned off. */
const FOLLOW = 'followNote'; const FOLLOW = 'followNote';
@@ -17,29 +13,14 @@ const FOLLOW = 'followNote';
* A Chip node drawn as its device (#386): its wave and its pitch drawn from its settings, every one * A Chip node drawn as its device (#386): its wave and its pitch drawn from its settings, every one
* of them worked right in the graph. It listens to nothing, so it has nothing to move between changes. * of them worked right in the graph. It listens to nothing, so it has nothing to move between changes.
*/ */
export class ChipFace implements NodeFace, ChipHost { export class ChipFace extends DeviceFace implements NodeFace, ChipHost {
readonly widget: ChipPanel; readonly widget: ChipPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, InstrumentChip.type, 'chip-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new ChipPanel(this); this.widget = new ChipPanel(this);
this.drag = new NodeParamDrag(state, 'chip-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentChip.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -52,29 +33,12 @@ export class ChipFace implements NodeFace, ChipHost {
}); });
} }
tick(): boolean {
return false;
}
hide(): void {}
preview(key: ChipKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: ChipKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickWave(wave: ChipWave): void { pickWave(wave: ChipWave): void {
const node = this.node(); const node = this.node();
if (node && chipWave(node.params) !== wave) if (node && chipWave(node.params) !== wave) this.set('wave', wave);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave));
} }
switchFollow(on: boolean): void { switchFollow(on: boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, FOLLOW, on)); this.set(FOLLOW, on);
} }
} }
+48 -117
View File
@@ -4,10 +4,10 @@
* "follow note", two green screens – the wave over two periods, and what the slide, the vibrato, * "follow note", two green screens – the wave over two periods, and what the slide, the vibrato,
* the arpeggio and the repeat make of the pitch over a note – and a chrome knob for every number. * the arpeggio and the repeat make of the pitch over a note – and a chrome knob for every number.
*/ */
import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text, type TextStyle } from 'pixi.js';
import type { ChipWave } from '../../core/soundfx/chip'; import type { ChipWave } from '../../core/soundfx/chip';
import { CHIP_ROWS } from '../../core/instrument/nodes'; import { CHIP_ROWS } from '../../core/instrument/nodes';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { crispText, type Knob, Widget } from '../../ui';
import { import {
CHIP_GROUPS, CHIP_GROUPS,
CHIP_KEYS, CHIP_KEYS,
@@ -25,8 +25,25 @@ import {
WAVE_NAMES, WAVE_NAMES,
wavePoints, wavePoints,
} from './chipView'; } from './chipView';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; import {
import { type Box, KNOB_CAPTION } from './hardwareView'; bezelText,
DeviceKnobs,
drawBezel,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
Lever,
placeEngraving,
placeGroups,
PlateButton,
type PlateLook,
put,
screenStyle,
} from './deviceParts';
import type { Box } from './hardwareView';
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
export interface ChipHost { export interface ChipHost {
@@ -62,17 +79,8 @@ const BEZEL_TEXT = 0xb9bccb;
const LCD = { paper: 0x9bbc0f, grid: 0x8bac0f, mid: 0x306230, ink: 0x0f380f }; const LCD = { paper: 0x9bbc0f, grid: 0x8bac0f, mid: 0x306230, ink: 0x0f380f };
/** A button's lit wave: the colour of the console's A and B. */ /** A button's lit wave: the colour of the console's A and B. */
const PICKED = 0xa3285f; const PICKED = 0xa3285f;
/** How far an idle setting fades. */
const IDLE_ALPHA = 0.4;
function lcdStyle(size = 10): TextStyle { const lcdStyle = (size = 10): TextStyle => screenStyle(size, LCD.ink);
const style = textStyle('small').clone();
style.fill = LCD.ink;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
/** A line through the points, as one path. */ /** A line through the points, as one path. */
function polyline(g: Graphics, points: readonly { x: number; y: number }[]): Graphics { function polyline(g: Graphics, points: readonly { x: number; y: number }[]): Graphics {
@@ -90,29 +98,12 @@ function dashed(g: Graphics, x0: number, y0: number, x1: number, y1: number, das
} }
/** A wave's button: its shape drawn on it, lit in the console's magenta while it is the one. */ /** A wave's button: its shape drawn on it, lit in the console's magenta while it is the one. */
class WaveButton extends Widget { class WaveButton extends PlateButton<ChipWave> {
private readonly g = new Graphics(); private readonly g = new Graphics();
picked = false;
constructor( constructor(wave: ChipWave, onPick: (wave: ChipWave) => void) {
readonly wave: ChipWave, super(wave, onPick);
onPick: (wave: ChipWave) => void,
) {
super();
this.cursor = 'pointer';
this.addChild(this.g); this.addChild(this.g);
this.trackHover();
// A press on a button is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onPick(wave);
});
}
pick(on: boolean): void {
if (on === this.picked) return;
this.picked = on;
this.invalidate();
} }
override redraw(): void { override redraw(): void {
@@ -125,7 +116,7 @@ class WaveButton extends Widget {
.stroke({ color: 0x24252a, width: 1 }); .stroke({ color: 0x24252a, width: 1 });
g.roundRect(3, down + 2, w - 6, 5, 3).fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.12 }); g.roundRect(3, down + 2, w - 6, 5, 3).fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.12 });
const box = { x: 8, y: down + 7, w: w - 16, h: h - 18 }; const box = { x: 8, y: down + 7, w: w - 16, h: h - 18 };
const points = glyphPoints(this.wave).map((p) => ({ const points = glyphPoints(this.value).map((p) => ({
x: box.x + p.x * box.w, x: box.x + p.x * box.w,
y: box.y + box.h / 2 - (p.y * box.h) / 2, y: box.y + box.h / 2 - (p.y * box.h) / 2,
})); }));
@@ -262,11 +253,11 @@ export class ChipPanel extends Widget {
readonly follow: Lever; readonly follow: Lever;
readonly waveScreen = new WaveScreen(); readonly waveScreen = new WaveScreen();
readonly pitchScreen = new PitchScreen(); readonly pitchScreen = new PitchScreen();
readonly knobs = {} as Record<ChipKey, Knob>; readonly knobs: Record<ChipKey, Knob>;
private readonly captions = {} as Record<ChipKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<ChipKey>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly rules = new Graphics(); private readonly rules = new Graphics();
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly screenNames: Text[]; private readonly screenNames: Text[];
/** The settings shown idle now (checks). */ /** The settings shown idle now (checks). */
idle: ReadonlySet<string> = new Set(); idle: ReadonlySet<string> = new Set();
@@ -277,17 +268,7 @@ export class ChipPanel extends Widget {
this.minHeight = CHIP_PANEL.h; this.minHeight = CHIP_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.waveScreen, this.pitchScreen); this.addChild(this.plate, this.rules, this.bezel, this.waveScreen, this.pitchScreen);
this.title = engrave(this, 'CHIP', 14); this.title = engrave(this, 'CHIP', 14);
const nameStyle = textStyle('small').clone(); this.screenNames = ['WAVE', 'PITCH OVER A NOTE'].map((text) => bezelText(this, text, BEZEL_TEXT));
nameStyle.fill = BEZEL_TEXT;
nameStyle.fontSize = 9;
nameStyle.fontWeight = 'bold';
nameStyle.letterSpacing = 1;
this.screenNames = ['WAVE', 'PITCH OVER A NOTE'].map((text) => {
const t = crispText({ text, style: nameStyle });
t.eventMode = 'none';
this.addChild(t);
return t;
});
this.waveButtons = CHIP_WAVES.map((wave) => { this.waveButtons = CHIP_WAVES.map((wave) => {
const button = new WaveButton(wave, (picked) => host.pickWave(picked)); const button = new WaveButton(wave, (picked) => host.pickWave(picked));
this.addChild(button); this.addChild(button);
@@ -295,30 +276,14 @@ export class ChipPanel extends Widget {
}); });
this.follow = new Lever('FOLLOW NOTE', (on) => host.switchFollow(on)); this.follow = new Lever('FOLLOW NOTE', (on) => host.switchFollow(on));
this.addChild(this.follow); this.addChild(this.follow);
this.groupNames = CHIP_GROUPS.map((group) => engrave(this, group.label, 9)); this.groupNames = engraveGroups(this, CHIP_GROUPS);
for (const key of CHIP_KEYS) { this.dials = new DeviceKnobs(
const row = CHIP_ROWS[key]; this,
const look = CHIP_KNOBS[key]; CHIP_KEYS,
const knob = new Knob('', row.default, { (key) => ({ row: CHIP_ROWS[key], look: CHIP_KNOBS[key], size: CHIP_LAYOUT.knobs[key].size }),
min: row.min ?? 0, host,
max: row.max ?? 0, );
step: row.step ?? 1, this.knobs = this.dials.knobs;
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size: CHIP_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -326,15 +291,8 @@ export class ChipPanel extends Widget {
} }
sync(shown: ChipShown): void { sync(shown: ChipShown): void {
for (const key of CHIP_KEYS) { this.dials.sync((key) => shown.values[key], shown.wired, shown.idle);
const knob = this.knobs[key]; for (const button of this.waveButtons) button.pick(button.value === shown.wave);
knob.setValue(shown.values[key]);
knob.setEnabled(!shown.wired(key));
const idle = shown.idle.has(key);
knob.alpha = idle ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = idle ? IDLE_ALPHA : 1;
}
for (const button of this.waveButtons) button.pick(button.wave === shown.wave);
this.follow.set(shown.follow, shown.follow ? 'on' : 'off'); this.follow.set(shown.follow, shown.follow ? 'on' : 'off');
this.follow.alpha = shown.idle.has('followNote') ? IDLE_ALPHA : 1; this.follow.alpha = shown.idle.has('followNote') ? IDLE_ALPHA : 1;
this.idle = shown.idle; this.idle = shown.idle;
@@ -344,27 +302,14 @@ export class ChipPanel extends Widget {
override layout(): void { override layout(): void {
const L = CHIP_LAYOUT; const L = CHIP_LAYOUT;
const put = (w: Widget, b: Box) => {
w.position.set(b.x, b.y);
w.resize(b.w, b.h);
};
this.waveButtons.forEach((button, i) => put(button, L.waves[i]!)); this.waveButtons.forEach((button, i) => put(button, L.waves[i]!));
put(this.follow, L.follow); put(this.follow, L.follow);
put(this.waveScreen, L.wave); put(this.waveScreen, L.wave);
put(this.pitchScreen, L.pitch); put(this.pitchScreen, L.pitch);
this.screenNames[0]!.position.set(L.wave.x, L.bezel.y + 3); this.screenNames[0]!.position.set(L.wave.x, L.bezel.y + 3);
this.screenNames[1]!.position.set(L.pitch.x, L.bezel.y + 3); this.screenNames[1]!.position.set(L.pitch.x, L.bezel.y + 3);
for (const key of CHIP_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key]; placeGroups(this.groupNames, L.groups);
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 6); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 6);
} }
@@ -372,24 +317,10 @@ export class ChipPanel extends Widget {
const L = CHIP_LAYOUT; const L = CHIP_LAYOUT;
drawPlate(this.plate.clear(), CHIP_PANEL, CONSOLE, L.screws); drawPlate(this.plate.clear(), CHIP_PANEL, CONSOLE, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x2c2d35);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x2c2d35, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
} }
} }
+5 -9
View File
@@ -1,7 +1,8 @@
/** /**
* The editor of a Compressor node (#378), in a tab of its own like a Parametric EQ's. A knob for * The editor of a Compressor node (#378), in a tab of its own like a Parametric EQ's. A knob for
* each of its numbers and a switch for its auto-gain; the curve from level in to level out, whose threshold and ratio are dragged * each of its numbers and a switch for its auto-gain; the curve from level in to level out, whose
* on it; and the level coming in and going out over the last seconds, tinted where it is squeezed. * threshold and ratio are dragged on it; and the level coming in and going out over the last
* seconds, tinted where it is squeezed.
* A drag is one undo step. A number fed from a link has its knob greyed: the link decides it. * A drag is one undo step. A number fed from a link has its knob greyed: the link decides it.
*/ */
import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
@@ -56,6 +57,7 @@ import {
reductionShapes, reductionShapes,
thresholdHandle, thresholdHandle,
} from './compressorView'; } from './compressorView';
import { paramWired } from './deviceFace';
import { NodeParamDrag } from './nodeParamDrag'; import { NodeParamDrag } from './nodeParamDrag';
type Key = CompressorKey; type Key = CompressorKey;
@@ -464,7 +466,7 @@ export class CompressorEditor extends VStack implements CompressorHost {
} }
wired(key: Key): boolean { wired(key: Key): boolean {
return compressorWired(this.state.project, this.assetId, this.nodeId, key); return paramWired(this.state.project, this.assetId, this.nodeId, key);
} }
/** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */ /** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */
@@ -501,12 +503,6 @@ export class CompressorEditor extends VStack implements CompressorHost {
} }
} }
/** Whether a link decides one of a Compressor's (or a Gain's, #384) numbers: its knob is greyed then. */
export function compressorWired(project: Project, assetId: string, nodeId: string, key: string): boolean {
const graph = findGraphAsset(project, assetId)?.graph;
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
}
/** The Compressor node a tab is about, or null once it, or its asset, is gone. */ /** The Compressor node a tab is about, or null once it, or its asset, is gone. */
export function compressorNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null { export function compressorNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId); const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
+12 -39
View File
@@ -1,11 +1,8 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project'; import { findGraphAsset } from '../../core/model/Project';
import { COMPRESS_AUTO_GAIN, tapId } from '../../core/soundfx/nodes'; import { COMPRESS_AUTO_GAIN, SoundFxCompressor } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorNodeOf, compressorWired } from './CompressorEditor';
import { CompressorPanel, type PanelHost } from './CompressorPanel'; import { CompressorPanel, type PanelHost } from './CompressorPanel';
import { import {
autoGainText, autoGainText,
@@ -14,30 +11,20 @@ import {
compressorNumbers, compressorNumbers,
LEVEL_WINDOW, LEVEL_WINDOW,
} from './compressorView'; } from './compressorView';
import { DeviceFace } from './deviceFace';
import { gainReduction } from './hardwareView'; import { gainReduction } from './hardwareView';
import { NodeParamDrag } from './nodeParamDrag';
/** /**
* A Compressor node drawn as its device (#380): the panel of #379, turned right in the graph. Each * A Compressor node drawn as its device (#380): the panel of #379, turned right in the graph. Each
* node has its own, and its meter runs while the node is in the picture. * node has its own, and its meter runs while the node is in the picture.
*/ */
export class CompressorFace implements NodeFace, PanelHost { export class CompressorFace extends DeviceFace implements NodeFace, PanelHost {
readonly widget: CompressorPanel; readonly widget: CompressorPanel;
private readonly drag: NodeParamDrag;
private listening = false;
private lastTick = 0; private lastTick = 0;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxCompressor.type, 'compressor-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new CompressorPanel(this); this.widget = new CompressorPanel(this);
this.drag = new NodeParamDrag(state, 'compressor-face');
}
private node(): GraphNode | null {
return compressorNodeOf(this.state.project, this.assetId, this.nodeId);
} }
sync(): void { sync(): void {
@@ -45,20 +32,14 @@ export class CompressorFace implements NodeFace, PanelHost {
if (!node) return; if (!node) return;
const project = this.state.project; const project = this.state.project;
const numbers = compressorNumbers(node.params); const numbers = compressorNumbers(node.params);
this.widget.sync( this.widget.sync(numbers, this.wired, findGraphAsset(project, this.assetId)?.name ?? '');
numbers,
(key) => compressorWired(project, this.assetId, this.nodeId, key),
findGraphAsset(project, this.assetId)?.name ?? '',
);
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers)); this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
} }
tick(): boolean { override tick(): boolean {
const node = this.node(); const node = this.node();
if (!node) return false; if (!node) return false;
const id = tapId(this.assetId, this.nodeId); const id = this.listen();
soundLibrary.listen(id);
this.listening = true;
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW); const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
const now = performance.now() / 1000; const now = performance.now() / 1000;
const dt = this.lastTick ? now - this.lastTick : 0; const dt = this.lastTick ? now - this.lastTick : 0;
@@ -68,15 +49,13 @@ export class CompressorFace implements NodeFace, PanelHost {
return this.widget.moving; return this.widget.moving;
} }
hide(): void { override hide(): void {
if (!this.listening) return; if (!this.unlisten()) return;
this.listening = false;
this.lastTick = 0; this.lastTick = 0;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.widget.reset(); this.widget.reset();
} }
preview(key: CompressorKey, value: number): void { override preview(key: CompressorKey, value: number): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
this.drag.preview(node, key, value); this.drag.preview(node, key, value);
@@ -84,13 +63,7 @@ export class CompressorFace implements NodeFace, PanelHost {
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers)); this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
} }
commit(key: CompressorKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
switchAutoGain(on: boolean): void { switchAutoGain(on: boolean): void {
if (!this.node()) return; this.set(COMPRESS_AUTO_GAIN.key, on);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
} }
} }
+32 -196
View File
@@ -3,15 +3,8 @@
* needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. It is * needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. It is
* what its node shows in the graph (#380). * what its node shows in the graph (#380).
*/ */
import { import { Container, type FillGradient, Graphics, type Text } from 'pixi.js';
Container, import { crispText, type Knob, textStyle, Widget } from '../../ui';
FillGradient,
type FederatedPointerEvent,
Graphics,
type Text,
type TextStyle,
} from 'pixi.js';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { import {
COMPRESSOR_KEYS, COMPRESSOR_KEYS,
COMPRESSOR_KNOBS, COMPRESSOR_KNOBS,
@@ -21,9 +14,17 @@ import {
ratioText, ratioText,
} from './compressorView'; } from './compressorView';
import { import {
type Box, ALUMINIUM,
brushStrokes, DeviceKnobs,
KNOB_CAPTION, drawPlate,
type Engraving,
engrave,
gradient,
Lever,
placeEngraving,
put,
} from './deviceParts';
import {
LED_COUNT, LED_COUNT,
ledColour, ledColour,
ledsLit, ledsLit,
@@ -46,94 +47,11 @@ export interface PanelHost {
switchAutoGain(on: boolean): void; switchAutoGain(on: boolean): void;
} }
export const INK = 0x26282c;
const LCD_GLOW = 0x9cf58c; const LCD_GLOW = 0x9cf58c;
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a }; const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
/** Captions are engraved: dark letters with a light edge under them. */
export function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
const style = (fill: number): TextStyle => {
const s = textStyle('small').clone();
s.fill = fill;
s.fontSize = size;
s.fontWeight = 'bold';
s.letterSpacing = 1;
return s;
};
const edge = crispText({ text, style: style(0xffffff) });
edge.alpha = 0.55;
const ink = crispText({ text, style: style(INK) });
for (const t of [edge, ink]) {
t.eventMode = 'none';
t.anchor.set(0.5, 0);
parent.addChild(t);
}
return { ink, edge };
}
export function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
e.ink.position.set(Math.round(x), Math.round(y));
e.edge.position.set(Math.round(x), Math.round(y) + 1);
}
let faceFill: FillGradient | null = null; let faceFill: FillGradient | null = null;
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) => const NOTHING_IDLE: ReadonlySet<string> = new Set();
new FillGradient({
type: 'linear',
start: { x: 0, y: 0 },
end,
colorStops: stops.map(([offset, color]) => ({ offset, color })),
textureSpace: 'local',
});
/** The metal of a plate: its shade from top to bottom, its rim, and the dark strokes of the brush. */
export interface PlateLook {
stops: [number, number][];
rim: number;
grain: number;
}
/** Brushed aluminium, the Compressor's and the Delay's (#393). */
export const ALUMINIUM: PlateLook = {
stops: [
[0, 0xe2e4e7],
[0.5, 0xcfd2d6],
[1, 0xb4b8bd],
],
rim: 0x8a8e94,
grain: 0x6c7076,
};
const plateFills = new WeakMap<PlateLook, FillGradient>();
/** A brushed plate with a screw in each corner. */
export function drawPlate(
g: Graphics,
size: { w: number; h: number },
look: PlateLook,
screws: readonly { x: number; y: number }[],
): void {
const { w, h } = size;
let fill = plateFills.get(look);
if (!fill) plateFills.set(look, (fill = gradient(look.stops)));
g.roundRect(0, 0, w, h, 6).fill(fill);
for (const stroke of brushStrokes(h - 4))
g.rect(3, stroke.y + 2, w - 6, 1).fill({
color: stroke.light ? 0xffffff : look.grain,
alpha: stroke.alpha,
});
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: look.rim, width: 1 });
g.moveTo(3, 1.5)
.lineTo(w - 3, 1.5)
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
for (const screw of screws) {
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
g.moveTo(screw.x - 4, screw.y + 3)
.lineTo(screw.x + 4, screw.y - 3)
.stroke({ color: 0x45484d, width: 1.5 });
}
}
/** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */ /** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */
class ReductionMeter extends Widget { class ReductionMeter extends Widget {
@@ -249,7 +167,7 @@ class ReductionMeter extends Widget {
} }
/** The level going out, as a column of LEDs: lit at once, going out slowly. */ /** The level going out, as a column of LEDs: lit at once, going out slowly. */
export class OutputLeds extends Widget { class OutputLeds extends Widget {
private readonly lights = new Graphics(); private readonly lights = new Graphics();
shown = -Infinity; shown = -Infinity;
private lit = -1; private lit = -1;
@@ -295,59 +213,6 @@ export class OutputLeds extends Widget {
} }
} }
/** A switch on a plate: a lever and a lamp, its name and what it gives beside them. Auto-gain's, a Chip's "follow note" (#386). */
export class Lever extends Widget {
private readonly g = new Graphics();
private readonly caption: { ink: Text; edge: Text };
readonly says: Text;
lit = false;
constructor(name: string, onToggle: (on: boolean) => void) {
super();
this.cursor = 'pointer';
this.addChild(this.g);
this.caption = engrave(this, name, 9);
const style = textStyle('small').clone();
style.fill = INK;
style.fontSize = 11;
this.says = crispText({ text: 'off', style });
this.says.eventMode = 'none';
this.addChild(this.says);
// A press on the lever is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onToggle(!this.lit);
});
}
set(on: boolean, says: string): void {
if (on === this.lit && says === this.says.text) return;
this.lit = on;
this.says.text = says;
this.invalidate();
}
override redraw(): void {
const g = this.g.clear();
const lamp = { x: 10, y: this.h / 2 + 4 };
if (this.lit) g.circle(lamp.x, lamp.y, 10).fill({ color: 0xff5a2a, alpha: 0.25 });
g.circle(lamp.x, lamp.y, 6)
.fill(this.lit ? 0xff5a2a : 0x5a2a20)
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(lamp.x - 2, lamp.y - 2, 2).fill({ color: 0xffffff, alpha: this.lit ? 0.7 : 0.2 });
// The lever: up is on.
const body = { x: 24, y: 10, w: 20, h: this.h - 10 };
g.roundRect(body.x, body.y, body.w, body.h, 4).fill(0x2a2c30).stroke({ color: 0x55595f, width: 1 });
const cx = body.x + body.w / 2;
const cy = body.y + body.h / 2;
const tip = this.lit ? body.y - 4 : body.y + body.h + 4;
g.moveTo(cx, cy).lineTo(cx, tip).stroke({ color: 0xb9bdc3, width: 5, cap: 'round' });
g.circle(cx, tip, 4).fill(0xe8eaed).stroke({ color: 0x6d7178, width: 1 });
placeEngraving(this.caption, 52 + (this.w - 52) / 2, 2);
this.says.position.set(52, 20);
}
}
export class CompressorPanel extends Widget { export class CompressorPanel extends Widget {
private readonly plate = new Graphics(); private readonly plate = new Graphics();
private readonly lcd = new Container(); private readonly lcd = new Container();
@@ -357,10 +222,10 @@ export class CompressorPanel extends Widget {
readonly meter = new ReductionMeter(); readonly meter = new ReductionMeter();
readonly leds = new OutputLeds(); readonly leds = new OutputLeds();
readonly autoGain: Lever; readonly autoGain: Lever;
readonly knobs = {} as Record<CompressorKey, Knob>; readonly knobs: Record<CompressorKey, Knob>;
private readonly captions: Record<CompressorKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<CompressorKey>;
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly ledCaption: { ink: Text; edge: Text }; private readonly ledCaption: Engraving;
/** The most the needle and the LEDs have shown since the node was shown (checks). */ /** The most the needle and the LEDs have shown since the node was shown (checks). */
seen = { needle: 0, leds: 0 }; seen = { needle: 0, leds: 0 };
@@ -379,33 +244,18 @@ export class CompressorPanel extends Widget {
this.lcd.addChild(this.lcdName, this.lcdReading); this.lcd.addChild(this.lcdName, this.lcdReading);
this.title = engrave(this, 'COMPRESSOR', 14); this.title = engrave(this, 'COMPRESSOR', 14);
this.ledCaption = engrave(this, 'OUT', 9); this.ledCaption = engrave(this, 'OUT', 9);
this.captions = {} as Record<CompressorKey, { ink: Text; edge: Text }>; this.dials = new DeviceKnobs(
for (const key of COMPRESSOR_KEYS) { this,
const row = COMPRESSOR_ROWS[key]; COMPRESSOR_KEYS,
const look = COMPRESSOR_KNOBS[key]; (key) => ({
const knob = new Knob('', row.default, { row: COMPRESSOR_ROWS[key],
min: row.min, look: { ...COMPRESSOR_KNOBS[key], format: key === 'ratio' ? ratioText : undefined },
max: row.max,
step: row.step,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: PANEL_LAYOUT.knobs[key].size, size: PANEL_LAYOUT.knobs[key].size,
look: 'chrome', caption: PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9,
typed: false, }),
format: key === 'ratio' ? ratioText : undefined, host,
ink: INK, );
onInput: (value) => host.preview(key, value), this.knobs = this.dials.knobs;
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(
this,
look.label.toUpperCase(),
PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9,
);
this.addChild(knob);
}
this.autoGain = new Lever('AUTO GAIN', (on) => host.switchAutoGain(on)); this.autoGain = new Lever('AUTO GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.meter, this.leds, this.autoGain); this.addChild(this.meter, this.leds, this.autoGain);
} }
@@ -416,10 +266,7 @@ export class CompressorPanel extends Widget {
/** The numbers and the links of the node shown; auto-gain is set apart, with what it gives. */ /** The numbers and the links of the node shown; auto-gain is set apart, with what it gives. */
sync(numbers: CompressorNumbers, wired: (key: CompressorKey) => boolean, name: string): void { sync(numbers: CompressorNumbers, wired: (key: CompressorKey) => boolean, name: string): void {
for (const key of COMPRESSOR_KEYS) { this.dials.sync((key) => numbers[key], wired, NOTHING_IDLE);
this.knobs[key].setValue(numbers[key]);
this.knobs[key].setEnabled(!wired(key));
}
if (this.lcdName.text !== name) this.lcdName.text = name; if (this.lcdName.text !== name) this.lcdName.text = name;
} }
@@ -455,21 +302,10 @@ export class CompressorPanel extends Widget {
override layout(): void { override layout(): void {
const L = PANEL_LAYOUT; const L = PANEL_LAYOUT;
const put = (w: Widget, b: Box) => {
w.position.set(b.x, b.y);
w.resize(b.w, b.h);
};
put(this.meter, L.meter); put(this.meter, L.meter);
put(this.leds, L.leds); put(this.leds, L.leds);
put(this.autoGain, L.autoGain); put(this.autoGain, L.autoGain);
for (const key of COMPRESSOR_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + 60, L.title.y + 6); placeEngraving(this.title, L.title.x + 60, L.title.y + 6);
placeEngraving(this.ledCaption, L.leds.x + L.leds.w / 2, L.leds.y - 14); placeEngraving(this.ledCaption, L.leds.x + L.leds.w / 2, L.leds.y - 14);
this.lcd.position.set(L.lcd.x, L.lcd.y); this.lcd.position.set(L.lcd.x, L.lcd.y);
+5 -39
View File
@@ -1,40 +1,22 @@
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { SoundFxBitcrusher } from '../../core/soundfx/nodes'; import { SoundFxBitcrusher } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type CrusherHost, CrusherPanel } from './CrusherPanel'; import { type CrusherHost, CrusherPanel } from './CrusherPanel';
import { crushOf, type CrushKey, crushTexts, crushTrace, crushValues, idleCrushKeys } from './crusherView'; import { crushOf, crushTexts, crushTrace, crushValues, idleCrushKeys } from './crusherView';
import { NodeParamDrag } from './nodeParamDrag'; import { DeviceFace } from './deviceFace';
/** /**
* A Bitcrusher node drawn as its device (#399): what a sine comes out as, worked out from its * A Bitcrusher node drawn as its device (#399): what a sine comes out as, worked out from its
* settings by the node's own crush. Nothing on it moves by itself, and it listens to nothing. * settings by the node's own crush. Nothing on it moves by itself, and it listens to nothing.
*/ */
export class CrusherFace implements NodeFace, CrusherHost { export class CrusherFace extends DeviceFace implements NodeFace, CrusherHost {
readonly widget: CrusherPanel; readonly widget: CrusherPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxBitcrusher.type, 'crusher-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new CrusherPanel(this); this.widget = new CrusherPanel(this);
this.drag = new NodeParamDrag(state, 'crusher-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxBitcrusher.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -49,20 +31,4 @@ export class CrusherFace implements NodeFace, CrusherHost {
crush: crushOf(values), crush: crushOf(values),
}); });
} }
tick(): boolean {
return false;
}
hide(): void {}
preview(key: CrushKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: CrushKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
} }
+43 -96
View File
@@ -5,11 +5,10 @@
* each bit kept, one beside the name that lights the more it crushes, and the bits, the rate and * each bit kept, one beside the name that lights the more it crushes, and the bits, the rate and
* the wet as knobs. * the wet as knobs.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { CRUSH_ROWS } from '../../core/soundfx/nodes'; import { CRUSH_ROWS } from '../../core/soundfx/nodes';
import { mixColor } from '../../core/util/color'; import { mixColor } from '../../core/util/color';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { import {
BIT_LAMPS, BIT_LAMPS,
bitLampsLit, bitLampsLit,
@@ -27,7 +26,20 @@ import {
SINE_HZ, SINE_HZ,
TEXT_ROW, TEXT_ROW,
} from './crusherView'; } from './crusherView';
import { type Box, KNOB_CAPTION } from './hardwareView'; import {
bezelText,
DeviceKnobs,
drawBezel,
drawLamp,
drawPlate,
type Engraving,
engrave,
IDLE_ALPHA,
placeEngraving,
type PlateLook,
screenText,
} from './deviceParts';
import type { Box } from './hardwareView';
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
export interface CrusherHost { export interface CrusherHost {
@@ -72,28 +84,9 @@ const SCOPE = {
out: 0xf2fbff, out: 0xf2fbff,
}; };
const LAMP = { off: 0x4a2a3a, lit: 0xff3d9a, bitOff: 0x3a2a3a }; const LAMP = { off: 0x4a2a3a, lit: 0xff3d9a, bitOff: 0x3a2a3a };
const IDLE_ALPHA = 0.4;
/** The pixels between two scan lines. */ /** The pixels between two scan lines. */
const SCAN = 3; const SCAN = 3;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** /**
* The screen: the sine going in as a grey ghost and what the crush makes of it in cells over it, * The screen: the sine going in as a grey ghost and what the crush makes of it in cells over it,
* one per hold, on a grid of its steps and holds where they are wide enough to tell apart. Where * one per hold, on a grid of its steps and holds where they are wide enough to tell apart. Where
@@ -113,18 +106,8 @@ class PixelScope extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.glass, this.cells); this.addChild(this.glass, this.cells);
this.under = { noise: this.made(0), fold: this.made(1) }; this.under = { noise: screenText(this, 9, SCOPE.text, 0), fold: screenText(this, 9, SCOPE.text, 1) };
this.spanText = crispText({ text: '', style: scopeStyle(8, SCOPE.dim) }); this.spanText = screenText(this, 8, SCOPE.dim);
this.spanText.eventMode = 'none';
this.addChild(this.spanText);
}
private made(ax: number): Text {
const t = crispText({ text: '', style: scopeStyle(9, SCOPE.text) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(trace: CrushTrace, wet: number, texts: PixelScope['texts']): void { show(trace: CrushTrace, wet: number, texts: PixelScope['texts']): void {
@@ -221,11 +204,11 @@ export class CrusherPanel extends Widget {
private readonly bezel = new Graphics(); private readonly bezel = new Graphics();
private readonly lamps = new Graphics(); private readonly lamps = new Graphics();
readonly scope = new PixelScope(); readonly scope = new PixelScope();
readonly knobs = {} as Record<CrushKey, Knob>; readonly knobs: Record<CrushKey, Knob>;
private readonly captions = {} as Record<CrushKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<CrushKey>;
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly lampCaption: { ink: Text; edge: Text }; private readonly lampCaption: Engraving;
private readonly bitCaption: { ink: Text; edge: Text }; private readonly bitCaption: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** How crushed in a word, above the screen on the right. */ /** How crushed in a word, above the screen on the right. */
readonly crushText: Text; readonly crushText: Text;
@@ -242,35 +225,16 @@ export class CrusherPanel extends Widget {
this.title = engrave(this, 'BITCRUSHER', 14); this.title = engrave(this, 'BITCRUSHER', 14);
this.lampCaption = engrave(this, 'CRUSH', 9); this.lampCaption = engrave(this, 'CRUSH', 9);
this.bitCaption = engrave(this, 'BITS KEPT', 9); this.bitCaption = engrave(this, 'BITS KEPT', 9);
this.screenName = crispText({ text: `A ${SINE_HZ} Hz SINE AT 0 dBFS`, style: bezelStyle() }); this.screenName = bezelText(this, `A ${SINE_HZ} Hz SINE AT 0 dBFS`, BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.crushText = bezelText(this, '', BEZEL_TEXT, true);
this.crushText = crispText({ text: '', style: bezelStyle() }); this.dials = new DeviceKnobs(
this.crushText.anchor.set(1, 0); this,
this.crushText.eventMode = 'none'; CRUSH_KEYS,
this.addChild(this.screenName, this.crushText); (key) => ({ row: CRUSH_ROWS[key], look: CRUSH_KNOBS[key], size: CRUSHER_LAYOUT.knobs[key].size }),
for (const key of CRUSH_KEYS) { host,
const row = CRUSH_ROWS[key]; ARC,
const look = CRUSH_KNOBS[key]; );
const knob = new Knob('', row.default, { this.knobs = this.dials.knobs;
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: CRUSHER_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: ARC,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -279,13 +243,7 @@ export class CrusherPanel extends Widget {
sync(shown: CrusherShown): void { sync(shown: CrusherShown): void {
const v = shown.values; const v = shown.values;
for (const key of CRUSH_KEYS) { this.dials.sync((key) => v[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
this.scope.show(shown.trace, v.mix, shown.texts); this.scope.show(shown.trace, v.mix, shown.texts);
this.crushText.text = crushWord(shown.crush); this.crushText.text = crushWord(shown.crush);
const lit = bitLampsLit(v.bits); const lit = bitLampsLit(v.bits);
@@ -306,14 +264,7 @@ export class CrusherPanel extends Widget {
this.crushText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.crushText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
placeEngraving(this.bitCaption, L.bitCaption.x, L.bitCaption.y); placeEngraving(this.bitCaption, L.bitCaption.x, L.bitCaption.y);
for (const key of CRUSH_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -321,12 +272,7 @@ export class CrusherPanel extends Widget {
const L = CRUSHER_LAYOUT; const L = CRUSHER_LAYOUT;
drawPlate(this.plate.clear(), CRUSHER_PANEL, BUBBLEGUM, L.screws); drawPlate(this.plate.clear(), CRUSHER_PANEL, BUBBLEGUM, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x15101c);
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x15101c, width: 1 });
this.drawLamps(); this.drawLamps();
} }
@@ -336,12 +282,13 @@ export class CrusherPanel extends Widget {
const g = this.lamps.clear(); const g = this.lamps.clear();
const { x, y, r } = L.lamp; const { x, y, r } = L.lamp;
const lit = this.crush > 0; const lit = this.crush > 0;
const color = mixColor(LAMP.off, LAMP.lit, 0.4 + 0.6 * this.crush); drawLamp(
if (lit) g.circle(x, y, r + 4).fill({ color: LAMP.lit, alpha: 0.3 * this.crush }); g,
g.circle(x, y, r) { x, y, r },
.fill(lit ? color : LAMP.off) lit ? mixColor(LAMP.off, LAMP.lit, 0.4 + 0.6 * this.crush) : LAMP.off,
.stroke({ color: 0x2a2c30, width: 1.5 }); lit ? { color: LAMP.lit, alpha: 0.3 * this.crush } : null,
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 }); lit ? 0.7 : 0.2,
);
const dim = this.idle.has('bits') ? IDLE_ALPHA : 1; const dim = this.idle.has('bits') ? IDLE_ALPHA : 1;
L.bitLamps.forEach((box: Box, i) => { L.bitLamps.forEach((box: Box, i) => {
const share = this.bitsLit[i] ?? 0; const share = this.bitsLit[i] ?? 0;
+1 -1
View File
@@ -42,7 +42,7 @@ import { BandCurve, type CurveHost } from './BandCurve';
import { fittingCount, shownRows } from './eqView'; import { fittingCount, shownRows } from './eqView';
/** The height the curve keeps before the row under it comes in (#385). */ /** The height the curve keeps before the row under it comes in (#385). */
export const CURVE_ROOM = 120; const CURVE_ROOM = 120;
/** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */ /** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */
export const CURVE_NODE_MIN: Size = { w: 240, h: 120 }; export const CURVE_NODE_MIN: Size = { w: 240, h: 120 };
+6 -41
View File
@@ -1,42 +1,23 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { DELAY_SYNC, PING_PONG, SoundFxDelay } from '../../core/soundfx/nodes'; import { DELAY_SYNC, PING_PONG, SoundFxDelay } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type DelayHost, DelayPanel } from './DelayPanel'; import { type DelayHost, DelayPanel } from './DelayPanel';
import { type DelayKey, delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView'; import { delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView';
import { NodeParamDrag } from './nodeParamDrag'; import { DeviceFace } from './deviceFace';
/** /**
* A Delay node drawn as its device (#393): its echoes drawn from its settings – at 120 BPM where * A Delay node drawn as its device (#393): its echoes drawn from its settings – at 120 BPM where
* they count beats, as the voice's own tempo is not known here – and a light that runs over them * they count beats, as the voice's own tempo is not known here – and a light that runs over them
* at the beat they come at for as long as the node is in the picture. It listens to nothing. * at the beat they come at for as long as the node is in the picture. It listens to nothing.
*/ */
export class DelayFace implements NodeFace, DelayHost { export class DelayFace extends DeviceFace implements NodeFace, DelayHost {
readonly widget: DelayPanel; readonly widget: DelayPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxDelay.type, 'delay-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new DelayPanel(this); this.widget = new DelayPanel(this);
this.drag = new NodeParamDrag(state, 'delay-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxDelay.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -52,23 +33,11 @@ export class DelayFace implements NodeFace, DelayHost {
} }
/** The light runs on by the page's clock, so it keeps the beat however the frames come. */ /** The light runs on by the page's clock, so it keeps the beat however the frames come. */
tick(): boolean { override tick(): boolean {
this.widget.step(performance.now() / 1000); this.widget.step(performance.now() / 1000);
return true; return true;
} }
hide(): void {}
preview(key: DelayKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: DelayKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
switchSync(beats: boolean): void { switchSync(beats: boolean): void {
this.set(DELAY_SYNC.key, beats ? 'beats' : 'seconds'); this.set(DELAY_SYNC.key, beats ? 'beats' : 'seconds');
} }
@@ -76,8 +45,4 @@ export class DelayFace implements NodeFace, DelayHost {
switchPingPong(on: boolean): void { switchPingPong(on: boolean): void {
this.set(PING_PONG.key, on); this.set(PING_PONG.key, on);
} }
private set(key: string, value: string | boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
}
} }
+62 -114
View File
@@ -4,11 +4,10 @@
* left and right taking turns with ping-pong, warm while they keep their highs – a light that runs * left and right taking turns with ping-pong, warm while they keep their highs – a light that runs
* over them at the beat they come at, a lamp that blinks with it, two levers and four knobs. * over them at the beat they come at, a lamp that blinks with it, two levers and four knobs.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { DELAY_ROWS } from '../../core/soundfx/nodes'; import { DELAY_ROWS } from '../../core/soundfx/nodes';
import { mixColor } from '../../core/util/color'; import { mixColor } from '../../core/util/color';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { ALUMINIUM, drawPlate, engrave, INK, Lever, placeEngraving } from './CompressorPanel';
import { import {
barHeight, barHeight,
DELAY_KEYS, DELAY_KEYS,
@@ -23,7 +22,24 @@ import {
TEXT_ROW, TEXT_ROW,
timeText, timeText,
} from './delayView'; } from './delayView';
import { type Box, KNOB_CAPTION } from './hardwareView'; import {
ALUMINIUM,
bezelText,
DeviceKnobs,
drawBezel,
drawLamp,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
Lever,
placeEngraving,
placeGroups,
put,
screenText,
} from './deviceParts';
import { percentText } from './reverbView'; import { percentText } from './reverbView';
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
@@ -57,29 +73,10 @@ const SCOPE = {
sweep: 0xffffff, sweep: 0xffffff,
}; };
const LAMP = 0x4dff5a; const LAMP = 0x4dff5a;
const IDLE_ALPHA = 0.4;
/** The sound's bar and the echoes', and the room left round the bars. */ /** The sound's bar and the echoes', and the room left round the bars. */
const BAR = 4; const BAR = 4;
const PAD = { left: 18, right: 22, top: 12 }; const PAD = { left: 18, right: 22, top: 12 };
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** A bar as drawn: where it stands and how far up and down from the middle it goes. */ /** A bar as drawn: where it stands and how far up and down from the middle it goes. */
interface DrawnBar { interface DrawnBar {
x: number; x: number;
@@ -109,17 +106,12 @@ class DelayScope extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.g, this.sweep); this.addChild(this.g, this.sweep);
this.sides = { left: this.made('L', 9, SCOPE.dim, 0, 0), right: this.made('R', 9, SCOPE.dim, 0, 1) }; this.sides = {
this.under = { every: this.made('', 9, SCOPE.text, 0, 0), ends: this.made('', 9, SCOPE.text, 1, 0) }; left: screenText(this, 9, SCOPE.dim, 0, 0, 'L'),
this.moreText = this.made('', 9, SCOPE.dim, 1, 0.5); right: screenText(this, 9, SCOPE.dim, 0, 1, 'R'),
} };
this.under = { every: screenText(this, 9, SCOPE.text, 0), ends: screenText(this, 9, SCOPE.text, 1) };
private made(text: string, size: number, fill: number, ax: number, ay: number): Text { this.moreText = screenText(this, 9, SCOPE.dim, 1, 0.5);
const t = crispText({ text, style: scopeStyle(size, fill) });
t.anchor.set(ax, ay);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void { show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void {
@@ -230,11 +222,11 @@ export class DelayPanel extends Widget {
readonly scope = new DelayScope(); readonly scope = new DelayScope();
readonly syncLever: Lever; readonly syncLever: Lever;
readonly pingPongLever: Lever; readonly pingPongLever: Lever;
readonly knobs = {} as Record<DelayKey, Knob>; readonly knobs: Record<DelayKey, Knob>;
private readonly captions = {} as Record<DelayKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<DelayKey>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly lampCaption: { ink: Text; edge: Text }; private readonly lampCaption: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** Where the echoes' highs end, above the screen on the right. */ /** Where the echoes' highs end, above the screen on the right. */
readonly highs: Text; readonly highs: Text;
@@ -251,38 +243,26 @@ export class DelayPanel extends Widget {
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, 'DELAY', 14); this.title = engrave(this, 'DELAY', 14);
this.lampCaption = engrave(this, 'ECHO', 9); this.lampCaption = engrave(this, 'ECHO', 9);
this.screenName = crispText({ text: 'ECHOES OVER TIME', style: bezelStyle() }); this.screenName = bezelText(this, 'ECHOES OVER TIME', BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.highs = bezelText(this, '', BEZEL_TEXT, true);
this.highs = crispText({ text: '', style: bezelStyle() }); this.groupNames = engraveGroups(this, DELAY_LAYOUT.groups);
this.highs.anchor.set(1, 0);
this.highs.eventMode = 'none';
this.addChild(this.screenName, this.highs);
this.groupNames = DELAY_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
this.syncLever = new Lever('SYNC', (on) => host.switchSync(on)); this.syncLever = new Lever('SYNC', (on) => host.switchSync(on));
this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on)); this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on));
this.addChild(this.syncLever, this.pingPongLever); this.addChild(this.syncLever, this.pingPongLever);
for (const key of DELAY_KEYS) { this.dials = new DeviceKnobs(
const row = DELAY_ROWS[key]; this,
const look = DELAY_KNOBS[key]; DELAY_KEYS,
const knob = new Knob('', row.default, { (key) => ({
min: row.min ?? 0, row: DELAY_ROWS[key],
max: row.max ?? 1, look: {
step: row.step ?? 0.01, ...DELAY_KNOBS[key],
taper: look.taper, format: key === 'time' ? (value) => timeText(value, this.beats) : percentText,
default: row.default, },
decimals: look.decimals,
size: DELAY_LAYOUT.knobs[key].size, size: DELAY_LAYOUT.knobs[key].size,
look: 'chrome', }),
typed: false, host,
format: key === 'time' ? (value) => timeText(value, this.beats) : percentText, );
ink: INK, this.knobs = this.dials.knobs;
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -296,13 +276,7 @@ export class DelayPanel extends Widget {
// The number under the time knob reads otherwise now, though its value stays. // The number under the time knob reads otherwise now, though its value stays.
this.knobs.time.reformat(); this.knobs.time.reformat();
} }
for (const key of DELAY_KEYS) { this.dials.sync((key) => v[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
this.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds'); this.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds');
this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1; this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1;
this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off'); this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off');
@@ -324,37 +298,25 @@ export class DelayPanel extends Widget {
private drawLamp(): void { private drawLamp(): void {
const { x, y, r } = DELAY_LAYOUT.lamp; const { x, y, r } = DELAY_LAYOUT.lamp;
const lit = this.lampLit === true; const lit = this.lampLit === true;
const g = this.lampLight.clear(); drawLamp(
if (lit) g.circle(x, y, r + 4).fill({ color: LAMP, alpha: 0.25 }); this.lampLight.clear(),
g.circle(x, y, r) { x, y, r },
.fill(lit ? LAMP : 0x1f4a22) lit ? LAMP : 0x1f4a22,
.stroke({ color: 0x2a2c30, width: 1.5 }); lit ? { color: LAMP, alpha: 0.25 } : null,
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 }); lit ? 0.7 : 0.2,
);
} }
override layout(): void { override layout(): void {
const L = DELAY_LAYOUT; const L = DELAY_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
put(this.syncLever, L.sync); put(this.syncLever, L.sync);
put(this.pingPongLever, L.pingPong); put(this.pingPongLever, L.pingPong);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
for (const key of DELAY_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key]; placeGroups(this.groupNames, L.groups);
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -362,25 +324,11 @@ export class DelayPanel extends Widget {
const L = DELAY_LAYOUT; const L = DELAY_LAYOUT;
drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, L.screws); drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1b1c20);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x1b1c20, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
this.drawLamp(); this.drawLamp();
} }
} }
+5 -38
View File
@@ -1,12 +1,10 @@
import type { GraphNode } from '../../core/graph';
import { InstrumentEnvelope } from '../../core/instrument/nodes'; import { InstrumentEnvelope } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project'; import { findGraphAsset } from '../../core/model/Project';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { type EnvelopeHost, EnvelopePanel } from './EnvelopePanel'; import { type EnvelopeHost, EnvelopePanel } from './EnvelopePanel';
import { type EnvelopeKey, envelopeValues, idleEnvelopeKeys } from './envelopeView'; import { envelopeValues, idleEnvelopeKeys } from './envelopeView';
import { NodeParamDrag } from './nodeParamDrag';
/** The output an Envelope moves a number by. */ /** The output an Envelope moves a number by. */
const VALUE = 'value'; const VALUE = 'value';
@@ -15,29 +13,14 @@ const VALUE = 'value';
* An Envelope node drawn as its device (#388): its curve drawn from its settings, every one of them * An Envelope node drawn as its device (#388): its curve drawn from its settings, every one of them
* worked right in the graph. It listens to nothing, so it has nothing to move between changes. * worked right in the graph. It listens to nothing, so it has nothing to move between changes.
*/ */
export class EnvelopeFace implements NodeFace, EnvelopeHost { export class EnvelopeFace extends DeviceFace implements NodeFace, EnvelopeHost {
readonly widget: EnvelopePanel; readonly widget: EnvelopePanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, InstrumentEnvelope.type, 'envelope-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new EnvelopePanel(this); this.widget = new EnvelopePanel(this);
this.drag = new NodeParamDrag(state, 'envelope-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentEnvelope.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -54,20 +37,4 @@ export class EnvelopeFace implements NodeFace, EnvelopeHost {
idle: idleEnvelopeKeys(values, this.wired, valueTaken), idle: idleEnvelopeKeys(values, this.wired, valueTaken),
}); });
} }
tick(): boolean {
return false;
}
hide(): void {}
preview(key: EnvelopeKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: EnvelopeKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
} }
+46 -88
View File
@@ -5,10 +5,24 @@
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { ENVELOPE_ROWS } from '../../core/instrument/nodes'; import { ENVELOPE_ROWS } from '../../core/instrument/nodes';
import { crispText, Knob, NumberField, textStyle, Widget } from '../../ui'; import { crispText, type Knob, NumberField, Widget } from '../../ui';
import { formatNumber } from '../../ui/math'; import { formatNumber } from '../../ui/math';
import {
bezelText,
DeviceKnobs,
drawBezel,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
placeEngraving,
placeGroups,
type PlateLook,
screenStyle,
} from './deviceParts';
import { apartLabels } from './eqView'; import { apartLabels } from './eqView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { import {
ENVELOPE_FIELD_KEYS, ENVELOPE_FIELD_KEYS,
ENVELOPE_KNOB_KEYS, ENVELOPE_KNOB_KEYS,
@@ -55,18 +69,10 @@ const BEZEL = 0x2f3a3a;
const BEZEL_TEXT = 0xb6d4cc; const BEZEL_TEXT = 0xb6d4cc;
/** An oscilloscope's dark glass and its amber trace. */ /** An oscilloscope's dark glass and its amber trace. */
const SCOPE = { glass: 0x101a1b, grid: 0x223536, trace: 0xffb43c, dim: 0xa0742c }; const SCOPE = { glass: 0x101a1b, grid: 0x223536, trace: 0xffb43c, dim: 0xa0742c };
const IDLE_ALPHA = 0.4;
/** The rows of text above and below the curve. */ /** The rows of text above and below the curve. */
const TEXT_ROW = 14; const TEXT_ROW = 14;
function scopeStyle(size: number, fill: number = SCOPE.trace): TextStyle { const scopeStyle = (size: number, fill: number = SCOPE.trace): TextStyle => screenStyle(size, fill);
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function dashed(g: Graphics, x0: number, x1: number, y: number, dash = 3): void { function dashed(g: Graphics, x0: number, x1: number, y: number, dash = 3): void {
for (let x = x0; x < x1; x += dash * 2) g.moveTo(x, y).lineTo(Math.min(x1, x + dash), y); for (let x = x0; x < x1; x += dash * 2) g.moveTo(x, y).lineTo(Math.min(x1, x + dash), y);
@@ -198,11 +204,12 @@ export class EnvelopePanel extends Widget {
private readonly bezel = new Graphics(); private readonly bezel = new Graphics();
private readonly rules = new Graphics(); private readonly rules = new Graphics();
readonly screen = new EnvelopeScreen(); readonly screen = new EnvelopeScreen();
readonly knobs = {} as Record<(typeof ENVELOPE_KNOB_KEYS)[number], Knob>; readonly knobs: Record<(typeof ENVELOPE_KNOB_KEYS)[number], Knob>;
readonly fields = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], NumberField>; readonly fields = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], NumberField>;
private readonly captions = {} as Record<EnvelopeKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<(typeof ENVELOPE_KNOB_KEYS)[number]>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly fieldCaptions = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], Engraving>;
private readonly title: { ink: Text; edge: Text }; private readonly groupNames: Engraving[];
private readonly title: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** What the value moves between, while a link takes it (checks). */ /** What the value moves between, while a link takes it (checks). */
readonly range: Text; readonly range: Text;
@@ -215,40 +222,20 @@ export class EnvelopePanel extends Widget {
this.minHeight = ENVELOPE_PANEL.h; this.minHeight = ENVELOPE_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.screen); this.addChild(this.plate, this.rules, this.bezel, this.screen);
this.title = engrave(this, 'ENVELOPE', 14); this.title = engrave(this, 'ENVELOPE', 14);
const nameStyle = textStyle('small').clone(); this.screenName = bezelText(this, 'LEVEL OVER A NOTE', BEZEL_TEXT);
nameStyle.fill = BEZEL_TEXT; this.range = bezelText(this, '', BEZEL_TEXT, true);
nameStyle.fontSize = 9; this.groupNames = engraveGroups(this, ENVELOPE_LAYOUT.groups);
nameStyle.fontWeight = 'bold'; this.dials = new DeviceKnobs<(typeof ENVELOPE_KNOB_KEYS)[number]>(
nameStyle.letterSpacing = 1; this,
this.screenName = crispText({ text: 'LEVEL OVER A NOTE', style: nameStyle }); ENVELOPE_KNOB_KEYS,
this.screenName.eventMode = 'none'; (key) => ({
this.range = crispText({ text: '', style: nameStyle }); row: ENVELOPE_ROWS[key],
this.range.anchor.set(1, 0); look: ENVELOPE_KNOBS[key],
this.range.eventMode = 'none';
this.addChild(this.screenName, this.range);
this.groupNames = ENVELOPE_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
for (const key of ENVELOPE_KNOB_KEYS) {
const row = ENVELOPE_ROWS[key];
const look = ENVELOPE_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 0,
step: row.step ?? 1,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: ENVELOPE_LAYOUT.knobs[key].size, size: ENVELOPE_LAYOUT.knobs[key].size,
look: 'chrome', }),
typed: false, host,
format: look.format, );
ink: INK, this.knobs = this.dials.knobs;
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
for (const key of ENVELOPE_FIELD_KEYS) { for (const key of ENVELOPE_FIELD_KEYS) {
const row = ENVELOPE_ROWS[key]; const row = ENVELOPE_ROWS[key];
const field = new NumberField(row.default, { const field = new NumberField(row.default, {
@@ -259,7 +246,7 @@ export class EnvelopePanel extends Widget {
onChange: (value) => host.commit(key, value), onChange: (value) => host.commit(key, value),
}); });
this.fields[key] = field; this.fields[key] = field;
this.captions[key] = engrave(this, key.toUpperCase(), 9); this.fieldCaptions[key] = engrave(this, key.toUpperCase(), 9);
this.addChild(field); this.addChild(field);
} }
} }
@@ -269,20 +256,14 @@ export class EnvelopePanel extends Widget {
} }
sync(shown: EnvelopeShown): void { sync(shown: EnvelopeShown): void {
for (const key of ENVELOPE_KNOB_KEYS) { this.dials.sync((key) => shown.values[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(shown.values[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
}
for (const key of ENVELOPE_FIELD_KEYS) { for (const key of ENVELOPE_FIELD_KEYS) {
const field = this.fields[key]; const field = this.fields[key];
field.setNumber(shown.values[key]); field.setNumber(shown.values[key]);
field.setEnabled(!shown.wired(key)); field.setEnabled(!shown.wired(key));
field.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; field.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.fieldCaptions[key].ink.alpha = field.alpha;
} }
for (const key of [...ENVELOPE_KNOB_KEYS, ...ENVELOPE_FIELD_KEYS])
this.captions[key].ink.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.idle = shown.idle; this.idle = shown.idle;
this.screen.show(envelopeTrace(shown.values)); this.screen.show(envelopeTrace(shown.values));
// Above the screen, clear of the curve: what the value moves between while a link takes it. // Above the screen, clear of the curve: what the value moves between while a link takes it.
@@ -297,14 +278,7 @@ export class EnvelopePanel extends Widget {
this.screen.resize(L.screen.w, L.screen.h); this.screen.resize(L.screen.w, L.screen.h);
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.range.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.range.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
for (const key of ENVELOPE_KNOB_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
for (const key of ENVELOPE_FIELD_KEYS) { for (const key of ENVELOPE_FIELD_KEYS) {
const box = L.fields[key]; const box = L.fields[key];
const field = this.fields[key]; const field = this.fields[key];
@@ -312,14 +286,12 @@ export class EnvelopePanel extends Widget {
field.position.set(Math.round(box.x), Math.round(box.y + (box.h - size.h) / 2)); field.position.set(Math.round(box.x), Math.round(box.y + (box.h - size.h) / 2));
field.resize(box.w, size.h); field.resize(box.w, size.h);
placeEngraving( placeEngraving(
this.captions[key], this.fieldCaptions[key],
box.x + box.w / 2, box.x + box.w / 2,
L.knobs.attack.y - L.knobs.attack.size / 2 - KNOB_CAPTION, L.knobs.attack.y - L.knobs.attack.size / 2 - KNOB_CAPTION,
); );
} }
L.groups.forEach((group, i) => placeGroups(this.groupNames, L.groups);
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -327,24 +299,10 @@ export class EnvelopePanel extends Widget {
const L = ENVELOPE_LAYOUT; const L = ENVELOPE_LAYOUT;
drawPlate(this.plate.clear(), ENVELOPE_PANEL, SEA, L.screws); drawPlate(this.plate.clear(), ENVELOPE_PANEL, SEA, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1c2323);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x1c2323, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ import type { EditorState } from '../state';
import { CurvePanel } from './CurvePanel'; import { CurvePanel } from './CurvePanel';
/** The panel in the node (#381): a little narrower than the Compressor's, and taller. */ /** The panel in the node (#381): a little narrower than the Compressor's, and taller. */
export const EQ_NODE_SIZE = { w: 720, h: 360 }; const EQ_NODE_SIZE = { w: 720, h: 360 };
export class EqPanel extends CurvePanel { export class EqPanel extends CurvePanel {
private readonly bandLabel: Label; private readonly bandLabel: Label;
+3 -3
View File
@@ -15,11 +15,11 @@ import {
} from '../../core/soundfx/nodes'; } from '../../core/soundfx/nodes';
import type { PassBand } from '../../core/soundfx/parametricEq'; import type { PassBand } from '../../core/soundfx/parametricEq';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { CurvePanel } from './CurvePanel'; import { CurvePanel } from './CurvePanel';
import { paramWired } from './deviceFace';
/** The panel in the node until it is dragged to another size: room for the whole row. */ /** The panel in the node until it is dragged to another size: room for the whole row. */
export const FILTER_NODE_SIZE = { w: 560, h: 280 }; const FILTER_NODE_SIZE = { w: 560, h: 280 };
const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 }; const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 };
@@ -57,7 +57,7 @@ export class FilterPanel extends CurvePanel {
override pick(): void {} override pick(): void {}
protected override locked(): { frequency: boolean; gain: boolean; q: boolean } { protected override locked(): { frequency: boolean; gain: boolean; q: boolean } {
const wired = (key: string) => compressorWired(this.state.project, this.assetId, this.nodeId, key); const wired = (key: string) => paramWired(this.state.project, this.assetId, this.nodeId, key);
return { return {
frequency: wired(FILTER_ROWS.cutoff.key), frequency: wired(FILTER_ROWS.cutoff.key),
gain: wired(FILTER_ROWS.gain.key), gain: wired(FILTER_ROWS.gain.key),
+8 -39
View File
@@ -1,35 +1,23 @@
import type { GraphNode } from '../../core/graph';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { findGraphAsset } from '../../core/model/Project';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { type KnobDevice, type KnobHost, KnobPanel } from './KnobPanel'; import { type KnobDevice, type KnobHost, KnobPanel } from './KnobPanel';
import { NodeParamDrag } from './nodeParamDrag';
/** /**
* A node drawn as a device with one knob (#384, #390), turned right in the graph. It listens to * A node drawn as a device with one knob (#384, #390), turned right in the graph. It listens to
* nothing, so it has nothing to move between changes. * nothing, so it has nothing to move between changes.
*/ */
export class KnobFace implements NodeFace, KnobHost { export class KnobFace extends DeviceFace implements NodeFace, KnobHost {
readonly widget: KnobPanel; readonly widget: KnobPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(
private readonly state: EditorState, state: EditorState,
private readonly assetId: string, assetId: string,
private readonly nodeId: string, nodeId: string,
private readonly device: KnobDevice, private readonly device: KnobDevice,
) { ) {
super(state, assetId, nodeId, device.type, 'knob-face');
this.widget = new KnobPanel(this, device); this.widget = new KnobPanel(this, device);
this.drag = new NodeParamDrag(state, 'knob-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === this.device.type ? node : null;
} }
sync(): void { sync(): void {
@@ -39,32 +27,13 @@ export class KnobFace implements NodeFace, KnobHost {
const value = node.params[this.device.row.key]; const value = node.params[this.device.row.key];
this.widget.sync( this.widget.sync(
typeof value === 'number' && Number.isFinite(value) ? value : this.device.row.default, typeof value === 'number' && Number.isFinite(value) ? value : this.device.row.default,
compressorWired(this.state.project, this.assetId, this.nodeId, this.device.row.key), this.wired(this.device.row.key),
lever !== undefined && node.params[lever.key] === lever.values[1], lever !== undefined && node.params[lever.key] === lever.values[1],
); );
} }
tick(): boolean {
return false;
}
hide(): void {}
preview(value: number): void {
const node = this.node();
if (node) this.drag.preview(node, this.device.row.key, value);
}
commit(value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, this.device.row.key, value);
}
flip(on: boolean): void { flip(on: boolean): void {
const lever = this.device.lever; const lever = this.device.lever;
if (lever && this.node()) if (lever) this.set(lever.key, lever.values[on ? 1 : 0]);
this.state.execute(
new SetNodeParamCommand(this.assetId, this.nodeId, lever.key, lever.values[on ? 1 : 0]),
);
} }
} }
+27 -34
View File
@@ -3,8 +3,8 @@
* orange plate, the Pitch (#390) on a green one, the Mix (#390) on a blue one with a lever for * orange plate, the Pitch (#390) on a green one, the Mix (#390) on a blue one with a lever for
* its curve, and the Pan (#392) on a cyan one. All on the Compressor's plate (#379). * its curve, and the Pan (#392) on a cyan one. All on the Compressor's plate (#379).
*/ */
import { Graphics, type Text } from 'pixi.js'; import { Graphics } from 'pixi.js';
import { Knob, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { import {
GAIN_ROW, GAIN_ROW,
MIX_CURVE_ROW, MIX_CURVE_ROW,
@@ -16,12 +16,21 @@ import {
SoundFxPan, SoundFxPan,
SoundFxPitch, SoundFxPitch,
} from '../../core/soundfx/nodes'; } from '../../core/soundfx/nodes';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; import {
chromeKnob,
drawPlate,
type Engraving,
engrave,
Lever,
placeEngraving,
placeKnob,
type PlateLook,
put,
} from './deviceParts';
import { import {
GAIN_LAYOUT, GAIN_LAYOUT,
GAIN_PANEL, GAIN_PANEL,
GAIN_TAPER, GAIN_TAPER,
KNOB_CAPTION,
knobEnds, knobEnds,
MIX_LAYOUT, MIX_LAYOUT,
MIX_PANEL, MIX_PANEL,
@@ -31,8 +40,8 @@ import {
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
export interface KnobHost { export interface KnobHost {
preview(value: number): void; preview(key: string, value: number): void;
commit(value: number): void; commit(key: string, value: number): void;
/** The lever, on a device that has one. */ /** The lever, on a device that has one. */
flip(on: boolean): void; flip(on: boolean): void;
} }
@@ -141,8 +150,8 @@ export class KnobPanel extends Widget {
private readonly plate = new Graphics(); private readonly plate = new Graphics();
readonly knob: Knob; readonly knob: Knob;
readonly lever: Lever | null = null; readonly lever: Lever | null = null;
private readonly caption: { ink: Text; edge: Text }; private readonly caption: Engraving;
private readonly ends: { ink: Text; edge: Text }[]; private readonly ends: Engraving[];
private readonly size: { w: number; h: number }; private readonly size: { w: number; h: number };
private readonly spots: typeof GAIN_LAYOUT; private readonly spots: typeof GAIN_LAYOUT;
@@ -158,23 +167,14 @@ export class KnobPanel extends Widget {
this.addChild(this.plate); this.addChild(this.plate);
this.caption = engrave(this, device.caption, 11); this.caption = engrave(this, device.caption, 11);
this.ends = (device.ends ?? []).map((end) => engrave(this, end, 11)); this.ends = (device.ends ?? []).map((end) => engrave(this, end, 11));
const row = device.row; this.knob = chromeKnob(
this.knob = new Knob('', row.default, { device.row,
min: row.min ?? 0, device,
max: row.max ?? 0, GAIN_LAYOUT.knob.size,
step: row.step ?? 1, device.arc,
taper: device.taper, (value) => host.preview(device.row.key, value),
bipolar: device.bipolar, (value) => host.commit(device.row.key, value),
default: row.default, );
decimals: device.decimals,
size: GAIN_LAYOUT.knob.size,
look: 'chrome',
typed: false,
ink: INK,
arc: device.arc,
onInput: (value) => host.preview(value),
onChange: (value) => host.commit(value),
});
this.addChild(this.knob); this.addChild(this.knob);
if (device.lever) { if (device.lever) {
this.lever = new Lever(device.lever.caption, (on) => host.flip(on)); this.lever = new Lever(device.lever.caption, (on) => host.flip(on));
@@ -196,19 +196,12 @@ export class KnobPanel extends Widget {
override layout(): void { override layout(): void {
const spot = this.spots.knob; const spot = this.spots.knob;
const size = this.knob.measure(); placeKnob(this.knob, this.caption, spot);
this.knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
this.knob.resize(size.w, size.h);
placeEngraving(this.caption, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
const ends = knobEnds(spot); const ends = knobEnds(spot);
const [a, b] = this.ends; const [a, b] = this.ends;
if (a) placeEngraving(a, ends.a.x, ends.a.y - 7); if (a) placeEngraving(a, ends.a.x, ends.a.y - 7);
if (b) placeEngraving(b, ends.b.x, ends.b.y - 7); if (b) placeEngraving(b, ends.b.x, ends.b.y - 7);
if (this.lever) { if (this.lever) put(this.lever, MIX_LAYOUT.curve);
const box = MIX_LAYOUT.curve;
this.lever.position.set(box.x, box.y);
this.lever.resize(box.w, box.h);
}
} }
override redraw(): void { override redraw(): void {
+10 -46
View File
@@ -1,23 +1,18 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { limiterLatency } from '../../core/soundfx/dynamics'; import { limiterLatency } from '../../core/soundfx/dynamics';
import { LIMIT_AUTO_GAIN, SoundFxLimiter, tapId } from '../../core/soundfx/nodes'; import { LIMIT_AUTO_GAIN, SoundFxLimiter } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { LEVEL_WINDOW } from './compressorView'; import { LEVEL_WINDOW } from './compressorView';
import { DeviceFace } from './deviceFace';
import { type LimiterHost, LimiterPanel } from './LimiterPanel'; import { type LimiterHost, LimiterPanel } from './LimiterPanel';
import { import {
idleLimiterKeys, idleLimiterKeys,
limiterAutoGainText, limiterAutoGainText,
type LimiterKey,
limiterReduction, limiterReduction,
limiterValues, limiterValues,
READ_MOST, READ_MOST,
} from './limiterView'; } from './limiterView';
import { NodeParamDrag } from './nodeParamDrag';
/** The rate the sound is read at before the page has any audio. */ /** The rate the sound is read at before the page has any audio. */
const SOME_RATE = 48000; const SOME_RATE = 48000;
@@ -27,31 +22,15 @@ const SOME_RATE = 48000;
* catches what it takes away from the moment a sound starts; once the sound ends, or the node is no * catches what it takes away from the moment a sound starts; once the sound ends, or the node is no
* longer seen, the capture is held until the next sound. * longer seen, the capture is held until the next sound.
*/ */
export class LimiterFace implements NodeFace, LimiterHost { export class LimiterFace extends DeviceFace implements NodeFace, LimiterHost {
readonly widget: LimiterPanel; readonly widget: LimiterPanel;
private readonly drag: NodeParamDrag;
private listening = false;
private lastTick = 0; private lastTick = 0;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxLimiter.type, 'limiter-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new LimiterPanel(this); this.widget = new LimiterPanel(this);
this.drag = new NodeParamDrag(state, 'limiter-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxLimiter.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -65,12 +44,10 @@ export class LimiterFace implements NodeFace, LimiterHost {
} }
/** Reads the taps over the time since the last frame, so no peak falls between two. */ /** Reads the taps over the time since the last frame, so no peak falls between two. */
tick(): boolean { override tick(): boolean {
const node = this.node(); const node = this.node();
if (!node) return false; if (!node) return false;
const id = tapId(this.assetId, this.nodeId); const id = this.listen();
soundLibrary.listen(id);
this.listening = true;
const now = performance.now() / 1000; const now = performance.now() / 1000;
const since = this.lastTick ? now - this.lastTick : LEVEL_WINDOW; const since = this.lastTick ? now - this.lastTick : LEVEL_WINDOW;
this.lastTick = now; this.lastTick = now;
@@ -80,26 +57,13 @@ export class LimiterFace implements NodeFace, LimiterHost {
return this.widget.read(now, reading) || this.widget.live; return this.widget.read(now, reading) || this.widget.live;
} }
hide(): void { override hide(): void {
if (!this.listening) return; if (!this.unlisten()) return;
this.listening = false;
this.lastTick = 0; this.lastTick = 0;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.widget.hold(); this.widget.hold();
} }
preview(key: LimiterKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: LimiterKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
switchAutoGain(on: boolean): void { switchAutoGain(on: boolean): void {
if (this.node()) this.set(LIMIT_AUTO_GAIN.key, on);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, LIMIT_AUTO_GAIN.key, on));
} }
} }
+49 -97
View File
@@ -4,12 +4,26 @@
* along the bottom with the limit across it – held once the sound ends, a lamp beside the name that * along the bottom with the limit across it – held once the sound ends, a lamp beside the name that
* lights while it limits, the auto-gain lever, the threshold big and the ceiling and release small. * lights while it limits, the auto-gain lever, the threshold big and the ceiling and release small.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { LIMIT_ROWS } from '../../core/soundfx/nodes'; import { LIMIT_ROWS } from '../../core/soundfx/nodes';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { secondsText } from './chipView'; import { secondsText } from './chipView';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; import {
import { type Box, KNOB_CAPTION } from './hardwareView'; bezelText,
DeviceKnobs,
drawBezel,
drawLamp,
drawPlate,
type Engraving,
engrave,
IDLE_ALPHA,
Lever,
placeEngraving,
type PlateLook,
put,
screenText,
} from './deviceParts';
import type { Box } from './hardwareView';
import { import {
captureTexts, captureTexts,
type CaptureTexts, type CaptureTexts,
@@ -73,30 +87,11 @@ const STATUS: Record<ReductionCapture['state'] | 'dry', number> = {
held: 0xffc94a, held: 0xffc94a,
dry: BEZEL_TEXT, dry: BEZEL_TEXT,
}; };
const IDLE_ALPHA = 0.4;
/** The strip at the bottom of the screen with the level coming in, the row of times above it, the dB scale on the right. */ /** The strip at the bottom of the screen with the level coming in, the row of times above it, the dB scale on the right. */
const STRIP = 30; const STRIP = 30;
const TIME_ROW = 13; const TIME_ROW = 13;
const SCALE = 22; const SCALE = 22;
function screenStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** /**
* The screen: what is taken away hangs down from the top, the deepest point marked; the level * The screen: what is taken away hangs down from the top, the deepest point marked; the level
* coming in rises from the bottom strip, brighter where it goes over the limit. Time runs across * coming in rises from the bottom strip, brighter where it goes over the limit. Time runs across
@@ -116,17 +111,9 @@ class ReductionScreen extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.glass, this.trace); this.addChild(this.glass, this.trace);
this.marks = REDUCTION_MARKS.map((db) => this.made(`${db}`, 0, 9, SCREEN.dim)); this.marks = REDUCTION_MARKS.map((db) => screenText(this, 9, SCREEN.dim, 0, 0, `${db}`));
this.times = timeMarks(1).map(() => this.made('', 0.5, 9, SCREEN.dim)); this.times = timeMarks(1).map(() => screenText(this, 9, SCREEN.dim, 0.5));
this.under = { most: this.made('', 0, 9, SCREEN.text), side: this.made('', 1, 9, SCREEN.text) }; this.under = { most: screenText(this, 9, SCREEN.text, 0), side: screenText(this, 9, SCREEN.text, 1) };
}
private made(text: string, ax: number, size: number, fill: number): Text {
const t = crispText({ text, style: screenStyle(size, fill) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
private get glassH(): number { private get glassH(): number {
@@ -222,10 +209,10 @@ export class LimiterPanel extends Widget {
private readonly lampLight = new Graphics(); private readonly lampLight = new Graphics();
readonly scope = new ReductionScreen(); readonly scope = new ReductionScreen();
readonly autoGain: Lever; readonly autoGain: Lever;
readonly knobs = {} as Record<LimiterKey, Knob>; readonly knobs: Record<LimiterKey, Knob>;
private readonly captions = {} as Record<LimiterKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<LimiterKey>;
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly lampCaption: { ink: Text; edge: Text }; private readonly lampCaption: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** Whether the screen waits, runs or holds, above it on the right. */ /** Whether the screen waits, runs or holds, above it on the right. */
readonly status: Text; readonly status: Text;
@@ -241,37 +228,23 @@ export class LimiterPanel extends Widget {
this.addChild(this.plate, this.bezel, this.scope, this.lampLight); this.addChild(this.plate, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, 'LIMITER', 14); this.title = engrave(this, 'LIMITER', 14);
this.lampCaption = engrave(this, 'LIMIT', 9); this.lampCaption = engrave(this, 'LIMIT', 9);
this.screenName = crispText({ text: 'GAIN REDUCTION dB', style: bezelStyle() }); this.screenName = bezelText(this, 'GAIN REDUCTION dB', BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.status = bezelText(this, '', BEZEL_TEXT, true);
this.status = crispText({ text: '', style: bezelStyle() });
this.status.anchor.set(1, 0);
this.status.eventMode = 'none';
this.addChild(this.screenName, this.status);
this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on)); this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.autoGain); this.addChild(this.autoGain);
for (const key of LIMITER_KEYS) { this.dials = new DeviceKnobs(
const row = LIMIT_ROWS[key]; this,
const look = LIMITER_KNOBS[key]; LIMITER_KEYS,
const knob = new Knob('', row.default, { (key) => ({
min: row.min ?? 0, row: LIMIT_ROWS[key],
max: row.max ?? 1, look: LIMITER_KNOBS[key],
step: row.step ?? 0.01,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: LIMITER_LAYOUT.knobs[key].size, size: LIMITER_LAYOUT.knobs[key].size,
look: 'chrome', caption: key === 'threshold' ? 11 : 9,
typed: false, }),
format: look.format, host,
ink: INK, ARC,
arc: ARC, );
onInput: (value) => host.preview(key, value), this.knobs = this.dials.knobs;
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), key === 'threshold' ? 11 : 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -280,13 +253,7 @@ export class LimiterPanel extends Widget {
sync(shown: LimiterShown): void { sync(shown: LimiterShown): void {
const v = shown.values; const v = shown.values;
for (const key of LIMITER_KEYS) { this.dials.sync((key) => v[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
this.autoGain.set(v.autoGain, shown.autoGain); this.autoGain.set(v.autoGain, shown.autoGain);
this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1; this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1;
this.idle = shown.idle; this.idle = shown.idle;
@@ -331,33 +298,23 @@ export class LimiterPanel extends Widget {
/** The lamp beside the name: red while it limits, dark otherwise. */ /** The lamp beside the name: red while it limits, dark otherwise. */
private drawLamp(): void { private drawLamp(): void {
const { x, y, r } = LIMITER_LAYOUT.lamp; const { x, y, r } = LIMITER_LAYOUT.lamp;
const g = this.lampLight.clear(); drawLamp(
if (this.lamp) g.circle(x, y, r + 4).fill({ color: SCREEN.reduction, alpha: 0.35 }); this.lampLight.clear(),
g.circle(x, y, r) { x, y, r },
.fill(this.lamp ? SCREEN.reduction : 0x4a1e16) this.lamp ? SCREEN.reduction : 0x4a1e16,
.stroke({ color: 0x2a2c30, width: 1.5 }); this.lamp ? { color: SCREEN.reduction, alpha: 0.35 } : null,
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.lamp ? 0.7 : 0.2 }); this.lamp ? 0.7 : 0.2,
);
} }
override layout(): void { override layout(): void {
const L = LIMITER_LAYOUT; const L = LIMITER_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.status.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.status.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
put(this.autoGain, L.autoGain); put(this.autoGain, L.autoGain);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
for (const key of LIMITER_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -365,12 +322,7 @@ export class LimiterPanel extends Widget {
const L = LIMITER_LAYOUT; const L = LIMITER_LAYOUT;
drawPlate(this.plate.clear(), LIMITER_PANEL, BRICK, L.screws); drawPlate(this.plate.clear(), LIMITER_PANEL, BRICK, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1b120e);
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x1b120e, width: 1 });
this.drawLamp(); this.drawLamp();
} }
} }
+8 -36
View File
@@ -1,42 +1,27 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { type ModHost, ModulationPanel } from './ModulationPanel'; import { type ModHost, ModulationPanel } from './ModulationPanel';
import { idleModKeys, loopAt, type ModDevice, modTexts, modValues, swingPicture } from './modulationView'; import { idleModKeys, loopAt, type ModDevice, modTexts, modValues, swingPicture } from './modulationView';
import { NodeParamDrag } from './nodeParamDrag';
/** /**
* A Tremolo, a Vibrato, a Phaser or a Flanger node drawn as its device (#398): what it does over * A Tremolo, a Vibrato, a Phaser or a Flanger node drawn as its device (#398): what it does over
* the first seconds of a sound, reckoned from its settings, and a light that plays it in real time * the first seconds of a sound, reckoned from its settings, and a light that plays it in real time
* for as long as something swings and the node is in the picture. It listens to nothing. * for as long as something swings and the node is in the picture. It listens to nothing.
*/ */
export class ModulationFace implements NodeFace, ModHost { export class ModulationFace extends DeviceFace implements NodeFace, ModHost {
readonly widget: ModulationPanel; readonly widget: ModulationPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(
private readonly state: EditorState, state: EditorState,
private readonly assetId: string, assetId: string,
private readonly nodeId: string, nodeId: string,
private readonly device: ModDevice, private readonly device: ModDevice,
) { ) {
super(state, assetId, nodeId, device.type, `${device.kind}-face`);
this.widget = new ModulationPanel(device, this); this.widget = new ModulationPanel(device, this);
this.drag = new NodeParamDrag(state, `${device.kind}-face`);
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === this.device.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -52,29 +37,16 @@ export class ModulationFace implements NodeFace, ModHost {
} }
/** The light runs by the page's clock; with nothing that swings there is nothing to move. */ /** The light runs by the page's clock; with nothing that swings there is nothing to move. */
tick(): boolean { override tick(): boolean {
const picture = this.widget.scope.picture; const picture = this.widget.scope.picture;
if (!picture?.moving) return false; if (!picture?.moving) return false;
this.widget.step(loopAt(picture.span, performance.now() / 1000)); this.widget.step(loopAt(picture.span, performance.now() / 1000));
return true; return true;
} }
hide(): void {}
preview(key: string, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: string, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pick(value: string): void { pick(value: string): void {
const node = this.node(); const node = this.node();
const pick = this.device.pick; const pick = this.device.pick;
if (node && pick && pick.of(node.params) !== value) if (node && pick && pick.of(node.params) !== value) this.set(pick.key, value);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, pick.key, value));
} }
} }
+59 -126
View File
@@ -6,13 +6,31 @@
* they do to each frequency, and in a lane under it where that is going. A light plays it all in * they do to each frequency, and in a lane under it where that is going. A light plays it all in
* real time, over and over. * real time, over and over.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { mixColor } from '../../core/util/color'; import { mixColor } from '../../core/util/color';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { secondsText } from './chipView'; import { secondsText } from './chipView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { TEXT_ROW } from './delayView'; import { TEXT_ROW } from './delayView';
import { type Box, KNOB_CAPTION } from './hardwareView'; import {
bezelText,
DeviceKnobs,
drawBezel,
drawLamp,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
PickButton,
type PickLook,
placeEngraving,
placeGroups,
type PlateLook,
put,
screenText,
} from './deviceParts';
import type { Box } from './hardwareView';
import { import {
heightOf, heightOf,
lampOf, lampOf,
@@ -30,7 +48,6 @@ import {
swingValueAt, swingValueAt,
wetKey, wetKey,
} from './modulationView'; } from './modulationView';
import { PickButton, type PickLook } from './ReverbPanel';
/** What the panel asks of the node it sits in. */ /** What the panel asks of the node it sits in. */
export interface ModHost { export interface ModHost {
@@ -153,30 +170,11 @@ const LOOKS: Record<ModKind, ModLook> = {
const BEZEL = 0x2a2624; const BEZEL = 0x2a2624;
const BEZEL_TEXT = 0xe4dccf; const BEZEL_TEXT = 0xe4dccf;
const IDLE_ALPHA = 0.4;
/** Cycles of the wave a Tremolo's volume shapes, across its screen. */ /** Cycles of the wave a Tremolo's volume shapes, across its screen. */
const CARRIER = 48; const CARRIER = 48;
/** How finely a Vibrato's pitch is coloured: by chunks of so many points. */ /** How finely a Vibrato's pitch is coloured: by chunks of so many points. */
const PITCH_CHUNK = 20; const PITCH_CHUNK = 20;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** What both screens share: the glass, what they write and where the light is. */ /** What both screens share: the glass, what they write and where the light is. */
abstract class ModScope extends Widget { abstract class ModScope extends Widget {
protected readonly g = new Graphics(); protected readonly g = new Graphics();
@@ -195,15 +193,7 @@ abstract class ModScope extends Widget {
) { ) {
super(); super();
this.addChild(this.g, this.light); this.addChild(this.g, this.light);
this.under = { left: this.made(9, look.text, 0, 0), right: this.made(9, look.text, 1, 0) }; this.under = { left: screenText(this, 9, look.text, 0, 0), right: screenText(this, 9, look.text, 1, 0) };
}
protected made(size: number, fill: number, ax: number, ay: number, text = ''): Text {
const t = crispText({ text, style: scopeStyle(size, fill) });
t.anchor.set(ax, ay);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(picture: SwingPicture, values: ModValues, texts: ModTexts): void { show(picture: SwingPicture, values: ModValues, texts: ModTexts): void {
@@ -259,9 +249,9 @@ class SwingScope extends ModScope {
constructor(device: ModDevice, look: ModLook) { constructor(device: ModDevice, look: ModLook) {
super(device, look); super(device, look);
this.marks = { this.marks = {
top: this.made(8, look.dim, 0, 0), top: screenText(this, 8, look.dim, 0, 0),
bottom: this.made(8, look.dim, 0, 1), bottom: screenText(this, 8, look.dim, 0, 1),
span: this.made(8, look.dim, 1, 1), span: screenText(this, 8, look.dim, 1, 1),
}; };
} }
@@ -395,9 +385,9 @@ class ResponseScope extends ModScope {
constructor(device: ModDevice, look: ModLook) { constructor(device: ModDevice, look: ModLook) {
super(device, look); super(device, look);
this.addChildAt(this.curve, 1); this.addChildAt(this.curve, 1);
this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text)); this.hzMarks = DECADES.map(([, text]) => screenText(this, 8, look.dim, 0.5, 0, text));
this.dbMarks = DB_MARKS.map((db) => this.made(8, look.dim, 1, 0.5, String(db))); this.dbMarks = DB_MARKS.map((db) => screenText(this, 8, look.dim, 1, 0.5, String(db)));
this.marks = { lane: this.made(8, look.dim, 0, 0), span: this.made(8, look.dim, 1, 0) }; this.marks = { lane: screenText(this, 8, look.dim, 0, 0), span: screenText(this, 8, look.dim, 1, 0) };
} }
/** The response, with room on its left for its dB. */ /** The response, with room on its left for its dB. */
@@ -536,12 +526,12 @@ export class ModulationPanel extends Widget {
private readonly look: ModLook; private readonly look: ModLook;
readonly scope: SwingScope | ResponseScope; readonly scope: SwingScope | ResponseScope;
readonly pickButtons: PickButton<string>[]; readonly pickButtons: PickButton<string>[];
readonly knobs: Record<string, Knob> = {}; readonly knobs: Record<string, Knob>;
private readonly captions: Record<string, { ink: Text; edge: Text }> = {}; private readonly dials: DeviceKnobs<string>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly pickName: { ink: Text; edge: Text } | null; private readonly pickName: Engraving | null;
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly lampCaption: { ink: Text; edge: Text }; private readonly lampCaption: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** What the screen sums up, above it on the right. */ /** What the screen sums up, above it on the right. */
readonly sideText: Text; readonly sideText: Text;
@@ -565,12 +555,8 @@ export class ModulationPanel extends Widget {
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, device.title, 14); this.title = engrave(this, device.title, 14);
this.lampCaption = engrave(this, 'RATE', 9); this.lampCaption = engrave(this, 'RATE', 9);
this.screenName = crispText({ text: device.screenName, style: bezelStyle() }); this.screenName = bezelText(this, device.screenName, BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.sideText = bezelText(this, '', BEZEL_TEXT, true);
this.sideText = crispText({ text: '', style: bezelStyle() });
this.sideText.anchor.set(1, 0);
this.sideText.eventMode = 'none';
this.addChild(this.screenName, this.sideText);
this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null; this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null;
this.pickButtons = (device.pick?.options ?? []).map((option) => { this.pickButtons = (device.pick?.options ?? []).map((option) => {
const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) => const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) =>
@@ -579,30 +565,16 @@ export class ModulationPanel extends Widget {
this.addChild(button); this.addChild(button);
return button; return button;
}); });
this.groupNames = L.groups.map((group) => engrave(this, group.label, 9)); this.groupNames = engraveGroups(this, L.groups);
for (const look of device.knobs) { const looks = new Map(device.knobs.map((look) => [look.key, look]));
const row = look.row; this.dials = new DeviceKnobs(
const knob = new Knob('', row.default, { this,
min: row.min ?? 0, [...looks.keys()],
max: row.max ?? 1, (key) => ({ row: looks.get(key)!.row, look: looks.get(key)!, size: L.knobs[key]!.size }),
step: row.step ?? 0.01, host,
taper: look.taper, this.look.arc,
bipolar: look.bipolar, );
default: row.default, this.knobs = this.dials.knobs;
decimals: look.decimals,
size: L.knobs[look.key]!.size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: this.look.arc,
onInput: (value) => host.preview(look.key, value),
onChange: (value) => host.commit(look.key, value),
});
this.knobs[look.key] = knob;
this.captions[look.key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -610,13 +582,7 @@ export class ModulationPanel extends Widget {
} }
sync(shown: ModShown): void { sync(shown: ModShown): void {
for (const look of this.device.knobs) { this.dials.sync((key) => shown.values.numbers[key]!, shown.wired, shown.idle);
const knob = this.knobs[look.key]!;
knob.setValue(shown.values.numbers[look.key]!);
knob.setEnabled(!shown.wired(look.key));
knob.alpha = shown.idle.has(look.key) ? IDLE_ALPHA : 1;
this.captions[look.key]!.ink.alpha = knob.alpha;
}
const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key); const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key);
for (const button of this.pickButtons) { for (const button of this.pickButtons) {
button.pick(button.value === shown.values.picked); button.pick(button.value === shown.values.picked);
@@ -630,11 +596,6 @@ export class ModulationPanel extends Widget {
this.drawLamp(); this.drawLamp();
} }
/** Whether anything moves: there is a swing, and it is heard. */
get moving(): boolean {
return this.scope.picture?.moving === true;
}
/** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */ /** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */
step(t: number): void { step(t: number): void {
this.scope.step(t); this.scope.step(t);
@@ -646,20 +607,17 @@ export class ModulationPanel extends Widget {
const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp; const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp;
const [off, on] = this.look.lamp; const [off, on] = this.look.lamp;
const lit = this.lamp; const lit = this.lamp;
const g = this.lampLight.clear(); drawLamp(
if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit }); this.lampLight.clear(),
g.circle(x, y, r) { x, y, r },
.fill(mixColor(off, on, lit)) mixColor(off, on, lit),
.stroke({ color: 0x2a2c30, width: 1.5 }); lit > 0 ? { color: on, alpha: 0.3 * lit } : null,
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit }); 0.2 + 0.5 * lit,
);
} }
override layout(): void { override layout(): void {
const L = MOD_LAYOUTS[this.device.kind]; const L = MOD_LAYOUTS[this.device.kind];
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
@@ -667,17 +625,8 @@ export class ModulationPanel extends Widget {
if (this.pickName && L.pickGroup) if (this.pickName && L.pickGroup)
placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y); placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
for (const look of this.device.knobs) { this.dials.place((key) => L.knobs[key]!);
const spot = L.knobs[look.key]!; placeGroups(this.groupNames, L.groups);
const knob = this.knobs[look.key]!;
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[look.key]!, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -685,26 +634,10 @@ export class ModulationPanel extends Widget {
const L = MOD_LAYOUTS[this.device.kind]; const L = MOD_LAYOUTS[this.device.kind];
drawPlate(this.plate.clear(), L.panel, this.look.plate, L.screws); drawPlate(this.plate.clear(), L.panel, this.look.plate, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x161412);
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x161412, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]); const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]);
if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]); if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]);
for (const [box, name] of named) { drawRules(this.rules.clear(), named);
const mid = box.y + 6;
const left = box.x + 6;
const right = box.x + box.w - 6;
const gap = name.width / 2 + 6;
const centre = box.x + box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
}
r.stroke({ color: INK, width: 1, alpha: 0.5 });
this.drawLamp(); this.drawLamp();
} }
} }
+8 -41
View File
@@ -1,7 +1,5 @@
import { oneStep } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef'; import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
import { playedSoundById } from '../../core/properties/playedSound'; import { playedSoundById } from '../../core/properties/playedSound';
import { REVERB_IMPULSE_KEY, REVERB_ROOM_KEY, SoundFxReverb } from '../../core/soundfx/nodes'; import { REVERB_IMPULSE_KEY, REVERB_ROOM_KEY, SoundFxReverb } from '../../core/soundfx/nodes';
@@ -10,8 +8,7 @@ import { Menu, type Widget } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { NodeParamDrag } from './nodeParamDrag';
import { type ReverbHost, ReverbPanel } from './ReverbPanel'; import { type ReverbHost, ReverbPanel } from './ReverbPanel';
import { import {
customFrom, customFrom,
@@ -28,31 +25,14 @@ import {
* goes over to Custom with the numbers the type had. It listens to nothing while the sound plays; * goes over to Custom with the numbers the type had. It listens to nothing while the sound plays;
* what moves on its screen moves only for the look of it. * what moves on its screen moves only for the look of it.
*/ */
export class ReverbFace implements NodeFace, ReverbHost { export class ReverbFace extends DeviceFace implements NodeFace, ReverbHost {
readonly widget: ReverbPanel; readonly widget: ReverbPanel;
private readonly drag: NodeParamDrag;
/** Sounds whose peaks are on their way, so they are asked for once. */
private readonly loading = new Set<string>();
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxReverb.type, 'reverb-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new ReverbPanel(this); this.widget = new ReverbPanel(this);
this.drag = new NodeParamDrag(state, 'reverb-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxReverb.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -83,24 +63,12 @@ export class ReverbFace implements NodeFace, ReverbHost {
}); });
} }
/** Loads what the screen draws a sound's tail from, and draws it once it is there. */
private load(file: string): void {
if (this.loading.has(file)) return;
this.loading.add(file);
void soundLibrary.loadPicture(file).then(() => {
this.loading.delete(file);
this.sync();
});
}
/** The sound rings out by the page's clock, and the reflections drift; nothing moves without a tail to see. */ /** The sound rings out by the page's clock, and the reflections drift; nothing moves without a tail to see. */
tick(): boolean { override tick(): boolean {
return this.widget.step(performance.now() / 1000); return this.widget.step(performance.now() / 1000);
} }
hide(): void {} override preview(key: ReverbKey, value: number): void {
preview(key: ReverbKey, value: number): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
// A knob the type does not take: Custom, from the numbers the knobs showed. // A knob the type does not take: Custom, from the numbers the knobs showed.
@@ -109,7 +77,7 @@ export class ReverbFace implements NodeFace, ReverbHost {
else this.drag.preview(node, key, value); else this.drag.preview(node, key, value);
} }
commit(key: ReverbKey, value: number): void { override commit(key: ReverbKey, value: number): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
this.preview(key, value); this.preview(key, value);
@@ -118,8 +86,7 @@ export class ReverbFace implements NodeFace, ReverbHost {
pickRoom(room: ReverbRoom): void { pickRoom(room: ReverbRoom): void {
const node = this.node(); const node = this.node();
if (node && reverbValues(node.params).room !== room) if (node && reverbValues(node.params).room !== room) this.set(REVERB_ROOM_KEY, room);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, REVERB_ROOM_KEY, room));
} }
/** The sounds of the project; picking one makes it the impulse response and the type Sound. */ /** The sounds of the project; picking one makes it the impulse response and the type Sound. */
+45 -168
View File
@@ -4,14 +4,30 @@
* rings, as bright as it is loud, as blue as its highs are still there – a button for each type, the * rings, as bright as it is loud, as blue as its highs are still there – a button for each type, the
* sound a type of Sound takes, and a chrome knob for each number. * sound a type of Sound takes, and a chrome knob for each number.
*/ */
import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js'; import { type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
import { REVERB_ROWS } from '../../core/soundfx/nodes'; import { REVERB_ROWS } from '../../core/soundfx/nodes';
import { REVERB_ROOMS, type ReverbRoom } from '../../core/soundfx/reverb'; import { REVERB_ROOMS, type ReverbRoom } from '../../core/soundfx/reverb';
import { mixColor } from '../../core/util/color'; import { mixColor } from '../../core/util/color';
import { crispText, Dropdown, Knob, textStyle, Widget } from '../../ui'; import { Dropdown, type Knob, Widget } from '../../ui';
import {
bezelText,
DeviceKnobs,
drawBezel,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
PickButton,
type PickLook,
placeEngraving,
placeGroups,
type PlateLook,
put,
screenText,
} from './deviceParts';
import { apartLabels } from './eqView'; import { apartLabels } from './eqView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { type Box, KNOB_CAPTION } from './hardwareView';
import { import {
markText, markText,
REVERB_KEYS, REVERB_KEYS,
@@ -83,91 +99,15 @@ const SCOPE = {
/** A sound's tail, whose highs the screen cannot tell. */ /** A sound's tail, whose highs the screen cannot tell. */
const UNKNOWN_TINT = 0xb9c2ff; const UNKNOWN_TINT = 0xb9c2ff;
const PICKED = 0x5b45b8; const PICKED = 0x5b45b8;
const IDLE_ALPHA = 0.4;
/** The ring a pixel of the tail is drawn with, and the strip of text under the screen. */ /** The ring a pixel of the tail is drawn with, and the strip of text under the screen. */
const RING_STEP = 1.5; const RING_STEP = 1.5;
const TEXT_ROW = 14; const TEXT_ROW = 14;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
const tint = (bright: number | null): number => const tint = (bright: number | null): number =>
bright === null ? UNKNOWN_TINT : mixColor(SCOPE.dull, SCOPE.bright, bright); bright === null ? UNKNOWN_TINT : mixColor(SCOPE.dull, SCOPE.bright, bright);
/** The colours of a row of type buttons: the face, hovered, picked, and the shadow under them. */
export interface PickLook {
face: number;
hover: number;
picked: number;
shadow: number;
}
const LAVENDER_BUTTONS: PickLook = { face: 0x45435a, hover: 0x55536a, picked: PICKED, shadow: 0x24222f }; const LAVENDER_BUTTONS: PickLook = { face: 0x45435a, hover: 0x55536a, picked: PICKED, shadow: 0x24222f };
/** A type's button: its name on it, lit while it is the one. The Reverb's (#389), the Saturation's (#396). */
export class PickButton<T extends string> extends Widget {
private readonly g = new Graphics();
private readonly caption: Text;
picked = false;
constructor(
readonly value: T,
name: string,
private readonly look: PickLook,
onPick: (value: T) => void,
) {
super();
this.cursor = 'pointer';
this.caption = crispText({ text: name.toUpperCase(), style: scopeStyle(9, 0xe4e2ee) });
this.caption.anchor.set(0.5, 0.5);
this.caption.eventMode = 'none';
this.addChild(this.g, this.caption);
this.trackHover();
// A press on a button is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onPick(value);
});
}
pick(on: boolean): void {
if (on === this.picked) return;
this.picked = on;
this.invalidate();
}
override redraw(): void {
const { w, h } = this;
const down = this.picked ? 1 : 0;
this.g
.clear()
.roundRect(0, 2, w, h - 2, 5)
.fill(this.look.shadow)
.roundRect(0, down, w, h - 2, 5)
.fill(this.picked ? this.look.picked : this.hovered ? this.look.hover : this.look.face)
.stroke({ color: 0x1e1d27, width: 1 })
.roundRect(3, down + 2, w - 6, 4, 2)
.fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.1 });
this.caption.position.set(Math.round(w / 2), Math.round(down + (h - 2) / 2));
this.caption.style.fill = this.picked ? 0xffffff : 0xd9d7e6;
}
}
/** /**
* The round screen: the sound as a dot in the middle, the tail as rings out from it on a scale of * The round screen: the sound as a dot in the middle, the tail as rings out from it on a scale of
* seconds, scattered reflections over it, and under it the pre-delay and the end. The sound rings * seconds, scattered reflections over it, and under it the pre-delay and the end. The sound rings
@@ -193,19 +133,8 @@ class ReverbScope extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.g, this.motion); this.addChild(this.g, this.motion);
this.marks = SCOPE_MARKS.map((seconds) => this.made(markText(seconds), 8, SCOPE.dim, 0, 1)); this.marks = SCOPE_MARKS.map((seconds) => screenText(this, 8, SCOPE.dim, 0, 1, markText(seconds)));
this.under = { this.under = { pre: screenText(this, 9, SCOPE.text, 0), ends: screenText(this, 9, SCOPE.text, 1) };
pre: this.made('', 9, SCOPE.text, 0, 0),
ends: this.made('', 9, SCOPE.text, 1, 0),
};
}
private made(text: string, size: number, fill: number, ax: number, ay: number): Text {
const t = crispText({ text, style: scopeStyle(size, fill) });
t.anchor.set(ax, ay);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(tail: ReverbTail | null, mix: number, missing: string): void { show(tail: ReverbTail | null, mix: number, missing: string): void {
@@ -329,11 +258,11 @@ export class ReverbPanel extends Widget {
readonly scope = new ReverbScope(); readonly scope = new ReverbScope();
readonly roomButtons: PickButton<ReverbRoom>[]; readonly roomButtons: PickButton<ReverbRoom>[];
readonly soundButton: Dropdown; readonly soundButton: Dropdown;
readonly knobs = {} as Record<ReverbKey, Knob>; readonly knobs: Record<ReverbKey, Knob>;
private readonly captions = {} as Record<ReverbKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<ReverbKey>;
private readonly soundCaption: { ink: Text; edge: Text }; private readonly soundCaption: Engraving;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** Where the highs end up, above the screen on the right: there was no room for it below. */ /** Where the highs end up, above the screen on the right: there was no room for it below. */
readonly highs: Text; readonly highs: Text;
@@ -346,13 +275,9 @@ export class ReverbPanel extends Widget {
this.minHeight = REVERB_PANEL.h; this.minHeight = REVERB_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.scope); this.addChild(this.plate, this.rules, this.bezel, this.scope);
this.title = engrave(this, 'REVERB', 14); this.title = engrave(this, 'REVERB', 14);
this.screenName = crispText({ text: 'TAIL OVER TIME', style: bezelStyle() }); this.screenName = bezelText(this, 'TAIL OVER TIME', BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.highs = bezelText(this, '', BEZEL_TEXT, true);
this.highs = crispText({ text: '', style: bezelStyle() }); this.groupNames = engraveGroups(this, REVERB_LAYOUT.groups);
this.highs.anchor.set(1, 0);
this.highs.eventMode = 'none';
this.addChild(this.screenName, this.highs);
this.groupNames = REVERB_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
this.roomButtons = REVERB_ROOMS.map((room) => { this.roomButtons = REVERB_ROOMS.map((room) => {
const button = new PickButton(room.value, room.label, LAVENDER_BUTTONS, (picked) => const button = new PickButton(room.value, room.label, LAVENDER_BUTTONS, (picked) =>
host.pickRoom(picked), host.pickRoom(picked),
@@ -368,28 +293,13 @@ export class ReverbPanel extends Widget {
// A press on it is not one on the node. // A press on it is not one on the node.
this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.addChild(this.soundButton); this.addChild(this.soundButton);
for (const key of REVERB_KEYS) { this.dials = new DeviceKnobs(
const row = REVERB_ROWS[key]; this,
const look = REVERB_KNOBS[key]; REVERB_KEYS,
const knob = new Knob('', row.default, { (key) => ({ row: REVERB_ROWS[key], look: REVERB_KNOBS[key], size: REVERB_LAYOUT.knobs[key].size }),
min: row.min ?? 0, host,
max: row.max ?? 1, );
step: row.step ?? 0.01, this.knobs = this.dials.knobs;
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: REVERB_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -398,13 +308,7 @@ export class ReverbPanel extends Widget {
sync(shown: ReverbShown): void { sync(shown: ReverbShown): void {
const v = shown.values; const v = shown.values;
for (const key of REVERB_KEYS) { this.dials.sync((key) => v[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
for (const button of this.roomButtons) button.pick(button.value === v.room); for (const button of this.roomButtons) button.pick(button.value === v.room);
this.soundButton.setText(shown.sound || 'No sound'); this.soundButton.setText(shown.sound || 'No sound');
this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1; this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1;
@@ -421,10 +325,6 @@ export class ReverbPanel extends Widget {
override layout(): void { override layout(): void {
const L = REVERB_LAYOUT; const L = REVERB_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
@@ -432,17 +332,8 @@ export class ReverbPanel extends Widget {
const sound = this.soundButton.measure(); const sound = this.soundButton.measure();
put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h }); put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h });
placeEngraving(this.soundCaption, L.soundCaption.x, L.soundCaption.y); placeEngraving(this.soundCaption, L.soundCaption.x, L.soundCaption.y);
for (const key of REVERB_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key]; placeGroups(this.groupNames, L.groups);
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -450,24 +341,10 @@ export class ReverbPanel extends Widget {
const L = REVERB_LAYOUT; const L = REVERB_LAYOUT;
drawPlate(this.plate.clear(), REVERB_PANEL, LAVENDER, L.screws); drawPlate(this.plate.clear(), REVERB_PANEL, LAVENDER, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1b1a24);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x1b1a24, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
} }
} }
+7 -50
View File
@@ -1,7 +1,5 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { InstrumentSample, type SamplePlay } from '../../core/instrument/nodes'; import { InstrumentSample, type SamplePlay } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project';
import { findSound } from '../../core/model/sound'; import { findSound } from '../../core/model/sound';
import { assetRefLabel, soundChoices } from '../../core/properties/assetRef'; import { assetRefLabel, soundChoices } from '../../core/properties/assetRef';
import { playedSoundOf } from '../../core/properties/playedSound'; import { playedSoundOf } from '../../core/properties/playedSound';
@@ -11,9 +9,9 @@ import { Menu, type Widget } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary'; import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { NodeParamDrag } from './nodeParamDrag'; import { DeviceFace } from './deviceFace';
import { type SampleHost, SamplePanel, type SamplePicture } from './SamplePanel'; import { type SampleHost, SamplePanel, type SamplePicture } from './SamplePanel';
import { idleSampleKeys, type SampleLoopKey, sampleValues } from './sampleView'; import { idleSampleKeys, sampleValues } from './sampleView';
const SOUND = 'sound'; const SOUND = 'sound';
const ROOT = 'root'; const ROOT = 'root';
@@ -25,26 +23,12 @@ const FOLLOW = 'followNote';
* its peaks once they are loaded, and every setting worked right in the graph. Nothing on it moves * its peaks once they are loaded, and every setting worked right in the graph. Nothing on it moves
* by itself, and it listens to nothing. * by itself, and it listens to nothing.
*/ */
export class SampleFace implements NodeFace, SampleHost { export class SampleFace extends DeviceFace implements NodeFace, SampleHost {
readonly widget: SamplePanel; readonly widget: SamplePanel;
private readonly drag: NodeParamDrag;
/** Sounds whose peaks are on their way, so they are asked for once. */
private readonly loading = new Set<string>();
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, InstrumentSample.type, 'sample-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new SamplePanel(this); this.widget = new SamplePanel(this);
this.drag = new NodeParamDrag(state, 'sample-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentSample.type ? node : null;
} }
sync(): void { sync(): void {
@@ -79,22 +63,6 @@ export class SampleFace implements NodeFace, SampleHost {
}); });
} }
/** Loads what the screen draws the waveform from, and draws it once it is there. */
private load(file: string): void {
if (this.loading.has(file)) return;
this.loading.add(file);
void soundLibrary.loadPicture(file).then(() => {
this.loading.delete(file);
this.sync();
});
}
tick(): boolean {
return false;
}
hide(): void {}
previewRoot(note: number): void { previewRoot(note: number): void {
const node = this.node(); const node = this.node();
if (node) this.drag.previewAll(node, { [ROOT]: String(note) }); if (node) this.drag.previewAll(node, { [ROOT]: String(note) });
@@ -105,24 +73,13 @@ export class SampleFace implements NodeFace, SampleHost {
if (node) this.drag.commit(this.assetId, node, ROOT, String(note)); if (node) this.drag.commit(this.assetId, node, ROOT, String(note));
} }
preview(key: SampleLoopKey, seconds: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, seconds);
}
commit(key: SampleLoopKey, seconds: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, seconds);
}
pickPlay(play: SamplePlay): void { pickPlay(play: SamplePlay): void {
const node = this.node(); const node = this.node();
if (node && sampleValues(node.params).play !== play) if (node && sampleValues(node.params).play !== play) this.set(PLAY, play);
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, PLAY, play));
} }
switchFollow(on: boolean): void { switchFollow(on: boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, FOLLOW, on)); this.set(FOLLOW, on);
} }
/** The sounds of the project and their clips; picking one makes it what the Sample plays. */ /** The sounds of the project and their clips; picking one makes it what the Sample plays. */
+48 -95
View File
@@ -5,13 +5,30 @@
* its name, a button for each way of playing it, the root as a knob, a lever for following the note * its name, a button for each way of playing it, the root as a knob, a lever for following the note
* and the loop's two ends typed in. * and the loop's two ends typed in.
*/ */
import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js'; import { type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
import { SAMPLE_LOOP_ROWS, SAMPLE_PLAYS, type SamplePlay } from '../../core/instrument/nodes'; import { SAMPLE_LOOP_ROWS, SAMPLE_PLAYS, type SamplePlay } from '../../core/instrument/nodes';
import { HIGHEST_NOTE, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; import { HIGHEST_NOTE, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes';
import { crispText, Dropdown, Knob, NumberField, textStyle, Widget } from '../../ui'; import { Dropdown, type Knob, NumberField, Widget } from '../../ui';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; import {
import { type Box, KNOB_CAPTION } from './hardwareView'; bezelText,
import { PickButton, type PickLook } from './ReverbPanel'; chromeKnob,
drawBezel,
drawPlate,
drawRules,
type Engraving,
engrave,
engraveGroups,
IDLE_ALPHA,
Lever,
PickButton,
type PickLook,
placeEngraving,
placeGroups,
placeKnob,
type PlateLook,
put,
screenText,
} from './deviceParts';
import { import {
FIELD_NAME, FIELD_NAME,
markText, markText,
@@ -93,25 +110,6 @@ const WAVE: Record<RegionKind, number> = {
never: 0x5a4a28, never: 0x5a4a28,
}; };
const GOLD_BUTTONS: PickLook = { face: 0x4a3c20, hover: 0x5c4c2c, picked: 0xa86f0a, shadow: 0x271e0c }; const GOLD_BUTTONS: PickLook = { face: 0x4a3c20, hover: 0x5c4c2c, picked: 0xa86f0a, shadow: 0x271e0c };
const IDLE_ALPHA = 0.4;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** /**
* The screen: the waveform of the sound across its whole length, mirrored about the middle, each * The screen: the waveform of the sound across its whole length, mirrored about the middle, each
@@ -135,18 +133,9 @@ class WaveScope extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.glass, this.wave); this.addChild(this.glass, this.wave);
this.flags = { loop: this.made(8, SCOPE.band, 0.5), after: this.made(8, WAVE.after, 0.5) }; this.flags = { loop: screenText(this, 8, SCOPE.band, 0.5), after: screenText(this, 8, WAVE.after, 0.5) };
this.middle = this.made(11, SCOPE.text, 0.5); this.middle = screenText(this, 11, SCOPE.text, 0.5, 0.5);
this.middle.anchor.set(0.5, 0.5); this.under = { play: screenText(this, 9, SCOPE.text, 0), pitch: screenText(this, 9, SCOPE.text, 1) };
this.under = { play: this.made(9, SCOPE.text, 0), pitch: this.made(9, SCOPE.text, 1) };
}
private made(size: number, fill: number, ax: number): Text {
const t = crispText({ text: '', style: scopeStyle(size, fill) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(values: SampleValues, picture: SamplePicture | null, missing: string): void { show(values: SampleValues, picture: SamplePicture | null, missing: string): void {
@@ -181,7 +170,7 @@ class WaveScope extends Widget {
marks.forEach((t, i) => { marks.forEach((t, i) => {
const mx = x(t); const mx = x(t);
g.moveTo(mx, plot.y).lineTo(mx, plot.y + plot.h); g.moveTo(mx, plot.y).lineTo(mx, plot.y + plot.h);
const label = (this.marks[i] ??= this.made(8, SCOPE.dim, 0)); const label = (this.marks[i] ??= screenText(this, 8, SCOPE.dim));
label.text = markText(t); label.text = markText(t);
label.anchor.set(i === 0 ? 0 : mx + label.width / 2 > w - 4 ? 1 : 0.5, 0); label.anchor.set(i === 0 ? 0 : mx + label.width / 2 > w - 4 ? 1 : 0.5, 0);
label.position.set(Math.round(mx), Math.round(plot.y + plot.h + 2)); label.position.set(Math.round(mx), Math.round(plot.y + plot.h + 2));
@@ -258,10 +247,10 @@ export class SamplePanel extends Widget {
readonly root: Knob; readonly root: Knob;
readonly follow: Lever; readonly follow: Lever;
readonly fields = {} as Record<SampleLoopKey, NumberField>; readonly fields = {} as Record<SampleLoopKey, NumberField>;
private readonly rootCaption: { ink: Text; edge: Text }; private readonly rootCaption: Engraving;
private readonly fieldCaptions = {} as Record<SampleLoopKey, { ink: Text; edge: Text }>; private readonly fieldCaptions = {} as Record<SampleLoopKey, Engraving>;
private readonly groupNames: { ink: Text; edge: Text }[]; private readonly groupNames: Engraving[];
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** How long the sound runs and how loud it plays, above the screen on the right. */ /** How long the sound runs and how loud it plays, above the screen on the right. */
readonly length: Text; readonly length: Text;
@@ -274,13 +263,9 @@ export class SamplePanel extends Widget {
this.minHeight = SAMPLE_PANEL.h; this.minHeight = SAMPLE_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.scope); this.addChild(this.plate, this.rules, this.bezel, this.scope);
this.title = engrave(this, 'SAMPLE', 14); this.title = engrave(this, 'SAMPLE', 14);
this.screenName = crispText({ text: 'WAVEFORM', style: bezelStyle() }); this.screenName = bezelText(this, 'WAVEFORM', BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.length = bezelText(this, '', BEZEL_TEXT, true);
this.length = crispText({ text: '', style: bezelStyle() }); this.groupNames = engraveGroups(this, SAMPLE_LAYOUT.groups);
this.length.anchor.set(1, 0);
this.length.eventMode = 'none';
this.addChild(this.screenName, this.length);
this.groupNames = SAMPLE_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
this.soundButton = new Dropdown('No sound', { this.soundButton = new Dropdown('No sound', {
width: SAMPLE_LAYOUT.sound.w, width: SAMPLE_LAYOUT.sound.w,
onClick: () => host.pickSound(this.soundButton), onClick: () => host.pickSound(this.soundButton),
@@ -295,22 +280,14 @@ export class SamplePanel extends Widget {
this.addChild(button); this.addChild(button);
return button; return button;
}); });
this.root = new Knob('', MIDDLE_C, { this.root = chromeKnob(
min: LOWEST_NOTE, { default: MIDDLE_C, min: LOWEST_NOTE, max: HIGHEST_NOTE, step: 1 },
max: HIGHEST_NOTE, { taper: 1, decimals: 0, format: noteName },
step: 1, SAMPLE_LAYOUT.root.size,
taper: 1, ARC,
default: MIDDLE_C, (note) => host.previewRoot(note),
decimals: 0, (note) => host.commitRoot(note),
size: SAMPLE_LAYOUT.root.size, );
look: 'chrome',
typed: false,
format: noteName,
ink: INK,
arc: ARC,
onInput: (note) => host.previewRoot(note),
onChange: (note) => host.commitRoot(note),
});
this.rootCaption = engrave(this, 'ROOT', 9); this.rootCaption = engrave(this, 'ROOT', 9);
this.addChild(this.root); this.addChild(this.root);
this.follow = new Lever('FOLLOW', (on) => host.switchFollow(on)); this.follow = new Lever('FOLLOW', (on) => host.switchFollow(on));
@@ -360,21 +337,13 @@ export class SamplePanel extends Widget {
override layout(): void { override layout(): void {
const L = SAMPLE_LAYOUT; const L = SAMPLE_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.length.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.length.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
const sound = this.soundButton.measure(); const sound = this.soundButton.measure();
put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h }); put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h });
this.playButtons.forEach((button, i) => put(button, L.plays[i]!)); this.playButtons.forEach((button, i) => put(button, L.plays[i]!));
const size = this.root.measure(); placeKnob(this.root, this.rootCaption, L.root);
this.root.position.set(Math.round(L.root.x - size.w / 2), Math.round(L.root.y - L.root.size / 2));
this.root.resize(size.w, size.h);
const captionY = L.root.y - L.root.size / 2 - KNOB_CAPTION;
placeEngraving(this.rootCaption, L.root.x, captionY);
put(this.follow, L.follow); put(this.follow, L.follow);
for (const key of SAMPLE_LOOP_KEYS) { for (const key of SAMPLE_LOOP_KEYS) {
const box = L.fields[key]; const box = L.fields[key];
@@ -385,9 +354,7 @@ export class SamplePanel extends Widget {
const caption = this.fieldCaptions[key]; const caption = this.fieldCaptions[key];
placeEngraving(caption, box.x - FIELD_NAME / 2, box.y + (box.h - caption.ink.height) / 2); placeEngraving(caption, box.x - FIELD_NAME / 2, box.y + (box.h - caption.ink.height) / 2);
} }
L.groups.forEach((group, i) => placeGroups(this.groupNames, L.groups);
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -395,24 +362,10 @@ export class SamplePanel extends Widget {
const L = SAMPLE_LAYOUT; const L = SAMPLE_LAYOUT;
drawPlate(this.plate.clear(), SAMPLE_PANEL, GOLD, L.screws); drawPlate(this.plate.clear(), SAMPLE_PANEL, GOLD, L.screws);
const b = L.bezel; const b = L.bezel;
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1a140a);
.clear() drawRules(
.roundRect(b.x, b.y, b.w, b.h, 8) this.rules.clear(),
.fill(BEZEL) L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8) );
.stroke({ color: 0x1a140a, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
});
r.stroke({ color: INK, width: 1, alpha: 0.5 });
} }
} }
+6 -48
View File
@@ -1,50 +1,24 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { SATURATION_AUTO_GAIN, SATURATION_TYPE, SoundFxSaturation } from '../../core/soundfx/nodes'; import { SATURATION_AUTO_GAIN, SATURATION_TYPE, SoundFxSaturation } from '../../core/soundfx/nodes';
import type { SaturationType } from '../../core/soundfx/saturation'; import type { SaturationType } from '../../core/soundfx/saturation';
import type { NodeFace } from '../graph/nodeFace'; import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor'; import { DeviceFace } from './deviceFace';
import { NodeParamDrag } from './nodeParamDrag';
import { type SaturationHost, SaturationPanel } from './SaturationPanel'; import { type SaturationHost, SaturationPanel } from './SaturationPanel';
import { import { heatOf, idleSaturationKeys, saturationTexts, saturationValues, waveTrace } from './saturationView';
heatOf,
idleSaturationKeys,
type SaturationKey,
saturationTexts,
saturationValues,
waveTrace,
} from './saturationView';
/** /**
* A Saturation node drawn as its device (#396): the wave a sine comes out as, reckoned from its * A Saturation node drawn as its device (#396): the wave a sine comes out as, reckoned from its
* settings, and a fire under it that burns as long as there is drive to be heard and the node is in * settings, and a fire under it that burns as long as there is drive to be heard and the node is in
* the picture. It listens to nothing. * the picture. It listens to nothing.
*/ */
export class SaturationFace implements NodeFace, SaturationHost { export class SaturationFace extends DeviceFace implements NodeFace, SaturationHost {
readonly widget: SaturationPanel; readonly widget: SaturationPanel;
private readonly drag: NodeParamDrag;
constructor( constructor(state: EditorState, assetId: string, nodeId: string) {
private readonly state: EditorState, super(state, assetId, nodeId, SoundFxSaturation.type, 'saturation-face');
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new SaturationPanel(this); this.widget = new SaturationPanel(this);
this.drag = new NodeParamDrag(state, 'saturation-face');
} }
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxSaturation.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void { sync(): void {
const node = this.node(); const node = this.node();
if (!node) return; if (!node) return;
@@ -61,24 +35,12 @@ export class SaturationFace implements NodeFace, SaturationHost {
} }
/** The fire flickers by the page's clock; without heat there is nothing to move. */ /** The fire flickers by the page's clock; without heat there is nothing to move. */
tick(): boolean { override tick(): boolean {
if (!this.widget.burning) return false; if (!this.widget.burning) return false;
this.widget.step(performance.now() / 1000); this.widget.step(performance.now() / 1000);
return true; return true;
} }
hide(): void {}
preview(key: SaturationKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: SaturationKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickType(type: SaturationType): void { pickType(type: SaturationType): void {
const node = this.node(); const node = this.node();
if (node && saturationValues(node.params).type !== type) this.set(SATURATION_TYPE.key, type); if (node && saturationValues(node.params).type !== type) this.set(SATURATION_TYPE.key, type);
@@ -87,8 +49,4 @@ export class SaturationFace implements NodeFace, SaturationHost {
switchAutoGain(on: boolean): void { switchAutoGain(on: boolean): void {
this.set(SATURATION_AUTO_GAIN.key, on); this.set(SATURATION_AUTO_GAIN.key, on);
} }
private set(key: string, value: string | boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
}
} }
+50 -110
View File
@@ -4,13 +4,28 @@
* up, the hotter the harder it is driven – a lamp beside the name that glows with it, a button for * up, the hotter the harder it is driven – a lamp beside the name that glows with it, a button for
* each type, the auto-gain lever, the drive big and the tone, the output and the wet small. * each type, the auto-gain lever, the drive big and the tone, the output and the wet small.
*/ */
import { Graphics, type Text, type TextStyle } from 'pixi.js'; import { Graphics, type Text } from 'pixi.js';
import { SATURATION_ROWS } from '../../core/soundfx/nodes'; import { SATURATION_ROWS } from '../../core/soundfx/nodes';
import { SATURATION_TYPES, type SaturationType } from '../../core/soundfx/saturation'; import { SATURATION_TYPES, type SaturationType } from '../../core/soundfx/saturation';
import { crispText, Knob, textStyle, Widget } from '../../ui'; import { type Knob, Widget } from '../../ui';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; import {
import { type Box, KNOB_CAPTION } from './hardwareView'; bezelText,
import { PickButton, type PickLook } from './ReverbPanel'; DeviceKnobs,
drawBezel,
drawLamp,
drawPlate,
drawRules,
type Engraving,
engrave,
IDLE_ALPHA,
Lever,
PickButton,
type PickLook,
placeEngraving,
type PlateLook,
put,
screenText,
} from './deviceParts';
import { import {
embersAt, embersAt,
flicker, flicker,
@@ -65,29 +80,10 @@ const BEZEL = 0x2e2420;
const BEZEL_TEXT = 0xe2c9b8; const BEZEL_TEXT = 0xe2c9b8;
/** Soot-black glass; the sine going in as a grey ghost behind what comes out. */ /** Soot-black glass; the sine going in as a grey ghost behind what comes out. */
const SCOPE = { glass: 0x0d0907, grid: 0x2e2420, text: 0xffc98a, dim: 0x7a6650, ghost: 0x6b6460 }; const SCOPE = { glass: 0x0d0907, grid: 0x2e2420, text: 0xffc98a, dim: 0x7a6650, ghost: 0x6b6460 };
const IDLE_ALPHA = 0.4;
/** The rows the glow is drawn in, and the share of the screen it rises to. */ /** The rows the glow is drawn in, and the share of the screen it rises to. */
const GLOW_ROW = 2; const GLOW_ROW = 2;
const GLOW_RISE = 0.85; const GLOW_RISE = 0.85;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** /**
* The screen: the sine going in as a grey ghost, the wave that comes out over it in the colour of * The screen: the sine going in as a grey ghost, the wave that comes out over it in the colour of
* the heat, a glow under them and embers rising – both moving by the page's clock, the wave not. * the heat, a glow under them and embers rising – both moving by the page's clock, the wave not.
@@ -109,15 +105,7 @@ class WaveScope extends Widget {
constructor() { constructor() {
super(); super();
this.addChild(this.glass, this.fire, this.wave); this.addChild(this.glass, this.fire, this.wave);
this.under = { auto: this.made(0), peak: this.made(1) }; this.under = { auto: screenText(this, 9, SCOPE.text, 0), peak: screenText(this, 9, SCOPE.text, 1) };
}
private made(ax: number): Text {
const t = crispText({ text: '', style: scopeStyle(9, SCOPE.text) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
} }
show(trace: WaveTrace, heat: number, texts: WaveScope['texts']): void { show(trace: WaveTrace, heat: number, texts: WaveScope['texts']): void {
@@ -223,11 +211,11 @@ export class SaturationPanel extends Widget {
readonly scope = new WaveScope(); readonly scope = new WaveScope();
readonly typeButtons: PickButton<SaturationType>[]; readonly typeButtons: PickButton<SaturationType>[];
readonly autoGain: Lever; readonly autoGain: Lever;
readonly knobs = {} as Record<SaturationKey, Knob>; readonly knobs: Record<SaturationKey, Knob>;
private readonly captions = {} as Record<SaturationKey, { ink: Text; edge: Text }>; private readonly dials: DeviceKnobs<SaturationKey>;
private readonly typeName: { ink: Text; edge: Text }; private readonly typeName: Engraving;
private readonly title: { ink: Text; edge: Text }; private readonly title: Engraving;
private readonly lampCaption: { ink: Text; edge: Text }; private readonly lampCaption: Engraving;
private readonly screenName: Text; private readonly screenName: Text;
/** The heat in a word, above the screen on the right, in its colour. */ /** The heat in a word, above the screen on the right, in its colour. */
readonly heatText: Text; readonly heatText: Text;
@@ -242,12 +230,8 @@ export class SaturationPanel extends Widget {
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, 'SATURATION', 14); this.title = engrave(this, 'SATURATION', 14);
this.lampCaption = engrave(this, 'HEAT', 9); this.lampCaption = engrave(this, 'HEAT', 9);
this.screenName = crispText({ text: 'A SINE AT -12 dBFS', style: bezelStyle() }); this.screenName = bezelText(this, 'A SINE AT -12 dBFS', BEZEL_TEXT);
this.screenName.eventMode = 'none'; this.heatText = bezelText(this, '', BEZEL_TEXT, true);
this.heatText = crispText({ text: '', style: bezelStyle() });
this.heatText.anchor.set(1, 0);
this.heatText.eventMode = 'none';
this.addChild(this.screenName, this.heatText);
this.typeName = engrave(this, 'TYPE', 9); this.typeName = engrave(this, 'TYPE', 9);
this.typeButtons = SATURATION_TYPES.map((type) => { this.typeButtons = SATURATION_TYPES.map((type) => {
const button = new PickButton(type.value, typeCaption(type.value), COPPER_BUTTONS, (picked) => const button = new PickButton(type.value, typeCaption(type.value), COPPER_BUTTONS, (picked) =>
@@ -258,30 +242,18 @@ export class SaturationPanel extends Widget {
}); });
this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on)); this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.autoGain); this.addChild(this.autoGain);
for (const key of SATURATION_KEYS) { this.dials = new DeviceKnobs(
const row = SATURATION_ROWS[key]; this,
const look = SATURATION_KNOBS[key]; SATURATION_KEYS,
const knob = new Knob('', row.default, { (key) => ({
min: row.min ?? 0, row: SATURATION_ROWS[key],
max: row.max ?? 1, look: SATURATION_KNOBS[key],
step: row.step ?? 0.01,
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size: SATURATION_LAYOUT.knobs[key].size, size: SATURATION_LAYOUT.knobs[key].size,
look: 'chrome', }),
typed: false, host,
format: look.format, ARC,
ink: INK, );
arc: ARC, this.knobs = this.dials.knobs;
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
} }
override measure(): { w: number; h: number } { override measure(): { w: number; h: number } {
@@ -290,13 +262,7 @@ export class SaturationPanel extends Widget {
sync(shown: SaturationShown): void { sync(shown: SaturationShown): void {
const v = shown.values; const v = shown.values;
for (const key of SATURATION_KEYS) { this.dials.sync((key) => v[key], shown.wired, shown.idle);
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
for (const button of this.typeButtons) { for (const button of this.typeButtons) {
button.pick(button.value === v.type); button.pick(button.value === v.type);
button.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1; button.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1;
@@ -331,20 +297,17 @@ export class SaturationPanel extends Widget {
const lit = Math.min(1, this.heat * 2) * this.scope.flare; const lit = Math.min(1, this.heat * 2) * this.scope.flare;
// Never quite white, so the lamp still reads as one at white heat. // Never quite white, so the lamp still reads as one at white heat.
const color = heatColor(0.2 + 0.6 * this.heat); const color = heatColor(0.2 + 0.6 * this.heat);
const g = this.lampLight.clear(); drawLamp(
if (lit > 0) g.circle(x, y, r + 4).fill({ color, alpha: 0.3 * Math.min(1, lit) }); this.lampLight.clear(),
g.circle(x, y, r) { x, y, r },
.fill(this.heat > 0 ? color : 0x4a2a1e) this.heat > 0 ? color : 0x4a2a1e,
.stroke({ color: 0x2a2c30, width: 1.5 }); lit > 0 ? { color, alpha: 0.3 * Math.min(1, lit) } : null,
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.heat > 0 ? 0.7 : 0.2 }); this.heat > 0 ? 0.7 : 0.2,
);
} }
override layout(): void { override layout(): void {
const L = SATURATION_LAYOUT; const L = SATURATION_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3); this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.heatText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); this.heatText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
@@ -352,14 +315,7 @@ export class SaturationPanel extends Widget {
put(this.autoGain, L.autoGain); put(this.autoGain, L.autoGain);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
placeEngraving(this.typeName, L.typeGroup.x + L.typeGroup.w / 2, L.typeGroup.y); placeEngraving(this.typeName, L.typeGroup.x + L.typeGroup.w / 2, L.typeGroup.y);
for (const key of SATURATION_KEYS) { this.dials.place((key) => L.knobs[key]);
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
} }
@@ -368,28 +324,12 @@ export class SaturationPanel extends Widget {
drawPlate(this.plate.clear(), SATURATION_PANEL, COPPER, L.screws); drawPlate(this.plate.clear(), SATURATION_PANEL, COPPER, L.screws);
const b = L.bezel; const b = L.bezel;
// The frame takes on the heat of the glass in it. // The frame takes on the heat of the glass in it.
this.bezel drawBezel(this.bezel.clear(), b, BEZEL, 0x1b120e);
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x1b120e, width: 1 });
if (this.heat > 0) if (this.heat > 0)
this.bezel this.bezel
.roundRect(L.screen.x - 2, L.screen.y - 2, L.screen.w + 4, L.screen.h + 4, 5) .roundRect(L.screen.x - 2, L.screen.y - 2, L.screen.w + 4, L.screen.h + 4, 5)
.stroke({ color: heatColor(this.heat), width: 1.5, alpha: 0.6 * this.heat }); .stroke({ color: heatColor(this.heat), width: 1.5, alpha: 0.6 * this.heat });
// A rule each side of the types' name, engraved as the name is. drawRules(this.rules.clear(), [[L.typeGroup, this.typeName.ink]]);
const group = L.typeGroup;
const mid = group.y + 6;
const centre = group.x + group.w / 2;
const gap = this.typeName.ink.width / 2 + 6;
this.rules
.clear()
.moveTo(group.x + 6, mid)
.lineTo(centre - gap, mid)
.moveTo(centre + gap, mid)
.lineTo(group.x + group.w - 6, mid)
.stroke({ color: INK, width: 1, alpha: 0.5 });
this.drawLamp(); this.drawLamp();
} }
} }
+7 -23
View File
@@ -7,7 +7,7 @@ import { ACID_ROWS } from '../../core/instrument/nodes';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { acidResponseDb, acidTopHz, SUSTAIN_SWEEP } from '../../core/soundfx/acid'; import { acidResponseDb, acidTopHz, SUSTAIN_SWEEP } from '../../core/soundfx/acid';
import { hzText, secondsText } from './chipView'; import { hzText, secondsText } from './chipView';
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; import { type Box, knobGroups, knobGroupsWidth, type KnobSpot, screwsOf } from './hardwareView';
export type AcidKey = keyof typeof ACID_ROWS; export type AcidKey = keyof typeof ACID_ROWS;
export type AcidValues = Record<AcidKey, number>; export type AcidValues = Record<AcidKey, number>;
@@ -57,10 +57,7 @@ export const ACID_GROUPS: readonly { label: string; keys: readonly AcidKey[] }[]
/** The plate: as the Compressor's (#379), the panel never takes another size. */ /** The plate: as the Compressor's (#379), the panel never takes another size. */
export const ACID_PANEL = { w: 580, h: 280 }; export const ACID_PANEL = { w: 580, h: 280 };
const KNOB_SLOT = 64;
const GROUP_GAP = 22; const GROUP_GAP = 22;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
export interface AcidLayout { export interface AcidLayout {
title: Box; title: Box;
@@ -78,25 +75,12 @@ function acidLayout(): AcidLayout {
const waves = [0, 1].map((i) => ({ x: 104 + i * 70, y: 13, w: 64, h: 28 })); const waves = [0, 1].map((i) => ({ x: 104 + i * 70, y: 13, w: 64, h: 28 }));
const bezel = { x: 24, y: 52, w: w - 48, h: 118 }; const bezel = { x: 24, y: 52, w: w - 48, h: 118 };
const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 }; const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 };
const rowWidth = const { groups, knobs } = knobGroups(
ACID_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) + ACID_GROUPS,
(ACID_GROUPS.length - 1) * GROUP_GAP; (w - knobGroupsWidth(ACID_GROUPS, GROUP_GAP)) / 2,
const groupTop = bezel.y + bezel.h + 6; bezel.y + bezel.h + 6,
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; GROUP_GAP,
const knobs = {} as Record<AcidKey, KnobSpot>; );
const groups: { label: string; box: Box }[] = [];
let x = (w - rowWidth) / 2;
for (const group of ACID_GROUPS) {
groups.push({
label: group.label,
box: { x, y: groupTop, w: group.keys.length * KNOB_SLOT, h: GROUP_LINE },
});
for (const key of group.keys) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
}
return { return {
title: { x: 34, y: 14, w: 60, h: 30 }, title: { x: 34, y: 14, w: 60, h: 30 },
waves, waves,
+9 -25
View File
@@ -8,7 +8,7 @@ import { noteName } from '../../core/instrument/notes';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { type ChipWave, periodicSteps, toneValue } from '../../core/soundfx/chip'; import { type ChipWave, periodicSteps, toneValue } from '../../core/soundfx/chip';
import { formatNumber } from '../../ui/math'; import { formatNumber } from '../../ui/math';
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; import { type Box, knobGroups, knobGroupsWidth, type KnobSpot, screwsOf } from './hardwareView';
export type ChipKey = keyof typeof CHIP_ROWS; export type ChipKey = keyof typeof CHIP_ROWS;
export type ChipValues = Record<ChipKey, number>; export type ChipValues = Record<ChipKey, number>;
@@ -90,10 +90,7 @@ export const WAVE_NAMES: Record<ChipWave, string> = {
export const CHIP_PANEL = { w: 840, h: 280 }; export const CHIP_PANEL = { w: 840, h: 280 };
/** Each knob's room in its group, and the room between two groups. */ /** Each knob's room in its group, and the room between two groups. */
const KNOB_SLOT = 64;
const GROUP_GAP = 22; const GROUP_GAP = 22;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
export interface ChipLayout { export interface ChipLayout {
title: Box; title: Box;
@@ -127,25 +124,12 @@ function chipLayout(): ChipLayout {
w: bezel.x + bezel.w - 10 - (wave.x + wave.w + 12), w: bezel.x + bezel.w - 10 - (wave.x + wave.w + 12),
h: screenH, h: screenH,
}; };
const rowWidth = const { groups, knobs } = knobGroups(
CHIP_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) + CHIP_GROUPS,
(CHIP_GROUPS.length - 1) * GROUP_GAP; (w - knobGroupsWidth(CHIP_GROUPS, GROUP_GAP)) / 2,
const groupTop = bezel.y + bezel.h + 6; bezel.y + bezel.h + 6,
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; GROUP_GAP,
const knobs = {} as Record<ChipKey, KnobSpot>; );
const groups: { label: string; box: Box }[] = [];
let x = (w - rowWidth) / 2;
for (const group of CHIP_GROUPS) {
groups.push({
label: group.label,
box: { x, y: groupTop, w: group.keys.length * KNOB_SLOT, h: GROUP_LINE },
});
for (const key of group.keys) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
}
return { return {
title: { x: 34, y: 14, w: 80, h: 30 }, title: { x: 34, y: 14, w: 80, h: 30 },
waves, waves,
@@ -164,7 +148,7 @@ export const CHIP_LAYOUT: ChipLayout = chipLayout();
// --- The wave screen: two periods of what the Chip plays. --- // --- The wave screen: two periods of what the Chip plays. ---
/** The periods the wave screen shows. */ /** The periods the wave screen shows. */
export const WAVE_PERIODS = 2; const WAVE_PERIODS = 2;
const NOISE_STEPS = 32; const NOISE_STEPS = 32;
/** The noise drawn the same every time: a period of 32 values, as the Chip draws its own. */ /** The noise drawn the same every time: a period of 32 values, as the Chip draws its own. */
@@ -232,7 +216,7 @@ function stepped(values: readonly number[]): { x: number; y: number }[] {
// --- The pitch screen: what slide, vibrato, arpeggio and repeat do over a note. --- // --- The pitch screen: what slide, vibrato, arpeggio and repeat do over a note. ---
/** The pitch the trace starts from when the Chip follows the note: A4. */ /** The pitch the trace starts from when the Chip follows the note: A4. */
export const TRACE_NOTE = 69; const TRACE_NOTE = 69;
const TRACE_HZ = 440; const TRACE_HZ = 440;
/** The longest and shortest stretch the pitch screen shows, in seconds. */ /** The longest and shortest stretch the pitch screen shows, in seconds. */
const LONGEST = 10; const LONGEST = 10;
+2 -6
View File
@@ -11,7 +11,7 @@ import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */ /** The dB the level picture spans, bottom to top. */
export const LEVEL_FLOOR_DB = -60; export const LEVEL_FLOOR_DB = -60;
export const LEVEL_TOP_DB = 6; const LEVEL_TOP_DB = 6;
/** How long the level picture looks back. */ /** How long the level picture looks back. */
export const HISTORY_SECONDS = 4; export const HISTORY_SECONDS = 4;
/** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */ /** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */
@@ -19,7 +19,7 @@ export const LEVEL_WINDOW = 0.025;
/** The dB the curve spans on both axes, from here to 0. */ /** The dB the curve spans on both axes, from here to 0. */
export const CURVE_FLOOR_DB = -60; export const CURVE_FLOOR_DB = -60;
/** Less taken away than this is not tinted: the two sides are read a few ms apart. */ /** Less taken away than this is not tinted: the two sides are read a few ms apart. */
export const REDUCTION_SHOWN_DB = 0.5; const REDUCTION_SHOWN_DB = 0.5;
export type CompressorKey = keyof typeof COMPRESS_ROWS; export type CompressorKey = keyof typeof COMPRESS_ROWS;
export const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[]; export const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[];
@@ -199,10 +199,6 @@ export class LevelHistory {
get heard(): boolean { get heard(): boolean {
return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB); return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB);
} }
get latest(): LevelPoint | null {
return this.points.at(-1) ?? null;
}
} }
/** How much the compressing takes away at a reading, in dB: the gain after it left out. */ /** How much the compressing takes away at a reading, in dB: the gain after it left out. */
+2 -2
View File
@@ -37,7 +37,7 @@ export interface CrushKnob {
format: (value: number) => string; format: (value: number) => string;
} }
export const bitsText = (bits: number): string => `${formatNumber(bits, 1)} bit`; const bitsText = (bits: number): string => `${formatNumber(bits, 1)} bit`;
/** How each knob turns: the bits evenly, the rate giving its low end most of the turn – 8 kHz past the middle. */ /** How each knob turns: the bits evenly, the rate giving its low end most of the turn – 8 kHz past the middle. */
export const CRUSH_KNOBS: Record<CrushKey, CrushKnob> = { export const CRUSH_KNOBS: Record<CrushKey, CrushKnob> = {
@@ -198,7 +198,7 @@ export function cellOf(wide: number, tall: number): { gap: number; h: number } {
} }
/** The noise a sine at full scale has over it at so many bits, in dB under it: 6 dB a bit. */ /** The noise a sine at full scale has over it at so many bits, in dB under it: 6 dB a bit. */
export const noiseDb = (bits: number): number => 6.02 * Math.max(1, Math.min(16, bits)) + 1.76; const noiseDb = (bits: number): number => 6.02 * Math.max(1, Math.min(16, bits)) + 1.76;
/** What the screen writes under it: the noise the bits leave, where the rate folds the highs back down. */ /** What the screen writes under it: the noise the bits leave, where the rate folds the highs back down. */
export function crushTexts( export function crushTexts(
+15 -22
View File
@@ -16,7 +16,7 @@ import {
} from '../../core/soundfx/nodes'; } from '../../core/soundfx/nodes';
import { formatNumber } from '../../ui/math'; import { formatNumber } from '../../ui/math';
import { secondsText } from './chipView'; import { secondsText } from './chipView';
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; import { type Box, knobGroups, type KnobSpot, screwsOf } from './hardwareView';
import { brightness, hzText } from './reverbView'; import { brightness, hzText } from './reverbView';
export type DelayKey = keyof typeof DELAY_ROWS; export type DelayKey = keyof typeof DELAY_ROWS;
@@ -118,10 +118,7 @@ export const DELAY_KNOBS: Record<DelayKey, DelayKnob> = {
/** The plate: as the Compressor's (#379), the panel never takes another size – no taller than its knobs need. */ /** The plate: as the Compressor's (#379), the panel never takes another size – no taller than its knobs need. */
export const DELAY_PANEL = { w: 680, h: 224 }; export const DELAY_PANEL = { w: 680, h: 224 };
const KNOB_SLOT = 64;
const GROUP_GAP = 16; const GROUP_GAP = 16;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
/** A lever's box, as the Compressor's auto-gain (#379). */ /** A lever's box, as the Compressor's auto-gain (#379). */
const LEVER = { w: 118, h: 40 }; const LEVER = { w: 118, h: 40 };
/** The extra the strip of text under the screen takes. */ /** The extra the strip of text under the screen takes. */
@@ -134,7 +131,10 @@ export interface DelayLayout {
screen: Box; screen: Box;
sync: Box; sync: Box;
pingPong: Box; pingPong: Box;
/** The light that blinks with every echo, beside the device's name and away from the levers' lamps; its own name after it. */ /**
* The light that blinks with every echo, beside the device's name and away from the levers'
* lamps; its own name after it.
*/
lamp: { x: number; y: number; r: number }; lamp: { x: number; y: number; r: number };
lampCaption: { x: number; y: number }; lampCaption: { x: number; y: number };
groups: { label: string; box: Box }[]; groups: { label: string; box: Box }[];
@@ -152,23 +152,16 @@ function delayLayout(): DelayLayout {
const right = w - 24; const right = w - 24;
const sync = { x: left, y: 52, ...LEVER }; const sync = { x: left, y: 52, ...LEVER };
const pingPong = { x: right - LEVER.w, y: 52, ...LEVER }; const pingPong = { x: right - LEVER.w, y: 52, ...LEVER };
const groupTop = 114; const { groups, knobs } = knobGroups<DelayKey>(
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; [
const groups: { label: string; box: Box }[] = []; { label: 'TIME', keys: ['time'] },
const knobs = {} as Record<DelayKey, KnobSpot>; { label: 'ECHOES', keys: ['feedback', 'damping'] },
let x = left; { label: 'MIX', keys: ['mix'] },
for (const [label, keys] of [ ],
['TIME', ['time']], left,
['ECHOES', ['feedback', 'damping']], 114,
['MIX', ['mix']], GROUP_GAP,
] as const) { );
groups.push({ label, box: { x, y: groupTop, w: keys.length * KNOB_SLOT, h: GROUP_LINE } });
for (const key of keys) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
}
return { return {
title: { x: 34, y: 14, w: 80, h: 24 }, title: { x: 34, y: 14, w: 80, h: 24 },
bezel, bezel,
+94
View File
@@ -0,0 +1,94 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { EditorState } from '../state';
import { NodeParamDrag } from './nodeParamDrag';
/** Whether a link feeds a node's setting: its knob cannot be turned then. */
export function paramWired(project: Project, assetId: string, nodeId: string, key: string): boolean {
const graph = findGraphAsset(project, assetId)?.graph;
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
}
/**
* What a node drawn as its device (#380 ff.) does with that node: looks it up anew each time, as
* an undo may have taken it away; tells which settings a link feeds; writes a turned knob as it
* goes and as one undo step when it lands; and sets anything else in one step.
*/
export abstract class DeviceFace {
protected readonly drag: NodeParamDrag;
/** Sounds whose peaks are on their way, so they are asked for once. */
private readonly loading = new Set<string>();
private listening = false;
constructor(
protected readonly state: EditorState,
protected readonly assetId: string,
protected readonly nodeId: string,
private readonly nodeType: string,
dragName: string,
) {
this.drag = new NodeParamDrag(state, dragName);
}
protected node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === this.nodeType ? node : null;
}
protected wired = (key: string): boolean => paramWired(this.state.project, this.assetId, this.nodeId, key);
abstract sync(): void;
/** A frame on; most devices have nothing that moves by itself. */
tick(): boolean {
return false;
}
hide(): void {}
preview(key: string, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: string, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
/** Loads what the screen draws a sound from, and draws it once it is there. */
protected load(file: string): void {
if (this.loading.has(file)) return;
this.loading.add(file);
void soundLibrary.loadPicture(file).then(() => {
this.loading.delete(file);
this.sync();
});
}
/** Hears the node's tap from now on; its id. */
protected listen(): string {
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.listening = true;
return id;
}
/** Stops hearing the node's tap; whether it was heard. */
protected unlisten(): boolean {
if (!this.listening) return false;
this.listening = false;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
return true;
}
protected set(key: string, value: PropertyValue): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
}
}
+417
View File
@@ -0,0 +1,417 @@
/**
* What the devices of the sound FX and instrument nodes (#379 ff.) are built of: the brushed plate
* with its engraved names, the chrome knobs with their captions, the bezel round a screen, the
* lamps, levers and type buttons, and the text styles of screen and bezel.
*/
import {
type Container,
type FederatedPointerEvent,
FillGradient,
Graphics,
type Text,
type TextStyle,
} from 'pixi.js';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { type Box, brushStrokes, KNOB_CAPTION, type KnobSpot } from './hardwareView';
const INK = 0x26282c;
/** How far a setting fades that does nothing as things stand. */
export const IDLE_ALPHA = 0.4;
/** An engraved name: dark letters with a light edge under them. */
export interface Engraving {
ink: Text;
edge: Text;
}
export function engrave(parent: Container, text: string, size = 10): Engraving {
const style = (fill: number): TextStyle => {
const s = textStyle('small').clone();
s.fill = fill;
s.fontSize = size;
s.fontWeight = 'bold';
s.letterSpacing = 1;
return s;
};
const edge = crispText({ text, style: style(0xffffff) });
edge.alpha = 0.55;
const ink = crispText({ text, style: style(INK) });
for (const t of [edge, ink]) {
t.eventMode = 'none';
t.anchor.set(0.5, 0);
parent.addChild(t);
}
return { ink, edge };
}
export function placeEngraving(e: Engraving, x: number, y: number): void {
e.ink.position.set(Math.round(x), Math.round(y));
e.edge.position.set(Math.round(x), Math.round(y) + 1);
}
/** The bold monospace a screen writes in. */
export function screenStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
/** A text on a screen, anchored at `ax`, `ay`. */
export function screenText(parent: Container, size: number, fill: number, ax = 0, ay = 0, text = ''): Text {
const t = crispText({ text, style: screenStyle(size, fill) });
t.anchor.set(ax, ay);
t.eventMode = 'none';
parent.addChild(t);
return t;
}
/** A text on the bezel above a screen: its name on the left, or what it reads on the right. */
export function bezelText(parent: Container, text: string, fill: number, right = false): Text {
const style = textStyle('small').clone();
style.fill = fill;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
const t = crispText({ text, style });
if (right) t.anchor.set(1, 0);
t.eventMode = 'none';
parent.addChild(t);
return t;
}
/** A widget put in its box, on whole pixels. */
export function put(widget: Widget, box: Box): void {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
}
export const gradient = (stops: [number, number][], end = { x: 0, y: 1 }): FillGradient =>
new FillGradient({
type: 'linear',
start: { x: 0, y: 0 },
end,
colorStops: stops.map(([offset, color]) => ({ offset, color })),
textureSpace: 'local',
});
/** The metal of a plate: its shade from top to bottom, its rim, and the dark strokes of the brush. */
export interface PlateLook {
stops: [number, number][];
rim: number;
grain: number;
}
/** Brushed aluminium, the Compressor's and the Delay's (#393). */
export const ALUMINIUM: PlateLook = {
stops: [
[0, 0xe2e4e7],
[0.5, 0xcfd2d6],
[1, 0xb4b8bd],
],
rim: 0x8a8e94,
grain: 0x6c7076,
};
const plateFills = new WeakMap<PlateLook, FillGradient>();
/** A brushed plate with a screw in each corner. */
export function drawPlate(
g: Graphics,
size: { w: number; h: number },
look: PlateLook,
screws: readonly { x: number; y: number }[],
): void {
const { w, h } = size;
let fill = plateFills.get(look);
if (!fill) plateFills.set(look, (fill = gradient(look.stops)));
g.roundRect(0, 0, w, h, 6).fill(fill);
for (const stroke of brushStrokes(h - 4))
g.rect(3, stroke.y + 2, w - 6, 1).fill({
color: stroke.light ? 0xffffff : look.grain,
alpha: stroke.alpha,
});
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: look.rim, width: 1 });
g.moveTo(3, 1.5)
.lineTo(w - 3, 1.5)
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
for (const screw of screws) {
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
g.moveTo(screw.x - 4, screw.y + 3)
.lineTo(screw.x + 4, screw.y - 3)
.stroke({ color: 0x45484d, width: 1.5 });
}
}
/** The dark frame round a device's screens, with a darker line just inside its edge. */
export function drawBezel(g: Graphics, box: Box, fill: number, edge: number): void {
g.roundRect(box.x, box.y, box.w, box.h, 8)
.fill(fill)
.roundRect(box.x + 0.5, box.y + 0.5, box.w - 1, box.h - 1, 8)
.stroke({ color: edge, width: 1 });
}
/** A round lamp: its glass, a halo round it while it glows, and the glint on it. */
export function drawLamp(
g: Graphics,
at: { x: number; y: number; r: number },
glass: number,
halo: { color: number; alpha: number } | null,
glint: number,
): void {
if (halo) g.circle(at.x, at.y, at.r + 4).fill(halo);
g.circle(at.x, at.y, at.r).fill(glass).stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(at.x - 2, at.y - 2, 2).fill({ color: 0xffffff, alpha: glint });
}
/** A rule each side of a group's name across the top of its box, engraved as the name is. */
export function drawRules(g: Graphics, named: readonly (readonly [Box, Text])[]): void {
for (const [box, name] of named) {
const mid = box.y + 6;
const left = box.x + 6;
const right = box.x + box.w - 6;
const gap = name.width / 2 + 6;
const centre = box.x + box.w / 2;
if (centre - gap > left) g.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) g.moveTo(centre + gap, mid).lineTo(right, mid);
}
g.stroke({ color: INK, width: 1, alpha: 0.5 });
}
/** The names of a device's groups, engraved over their boxes. */
export function engraveGroups(parent: Container, groups: readonly { label: string }[]): Engraving[] {
return groups.map((group) => engrave(parent, group.label, 9));
}
export function placeGroups(names: readonly Engraving[], groups: readonly { box: Box }[]): void {
groups.forEach((group, i) => placeEngraving(names[i]!, group.box.x + group.box.w / 2, group.box.y));
}
/** The numbers of a knob's node row. */
export interface DialRow {
default: number;
min?: number;
max?: number;
step?: number;
}
/** How a knob turns and reads. */
export interface DialLook {
label: string;
taper: number;
decimals: number;
bipolar?: boolean;
format?: (value: number) => string;
}
/** A chrome knob on a plate, without a typed field: its number is read under it. */
export function chromeKnob(
row: DialRow,
look: Omit<DialLook, 'label'>,
size: number,
arc: number | undefined,
onInput: (value: number) => void,
onChange: (value: number) => void,
): Knob {
return new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc,
onInput,
onChange,
});
}
/** A knob put on its spot, its caption above it. */
export function placeKnob(knob: Knob, caption: Engraving, spot: KnobSpot): void {
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(caption, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
/** A knob of a row of them: its node row, how it reads, and the size of its dial. */
export interface DialSpec {
row: DialRow;
look: DialLook;
size: number;
/** The size its caption is engraved at. */
caption?: number;
}
/** The chrome knobs of a device, one for each key, each with its caption engraved above it. */
export class DeviceKnobs<K extends string> {
readonly knobs = {} as Record<K, Knob>;
readonly captions = {} as Record<K, Engraving>;
constructor(
parent: Container,
private readonly keys: readonly K[],
spec: (key: K) => DialSpec,
host: { preview(key: K, value: number): void; commit(key: K, value: number): void },
arc?: number,
) {
for (const key of keys) {
const { row, look, size, caption = 9 } = spec(key);
const knob = chromeKnob(
row,
look,
size,
arc,
(value) => host.preview(key, value),
(value) => host.commit(key, value),
);
this.knobs[key] = knob;
this.captions[key] = engrave(parent, look.label.toUpperCase(), caption);
parent.addChild(knob);
}
}
/** The node's numbers: a linked one cannot be turned, an idle one fades with its caption. */
sync(value: (key: K) => number, wired: (key: K) => boolean, idle: ReadonlySet<string>): void {
for (const key of this.keys) {
const knob = this.knobs[key];
knob.setValue(value(key));
knob.setEnabled(!wired(key));
knob.alpha = idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
}
place(spot: (key: K) => KnobSpot): void {
for (const key of this.keys) placeKnob(this.knobs[key], this.captions[key], spot(key));
}
}
/** A switch on a plate: a lever and a lamp, its name and what it gives beside them. Auto-gain's, a Chip's "follow note" (#386). */
export class Lever extends Widget {
private readonly g = new Graphics();
private readonly caption: Engraving;
readonly says: Text;
lit = false;
constructor(name: string, onToggle: (on: boolean) => void) {
super();
this.cursor = 'pointer';
this.addChild(this.g);
this.caption = engrave(this, name, 9);
const style = textStyle('small').clone();
style.fill = INK;
style.fontSize = 11;
this.says = crispText({ text: 'off', style });
this.says.eventMode = 'none';
this.addChild(this.says);
// A press on the lever is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onToggle(!this.lit);
});
}
set(on: boolean, says: string): void {
if (on === this.lit && says === this.says.text) return;
this.lit = on;
this.says.text = says;
this.invalidate();
}
override redraw(): void {
const g = this.g.clear();
drawLamp(
g,
{ x: 10, y: this.h / 2 + 4, r: 6 },
this.lit ? 0xff5a2a : 0x5a2a20,
this.lit ? { color: 0xff5a2a, alpha: 0.25 } : null,
this.lit ? 0.7 : 0.2,
);
// The lever: up is on.
const body = { x: 24, y: 10, w: 20, h: this.h - 10 };
g.roundRect(body.x, body.y, body.w, body.h, 4).fill(0x2a2c30).stroke({ color: 0x55595f, width: 1 });
const cx = body.x + body.w / 2;
const cy = body.y + body.h / 2;
const tip = this.lit ? body.y - 4 : body.y + body.h + 4;
g.moveTo(cx, cy).lineTo(cx, tip).stroke({ color: 0xb9bdc3, width: 5, cap: 'round' });
g.circle(cx, tip, 4).fill(0xe8eaed).stroke({ color: 0x6d7178, width: 1 });
placeEngraving(this.caption, 52 + (this.w - 52) / 2, 2);
this.says.position.set(52, 20);
}
}
/** The colours of a row of type buttons: the face, hovered, picked, and the shadow under them. */
export interface PickLook {
face: number;
hover: number;
picked: number;
shadow: number;
}
/** A button of a row on a plate, lit while it is the one picked. */
export abstract class PlateButton<T extends string> extends Widget {
picked = false;
constructor(
readonly value: T,
onPick: (value: T) => void,
) {
super();
this.cursor = 'pointer';
this.trackHover();
// A press on a button is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onPick(value);
});
}
pick(on: boolean): void {
if (on === this.picked) return;
this.picked = on;
this.invalidate();
}
}
/** A type's button: its name on it. The Reverb's (#389), the Saturation's (#396). */
export class PickButton<T extends string> extends PlateButton<T> {
private readonly g = new Graphics();
private readonly caption: Text;
constructor(
value: T,
name: string,
private readonly look: PickLook,
onPick: (value: T) => void,
) {
super(value, onPick);
this.addChild(this.g);
this.caption = screenText(this, 9, 0xe4e2ee, 0.5, 0.5, name.toUpperCase());
}
override redraw(): void {
const { w, h } = this;
const down = this.picked ? 1 : 0;
this.g
.clear()
.roundRect(0, 2, w, h - 2, 5)
.fill(this.look.shadow)
.roundRect(0, down, w, h - 2, 5)
.fill(this.picked ? this.look.picked : this.hovered ? this.look.hover : this.look.face)
.stroke({ color: 0x1e1d27, width: 1 })
.roundRect(3, down + 2, w - 6, 4, 2)
.fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.1 });
this.caption.position.set(Math.round(w / 2), Math.round(down + (h - 2) / 2));
this.caption.style.fill = this.picked ? 0xffffff : 0xd9d7e6;
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './h
export type EnvelopeKey = keyof typeof ENVELOPE_ROWS; export type EnvelopeKey = keyof typeof ENVELOPE_ROWS;
export type EnvelopeValues = Record<EnvelopeKey, number>; export type EnvelopeValues = Record<EnvelopeKey, number>;
export const ENVELOPE_KEYS = Object.keys(ENVELOPE_ROWS) as EnvelopeKey[]; const ENVELOPE_KEYS = Object.keys(ENVELOPE_ROWS) as EnvelopeKey[];
/** The four stages turn a knob each; the two ends of the value have no range, so they are typed. */ /** The four stages turn a knob each; the two ends of the value have no range, so they are typed. */
export const ENVELOPE_KNOB_KEYS = ['attack', 'decay', 'sustain', 'release'] as const; export const ENVELOPE_KNOB_KEYS = ['attack', 'decay', 'sustain', 'release'] as const;
export const ENVELOPE_FIELD_KEYS = ['from', 'to'] as const; export const ENVELOPE_FIELD_KEYS = ['from', 'to'] as const;
@@ -73,7 +73,7 @@ const FIELD_SLOT = 84;
const GROUP_GAP = 22; const GROUP_GAP = 22;
/** The height of a group's name and its rule. */ /** The height of a group's name and its rule. */
const GROUP_LINE = 16; const GROUP_LINE = 16;
export const ENVELOPE_FIELD = { w: 72, h: 24 }; const ENVELOPE_FIELD = { w: 72, h: 24 };
export interface EnvelopeLayout { export interface EnvelopeLayout {
title: Box; title: Box;
+41 -2
View File
@@ -20,7 +20,7 @@ export const PANEL = { w: 820, h: 280 };
/** The engraved name above a knob, and the number below its dial. */ /** The engraved name above a knob, and the number below its dial. */
export const KNOB_CAPTION = 14; export const KNOB_CAPTION = 14;
export const KNOB_FIELD = 24; const KNOB_FIELD = 24;
/** The narrowest a knob is: its number below. */ /** The narrowest a knob is: its number below. */
const KNOB_LEAST_WIDTH = 60; const KNOB_LEAST_WIDTH = 60;
export const BIG_DIAL = 100; export const BIG_DIAL = 100;
@@ -57,6 +57,45 @@ export function screwsOf(plate: { w: number; h: number }): { x: number; y: numbe
]; ];
} }
/** The width a knob of a row of groups takes, and the height of a group's name and its rule. */
const GROUP_SLOT = 64;
const GROUP_LINE = 16;
/** A group of knobs in a row of them, its name engraved above them. */
export interface KnobGroup<K extends string> {
label: string;
keys: readonly K[];
}
/** How wide a row of groups of small knobs is, `gap` between two groups. */
export function knobGroupsWidth(groups: readonly KnobGroup<string>[], gap: number): number {
return groups.reduce((sum, group) => sum + group.keys.length * GROUP_SLOT, 0) + (groups.length - 1) * gap;
}
/**
* A row of groups of small knobs from `x` on, the names `top` down: the box of each group's name and
* the spot of each knob under it. The Chip's (#386), the Delay's (#393) and the Acid's (#421).
*/
export function knobGroups<K extends string>(
groups: readonly KnobGroup<K>[],
x: number,
top: number,
gap: number,
): { groups: { label: string; box: Box }[]; knobs: Record<K, KnobSpot> } {
const dialY = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
const boxes: { label: string; box: Box }[] = [];
const knobs = {} as Record<K, KnobSpot>;
for (const group of groups) {
boxes.push({ label: group.label, box: { x, y: top, w: group.keys.length * GROUP_SLOT, h: GROUP_LINE } });
for (const key of group.keys) {
knobs[key] = { x: x + GROUP_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += GROUP_SLOT;
}
x += gap;
}
return { groups: boxes, knobs };
}
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */ /** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
export const PANEL_LAYOUT: PanelLayout = { export const PANEL_LAYOUT: PanelLayout = {
title: { x: 34, y: 14, w: 220, h: 30 }, title: { x: 34, y: 14, w: 220, h: 30 },
@@ -137,7 +176,7 @@ export function knobBox(spot: KnobSpot): Box {
export const METER_RANGE_DB = 24; export const METER_RANGE_DB = 24;
/** How far the needle swings from end to end, in radians. */ /** How far the needle swings from end to end, in radians. */
export const METER_SWEEP = (96 * Math.PI) / 180; const METER_SWEEP = (96 * Math.PI) / 180;
/** The marks with a number, and the ones between. */ /** The marks with a number, and the ones between. */
export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24]; export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24];
export const METER_TICKS = [2, 4, 5, 8, 12, 20]; export const METER_TICKS = [2, 4, 5, 8, 12, 20];
+3 -3
View File
@@ -118,7 +118,7 @@ export const LIMITER_LAYOUT: LimiterLayout = limiterLayout();
// --- What it takes away, as read from its taps. --- // --- What it takes away, as read from its taps. ---
/** Less taken away than this counts as none: the two sides are read a render quantum apart at worst. */ /** Less taken away than this counts as none: the two sides are read a render quantum apart at worst. */
export const REDUCTION_FLOOR_DB = 0.2; const REDUCTION_FLOOR_DB = 0.2;
/** /**
* What the Limiter takes away now, in dB, from the loudest sample in front of it – read as late as * What the Limiter takes away now, in dB, from the loudest sample in front of it – read as late as
@@ -138,7 +138,7 @@ export function limiterReduction(levels: Levels | null, values: LimiterValues):
// --- The screen: the reduction hangs down from the top, the level coming in sits at the bottom. --- // --- The screen: the reduction hangs down from the top, the level coming in sits at the bottom. ---
/** The most the screen shows taken away; the root spreads the first dB as the Compressor's meter does. */ /** The most the screen shows taken away; the root spreads the first dB as the Compressor's meter does. */
export const REDUCTION_RANGE_DB = 24; const REDUCTION_RANGE_DB = 24;
export const REDUCTION_MARKS = [1, 3, 6, 12, 24]; export const REDUCTION_MARKS = [1, 3, 6, 12, 24];
/** How far down the reduction reaches, 0 at the top to 1 at the range. */ /** How far down the reduction reaches, 0 at the top to 1 at the range. */
@@ -150,7 +150,7 @@ export const levelHeight = (db: number): number =>
Number.isFinite(db) ? Math.max(0, Math.min(1, (db - LEVEL_FLOOR_DB) / -LEVEL_FLOOR_DB)) : 0; Number.isFinite(db) ? Math.max(0, Math.min(1, (db - LEVEL_FLOOR_DB) / -LEVEL_FLOOR_DB)) : 0;
/** The time spans the screen grows through as a sound goes on; past the last it rolls. */ /** The time spans the screen grows through as a sound goes on; past the last it rolls. */
export const CAPTURE_SPANS = [1, 2, 4, 8]; const CAPTURE_SPANS = [1, 2, 4, 8];
/** The span a capture of `elapsed` seconds is shown in: the shortest it fits in. */ /** The span a capture of `elapsed` seconds is shown in: the shortest it fits in. */
export function captureSpan(elapsed: number): number { export function captureSpan(elapsed: number): number {
+8 -8
View File
@@ -231,9 +231,9 @@ export function idleModKeys(device: ModDevice, values: ModValues): Set<string> {
// --- What the light plays: the first seconds of a sound. --- // --- What the light plays: the first seconds of a sound. ---
/** The seconds a screen spans: the first of these that is long enough. */ /** The seconds a screen spans: the first of these that is long enough. */
export const SPANS = [1, 2, 4, 8] as const; const SPANS = [1, 2, 4, 8] as const;
export const spanFor = (seconds: number): number => SPANS.find((s) => s >= seconds - 1e-9) ?? SPANS.at(-1)!; const spanFor = (seconds: number): number => SPANS.find((s) => s >= seconds - 1e-9) ?? SPANS.at(-1)!;
/** Two swings at least of an LFO at `rate` Hz; one that stands still is shown a second. */ /** Two swings at least of an LFO at `rate` Hz; one that stands still is shown a second. */
export const lfoSpan = (rate: number): number => (rate > 0 ? spanFor(2 / rate) : SPANS[0]); export const lfoSpan = (rate: number): number => (rate > 0 ? spanFor(2 / rate) : SPANS[0]);
@@ -242,7 +242,7 @@ export const lfoSpan = (rate: number): number => (rate > 0 ? spanFor(2 / rate) :
export const loopAt = (span: number, seconds: number): number => ((seconds % span) + span) % span; export const loopAt = (span: number, seconds: number): number => ((seconds % span) + span) % span;
/** A Phaser's allpasses turn where its LFO has them `t` seconds after the sound starts (#341). */ /** A Phaser's allpasses turn where its LFO has them `t` seconds after the sound starts (#341). */
export function phaserTurnAt(values: ModValues, t: number, rate = SHOWN_RATE): number { function phaserTurnAt(values: ModValues, t: number, rate = SHOWN_RATE): number {
const swing = Math.sin(2 * Math.PI * Math.max(0, num(values, 'rate')) * t); const swing = Math.sin(2 * Math.PI * Math.max(0, num(values, 'rate')) * t);
const hz = num(values, 'frequency') * 2 ** ((num(values, 'depth') / 2) * swing); const hz = num(values, 'frequency') * 2 ** ((num(values, 'depth') / 2) * swing);
return Math.max(10, Math.min(rate * 0.45, hz)); return Math.max(10, Math.min(rate * 0.45, hz));
@@ -289,7 +289,7 @@ const PITCH_REACHES = [0.5, 1, 2, 4, 8] as const;
/** How far up a Flanger's lane of delay goes, in ms. */ /** How far up a Flanger's lane of delay goes, in ms. */
const DELAY_REACHES = [2.5, 5, 10, FLANGER_MAX_MS] as const; const DELAY_REACHES = [2.5, 5, 10, FLANGER_MAX_MS] as const;
export const SWING_POINTS = 601; const SWING_POINTS = 601;
export interface SwingPicture { export interface SwingPicture {
/** The seconds the screen spans. */ /** The seconds the screen spans. */
@@ -364,8 +364,8 @@ export function lampOf(picture: SwingPicture, value: number): number {
/** The rate the screens reckon at: the Phaser's allpasses differ a little by it near the top. */ /** The rate the screens reckon at: the Phaser's allpasses differ a little by it near the top. */
export const SHOWN_RATE = 48000; export const SHOWN_RATE = 48000;
export const LOW_HZ = 20; const LOW_HZ = 20;
export const HIGH_HZ = 20000; const HIGH_HZ = 20000;
/** The screen's dB, top to bottom: a notch goes deeper than its floor. */ /** The screen's dB, top to bottom: a notch goes deeper than its floor. */
export const RESPONSE_TOP = 12; export const RESPONSE_TOP = 12;
export const RESPONSE_FLOOR = -36; export const RESPONSE_FLOOR = -36;
@@ -507,7 +507,7 @@ export function flangerLine(values: ModValues, ms: number, columns: number): Res
* time, drawn faintly behind the line. High up its teeth pass faster than frames are drawn, and a * time, drawn faintly behind the line. High up its teeth pass faster than frames are drawn, and a
* sharp line of them flickers. There the trail is a calm band, over 10 kHz it changes 0.04 dB a frame. * sharp line of them flickers. There the trail is a calm band, over 10 kHz it changes 0.04 dB a frame.
*/ */
export const RESPONSE_TRAIL = 1 / 20; const RESPONSE_TRAIL = 1 / 20;
/** /**
* How clearly the line can be followed at `hz` while the delay moves `movedMs` within the trail: * How clearly the line can be followed at `hz` while the delay moves `movedMs` within the trail:
@@ -645,7 +645,7 @@ export function modTexts(device: ModDevice, values: ModValues, picture: SwingPic
// --- The plates. --- // --- The plates. ---
/** The plates: as the Delay's (#393), a panel never takes another size. */ /** The plates: as the Delay's (#393), a panel never takes another size. */
export const MOD_PANELS: Record<ModKind, { w: number; h: number }> = { const MOD_PANELS: Record<ModKind, { w: number; h: number }> = {
tremolo: { w: 640, h: 280 }, tremolo: { w: 640, h: 280 },
vibrato: { w: 640, h: 280 }, vibrato: { w: 640, h: 280 },
phaser: { w: 760, h: 280 }, phaser: { w: 760, h: 280 },
+4 -1
View File
@@ -35,7 +35,10 @@ import { ReverbFace } from './ReverbFace';
import { SampleFace } from './SampleFace'; import { SampleFace } from './SampleFace';
import { SaturationFace } from './SaturationFace'; import { SaturationFace } from './SaturationFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400, #421), by type. */ /**
* The nodes drawn as a face of their own instead of rows, by type (#380, #381, #384–#386,
* #388–#390, #392, #393, #396–#400, #421).
*/
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null { export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id); if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentAcid.type) return new AcidFace(state, assetId, node.id); if (node.type === InstrumentAcid.type) return new AcidFace(state, assetId, node.id);
+1 -1
View File
@@ -113,7 +113,7 @@ export const REVERB_KNOBS: Record<ReverbKey, ReverbKnob> = {
export const REVERB_PANEL = { w: 640, h: 280 }; export const REVERB_PANEL = { w: 640, h: 280 };
/** The round screen's side, and the radius of its outer ring inside it. */ /** The round screen's side, and the radius of its outer ring inside it. */
export const SCOPE_SIZE = 176; const SCOPE_SIZE = 176;
const KNOB_SLOT = 64; const KNOB_SLOT = 64;
const GROUP_GAP = 22; const GROUP_GAP = 22;
/** The height of a group's name and its rule. */ /** The height of a group's name and its rule. */
+1 -1
View File
@@ -70,7 +70,7 @@ const KNOB_SLOT = 60;
const LEVER = { w: 96, h: 40 }; const LEVER = { w: 96, h: 40 };
/** The room left of a loop field for its name. */ /** The room left of a loop field for its name. */
export const FIELD_NAME = 40; export const FIELD_NAME = 40;
export const SAMPLE_FIELD = { w: 64, h: 24 }; const SAMPLE_FIELD = { w: 64, h: 24 };
const GROUP_GAP = 20; const GROUP_GAP = 20;
/** The height of a group's name and its rule. */ /** The height of a group's name and its rule. */
const GROUP_LINE = 16; const GROUP_LINE = 16;
+2 -2
View File
@@ -143,7 +143,7 @@ export const SATURATION_LAYOUT: SaturationLayout = saturationLayout();
export const WAVE_REACH = 0.5; export const WAVE_REACH = 0.5;
/** The periods of the sine across the screen, and the points the wave is drawn with. */ /** The periods of the sine across the screen, and the points the wave is drawn with. */
const PERIODS = 2; const PERIODS = 2;
export const WAVE_POINTS = 241; const WAVE_POINTS = 241;
export interface WaveTrace { export interface WaveTrace {
/** The sine going in, at -12 dBFS. */ /** The sine going in, at -12 dBFS. */
@@ -235,7 +235,7 @@ export const glowStrength = (heat: number): number => 0.65 * Math.max(0, Math.mi
/** The most embers that rise at white heat; under `EMBERS_FROM` none do. */ /** The most embers that rise at white heat; under `EMBERS_FROM` none do. */
export const MOST_EMBERS = 24; export const MOST_EMBERS = 24;
export const EMBERS_FROM = 0.15; const EMBERS_FROM = 0.15;
export const emberCount = (heat: number): number => export const emberCount = (heat: number): number =>
Math.round((Math.max(0, heat - EMBERS_FROM) / (1 - EMBERS_FROM)) * MOST_EMBERS); Math.round((Math.max(0, heat - EMBERS_FROM) / (1 - EMBERS_FROM)) * MOST_EMBERS);
+13 -48
View File
@@ -4,7 +4,6 @@ import {
type FederatedWheelEvent, type FederatedWheelEvent,
Graphics, Graphics,
Sprite, Sprite,
type Text,
Texture, Texture,
} from 'pixi.js'; } from 'pixi.js';
import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands'; import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands';
@@ -19,7 +18,7 @@ import {
VIDEO_END_KEY, VIDEO_END_KEY,
VIDEO_START_KEY, VIDEO_START_KEY,
} from '../../core/properties/videos'; } from '../../core/properties/videos';
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui'; import { crispText, type Size, TextPool, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
import { import {
clampWave, clampWave,
type ClipBand, type ClipBand,
@@ -44,6 +43,7 @@ import { openVideo, videoFileUrl } from '../../viewport/VideoLibrary';
import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek'; import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { containRect } from '../texture/imageGrid'; import { containRect } from '../texture/imageGrid';
import { type ClipName, showClipNames } from '../sound/clipStrip';
import { axisTween } from '../viewTween'; import { axisTween } from '../viewTween';
const RULER_HEIGHT = 24; const RULER_HEIGHT = 24;
@@ -60,8 +60,6 @@ const DRAG_START = 3;
/** Clip edges are stored to the millisecond. */ /** Clip edges are stored to the millisecond. */
const STORED_RATE = 1000; const STORED_RATE = 1000;
const PREVIEW_PAD = 8; const PREVIEW_PAD = 8;
const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
type Gesture = type Gesture =
| { kind: 'pan'; pointerX: number; panX: number } | { kind: 'pan'; pointerX: number; panX: number }
@@ -101,7 +99,7 @@ interface Preview {
* middle button pans, as in the Sound tab. * middle button pans, as in the Sound tab.
*/ */
/** The colour in the middle of the frame a video holds; transparent where it has none to draw. */ /** The colour in the middle of the frame a video holds; transparent where it has none to draw. */
export function middlePixel(element: HTMLVideoElement): number[] | null { function middlePixel(element: HTMLVideoElement): number[] | null {
if (element.readyState < 2) return null; if (element.readyState < 2) return null;
const context = new OffscreenCanvas(8, 8).getContext('2d', { willReadFrequently: true }); const context = new OffscreenCanvas(8, 8).getContext('2d', { willReadFrequently: true });
if (!context) return null; if (!context) return null;
@@ -114,9 +112,9 @@ export class VideoCanvas extends Widget {
private readonly picture = new Sprite(); private readonly picture = new Sprite();
private readonly overlay = new Graphics(); private readonly overlay = new Graphics();
private readonly labels = new Container(); private readonly labels = new Container();
private readonly texts: Text[] = []; private readonly rulerTexts = new TextPool(this.labels);
private readonly stripLabels = new Container(); private readonly stripLabels = new Container();
private readonly stripTexts: Text[] = []; private readonly stripTexts = new TextPool(this.stripLabels);
private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly hint = crispText({ text: '', style: textStyle('muted') });
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 }; private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
/** A zoom or a Fit of the time shown as a transition, as in the Sound tab (#377). */ /** A zoom or a Fit of the time shown as a transition, as in the Sound tab (#377). */
@@ -589,8 +587,8 @@ export class VideoCanvas extends Widget {
this.hint.position.set(Theme.padding * 2, Theme.padding * 2); this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
if (!asset?.file) { if (!asset?.file) {
this.picture.visible = false; this.picture.visible = false;
this.syncLabels([]); this.rulerTexts.show([]);
this.syncStrip([]); this.stripTexts.show([]);
return; return;
} }
this.drawPicture(asset, top); this.drawPicture(asset, top);
@@ -630,7 +628,9 @@ export class VideoCanvas extends Widget {
this.overlay.rect(x, top + RULER_HEIGHT - 8, 1, 7).fill({ color: Theme.colors.textMuted, alpha: 0.8 }); this.overlay.rect(x, top + RULER_HEIGHT - 8, 1, 7).fill({ color: Theme.colors.textMuted, alpha: 0.8 });
placed.push({ text: rulerLabel(seconds, step), x: x + 3 }); placed.push({ text: rulerLabel(seconds, step), x: x + 3 });
} }
this.syncLabels(placed.map((label) => ({ ...label, y: top + 6 }))); this.rulerTexts
.show(placed.map((label) => label.text))
.forEach((text, i) => text.position.set(Math.round(placed[i]!.x), Math.round(top + 6)));
} }
private litClipId(): string { private litClipId(): string {
@@ -642,7 +642,7 @@ export class VideoCanvas extends Widget {
const c = Theme.colors; const c = Theme.colors;
const lit = this.litClipId(); const lit = this.litClipId();
const names = new Map(clipsOf<VideoClip>(asset).map((clip) => [clip.id, clip.name])); const names = new Map(clipsOf<VideoClip>(asset).map((clip) => [clip.id, clip.name]));
const placed: { text: string; x: number; room: number; lit: boolean }[] = []; const placed: ClipName[] = [];
const y = top + RULER_HEIGHT; const y = top + RULER_HEIGHT;
for (const { id, span } of this.bands(asset)) { for (const { id, span } of this.bands(asset)) {
const a = secondsToX(this.view, span.start); const a = secondsToX(this.view, span.start);
@@ -658,11 +658,12 @@ export class VideoCanvas extends Widget {
placed.push({ placed.push({
text: name, text: name,
x: Math.max(a, 0) + 4, x: Math.max(a, 0) + 4,
y,
room: Math.min(b, this.w) - Math.max(a, 0) - 8, room: Math.min(b, this.w) - Math.max(a, 0) - 8,
lit: on, lit: on,
}); });
} }
this.syncStrip(placed.map((label) => ({ ...label, y }))); showClipNames(this.stripTexts, placed, STRIP_HEIGHT);
} }
/** /**
@@ -708,40 +709,4 @@ export class VideoCanvas extends Widget {
.fill(held ? c.hot : c.accent); .fill(held ? c.hot : c.accent);
} }
} }
private syncLabels(placed: readonly { text: string; x: number; y: number }[]): void {
placed.forEach((label, i) => {
let text = this.texts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.texts.push(text);
this.labels.addChild(text);
}
text.visible = true;
if (text.text !== label.text) text.text = label.text;
text.position.set(Math.round(label.x), Math.round(label.y));
});
for (let i = placed.length; i < this.texts.length; i++) this.texts[i]!.visible = false;
}
private syncStrip(
placed: readonly { text: string; x: number; y: number; room: number; lit: boolean }[],
): void {
placed.forEach((label, i) => {
let text = this.stripTexts[i];
if (!text) {
text = crispText({ style: textStyle('small') });
this.stripTexts.push(text);
this.stripLabels.addChild(text);
}
const style = label.lit ? LIT_LABEL : textStyle('small');
if (text.style !== style) text.style = style;
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = text.width <= label.room;
text.position.set(Math.round(label.x), Math.round(label.y + (STRIP_HEIGHT - text.height) / 2));
});
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
}
} }
-5
View File
@@ -11,11 +11,6 @@ export interface ViewMap {
y: number; y: number;
} }
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
return glide(t, 3);
}
/** The steepest start `glide` takes without running past its end. */ /** The steepest start `glide` takes without running past its end. */
const STEEPEST = 4; const STEEPEST = 4;
-4
View File
@@ -21,10 +21,6 @@ export class AudioSwitch implements AudioSink {
constructor(private readonly live: AudioSink) {} constructor(private readonly live: AudioSink) {}
get diverted(): boolean {
return this.aside !== null;
}
/** From now on what the run plays goes to `sink`, which numbers from `DIVERTED_IDS`. */ /** From now on what the run plays goes to `sink`, which numbers from `DIVERTED_IDS`. */
divert(sink: AudioSink): void { divert(sink: AudioSink): void {
this.aside = sink; this.aside = sink;
+1
View File
@@ -47,6 +47,7 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep,
export { downloadBlob, downloadFile } from './download'; export { downloadBlob, downloadFile } from './download';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
export { Label, truncate } from './widgets/Label'; export { Label, truncate } from './widgets/Label';
export { TextPool } from './textPool';
export { Button, type ButtonOptions } from './widgets/Button'; export { Button, type ButtonOptions } from './widgets/Button';
export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip'; export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip';
export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip'; export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip';
+2 -2
View File
@@ -25,9 +25,9 @@ const reach = (range: KnobRange) => Math.max(-range.min, range.max);
/** Pixels of a vertical drag that take the knob from one end to the other. */ /** Pixels of a vertical drag that take the knob from one end to the other. */
export const KNOB_TRAVEL = 200; export const KNOB_TRAVEL = 200;
/** How much of the turn Shift leaves to a drag. */ /** How much of the turn Shift leaves to a drag. */
export const KNOB_FINE = 0.1; const KNOB_FINE = 0.1;
/** How far one notch of the wheel turns the knob. */ /** How far one notch of the wheel turns the knob. */
export const KNOB_NOTCH = 0.02; const KNOB_NOTCH = 0.02;
/** The turn as an angle: from down-left round over the top to down-right, in radians. */ /** The turn as an angle: from down-left round over the top to down-right, in radians. */
export const KNOB_START = Math.PI * 0.75; export const KNOB_START = Math.PI * 0.75;
export const KNOB_SWEEP = Math.PI * 1.5; export const KNOB_SWEEP = Math.PI * 1.5;
+43
View File
@@ -0,0 +1,43 @@
import type { Container, Text, TextStyle } from 'pixi.js';
import { crispText } from './pixelGrid';
import { textStyle } from './Theme';
/**
* The texts of a layer that shows a changing set of them, a ruler's or a row of names: made as
* they are first wanted and kept for the next frame. A text that already shows a wanted string
* keeps it, so a pan that shifts which one comes first sets none anew (#360).
*/
export class TextPool {
private readonly texts: Text[] = [];
/** How many times a text was set to a string it did not show (checks). */
changes = 0;
constructor(
private readonly layer: Container,
private readonly style: TextStyle = textStyle('small'),
) {}
/** A shown text for each wanted string, in their order; the others hidden. */
show(wanted: readonly string[]): Text[] {
const free = new Map<string, Text[]>();
for (const text of this.texts)
if (text.visible) free.set(text.text, [...(free.get(text.text) ?? []), text]);
const kept = wanted.map((s) => free.get(s)?.shift());
const spare = this.texts.filter((text) => !kept.includes(text));
for (const text of spare) text.visible = false;
return wanted.map((s, i) => {
let text = kept[i] ?? spare.pop();
if (!text) {
text = crispText({ style: this.style });
this.texts.push(text);
this.layer.addChild(text);
}
text.visible = true;
if (text.text !== s) {
text.text = s;
this.changes++;
}
return text;
});
}
}
+2 -2
View File
@@ -430,7 +430,7 @@ export class SoundLibrary implements AudioSink {
/** /**
* The names a game streams rather than decodes (#349), each with its copy (`streamTable`): the first * The names a game streams rather than decodes (#349), each with its copy (`streamTable`): the first
* seconds of every copy are fetched and decoded now, so it starts at once. A library that bakes, * seconds of every copy are fetched and decoded now, so it starts at once. A library that bakes,
* and a browser without Opus in WebCodecs, decode everything as before. * and a browser without Opus in WebCodecs, decode every file whole.
*/ */
setStreams(table: ReadonlyMap<string, StreamedFile>): void { setStreams(table: ReadonlyMap<string, StreamedFile>): void {
this.streams = table; this.streams = table;
@@ -439,7 +439,7 @@ export class SoundLibrary implements AudioSink {
/** /**
* The copies clips are decoded from (#349), each under the cut it plays (`clipCopyTable`). A clip * The copies clips are decoded from (#349), each under the cut it plays (`clipCopyTable`). A clip
* whose sound's whole file is decoded anyway is cut from that as before. One that bakes keeps to the * whose sound's whole file is decoded anyway is cut from that file. One that bakes keeps to the
* whole files, as the fingerprint of a bake does. * whole files, as the fingerprint of a bake does.
*/ */
setCopies(table: ReadonlyMap<string, string>): void { setCopies(table: ReadonlyMap<string, string>): void {
+1 -8
View File
@@ -282,13 +282,6 @@ export class VideoLibrary {
if (element.paused) void element.play().catch(() => {}); if (element.paused) void element.play().catch(() => {});
} }
/** Lets every player go: a session that ends takes its videos along. */
clear(): void {
for (const player of this.players.values()) this.drop(player);
this.players.clear();
this.stopSweeping();
}
/** /**
* Drops the players nobody asked for lately; a paused run asks for nothing and keeps them. So * Drops the players nobody asked for lately; a paused run asks for nothing and keeps them. So
* does a hidden page: Chrome draws no frames for it while its timers go on, and a sprite that * does a hidden page: Chrome draws no frames for it while its timers go on, and a sprite that
@@ -528,7 +521,7 @@ export function openVideo(url: string, at: number, onPicture: (texture: Texture)
return element; return element;
} }
/** The stills the worker cut from each video, for the picker (see `posterFileOf`). */ /** The stills the worker cut from each video, for the picker: the `poster` the server names. */
class PosterLibrary extends FileLibrary<Texture> { class PosterLibrary extends FileLibrary<Texture> {
private readonly loaded = new Map<string, Texture>(); private readonly loaded = new Map<string, Texture>();
+1 -1
View File
@@ -381,7 +381,7 @@ export interface AcidOptions extends AcidNote {
} }
/** The Acid's AudioParams: its knobs, its pitch, and the note's end and cut, set on the clock. */ /** The Acid's AudioParams: its knobs, its pitch, and the note's end and cut, set on the clock. */
export const ACID_PARAMS = ['cutoff', 'resonance', 'envMod', 'pitch', 'gate', 'cut'] as const; const ACID_PARAMS = ['cutoff', 'resonance', 'envMod', 'pitch', 'gate', 'cut'] as const;
/** The Acid of a note (#421): a source of its own that plays out its release and then ends. */ /** The Acid of a note (#421): a source of its own that plays out its release and then ends. */
class AcidProcessor extends Source { class AcidProcessor extends Source {
+2 -2
View File
@@ -6,8 +6,8 @@
*/ */
/** The speeds a driven video plays at; outside them it holds and seeks to every asked time. */ /** The speeds a driven video plays at; outside them it holds and seeks to every asked time. */
export const MIN_PLAY_SPEED = 0.25; const MIN_PLAY_SPEED = 0.25;
export const MAX_PLAY_SPEED = 4; const MAX_PLAY_SPEED = 4;
/** How far a playing video may be off before it seeks instead of being nudged, in seconds. */ /** How far a playing video may be off before it seeks instead of being nudged, in seconds. */
export const SEEK_DRIFT = 0.25; export const SEEK_DRIFT = 0.25;
/** How much the speed is nudged per second off, and at most, as a share of it. */ /** How much the speed is nudged per second off, and at most, as a share of it. */
+7 -4
View File
@@ -30,7 +30,6 @@ import {
movedItems, movedItems,
movedWithClips, movedWithClips,
pastedClips, pastedClips,
pastedItems,
pastedItemsWithClips, pastedItemsWithClips,
resizedItem, resizedItem,
snappedToMedia, snappedToMedia,
@@ -348,13 +347,17 @@ describe('items and their objects', () => {
['b', 'b2'], ['b', 'b2'],
]); ]);
const tracks = ['t1', 't2', 't3']; const tracks = ['t1', 't2', 't3'];
expect(pastedItems(copied, ids, 100, tracks)).toEqual([ expect(pastedItemsWithClips(copied, [], [], ids, 100, tracks, () => 1).items).toEqual([
item('a2', 't2', 100, 60, { offset: 1.5 }), item('a2', 't2', 100, 60, { offset: 1.5 }),
item('b2', 't3', 120, 20), item('b2', 't3', 120, 20),
]); ]);
expect(pastedItems(copied, ids, 0, tracks, 0).map((i) => i.track)).toEqual(['t1', 't2']); expect(
pastedItemsWithClips(copied, [], [], ids, 0, tracks, () => 1, 0).items.map((i) => i.track),
).toEqual(['t1', 't2']);
// As far as there are tracks: the bottom one may not fall off. // As far as there are tracks: the bottom one may not fall off.
expect(pastedItems(copied, ids, 0, tracks, 2).map((i) => i.track)).toEqual(['t2', 't3']); expect(
pastedItemsWithClips(copied, [], [], ids, 0, tracks, () => 1, 2).items.map((i) => i.track),
).toEqual(['t2', 't3']);
}); });
it('pasted clips keep their timing and move on until none lies over another clip', () => { it('pasted clips keep their timing and move on until none lies over another clip', () => {
-11
View File
@@ -20,11 +20,9 @@ import {
isVideoFile, isVideoFile,
parseVideoKey, parseVideoKey,
routedVideoKey, routedVideoKey,
posterFileOf,
videoKey, videoKey,
videoPlayOf, videoPlayOf,
videoSpan, videoSpan,
videoTimeAt,
} from '../../src/core/model/video'; } from '../../src/core/model/video';
import { pictureChoices, textureRefLabel, videoChoices } from '../../src/core/properties/textureRef'; import { pictureChoices, textureRefLabel, videoChoices } from '../../src/core/properties/textureRef';
import { compositionBusPick, FROM_ASSET_BUS, MASTER_BUS } from '../../src/core/properties/buses'; import { compositionBusPick, FROM_ASSET_BUS, MASTER_BUS } from '../../src/core/properties/buses';
@@ -62,7 +60,6 @@ describe('a video asset (#402)', () => {
expect(() => f.stack.execute(new CreateVideoCommand('Bad', 'holiday.mov', DETAILS))).toThrow( expect(() => f.stack.execute(new CreateVideoCommand('Bad', 'holiday.mov', DETAILS))).toThrow(
CommandError, CommandError,
); );
expect(posterFileOf(FILE)).toBe(`a1b2c3d4e5f6/${'c7'.repeat(32)}.jpg`);
}); });
it('keeps its details, loops by default and has clips like a sound', () => { it('keeps its details, loops by default and has clips like a sound', () => {
@@ -137,14 +134,6 @@ describe('the stretch a video plays', () => {
it('runs to the end when the end is not after the start', () => { it('runs to the end when the end is not after the start', () => {
expect(videoSpan(10, 6, 4)).toEqual({ start: 6, end: 10 }); expect(videoSpan(10, 6, 4)).toEqual({ start: 6, end: 10 });
}); });
it('is told where it stands after a while, repeating or held at its end', () => {
const clip = { start: 2, end: 4, loop: true };
expect(videoTimeAt(clip, 0.5)).toBe(2.5);
expect(videoTimeAt(clip, 2.5)).toBe(2.5);
expect(videoTimeAt({ ...clip, loop: false }, 9)).toBe(4);
expect(videoTimeAt({ start: 0, end: Infinity, loop: true }, 7)).toBe(7);
});
}); });
describe('the key a playing video goes by', () => { describe('the key a playing video goes by', () => {
+5 -6
View File
@@ -12,7 +12,6 @@ import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
import { import {
axisTween, axisTween,
carriedSpeed, carriedSpeed,
easeOut,
glide, glide,
glideSlope, glideSlope,
scrollTween, scrollTween,
@@ -102,11 +101,11 @@ describe('the way between two views', () => {
}); });
}); });
it('eases out: most of the way early, nothing past the end', () => { it('eases out at 3: most of the way early, nothing past the end', () => {
expect(easeOut(0)).toBe(0); expect(glide(0, 3)).toBe(0);
expect(easeOut(1)).toBe(1); expect(glide(1, 3)).toBe(1);
expect(easeOut(2)).toBe(1); expect(glide(2, 3)).toBe(1);
expect(easeOut(0.5)).toBeCloseTo(0.875, 12); expect(glide(0.5, 3)).toBeCloseTo(0.875, 12);
}); });
}); });
+43
View File
@@ -0,0 +1,43 @@
import { Container } from 'pixi.js';
import { describe, expect, it } from 'vitest';
import { TextPool } from '../../src/ui/textPool';
describe('a pool of texts', () => {
it('makes a text for each wanted string, in their order', () => {
const layer = new Container();
const shown = new TextPool(layer).show(['0', '1', '2']);
expect(shown.map((t) => t.text)).toEqual(['0', '1', '2']);
expect(layer.children).toHaveLength(3);
});
it('keeps a text on the string it shows, so a pan sets only the new one', () => {
const pool = new TextPool(new Container());
const [, one, two] = pool.show(['0', '1', '2']);
const before = pool.changes;
const shown = pool.show(['1', '2', '3']);
expect(shown[0]).toBe(one);
expect(shown[1]).toBe(two);
expect(shown.map((t) => t.text)).toEqual(['1', '2', '3']);
expect(pool.changes - before).toBe(1);
});
it('hides the texts not wanted and makes no more than it needs', () => {
const layer = new Container();
const pool = new TextPool(layer);
pool.show(['a', 'b', 'c']);
const [kept] = pool.show(['b']);
expect(kept!.text).toBe('b');
expect(layer.children.filter((t) => t.visible)).toEqual([kept]);
pool.show(['x', 'y', 'z']);
expect(layer.children).toHaveLength(3);
expect(layer.children.every((t) => t.visible)).toBe(true);
});
it('gives a string wanted twice two texts', () => {
const pool = new TextPool(new Container());
pool.show(['…', '…']);
const shown = pool.show(['…', '…']);
expect(new Set(shown).size).toBe(2);
expect(pool.changes).toBe(2);
});
});
+1 -1
View File
@@ -347,7 +347,7 @@ describe('VideoLibrary driven players (#404)', () => {
videosMade = 0; videosMade = 0;
drawFor(500); drawFor(500);
expect([...state(), videosMade]).toEqual([false, false, 0]); expect([...state(), videosMade]).toEqual([false, false, 0]);
// A hidden page draws nothing, and asks for nothing: it plays on as before. // A hidden page draws nothing, and asks for nothing: it plays on all the same.
page.hidden = true; page.hidden = true;
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
expect(state()).toEqual([false, false]); expect(state()).toEqual([false, false]);
+1 -1
View File
@@ -38,7 +38,7 @@ export async function topbar(t) {
); );
// The group is centred on the bar itself. The row cannot do that on its own: the spacers around // The group is centred on the bar itself. The row cannot do that on its own: the spacers around
// the buttons share the free space with the flexible notice and so get a third of it each, which // the buttons share the free space with the flexible notice and so get a third of it each, which
// used to leave the group well left of the middle. // would leave the group well left of the middle.
const play = await json('__gm.toolbarAt("play")'); const play = await json('__gm.toolbarAt("play")');
const step = await json('__gm.toolbarAt("step")'); const step = await json('__gm.toolbarAt("step")');
const barCentre = await json('window.innerWidth / 2'); const barCentre = await json('window.innerWidth / 2');
+4 -4
View File
@@ -191,7 +191,7 @@ export async function video(t) {
// 66e-iii. With sound the video has a node in the Master routing (#351), linked to the master. // 66e-iii. With sound the video has a node in the Master routing (#351), linked to the master.
// Sent into a bus there, the tab still plays it straight into the master, as every // Sent into a bus there, the tab still plays it straight into the master, as every
// tab's preview does (#368): heard at the output, where a recording of the editor // tab's preview does (#368): heard at the output, where a recording of the editor
// listens, and nothing on the bus. It used to play past the mix altogether. // listens, and nothing on the bus.
const graphOf = (expr) => json(`((g) => ${expr})(__gm.routing.graph())`); const graphOf = (expr) => json(`((g) => ${expr})(__gm.routing.graph())`);
const routed = await graphOf( const routed = await graphOf(
`({ node: g.nodes.find((n) => n.id === ${JSON.stringify(assetId)}) ?? null, `({ node: g.nodes.find((n) => n.id === ${JSON.stringify(assetId)}) ?? null,
@@ -589,9 +589,9 @@ export async function video(t) {
JSON.stringify({ readied, firstShown }), JSON.stringify({ readied, firstShown }),
); );
// 66n. The same in a run: the world used to leave a switched-off object out of what the Game tab // 66n. The same in a run: a switched-off object stays in what the Game tab draws, so a cut to it
// draws, so a cut to it loaded the video only then and showed white meanwhile. Nor did wearing // finds its video loaded and shows no white. Wearing the player counts as asking for it, so it
// the player count as asking for it: let go after 2 to 4 s, it was opened again. // is not let go after 2 to 4 s and opened again.
await switchLate(false); await switchLate(false);
const sceneKeys = new Set((await json('__gm.videos()')).map((v) => v.key)); const sceneKeys = new Set((await json('__gm.videos()')).map((v) => v.key));
await play(); await play();
+1 -1
View File
@@ -506,7 +506,7 @@ export async function recording(t) {
// it records (resolution 1.8) rather than scaled up, here at 30 fps without sound. Without // it records (resolution 1.8) rather than scaled up, here at 30 fps without sound. Without
// Convert, Import goes grey, the file is only kept on the server and nothing comes into the // Convert, Import goes grey, the file is only kept on the server and nothing comes into the
// project. The video picker downloads it as it is, and converts it on asking at its full size, // project. The video picker downloads it as it is, and converts it on asking at its full size,
// above the 1080p the server used to shrink every video to. // even above 1080p.
const assetsBefore = await json('__gm.state.project.assets.length'); const assetsBefore = await json('__gm.state.project.assets.length');
await startDialog({ resolution: '1440p', fps: 30, pointer: false, sound: false }); await startDialog({ resolution: '1440p', fps: 30, pointer: false, sound: false });
const convertAt = (await json('__gm.recordingDialog()')).switches.convert; const convertAt = (await json('__gm.recordingDialog()')).switches.convert;
+2 -1
View File
@@ -51,7 +51,8 @@ export async function composition(t) {
], ],
}; };
// Red from the start, a larger blue behind it from the next frame – built after red, it still goes // Red from the start, a larger blue behind it from the next frame – built after red, it still goes
// behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there. // behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s.
// Placed where the camera looks, in front of what is there.
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
+11 -10
View File
@@ -2,16 +2,17 @@
* The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long * The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long
* and picked, so the inspector shows its TIMING; a second one dropped onto the first lands there, * and picked, so the inspector shows its TIMING; a second one dropped onto the first lands there,
* over it. The end of the first, covered by the second, lights up under the pointer and is dragged * over it. The end of the first, covered by the second, lights up under the pointer and is dragged
* past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete * past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture.
* takes an item with its object, and Play runs the playhead to the end. The video of an item about * The hierarchy lists what is alive at the playhead, Delete takes an item with its object, and Play
* to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video * runs the playhead to the end. The video of an item about to begin is opened ahead, so the cut to
* whose player is still loading. A copied item is pasted under the pointer or at the playhead. A * it shows its picture at once, and Play waits for a video whose player is still loading. A copied
* video dragged in wears a sprite the composition keeps to itself. An item dragged in the picture * item is pasted under the pointer or at the playhead. A video dragged in wears a sprite the
* follows the pointer while the button is down. A band picks items, with Alt clips, and copied clips * composition keeps to itself. An item dragged in the picture follows the pointer while the button
* are pasted where they fit. A sound dragged in is an audio item, heard while the playhead runs; * is down. A band picks items, with Alt clips, and copied clips are pasted where they fit. A sound
* the handle of its fade in drags out a fade its bus rises along. A video item's sprite reads as * dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a
* driven by the composition and goes into its bus, which has a node in the master routing; after a * fade its bus rises along. A video item's sprite reads as driven by the composition and goes into
* hitch the sound is started again where the playhead is. * its bus, which has a node in the master routing; after a hitch the sound is started again where
* the playhead is.
* *
* Everything it adds carries a `ct69` id and goes again at the end. * Everything it adds carries a `ct69` id and goes again at the end.
*/ */