From 0701fe38560c47bc21c08653ae9236e140b51eb6 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 14 Oct 2026 20:40:05 +0200 Subject: [PATCH] Share the parts the sound devices and timelines repeated (#85) 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 --- README.md | 4 +- src/core/commands/compositionCommands.ts | 6 +- src/core/commands/compositionSprites.ts | 2 +- src/core/instrument/nodes.ts | 3 - src/core/model/composition.ts | 7 +- src/core/model/compositionEdit.ts | 71 ++- src/core/model/projectFiles.ts | 2 +- src/core/model/video.ts | 14 - src/core/music/ogg.ts | 19 +- src/core/music/projectRouting.ts | 6 +- src/core/music/streams.ts | 4 +- src/core/properties/PropertySchema.ts | 7 +- src/core/properties/bounds.ts | 2 +- src/core/properties/videos.ts | 7 +- src/core/recording/avcColour.ts | 15 +- src/core/recording/editorRecording.ts | 2 +- src/core/recording/matroska.ts | 21 +- src/core/recording/recording.ts | 2 +- src/core/soundfx/acid.ts | 9 +- src/core/util/bytes.ts | 10 + src/editor/animation/TimelineCanvas.ts | 24 +- src/editor/clipboard.ts | 2 +- src/editor/composition/CompositionTimeline.ts | 36 +- src/editor/debug/graphHooks.ts | 4 +- src/editor/inspector/compositionSection.ts | 7 +- src/editor/music/TimeRuler.ts | 23 +- src/editor/pattern/PianoRoll.ts | 24 +- src/editor/routing/RoutingEditor.ts | 5 +- src/editor/song/SongTimeline.ts | 41 +- src/editor/sound/WaveCanvas.ts | 107 +---- src/editor/sound/clipStrip.ts | 29 ++ src/editor/soundfx/AcidFace.ts | 46 +- src/editor/soundfx/AcidPanel.ts | 148 ++----- src/editor/soundfx/ChipFace.ts | 50 +-- src/editor/soundfx/ChipPanel.ts | 165 ++----- src/editor/soundfx/CompressorEditor.ts | 14 +- src/editor/soundfx/CompressorFace.ts | 51 +-- src/editor/soundfx/CompressorPanel.ts | 228 ++-------- src/editor/soundfx/CrusherFace.ts | 44 +- src/editor/soundfx/CrusherPanel.ts | 139 ++---- src/editor/soundfx/CurvePanel.ts | 2 +- src/editor/soundfx/DelayFace.ts | 47 +- src/editor/soundfx/DelayPanel.ts | 176 +++----- src/editor/soundfx/EnvelopeFace.ts | 43 +- src/editor/soundfx/EnvelopePanel.ts | 134 ++---- src/editor/soundfx/EqPanel.ts | 2 +- src/editor/soundfx/FilterPanel.ts | 6 +- src/editor/soundfx/KnobFace.ts | 47 +- src/editor/soundfx/KnobPanel.ts | 61 ++- src/editor/soundfx/LimiterFace.ts | 56 +-- src/editor/soundfx/LimiterPanel.ts | 146 ++---- src/editor/soundfx/ModulationFace.ts | 44 +- src/editor/soundfx/ModulationPanel.ts | 185 +++----- src/editor/soundfx/ReverbFace.ts | 49 +- src/editor/soundfx/ReverbPanel.ts | 213 ++------- src/editor/soundfx/SampleFace.ts | 57 +-- src/editor/soundfx/SamplePanel.ts | 143 ++---- src/editor/soundfx/SaturationFace.ts | 54 +-- src/editor/soundfx/SaturationPanel.ts | 160 +++---- src/editor/soundfx/acidView.ts | 30 +- src/editor/soundfx/chipView.ts | 34 +- src/editor/soundfx/compressorView.ts | 8 +- src/editor/soundfx/crusherView.ts | 4 +- src/editor/soundfx/delayView.ts | 37 +- src/editor/soundfx/deviceFace.ts | 94 ++++ src/editor/soundfx/deviceParts.ts | 417 ++++++++++++++++++ src/editor/soundfx/envelopeView.ts | 4 +- src/editor/soundfx/hardwareView.ts | 43 +- src/editor/soundfx/limiterView.ts | 6 +- src/editor/soundfx/modulationView.ts | 16 +- src/editor/soundfx/nodeFaces.ts | 5 +- src/editor/soundfx/reverbView.ts | 2 +- src/editor/soundfx/sampleView.ts | 2 +- src/editor/soundfx/saturationView.ts | 4 +- src/editor/video/VideoCanvas.ts | 61 +-- src/editor/viewTween.ts | 5 - src/runtime/audioSwitch.ts | 4 - src/ui/index.ts | 1 + src/ui/knob.ts | 4 +- src/ui/textPool.ts | 43 ++ src/viewport/SoundLibrary.ts | 4 +- src/viewport/VideoLibrary.ts | 9 +- src/viewport/fxWorklet.ts | 2 +- src/viewport/videoFollow.ts | 4 +- tests/core/composition-edit.test.ts | 11 +- tests/core/videos.test.ts | 11 - tests/editor/view-tween.test.ts | 11 +- tests/ui/text-pool.test.ts | 43 ++ tests/viewport/video-library.test.ts | 2 +- tools/e2e/46-topbar.mjs | 2 +- tools/e2e/66-video.mjs | 8 +- tools/e2e/67-recording.mjs | 2 +- tools/e2e/68-composition.mjs | 3 +- tools/e2e/69-composition-tab.mjs | 21 +- 94 files changed, 1602 insertions(+), 2380 deletions(-) create mode 100644 src/core/util/bytes.ts create mode 100644 src/editor/sound/clipStrip.ts create mode 100644 src/editor/soundfx/deviceFace.ts create mode 100644 src/editor/soundfx/deviceParts.ts create mode 100644 src/ui/textPool.ts create mode 100644 tests/ui/text-pool.test.ts diff --git a/README.md b/README.md index 880a7ea2..51e38655 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ way in. - 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, 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 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 @@ -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` | | [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 | +| [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 | | [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 | | [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` | | [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 | | [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 | diff --git a/src/core/commands/compositionCommands.ts b/src/core/commands/compositionCommands.ts index d6d6c45f..58bd221a 100644 --- a/src/core/commands/compositionCommands.ts +++ b/src/core/commands/compositionCommands.ts @@ -86,9 +86,9 @@ export function addCompositionItem( /** * 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 - * 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 - * no time to land at. `itemIds` are the new items', to pick them afterwards. + * and the clips that came with them as `pastedItemsWithClips` places them. Null when the copy holds + * anything but items – a scene's objects have no time to land at. `itemIds` are the new items', to + * pick them afterwards. */ export function pasteCompositionItems( project: Project, diff --git a/src/core/commands/compositionSprites.ts b/src/core/commands/compositionSprites.ts index 7235cd7b..699f5c9d 100644 --- a/src/core/commands/compositionSprites.ts +++ b/src/core/commands/compositionSprites.ts @@ -106,7 +106,7 @@ export interface SpriteChange { * 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. */ -export function settleCompositionSprites(project: Project): SpriteChange { +function settleCompositionSprites(project: Project): SpriteChange { const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() }; const sprites = project.assets.filter(isCompositionSprite); if (sprites.length === 0) return change; diff --git a/src/core/instrument/nodes.ts b/src/core/instrument/nodes.ts index c52538fe..352d8de1 100644 --- a/src/core/instrument/nodes.ts +++ b/src/core/instrument/nodes.ts @@ -375,9 +375,6 @@ export const ACID_ROWS = { glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05), } satisfies Record>; -/** 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 = { type: INSTRUMENT_ACID, label: 'Acid', diff --git a/src/core/model/composition.ts b/src/core/model/composition.ts index c17b06cd..465816e8 100644 --- a/src/core/model/composition.ts +++ b/src/core/model/composition.ts @@ -98,7 +98,10 @@ export function itemSounds(project: Project, comp: CompositionAsset, item: Compo 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 { fadeIn: 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. */ -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 diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 6cd81f31..68944d35 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -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, - * or, given `row`, the topmost on that track and the others as far below as they were, as far as - * 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, - 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. + * Copied clips placed as `placing` places what is pasted, 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( copied: readonly CompositionClip[], @@ -232,19 +217,15 @@ export function pastedClips( lengthOf: (clip: CompositionClip) => number, row?: number, ): CompositionClip[] { - const placed = pasted(copied, ids, at, trackIds, row); - if (placed.length === 0) return []; - // 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)))); - 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; - } + const place = placing(copied, at, trackIds, row); + const placed = copied.map((c) => place(c, ids)); + const by = framesToRoom(placed, clips, lengthOf); + return placed.map((c) => ({ ...c, start: c.start + by })); } /** - * Copied items placed as `pastedItems` places them, the copied clips that came with them moved by - * as much; then both moved on frame by frame as long as one of the clips would lie over a clip of + * Copied items placed as `placing` places them, the copied clips that came with them moved by as + * 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. */ export function pastedItemsWithClips( @@ -260,26 +241,32 @@ export function pastedItemsWithClips( const place = placing(items, at, trackIds, row); const placedItems = items.map((i) => place(i, 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)))); for (let by = 0; ; by++) { - const moved = placedClips.map((c) => ({ ...c, start: c.start + by })); - if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) - return { items: placedItems.map((i) => ({ ...i, start: i.start + by })), clips: moved }; + const moved = placed.map((c) => ({ ...c, start: c.start + by })); + if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return by; } } -function pasted( - items: readonly T[], - ids: ReadonlyMap, - at: number, - 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. */ +/** + * How a paste moves what was copied, under the ids `ids` gives it: the earliest of `items` to `at`, + * on their tracks, or, given `row`, the topmost on that track and the others as far below as they + * were, as far as there are tracks – a song's paste. One whose track is gone counts as on the first. + */ function placing( items: readonly Placed[], at: number, diff --git a/src/core/model/projectFiles.ts b/src/core/model/projectFiles.ts index 25ab16ff..5dc71ac9 100644 --- a/src/core/model/projectFiles.ts +++ b/src/core/model/projectFiles.ts @@ -64,7 +64,7 @@ const UNNAMING: ReadonlySet = new Set(['sound', 'texture', 'pixelart' * 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 * 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 { const seen = new Set(); diff --git a/src/core/model/video.ts b/src/core/model/video.ts index 48954696..270a7ffe 100644 --- a/src/core/model/video.ts +++ b/src/core/model/video.ts @@ -17,11 +17,6 @@ export function isVideoFile(name: string): boolean { 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. */ export interface VideoRef { asset: VideoAsset; @@ -193,12 +188,3 @@ export function drivenTimeOf( if (play.loop) return play.start + (run % length); 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, 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); -} diff --git a/src/core/music/ogg.ts b/src/core/music/ogg.ts index 5da3d916..b6e7a4ef 100644 --- a/src/core/music/ogg.ts +++ b/src/core/music/ogg.ts @@ -4,6 +4,7 @@ * (`preSkip`) and the last page how many there are, so a decoder gives back exactly what went in, * to the sample. */ +import { concatBytes } from '../util/bytes'; /** Opus always counts at 48 kHz, whatever the rate of what went in. */ 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 (stream.packets.length === 0) page([], end, 0x04); - return concat(pages); + return concatBytes(pages); } function oggPage( @@ -115,7 +116,7 @@ function oggPage( for (let left = packet.length; left >= 255; left -= 255) lacing.push(255); lacing.push(packet.length % 255); } - const body = concat(packets); + const body = concatBytes(packets); const page = new Uint8Array(27 + lacing.length + body.length); const view = new DataView(page.buffer); page.set(ascii('OggS')); @@ -179,7 +180,7 @@ export class OggOpusReader { private next = 0; 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; while (data.length - at >= 27) { 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)); from += value; if (value < 255) { - this.packet(concat(this.open)); + this.packet(concatBytes(this.open)); this.open = []; } } @@ -254,13 +255,3 @@ function ascii(value: string): Uint8Array { function text(bytes: Uint8Array): string { 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; -} diff --git a/src/core/music/projectRouting.ts b/src/core/music/projectRouting.ts index bfc1cc5a..8f222483 100644 --- a/src/core/music/projectRouting.ts +++ b/src/core/music/projectRouting.ts @@ -1,8 +1,8 @@ /** * 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 - * that comes gets a node of its own and the links most others of its type have, one that goes takes - * its node along. + * video with sound, composition and bus, and the master, its links the sends from one to the next. + * Its nodes follow the assets: one that comes gets a node of its own and the links most others of + * its type have, one that goes takes its node along. */ import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { newId } from '../ids'; diff --git a/src/core/music/streams.ts b/src/core/music/streams.ts index 04090230..333f66e1 100644 --- a/src/core/music/streams.ts +++ b/src/core/music/streams.ts @@ -2,8 +2,8 @@ * 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 * 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 - * a clip is – and the tables here say which of those names to play from which copy. + * Opus already and always streams. A game names what it plays by the file, or by the cut a clip is, + * and the tables here say which of those names to play from which copy. */ import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset'; import type { Project } from '../model/Project'; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index a982f743..eee3c8dc 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -140,9 +140,10 @@ import { Theme } from '../../ui'; * 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, * `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 - * 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 - * instead of its collision layers (#302), with "Inherit" as the first choice of a single layer. + * "from the asset" (#352); `uploadedOnly` keeps drawn pictures out 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 instead of its collision layers (#302), with "Inherit" as the first + * choice of a single layer. */ type Extras = K extends 'number' ? { min?: number; max?: number; step?: number; decimals?: number } diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 65dfe70d..8a902c17 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -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. */ -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 lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); diff --git a/src/core/properties/videos.ts b/src/core/properties/videos.ts index d5f73ff5..d17938ee 100644 --- a/src/core/properties/videos.ts +++ b/src/core/properties/videos.ts @@ -22,7 +22,10 @@ export const VIDEO_ROWS: readonly PropertyDef[] = [ */ export const VIDEO_CLOCK_KEY = 'videoClock'; 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'; /** * 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_CLOCK_OPTIONS = [ +const VIDEO_CLOCK_OPTIONS = [ { value: 'free', label: 'Free' }, { value: 'driven', label: 'Driven' }, ]; diff --git a/src/core/recording/avcColour.ts b/src/core/recording/avcColour.ts index c969f4cb..9680860f 100644 --- a/src/core/recording/avcColour.ts +++ b/src/core/recording/avcColour.ts @@ -8,6 +8,7 @@ * 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. */ +import { concatBytes } from '../util/bytes'; import type { MatroskaColour } from './matroska'; /** What `video_format` says when the SPS had none: "unspecified". */ @@ -34,7 +35,7 @@ export function avcConfigWithColour(config: Uint8Array, colour: MatroskaColour): at += 2 + length; } parts.push(config.subarray(at)); - return concat(parts); + return concatBytes(parts); } /** 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. else out.bits(0, 6); 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. */ @@ -267,13 +268,3 @@ class BitWriter { 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; -} diff --git a/src/core/recording/editorRecording.ts b/src/core/recording/editorRecording.ts index a7d3f340..7bd1fb89 100644 --- a/src/core/recording/editorRecording.ts +++ b/src/core/recording/editorRecording.ts @@ -33,7 +33,7 @@ export const EDITOR_RECORDING_RESOLUTIONS: readonly { { value: '4k', label: '4K', box: { w: 3840, h: 2160 } }, ]; -export const EDITOR_RECORDING_DEFAULTS: EditorRecordingSettings = { +const EDITOR_RECORDING_DEFAULTS: EditorRecordingSettings = { name: '', resolution: 'screen', fps: 60, diff --git a/src/core/recording/matroska.ts b/src/core/recording/matroska.ts index ca35d6bc..63202d35 100644 --- a/src/core/recording/matroska.ts +++ b/src/core/recording/matroska.ts @@ -5,10 +5,11 @@ * length – so the clusters can leave the writer long before. */ import { OPUS_RATE } from '../music/ogg'; +import { concatBytes } from '../util/bytes'; export const AVC_CODEC = 'V_MPEG4/ISO/AVC'; 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. */ const TIMESTAMP_SCALE = 1_000_000; @@ -225,7 +226,7 @@ export class MatroskaWriter { header[0] = 0x80 | block.track; new DataView(header.buffer).setInt16(1, block.ms - cluster.ms); header[3] = block.key ? 0x80 : 0; - const element = concat([ + const element = concatBytes([ idBytes(ID.simpleBlock), sizeBytes(header.length + block.data.length), header, @@ -301,7 +302,7 @@ function headOf(head: MatroskaHead, clusterBytes: number): Uint8Array { ); const tracks = master(ID.tracks, entries); 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. */ @@ -332,11 +333,11 @@ function sizeLength(size: number): number { } 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 { - return element(id, concat(children)); + return element(id, concatBytes(children)); } 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 { 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; -} diff --git a/src/core/recording/recording.ts b/src/core/recording/recording.ts index 5b7dd027..da0d5bcf 100644 --- a/src/core/recording/recording.ts +++ b/src/core/recording/recording.ts @@ -290,7 +290,7 @@ const PACE_SETTLE_MS = 1000; /** How much of its time a recording is judged over at once. */ const PACE_SPAN_MS = 2000; /** 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. */ const PACE_MOST_MISSED = 0.05; diff --git a/src/core/soundfx/acid.ts b/src/core/soundfx/acid.ts index 77bf6e27..535af0fc 100644 --- a/src/core/soundfx/acid.ts +++ b/src/core/soundfx/acid.ts @@ -45,8 +45,8 @@ export interface AcidKnobs { const LEAST_Q_DB = 0.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. */ -export const ENV_MOD_OCTAVES = 5; -export const TOP_HZ = 18000; +const ENV_MOD_OCTAVES = 5; +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. */ export const SUSTAIN_SWEEP = 0.6; /** @@ -216,7 +216,10 @@ function blep(p: number, step: number): number { 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 { const w = hz / Math.max(LOWEST_HZ, fc); const q = acidQ(resonance); diff --git a/src/core/util/bytes.ts b/src/core/util/bytes.ts new file mode 100644 index 00000000..628c4d2d --- /dev/null +++ b/src/core/util/bytes.ts @@ -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; +} diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index cf44956e..6dd894ef 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -40,18 +40,19 @@ import { findAssetOf } from '../../core/model/Project'; import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video'; import { VIDEO_TIME_KEY } from '../../core/properties/videos'; import { + crispText, drawBand, Menu, type MenuEntry, pressCount, type Size, - Theme, + TextPool, textStyle, + Theme, truncate, ui, Widget, zoomFactor, - crispText, } from '../../ui'; import { createField, type Field } from '../inspector/fieldFactory'; import { type EditorState, trackSelection } from '../state'; @@ -145,7 +146,7 @@ export class TimelineCanvas extends Widget { private readonly labels = new Map(); private readonly fields = new Map(); 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') }); /** Right of the header column. */ private readonly clip = new Graphics(); @@ -895,25 +896,18 @@ export class TimelineCanvas extends Widget { 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 }); } - let used = 0; + const marks: { frame: number; x: number }[] = []; const first = frameAt(this.view, 0); for (let frame = Math.floor(first / step) * step; ; frame += step) { const x = HEADER_WIDTH + frameToX(this.view, frame); if (x > this.w) break; if (x < HEADER_WIDTH) continue; this.ruler.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted); - let text = this.rulerTexts[used]; - 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; + marks.push({ frame, x }); } - 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 { diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index bfa31ece..cfc07d30 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -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 * objects, the audio items (#418) going along; false when none of them is there. */ -export function copyCompositionClips( +function copyCompositionClips( state: EditorState, assetId: string, ids: readonly string[], diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 7cb56910..0922fe84 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -3,7 +3,6 @@ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, - type Text, type TextStyle, } from 'pixi.js'; import { @@ -72,6 +71,7 @@ import { Menu, onDragChange, type Size, + TextPool, textStyle, Theme, truncate, @@ -191,10 +191,10 @@ export class CompositionTimeline extends Widget { private readonly clipLayer = new Container(); private readonly headLayer = new Container(); private readonly rulerLayer = new Container(); - private readonly itemTexts: Text[] = []; - private readonly clipTexts: Text[] = []; - private readonly headTexts: Text[] = []; - private readonly rulerTexts: Text[] = []; + private readonly itemTexts = new TextPool(this.itemLayer); + private readonly clipTexts = new TextPool(this.clipLayer, CLIP_LABEL); + private readonly headTexts = new TextPool(this.headLayer, textStyle('body')); + private readonly rulerTexts = new TextPool(this.rulerLayer); private readonly hint = crispText({ text: '', style: textStyle('muted') }); private view: CompositionView = { ...DEFAULT_VIEW }; 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 }); 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.clipTexts, this.clipLayer, clips, CLIP_LABEL); - syncTexts(this.headTexts, this.headLayer, heads, textStyle('body')); - syncTexts(this.rulerTexts, this.rulerLayer, ruler, textStyle('small')); + syncTexts(this.itemTexts, items, textStyle('small')); + syncTexts(this.clipTexts, clips, CLIP_LABEL); + syncTexts(this.headTexts, heads, textStyle('body')); + syncTexts(this.rulerTexts, ruler, textStyle('small')); const hint = !asset ? '' : asset.items.length === 0 && asset.clips.length === 0 @@ -1596,18 +1596,8 @@ function audioOf(kind: CompositionAudioKind, asset: string): NonNullable { - let text = pool[i]; - if (!text) { - 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; +function syncTexts(pool: TextPool, wanted: readonly Placed[], style: TextStyle): void { + pool + .show(wanted.map((place) => truncate(place.text, style, place.w))) + .forEach((text, i) => text.position.set(wanted[i]!.x, wanted[i]!.y)); } diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 910faef8..f4e1d4d5 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -366,8 +366,8 @@ export function graphHooks({ state, editor }: HookContext) { ]), ), idle: [...panel.idle], - wave: panel.waveButtons.find((b) => b.picked)?.wave ?? null, - waves: Object.fromEntries(panel.waveButtons.map((b) => [b.wave, screenCentre(b)])), + wave: panel.waveButtons.find((b) => b.picked)?.value ?? null, + waves: Object.fromEntries(panel.waveButtons.map((b) => [b.value, screenCentre(b)])), follow: { on: panel.follow.lit, at: screenCentre(panel.follow) }, waveScreen: panel.waveScreen.says.text, pitch: trace && { diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index ed145f17..8547f26f 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -102,7 +102,10 @@ export function compositionSignature( 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( host: InspectorHost, assetId: string, @@ -185,7 +188,7 @@ export function refreshCompositionSection( } /** The item an object of a composition stands for; undefined for any other object. */ -export function compositionItemOf( +function compositionItemOf( project: Project, holderId: string, objectId: string, diff --git a/src/editor/music/TimeRuler.ts b/src/editor/music/TimeRuler.ts index 7fdbc413..3c835066 100644 --- a/src/editor/music/TimeRuler.ts +++ b/src/editor/music/TimeRuler.ts @@ -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 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'; export interface RulerMarks { @@ -22,7 +22,7 @@ export interface RulerMarks { export class TimeRuler extends Container { private readonly g = new Graphics(); private readonly labels = new Container(); - private readonly texts: Text[] = []; + private readonly texts = new TextPool(this.labels); constructor() { super(); @@ -52,7 +52,7 @@ export class TimeRuler extends Container { const bar = ticksPerBar(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. for (const line of timeLines(view, meter, Number.MAX_SAFE_INTEGER, width)) { if (line.x < 0) continue; @@ -63,18 +63,11 @@ export class TimeRuler extends Container { }); const number = line.tick / bar; if (!tall || number % every !== 0) continue; - let text = this.texts[used]; - 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; + numbers.push({ text: String(number + 1), x: line.x + 3 }); } - 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); if (start >= -5 && start <= width + 5) diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 51b3904c..1a303e6e 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -1,10 +1,4 @@ -import { - Container, - type FederatedPointerEvent, - type FederatedWheelEvent, - Graphics, - type Text, -} from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { SetLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands'; import type { GhostNote, Ghosts } from '../../core/music/ghosts'; import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; @@ -26,6 +20,7 @@ import { drawBand, Menu, type Size, + TextPool, textStyle, Theme, tooltips, @@ -164,7 +159,7 @@ export class PianoRoll extends Widget { private readonly keysLayer = new Container(); private readonly keysG = new Graphics(); 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 laneLayer = new Container(); private readonly laneG = new Graphics(); @@ -1188,19 +1183,10 @@ export class PianoRoll extends Widget { private syncKeyLabels(rows: readonly { note: number; y: number }[]): void { const rh = this.view.rowHeight; - rows.forEach(({ note, y }, i) => { - let text = this.keyTexts[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; + this.keyTexts.show(rows.map(({ note }) => this.keyLabel(note))).forEach((text, i) => { + const { note, y } = rows[i]!; text.style = isSharp(note) ? textStyle('small') : DARK_LABEL; - text.visible = true; text.position.set(4, y + (rh - text.height) / 2); }); - for (let i = rows.length; i < this.keyTexts.length; i++) this.keyTexts[i]!.visible = false; } } diff --git a/src/editor/routing/RoutingEditor.ts b/src/editor/routing/RoutingEditor.ts index 5dc8e25a..cd235c34 100644 --- a/src/editor/routing/RoutingEditor.ts +++ b/src/editor/routing/RoutingEditor.ts @@ -10,8 +10,9 @@ const INFO_WIDTH = 220; /** * 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 - * (#350); a click on a node shows its asset in the inspector, a double click opens it. + * with sound, composition and bus and the master, the links between them where each one goes. + * 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 { readonly canvas: GraphCanvas; diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 0974bbd7..71941a23 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -1,10 +1,4 @@ -import { - Container, - type FederatedPointerEvent, - type FederatedWheelEvent, - Graphics, - type Text, -} from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { SetLoopCommand } from '../../core/commands/patternCommands'; @@ -50,8 +44,10 @@ import { onDragChange, pressCount, type Size, + TextPool, textStyle, Theme, + truncate, ui, Widget, zoomFactor, @@ -232,11 +228,11 @@ export class SongTimeline extends Widget { private readonly itemsG = new Graphics(); private readonly overlay = new Graphics(); private readonly itemLabels = new Container(); - private readonly itemTexts: Text[] = []; + private readonly itemTexts = new TextPool(this.itemLabels); private readonly headsLayer = new Container(); private readonly headsG = new Graphics(); private readonly headLabels = new Container(); - private readonly headTexts: Text[] = []; + private readonly headTexts = new TextPool(this.headLabels); private readonly ruler = new TimeRuler(); private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; private readonly hint = crispText({ text: '', style: textStyle('muted') }); @@ -1441,8 +1437,8 @@ export class SongTimeline extends Widget { this.headsG.clear(); this.outLabel.visible = this.outPeak.visible = asset !== null; if (!asset) { - this.syncTexts(this.itemTexts, this.itemLabels, []); - this.syncTexts(this.headTexts, this.headLabels, []); + this.itemTexts.show([]); + this.headTexts.show([]); this.ruler.visible = false; 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); @@ -1557,7 +1553,7 @@ export class SongTimeline extends Widget { } const take = this.take && this.drawTake(this.take, lw); 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. */ @@ -1731,7 +1727,7 @@ export class SongTimeline extends Widget { .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.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. */ @@ -1755,19 +1751,11 @@ export class SongTimeline extends Widget { } /** 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 { - labels.forEach((label, i) => { - let text = pool[i]; - if (!text) { - text = crispText({ style: textStyle('small') }); - 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; + private syncTexts(pool: TextPool, labels: readonly Placed[]): void { + const styles = labels.map((label) => (label.dark ? DARK_LABEL : textStyle('small'))); + pool.show(labels.map((label, i) => truncate(label.text, styles[i]!, label.w))).forEach((text, i) => { + const label = labels[i]!; + if (text.style !== styles[i]) text.style = styles[i]!; if (label.h === undefined) text.position.set(label.x, label.y); else text.position.set( @@ -1775,6 +1763,5 @@ export class SongTimeline extends Widget { Math.round(label.y + (label.h - text.height) / 2), ); }); - for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false; } } diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index cc56eb60..f5b69b3c 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -1,20 +1,15 @@ -import { - Container, - type FederatedPointerEvent, - type FederatedWheelEvent, - Graphics, - type Text, -} from 'pixi.js'; +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands'; import type { SoundAsset, SoundClip } from '../../core/model/Asset'; import type { PropertyBag, Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { clipsOf, cutRange, type FadeCurve, fadeGain } from '../../core/model/sound'; 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 type { EditorState } from '../state'; import { axisTween } from '../viewTween'; +import { type ClipName, showClipNames } from './clipStrip'; import { clampWave, type ClipBand, @@ -64,9 +59,6 @@ const LABEL_SPACING = 70; const LEAST_SPAN = 0.001; /** Pixels a press has to travel before it spans a new stretch. */ 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. */ 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). */ @@ -123,7 +115,7 @@ export class WaveCanvas extends Widget { private readonly bg = new Graphics(); private readonly scaleG = new Graphics(); private readonly scaleLabels = new Container(); - private readonly scaleTexts: Text[] = []; + private readonly scaleTexts = new TextPool(this.scaleLabels); private readonly waveG = new Graphics(); /** What `waveG` was drawn for: a pan within `seconds` only moves it. */ private drawn: { @@ -136,15 +128,14 @@ export class WaveCanvas extends Widget { seconds: CutSpan | null; } | null = null; private waveDraws = 0; - private rulerTexts = 0; private readonly overlay = new Graphics(); private readonly labels = 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. */ private readonly content = new Container(); 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 view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 }; /** How many times its full height the wave is drawn. */ @@ -540,9 +531,9 @@ export class WaveCanvas extends Widget { if (!wave || !asset) { this.waveG.clear(); this.drawn = null; - this.syncLabels([]); - this.syncScale([]); - this.syncStrip([]); + this.rulerTexts.show([]); + this.scaleTexts.show([]); + this.stripTexts.show([]); return; } 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). */ 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 }); 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. */ @@ -664,7 +657,7 @@ export class WaveCanvas extends Widget { const c = Theme.colors; const picked = this.litClipId(); 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)) { const a = secondsToX(this.view, span.start); const b = secondsToX(this.view, span.end); @@ -679,11 +672,12 @@ export class WaveCanvas extends Widget { placed.push({ text: name, x: Math.max(a, 0) + 4, + y: RULER_HEIGHT, room: Math.min(b, this.areaWidth) - Math.max(a, 0) - 8, 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. */ @@ -781,66 +775,13 @@ export class WaveCanvas extends Widget { for (const { offset } of marks) 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.syncScale(marks.map(({ db, offset }) => ({ text: String(db), y: mid - offset }))); - } - - private syncScale(placed: readonly { text: string; y: number }[]): void { - placed.forEach((label, i) => { - let text = this.scaleTexts[i]; - 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); - }); + this.scaleTexts + .show(marks.map(({ db }) => String(db))) + .forEach((text, i) => + text.position.set( + Math.round(SCALE_WIDTH - 8 - text.width), + Math.round(mid - marks[i]!.offset - text.height / 2), + ), + ); } } diff --git a/src/editor/sound/clipStrip.ts b/src/editor/sound/clipStrip.ts new file mode 100644 index 00000000..785c2827 --- /dev/null +++ b/src/editor/sound/clipStrip.ts @@ -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)); + }); +} diff --git a/src/editor/soundfx/AcidFace.ts b/src/editor/soundfx/AcidFace.ts index f41e6e90..43b44315 100644 --- a/src/editor/soundfx/AcidFace.ts +++ b/src/editor/soundfx/AcidFace.ts @@ -1,42 +1,23 @@ -import { SetNodeParamCommand } from '../../core/commands/graphCommands'; -import type { GraphNode } from '../../core/graph'; import { acidWave, InstrumentAcid } from '../../core/instrument/nodes'; -import { findGraphAsset } from '../../core/model/Project'; import type { AcidWave } from '../../core/soundfx/acid'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; import { type AcidHost, AcidPanel } from './AcidPanel'; import { type AcidKey, acidValues } from './acidView'; -import { compressorWired } from './CompressorEditor'; -import { NodeParamDrag } from './nodeParamDrag'; +import { DeviceFace } from './deviceFace'; /** * 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. */ -export class AcidFace implements NodeFace, AcidHost { +export class AcidFace extends DeviceFace implements NodeFace, AcidHost { readonly widget: AcidPanel; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, InstrumentAcid.type, 'acid-face'); 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 { const node = this.node(); 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 { const node = this.node(); - if (node && acidWave(node.params) !== wave) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave)); + if (node && acidWave(node.params) !== wave) this.set('wave', wave); } } diff --git a/src/editor/soundfx/AcidPanel.ts b/src/editor/soundfx/AcidPanel.ts index 3cf3367d..b1f26ce7 100644 --- a/src/editor/soundfx/AcidPanel.ts +++ b/src/editor/soundfx/AcidPanel.ts @@ -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 * 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 type { AcidWave } from '../../core/soundfx/acid'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { type Knob, Widget } from '../../ui'; import { ACID_GROUPS, ACID_KEYS, @@ -24,9 +24,24 @@ import { RESPONSE_LOW_HZ, RESPONSE_TOP_DB, } from './acidView'; -import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; -import { PickButton, type PickLook } from './ReverbPanel'; +import { + bezelText, + 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. */ export interface AcidHost { @@ -66,19 +81,9 @@ const SCOPE = { text: 0xb8ff3c, dim: 0x6f9a52, }; -const IDLE_ALPHA = 0.4; /** The row of text at the top of the screen. */ 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. */ class AcidScreen extends Widget { private readonly g = new Graphics(); @@ -90,11 +95,9 @@ class AcidScreen extends Widget { constructor() { super(); - this.rest = crispText({ text: '', style: scopeStyle(SCOPE.dim) }); - this.top = crispText({ text: '', style: scopeStyle(SCOPE.text) }); - this.top.anchor.set(1, 0); - for (const t of [this.rest, this.top]) t.eventMode = 'none'; - this.addChild(this.g, this.rest, this.top); + this.addChild(this.g); + this.rest = screenText(this, 9, SCOPE.dim); + this.top = screenText(this, 9, SCOPE.text, 1); } 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 rules = new Graphics(); readonly screen = new AcidScreen(); - readonly knobs = {} as Record; + readonly knobs: Record; readonly waveButtons: PickButton[]; - private readonly captions = {} as Record; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly title: { ink: Text; edge: Text }; + private readonly dials: DeviceKnobs; + private readonly groupNames: Engraving[]; + private readonly title: Engraving; private readonly screenName: Text; /** Where the sustain holds the filter, on the bezel (checks). */ readonly held: Text; @@ -166,46 +169,21 @@ export class AcidPanel extends Widget { this.minHeight = ACID_PANEL.h; this.addChild(this.plate, this.rules, this.bezel, this.screen); this.title = engrave(this, 'ACID', 16); - const nameStyle = textStyle('small').clone(); - nameStyle.fill = BEZEL_TEXT; - 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.screenName = bezelText(this, 'FILTER OVER A NOTE', BEZEL_TEXT); + this.held = bezelText(this, '', BEZEL_TEXT, true); this.waveButtons = ACID_WAVES.map((wave) => { const button = new PickButton(wave.value, wave.label, WAVE_BUTTONS, (picked) => host.pickWave(picked)); this.addChild(button); return button; }); - this.groupNames = ACID_GROUPS.map((group) => engrave(this, group.label, 9)); - for (const key of ACID_KEYS) { - const row = ACID_ROWS[key]; - const look = ACID_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: 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); - } + this.groupNames = engraveGroups(this, ACID_GROUPS); + this.dials = new DeviceKnobs( + this, + ACID_KEYS, + (key) => ({ row: ACID_ROWS[key], look: ACID_KNOBS[key], size: ACID_LAYOUT.knobs[key].size }), + host, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -213,14 +191,7 @@ export class AcidPanel extends Widget { } sync(shown: AcidShown): void { - for (const key of ACID_KEYS) { - 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; - } + this.dials.sync((key) => shown.values[key], shown.wired, shown.idle); for (const button of this.waveButtons) button.pick(button.value === shown.wave); this.idle = shown.idle; const trace = acidTrace(shown.values); @@ -235,22 +206,9 @@ export class AcidPanel extends Widget { this.screen.resize(L.screen.w, L.screen.h); 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.waveButtons.forEach((button, i) => { - const box = L.waves[i]!; - button.position.set(box.x, box.y); - 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), - ); + this.waveButtons.forEach((button, i) => put(button, L.waves[i]!)); + this.dials.place((key) => L.knobs[key]); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), ACID_PANEL, SILVER, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x151615); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); } } diff --git a/src/editor/soundfx/ChipFace.ts b/src/editor/soundfx/ChipFace.ts index 22b68408..0b727b69 100644 --- a/src/editor/soundfx/ChipFace.ts +++ b/src/editor/soundfx/ChipFace.ts @@ -1,14 +1,10 @@ -import { SetNodeParamCommand } from '../../core/commands/graphCommands'; -import type { GraphNode } from '../../core/graph'; import { chipWave, InstrumentChip } from '../../core/instrument/nodes'; -import { findGraphAsset } from '../../core/model/Project'; import type { ChipWave } from '../../core/soundfx/chip'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; import { type ChipHost, ChipPanel } from './ChipPanel'; -import { type ChipKey, chipValues } from './chipView'; -import { NodeParamDrag } from './nodeParamDrag'; +import { chipValues } from './chipView'; +import { DeviceFace } from './deviceFace'; /** "follow note": on unless it was turned off. */ 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 * 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; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, InstrumentChip.type, 'chip-face'); 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 { const node = this.node(); 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 { const node = this.node(); - if (node && chipWave(node.params) !== wave) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave)); + if (node && chipWave(node.params) !== wave) this.set('wave', wave); } switchFollow(on: boolean): void { - if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, FOLLOW, on)); + this.set(FOLLOW, on); } } diff --git a/src/editor/soundfx/ChipPanel.ts b/src/editor/soundfx/ChipPanel.ts index 7122be15..2b053524 100644 --- a/src/editor/soundfx/ChipPanel.ts +++ b/src/editor/soundfx/ChipPanel.ts @@ -4,10 +4,10 @@ * "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. */ -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 { CHIP_ROWS } from '../../core/instrument/nodes'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { crispText, type Knob, Widget } from '../../ui'; import { CHIP_GROUPS, CHIP_KEYS, @@ -25,8 +25,25 @@ import { WAVE_NAMES, wavePoints, } from './chipView'; -import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; +import { + 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. */ export interface ChipHost { @@ -62,17 +79,8 @@ const BEZEL_TEXT = 0xb9bccb; const LCD = { paper: 0x9bbc0f, grid: 0x8bac0f, mid: 0x306230, ink: 0x0f380f }; /** A button's lit wave: the colour of the console's A and B. */ const PICKED = 0xa3285f; -/** How far an idle setting fades. */ -const IDLE_ALPHA = 0.4; -function lcdStyle(size = 10): TextStyle { - const style = textStyle('small').clone(); - style.fill = LCD.ink; - style.fontFamily = 'monospace'; - style.fontSize = size; - style.fontWeight = 'bold'; - return style; -} +const lcdStyle = (size = 10): TextStyle => screenStyle(size, LCD.ink); /** A line through the points, as one path. */ 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. */ -class WaveButton extends Widget { +class WaveButton extends PlateButton { private readonly g = new Graphics(); - picked = false; - constructor( - readonly wave: ChipWave, - onPick: (wave: ChipWave) => void, - ) { - super(); - this.cursor = 'pointer'; + constructor(wave: ChipWave, onPick: (wave: ChipWave) => void) { + super(wave, onPick); 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 { @@ -125,7 +116,7 @@ class WaveButton extends Widget { .stroke({ color: 0x24252a, width: 1 }); 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 points = glyphPoints(this.wave).map((p) => ({ + const points = glyphPoints(this.value).map((p) => ({ x: box.x + p.x * box.w, y: box.y + box.h / 2 - (p.y * box.h) / 2, })); @@ -262,11 +253,11 @@ export class ChipPanel extends Widget { readonly follow: Lever; readonly waveScreen = new WaveScreen(); readonly pitchScreen = new PitchScreen(); - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly groupNames: { ink: Text; edge: Text }[]; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly groupNames: Engraving[]; private readonly rules = new Graphics(); - private readonly title: { ink: Text; edge: Text }; + private readonly title: Engraving; private readonly screenNames: Text[]; /** The settings shown idle now (checks). */ idle: ReadonlySet = new Set(); @@ -277,17 +268,7 @@ export class ChipPanel extends Widget { this.minHeight = CHIP_PANEL.h; this.addChild(this.plate, this.rules, this.bezel, this.waveScreen, this.pitchScreen); this.title = engrave(this, 'CHIP', 14); - const nameStyle = textStyle('small').clone(); - 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.screenNames = ['WAVE', 'PITCH OVER A NOTE'].map((text) => bezelText(this, text, BEZEL_TEXT)); this.waveButtons = CHIP_WAVES.map((wave) => { const button = new WaveButton(wave, (picked) => host.pickWave(picked)); this.addChild(button); @@ -295,30 +276,14 @@ export class ChipPanel extends Widget { }); this.follow = new Lever('FOLLOW NOTE', (on) => host.switchFollow(on)); this.addChild(this.follow); - this.groupNames = CHIP_GROUPS.map((group) => engrave(this, group.label, 9)); - for (const key of CHIP_KEYS) { - const row = CHIP_ROWS[key]; - const look = CHIP_KNOBS[key]; - const knob = new Knob('', row.default, { - min: row.min ?? 0, - max: row.max ?? 0, - step: row.step ?? 1, - 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); - } + this.groupNames = engraveGroups(this, CHIP_GROUPS); + this.dials = new DeviceKnobs( + this, + CHIP_KEYS, + (key) => ({ row: CHIP_ROWS[key], look: CHIP_KNOBS[key], size: CHIP_LAYOUT.knobs[key].size }), + host, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -326,15 +291,8 @@ export class ChipPanel extends Widget { } sync(shown: ChipShown): void { - for (const key of CHIP_KEYS) { - const knob = this.knobs[key]; - 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.dials.sync((key) => shown.values[key], shown.wired, shown.idle); + for (const button of this.waveButtons) button.pick(button.value === shown.wave); this.follow.set(shown.follow, shown.follow ? 'on' : 'off'); this.follow.alpha = shown.idle.has('followNote') ? IDLE_ALPHA : 1; this.idle = shown.idle; @@ -344,27 +302,14 @@ export class ChipPanel extends Widget { override layout(): void { 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]!)); put(this.follow, L.follow); put(this.waveScreen, L.wave); put(this.pitchScreen, L.pitch); this.screenNames[0]!.position.set(L.wave.x, L.bezel.y + 3); this.screenNames[1]!.position.set(L.pitch.x, L.bezel.y + 3); - for (const key of CHIP_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), - ); + this.dials.place((key) => L.knobs[key]); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), CHIP_PANEL, CONSOLE, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x2c2d35); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); } } diff --git a/src/editor/soundfx/CompressorEditor.ts b/src/editor/soundfx/CompressorEditor.ts index c700f61d..2fc11751 100644 --- a/src/editor/soundfx/CompressorEditor.ts +++ b/src/editor/soundfx/CompressorEditor.ts @@ -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 - * 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 - * on it; and the level coming in and going out over the last seconds, tinted where it is squeezed. + * 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 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. */ import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; @@ -56,6 +57,7 @@ import { reductionShapes, thresholdHandle, } from './compressorView'; +import { paramWired } from './deviceFace'; import { NodeParamDrag } from './nodeParamDrag'; type Key = CompressorKey; @@ -464,7 +466,7 @@ export class CompressorEditor extends VStack implements CompressorHost { } 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. */ @@ -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. */ export function compressorNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null { const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId); diff --git a/src/editor/soundfx/CompressorFace.ts b/src/editor/soundfx/CompressorFace.ts index d59ee098..5f2ad9c8 100644 --- a/src/editor/soundfx/CompressorFace.ts +++ b/src/editor/soundfx/CompressorFace.ts @@ -1,11 +1,8 @@ -import { SetNodeParamCommand } from '../../core/commands/graphCommands'; -import type { GraphNode } from '../../core/graph'; 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 type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorNodeOf, compressorWired } from './CompressorEditor'; import { CompressorPanel, type PanelHost } from './CompressorPanel'; import { autoGainText, @@ -14,30 +11,20 @@ import { compressorNumbers, LEVEL_WINDOW, } from './compressorView'; +import { DeviceFace } from './deviceFace'; 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 * 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; - private readonly drag: NodeParamDrag; - private listening = false; private lastTick = 0; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxCompressor.type, 'compressor-face'); 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 { @@ -45,20 +32,14 @@ export class CompressorFace implements NodeFace, PanelHost { if (!node) return; const project = this.state.project; const numbers = compressorNumbers(node.params); - this.widget.sync( - numbers, - (key) => compressorWired(project, this.assetId, this.nodeId, key), - findGraphAsset(project, this.assetId)?.name ?? '', - ); + this.widget.sync(numbers, this.wired, findGraphAsset(project, this.assetId)?.name ?? ''); this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers)); } - tick(): boolean { + override tick(): boolean { const node = this.node(); if (!node) return false; - const id = tapId(this.assetId, this.nodeId); - soundLibrary.listen(id); - this.listening = true; + const id = this.listen(); const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW); const now = performance.now() / 1000; const dt = this.lastTick ? now - this.lastTick : 0; @@ -68,15 +49,13 @@ export class CompressorFace implements NodeFace, PanelHost { return this.widget.moving; } - hide(): void { - if (!this.listening) return; - this.listening = false; + override hide(): void { + if (!this.unlisten()) return; this.lastTick = 0; - soundLibrary.unlisten(tapId(this.assetId, this.nodeId)); this.widget.reset(); } - preview(key: CompressorKey, value: number): void { + override preview(key: CompressorKey, value: number): void { const node = this.node(); if (!node) return; this.drag.preview(node, key, value); @@ -84,13 +63,7 @@ export class CompressorFace implements NodeFace, PanelHost { 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 { - if (!this.node()) return; - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on)); + this.set(COMPRESS_AUTO_GAIN.key, on); } } diff --git a/src/editor/soundfx/CompressorPanel.ts b/src/editor/soundfx/CompressorPanel.ts index a7428638..bb8a9947 100644 --- a/src/editor/soundfx/CompressorPanel.ts +++ b/src/editor/soundfx/CompressorPanel.ts @@ -3,15 +3,8 @@ * 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). */ -import { - Container, - FillGradient, - type FederatedPointerEvent, - Graphics, - type Text, - type TextStyle, -} from 'pixi.js'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { Container, type FillGradient, Graphics, type Text } from 'pixi.js'; +import { crispText, type Knob, textStyle, Widget } from '../../ui'; import { COMPRESSOR_KEYS, COMPRESSOR_KNOBS, @@ -21,9 +14,17 @@ import { ratioText, } from './compressorView'; import { - type Box, - brushStrokes, - KNOB_CAPTION, + ALUMINIUM, + DeviceKnobs, + drawPlate, + type Engraving, + engrave, + gradient, + Lever, + placeEngraving, + put, +} from './deviceParts'; +import { LED_COUNT, ledColour, ledsLit, @@ -46,94 +47,11 @@ export interface PanelHost { switchAutoGain(on: boolean): void; } -export const INK = 0x26282c; const LCD_GLOW = 0x9cf58c; 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; -const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) => - 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(); - -/** 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 }); - } -} +const NOTHING_IDLE: ReadonlySet = new Set(); /** 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 { @@ -249,7 +167,7 @@ class ReductionMeter extends Widget { } /** 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(); shown = -Infinity; 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 { private readonly plate = new Graphics(); private readonly lcd = new Container(); @@ -357,10 +222,10 @@ export class CompressorPanel extends Widget { readonly meter = new ReductionMeter(); readonly leds = new OutputLeds(); readonly autoGain: Lever; - readonly knobs = {} as Record; - private readonly captions: Record; - private readonly title: { ink: Text; edge: Text }; - private readonly ledCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly title: Engraving; + private readonly ledCaption: Engraving; /** The most the needle and the LEDs have shown since the node was shown (checks). */ seen = { needle: 0, leds: 0 }; @@ -379,33 +244,18 @@ export class CompressorPanel extends Widget { this.lcd.addChild(this.lcdName, this.lcdReading); this.title = engrave(this, 'COMPRESSOR', 14); this.ledCaption = engrave(this, 'OUT', 9); - this.captions = {} as Record; - for (const key of COMPRESSOR_KEYS) { - const row = COMPRESSOR_ROWS[key]; - const look = COMPRESSOR_KNOBS[key]; - const knob = new Knob('', row.default, { - min: row.min, - max: row.max, - step: row.step, - taper: look.taper, - default: row.default, - decimals: look.decimals, + this.dials = new DeviceKnobs( + this, + COMPRESSOR_KEYS, + (key) => ({ + row: COMPRESSOR_ROWS[key], + look: { ...COMPRESSOR_KNOBS[key], format: key === 'ratio' ? ratioText : undefined }, size: PANEL_LAYOUT.knobs[key].size, - look: 'chrome', - typed: false, - format: key === 'ratio' ? ratioText : undefined, - 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(), - PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9, - ); - this.addChild(knob); - } + caption: PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9, + }), + host, + ); + this.knobs = this.dials.knobs; this.autoGain = new Lever('AUTO GAIN', (on) => host.switchAutoGain(on)); 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. */ sync(numbers: CompressorNumbers, wired: (key: CompressorKey) => boolean, name: string): void { - for (const key of COMPRESSOR_KEYS) { - this.knobs[key].setValue(numbers[key]); - this.knobs[key].setEnabled(!wired(key)); - } + this.dials.sync((key) => numbers[key], wired, NOTHING_IDLE); if (this.lcdName.text !== name) this.lcdName.text = name; } @@ -455,21 +302,10 @@ export class CompressorPanel extends Widget { override layout(): void { 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.leds, L.leds); put(this.autoGain, L.autoGain); - for (const key of COMPRESSOR_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); - } + this.dials.place((key) => L.knobs[key]); 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); this.lcd.position.set(L.lcd.x, L.lcd.y); diff --git a/src/editor/soundfx/CrusherFace.ts b/src/editor/soundfx/CrusherFace.ts index 477bce5e..b8704440 100644 --- a/src/editor/soundfx/CrusherFace.ts +++ b/src/editor/soundfx/CrusherFace.ts @@ -1,40 +1,22 @@ -import type { GraphNode } from '../../core/graph'; -import { findGraphAsset } from '../../core/model/Project'; import { SoundFxBitcrusher } from '../../core/soundfx/nodes'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; import { type CrusherHost, CrusherPanel } from './CrusherPanel'; -import { crushOf, type CrushKey, crushTexts, crushTrace, crushValues, idleCrushKeys } from './crusherView'; -import { NodeParamDrag } from './nodeParamDrag'; +import { crushOf, crushTexts, crushTrace, crushValues, idleCrushKeys } from './crusherView'; +import { DeviceFace } from './deviceFace'; /** * 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. */ -export class CrusherFace implements NodeFace, CrusherHost { +export class CrusherFace extends DeviceFace implements NodeFace, CrusherHost { readonly widget: CrusherPanel; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxBitcrusher.type, 'crusher-face'); 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 { const node = this.node(); if (!node) return; @@ -49,20 +31,4 @@ export class CrusherFace implements NodeFace, CrusherHost { 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); - } } diff --git a/src/editor/soundfx/CrusherPanel.ts b/src/editor/soundfx/CrusherPanel.ts index 3cfa03da..b02c9470 100644 --- a/src/editor/soundfx/CrusherPanel.ts +++ b/src/editor/soundfx/CrusherPanel.ts @@ -5,11 +5,10 @@ * each bit kept, one beside the name that lights the more it crushes, and the bits, the rate and * 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 { mixColor } from '../../core/util/color'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; -import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; +import { type Knob, Widget } from '../../ui'; import { BIT_LAMPS, bitLampsLit, @@ -27,7 +26,20 @@ import { SINE_HZ, TEXT_ROW, } 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. */ export interface CrusherHost { @@ -72,28 +84,9 @@ const SCOPE = { out: 0xf2fbff, }; const LAMP = { off: 0x4a2a3a, lit: 0xff3d9a, bitOff: 0x3a2a3a }; -const IDLE_ALPHA = 0.4; /** The pixels between two scan lines. */ 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, * 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() { super(); this.addChild(this.glass, this.cells); - this.under = { noise: this.made(0), fold: this.made(1) }; - this.spanText = crispText({ text: '', style: scopeStyle(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; + this.under = { noise: screenText(this, 9, SCOPE.text, 0), fold: screenText(this, 9, SCOPE.text, 1) }; + this.spanText = screenText(this, 8, SCOPE.dim); } 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 lamps = new Graphics(); readonly scope = new PixelScope(); - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly title: { ink: Text; edge: Text }; - private readonly lampCaption: { ink: Text; edge: Text }; - private readonly bitCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly title: Engraving; + private readonly lampCaption: Engraving; + private readonly bitCaption: Engraving; private readonly screenName: Text; /** How crushed in a word, above the screen on the right. */ readonly crushText: Text; @@ -242,35 +225,16 @@ export class CrusherPanel extends Widget { this.title = engrave(this, 'BITCRUSHER', 14); this.lampCaption = engrave(this, 'CRUSH', 9); this.bitCaption = engrave(this, 'BITS KEPT', 9); - this.screenName = crispText({ text: `A ${SINE_HZ} Hz SINE AT 0 dBFS`, style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.crushText = crispText({ text: '', style: bezelStyle() }); - this.crushText.anchor.set(1, 0); - this.crushText.eventMode = 'none'; - this.addChild(this.screenName, this.crushText); - for (const key of CRUSH_KEYS) { - const row = CRUSH_ROWS[key]; - const look = CRUSH_KNOBS[key]; - const knob = new Knob('', row.default, { - 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); - } + this.screenName = bezelText(this, `A ${SINE_HZ} Hz SINE AT 0 dBFS`, BEZEL_TEXT); + this.crushText = bezelText(this, '', BEZEL_TEXT, true); + this.dials = new DeviceKnobs( + this, + CRUSH_KEYS, + (key) => ({ row: CRUSH_ROWS[key], look: CRUSH_KNOBS[key], size: CRUSHER_LAYOUT.knobs[key].size }), + host, + ARC, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -279,13 +243,7 @@ export class CrusherPanel extends Widget { sync(shown: CrusherShown): void { const v = shown.values; - for (const key of CRUSH_KEYS) { - 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.dials.sync((key) => v[key], shown.wired, shown.idle); this.scope.show(shown.trace, v.mix, shown.texts); this.crushText.text = crushWord(shown.crush); 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); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.bitCaption, L.bitCaption.x, L.bitCaption.y); - for (const key of CRUSH_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); - } + this.dials.place((key) => L.knobs[key]); 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; drawPlate(this.plate.clear(), CRUSHER_PANEL, BUBBLEGUM, L.screws); const b = L.bezel; - this.bezel - .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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x15101c); this.drawLamps(); } @@ -336,12 +282,13 @@ export class CrusherPanel extends Widget { const g = this.lamps.clear(); const { x, y, r } = L.lamp; const lit = this.crush > 0; - const color = mixColor(LAMP.off, LAMP.lit, 0.4 + 0.6 * this.crush); - if (lit) g.circle(x, y, r + 4).fill({ color: LAMP.lit, alpha: 0.3 * this.crush }); - g.circle(x, y, r) - .fill(lit ? color : LAMP.off) - .stroke({ color: 0x2a2c30, width: 1.5 }); - g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 }); + drawLamp( + g, + { x, y, r }, + lit ? mixColor(LAMP.off, LAMP.lit, 0.4 + 0.6 * this.crush) : LAMP.off, + lit ? { color: LAMP.lit, alpha: 0.3 * this.crush } : null, + lit ? 0.7 : 0.2, + ); const dim = this.idle.has('bits') ? IDLE_ALPHA : 1; L.bitLamps.forEach((box: Box, i) => { const share = this.bitsLit[i] ?? 0; diff --git a/src/editor/soundfx/CurvePanel.ts b/src/editor/soundfx/CurvePanel.ts index 1d80779f..e848033f 100644 --- a/src/editor/soundfx/CurvePanel.ts +++ b/src/editor/soundfx/CurvePanel.ts @@ -42,7 +42,7 @@ import { BandCurve, type CurveHost } from './BandCurve'; import { fittingCount, shownRows } from './eqView'; /** 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. */ export const CURVE_NODE_MIN: Size = { w: 240, h: 120 }; diff --git a/src/editor/soundfx/DelayFace.ts b/src/editor/soundfx/DelayFace.ts index 37ecef7b..e0c31f28 100644 --- a/src/editor/soundfx/DelayFace.ts +++ b/src/editor/soundfx/DelayFace.ts @@ -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 type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; import { type DelayHost, DelayPanel } from './DelayPanel'; -import { type DelayKey, delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView'; -import { NodeParamDrag } from './nodeParamDrag'; +import { delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView'; +import { DeviceFace } from './deviceFace'; /** * 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 * 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; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxDelay.type, 'delay-face'); 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 { const node = this.node(); 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. */ - tick(): boolean { + override tick(): boolean { this.widget.step(performance.now() / 1000); 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 { this.set(DELAY_SYNC.key, beats ? 'beats' : 'seconds'); } @@ -76,8 +45,4 @@ export class DelayFace implements NodeFace, DelayHost { switchPingPong(on: boolean): void { 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)); - } } diff --git a/src/editor/soundfx/DelayPanel.ts b/src/editor/soundfx/DelayPanel.ts index 2d138124..8af98da8 100644 --- a/src/editor/soundfx/DelayPanel.ts +++ b/src/editor/soundfx/DelayPanel.ts @@ -4,11 +4,10 @@ * 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. */ -import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { Graphics, type Text } from 'pixi.js'; import { DELAY_ROWS } from '../../core/soundfx/nodes'; import { mixColor } from '../../core/util/color'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; -import { ALUMINIUM, drawPlate, engrave, INK, Lever, placeEngraving } from './CompressorPanel'; +import { type Knob, Widget } from '../../ui'; import { barHeight, DELAY_KEYS, @@ -23,7 +22,24 @@ import { TEXT_ROW, timeText, } 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'; /** What the panel asks of the node it sits in. */ @@ -57,29 +73,10 @@ const SCOPE = { sweep: 0xffffff, }; const LAMP = 0x4dff5a; -const IDLE_ALPHA = 0.4; /** The sound's bar and the echoes', and the room left round the bars. */ const BAR = 4; 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. */ interface DrawnBar { x: number; @@ -109,17 +106,12 @@ class DelayScope extends Widget { constructor() { super(); 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.under = { every: this.made('', 9, SCOPE.text, 0, 0), ends: this.made('', 9, SCOPE.text, 1, 0) }; - this.moreText = this.made('', 9, SCOPE.dim, 1, 0.5); - } - - 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; + this.sides = { + left: screenText(this, 9, SCOPE.dim, 0, 0, 'L'), + 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) }; + this.moreText = screenText(this, 9, SCOPE.dim, 1, 0.5); } show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void { @@ -230,11 +222,11 @@ export class DelayPanel extends Widget { readonly scope = new DelayScope(); readonly syncLever: Lever; readonly pingPongLever: Lever; - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly title: { ink: Text; edge: Text }; - private readonly lampCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly groupNames: Engraving[]; + private readonly title: Engraving; + private readonly lampCaption: Engraving; private readonly screenName: Text; /** Where the echoes' highs end, above the screen on the right. */ 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.title = engrave(this, 'DELAY', 14); this.lampCaption = engrave(this, 'ECHO', 9); - this.screenName = crispText({ text: 'ECHOES OVER TIME', style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.highs = crispText({ text: '', style: bezelStyle() }); - 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.screenName = bezelText(this, 'ECHOES OVER TIME', BEZEL_TEXT); + this.highs = bezelText(this, '', BEZEL_TEXT, true); + this.groupNames = engraveGroups(this, DELAY_LAYOUT.groups); this.syncLever = new Lever('SYNC', (on) => host.switchSync(on)); this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on)); this.addChild(this.syncLever, this.pingPongLever); - for (const key of DELAY_KEYS) { - const row = DELAY_ROWS[key]; - const look = DELAY_KNOBS[key]; - const knob = new Knob('', row.default, { - min: row.min ?? 0, - max: row.max ?? 1, - step: row.step ?? 0.01, - taper: look.taper, - default: row.default, - decimals: look.decimals, + this.dials = new DeviceKnobs( + this, + DELAY_KEYS, + (key) => ({ + row: DELAY_ROWS[key], + look: { + ...DELAY_KNOBS[key], + format: key === 'time' ? (value) => timeText(value, this.beats) : percentText, + }, size: DELAY_LAYOUT.knobs[key].size, - look: 'chrome', - typed: false, - format: key === 'time' ? (value) => timeText(value, this.beats) : percentText, - 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); - } + }), + host, + ); + this.knobs = this.dials.knobs; } 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. this.knobs.time.reformat(); } - for (const key of DELAY_KEYS) { - 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.dials.sync((key) => v[key], shown.wired, shown.idle); this.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds'); this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1; this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off'); @@ -324,37 +298,25 @@ export class DelayPanel extends Widget { private drawLamp(): void { const { x, y, r } = DELAY_LAYOUT.lamp; const lit = this.lampLit === true; - const g = this.lampLight.clear(); - if (lit) g.circle(x, y, r + 4).fill({ color: LAMP, alpha: 0.25 }); - g.circle(x, y, r) - .fill(lit ? LAMP : 0x1f4a22) - .stroke({ color: 0x2a2c30, width: 1.5 }); - g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 }); + drawLamp( + this.lampLight.clear(), + { x, y, r }, + lit ? LAMP : 0x1f4a22, + lit ? { color: LAMP, alpha: 0.25 } : null, + lit ? 0.7 : 0.2, + ); } override layout(): void { 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 }); 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); put(this.syncLever, L.sync); put(this.pingPongLever, L.pingPong); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); - for (const key of DELAY_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), - ); + this.dials.place((key) => L.knobs[key]); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1b1c20); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); this.drawLamp(); } } diff --git a/src/editor/soundfx/EnvelopeFace.ts b/src/editor/soundfx/EnvelopeFace.ts index e1b3efd2..a6b1cc8a 100644 --- a/src/editor/soundfx/EnvelopeFace.ts +++ b/src/editor/soundfx/EnvelopeFace.ts @@ -1,12 +1,10 @@ -import type { GraphNode } from '../../core/graph'; import { InstrumentEnvelope } from '../../core/instrument/nodes'; import { findGraphAsset } from '../../core/model/Project'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; +import { DeviceFace } from './deviceFace'; import { type EnvelopeHost, EnvelopePanel } from './EnvelopePanel'; -import { type EnvelopeKey, envelopeValues, idleEnvelopeKeys } from './envelopeView'; -import { NodeParamDrag } from './nodeParamDrag'; +import { envelopeValues, idleEnvelopeKeys } from './envelopeView'; /** The output an Envelope moves a number by. */ 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 * 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; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, InstrumentEnvelope.type, 'envelope-face'); 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 { const node = this.node(); if (!node) return; @@ -54,20 +37,4 @@ export class EnvelopeFace implements NodeFace, EnvelopeHost { 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); - } } diff --git a/src/editor/soundfx/EnvelopePanel.ts b/src/editor/soundfx/EnvelopePanel.ts index 8dab3c6e..1c4570e0 100644 --- a/src/editor/soundfx/EnvelopePanel.ts +++ b/src/editor/soundfx/EnvelopePanel.ts @@ -5,10 +5,24 @@ */ import { Graphics, type Text, type TextStyle } from 'pixi.js'; 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 { + bezelText, + DeviceKnobs, + drawBezel, + drawPlate, + drawRules, + type Engraving, + engrave, + engraveGroups, + IDLE_ALPHA, + placeEngraving, + placeGroups, + type PlateLook, + screenStyle, +} from './deviceParts'; import { apartLabels } from './eqView'; -import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; import { ENVELOPE_FIELD_KEYS, ENVELOPE_KNOB_KEYS, @@ -55,18 +69,10 @@ const BEZEL = 0x2f3a3a; const BEZEL_TEXT = 0xb6d4cc; /** An oscilloscope's dark glass and its amber trace. */ const SCOPE = { glass: 0x101a1b, grid: 0x223536, trace: 0xffb43c, dim: 0xa0742c }; -const IDLE_ALPHA = 0.4; /** The rows of text above and below the curve. */ const TEXT_ROW = 14; -function scopeStyle(size: number, fill: number = SCOPE.trace): TextStyle { - const style = textStyle('small').clone(); - style.fill = fill; - style.fontFamily = 'monospace'; - style.fontSize = size; - style.fontWeight = 'bold'; - return style; -} +const scopeStyle = (size: number, fill: number = SCOPE.trace): TextStyle => screenStyle(size, fill); 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); @@ -198,11 +204,12 @@ export class EnvelopePanel extends Widget { private readonly bezel = new Graphics(); private readonly rules = new Graphics(); 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>; - private readonly captions = {} as Record; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly title: { ink: Text; edge: Text }; + private readonly dials: DeviceKnobs<(typeof ENVELOPE_KNOB_KEYS)[number]>; + private readonly fieldCaptions = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], Engraving>; + private readonly groupNames: Engraving[]; + private readonly title: Engraving; private readonly screenName: Text; /** What the value moves between, while a link takes it (checks). */ readonly range: Text; @@ -215,40 +222,20 @@ export class EnvelopePanel extends Widget { this.minHeight = ENVELOPE_PANEL.h; this.addChild(this.plate, this.rules, this.bezel, this.screen); this.title = engrave(this, 'ENVELOPE', 14); - const nameStyle = textStyle('small').clone(); - nameStyle.fill = BEZEL_TEXT; - nameStyle.fontSize = 9; - nameStyle.fontWeight = 'bold'; - nameStyle.letterSpacing = 1; - this.screenName = crispText({ text: 'LEVEL OVER A NOTE', style: nameStyle }); - this.screenName.eventMode = 'none'; - this.range = crispText({ text: '', style: nameStyle }); - this.range.anchor.set(1, 0); - 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, + this.screenName = bezelText(this, 'LEVEL OVER A NOTE', BEZEL_TEXT); + this.range = bezelText(this, '', BEZEL_TEXT, true); + this.groupNames = engraveGroups(this, ENVELOPE_LAYOUT.groups); + this.dials = new DeviceKnobs<(typeof ENVELOPE_KNOB_KEYS)[number]>( + this, + ENVELOPE_KNOB_KEYS, + (key) => ({ + row: ENVELOPE_ROWS[key], + look: ENVELOPE_KNOBS[key], size: ENVELOPE_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); - } + }), + host, + ); + this.knobs = this.dials.knobs; for (const key of ENVELOPE_FIELD_KEYS) { const row = ENVELOPE_ROWS[key]; const field = new NumberField(row.default, { @@ -259,7 +246,7 @@ export class EnvelopePanel extends Widget { onChange: (value) => host.commit(key, value), }); this.fields[key] = field; - this.captions[key] = engrave(this, key.toUpperCase(), 9); + this.fieldCaptions[key] = engrave(this, key.toUpperCase(), 9); this.addChild(field); } } @@ -269,20 +256,14 @@ export class EnvelopePanel extends Widget { } sync(shown: EnvelopeShown): void { - for (const key of ENVELOPE_KNOB_KEYS) { - const knob = this.knobs[key]; - knob.setValue(shown.values[key]); - knob.setEnabled(!shown.wired(key)); - knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1; - } + this.dials.sync((key) => shown.values[key], shown.wired, shown.idle); for (const key of ENVELOPE_FIELD_KEYS) { const field = this.fields[key]; field.setNumber(shown.values[key]); field.setEnabled(!shown.wired(key)); 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.screen.show(envelopeTrace(shown.values)); // 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.screenName.position.set(L.screen.x, 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) { - 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); - } + this.dials.place((key) => L.knobs[key]); for (const key of ENVELOPE_FIELD_KEYS) { const box = L.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.resize(box.w, size.h); placeEngraving( - this.captions[key], + this.fieldCaptions[key], box.x + box.w / 2, L.knobs.attack.y - L.knobs.attack.size / 2 - KNOB_CAPTION, ); } - L.groups.forEach((group, i) => - placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y), - ); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), ENVELOPE_PANEL, SEA, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1c2323); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); } } diff --git a/src/editor/soundfx/EqPanel.ts b/src/editor/soundfx/EqPanel.ts index dbc58898..9beffb69 100644 --- a/src/editor/soundfx/EqPanel.ts +++ b/src/editor/soundfx/EqPanel.ts @@ -24,7 +24,7 @@ import type { EditorState } from '../state'; import { CurvePanel } from './CurvePanel'; /** 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 { private readonly bandLabel: Label; diff --git a/src/editor/soundfx/FilterPanel.ts b/src/editor/soundfx/FilterPanel.ts index 5dae7a20..cac619ff 100644 --- a/src/editor/soundfx/FilterPanel.ts +++ b/src/editor/soundfx/FilterPanel.ts @@ -15,11 +15,11 @@ import { } from '../../core/soundfx/nodes'; import type { PassBand } from '../../core/soundfx/parametricEq'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; 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. */ -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 }; @@ -57,7 +57,7 @@ export class FilterPanel extends CurvePanel { override pick(): void {} 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 { frequency: wired(FILTER_ROWS.cutoff.key), gain: wired(FILTER_ROWS.gain.key), diff --git a/src/editor/soundfx/KnobFace.ts b/src/editor/soundfx/KnobFace.ts index 7423613b..fc049ef0 100644 --- a/src/editor/soundfx/KnobFace.ts +++ b/src/editor/soundfx/KnobFace.ts @@ -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 { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; +import { DeviceFace } from './deviceFace'; 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 * 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; - private readonly drag: NodeParamDrag; constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, + state: EditorState, + assetId: string, + nodeId: string, private readonly device: KnobDevice, ) { + super(state, assetId, nodeId, device.type, 'knob-face'); 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 { @@ -39,32 +27,13 @@ export class KnobFace implements NodeFace, KnobHost { const value = node.params[this.device.row.key]; this.widget.sync( 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], ); } - 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 { const lever = this.device.lever; - if (lever && this.node()) - this.state.execute( - new SetNodeParamCommand(this.assetId, this.nodeId, lever.key, lever.values[on ? 1 : 0]), - ); + if (lever) this.set(lever.key, lever.values[on ? 1 : 0]); } } diff --git a/src/editor/soundfx/KnobPanel.ts b/src/editor/soundfx/KnobPanel.ts index 9ee4e8ab..94dc8a13 100644 --- a/src/editor/soundfx/KnobPanel.ts +++ b/src/editor/soundfx/KnobPanel.ts @@ -3,8 +3,8 @@ * 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). */ -import { Graphics, type Text } from 'pixi.js'; -import { Knob, Widget } from '../../ui'; +import { Graphics } from 'pixi.js'; +import { type Knob, Widget } from '../../ui'; import { GAIN_ROW, MIX_CURVE_ROW, @@ -16,12 +16,21 @@ import { SoundFxPan, SoundFxPitch, } 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 { GAIN_LAYOUT, GAIN_PANEL, GAIN_TAPER, - KNOB_CAPTION, knobEnds, MIX_LAYOUT, MIX_PANEL, @@ -31,8 +40,8 @@ import { /** What the panel asks of the node it sits in. */ export interface KnobHost { - preview(value: number): void; - commit(value: number): void; + preview(key: string, value: number): void; + commit(key: string, value: number): void; /** The lever, on a device that has one. */ flip(on: boolean): void; } @@ -141,8 +150,8 @@ export class KnobPanel extends Widget { private readonly plate = new Graphics(); readonly knob: Knob; readonly lever: Lever | null = null; - private readonly caption: { ink: Text; edge: Text }; - private readonly ends: { ink: Text; edge: Text }[]; + private readonly caption: Engraving; + private readonly ends: Engraving[]; private readonly size: { w: number; h: number }; private readonly spots: typeof GAIN_LAYOUT; @@ -158,23 +167,14 @@ export class KnobPanel extends Widget { this.addChild(this.plate); this.caption = engrave(this, device.caption, 11); this.ends = (device.ends ?? []).map((end) => engrave(this, end, 11)); - const row = device.row; - this.knob = new Knob('', row.default, { - min: row.min ?? 0, - max: row.max ?? 0, - step: row.step ?? 1, - taper: device.taper, - bipolar: device.bipolar, - 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.knob = chromeKnob( + device.row, + device, + GAIN_LAYOUT.knob.size, + device.arc, + (value) => host.preview(device.row.key, value), + (value) => host.commit(device.row.key, value), + ); this.addChild(this.knob); if (device.lever) { this.lever = new Lever(device.lever.caption, (on) => host.flip(on)); @@ -196,19 +196,12 @@ export class KnobPanel extends Widget { override layout(): void { const spot = this.spots.knob; - const size = this.knob.measure(); - 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); + placeKnob(this.knob, this.caption, spot); const ends = knobEnds(spot); const [a, b] = this.ends; if (a) placeEngraving(a, ends.a.x, ends.a.y - 7); if (b) placeEngraving(b, ends.b.x, ends.b.y - 7); - if (this.lever) { - const box = MIX_LAYOUT.curve; - this.lever.position.set(box.x, box.y); - this.lever.resize(box.w, box.h); - } + if (this.lever) put(this.lever, MIX_LAYOUT.curve); } override redraw(): void { diff --git a/src/editor/soundfx/LimiterFace.ts b/src/editor/soundfx/LimiterFace.ts index e0c997da..2b8d1437 100644 --- a/src/editor/soundfx/LimiterFace.ts +++ b/src/editor/soundfx/LimiterFace.ts @@ -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 { LIMIT_AUTO_GAIN, SoundFxLimiter, tapId } from '../../core/soundfx/nodes'; +import { LIMIT_AUTO_GAIN, SoundFxLimiter } from '../../core/soundfx/nodes'; import { soundLibrary } from '../../viewport/SoundLibrary'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; import { LEVEL_WINDOW } from './compressorView'; +import { DeviceFace } from './deviceFace'; import { type LimiterHost, LimiterPanel } from './LimiterPanel'; import { idleLimiterKeys, limiterAutoGainText, - type LimiterKey, limiterReduction, limiterValues, READ_MOST, } from './limiterView'; -import { NodeParamDrag } from './nodeParamDrag'; /** The rate the sound is read at before the page has any audio. */ 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 * 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; - private readonly drag: NodeParamDrag; - private listening = false; private lastTick = 0; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxLimiter.type, 'limiter-face'); 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 { const node = this.node(); 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. */ - tick(): boolean { + override tick(): boolean { const node = this.node(); if (!node) return false; - const id = tapId(this.assetId, this.nodeId); - soundLibrary.listen(id); - this.listening = true; + const id = this.listen(); const now = performance.now() / 1000; const since = this.lastTick ? now - this.lastTick : LEVEL_WINDOW; this.lastTick = now; @@ -80,26 +57,13 @@ export class LimiterFace implements NodeFace, LimiterHost { return this.widget.read(now, reading) || this.widget.live; } - hide(): void { - if (!this.listening) return; - this.listening = false; + override hide(): void { + if (!this.unlisten()) return; this.lastTick = 0; - soundLibrary.unlisten(tapId(this.assetId, this.nodeId)); 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 { - if (this.node()) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, LIMIT_AUTO_GAIN.key, on)); + this.set(LIMIT_AUTO_GAIN.key, on); } } diff --git a/src/editor/soundfx/LimiterPanel.ts b/src/editor/soundfx/LimiterPanel.ts index 35ecf909..e6493b97 100644 --- a/src/editor/soundfx/LimiterPanel.ts +++ b/src/editor/soundfx/LimiterPanel.ts @@ -4,12 +4,26 @@ * 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. */ -import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { Graphics, type Text } from 'pixi.js'; 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 { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; +import { + bezelText, + DeviceKnobs, + drawBezel, + drawLamp, + drawPlate, + type Engraving, + engrave, + IDLE_ALPHA, + Lever, + placeEngraving, + type PlateLook, + put, + screenText, +} from './deviceParts'; +import type { Box } from './hardwareView'; import { captureTexts, type CaptureTexts, @@ -73,30 +87,11 @@ const STATUS: Record = { held: 0xffc94a, 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. */ const STRIP = 30; const TIME_ROW = 13; 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 * 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() { super(); this.addChild(this.glass, this.trace); - this.marks = REDUCTION_MARKS.map((db) => this.made(`${db}`, 0, 9, SCREEN.dim)); - this.times = timeMarks(1).map(() => this.made('', 0.5, 9, SCREEN.dim)); - this.under = { most: this.made('', 0, 9, SCREEN.text), side: this.made('', 1, 9, SCREEN.text) }; - } - - 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; + this.marks = REDUCTION_MARKS.map((db) => screenText(this, 9, SCREEN.dim, 0, 0, `${db}`)); + this.times = timeMarks(1).map(() => screenText(this, 9, SCREEN.dim, 0.5)); + this.under = { most: screenText(this, 9, SCREEN.text, 0), side: screenText(this, 9, SCREEN.text, 1) }; } private get glassH(): number { @@ -222,10 +209,10 @@ export class LimiterPanel extends Widget { private readonly lampLight = new Graphics(); readonly scope = new ReductionScreen(); readonly autoGain: Lever; - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly title: { ink: Text; edge: Text }; - private readonly lampCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly title: Engraving; + private readonly lampCaption: Engraving; private readonly screenName: Text; /** Whether the screen waits, runs or holds, above it on the right. */ readonly status: Text; @@ -241,37 +228,23 @@ export class LimiterPanel extends Widget { this.addChild(this.plate, this.bezel, this.scope, this.lampLight); this.title = engrave(this, 'LIMITER', 14); this.lampCaption = engrave(this, 'LIMIT', 9); - this.screenName = crispText({ text: 'GAIN REDUCTION dB', style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.status = crispText({ text: '', style: bezelStyle() }); - this.status.anchor.set(1, 0); - this.status.eventMode = 'none'; - this.addChild(this.screenName, this.status); + this.screenName = bezelText(this, 'GAIN REDUCTION dB', BEZEL_TEXT); + this.status = bezelText(this, '', BEZEL_TEXT, true); this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on)); this.addChild(this.autoGain); - for (const key of LIMITER_KEYS) { - const row = LIMIT_ROWS[key]; - const look = LIMITER_KNOBS[key]; - const knob = new Knob('', row.default, { - min: row.min ?? 0, - max: row.max ?? 1, - step: row.step ?? 0.01, - taper: look.taper, - default: row.default, - decimals: look.decimals, + this.dials = new DeviceKnobs( + this, + LIMITER_KEYS, + (key) => ({ + row: LIMIT_ROWS[key], + look: LIMITER_KNOBS[key], size: LIMITER_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(), key === 'threshold' ? 11 : 9); - this.addChild(knob); - } + caption: key === 'threshold' ? 11 : 9, + }), + host, + ARC, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -280,13 +253,7 @@ export class LimiterPanel extends Widget { sync(shown: LimiterShown): void { const v = shown.values; - for (const key of LIMITER_KEYS) { - 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.dials.sync((key) => v[key], shown.wired, shown.idle); this.autoGain.set(v.autoGain, shown.autoGain); this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1; this.idle = shown.idle; @@ -331,33 +298,23 @@ export class LimiterPanel extends Widget { /** The lamp beside the name: red while it limits, dark otherwise. */ private drawLamp(): void { const { x, y, r } = LIMITER_LAYOUT.lamp; - const g = this.lampLight.clear(); - if (this.lamp) g.circle(x, y, r + 4).fill({ color: SCREEN.reduction, alpha: 0.35 }); - g.circle(x, y, r) - .fill(this.lamp ? SCREEN.reduction : 0x4a1e16) - .stroke({ color: 0x2a2c30, width: 1.5 }); - g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.lamp ? 0.7 : 0.2 }); + drawLamp( + this.lampLight.clear(), + { x, y, r }, + this.lamp ? SCREEN.reduction : 0x4a1e16, + this.lamp ? { color: SCREEN.reduction, alpha: 0.35 } : null, + this.lamp ? 0.7 : 0.2, + ); } override layout(): void { 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 }); 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); put(this.autoGain, L.autoGain); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); - for (const key of LIMITER_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); - } + this.dials.place((key) => L.knobs[key]); 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; drawPlate(this.plate.clear(), LIMITER_PANEL, BRICK, L.screws); const b = L.bezel; - this.bezel - .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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1b120e); this.drawLamp(); } } diff --git a/src/editor/soundfx/ModulationFace.ts b/src/editor/soundfx/ModulationFace.ts index 4ef7d12c..7e62a778 100644 --- a/src/editor/soundfx/ModulationFace.ts +++ b/src/editor/soundfx/ModulationFace.ts @@ -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 { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; +import { DeviceFace } from './deviceFace'; import { type ModHost, ModulationPanel } from './ModulationPanel'; 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 * 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. */ -export class ModulationFace implements NodeFace, ModHost { +export class ModulationFace extends DeviceFace implements NodeFace, ModHost { readonly widget: ModulationPanel; - private readonly drag: NodeParamDrag; constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, + state: EditorState, + assetId: string, + nodeId: string, private readonly device: ModDevice, ) { + super(state, assetId, nodeId, device.type, `${device.kind}-face`); 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 { const node = this.node(); 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. */ - tick(): boolean { + override tick(): boolean { const picture = this.widget.scope.picture; if (!picture?.moving) return false; this.widget.step(loopAt(picture.span, performance.now() / 1000)); 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 { const node = this.node(); const pick = this.device.pick; - if (node && pick && pick.of(node.params) !== value) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, pick.key, value)); + if (node && pick && pick.of(node.params) !== value) this.set(pick.key, value); } } diff --git a/src/editor/soundfx/ModulationPanel.ts b/src/editor/soundfx/ModulationPanel.ts index d23c2da3..57e21972 100644 --- a/src/editor/soundfx/ModulationPanel.ts +++ b/src/editor/soundfx/ModulationPanel.ts @@ -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 * 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 { crispText, Knob, textStyle, Widget } from '../../ui'; +import { type Knob, Widget } from '../../ui'; import { secondsText } from './chipView'; -import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; 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 { heightOf, lampOf, @@ -30,7 +48,6 @@ import { swingValueAt, wetKey, } from './modulationView'; -import { PickButton, type PickLook } from './ReverbPanel'; /** What the panel asks of the node it sits in. */ export interface ModHost { @@ -153,30 +170,11 @@ const LOOKS: Record = { const BEZEL = 0x2a2624; const BEZEL_TEXT = 0xe4dccf; -const IDLE_ALPHA = 0.4; /** Cycles of the wave a Tremolo's volume shapes, across its screen. */ const CARRIER = 48; /** How finely a Vibrato's pitch is coloured: by chunks of so many points. */ 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. */ abstract class ModScope extends Widget { protected readonly g = new Graphics(); @@ -195,15 +193,7 @@ abstract class ModScope extends Widget { ) { super(); this.addChild(this.g, this.light); - this.under = { left: this.made(9, look.text, 0, 0), right: this.made(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; + this.under = { left: screenText(this, 9, look.text, 0, 0), right: screenText(this, 9, look.text, 1, 0) }; } show(picture: SwingPicture, values: ModValues, texts: ModTexts): void { @@ -259,9 +249,9 @@ class SwingScope extends ModScope { constructor(device: ModDevice, look: ModLook) { super(device, look); this.marks = { - top: this.made(8, look.dim, 0, 0), - bottom: this.made(8, look.dim, 0, 1), - span: this.made(8, look.dim, 1, 1), + top: screenText(this, 8, look.dim, 0, 0), + bottom: screenText(this, 8, look.dim, 0, 1), + span: screenText(this, 8, look.dim, 1, 1), }; } @@ -395,9 +385,9 @@ class ResponseScope extends ModScope { constructor(device: ModDevice, look: ModLook) { super(device, look); this.addChildAt(this.curve, 1); - this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text)); - this.dbMarks = DB_MARKS.map((db) => this.made(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.hzMarks = DECADES.map(([, text]) => screenText(this, 8, look.dim, 0.5, 0, text)); + this.dbMarks = DB_MARKS.map((db) => screenText(this, 8, look.dim, 1, 0.5, String(db))); + 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. */ @@ -536,12 +526,12 @@ export class ModulationPanel extends Widget { private readonly look: ModLook; readonly scope: SwingScope | ResponseScope; readonly pickButtons: PickButton[]; - readonly knobs: Record = {}; - private readonly captions: Record = {}; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly pickName: { ink: Text; edge: Text } | null; - private readonly title: { ink: Text; edge: Text }; - private readonly lampCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly groupNames: Engraving[]; + private readonly pickName: Engraving | null; + private readonly title: Engraving; + private readonly lampCaption: Engraving; private readonly screenName: Text; /** What the screen sums up, above it on the right. */ 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.title = engrave(this, device.title, 14); this.lampCaption = engrave(this, 'RATE', 9); - this.screenName = crispText({ text: device.screenName, style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.sideText = crispText({ text: '', style: bezelStyle() }); - this.sideText.anchor.set(1, 0); - this.sideText.eventMode = 'none'; - this.addChild(this.screenName, this.sideText); + this.screenName = bezelText(this, device.screenName, BEZEL_TEXT); + this.sideText = bezelText(this, '', BEZEL_TEXT, true); this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null; this.pickButtons = (device.pick?.options ?? []).map((option) => { const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) => @@ -579,30 +565,16 @@ export class ModulationPanel extends Widget { this.addChild(button); return button; }); - this.groupNames = L.groups.map((group) => engrave(this, group.label, 9)); - for (const look of device.knobs) { - const row = look.row; - const knob = 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: 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); - } + this.groupNames = engraveGroups(this, L.groups); + const looks = new Map(device.knobs.map((look) => [look.key, look])); + this.dials = new DeviceKnobs( + this, + [...looks.keys()], + (key) => ({ row: looks.get(key)!.row, look: looks.get(key)!, size: L.knobs[key]!.size }), + host, + this.look.arc, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -610,13 +582,7 @@ export class ModulationPanel extends Widget { } sync(shown: ModShown): void { - for (const look of this.device.knobs) { - 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; - } + this.dials.sync((key) => shown.values.numbers[key]!, shown.wired, shown.idle); const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key); for (const button of this.pickButtons) { button.pick(button.value === shown.values.picked); @@ -630,11 +596,6 @@ export class ModulationPanel extends Widget { 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. */ step(t: number): void { this.scope.step(t); @@ -646,20 +607,17 @@ export class ModulationPanel extends Widget { const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp; const [off, on] = this.look.lamp; const lit = this.lamp; - const g = this.lampLight.clear(); - if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit }); - g.circle(x, y, r) - .fill(mixColor(off, on, lit)) - .stroke({ color: 0x2a2c30, width: 1.5 }); - g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit }); + drawLamp( + this.lampLight.clear(), + { x, y, r }, + mixColor(off, on, lit), + lit > 0 ? { color: on, alpha: 0.3 * lit } : null, + 0.2 + 0.5 * lit, + ); } override layout(): void { 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 }); 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); @@ -667,17 +625,8 @@ export class ModulationPanel extends Widget { if (this.pickName && L.pickGroup) placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); - for (const look of this.device.knobs) { - const spot = L.knobs[look.key]!; - 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), - ); + this.dials.place((key) => L.knobs[key]!); + placeGroups(this.groupNames, L.groups); 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]; drawPlate(this.plate.clear(), L.panel, this.look.plate, L.screws); const b = L.bezel; - this.bezel - .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(); + drawBezel(this.bezel.clear(), b, BEZEL, 0x161412); 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]); - 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) 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 }); + drawRules(this.rules.clear(), named); this.drawLamp(); } } diff --git a/src/editor/soundfx/ReverbFace.ts b/src/editor/soundfx/ReverbFace.ts index 82850319..28060da5 100644 --- a/src/editor/soundfx/ReverbFace.ts +++ b/src/editor/soundfx/ReverbFace.ts @@ -1,7 +1,5 @@ import { oneStep } from '../../core/commands/CompoundCommand'; 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 { playedSoundById } from '../../core/properties/playedSound'; 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 type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; -import { NodeParamDrag } from './nodeParamDrag'; +import { DeviceFace } from './deviceFace'; import { type ReverbHost, ReverbPanel } from './ReverbPanel'; import { customFrom, @@ -28,31 +25,14 @@ import { * 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. */ -export class ReverbFace implements NodeFace, ReverbHost { +export class ReverbFace extends DeviceFace implements NodeFace, ReverbHost { 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(); - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxReverb.type, 'reverb-face'); 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 { const node = this.node(); 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. */ - tick(): boolean { + override tick(): boolean { return this.widget.step(performance.now() / 1000); } - hide(): void {} - - preview(key: ReverbKey, value: number): void { + override preview(key: ReverbKey, value: number): void { const node = this.node(); if (!node) return; // 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); } - commit(key: ReverbKey, value: number): void { + override commit(key: ReverbKey, value: number): void { const node = this.node(); if (!node) return; this.preview(key, value); @@ -118,8 +86,7 @@ export class ReverbFace implements NodeFace, ReverbHost { pickRoom(room: ReverbRoom): void { const node = this.node(); - if (node && reverbValues(node.params).room !== room) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, REVERB_ROOM_KEY, room)); + if (node && reverbValues(node.params).room !== room) this.set(REVERB_ROOM_KEY, room); } /** The sounds of the project; picking one makes it the impulse response and the type Sound. */ diff --git a/src/editor/soundfx/ReverbPanel.ts b/src/editor/soundfx/ReverbPanel.ts index 6c76350f..a6c3dc45 100644 --- a/src/editor/soundfx/ReverbPanel.ts +++ b/src/editor/soundfx/ReverbPanel.ts @@ -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 * 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_ROOMS, type ReverbRoom } from '../../core/soundfx/reverb'; 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 { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; import { markText, REVERB_KEYS, @@ -83,91 +99,15 @@ const SCOPE = { /** A sound's tail, whose highs the screen cannot tell. */ const UNKNOWN_TINT = 0xb9c2ff; 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. */ const RING_STEP = 1.5; 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 => 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 }; -/** 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 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 * 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() { super(); this.addChild(this.g, this.motion); - this.marks = SCOPE_MARKS.map((seconds) => this.made(markText(seconds), 8, SCOPE.dim, 0, 1)); - this.under = { - 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; + this.marks = SCOPE_MARKS.map((seconds) => screenText(this, 8, SCOPE.dim, 0, 1, markText(seconds))); + this.under = { pre: screenText(this, 9, SCOPE.text, 0), ends: screenText(this, 9, SCOPE.text, 1) }; } show(tail: ReverbTail | null, mix: number, missing: string): void { @@ -329,11 +258,11 @@ export class ReverbPanel extends Widget { readonly scope = new ReverbScope(); readonly roomButtons: PickButton[]; readonly soundButton: Dropdown; - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly soundCaption: { ink: Text; edge: Text }; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly title: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly soundCaption: Engraving; + private readonly groupNames: Engraving[]; + private readonly title: Engraving; private readonly screenName: Text; /** Where the highs end up, above the screen on the right: there was no room for it below. */ readonly highs: Text; @@ -346,13 +275,9 @@ export class ReverbPanel extends Widget { this.minHeight = REVERB_PANEL.h; this.addChild(this.plate, this.rules, this.bezel, this.scope); this.title = engrave(this, 'REVERB', 14); - this.screenName = crispText({ text: 'TAIL OVER TIME', style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.highs = crispText({ text: '', style: bezelStyle() }); - 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.screenName = bezelText(this, 'TAIL OVER TIME', BEZEL_TEXT); + this.highs = bezelText(this, '', BEZEL_TEXT, true); + this.groupNames = engraveGroups(this, REVERB_LAYOUT.groups); this.roomButtons = REVERB_ROOMS.map((room) => { const button = new PickButton(room.value, room.label, LAVENDER_BUTTONS, (picked) => host.pickRoom(picked), @@ -368,28 +293,13 @@ export class ReverbPanel extends Widget { // A press on it is not one on the node. this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); this.addChild(this.soundButton); - for (const key of REVERB_KEYS) { - const row = REVERB_ROWS[key]; - const look = REVERB_KNOBS[key]; - const knob = new Knob('', row.default, { - min: row.min ?? 0, - max: row.max ?? 1, - step: row.step ?? 0.01, - 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); - } + this.dials = new DeviceKnobs( + this, + REVERB_KEYS, + (key) => ({ row: REVERB_ROWS[key], look: REVERB_KNOBS[key], size: REVERB_LAYOUT.knobs[key].size }), + host, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -398,13 +308,7 @@ export class ReverbPanel extends Widget { sync(shown: ReverbShown): void { const v = shown.values; - for (const key of REVERB_KEYS) { - 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.dials.sync((key) => v[key], shown.wired, shown.idle); for (const button of this.roomButtons) button.pick(button.value === v.room); this.soundButton.setText(shown.sound || 'No sound'); this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1; @@ -421,10 +325,6 @@ export class ReverbPanel extends Widget { override layout(): void { 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 }); 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); @@ -432,17 +332,8 @@ export class ReverbPanel extends Widget { const sound = this.soundButton.measure(); 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); - for (const key of REVERB_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), - ); + this.dials.place((key) => L.knobs[key]); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), REVERB_PANEL, LAVENDER, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1b1a24); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); } } diff --git a/src/editor/soundfx/SampleFace.ts b/src/editor/soundfx/SampleFace.ts index d2d5a052..9bf0c656 100644 --- a/src/editor/soundfx/SampleFace.ts +++ b/src/editor/soundfx/SampleFace.ts @@ -1,7 +1,5 @@ import { SetNodeParamCommand } from '../../core/commands/graphCommands'; -import type { GraphNode } from '../../core/graph'; import { InstrumentSample, type SamplePlay } from '../../core/instrument/nodes'; -import { findGraphAsset } from '../../core/model/Project'; import { findSound } from '../../core/model/sound'; import { assetRefLabel, soundChoices } from '../../core/properties/assetRef'; import { playedSoundOf } from '../../core/properties/playedSound'; @@ -11,9 +9,9 @@ import { Menu, type Widget } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { NodeParamDrag } from './nodeParamDrag'; +import { DeviceFace } from './deviceFace'; import { type SampleHost, SamplePanel, type SamplePicture } from './SamplePanel'; -import { idleSampleKeys, type SampleLoopKey, sampleValues } from './sampleView'; +import { idleSampleKeys, sampleValues } from './sampleView'; const SOUND = 'sound'; 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 * by itself, and it listens to nothing. */ -export class SampleFace implements NodeFace, SampleHost { +export class SampleFace extends DeviceFace implements NodeFace, SampleHost { 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(); - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, InstrumentSample.type, 'sample-face'); 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 { @@ -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 { const node = this.node(); 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)); } - 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 { const node = this.node(); - if (node && sampleValues(node.params).play !== play) - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, PLAY, play)); + if (node && sampleValues(node.params).play !== play) this.set(PLAY, play); } 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. */ diff --git a/src/editor/soundfx/SamplePanel.ts b/src/editor/soundfx/SamplePanel.ts index a710b169..bc68d345 100644 --- a/src/editor/soundfx/SamplePanel.ts +++ b/src/editor/soundfx/SamplePanel.ts @@ -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 * 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 { HIGHEST_NOTE, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; -import { crispText, Dropdown, Knob, NumberField, textStyle, Widget } from '../../ui'; -import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; -import { PickButton, type PickLook } from './ReverbPanel'; +import { Dropdown, type Knob, NumberField, Widget } from '../../ui'; +import { + bezelText, + chromeKnob, + drawBezel, + drawPlate, + drawRules, + type Engraving, + engrave, + engraveGroups, + IDLE_ALPHA, + Lever, + PickButton, + type PickLook, + placeEngraving, + placeGroups, + placeKnob, + type PlateLook, + put, + screenText, +} from './deviceParts'; import { FIELD_NAME, markText, @@ -93,25 +110,6 @@ const WAVE: Record = { never: 0x5a4a28, }; 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 @@ -135,18 +133,9 @@ class WaveScope extends Widget { constructor() { super(); 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.middle = this.made(11, SCOPE.text, 0.5); - this.middle.anchor.set(0.5, 0.5); - 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; + this.flags = { loop: screenText(this, 8, SCOPE.band, 0.5), after: screenText(this, 8, WAVE.after, 0.5) }; + this.middle = screenText(this, 11, SCOPE.text, 0.5, 0.5); + this.under = { play: screenText(this, 9, SCOPE.text, 0), pitch: screenText(this, 9, SCOPE.text, 1) }; } show(values: SampleValues, picture: SamplePicture | null, missing: string): void { @@ -181,7 +170,7 @@ class WaveScope extends Widget { marks.forEach((t, i) => { const mx = x(t); 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.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)); @@ -258,10 +247,10 @@ export class SamplePanel extends Widget { readonly root: Knob; readonly follow: Lever; readonly fields = {} as Record; - private readonly rootCaption: { ink: Text; edge: Text }; - private readonly fieldCaptions = {} as Record; - private readonly groupNames: { ink: Text; edge: Text }[]; - private readonly title: { ink: Text; edge: Text }; + private readonly rootCaption: Engraving; + private readonly fieldCaptions = {} as Record; + private readonly groupNames: Engraving[]; + private readonly title: Engraving; private readonly screenName: Text; /** How long the sound runs and how loud it plays, above the screen on the right. */ readonly length: Text; @@ -274,13 +263,9 @@ export class SamplePanel extends Widget { this.minHeight = SAMPLE_PANEL.h; this.addChild(this.plate, this.rules, this.bezel, this.scope); this.title = engrave(this, 'SAMPLE', 14); - this.screenName = crispText({ text: 'WAVEFORM', style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.length = crispText({ text: '', style: bezelStyle() }); - 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.screenName = bezelText(this, 'WAVEFORM', BEZEL_TEXT); + this.length = bezelText(this, '', BEZEL_TEXT, true); + this.groupNames = engraveGroups(this, SAMPLE_LAYOUT.groups); this.soundButton = new Dropdown('No sound', { width: SAMPLE_LAYOUT.sound.w, onClick: () => host.pickSound(this.soundButton), @@ -295,22 +280,14 @@ export class SamplePanel extends Widget { this.addChild(button); return button; }); - this.root = new Knob('', MIDDLE_C, { - min: LOWEST_NOTE, - max: HIGHEST_NOTE, - step: 1, - taper: 1, - default: MIDDLE_C, - decimals: 0, - 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.root = chromeKnob( + { default: MIDDLE_C, min: LOWEST_NOTE, max: HIGHEST_NOTE, step: 1 }, + { taper: 1, decimals: 0, format: noteName }, + SAMPLE_LAYOUT.root.size, + ARC, + (note) => host.previewRoot(note), + (note) => host.commitRoot(note), + ); this.rootCaption = engrave(this, 'ROOT', 9); this.addChild(this.root); this.follow = new Lever('FOLLOW', (on) => host.switchFollow(on)); @@ -360,21 +337,13 @@ export class SamplePanel extends Widget { override layout(): void { 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 }); 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); const sound = this.soundButton.measure(); 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]!)); - const size = this.root.measure(); - 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); + placeKnob(this.root, this.rootCaption, L.root); put(this.follow, L.follow); for (const key of SAMPLE_LOOP_KEYS) { const box = L.fields[key]; @@ -385,9 +354,7 @@ export class SamplePanel extends Widget { const caption = this.fieldCaptions[key]; placeEngraving(caption, box.x - FIELD_NAME / 2, box.y + (box.h - caption.ink.height) / 2); } - L.groups.forEach((group, i) => - placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y), - ); + placeGroups(this.groupNames, L.groups); 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; drawPlate(this.plate.clear(), SAMPLE_PANEL, GOLD, L.screws); const b = L.bezel; - this.bezel - .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: 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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1a140a); + drawRules( + this.rules.clear(), + L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const), + ); } } diff --git a/src/editor/soundfx/SaturationFace.ts b/src/editor/soundfx/SaturationFace.ts index ccec0d5b..a4e7692c 100644 --- a/src/editor/soundfx/SaturationFace.ts +++ b/src/editor/soundfx/SaturationFace.ts @@ -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 type { SaturationType } from '../../core/soundfx/saturation'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; -import { compressorWired } from './CompressorEditor'; -import { NodeParamDrag } from './nodeParamDrag'; +import { DeviceFace } from './deviceFace'; import { type SaturationHost, SaturationPanel } from './SaturationPanel'; -import { - heatOf, - idleSaturationKeys, - type SaturationKey, - saturationTexts, - saturationValues, - waveTrace, -} from './saturationView'; +import { heatOf, idleSaturationKeys, saturationTexts, saturationValues, waveTrace } from './saturationView'; /** * 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 * the picture. It listens to nothing. */ -export class SaturationFace implements NodeFace, SaturationHost { +export class SaturationFace extends DeviceFace implements NodeFace, SaturationHost { readonly widget: SaturationPanel; - private readonly drag: NodeParamDrag; - constructor( - private readonly state: EditorState, - private readonly assetId: string, - private readonly nodeId: string, - ) { + constructor(state: EditorState, assetId: string, nodeId: string) { + super(state, assetId, nodeId, SoundFxSaturation.type, 'saturation-face'); 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 { const node = this.node(); 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. */ - tick(): boolean { + override tick(): boolean { if (!this.widget.burning) return false; this.widget.step(performance.now() / 1000); 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 { const node = this.node(); 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 { 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)); - } } diff --git a/src/editor/soundfx/SaturationPanel.ts b/src/editor/soundfx/SaturationPanel.ts index 80e40732..a8939e1c 100644 --- a/src/editor/soundfx/SaturationPanel.ts +++ b/src/editor/soundfx/SaturationPanel.ts @@ -4,13 +4,28 @@ * 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. */ -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_TYPES, type SaturationType } from '../../core/soundfx/saturation'; -import { crispText, Knob, textStyle, Widget } from '../../ui'; -import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; -import { type Box, KNOB_CAPTION } from './hardwareView'; -import { PickButton, type PickLook } from './ReverbPanel'; +import { type Knob, Widget } from '../../ui'; +import { + bezelText, + DeviceKnobs, + drawBezel, + drawLamp, + drawPlate, + drawRules, + type Engraving, + engrave, + IDLE_ALPHA, + Lever, + PickButton, + type PickLook, + placeEngraving, + type PlateLook, + put, + screenText, +} from './deviceParts'; import { embersAt, flicker, @@ -65,29 +80,10 @@ const BEZEL = 0x2e2420; const BEZEL_TEXT = 0xe2c9b8; /** 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 IDLE_ALPHA = 0.4; /** The rows the glow is drawn in, and the share of the screen it rises to. */ const GLOW_ROW = 2; 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 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() { super(); this.addChild(this.glass, this.fire, this.wave); - this.under = { auto: this.made(0), peak: this.made(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; + this.under = { auto: screenText(this, 9, SCOPE.text, 0), peak: screenText(this, 9, SCOPE.text, 1) }; } show(trace: WaveTrace, heat: number, texts: WaveScope['texts']): void { @@ -223,11 +211,11 @@ export class SaturationPanel extends Widget { readonly scope = new WaveScope(); readonly typeButtons: PickButton[]; readonly autoGain: Lever; - readonly knobs = {} as Record; - private readonly captions = {} as Record; - private readonly typeName: { ink: Text; edge: Text }; - private readonly title: { ink: Text; edge: Text }; - private readonly lampCaption: { ink: Text; edge: Text }; + readonly knobs: Record; + private readonly dials: DeviceKnobs; + private readonly typeName: Engraving; + private readonly title: Engraving; + private readonly lampCaption: Engraving; private readonly screenName: Text; /** The heat in a word, above the screen on the right, in its colour. */ 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.title = engrave(this, 'SATURATION', 14); this.lampCaption = engrave(this, 'HEAT', 9); - this.screenName = crispText({ text: 'A SINE AT -12 dBFS', style: bezelStyle() }); - this.screenName.eventMode = 'none'; - this.heatText = crispText({ text: '', style: bezelStyle() }); - this.heatText.anchor.set(1, 0); - this.heatText.eventMode = 'none'; - this.addChild(this.screenName, this.heatText); + this.screenName = bezelText(this, 'A SINE AT -12 dBFS', BEZEL_TEXT); + this.heatText = bezelText(this, '', BEZEL_TEXT, true); this.typeName = engrave(this, 'TYPE', 9); this.typeButtons = SATURATION_TYPES.map((type) => { 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.addChild(this.autoGain); - for (const key of SATURATION_KEYS) { - const row = SATURATION_ROWS[key]; - const look = SATURATION_KNOBS[key]; - const knob = 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, + this.dials = new DeviceKnobs( + this, + SATURATION_KEYS, + (key) => ({ + row: SATURATION_ROWS[key], + look: SATURATION_KNOBS[key], size: SATURATION_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); - } + }), + host, + ARC, + ); + this.knobs = this.dials.knobs; } override measure(): { w: number; h: number } { @@ -290,13 +262,7 @@ export class SaturationPanel extends Widget { sync(shown: SaturationShown): void { const v = shown.values; - for (const key of SATURATION_KEYS) { - 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.dials.sync((key) => v[key], shown.wired, shown.idle); for (const button of this.typeButtons) { button.pick(button.value === v.type); 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; // Never quite white, so the lamp still reads as one at white heat. const color = heatColor(0.2 + 0.6 * this.heat); - const g = this.lampLight.clear(); - if (lit > 0) g.circle(x, y, r + 4).fill({ color, alpha: 0.3 * Math.min(1, lit) }); - g.circle(x, y, r) - .fill(this.heat > 0 ? color : 0x4a2a1e) - .stroke({ color: 0x2a2c30, width: 1.5 }); - g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.heat > 0 ? 0.7 : 0.2 }); + drawLamp( + this.lampLight.clear(), + { x, y, r }, + this.heat > 0 ? color : 0x4a2a1e, + lit > 0 ? { color, alpha: 0.3 * Math.min(1, lit) } : null, + this.heat > 0 ? 0.7 : 0.2, + ); } override layout(): void { 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 }); 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); @@ -352,14 +315,7 @@ export class SaturationPanel extends Widget { put(this.autoGain, L.autoGain); placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); placeEngraving(this.typeName, L.typeGroup.x + L.typeGroup.w / 2, L.typeGroup.y); - for (const key of SATURATION_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); - } + this.dials.place((key) => L.knobs[key]); 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); const b = L.bezel; // The frame takes on the heat of the glass in it. - this.bezel - .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 }); + drawBezel(this.bezel.clear(), b, BEZEL, 0x1b120e); if (this.heat > 0) this.bezel .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 }); - // A rule each side of the types' name, engraved as the name is. - 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 }); + drawRules(this.rules.clear(), [[L.typeGroup, this.typeName.ink]]); this.drawLamp(); } } diff --git a/src/editor/soundfx/acidView.ts b/src/editor/soundfx/acidView.ts index 3fc6a9df..f03fd925 100644 --- a/src/editor/soundfx/acidView.ts +++ b/src/editor/soundfx/acidView.ts @@ -7,7 +7,7 @@ import { ACID_ROWS } from '../../core/instrument/nodes'; import type { PropertyBag } from '../../core/model/Properties'; import { acidResponseDb, acidTopHz, SUSTAIN_SWEEP } from '../../core/soundfx/acid'; 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 AcidValues = Record; @@ -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. */ export const ACID_PANEL = { w: 580, h: 280 }; -const KNOB_SLOT = 64; const GROUP_GAP = 22; -/** The height of a group's name and its rule. */ -const GROUP_LINE = 16; export interface AcidLayout { 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 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 rowWidth = - ACID_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) + - (ACID_GROUPS.length - 1) * GROUP_GAP; - const groupTop = bezel.y + bezel.h + 6; - const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; - const knobs = {} as Record; - 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; - } + const { groups, knobs } = knobGroups( + ACID_GROUPS, + (w - knobGroupsWidth(ACID_GROUPS, GROUP_GAP)) / 2, + bezel.y + bezel.h + 6, + GROUP_GAP, + ); return { title: { x: 34, y: 14, w: 60, h: 30 }, waves, diff --git a/src/editor/soundfx/chipView.ts b/src/editor/soundfx/chipView.ts index ac969e62..12fadf2c 100644 --- a/src/editor/soundfx/chipView.ts +++ b/src/editor/soundfx/chipView.ts @@ -8,7 +8,7 @@ import { noteName } from '../../core/instrument/notes'; import type { PropertyBag } from '../../core/model/Properties'; import { type ChipWave, periodicSteps, toneValue } from '../../core/soundfx/chip'; 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 ChipValues = Record; @@ -90,10 +90,7 @@ export const WAVE_NAMES: Record = { export const CHIP_PANEL = { w: 840, h: 280 }; /** Each knob's room in its group, and the room between two groups. */ -const KNOB_SLOT = 64; const GROUP_GAP = 22; -/** The height of a group's name and its rule. */ -const GROUP_LINE = 16; export interface ChipLayout { title: Box; @@ -127,25 +124,12 @@ function chipLayout(): ChipLayout { w: bezel.x + bezel.w - 10 - (wave.x + wave.w + 12), h: screenH, }; - const rowWidth = - CHIP_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) + - (CHIP_GROUPS.length - 1) * GROUP_GAP; - const groupTop = bezel.y + bezel.h + 6; - const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; - const knobs = {} as Record; - 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; - } + const { groups, knobs } = knobGroups( + CHIP_GROUPS, + (w - knobGroupsWidth(CHIP_GROUPS, GROUP_GAP)) / 2, + bezel.y + bezel.h + 6, + GROUP_GAP, + ); return { title: { x: 34, y: 14, w: 80, h: 30 }, waves, @@ -164,7 +148,7 @@ export const CHIP_LAYOUT: ChipLayout = chipLayout(); // --- The wave screen: two periods of what the Chip plays. --- /** The periods the wave screen shows. */ -export const WAVE_PERIODS = 2; +const WAVE_PERIODS = 2; const NOISE_STEPS = 32; /** 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 the trace starts from when the Chip follows the note: A4. */ -export const TRACE_NOTE = 69; +const TRACE_NOTE = 69; const TRACE_HZ = 440; /** The longest and shortest stretch the pitch screen shows, in seconds. */ const LONGEST = 10; diff --git a/src/editor/soundfx/compressorView.ts b/src/editor/soundfx/compressorView.ts index 74bf4641..3eee6e22 100644 --- a/src/editor/soundfx/compressorView.ts +++ b/src/editor/soundfx/compressorView.ts @@ -11,7 +11,7 @@ import type { Levels } from '../../viewport/spectrum'; /** The dB the level picture spans, bottom to top. */ export const LEVEL_FLOOR_DB = -60; -export const LEVEL_TOP_DB = 6; +const LEVEL_TOP_DB = 6; /** How long the level picture looks back. */ 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. */ @@ -19,7 +19,7 @@ export const LEVEL_WINDOW = 0.025; /** The dB the curve spans on both axes, from here to 0. */ export const CURVE_FLOOR_DB = -60; /** 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 const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[]; @@ -199,10 +199,6 @@ export class LevelHistory { get heard(): boolean { 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. */ diff --git a/src/editor/soundfx/crusherView.ts b/src/editor/soundfx/crusherView.ts index 532782ce..a2f0d8ec 100644 --- a/src/editor/soundfx/crusherView.ts +++ b/src/editor/soundfx/crusherView.ts @@ -37,7 +37,7 @@ export interface CrushKnob { 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. */ export const CRUSH_KNOBS: Record = { @@ -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. */ -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. */ export function crushTexts( diff --git a/src/editor/soundfx/delayView.ts b/src/editor/soundfx/delayView.ts index c892881a..0bd9f466 100644 --- a/src/editor/soundfx/delayView.ts +++ b/src/editor/soundfx/delayView.ts @@ -16,7 +16,7 @@ import { } from '../../core/soundfx/nodes'; import { formatNumber } from '../../ui/math'; 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'; export type DelayKey = keyof typeof DELAY_ROWS; @@ -118,10 +118,7 @@ export const DELAY_KNOBS: Record = { /** 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 }; -const KNOB_SLOT = 64; 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). */ const LEVER = { w: 118, h: 40 }; /** The extra the strip of text under the screen takes. */ @@ -134,7 +131,10 @@ export interface DelayLayout { screen: Box; sync: 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 }; lampCaption: { x: number; y: number }; groups: { label: string; box: Box }[]; @@ -152,23 +152,16 @@ function delayLayout(): DelayLayout { const right = w - 24; const sync = { x: left, y: 52, ...LEVER }; const pingPong = { x: right - LEVER.w, y: 52, ...LEVER }; - const groupTop = 114; - const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; - const groups: { label: string; box: Box }[] = []; - const knobs = {} as Record; - let x = left; - for (const [label, keys] of [ - ['TIME', ['time']], - ['ECHOES', ['feedback', 'damping']], - ['MIX', ['mix']], - ] 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; - } + const { groups, knobs } = knobGroups( + [ + { label: 'TIME', keys: ['time'] }, + { label: 'ECHOES', keys: ['feedback', 'damping'] }, + { label: 'MIX', keys: ['mix'] }, + ], + left, + 114, + GROUP_GAP, + ); return { title: { x: 34, y: 14, w: 80, h: 24 }, bezel, diff --git a/src/editor/soundfx/deviceFace.ts b/src/editor/soundfx/deviceFace.ts new file mode 100644 index 00000000..ef283d10 --- /dev/null +++ b/src/editor/soundfx/deviceFace.ts @@ -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(); + 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)); + } +} diff --git a/src/editor/soundfx/deviceParts.ts b/src/editor/soundfx/deviceParts.ts new file mode 100644 index 00000000..9f896a82 --- /dev/null +++ b/src/editor/soundfx/deviceParts.ts @@ -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(); + +/** 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, + 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 { + readonly knobs = {} as Record; + readonly captions = {} as Record; + + 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): 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 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 extends PlateButton { + 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; + } +} diff --git a/src/editor/soundfx/envelopeView.ts b/src/editor/soundfx/envelopeView.ts index 3dbb75b9..8da53305 100644 --- a/src/editor/soundfx/envelopeView.ts +++ b/src/editor/soundfx/envelopeView.ts @@ -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 EnvelopeValues = Record; -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. */ export const ENVELOPE_KNOB_KEYS = ['attack', 'decay', 'sustain', 'release'] as const; export const ENVELOPE_FIELD_KEYS = ['from', 'to'] as const; @@ -73,7 +73,7 @@ const FIELD_SLOT = 84; const GROUP_GAP = 22; /** The height of a group's name and its rule. */ const GROUP_LINE = 16; -export const ENVELOPE_FIELD = { w: 72, h: 24 }; +const ENVELOPE_FIELD = { w: 72, h: 24 }; export interface EnvelopeLayout { title: Box; diff --git a/src/editor/soundfx/hardwareView.ts b/src/editor/soundfx/hardwareView.ts index 0a31e79c..65bed7d3 100644 --- a/src/editor/soundfx/hardwareView.ts +++ b/src/editor/soundfx/hardwareView.ts @@ -20,7 +20,7 @@ export const PANEL = { w: 820, h: 280 }; /** The engraved name above a knob, and the number below its dial. */ export const KNOB_CAPTION = 14; -export const KNOB_FIELD = 24; +const KNOB_FIELD = 24; /** The narrowest a knob is: its number below. */ const KNOB_LEAST_WIDTH = 60; 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 { + 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[], 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( + groups: readonly KnobGroup[], + x: number, + top: number, + gap: number, +): { groups: { label: string; box: Box }[]; knobs: Record } { + const dialY = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + const boxes: { label: string; box: Box }[] = []; + const knobs = {} as Record; + 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. */ export const PANEL_LAYOUT: PanelLayout = { 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; /** 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. */ export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24]; export const METER_TICKS = [2, 4, 5, 8, 12, 20]; diff --git a/src/editor/soundfx/limiterView.ts b/src/editor/soundfx/limiterView.ts index bc19c09d..eb9f2bde 100644 --- a/src/editor/soundfx/limiterView.ts +++ b/src/editor/soundfx/limiterView.ts @@ -118,7 +118,7 @@ export const LIMITER_LAYOUT: LimiterLayout = limiterLayout(); // --- 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. */ -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 @@ -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 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]; /** 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; /** 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. */ export function captureSpan(elapsed: number): number { diff --git a/src/editor/soundfx/modulationView.ts b/src/editor/soundfx/modulationView.ts index e39d44d5..88c8dc7d 100644 --- a/src/editor/soundfx/modulationView.ts +++ b/src/editor/soundfx/modulationView.ts @@ -231,9 +231,9 @@ export function idleModKeys(device: ModDevice, values: ModValues): Set { // --- What the light plays: the first seconds of a sound. --- /** 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. */ 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; /** 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 hz = num(values, 'frequency') * 2 ** ((num(values, 'depth') / 2) * swing); 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. */ const DELAY_REACHES = [2.5, 5, 10, FLANGER_MAX_MS] as const; -export const SWING_POINTS = 601; +const SWING_POINTS = 601; export interface SwingPicture { /** 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. */ export const SHOWN_RATE = 48000; -export const LOW_HZ = 20; -export const HIGH_HZ = 20000; +const LOW_HZ = 20; +const HIGH_HZ = 20000; /** The screen's dB, top to bottom: a notch goes deeper than its floor. */ export const RESPONSE_TOP = 12; 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 * 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: @@ -645,7 +645,7 @@ export function modTexts(device: ModDevice, values: ModValues, picture: SwingPic // --- The plates. --- /** The plates: as the Delay's (#393), a panel never takes another size. */ -export const MOD_PANELS: Record = { +const MOD_PANELS: Record = { tremolo: { w: 640, h: 280 }, vibrato: { w: 640, h: 280 }, phaser: { w: 760, h: 280 }, diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 6c711b92..77fea954 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -35,7 +35,10 @@ import { ReverbFace } from './ReverbFace'; import { SampleFace } from './SampleFace'; 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 { if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id); if (node.type === InstrumentAcid.type) return new AcidFace(state, assetId, node.id); diff --git a/src/editor/soundfx/reverbView.ts b/src/editor/soundfx/reverbView.ts index b3a3f04d..45a325c4 100644 --- a/src/editor/soundfx/reverbView.ts +++ b/src/editor/soundfx/reverbView.ts @@ -113,7 +113,7 @@ export const REVERB_KNOBS: Record = { export const REVERB_PANEL = { w: 640, h: 280 }; /** 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 GROUP_GAP = 22; /** The height of a group's name and its rule. */ diff --git a/src/editor/soundfx/sampleView.ts b/src/editor/soundfx/sampleView.ts index f71898e9..e24f42d1 100644 --- a/src/editor/soundfx/sampleView.ts +++ b/src/editor/soundfx/sampleView.ts @@ -70,7 +70,7 @@ const KNOB_SLOT = 60; const LEVER = { w: 96, h: 40 }; /** The room left of a loop field for its name. */ export const FIELD_NAME = 40; -export const SAMPLE_FIELD = { w: 64, h: 24 }; +const SAMPLE_FIELD = { w: 64, h: 24 }; const GROUP_GAP = 20; /** The height of a group's name and its rule. */ const GROUP_LINE = 16; diff --git a/src/editor/soundfx/saturationView.ts b/src/editor/soundfx/saturationView.ts index 334af100..64335e72 100644 --- a/src/editor/soundfx/saturationView.ts +++ b/src/editor/soundfx/saturationView.ts @@ -143,7 +143,7 @@ export const SATURATION_LAYOUT: SaturationLayout = saturationLayout(); export const WAVE_REACH = 0.5; /** The periods of the sine across the screen, and the points the wave is drawn with. */ const PERIODS = 2; -export const WAVE_POINTS = 241; +const WAVE_POINTS = 241; export interface WaveTrace { /** 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. */ export const MOST_EMBERS = 24; -export const EMBERS_FROM = 0.15; +const EMBERS_FROM = 0.15; export const emberCount = (heat: number): number => Math.round((Math.max(0, heat - EMBERS_FROM) / (1 - EMBERS_FROM)) * MOST_EMBERS); diff --git a/src/editor/video/VideoCanvas.ts b/src/editor/video/VideoCanvas.ts index 204c2194..a51b3f68 100644 --- a/src/editor/video/VideoCanvas.ts +++ b/src/editor/video/VideoCanvas.ts @@ -4,7 +4,6 @@ import { type FederatedWheelEvent, Graphics, Sprite, - type Text, Texture, } from 'pixi.js'; import { AddClipCommand, SetClipPropsCommand } from '../../core/commands/clipCommands'; @@ -19,7 +18,7 @@ import { VIDEO_END_KEY, VIDEO_START_KEY, } 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 { clampWave, type ClipBand, @@ -44,6 +43,7 @@ import { openVideo, videoFileUrl } from '../../viewport/VideoLibrary'; import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek'; import type { EditorState } from '../state'; import { containRect } from '../texture/imageGrid'; +import { type ClipName, showClipNames } from '../sound/clipStrip'; import { axisTween } from '../viewTween'; const RULER_HEIGHT = 24; @@ -60,8 +60,6 @@ const DRAG_START = 3; /** Clip edges are stored to the millisecond. */ const STORED_RATE = 1000; const PREVIEW_PAD = 8; -const LIT_LABEL = textStyle('small').clone(); -LIT_LABEL.fill = 0x30343c; type Gesture = | { kind: 'pan'; pointerX: number; panX: number } @@ -101,7 +99,7 @@ interface Preview { * 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. */ -export function middlePixel(element: HTMLVideoElement): number[] | null { +function middlePixel(element: HTMLVideoElement): number[] | null { if (element.readyState < 2) return null; const context = new OffscreenCanvas(8, 8).getContext('2d', { willReadFrequently: true }); if (!context) return null; @@ -114,9 +112,9 @@ export class VideoCanvas extends Widget { private readonly picture = new Sprite(); private readonly overlay = new Graphics(); private readonly labels = new Container(); - private readonly texts: Text[] = []; + private readonly rulerTexts = new TextPool(this.labels); 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 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). */ @@ -589,8 +587,8 @@ export class VideoCanvas extends Widget { this.hint.position.set(Theme.padding * 2, Theme.padding * 2); if (!asset?.file) { this.picture.visible = false; - this.syncLabels([]); - this.syncStrip([]); + this.rulerTexts.show([]); + this.stripTexts.show([]); return; } 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 }); 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 { @@ -642,7 +642,7 @@ export class VideoCanvas extends Widget { const c = Theme.colors; const lit = this.litClipId(); 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[] = []; const y = top + RULER_HEIGHT; for (const { id, span } of this.bands(asset)) { const a = secondsToX(this.view, span.start); @@ -658,11 +658,12 @@ export class VideoCanvas extends Widget { placed.push({ text: name, x: Math.max(a, 0) + 4, + y, room: Math.min(b, this.w) - Math.max(a, 0) - 8, 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); } } - - 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; - } } diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts index 8cd7ca88..c9313e4e 100644 --- a/src/editor/viewTween.ts +++ b/src/editor/viewTween.ts @@ -11,11 +11,6 @@ export interface ViewMap { 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. */ const STEEPEST = 4; diff --git a/src/runtime/audioSwitch.ts b/src/runtime/audioSwitch.ts index 0ec8db81..06819aa4 100644 --- a/src/runtime/audioSwitch.ts +++ b/src/runtime/audioSwitch.ts @@ -21,10 +21,6 @@ export class AudioSwitch implements 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`. */ divert(sink: AudioSink): void { this.aside = sink; diff --git a/src/ui/index.ts b/src/ui/index.ts index 6e7e5f59..7f3084ca 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -47,6 +47,7 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, export { downloadBlob, downloadFile } from './download'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { Label, truncate } from './widgets/Label'; +export { TextPool } from './textPool'; export { Button, type ButtonOptions } from './widgets/Button'; export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip'; export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip'; diff --git a/src/ui/knob.ts b/src/ui/knob.ts index a9a9a39e..af882583 100644 --- a/src/ui/knob.ts +++ b/src/ui/knob.ts @@ -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. */ export const KNOB_TRAVEL = 200; /** 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. */ -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. */ export const KNOB_START = Math.PI * 0.75; export const KNOB_SWEEP = Math.PI * 1.5; diff --git a/src/ui/textPool.ts b/src/ui/textPool.ts new file mode 100644 index 00000000..f349370b --- /dev/null +++ b/src/ui/textPool.ts @@ -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(); + 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; + }); + } +} diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 9e0c2482..85c03ae0 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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 * 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): void { 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 - * 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. */ setCopies(table: ReadonlyMap): void { diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 4e677151..09fb3aec 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -282,13 +282,6 @@ export class VideoLibrary { 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 * 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; } -/** 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 { private readonly loaded = new Map(); diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 3fc9da8f..3d2cab8b 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -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. */ -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. */ class AcidProcessor extends Source { diff --git a/src/viewport/videoFollow.ts b/src/viewport/videoFollow.ts index 1a28ad67..5fa32e3d 100644 --- a/src/viewport/videoFollow.ts +++ b/src/viewport/videoFollow.ts @@ -6,8 +6,8 @@ */ /** The speeds a driven video plays at; outside them it holds and seeks to every asked time. */ -export const MIN_PLAY_SPEED = 0.25; -export const MAX_PLAY_SPEED = 4; +const MIN_PLAY_SPEED = 0.25; +const MAX_PLAY_SPEED = 4; /** How far a playing video may be off before it seeks instead of being nudged, in seconds. */ export const SEEK_DRIFT = 0.25; /** How much the speed is nudged per second off, and at most, as a share of it. */ diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index 0e2213a3..51d4c789 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -30,7 +30,6 @@ import { movedItems, movedWithClips, pastedClips, - pastedItems, pastedItemsWithClips, resizedItem, snappedToMedia, @@ -348,13 +347,17 @@ describe('items and their objects', () => { ['b', 'b2'], ]); 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('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. - 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', () => { diff --git a/tests/core/videos.test.ts b/tests/core/videos.test.ts index a9088717..02c378ee 100644 --- a/tests/core/videos.test.ts +++ b/tests/core/videos.test.ts @@ -20,11 +20,9 @@ import { isVideoFile, parseVideoKey, routedVideoKey, - posterFileOf, videoKey, videoPlayOf, videoSpan, - videoTimeAt, } from '../../src/core/model/video'; import { pictureChoices, textureRefLabel, videoChoices } from '../../src/core/properties/textureRef'; 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( CommandError, ); - expect(posterFileOf(FILE)).toBe(`a1b2c3d4e5f6/${'c7'.repeat(32)}.jpg`); }); 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', () => { 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', () => { diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index 01787e8d..c7d74269 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -12,7 +12,6 @@ import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; import { axisTween, carriedSpeed, - easeOut, glide, glideSlope, scrollTween, @@ -102,11 +101,11 @@ describe('the way between two views', () => { }); }); - it('eases out: most of the way early, nothing past the end', () => { - expect(easeOut(0)).toBe(0); - expect(easeOut(1)).toBe(1); - expect(easeOut(2)).toBe(1); - expect(easeOut(0.5)).toBeCloseTo(0.875, 12); + it('eases out at 3: most of the way early, nothing past the end', () => { + expect(glide(0, 3)).toBe(0); + expect(glide(1, 3)).toBe(1); + expect(glide(2, 3)).toBe(1); + expect(glide(0.5, 3)).toBeCloseTo(0.875, 12); }); }); diff --git a/tests/ui/text-pool.test.ts b/tests/ui/text-pool.test.ts new file mode 100644 index 00000000..18d23ead --- /dev/null +++ b/tests/ui/text-pool.test.ts @@ -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); + }); +}); diff --git a/tests/viewport/video-library.test.ts b/tests/viewport/video-library.test.ts index 532edda3..d1c26cf1 100644 --- a/tests/viewport/video-library.test.ts +++ b/tests/viewport/video-library.test.ts @@ -347,7 +347,7 @@ describe('VideoLibrary driven players (#404)', () => { videosMade = 0; drawFor(500); 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; vi.advanceTimersByTime(1000); expect(state()).toEqual([false, false]); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 615e7169..2ed8d22d 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -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 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 step = await json('__gm.toolbarAt("step")'); const barCentre = await json('window.innerWidth / 2'); diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index e3d5abd6..68bbe610 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -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. // 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 - // 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 routed = await graphOf( `({ node: g.nodes.find((n) => n.id === ${JSON.stringify(assetId)}) ?? null, @@ -589,9 +589,9 @@ export async function video(t) { 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 - // draws, so a cut to it loaded the video only then and showed white meanwhile. Nor did wearing - // the player count as asking for it: let go after 2 to 4 s, it was opened again. + // 66n. The same in a run: a switched-off object stays in what the Game tab draws, so a cut to it + // finds its video loaded and shows no white. Wearing the player counts as asking for it, so it + // is not let go after 2 to 4 s and opened again. await switchLate(false); const sceneKeys = new Set((await json('__gm.videos()')).map((v) => v.key)); await play(); diff --git a/tools/e2e/67-recording.mjs b/tools/e2e/67-recording.mjs index 49ce1e3c..8ab43af0 100644 --- a/tools/e2e/67-recording.mjs +++ b/tools/e2e/67-recording.mjs @@ -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 // 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, - // above the 1080p the server used to shrink every video to. + // even above 1080p. const assetsBefore = await json('__gm.state.project.assets.length'); await startDialog({ resolution: '1440p', fps: 30, pointer: false, sound: false }); const convertAt = (await json('__gm.recordingDialog()')).switches.convert; diff --git a/tools/e2e/68-composition.mjs b/tools/e2e/68-composition.mjs index 05d2eb98..fbee340e 100644 --- a/tools/e2e/68-composition.mjs +++ b/tools/e2e/68-composition.mjs @@ -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 - // 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(` const p = __gm.state.project; const scene = __gm.state.activeScene; diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index edac5494..fd847cf9 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -2,16 +2,17 @@ * 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, * 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 - * takes an item with its object, and Play runs the playhead to the end. The video of an item about - * to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video - * whose player is still loading. A copied item is pasted under the pointer or at the playhead. A - * video dragged in wears a sprite the composition keeps to itself. An item dragged in the picture - * follows the pointer while the button is down. A band picks items, with Alt clips, and copied clips - * are pasted where they fit. A sound dragged in is an audio item, heard while the playhead runs; - * the handle of its fade in drags out a fade its bus rises along. A video item's sprite reads as - * driven by the composition and goes into its bus, which has a node in the master routing; after a - * hitch the sound is started again where the playhead is. + * 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 takes an item with its object, and Play + * runs the playhead to the end. The video of an item about to begin is opened ahead, so the cut to + * it shows its picture at once, and Play waits for a video whose player is still loading. A copied + * item is pasted under the pointer or at the playhead. A video dragged in wears a sprite the + * composition keeps to itself. An item dragged in the picture follows the pointer while the button + * is down. A band picks items, with Alt clips, and copied clips are pasted where they fit. A sound + * dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a + * fade its bus rises along. A video item's sprite reads as driven by the composition and goes into + * 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. */