Draw notes for an instrument in a piano roll and loop them #333
Notes could only be played one at a time, by a key or a behavior. A pattern is a stretch of notes for one instrument, drawn in a piano roll and played in time through the scheduler of #332. Songs (#334) will lay patterns out on tracks. - New asset type pattern under the heading Patterns. Instruments are listed under Instruments now, since a heading is one per type. - Notes are a start and a length in ticks, a note number and a velocity, kept to their rules on load and in every command. The instrument, the length in bars, the grid, the meter and the tempo are rows in the inspector. The loop is the asset's own data, the whole pattern while none is set. - Grids from 1/1 to 1/32 and the triplets 1/4T to 1/32T, all whole ticks. The meter only moves the bar lines, the notes stay put. - The tab shows the keys down the left, a ruler with bars, beats, loop and start along the top, the notes between them and a velocity lane below. Keys the instrument plays nothing for are grey, the names of a drum kit stand on its keys. - A click draws a note on the grid line before the pointer, as long as the last one drawn or lengthened and as hard as the last velocity set. The body moves a note, the right end lengthens it, Alt leaves the grid. A drag on empty grid picks with a band, Shift adds, a right-click deletes. - Keys: Space plays, Delete, Ctrl+C/V/D/A, the arrows by a semitone or a grid step and with Shift by an octave or a bar. A paste lands where playing starts. Every gesture is one undo step. - A click on the ruler sets where playing starts, a drag the loop or one of its ends, its menu loops a bar or the whole pattern. - The wheel scrolls, Shift across, on the ruler or with Alt it zooms. Ctrl+wheel stays with the browser, Pixi listens passively. - Play runs the loop through the scheduler. An edit goes to it on the next window without a restart, a new loop or tempo counts from what is planned. Another pattern shown or a project opened stops it. - The time axis and the ruler are their own pieces in editor/music for the song tab. The rules of the roll are pure functions in rollView and rollEdit. Findings: - Music.play could start after a stop that came while it was still loading its files. A run counter makes the late start give up. Music.edit now loads what a new note needs as well. - A click on an end of the loop would have set the loop again. A press there only moves the end once it is dragged. Tests: - pattern.test.ts (new, 9) and piano-roll.test.ts (new, 16): model, grids, meter, held notes, sequence, commands, loop, round trip, drum names, time lines, rows, hits, bands, lane, edit rules. - e2e 64 (new) on melektaus-atx: ALL PASSED, 20 checks. e2e 4 with 29 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 247 test files, 3839 tests. - Counter-checks: the move without its clamp, the sequence without the end of the pattern, the duplicate offset without whole grid steps each fail 1 test. Without passing edits to the scheduler the e2e check for a note drawn while playing fails, the rest passes. - Probe on stein2: the tab with a drum kit, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c71eb1a0d
commit
4ea752feef
29 files changed
+2744
-10
No files matched your search
@@ -106,6 +106,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
|||||||
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
|
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
|
||||||
case 'instrument':
|
case 'instrument':
|
||||||
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
|
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
|
||||||
|
case 'pattern':
|
||||||
|
return { ...base, type: 'pattern', notes: [] };
|
||||||
case 'particles':
|
case 'particles':
|
||||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Editing the notes of a pattern (#333). As with a curve, one gesture is one command: the whole list
|
||||||
|
* is written once the pointer lets go, so an undo takes the drag back. What is written is held to the
|
||||||
|
* rules of a note first (`heldPatternNotes`).
|
||||||
|
*/
|
||||||
|
import type { PatternAsset } from '../model/Asset';
|
||||||
|
import { heldPatternNotes, type PatternNote } from '../music/pattern';
|
||||||
|
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||||
|
import type { Project } from '../model/Project';
|
||||||
|
import type { Command } from './Command';
|
||||||
|
import { requirePattern } from './require';
|
||||||
|
|
||||||
|
export class SetPatternNotesCommand implements Command {
|
||||||
|
private previous: PatternNote[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly notes: readonly PatternNote[],
|
||||||
|
readonly label = 'Edit notes',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Nothing to do when the notes already read exactly like this – no empty undo step. */
|
||||||
|
static changes(asset: PatternAsset, notes: readonly PatternNote[]): boolean {
|
||||||
|
return JSON.stringify(asset.notes) !== JSON.stringify(heldPatternNotes(notes));
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requirePattern(project, this.assetId);
|
||||||
|
this.previous = asset.notes;
|
||||||
|
asset.notes = heldPatternNotes(this.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
requirePattern(project, this.assetId).notes = this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the loop of the ruler, or takes it away (`null`): then the whole pattern is the loop. */
|
||||||
|
export class SetPatternLoopCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private previous: LoopRange | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly loop: LoopRange | null,
|
||||||
|
) {
|
||||||
|
this.label = loop ? 'Set loop' : 'Loop the whole pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requirePattern(project, this.assetId);
|
||||||
|
this.previous = asset.loop;
|
||||||
|
const held = heldLoop(this.loop);
|
||||||
|
if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) };
|
||||||
|
else delete asset.loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const asset = requirePattern(project, this.assetId);
|
||||||
|
if (this.previous) asset.loop = this.previous;
|
||||||
|
else delete asset.loop;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
|||||||
import type {
|
import type {
|
||||||
Asset,
|
Asset,
|
||||||
CurveAsset,
|
CurveAsset,
|
||||||
|
PatternAsset,
|
||||||
TileAsset,
|
TileAsset,
|
||||||
BehaviorAsset,
|
BehaviorAsset,
|
||||||
StateMachineAsset,
|
StateMachineAsset,
|
||||||
@@ -141,6 +142,12 @@ export function requireCurve(project: Project, assetId: string): CurveAsset {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requirePattern(project: Project, assetId: string): PatternAsset {
|
||||||
|
const asset = findAssetOf(project, assetId, 'pattern');
|
||||||
|
if (!asset) throw new CommandError(`Pattern ${assetId} not found`);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
|
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
|
||||||
const asset = findAssetOf(project, assetId, 'variables');
|
const asset = findAssetOf(project, assetId, 'variables');
|
||||||
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
|
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
InstrumentOutput,
|
InstrumentOutput,
|
||||||
isInstrumentNodeDef,
|
isInstrumentNodeDef,
|
||||||
loops,
|
loops,
|
||||||
|
noteNameKey,
|
||||||
noteOutputKey,
|
noteOutputKey,
|
||||||
noteParam,
|
noteParam,
|
||||||
notesRange,
|
notesRange,
|
||||||
@@ -370,3 +371,17 @@ export function playedNotes(graph: Graph): Set<number> {
|
|||||||
}
|
}
|
||||||
return notes;
|
return notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notes a Notes node with single notes has given a name of their own (a drum kit's). */
|
||||||
|
export function namedNotes(graph: Graph): Map<number, string> {
|
||||||
|
const names = new Map<number, string>();
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (node.type !== INSTRUMENT_NOTES || !singleNotes(node.params)) continue;
|
||||||
|
const { from, to } = notesRange(node.params);
|
||||||
|
for (let note = from; note <= to; note++) {
|
||||||
|
const name = textOf(node.params[noteNameKey(note)] ?? '').trim();
|
||||||
|
if (name && !names.has(note)) names.set(note, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Graph } from '../graph/Graph';
|
import type { Graph } from '../graph/Graph';
|
||||||
|
import type { PatternNote } from '../music/pattern';
|
||||||
|
import type { LoopRange } from '../music/sequence';
|
||||||
import type { PropertyDef } from '../properties/PropertySchema';
|
import type { PropertyDef } from '../properties/PropertySchema';
|
||||||
import type { AssetType } from './AssetTypes';
|
import type { AssetType } from './AssetTypes';
|
||||||
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
||||||
@@ -328,6 +330,17 @@ export interface InstrumentAsset extends AssetBase {
|
|||||||
graph: Graph;
|
graph: Graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes for one instrument (#333), drawn in a piano roll. The instrument, the length in bars, the
|
||||||
|
* grid, the meter and the tempo are properties; `loop` is the stretch the playhead runs round, the
|
||||||
|
* whole pattern while there is none.
|
||||||
|
*/
|
||||||
|
export interface PatternAsset extends AssetBase {
|
||||||
|
type: 'pattern';
|
||||||
|
notes: PatternNote[];
|
||||||
|
loop?: LoopRange;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
|
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
|
||||||
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
|
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
|
||||||
@@ -582,6 +595,7 @@ export type Asset =
|
|||||||
| SoundAsset
|
| SoundAsset
|
||||||
| SoundFxAsset
|
| SoundFxAsset
|
||||||
| InstrumentAsset
|
| InstrumentAsset
|
||||||
|
| PatternAsset
|
||||||
| ParticlesAsset;
|
| ParticlesAsset;
|
||||||
|
|
||||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const ASSET_TYPES = [
|
|||||||
'sound',
|
'sound',
|
||||||
'soundfx',
|
'soundfx',
|
||||||
'instrument',
|
'instrument',
|
||||||
|
'pattern',
|
||||||
'particles',
|
'particles',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
|||||||
sound: 'Sound',
|
sound: 'Sound',
|
||||||
soundfx: 'Sound FX',
|
soundfx: 'Sound FX',
|
||||||
instrument: 'Instrument',
|
instrument: 'Instrument',
|
||||||
|
pattern: 'Pattern',
|
||||||
particles: 'Particles',
|
particles: 'Particles',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,8 +73,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
|||||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||||
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
|
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
|
||||||
* sound is played through (#311), heard but never seen, and an instrument what a note is played
|
* sound is played through (#311), heard but never seen, an instrument what a note is played
|
||||||
* with (#331).
|
* with (#331) and a pattern the notes it plays (#333).
|
||||||
*/
|
*/
|
||||||
export type PlaceableAssetType = Exclude<
|
export type PlaceableAssetType = Exclude<
|
||||||
AssetType,
|
AssetType,
|
||||||
@@ -91,6 +93,7 @@ export type PlaceableAssetType = Exclude<
|
|||||||
| 'sound'
|
| 'sound'
|
||||||
| 'soundfx'
|
| 'soundfx'
|
||||||
| 'instrument'
|
| 'instrument'
|
||||||
|
| 'pattern'
|
||||||
| 'variables'
|
| 'variables'
|
||||||
| 'struct'
|
| 'struct'
|
||||||
>;
|
>;
|
||||||
@@ -111,6 +114,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
|||||||
'sound',
|
'sound',
|
||||||
'soundfx',
|
'soundfx',
|
||||||
'instrument',
|
'instrument',
|
||||||
|
'pattern',
|
||||||
'variables',
|
'variables',
|
||||||
'struct',
|
'struct',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* A pattern (#333): notes for one instrument, edited in a piano roll and played by the scheduler
|
||||||
|
* (#332). The notes are counted in ticks; the meter only says where the bars of the ruler are, so a
|
||||||
|
* pattern changed from 4/4 to 6/8 keeps every note where it was.
|
||||||
|
*/
|
||||||
|
import { newId } from '../ids';
|
||||||
|
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||||
|
import type { PatternAsset } from '../model/Asset';
|
||||||
|
import { type PropertyBag, textOf } from '../model/Properties';
|
||||||
|
import { heldLoop, type LoopRange, type Sequence } from './sequence';
|
||||||
|
import { heldMeter, heldTempo, type Meter, ticksPerBar } from './time';
|
||||||
|
|
||||||
|
export interface PatternNote {
|
||||||
|
id: string;
|
||||||
|
/** Where it starts and how long it is held, in ticks. */
|
||||||
|
tick: number;
|
||||||
|
length: number;
|
||||||
|
/** As MIDI counts it: 60 is C4. */
|
||||||
|
note: number;
|
||||||
|
/** 0 to 1. */
|
||||||
|
velocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATTERN_INSTRUMENT_KEY = 'instrument';
|
||||||
|
export const PATTERN_BARS_KEY = 'bars';
|
||||||
|
export const PATTERN_GRID_KEY = 'grid';
|
||||||
|
export const PATTERN_BEATS_KEY = 'beats';
|
||||||
|
export const PATTERN_BEAT_KEY = 'beat';
|
||||||
|
export const PATTERN_TEMPO_KEY = 'tempo';
|
||||||
|
|
||||||
|
export const DEFAULT_BARS = 4;
|
||||||
|
export const MAX_BARS = 256;
|
||||||
|
export const DEFAULT_GRID = '1/16';
|
||||||
|
/** What a new note is struck with until another velocity has been set. */
|
||||||
|
export const DEFAULT_VELOCITY = 0.8;
|
||||||
|
|
||||||
|
/** The grids a note is drawn on, in ticks: with 480 to the quarter every triplet is a whole number. */
|
||||||
|
const GRIDS: readonly (readonly [string, number])[] = [
|
||||||
|
['1/1', 1920],
|
||||||
|
['1/2', 960],
|
||||||
|
['1/4', 480],
|
||||||
|
['1/8', 240],
|
||||||
|
['1/16', 120],
|
||||||
|
['1/32', 60],
|
||||||
|
['1/4T', 320],
|
||||||
|
['1/8T', 160],
|
||||||
|
['1/16T', 80],
|
||||||
|
['1/32T', 40],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value }));
|
||||||
|
export const BEAT_OPTIONS = [1, 2, 4, 8, 16, 32].map((unit) => ({ value: String(unit), label: `1/${unit}` }));
|
||||||
|
|
||||||
|
export function gridTicks(grid: string): number {
|
||||||
|
return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wholeNumber(
|
||||||
|
value: PropertyBag[string] | undefined,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const n = typeof value === 'number' ? Math.round(value) : NaN;
|
||||||
|
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternMeter(props: PropertyBag): Meter {
|
||||||
|
return heldMeter({
|
||||||
|
beats: wholeNumber(props[PATTERN_BEATS_KEY], 4, 1, 32),
|
||||||
|
unit: Number(textOf(props[PATTERN_BEAT_KEY] ?? '4')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternTempo(props: PropertyBag): number {
|
||||||
|
const tempo = props[PATTERN_TEMPO_KEY];
|
||||||
|
return heldTempo(typeof tempo === 'number' ? tempo : NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternBars(props: PropertyBag): number {
|
||||||
|
return wholeNumber(props[PATTERN_BARS_KEY], DEFAULT_BARS, 1, MAX_BARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternGrid(props: PropertyBag): number {
|
||||||
|
return gridTicks(textOf(props[PATTERN_GRID_KEY] ?? DEFAULT_GRID));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternInstrument(props: PropertyBag): string {
|
||||||
|
return textOf(props[PATTERN_INSTRUMENT_KEY] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the pattern ends, in ticks: a note starting there or later is kept, but not played. */
|
||||||
|
export function patternLength(asset: PatternAsset): number {
|
||||||
|
return patternBars(asset.props) * ticksPerBar(patternMeter(asset.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loop the playhead runs round: as it was set, cut at the end of the pattern; else all of it. */
|
||||||
|
export function patternLoop(asset: PatternAsset): LoopRange {
|
||||||
|
const length = patternLength(asset);
|
||||||
|
const held = heldLoop(asset.loop ?? null);
|
||||||
|
if (!held || held.start >= length) return { start: 0, end: length };
|
||||||
|
return { start: held.start, end: Math.min(held.end, length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the scheduler plays of it: the notes before its end, with its instrument; none without one. */
|
||||||
|
export function patternSequence(asset: PatternAsset): Sequence {
|
||||||
|
const instrument = patternInstrument(asset.props);
|
||||||
|
if (!instrument) return { notes: [] };
|
||||||
|
const length = patternLength(asset);
|
||||||
|
return {
|
||||||
|
notes: asset.notes
|
||||||
|
.filter((n) => n.tick < length)
|
||||||
|
.map((n) => ({
|
||||||
|
tick: n.tick,
|
||||||
|
length: Math.min(n.length, length - n.tick),
|
||||||
|
note: n.note,
|
||||||
|
velocity: n.velocity,
|
||||||
|
instrument,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notes in the order they are played, the lower one first where two start together. */
|
||||||
|
export function byStart(a: PatternNote, b: PatternNote): number {
|
||||||
|
return a.tick - b.tick || a.note - b.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Held to what a note may be: a start in whole ticks from 0, at least a tick long, a note of the
|
||||||
|
* keyboard, a velocity from 0 to 1, an id no other note has. Anything else in the list is dropped.
|
||||||
|
*/
|
||||||
|
export function heldPatternNotes(raw: unknown): PatternNote[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const notes: PatternNote[] = [];
|
||||||
|
for (const item of raw as unknown[]) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue;
|
||||||
|
const n = item as Record<string, unknown>;
|
||||||
|
const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
|
||||||
|
const tick = Math.round(num(n.tick));
|
||||||
|
const length = num(n.length) > 0 ? Math.max(1, Math.round(num(n.length))) : NaN;
|
||||||
|
const note = Math.round(num(n.note));
|
||||||
|
const velocity = num(n.velocity);
|
||||||
|
if (!(tick >= 0) || !(length >= 1) || !(note >= LOWEST_NOTE && note <= HIGHEST_NOTE)) continue;
|
||||||
|
let id = typeof n.id === 'string' && n.id !== '' ? n.id : newId();
|
||||||
|
while (ids.has(id)) id = newId();
|
||||||
|
ids.add(id);
|
||||||
|
notes.push({
|
||||||
|
id,
|
||||||
|
tick,
|
||||||
|
length,
|
||||||
|
note,
|
||||||
|
velocity: Number.isFinite(velocity) ? Math.max(0, Math.min(1, velocity)) : DEFAULT_VELOCITY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return notes.sort(byStart);
|
||||||
|
}
|
||||||
@@ -104,6 +104,20 @@ import {
|
|||||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||||||
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
||||||
|
import {
|
||||||
|
BEAT_OPTIONS,
|
||||||
|
DEFAULT_BARS,
|
||||||
|
DEFAULT_GRID,
|
||||||
|
GRID_OPTIONS,
|
||||||
|
MAX_BARS,
|
||||||
|
PATTERN_BARS_KEY,
|
||||||
|
PATTERN_BEAT_KEY,
|
||||||
|
PATTERN_BEATS_KEY,
|
||||||
|
PATTERN_GRID_KEY,
|
||||||
|
PATTERN_INSTRUMENT_KEY,
|
||||||
|
PATTERN_TEMPO_KEY,
|
||||||
|
} from '../music/pattern';
|
||||||
|
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
|
||||||
import { Theme } from '../../ui';
|
import { Theme } from '../../ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,6 +673,48 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
|||||||
soundfx: [],
|
soundfx: [],
|
||||||
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
|
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
|
||||||
instrument: [],
|
instrument: [],
|
||||||
|
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
|
||||||
|
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
|
||||||
|
pattern: [
|
||||||
|
{
|
||||||
|
key: PATTERN_INSTRUMENT_KEY,
|
||||||
|
label: 'Instrument',
|
||||||
|
kind: 'asset',
|
||||||
|
default: '',
|
||||||
|
assetTypes: ['instrument'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PATTERN_BARS_KEY,
|
||||||
|
label: 'Length (bars)',
|
||||||
|
kind: 'number',
|
||||||
|
default: DEFAULT_BARS,
|
||||||
|
min: 1,
|
||||||
|
max: MAX_BARS,
|
||||||
|
step: 1,
|
||||||
|
decimals: 0,
|
||||||
|
},
|
||||||
|
{ key: PATTERN_GRID_KEY, label: 'Grid', kind: 'enum', default: DEFAULT_GRID, options: GRID_OPTIONS },
|
||||||
|
{
|
||||||
|
key: PATTERN_BEATS_KEY,
|
||||||
|
label: 'Beats per bar',
|
||||||
|
kind: 'number',
|
||||||
|
default: 4,
|
||||||
|
min: 1,
|
||||||
|
max: 32,
|
||||||
|
step: 1,
|
||||||
|
decimals: 0,
|
||||||
|
},
|
||||||
|
{ key: PATTERN_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
|
||||||
|
{
|
||||||
|
key: PATTERN_TEMPO_KEY,
|
||||||
|
label: 'Tempo (BPM)',
|
||||||
|
kind: 'number',
|
||||||
|
default: DEFAULT_TEMPO,
|
||||||
|
min: MIN_TEMPO,
|
||||||
|
max: MAX_TEMPO,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||||
// nodes add their rows after these – see `schemaForBag`.
|
// nodes add their rows after these – see `schemaForBag`.
|
||||||
particles: [
|
particles: [
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { isParticleNodeDef } from '../particles/nodes';
|
|||||||
import { isShaderNodeDef } from '../shader/nodes';
|
import { isShaderNodeDef } from '../shader/nodes';
|
||||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||||
|
import { heldPatternNotes } from '../music/pattern';
|
||||||
|
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||||
import type {
|
import type {
|
||||||
AnimationKey,
|
AnimationKey,
|
||||||
@@ -271,6 +273,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
assets.push(drawing);
|
assets.push(drawing);
|
||||||
} else if (type === 'palette') {
|
} else if (type === 'palette') {
|
||||||
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
|
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
|
||||||
|
} else if (type === 'pattern') {
|
||||||
|
const loop = readLoop(a.loop);
|
||||||
|
assets.push({ id, type, name, props, notes: heldPatternNotes(a.notes), ...(loop && { loop }) });
|
||||||
} else if (type === 'curve') {
|
} else if (type === 'curve') {
|
||||||
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
|
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
|
||||||
} else if (type === 'animation') {
|
} else if (type === 'animation') {
|
||||||
@@ -650,6 +655,14 @@ function normalizePixelArt(
|
|||||||
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
|
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A loop of a pattern (#333): two numbers of ticks with room between them, else none. */
|
||||||
|
function readLoop(raw: unknown): LoopRange | null {
|
||||||
|
const r = asRecord(raw);
|
||||||
|
const start = asFinite(r.start);
|
||||||
|
const end = asFinite(r.end);
|
||||||
|
return start === null || end === null ? null : heldLoop({ start: Math.round(start), end: Math.round(end) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
|
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
|
||||||
function normalizePalette(raw: unknown): number[] {
|
function normalizePalette(raw: unknown): number[] {
|
||||||
const colors: number[] = [];
|
const colors: number[] = [];
|
||||||
|
|||||||
+17
-2
@@ -74,6 +74,7 @@ import { ParticlesEditor } from './particles/ParticlesEditor';
|
|||||||
import { ShaderEditor } from './shader/ShaderEditor';
|
import { ShaderEditor } from './shader/ShaderEditor';
|
||||||
import { SoundFxEditor } from './soundfx/SoundFxEditor';
|
import { SoundFxEditor } from './soundfx/SoundFxEditor';
|
||||||
import { InstrumentEditor } from './instrument/InstrumentEditor';
|
import { InstrumentEditor } from './instrument/InstrumentEditor';
|
||||||
|
import { PatternEditor } from './pattern/PatternEditor';
|
||||||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||||
import { installShortcuts } from './shortcuts';
|
import { installShortcuts } from './shortcuts';
|
||||||
import type { PlayFrom } from '../core/model/playSettings';
|
import type { PlayFrom } from '../core/model/playSettings';
|
||||||
@@ -97,6 +98,7 @@ export type CentreTab =
|
|||||||
| 'particles'
|
| 'particles'
|
||||||
| 'soundfx'
|
| 'soundfx'
|
||||||
| 'instrument'
|
| 'instrument'
|
||||||
|
| 'pattern'
|
||||||
| 'game'
|
| 'game'
|
||||||
| 'texture'
|
| 'texture'
|
||||||
| 'vectorart'
|
| 'vectorart'
|
||||||
@@ -148,6 +150,7 @@ const PANE_ORDER = [
|
|||||||
'particles',
|
'particles',
|
||||||
'soundfx',
|
'soundfx',
|
||||||
'instrument',
|
'instrument',
|
||||||
|
'pattern',
|
||||||
'texture',
|
'texture',
|
||||||
'vectorart',
|
'vectorart',
|
||||||
'pixelart',
|
'pixelart',
|
||||||
@@ -169,6 +172,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
|||||||
particles: 'Particles',
|
particles: 'Particles',
|
||||||
soundfx: 'Sound FX',
|
soundfx: 'Sound FX',
|
||||||
instrument: 'Instrument',
|
instrument: 'Instrument',
|
||||||
|
pattern: 'Pattern',
|
||||||
texture: 'Texture',
|
texture: 'Texture',
|
||||||
vectorart: 'Vector art',
|
vectorart: 'Vector art',
|
||||||
pixelart: 'Pixel art',
|
pixelart: 'Pixel art',
|
||||||
@@ -219,6 +223,7 @@ export class Editor implements PlayHost {
|
|||||||
readonly particlesEditor: ParticlesEditor;
|
readonly particlesEditor: ParticlesEditor;
|
||||||
readonly soundFxEditor: SoundFxEditor;
|
readonly soundFxEditor: SoundFxEditor;
|
||||||
readonly instrumentEditor: InstrumentEditor;
|
readonly instrumentEditor: InstrumentEditor;
|
||||||
|
readonly patternEditor: PatternEditor;
|
||||||
readonly textureEditor: TextureEditor;
|
readonly textureEditor: TextureEditor;
|
||||||
readonly vectorArtEditor: VectorArtEditor;
|
readonly vectorArtEditor: VectorArtEditor;
|
||||||
readonly pixelArtEditor: PixelArtEditor;
|
readonly pixelArtEditor: PixelArtEditor;
|
||||||
@@ -306,6 +311,7 @@ export class Editor implements PlayHost {
|
|||||||
this.particlesEditor = new ParticlesEditor(state);
|
this.particlesEditor = new ParticlesEditor(state);
|
||||||
this.soundFxEditor = new SoundFxEditor(state);
|
this.soundFxEditor = new SoundFxEditor(state);
|
||||||
this.instrumentEditor = new InstrumentEditor(state);
|
this.instrumentEditor = new InstrumentEditor(state);
|
||||||
|
this.patternEditor = new PatternEditor(state);
|
||||||
this.textureEditor = new TextureEditor(state);
|
this.textureEditor = new TextureEditor(state);
|
||||||
this.vectorArtEditor = new VectorArtEditor(state);
|
this.vectorArtEditor = new VectorArtEditor(state);
|
||||||
this.pixelArtEditor = new PixelArtEditor(state);
|
this.pixelArtEditor = new PixelArtEditor(state);
|
||||||
@@ -416,6 +422,7 @@ export class Editor implements PlayHost {
|
|||||||
particles: this.particlesEditor,
|
particles: this.particlesEditor,
|
||||||
soundfx: this.soundFxEditor,
|
soundfx: this.soundFxEditor,
|
||||||
instrument: this.instrumentEditor,
|
instrument: this.instrumentEditor,
|
||||||
|
pattern: this.patternEditor,
|
||||||
texture: this.textureEditor,
|
texture: this.textureEditor,
|
||||||
vectorart: this.vectorArtEditor,
|
vectorart: this.vectorArtEditor,
|
||||||
pixelart: this.pixelArtEditor,
|
pixelart: this.pixelArtEditor,
|
||||||
@@ -486,7 +493,9 @@ export class Editor implements PlayHost {
|
|||||||
stop: () => this.play.stop(),
|
stop: () => this.play.stop(),
|
||||||
togglePause: () => this.play.togglePause(),
|
togglePause: () => this.play.togglePause(),
|
||||||
copy: () =>
|
copy: () =>
|
||||||
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state),
|
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) ||
|
||||||
|
(this.tab === 'pattern' && this.patternEditor.canvas.copy()) ||
|
||||||
|
copySelection(state),
|
||||||
paste: () => this.paste(),
|
paste: () => this.paste(),
|
||||||
removePoint: () => this.removePoint(),
|
removePoint: () => this.removePoint(),
|
||||||
}),
|
}),
|
||||||
@@ -583,6 +592,8 @@ export class Editor implements PlayHost {
|
|||||||
return this.vectorArtEditor.canvas.removePickedPoint();
|
return this.vectorArtEditor.canvas.removePickedPoint();
|
||||||
case 'curve':
|
case 'curve':
|
||||||
return this.curveEditor.canvas.removePickedPoint();
|
return this.curveEditor.canvas.removePickedPoint();
|
||||||
|
case 'pattern':
|
||||||
|
return this.patternEditor.canvas.deleteSelection();
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -609,6 +620,8 @@ export class Editor implements PlayHost {
|
|||||||
return this.soundFxEditor.canvas.paste();
|
return this.soundFxEditor.canvas.paste();
|
||||||
case 'instrument':
|
case 'instrument':
|
||||||
return this.instrumentEditor.canvas.paste();
|
return this.instrumentEditor.canvas.paste();
|
||||||
|
case 'pattern':
|
||||||
|
return this.patternEditor.canvas.paste();
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -995,6 +1008,7 @@ export class Editor implements PlayHost {
|
|||||||
if (this.tab === 'tile') this.tileEditor.tick(step);
|
if (this.tab === 'tile') this.tileEditor.tick(step);
|
||||||
// The preview is the editor's own, so it runs on while the game is paused.
|
// The preview is the editor's own, so it runs on while the game is paused.
|
||||||
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
||||||
|
if (this.tab === 'pattern') this.patternEditor.tick();
|
||||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||||
// to do it, and its states move just the same.
|
// to do it, and its states move just the same.
|
||||||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||||
@@ -1027,7 +1041,8 @@ export class Editor implements PlayHost {
|
|||||||
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||||
(this.tab === 'tile' && this.tileEditor.isPlaying) ||
|
(this.tab === 'tile' && this.tileEditor.isPlaying) ||
|
||||||
(this.tab === 'curve' && this.curveEditor.isPlaying)
|
(this.tab === 'curve' && this.curveEditor.isPlaying) ||
|
||||||
|
(this.tab === 'pattern' && this.patternEditor.isPlaying)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
|||||||
particles: 'particles',
|
particles: 'particles',
|
||||||
soundfx: 'soundfx',
|
soundfx: 'soundfx',
|
||||||
instrument: 'instrument',
|
instrument: 'instrument',
|
||||||
|
pattern: 'pattern',
|
||||||
texture: 'texture',
|
texture: 'texture',
|
||||||
vectorart: 'vectorart',
|
vectorart: 'vectorart',
|
||||||
pixelart: 'pixelart',
|
pixelart: 'pixelart',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
|
import { centreOf, type HookContext } from './hookHelpers';
|
||||||
|
|
||||||
|
/** The Pattern tab and its piano roll (#333). */
|
||||||
|
export function patternHooks({ state, editor }: HookContext) {
|
||||||
|
const { patternEditor } = editor;
|
||||||
|
const roll = patternEditor.canvas;
|
||||||
|
return {
|
||||||
|
pattern: {
|
||||||
|
current: () => patternEditor.currentAssetId,
|
||||||
|
info: () => patternEditor.shownInfo,
|
||||||
|
rect: () => patternEditor.canvasRect(),
|
||||||
|
/** The notes as the model holds them, and the loop if one is set. */
|
||||||
|
notes: (assetId: string) => {
|
||||||
|
const asset = findAssetOf(state.project, assetId, 'pattern');
|
||||||
|
return asset ? (JSON.parse(JSON.stringify(asset.notes)) as unknown) : null;
|
||||||
|
},
|
||||||
|
loop: (assetId: string) => findAssetOf(state.project, assetId, 'pattern')?.loop ?? null,
|
||||||
|
picked: () => roll.pickedIds,
|
||||||
|
/** Where a tick of a note's row, a tick of the ruler, a key, a velocity of the lane are on screen. */
|
||||||
|
gridAt: (tick: number, note: number) => roll.gridPoint(tick, note),
|
||||||
|
rulerAt: (tick: number) => roll.rulerPoint(tick),
|
||||||
|
keyAt: (note: number) => roll.keyPoint(note),
|
||||||
|
laneAt: (tick: number, velocity: number) => roll.lanePoint(tick, velocity),
|
||||||
|
view: () => roll.viewInfo(),
|
||||||
|
keys: () => roll.keysDrawn(),
|
||||||
|
playAt: () => ({ ...centreOf(patternEditor.playButton), text: patternEditor.playButton.text }),
|
||||||
|
gridButton: () => ({ ...centreOf(patternEditor.gridButton), text: patternEditor.gridButton.text }),
|
||||||
|
fitAt: () => centreOf(patternEditor.fitButton),
|
||||||
|
playing: () => patternEditor.isPlaying,
|
||||||
|
/** The notes the scheduler handed to the player since Play, and where it stands. */
|
||||||
|
planned: () => [...patternEditor.music.planned],
|
||||||
|
position: () => patternEditor.music.current?.position() ?? null,
|
||||||
|
clock: () => soundLibrary.clock(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
stageNodes,
|
stageNodes,
|
||||||
} from '../viewport/SoundLibrary';
|
} from '../viewport/SoundLibrary';
|
||||||
import { curveHooks } from './debug/curveHooks';
|
import { curveHooks } from './debug/curveHooks';
|
||||||
|
import { patternHooks } from './debug/patternHooks';
|
||||||
import { documentHooks } from './debug/documentHooks';
|
import { documentHooks } from './debug/documentHooks';
|
||||||
import { graphHooks } from './debug/graphHooks';
|
import { graphHooks } from './debug/graphHooks';
|
||||||
import { centreOf, type HookContext } from './debug/hookHelpers';
|
import { centreOf, type HookContext } from './debug/hookHelpers';
|
||||||
@@ -44,6 +45,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
...pixelArtHooks(context),
|
...pixelArtHooks(context),
|
||||||
...vectorArtHooks(context),
|
...vectorArtHooks(context),
|
||||||
...curveHooks(context),
|
...curveHooks(context),
|
||||||
|
...patternHooks(context),
|
||||||
...renderHooks(context),
|
...renderHooks(context),
|
||||||
/**
|
/**
|
||||||
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
|
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Container, Graphics, type Text } 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 { barLabelStep, RULER_HEIGHT, type TimeView, tickToX, timeLines } from './timeView';
|
||||||
|
|
||||||
|
/** The strip at the top of the ruler that shows the loop. */
|
||||||
|
export const LOOP_STRIP = 6;
|
||||||
|
|
||||||
|
export interface RulerMarks {
|
||||||
|
/** Where the piece ends, in ticks: the time before it reads lighter. */
|
||||||
|
length: number;
|
||||||
|
loop: LoopRange | null;
|
||||||
|
/** Where playing starts. */
|
||||||
|
start: number;
|
||||||
|
playhead: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ruler above the piano roll (#333), meant for the song tab too (#334): bars numbered from 1,
|
||||||
|
* the beats as short ticks, the loop as a band along its top, where playing starts as a small
|
||||||
|
* triangle at its foot and the playhead as one at its head. Draws `width` pixels from its own x 0,
|
||||||
|
* with the view measured from there.
|
||||||
|
*/
|
||||||
|
export class TimeRuler extends Container {
|
||||||
|
private readonly g = new Graphics();
|
||||||
|
private readonly labels = new Container();
|
||||||
|
private readonly texts: Text[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.eventMode = 'none';
|
||||||
|
this.addChild(this.g, this.labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(view: TimeView, meter: Meter, width: number, marks: RulerMarks): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
const g = this.g.clear();
|
||||||
|
g.rect(0, 0, width, RULER_HEIGHT).fill(c.panelHeader);
|
||||||
|
const end = Math.min(width, tickToX(view, marks.length));
|
||||||
|
const begin = Math.max(0, tickToX(view, 0));
|
||||||
|
if (end > begin)
|
||||||
|
g.rect(begin, LOOP_STRIP, end - begin, RULER_HEIGHT - LOOP_STRIP).fill({ color: c.accent, alpha: 0.1 });
|
||||||
|
if (marks.loop) {
|
||||||
|
const x0 = Math.max(0, tickToX(view, marks.loop.start));
|
||||||
|
const x1 = Math.min(width, tickToX(view, marks.loop.end));
|
||||||
|
if (x1 > x0) g.rect(x0, 0, x1 - x0, LOOP_STRIP).fill({ color: c.accent, alpha: 0.75 });
|
||||||
|
}
|
||||||
|
g.rect(0, RULER_HEIGHT - 1, width, 1).fill(c.border);
|
||||||
|
|
||||||
|
const bar = ticksPerBar(meter);
|
||||||
|
const every = barLabelStep(view, meter);
|
||||||
|
let used = 0;
|
||||||
|
// 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;
|
||||||
|
const tall = line.level === 0;
|
||||||
|
g.rect(line.x, tall ? LOOP_STRIP : RULER_HEIGHT - 5, 1, tall ? RULER_HEIGHT - LOOP_STRIP : 5).fill({
|
||||||
|
color: c.textMuted,
|
||||||
|
alpha: tall ? 0.8 : 0.6,
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
used += 1;
|
||||||
|
}
|
||||||
|
for (let i = used; i < this.texts.length; i++) this.texts[i]!.visible = false;
|
||||||
|
|
||||||
|
const start = tickToX(view, marks.start);
|
||||||
|
if (start >= -5 && start <= width + 5)
|
||||||
|
g.moveTo(start - 5, RULER_HEIGHT - 1)
|
||||||
|
.lineTo(start + 5, RULER_HEIGHT - 1)
|
||||||
|
.lineTo(start, RULER_HEIGHT - 7)
|
||||||
|
.closePath()
|
||||||
|
.fill(c.text);
|
||||||
|
if (marks.playhead !== null) {
|
||||||
|
const x = tickToX(view, marks.playhead);
|
||||||
|
if (x >= -5 && x <= width + 5)
|
||||||
|
g.moveTo(x - 5, 0)
|
||||||
|
.lineTo(x + 5, 0)
|
||||||
|
.lineTo(x, 7)
|
||||||
|
.closePath()
|
||||||
|
.fill(c.hot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { type Meter, ticksPerBar, ticksPerBeat } from '../../core/music/time';
|
||||||
|
import { clamp } from '../../ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time axis of the piano roll (#333), and of the song tab after it (#334), apart from Pixi so a
|
||||||
|
* test can drive it: ticks run left to right, `x` is measured from the left edge of the area that
|
||||||
|
* shows them. Only across; what is stacked above each other is up to the tab.
|
||||||
|
*/
|
||||||
|
export interface TimeView {
|
||||||
|
/** Pixels from the area's left edge to tick 0. */
|
||||||
|
panX: number;
|
||||||
|
pxPerTick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RULER_HEIGHT = 22;
|
||||||
|
/** A bar of 4/4 from 8 pixels to a 1/32 of 60. */
|
||||||
|
export const MIN_PX_PER_TICK = 0.004;
|
||||||
|
export const MAX_PX_PER_TICK = 1;
|
||||||
|
const BAR_LABEL_MIN_PX = 36;
|
||||||
|
const BEAT_MIN_PX = 6;
|
||||||
|
const GRID_MIN_PX = 5;
|
||||||
|
|
||||||
|
export function tickToX(view: TimeView, tick: number): number {
|
||||||
|
return view.panX + tick * view.pxPerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xToTick(view: TimeView, x: number): number {
|
||||||
|
return (x - view.panX) / view.pxPerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms about `x`, so the tick under the pointer stays under it. */
|
||||||
|
export function zoomTime(view: TimeView, factor: number, x: number): TimeView {
|
||||||
|
const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, MAX_PX_PER_TICK);
|
||||||
|
const tick = xToTick(view, x);
|
||||||
|
return { panX: x - tick * pxPerTick, pxPerTick };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time starts at 0: nothing to look at left of it. */
|
||||||
|
export function clampTimePan<V extends TimeView>(view: V): V {
|
||||||
|
return view.panX > 0 ? { ...view, panX: 0 } : view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `length` ticks across `width` pixels, from tick 0. */
|
||||||
|
export function fitTime(length: number, width: number): TimeView {
|
||||||
|
return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line across the time: 0 a bar, 1 a beat, 2 a step of the grid. */
|
||||||
|
export interface TimeLine {
|
||||||
|
tick: number;
|
||||||
|
x: number;
|
||||||
|
level: 0 | 1 | 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines from `0` to `width` pixels, each tick only at its strongest level. Beats and the grid
|
||||||
|
* are left out where they would stand closer than a few pixels.
|
||||||
|
*/
|
||||||
|
export function timeLines(view: TimeView, meter: Meter, grid: number, width: number): TimeLine[] {
|
||||||
|
const levels = new Map<number, 0 | 1 | 2>();
|
||||||
|
const from = Math.max(0, xToTick(view, 0));
|
||||||
|
const to = xToTick(view, width);
|
||||||
|
const spans: [number, 0 | 1 | 2, number][] = [
|
||||||
|
[ticksPerBar(meter), 0, 0],
|
||||||
|
[ticksPerBeat(meter), 1, BEAT_MIN_PX],
|
||||||
|
[grid, 2, GRID_MIN_PX],
|
||||||
|
];
|
||||||
|
for (const [step, level, minPx] of spans) {
|
||||||
|
if (step * view.pxPerTick < minPx) continue;
|
||||||
|
for (let tick = Math.ceil(from / step) * step; tick <= to; tick += step)
|
||||||
|
if (!levels.has(tick)) levels.set(tick, level);
|
||||||
|
}
|
||||||
|
return [...levels]
|
||||||
|
.map(([tick, level]) => ({ tick, x: tickToX(view, tick), level }))
|
||||||
|
.sort((a, b) => a.tick - b.tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `n`-th bar gets its number on the ruler, `n` a power of two so the numbers never overlap. */
|
||||||
|
export function barLabelStep(view: TimeView, meter: Meter): number {
|
||||||
|
const barPx = ticksPerBar(meter) * view.pxPerTick;
|
||||||
|
let n = 1;
|
||||||
|
while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
|||||||
sound: 'Sounds',
|
sound: 'Sounds',
|
||||||
// Not "Sound FX": that is the `+` menu entry (see Vector art).
|
// Not "Sound FX": that is the `+` menu entry (see Vector art).
|
||||||
soundfx: 'Sound effects',
|
soundfx: 'Sound effects',
|
||||||
// Where patterns and songs join it (#330).
|
instrument: 'Instruments',
|
||||||
instrument: 'Music',
|
pattern: 'Patterns',
|
||||||
particles: 'Particles',
|
particles: 'Particles',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import list from './icons/list.svg?raw';
|
|||||||
import instrument from './icons/instrument.svg?raw';
|
import instrument from './icons/instrument.svg?raw';
|
||||||
import joint from './icons/joint.svg?raw';
|
import joint from './icons/joint.svg?raw';
|
||||||
import palette from './icons/palette.svg?raw';
|
import palette from './icons/palette.svg?raw';
|
||||||
|
import pattern from './icons/pattern.svg?raw';
|
||||||
import particles from './icons/particles.svg?raw';
|
import particles from './icons/particles.svg?raw';
|
||||||
import pixelart from './icons/pixelart.svg?raw';
|
import pixelart from './icons/pixelart.svg?raw';
|
||||||
import plugins from './icons/plugins.svg?raw';
|
import plugins from './icons/plugins.svg?raw';
|
||||||
@@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
|||||||
joint,
|
joint,
|
||||||
palette,
|
palette,
|
||||||
particles,
|
particles,
|
||||||
|
pattern,
|
||||||
pixelart,
|
pixelart,
|
||||||
rigidbody,
|
rigidbody,
|
||||||
scene,
|
scene,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<!-- Notes of a piano roll: bars at their pitch and time. -->
|
||||||
|
<rect x="-6.5" y="-5.5" width="13" height="11" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.25" />
|
||||||
|
<rect x="-4.5" y="-3.5" width="4" height="2" fill="currentColor" />
|
||||||
|
<rect x="-1" y="-0.5" width="5.5" height="2" fill="currentColor" />
|
||||||
|
<rect x="-3" y="2.2" width="3" height="1.6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,202 @@
|
|||||||
|
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||||
|
import {
|
||||||
|
instrumentBuildFor,
|
||||||
|
instrumentSourcesOf,
|
||||||
|
namedNotes,
|
||||||
|
playedNotes,
|
||||||
|
} from '../../core/instrument/compile';
|
||||||
|
import {
|
||||||
|
GRID_OPTIONS,
|
||||||
|
PATTERN_GRID_KEY,
|
||||||
|
patternInstrument,
|
||||||
|
patternLoop,
|
||||||
|
patternMeter,
|
||||||
|
patternSequence,
|
||||||
|
patternTempo,
|
||||||
|
} from '../../core/music/pattern';
|
||||||
|
import type { Asset, PatternAsset } from '../../core/model/Asset';
|
||||||
|
import { textOf } from '../../core/model/Properties';
|
||||||
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import { playNote } from '../../runtime/systems/behaviorContext';
|
||||||
|
import { Button, Dropdown, Label, Menu } from '../../ui';
|
||||||
|
import { Music } from '../../viewport/music';
|
||||||
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
|
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { PianoRoll } from './PianoRoll';
|
||||||
|
|
||||||
|
const INFO_WIDTH = 200;
|
||||||
|
const GRID_WIDTH = 64;
|
||||||
|
/** How long a note drawn or moved sounds. */
|
||||||
|
const PREVIEW_SECONDS = 0.3;
|
||||||
|
/** Whose voices the tab starts for a key or a drawn note, apart from those the scheduler plays. */
|
||||||
|
const PREVIEW_OWNER = 'pattern-preview';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Pattern tab (#333): the piano roll, and in the header Play – the Space bar does the same –
|
||||||
|
* and the grid notes are drawn on. The pattern plays round its loop through the scheduler (#332);
|
||||||
|
* an edit is heard from its next window on, without starting over.
|
||||||
|
*/
|
||||||
|
export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
|
||||||
|
/** Public for the check scripts. */
|
||||||
|
readonly playButton: Button;
|
||||||
|
readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH });
|
||||||
|
readonly music = new Music(() => this.state.project);
|
||||||
|
/** The pattern playing, `''` while none is. */
|
||||||
|
private playingId = '';
|
||||||
|
/** What the scheduler was last told, so only a change is passed on. */
|
||||||
|
private told = '';
|
||||||
|
|
||||||
|
constructor(state: EditorState) {
|
||||||
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
|
this.playButton = new Button('Play', {
|
||||||
|
icon: TOOLBAR_ICON_SVG.play,
|
||||||
|
minWidth: 72,
|
||||||
|
tooltip: 'Play the pattern round its loop (Space)',
|
||||||
|
onClick: () => this.togglePlay(),
|
||||||
|
});
|
||||||
|
this.gridButton.onClick = () => this.openGridMenu();
|
||||||
|
const roll = new PianoRoll(state);
|
||||||
|
roll.onPreview = (note, held) => this.preview(note, held);
|
||||||
|
roll.onRelease = (voice) => soundLibrary.release(voice);
|
||||||
|
roll.onTogglePlay = () => this.togglePlay();
|
||||||
|
roll.onStart = (tick) => {
|
||||||
|
if (this.isPlaying) this.music.current?.seek(tick);
|
||||||
|
};
|
||||||
|
this.mount(roll, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
||||||
|
state.on('load', () => this.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
override selectedId(): string {
|
||||||
|
return this.state.selectedPatternId ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
override isMine(asset: Asset): asset is PatternAsset {
|
||||||
|
return asset.type === 'pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPlaying(): boolean {
|
||||||
|
return this.playingId !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlay(): void {
|
||||||
|
if (this.isPlaying) this.stop();
|
||||||
|
else this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays the pattern shown from where the ruler says, round its loop. */
|
||||||
|
play(): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return;
|
||||||
|
this.playingId = asset.id;
|
||||||
|
this.told = this.settingsOf(asset);
|
||||||
|
void this.music.play(patternSequence(asset), {
|
||||||
|
tempo: patternTempo(asset.props),
|
||||||
|
meter: patternMeter(asset.props),
|
||||||
|
loop: patternLoop(asset),
|
||||||
|
from: this.canvas.start,
|
||||||
|
});
|
||||||
|
this.syncPlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.music.stop();
|
||||||
|
this.playingId = '';
|
||||||
|
this.canvas.playhead = null;
|
||||||
|
this.canvas.invalidate();
|
||||||
|
this.syncPlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only called while the tab is visible: the playhead follows what is heard. */
|
||||||
|
tick(): void {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
||||||
|
this.canvas.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy(options?: Parameters<AssetTabEditor<PatternAsset, PianoRoll>['destroy']>[0]): void {
|
||||||
|
this.music.stop();
|
||||||
|
super.destroy(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment. */
|
||||||
|
private preview(note: number, held: boolean): number {
|
||||||
|
const asset = this.asset();
|
||||||
|
const instrument = asset ? patternInstrument(asset.props) : '';
|
||||||
|
if (!instrument) return 0;
|
||||||
|
const struck = { note, velocity: 1, volume: 1, duration: held ? 0 : PREVIEW_SECONDS };
|
||||||
|
return playNote(
|
||||||
|
{ project: this.state.project, audio: soundLibrary },
|
||||||
|
instrument,
|
||||||
|
struck,
|
||||||
|
PREVIEW_OWNER,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openGridMenu(): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return;
|
||||||
|
const current = textOf(asset.props[PATTERN_GRID_KEY] ?? '');
|
||||||
|
new Menu(
|
||||||
|
GRID_OPTIONS.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
current: option.value === current,
|
||||||
|
onSelect: () =>
|
||||||
|
this.state.execute(new SetAssetPropertyCommand(asset.id, PATTERN_GRID_KEY, option.value)),
|
||||||
|
})),
|
||||||
|
).openBelow(this.gridButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private settingsOf(asset: PatternAsset): string {
|
||||||
|
return JSON.stringify([patternTempo(asset.props), patternMeter(asset.props), patternLoop(asset)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What changed while playing goes to the scheduler; another pattern shown stops it. */
|
||||||
|
private follow(asset: PatternAsset | null): void {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
if (!asset || asset.id !== this.playingId) {
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.music.edit(patternSequence(asset));
|
||||||
|
const settings = this.settingsOf(asset);
|
||||||
|
if (settings === this.told) return;
|
||||||
|
this.told = settings;
|
||||||
|
const scheduler = this.music.current;
|
||||||
|
scheduler?.setTempo(patternTempo(asset.props));
|
||||||
|
scheduler?.setMeter(patternMeter(asset.props));
|
||||||
|
scheduler?.setLoop(patternLoop(asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPlayButton(): void {
|
||||||
|
this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
||||||
|
this.playButton.setText(this.isPlaying ? 'Stop' : 'Play');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override infoText(asset: PatternAsset): string {
|
||||||
|
const count = asset.notes.length;
|
||||||
|
const notes = `${count} ${count === 1 ? 'note' : 'notes'}`;
|
||||||
|
const instrument = findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument');
|
||||||
|
return instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override syncExtras(asset: PatternAsset | null): void {
|
||||||
|
this.fitButton.setEnabled(asset !== null);
|
||||||
|
this.playButton.setEnabled(asset !== null);
|
||||||
|
this.gridButton.setEnabled(asset !== null);
|
||||||
|
this.gridButton.setText(asset ? textOf(asset.props[PATTERN_GRID_KEY] ?? '1/16') : '1/16');
|
||||||
|
const instrument = asset
|
||||||
|
? findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument')
|
||||||
|
: undefined;
|
||||||
|
if (instrument) {
|
||||||
|
const sources = instrumentSourcesOf(this.state.project);
|
||||||
|
const sounding = [...playedNotes(instrument.graph)].filter(
|
||||||
|
(note) => instrumentBuildFor(instrument, note, sources).layers.length > 0,
|
||||||
|
);
|
||||||
|
this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph));
|
||||||
|
} else this.canvas.setInstrument(new Set(), new Map());
|
||||||
|
this.follow(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,960 @@
|
|||||||
|
import {
|
||||||
|
Container,
|
||||||
|
type FederatedPointerEvent,
|
||||||
|
type FederatedWheelEvent,
|
||||||
|
Graphics,
|
||||||
|
type Text,
|
||||||
|
} from 'pixi.js';
|
||||||
|
import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands';
|
||||||
|
import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes';
|
||||||
|
import {
|
||||||
|
DEFAULT_VELOCITY,
|
||||||
|
patternGrid,
|
||||||
|
patternLength,
|
||||||
|
patternLoop,
|
||||||
|
patternMeter,
|
||||||
|
type PatternNote,
|
||||||
|
} from '../../core/music/pattern';
|
||||||
|
import type { LoopRange } from '../../core/music/sequence';
|
||||||
|
import { ticksPerBar } from '../../core/music/time';
|
||||||
|
import type { PatternAsset } from '../../core/model/Asset';
|
||||||
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import { crispText, drawBand, Menu, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
|
||||||
|
import { TimeRuler } from '../music/TimeRuler';
|
||||||
|
import {
|
||||||
|
clampTimePan,
|
||||||
|
fitTime,
|
||||||
|
RULER_HEIGHT,
|
||||||
|
tickToX,
|
||||||
|
timeLines,
|
||||||
|
xToTick,
|
||||||
|
zoomTime,
|
||||||
|
} from '../music/timeView';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import {
|
||||||
|
clampShift,
|
||||||
|
draggedLength,
|
||||||
|
drawnNote,
|
||||||
|
duplicateOffset,
|
||||||
|
moveDelta,
|
||||||
|
placedCopies,
|
||||||
|
resized,
|
||||||
|
shifted,
|
||||||
|
snapDown,
|
||||||
|
snapNear,
|
||||||
|
withVelocity,
|
||||||
|
} from './rollEdit';
|
||||||
|
import {
|
||||||
|
centreRows,
|
||||||
|
clampRows,
|
||||||
|
DEFAULT_ROW_HEIGHT,
|
||||||
|
noteAt,
|
||||||
|
noteAtY,
|
||||||
|
noteBox,
|
||||||
|
noteTop,
|
||||||
|
notesWithin,
|
||||||
|
type RollView,
|
||||||
|
velocityAtY,
|
||||||
|
velocityBarAt,
|
||||||
|
zoomRows,
|
||||||
|
} from './rollView';
|
||||||
|
|
||||||
|
export const KEYS_WIDTH = 84;
|
||||||
|
export const VELOCITY_HEIGHT = 64;
|
||||||
|
const DRAG_THRESHOLD = 3;
|
||||||
|
/** How close to an end of the loop a press on the ruler takes that end. */
|
||||||
|
const LOOP_EDGE_REACH = 4;
|
||||||
|
const WHITE = 0xd9dbe0;
|
||||||
|
const BLACK = 0x22252b;
|
||||||
|
const MUTED_WHITE = 0x6b6f78;
|
||||||
|
const MUTED_BLACK = 0x34373e;
|
||||||
|
/** A note from velocity 0 to 1: from a dim blue to a bright one. */
|
||||||
|
const SOFT = 0x2a4a80;
|
||||||
|
const HARD = 0x8fb8ff;
|
||||||
|
/** Notes behind the end of the pattern are kept, but not played: drawn faint. */
|
||||||
|
const BEYOND_ALPHA = 0.35;
|
||||||
|
const DARK_LABEL = textStyle('small').clone();
|
||||||
|
DARK_LABEL.fill = 0x30343c;
|
||||||
|
|
||||||
|
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel';
|
||||||
|
|
||||||
|
type Gesture =
|
||||||
|
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||||
|
| { kind: 'key'; note: number; voice: number }
|
||||||
|
| {
|
||||||
|
kind: 'ruler';
|
||||||
|
from: number;
|
||||||
|
pointerX: number;
|
||||||
|
active: boolean;
|
||||||
|
loop: LoopRange | null;
|
||||||
|
free: boolean;
|
||||||
|
/** The end of the loop the press was at: a drag moves it, a click is a click all the same. */
|
||||||
|
edge: 'start' | 'end' | null;
|
||||||
|
}
|
||||||
|
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; free: boolean }
|
||||||
|
| {
|
||||||
|
kind: 'move';
|
||||||
|
anchor: PatternNote;
|
||||||
|
grab: number;
|
||||||
|
ticks: number;
|
||||||
|
notes: number;
|
||||||
|
moved: boolean;
|
||||||
|
pointer: Vec2;
|
||||||
|
}
|
||||||
|
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
|
||||||
|
| { kind: 'erase'; erased: Set<string> }
|
||||||
|
| { kind: 'velocity'; anchor: PatternNote; value: number };
|
||||||
|
|
||||||
|
function mix(a: number, b: number, t: number): number {
|
||||||
|
const channel = (shift: number) => {
|
||||||
|
const x = (a >> shift) & 0xff;
|
||||||
|
const y = (b >> shift) & 0xff;
|
||||||
|
return Math.round(x + (y - x) * t) << shift;
|
||||||
|
};
|
||||||
|
return channel(16) | channel(8) | channel(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
|
||||||
|
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
|
||||||
|
* a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a
|
||||||
|
* right-click or Delete. The ruler sets where playing starts, and a drag on it the loop.
|
||||||
|
*/
|
||||||
|
export class PianoRoll extends Widget {
|
||||||
|
private readonly bg = new Graphics();
|
||||||
|
private readonly gridLayer = new Container();
|
||||||
|
private readonly gridG = new Graphics();
|
||||||
|
private readonly gridOverlay = new Graphics();
|
||||||
|
private readonly keysLayer = new Container();
|
||||||
|
private readonly keysG = new Graphics();
|
||||||
|
private readonly keyLabels = new Container();
|
||||||
|
private readonly keyTexts: Text[] = [];
|
||||||
|
private readonly ruler = new TimeRuler();
|
||||||
|
private readonly laneLayer = new Container();
|
||||||
|
private readonly laneG = new Graphics();
|
||||||
|
private readonly clips = {
|
||||||
|
grid: new Graphics(),
|
||||||
|
keys: new Graphics(),
|
||||||
|
ruler: new Graphics(),
|
||||||
|
lane: new Graphics(),
|
||||||
|
};
|
||||||
|
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||||
|
private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') });
|
||||||
|
private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT };
|
||||||
|
private needsFit = true;
|
||||||
|
private assetId = '';
|
||||||
|
private gesture: Gesture | null = null;
|
||||||
|
/** The notes picked, by id; only this tab knows them. */
|
||||||
|
private picked = new Set<string>();
|
||||||
|
/** Where playing starts and a paste lands, in ticks. */
|
||||||
|
private startTick = 0;
|
||||||
|
/** What a new note gets: the length of the last one drawn or dragged, and the last velocity set. */
|
||||||
|
private lastLength: number | null = null;
|
||||||
|
private lastVelocity = DEFAULT_VELOCITY;
|
||||||
|
/** What Ctrl+C took, for any pattern of the project. */
|
||||||
|
private clipboard: PatternNote[] = [];
|
||||||
|
private sounding: ReadonlySet<number> = new Set();
|
||||||
|
private names: ReadonlyMap<number, string> = new Map();
|
||||||
|
/** Where the playhead stands while the pattern plays, in ticks; null while it does not. */
|
||||||
|
playhead: number | null = null;
|
||||||
|
/** Starts a note for the keys or for a note drawn or moved; `held` until `onRelease`. Gives back its voice. */
|
||||||
|
onPreview: (note: number, held: boolean) => number = () => 0;
|
||||||
|
onRelease: (voice: number) => void = () => {};
|
||||||
|
onTogglePlay: () => void = () => {};
|
||||||
|
/** Where playing starts was moved: a playing pattern jumps there. */
|
||||||
|
onStart: (tick: number) => void = () => {};
|
||||||
|
|
||||||
|
constructor(private readonly state: EditorState) {
|
||||||
|
super();
|
||||||
|
this.focusable = true;
|
||||||
|
this.gridLayer.addChild(this.gridG, this.gridOverlay);
|
||||||
|
this.keysLayer.addChild(this.keysG, this.keyLabels);
|
||||||
|
this.laneLayer.addChild(this.laneG);
|
||||||
|
this.addChild(
|
||||||
|
this.bg,
|
||||||
|
this.gridLayer,
|
||||||
|
this.keysLayer,
|
||||||
|
this.ruler,
|
||||||
|
this.laneLayer,
|
||||||
|
this.hint,
|
||||||
|
this.laneLabel,
|
||||||
|
);
|
||||||
|
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||||||
|
this.gridLayer.mask = this.clips.grid;
|
||||||
|
this.keysLayer.mask = this.clips.keys;
|
||||||
|
this.ruler.mask = this.clips.ruler;
|
||||||
|
this.laneLayer.mask = this.clips.lane;
|
||||||
|
this.hint.eventMode = 'none';
|
||||||
|
this.laneLabel.eventMode = 'none';
|
||||||
|
|
||||||
|
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||||
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||||
|
this.onPointerRelease(() => this.onPointerUp());
|
||||||
|
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||||
|
state.on('project', () => {
|
||||||
|
const notes = this.asset()?.notes ?? [];
|
||||||
|
const alive = new Set(notes.map((n) => n.id));
|
||||||
|
for (const id of this.picked) if (!alive.has(id)) this.picked.delete(id);
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): Size {
|
||||||
|
return { w: Math.max(this.minWidth, 320), h: Math.max(this.minHeight, 200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentAssetId(): string {
|
||||||
|
return this.assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another pattern starts with nothing picked, playing from its start, framed. */
|
||||||
|
show(assetId: string): void {
|
||||||
|
if (this.assetId === assetId) return;
|
||||||
|
this.endGesture();
|
||||||
|
this.assetId = assetId;
|
||||||
|
this.picked.clear();
|
||||||
|
this.startTick = 0;
|
||||||
|
this.needsFit = true;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveView(): RollView {
|
||||||
|
return { ...this.view };
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreView(view: unknown): void {
|
||||||
|
this.view = { ...(view as RollView) };
|
||||||
|
this.needsFit = false;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole pattern across, and its notes – or what the instrument plays – down the middle. */
|
||||||
|
fit(): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
const width = this.gridWidth;
|
||||||
|
const height = this.gridHeight;
|
||||||
|
if (!asset || width <= 0 || height <= 0) {
|
||||||
|
this.needsFit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.needsFit = false;
|
||||||
|
const notes = asset.notes.length > 0 ? asset.notes.map((n) => n.note) : [...this.sounding];
|
||||||
|
const low = notes.length > 0 ? Math.min(...notes) : MIDDLE_C - 12;
|
||||||
|
const high = notes.length > 0 ? Math.max(...notes) : MIDDLE_C + 12;
|
||||||
|
const time = fitTime(patternLength(asset), width);
|
||||||
|
this.view = clampRows({ ...time, ...centreRows(low, high, height) }, height);
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notes the instrument plays, and the names a drum kit gives some of them. */
|
||||||
|
setInstrument(sounding: ReadonlySet<number>, names: ReadonlyMap<number, string>): void {
|
||||||
|
this.sounding = sounding;
|
||||||
|
this.names = names;
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
get start(): number {
|
||||||
|
return this.startTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pickedIds(): string[] {
|
||||||
|
return [...this.picked];
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(ids: readonly string[]): void {
|
||||||
|
this.picked = new Set(ids);
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what the keys and the menus do ----------------------------------------------------------
|
||||||
|
|
||||||
|
selectAll(): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return false;
|
||||||
|
this.pick(asset.notes.map((n) => n.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelection(): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || this.picked.size === 0) return false;
|
||||||
|
const label = this.picked.size === 1 ? 'Delete note' : 'Delete notes';
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
asset.notes.filter((n) => !this.picked.has(n.id)),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
this.picked.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || this.picked.size === 0) return false;
|
||||||
|
this.clipboard = asset.notes.filter((n) => this.picked.has(n.id)).map((n) => ({ ...n }));
|
||||||
|
const n = this.clipboard.length;
|
||||||
|
this.state.setNotice(`Copied ${n} ${n === 1 ? 'note' : 'notes'}`, 'quiet');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lands at the start of playing; false with nothing copied, so Ctrl+V can fall through. */
|
||||||
|
paste(): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || this.clipboard.length === 0) return false;
|
||||||
|
const copies = placedCopies(this.clipboard, this.startTick);
|
||||||
|
this.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes');
|
||||||
|
this.pick(copies.map((n) => n.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy of the picked notes right behind them, picked in their place. */
|
||||||
|
duplicate(): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || this.picked.size === 0) return false;
|
||||||
|
const by = duplicateOffset(asset.notes, this.picked, patternGrid(asset.props));
|
||||||
|
const copies = placedCopies(
|
||||||
|
asset.notes.filter((n) => this.picked.has(n.id)),
|
||||||
|
Math.min(...asset.notes.filter((n) => this.picked.has(n.id)).map((n) => n.tick)) + by,
|
||||||
|
);
|
||||||
|
this.commit(asset, [...asset.notes, ...copies], 'Duplicate notes');
|
||||||
|
this.pick(copies.map((n) => n.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the picked notes by ticks and semitones, as far as they can go. */
|
||||||
|
nudge(ticks: number, pitch: number): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || this.picked.size === 0) return false;
|
||||||
|
const by = clampShift(asset.notes, this.picked, ticks, pitch);
|
||||||
|
if (by.ticks !== 0 || by.notes !== 0)
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
shifted(asset.notes, this.picked, by.ticks, by.notes),
|
||||||
|
by.notes !== 0 ? 'Transpose notes' : 'Move notes',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override onKeyDown(event: KeyboardEvent): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return false;
|
||||||
|
const mod = event.ctrlKey || event.metaKey;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (event.code === 'Space' && !mod) {
|
||||||
|
this.onTogglePlay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mod && key === 'c') return this.copy();
|
||||||
|
if (mod && key === 'v') return this.paste();
|
||||||
|
if (mod && key === 'd') return this.duplicate() || true;
|
||||||
|
if (mod && key === 'a') return this.selectAll();
|
||||||
|
if (mod) return false;
|
||||||
|
// Taken even with nothing picked: a second Delete must not go on to ask about the pattern itself.
|
||||||
|
if (event.key === 'Delete' || event.key === 'Backspace') return this.deleteSelection() || true;
|
||||||
|
if (event.key === 'Escape' && this.picked.size > 0) {
|
||||||
|
this.pick([]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const grid = patternGrid(asset.props);
|
||||||
|
const bar = ticksPerBar(patternMeter(asset.props));
|
||||||
|
if (event.key === 'ArrowUp') return this.nudge(0, event.shiftKey ? 12 : 1);
|
||||||
|
if (event.key === 'ArrowDown') return this.nudge(0, event.shiftKey ? -12 : -1);
|
||||||
|
if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0);
|
||||||
|
if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBlur(): void {
|
||||||
|
super.onBlur();
|
||||||
|
if (this.gesture?.kind === 'key') this.endGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- for the check scripts -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Where a tick and a note meet on screen: the middle of the note's row. */
|
||||||
|
gridPoint(tick: number, note: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
return {
|
||||||
|
x: at.x + KEYS_WIDTH + tickToX(this.view, tick),
|
||||||
|
y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rulerPoint(tick: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
return { x: at.x + KEYS_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPoint(note: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
return {
|
||||||
|
x: at.x + KEYS_WIDTH / 2,
|
||||||
|
y: at.y + RULER_HEIGHT + noteTop(this.view, note) + this.view.rowHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the lane has a velocity, at a tick. */
|
||||||
|
lanePoint(tick: number, velocity: number): Vec2 {
|
||||||
|
const at = this.getGlobalPosition();
|
||||||
|
const top = this.h - VELOCITY_HEIGHT;
|
||||||
|
return {
|
||||||
|
x: at.x + KEYS_WIDTH + tickToX(this.view, tick),
|
||||||
|
y: at.y + top + (1 - velocity) * VELOCITY_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
viewInfo(): RollView & {
|
||||||
|
start: number;
|
||||||
|
playhead: number | null;
|
||||||
|
loop: LoopRange | null;
|
||||||
|
keys: number | null;
|
||||||
|
} {
|
||||||
|
const g = this.gesture;
|
||||||
|
return {
|
||||||
|
...this.view,
|
||||||
|
start: this.startTick,
|
||||||
|
playhead: this.playhead,
|
||||||
|
loop: this.shownLoop(),
|
||||||
|
keys: g?.kind === 'key' ? g.note : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows of the keys as drawn, top to bottom: the note, its label and whether it sounds. */
|
||||||
|
keysDrawn(): { note: number; label: string; sounds: boolean }[] {
|
||||||
|
const out: { note: number; label: string; sounds: boolean }[] = [];
|
||||||
|
for (const note of this.visibleNotes())
|
||||||
|
out.push({ note, label: this.keyLabel(note), sounds: this.sounding.has(note) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pointer ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private get gridWidth(): number {
|
||||||
|
return Math.max(0, this.w - KEYS_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get gridHeight(): number {
|
||||||
|
return Math.max(0, this.h - RULER_HEIGHT - VELOCITY_HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private asset(): PatternAsset | null {
|
||||||
|
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'pattern') ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private areaAt(p: Vec2): Area {
|
||||||
|
const left = p.x < KEYS_WIDTH;
|
||||||
|
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
||||||
|
if (p.y >= this.h - VELOCITY_HEIGHT) return left ? 'laneLabel' : 'lane';
|
||||||
|
return left ? 'keys' : 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point of the widget in the grid's own pixels. */
|
||||||
|
private inGrid(p: Vec2): Vec2 {
|
||||||
|
return { x: p.x - KEYS_WIDTH, y: p.y - RULER_HEIGHT };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerDown(e: FederatedPointerEvent): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return;
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
const p = { x: local.x, y: local.y };
|
||||||
|
const area = this.areaAt(p);
|
||||||
|
e.stopPropagation();
|
||||||
|
ui().focus.focus(this);
|
||||||
|
if (e.button === 1) {
|
||||||
|
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const g = this.inGrid(p);
|
||||||
|
const tick = xToTick(this.view, g.x);
|
||||||
|
if (e.button === 2) {
|
||||||
|
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||||||
|
if (area === 'grid') {
|
||||||
|
this.gesture = { kind: 'erase', erased: new Set() };
|
||||||
|
this.erase(asset, g);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (area === 'keys') {
|
||||||
|
const note = noteAtY(this.view, g.y);
|
||||||
|
if (note !== null) this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||||||
|
} else if (area === 'ruler') {
|
||||||
|
const loop = patternLoop(asset);
|
||||||
|
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
|
||||||
|
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
|
||||||
|
this.gesture = {
|
||||||
|
kind: 'ruler',
|
||||||
|
from: Math.max(0, tick),
|
||||||
|
pointerX: g.x,
|
||||||
|
active: false,
|
||||||
|
loop: null,
|
||||||
|
free: e.altKey,
|
||||||
|
edge,
|
||||||
|
};
|
||||||
|
} else if (area === 'grid') {
|
||||||
|
this.pressGrid(asset, e, g);
|
||||||
|
} else if (area === 'lane') {
|
||||||
|
const id = velocityBarAt(this.view, asset.notes, this.picked, g.x);
|
||||||
|
const anchor = asset.notes.find((n) => n.id === id);
|
||||||
|
if (anchor) this.gesture = { kind: 'velocity', anchor, value: anchor.velocity };
|
||||||
|
}
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pressGrid(asset: PatternAsset, e: FederatedPointerEvent, g: Vec2): void {
|
||||||
|
const hit = noteAt(this.view, asset.notes, g);
|
||||||
|
const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined;
|
||||||
|
if (!hit || !anchor) {
|
||||||
|
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey, free: e.altKey };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (this.picked.has(hit.id)) this.picked.delete(hit.id);
|
||||||
|
else this.picked.add(hit.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
|
||||||
|
if (hit.part === 'end') {
|
||||||
|
this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grab = xToTick(this.view, g.x) - anchor.tick;
|
||||||
|
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g };
|
||||||
|
this.onPreview(anchor.note, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerMove(e: FederatedPointerEvent): void {
|
||||||
|
const gesture = this.gesture;
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!gesture || !asset) return;
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
const g = this.inGrid({ x: local.x, y: local.y });
|
||||||
|
const tick = xToTick(this.view, g.x);
|
||||||
|
const grid = patternGrid(asset.props);
|
||||||
|
const free = e.altKey;
|
||||||
|
switch (gesture.kind) {
|
||||||
|
case 'pan':
|
||||||
|
this.view = clampRows(
|
||||||
|
clampTimePan({
|
||||||
|
...this.view,
|
||||||
|
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||||||
|
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||||||
|
}),
|
||||||
|
this.gridHeight,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'key': {
|
||||||
|
const note = noteAtY(this.view, g.y);
|
||||||
|
if (note === null || note === gesture.note) return;
|
||||||
|
this.onRelease(gesture.voice);
|
||||||
|
this.gesture = { kind: 'key', note, voice: this.onPreview(note, true) };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ruler': {
|
||||||
|
gesture.free = free;
|
||||||
|
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||||||
|
gesture.active = true;
|
||||||
|
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
|
||||||
|
const at = snap(tick);
|
||||||
|
const { start, end } = patternLoop(asset);
|
||||||
|
if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end };
|
||||||
|
else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) };
|
||||||
|
else {
|
||||||
|
const from = snap(gesture.from);
|
||||||
|
gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'empty': {
|
||||||
|
gesture.current = g;
|
||||||
|
gesture.free = free;
|
||||||
|
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||||||
|
if (!gesture.active && span < DRAG_THRESHOLD) return;
|
||||||
|
gesture.active = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'move': {
|
||||||
|
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
|
gesture.moved = true;
|
||||||
|
const note = noteAtY(this.view, g.y) ?? gesture.anchor.note + gesture.notes;
|
||||||
|
const by = moveDelta(asset.notes, this.picked, gesture.anchor, tick - gesture.grab, note, grid, free);
|
||||||
|
if (by.notes !== gesture.notes) this.onPreview(gesture.anchor.note + by.notes, false);
|
||||||
|
gesture.ticks = by.ticks;
|
||||||
|
gesture.notes = by.notes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'resize':
|
||||||
|
gesture.moved = true;
|
||||||
|
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
|
||||||
|
break;
|
||||||
|
case 'erase':
|
||||||
|
this.erase(asset, g);
|
||||||
|
break;
|
||||||
|
case 'velocity':
|
||||||
|
gesture.value = velocityAtY(local.y, this.h - VELOCITY_HEIGHT, VELOCITY_HEIGHT);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerUp(): void {
|
||||||
|
const gesture = this.gesture;
|
||||||
|
this.gesture = null;
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!gesture || !asset) {
|
||||||
|
if (gesture?.kind === 'key') this.onRelease(gesture.voice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grid = patternGrid(asset.props);
|
||||||
|
switch (gesture.kind) {
|
||||||
|
case 'key':
|
||||||
|
this.onRelease(gesture.voice);
|
||||||
|
break;
|
||||||
|
case 'ruler':
|
||||||
|
if (gesture.active) {
|
||||||
|
const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset));
|
||||||
|
if (gesture.loop && !same) this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop));
|
||||||
|
} else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid));
|
||||||
|
break;
|
||||||
|
case 'empty':
|
||||||
|
if (gesture.active) {
|
||||||
|
const caught = notesWithin(this.view, asset.notes, gesture.start, gesture.current);
|
||||||
|
this.picked = new Set([...(gesture.add ? this.picked : []), ...caught]);
|
||||||
|
} else this.draw(asset, gesture.start, grid, gesture.free);
|
||||||
|
break;
|
||||||
|
case 'move':
|
||||||
|
if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) {
|
||||||
|
const label = this.picked.size === 1 ? 'Move note' : 'Move notes';
|
||||||
|
this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label);
|
||||||
|
} else if (!gesture.moved) this.picked = new Set([gesture.anchor.id]);
|
||||||
|
break;
|
||||||
|
case 'resize':
|
||||||
|
if (gesture.by !== 0) {
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
resized(asset.notes, this.picked, gesture.by),
|
||||||
|
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
|
||||||
|
);
|
||||||
|
this.lastLength = gesture.anchor.length + gesture.by;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'erase':
|
||||||
|
if (gesture.erased.size > 0) {
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
asset.notes.filter((n) => !gesture.erased.has(n.id)),
|
||||||
|
gesture.erased.size === 1 ? 'Delete note' : 'Delete notes',
|
||||||
|
);
|
||||||
|
for (const id of gesture.erased) this.picked.delete(id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'velocity':
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
withVelocity(asset.notes, this.picked, gesture.anchor, gesture.value),
|
||||||
|
'Set velocity',
|
||||||
|
);
|
||||||
|
this.lastVelocity = gesture.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets go of whatever the pointer held, without writing anything. */
|
||||||
|
private endGesture(): void {
|
||||||
|
if (this.gesture?.kind === 'key') this.onRelease(this.gesture.voice);
|
||||||
|
this.gesture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void {
|
||||||
|
const note = noteAtY(this.view, at.y);
|
||||||
|
if (note === null) return;
|
||||||
|
const drawn = drawnNote(
|
||||||
|
xToTick(this.view, at.x),
|
||||||
|
note,
|
||||||
|
grid,
|
||||||
|
free,
|
||||||
|
this.lastLength ?? grid,
|
||||||
|
this.lastVelocity,
|
||||||
|
);
|
||||||
|
this.commit(asset, [...asset.notes, drawn], 'Draw note');
|
||||||
|
this.lastLength = drawn.length;
|
||||||
|
this.picked = new Set([drawn.id]);
|
||||||
|
this.onPreview(note, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private erase(asset: PatternAsset, g: Vec2): void {
|
||||||
|
if (this.gesture?.kind !== 'erase') return;
|
||||||
|
const shown = asset.notes.filter((n) => !(this.gesture as { erased: Set<string> }).erased.has(n.id));
|
||||||
|
const hit = noteAt(this.view, shown, g);
|
||||||
|
if (hit) this.gesture.erased.add(hit.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStart(tick: number): void {
|
||||||
|
this.startTick = Math.max(0, tick);
|
||||||
|
this.onStart(this.startTick);
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private commit(asset: PatternAsset, notes: readonly PatternNote[], label: string): void {
|
||||||
|
if (SetPatternNotesCommand.changes(asset, notes))
|
||||||
|
this.state.execute(new SetPatternNotesCommand(asset.id, notes, label));
|
||||||
|
}
|
||||||
|
|
||||||
|
private openRulerMenu(asset: PatternAsset, tick: number, global: Vec2): void {
|
||||||
|
const bar = ticksPerBar(patternMeter(asset.props));
|
||||||
|
const first = snapDown(tick, bar);
|
||||||
|
new Menu([
|
||||||
|
{
|
||||||
|
label: `Loop bar ${first / bar + 1}`,
|
||||||
|
onSelect: () =>
|
||||||
|
this.state.execute(new SetPatternLoopCommand(asset.id, { start: first, end: first + bar })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Loop the whole pattern',
|
||||||
|
disabled: !asset.loop,
|
||||||
|
onSelect: () => this.state.execute(new SetPatternLoopCommand(asset.id, null)),
|
||||||
|
},
|
||||||
|
]).open(global.x, global.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWheel(e: FederatedWheelEvent): void {
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
const area = this.areaAt({ x: local.x, y: local.y });
|
||||||
|
const g = this.inGrid({ x: local.x, y: local.y });
|
||||||
|
let view: RollView = this.view;
|
||||||
|
if (area === 'ruler' || (e.altKey && area !== 'keys'))
|
||||||
|
view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) };
|
||||||
|
else if (e.altKey) view = zoomRows(view, zoomFactor(e.deltaY), g.y);
|
||||||
|
else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY };
|
||||||
|
else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY };
|
||||||
|
this.view = clampRows(clampTimePan(view), this.gridHeight);
|
||||||
|
this.needsFit = false;
|
||||||
|
this.redraw();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drawing ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The notes as the running gesture would leave them. */
|
||||||
|
private shownNotes(asset: PatternAsset): readonly PatternNote[] {
|
||||||
|
const g = this.gesture;
|
||||||
|
if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes);
|
||||||
|
if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by);
|
||||||
|
if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value);
|
||||||
|
if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id));
|
||||||
|
return asset.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shownLoop(): LoopRange | null {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return null;
|
||||||
|
const g = this.gesture;
|
||||||
|
if (g?.kind === 'ruler' && g.active) return g.loop;
|
||||||
|
return patternLoop(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notes whose rows are on screen, highest first. */
|
||||||
|
private visibleNotes(): number[] {
|
||||||
|
const top = noteAtY(this.view, 0) ?? HIGHEST_NOTE;
|
||||||
|
const bottom = noteAtY(this.view, this.gridHeight - 1) ?? LOWEST_NOTE;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let note = top; note >= bottom; note--) out.push(note);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyLabel(note: number): string {
|
||||||
|
return this.names.get(note) ?? (note % 12 === 0 ? noteName(note) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
if (this.needsFit) this.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
|
||||||
|
this.fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = Theme.colors;
|
||||||
|
const w = this.w;
|
||||||
|
const h = this.h;
|
||||||
|
const gw = this.gridWidth;
|
||||||
|
const gh = this.gridHeight;
|
||||||
|
const laneTop = h - VELOCITY_HEIGHT;
|
||||||
|
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
||||||
|
this.bg.rect(0, 0, KEYS_WIDTH, h).fill(c.panel);
|
||||||
|
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
|
||||||
|
this.bg.rect(0, laneTop, w, VELOCITY_HEIGHT).fill(c.panel);
|
||||||
|
this.bg.rect(KEYS_WIDTH - 1, 0, 1, h).fill(c.border);
|
||||||
|
this.bg.rect(0, laneTop, w, 1).fill(c.border);
|
||||||
|
this.clips.grid.clear().rect(KEYS_WIDTH, RULER_HEIGHT, gw, gh).fill(0xffffff);
|
||||||
|
this.clips.keys
|
||||||
|
.clear()
|
||||||
|
.rect(0, RULER_HEIGHT, KEYS_WIDTH - 1, gh)
|
||||||
|
.fill(0xffffff);
|
||||||
|
this.clips.ruler.clear().rect(KEYS_WIDTH, 0, gw, RULER_HEIGHT).fill(0xffffff);
|
||||||
|
this.clips.lane
|
||||||
|
.clear()
|
||||||
|
.rect(KEYS_WIDTH, laneTop + 1, gw, VELOCITY_HEIGHT - 1)
|
||||||
|
.fill(0xffffff);
|
||||||
|
this.gridLayer.position.set(KEYS_WIDTH, RULER_HEIGHT);
|
||||||
|
this.keysLayer.position.set(0, RULER_HEIGHT);
|
||||||
|
this.ruler.position.set(KEYS_WIDTH, 0);
|
||||||
|
this.laneLayer.position.set(KEYS_WIDTH, laneTop);
|
||||||
|
this.laneLabel.position.set(Theme.padding, laneTop + Theme.padding);
|
||||||
|
|
||||||
|
const asset = this.asset();
|
||||||
|
this.gridG.clear();
|
||||||
|
this.gridOverlay.clear();
|
||||||
|
this.laneG.clear();
|
||||||
|
this.keysG.clear();
|
||||||
|
if (!asset) {
|
||||||
|
this.syncKeyLabels([]);
|
||||||
|
this.ruler.visible = false;
|
||||||
|
this.hint.text = 'No pattern selected – pick one in the Assets panel or create one.';
|
||||||
|
this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||||||
|
this.hint.visible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ruler.visible = true;
|
||||||
|
const notes = this.shownNotes(asset);
|
||||||
|
const length = patternLength(asset);
|
||||||
|
const rows = this.visibleNotes();
|
||||||
|
this.drawRows(rows, gw);
|
||||||
|
this.drawLines(asset, gw, gh, length);
|
||||||
|
this.drawNotes(notes, length, gw, gh);
|
||||||
|
this.drawLane(notes, length, gw);
|
||||||
|
this.drawKeys(rows);
|
||||||
|
this.ruler.draw(this.view, patternMeter(asset.props), gw, {
|
||||||
|
length,
|
||||||
|
loop: this.shownLoop(),
|
||||||
|
start: this.startTick,
|
||||||
|
playhead: this.playhead,
|
||||||
|
});
|
||||||
|
const g = this.gesture;
|
||||||
|
if (g?.kind === 'empty' && g.active) drawBand(this.gridOverlay, g.start, g.current);
|
||||||
|
const start = tickToX(this.view, this.startTick);
|
||||||
|
if (start >= 0 && start <= gw) this.gridOverlay.rect(start, 0, 1, gh).fill({ color: c.text, alpha: 0.3 });
|
||||||
|
if (this.playhead !== null) {
|
||||||
|
const x = tickToX(this.view, this.playhead);
|
||||||
|
if (x >= 0 && x <= gw) {
|
||||||
|
this.gridOverlay.rect(x - 0.5, 0, 1, gh).fill(c.hot);
|
||||||
|
this.laneG.rect(x - 0.5, 0, 1, VELOCITY_HEIGHT).fill(c.hot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.hint.text = asset.notes.length === 0 ? 'Click to draw a note, drag to pick notes, Space plays.' : '';
|
||||||
|
this.hint.visible = this.hint.text !== '';
|
||||||
|
this.hint.position.set(KEYS_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row per note, the black keys' rows darker, a line under every C. */
|
||||||
|
private drawRows(rows: readonly number[], gw: number): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
const rh = this.view.rowHeight;
|
||||||
|
for (const note of rows) {
|
||||||
|
const y = noteTop(this.view, note);
|
||||||
|
this.gridG
|
||||||
|
.rect(0, y, gw, rh)
|
||||||
|
.fill(isSharp(note) ? { color: 0x000000, alpha: 0.22 } : { color: 0xffffff, alpha: 0.025 });
|
||||||
|
if (note % 12 === 0) this.gridG.rect(0, y + rh - 1, gw, 1).fill({ color: c.border, alpha: 0.9 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLines(asset: PatternAsset, gw: number, gh: number, length: number): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
const alpha = [1, 0.55, 0.25];
|
||||||
|
for (const line of timeLines(this.view, patternMeter(asset.props), patternGrid(asset.props), gw)) {
|
||||||
|
this.gridG.rect(line.x, 0, 1, gh).fill({ color: c.border, alpha: alpha[line.level] });
|
||||||
|
if (line.level === 0)
|
||||||
|
this.laneG.rect(line.x, 0, 1, VELOCITY_HEIGHT).fill({ color: c.border, alpha: 0.6 });
|
||||||
|
}
|
||||||
|
const end = tickToX(this.view, length);
|
||||||
|
if (end < gw) {
|
||||||
|
this.gridG.rect(Math.max(0, end), 0, gw - Math.max(0, end), gh).fill({ color: 0x000000, alpha: 0.35 });
|
||||||
|
this.laneG
|
||||||
|
.rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT)
|
||||||
|
.fill({ color: 0x000000, alpha: 0.25 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
for (const note of notes) {
|
||||||
|
const box = noteBox(this.view, note);
|
||||||
|
if (box.x > gw || box.x + box.w < 0 || box.y > gh || box.y + box.h < 0) continue;
|
||||||
|
const picked = this.picked.has(note.id);
|
||||||
|
const alpha = note.tick >= length ? BEYOND_ALPHA : 1;
|
||||||
|
const w = Math.max(2, box.w);
|
||||||
|
this.gridG
|
||||||
|
.rect(box.x, box.y + 1, w, Math.max(1, box.h - 1))
|
||||||
|
.fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha })
|
||||||
|
.stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLane(notes: readonly PatternNote[], length: number, gw: number): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 });
|
||||||
|
for (const note of notes) {
|
||||||
|
const x = tickToX(this.view, note.tick);
|
||||||
|
if (x < -8 || x > gw) continue;
|
||||||
|
const top = (1 - note.velocity) * (VELOCITY_HEIGHT - 2) + 2;
|
||||||
|
const color = this.picked.has(note.id) ? c.accentText : mix(SOFT, HARD, note.velocity);
|
||||||
|
const alpha = note.tick >= length ? BEYOND_ALPHA : 1;
|
||||||
|
this.laneG.rect(x, top, 2, VELOCITY_HEIGHT - top).fill({ color, alpha });
|
||||||
|
this.laneG
|
||||||
|
.rect(x, top, Math.max(2, Math.min(8, note.length * this.view.pxPerTick)), 2)
|
||||||
|
.fill({ color, alpha });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keys, the ones the instrument plays nothing for greyed out, the held one lit. */
|
||||||
|
private drawKeys(rows: readonly number[]): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
const rh = this.view.rowHeight;
|
||||||
|
const held = this.gesture?.kind === 'key' ? this.gesture.note : null;
|
||||||
|
const labelled: { note: number; y: number }[] = [];
|
||||||
|
for (const note of rows) {
|
||||||
|
const y = noteTop(this.view, note);
|
||||||
|
const sounds = this.sounding.has(note);
|
||||||
|
const fill =
|
||||||
|
note === held
|
||||||
|
? c.accent
|
||||||
|
: isSharp(note)
|
||||||
|
? sounds
|
||||||
|
? BLACK
|
||||||
|
: MUTED_BLACK
|
||||||
|
: sounds
|
||||||
|
? WHITE
|
||||||
|
: MUTED_WHITE;
|
||||||
|
this.keysG
|
||||||
|
.rect(0, y, KEYS_WIDTH - 1, rh)
|
||||||
|
.fill(fill)
|
||||||
|
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||||||
|
if (rh >= 9 && this.keyLabel(note)) labelled.push({ note, y });
|
||||||
|
}
|
||||||
|
this.syncKeyLabels(labelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { newId } from '../../core/ids';
|
||||||
|
import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
|
||||||
|
import type { PatternNote } from '../../core/music/pattern';
|
||||||
|
import { clamp } from '../../ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the piano roll does to notes (#333), as plain functions on lists so a test can check them.
|
||||||
|
* Everything snaps to the grid unless `free` – Alt held –, which puts it on the tick.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The grid line at or before a tick. */
|
||||||
|
export function snapDown(tick: number, grid: number): number {
|
||||||
|
return Math.floor(tick / grid) * grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The grid line nearest to a tick. */
|
||||||
|
export function snapNear(tick: number, grid: number): number {
|
||||||
|
return Math.round(tick / grid) * grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note drawn at a point: starting at the grid line before it, as long as the last one drawn. */
|
||||||
|
export function drawnNote(
|
||||||
|
pointerTick: number,
|
||||||
|
note: number,
|
||||||
|
grid: number,
|
||||||
|
free: boolean,
|
||||||
|
length: number,
|
||||||
|
velocity: number,
|
||||||
|
): PatternNote {
|
||||||
|
const tick = Math.max(0, free ? Math.round(pointerTick) : snapDown(pointerTick, grid));
|
||||||
|
return { id: newId(), tick, length: Math.max(1, Math.round(length)), note, velocity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a drag moves the picked notes: the grabbed one's start lands on the grid (or the tick),
|
||||||
|
* and no note goes before tick 0 or off the keyboard.
|
||||||
|
*/
|
||||||
|
export function moveDelta(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
anchor: PatternNote,
|
||||||
|
wantedStart: number,
|
||||||
|
wantedNote: number,
|
||||||
|
grid: number,
|
||||||
|
free: boolean,
|
||||||
|
): { ticks: number; notes: number } {
|
||||||
|
const start = free ? Math.round(wantedStart) : snapNear(wantedStart, grid);
|
||||||
|
return clampShift(notes, picked, start - anchor.tick, wantedNote - anchor.note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shift of the picked notes cut short where it would take one before tick 0 or off the keyboard. */
|
||||||
|
export function clampShift(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
ticks: number,
|
||||||
|
pitch: number,
|
||||||
|
): { ticks: number; notes: number } {
|
||||||
|
const chosen = notes.filter((n) => picked.has(n.id));
|
||||||
|
if (chosen.length === 0) return { ticks: 0, notes: 0 };
|
||||||
|
const first = Math.min(...chosen.map((n) => n.tick));
|
||||||
|
const low = Math.min(...chosen.map((n) => n.note));
|
||||||
|
const high = Math.max(...chosen.map((n) => n.note));
|
||||||
|
return {
|
||||||
|
// `|| 0`: no -0 for a note at tick 0.
|
||||||
|
ticks: Math.max(ticks, -first) || 0,
|
||||||
|
notes: clamp(pitch, LOWEST_NOTE - low, HIGHEST_NOTE - high),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shifted(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
ticks: number,
|
||||||
|
pitch: number,
|
||||||
|
): PatternNote[] {
|
||||||
|
return notes.map((n) => (picked.has(n.id) ? { ...n, tick: n.tick + ticks, note: n.note + pitch } : n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length the grabbed note takes when its end is dragged to a tick: to the grid line nearest the
|
||||||
|
* pointer, but never back to its own start – then to the first grid line after it.
|
||||||
|
*/
|
||||||
|
export function draggedLength(anchor: PatternNote, pointerTick: number, grid: number, free: boolean): number {
|
||||||
|
if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick);
|
||||||
|
const end = snapNear(pointerTick, grid);
|
||||||
|
return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every picked note lengthened by what the grabbed one was, none shorter than a tick. */
|
||||||
|
export function resized(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
by: number,
|
||||||
|
): PatternNote[] {
|
||||||
|
return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */
|
||||||
|
export function duplicateOffset(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
grid: number,
|
||||||
|
): number {
|
||||||
|
const chosen = notes.filter((n) => picked.has(n.id));
|
||||||
|
if (chosen.length === 0) return 0;
|
||||||
|
const first = Math.min(...chosen.map((n) => n.tick));
|
||||||
|
const end = Math.max(...chosen.map((n) => n.tick + n.length));
|
||||||
|
return Math.max(grid, Math.ceil((end - first) / grid) * grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies of notes with ids of their own, moved so the earliest starts at `at`. */
|
||||||
|
export function placedCopies(notes: readonly PatternNote[], at: number): PatternNote[] {
|
||||||
|
if (notes.length === 0) return [];
|
||||||
|
const first = Math.min(...notes.map((n) => n.tick));
|
||||||
|
return notes.map((n) => ({ ...n, id: newId(), tick: Math.max(0, at) + (n.tick - first) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grabbed note's velocity set to `value`, the other picked notes moved by as much, each held
|
||||||
|
* to 0 to 1.
|
||||||
|
*/
|
||||||
|
export function withVelocity(
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
anchor: PatternNote,
|
||||||
|
value: number,
|
||||||
|
): PatternNote[] {
|
||||||
|
const by = clamp(value, 0, 1) - anchor.velocity;
|
||||||
|
return notes.map((n) =>
|
||||||
|
n.id === anchor.id
|
||||||
|
? { ...n, velocity: clamp(value, 0, 1) }
|
||||||
|
: picked.has(n.id) && picked.has(anchor.id)
|
||||||
|
? { ...n, velocity: clamp(n.velocity + by, 0, 1) }
|
||||||
|
: n,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
|
||||||
|
import type { PatternNote } from '../../core/music/pattern';
|
||||||
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
|
import { clamp } from '../../ui';
|
||||||
|
import { type TimeView, tickToX } from '../music/timeView';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The piano roll's geometry (#333), apart from Pixi so a test can drive it. Across it is a time
|
||||||
|
* view; down it are the 128 notes, the highest on top, each a row. `y` is measured from the top of
|
||||||
|
* the note area, `x` from its left edge, as in the time view.
|
||||||
|
*/
|
||||||
|
export interface RollView extends TimeView {
|
||||||
|
/** Pixels from the area's top edge to the top of the highest note's row. */
|
||||||
|
panY: number;
|
||||||
|
rowHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
|
||||||
|
export const MIN_ROW_HEIGHT = 6;
|
||||||
|
export const MAX_ROW_HEIGHT = 28;
|
||||||
|
export const DEFAULT_ROW_HEIGHT = 14;
|
||||||
|
/** How far in from a note's right end a press takes its length rather than the note. */
|
||||||
|
const END_GRIP = 6;
|
||||||
|
/** A note narrower than this is still this wide to the pointer. */
|
||||||
|
const MIN_HIT_WIDTH = 4;
|
||||||
|
/** How close to a bar of the velocity lane a press must be to take it. */
|
||||||
|
const VELOCITY_REACH = 4;
|
||||||
|
|
||||||
|
export function noteTop(view: RollView, note: number): number {
|
||||||
|
return view.panY + (HIGHEST_NOTE - note) * view.rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The note whose row holds `y`; null above the highest and below the lowest. */
|
||||||
|
export function noteAtY(view: RollView, y: number): number | null {
|
||||||
|
const note = HIGHEST_NOTE - Math.floor((y - view.panY) / view.rowHeight);
|
||||||
|
return note >= LOWEST_NOTE && note <= HIGHEST_NOTE ? note : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the rows on screen: no gap above the highest note or below the lowest. */
|
||||||
|
export function clampRows(view: RollView, height: number): RollView {
|
||||||
|
const min = Math.min(0, height - ROWS * view.rowHeight);
|
||||||
|
return { ...view, panY: clamp(view.panY, min, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms the rows about `y`, so the note under the pointer stays under it. */
|
||||||
|
export function zoomRows(view: RollView, factor: number, y: number): RollView {
|
||||||
|
const rowHeight = clamp(view.rowHeight * factor, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
|
||||||
|
const at = (y - view.panY) / view.rowHeight;
|
||||||
|
return { ...view, rowHeight, panY: y - at * rowHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows from `low` to `high` in the middle of `height` pixels, a row of room above and below:
|
||||||
|
* at the default height where they fit, else lower.
|
||||||
|
*/
|
||||||
|
export function centreRows(low: number, high: number, height: number): Pick<RollView, 'panY' | 'rowHeight'> {
|
||||||
|
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, DEFAULT_ROW_HEIGHT);
|
||||||
|
const middle = (HIGHEST_NOTE - (low + high) / 2 + 0.5) * rowHeight;
|
||||||
|
return { rowHeight, panY: height / 2 - middle };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteBox(view: RollView, note: Pick<PatternNote, 'tick' | 'length' | 'note'>): NoteBox {
|
||||||
|
const x = tickToX(view, note.tick);
|
||||||
|
return { x, y: noteTop(view, note.note), w: note.length * view.pxPerTick, h: view.rowHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteHit {
|
||||||
|
id: string;
|
||||||
|
/** The body moves the note, the end changes its length. */
|
||||||
|
part: 'body' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The note under a point; where notes lie over each other, the one drawn last. */
|
||||||
|
export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null {
|
||||||
|
for (let i = notes.length - 1; i >= 0; i--) {
|
||||||
|
const note = notes[i]!;
|
||||||
|
const box = noteBox(view, note);
|
||||||
|
const w = Math.max(box.w, MIN_HIT_WIDTH);
|
||||||
|
if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue;
|
||||||
|
const grip = Math.min(END_GRIP, w / 3);
|
||||||
|
return { id: note.id, part: point.x >= box.x + w - grip ? 'end' : 'body' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notes a band between two points touches. */
|
||||||
|
export function notesWithin(view: RollView, notes: readonly PatternNote[], a: Vec2, b: Vec2): string[] {
|
||||||
|
const left = Math.min(a.x, b.x);
|
||||||
|
const right = Math.max(a.x, b.x);
|
||||||
|
const top = Math.min(a.y, b.y);
|
||||||
|
const bottom = Math.max(a.y, b.y);
|
||||||
|
return notes
|
||||||
|
.filter((note) => {
|
||||||
|
const box = noteBox(view, note);
|
||||||
|
return box.x <= right && box.x + Math.max(box.w, 1) >= left && box.y <= bottom && box.y + box.h >= top;
|
||||||
|
})
|
||||||
|
.map((note) => note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note whose bar in the velocity lane is under `x`: the bar stands at the note's start. Of
|
||||||
|
* several there, a picked one comes first, else the one drawn last.
|
||||||
|
*/
|
||||||
|
export function velocityBarAt(
|
||||||
|
view: TimeView,
|
||||||
|
notes: readonly PatternNote[],
|
||||||
|
picked: ReadonlySet<string>,
|
||||||
|
x: number,
|
||||||
|
): string | null {
|
||||||
|
let found: string | null = null;
|
||||||
|
let foundPicked = false;
|
||||||
|
for (const note of notes) {
|
||||||
|
if (Math.abs(tickToX(view, note.tick) - x) > VELOCITY_REACH) continue;
|
||||||
|
const isPicked = picked.has(note.id);
|
||||||
|
if (found === null || isPicked || !foundPicked) {
|
||||||
|
found = note.id;
|
||||||
|
foundPicked = isPicked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The velocity a point in the lane stands for: 1 at its top, 0 at its foot. */
|
||||||
|
export function velocityAtY(y: number, top: number, height: number): number {
|
||||||
|
return clamp(1 - (y - top) / height, 0, 1);
|
||||||
|
}
|
||||||
@@ -780,6 +780,13 @@ export class EditorState {
|
|||||||
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
|
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
|
||||||
|
get selectedPatternId(): string | null {
|
||||||
|
const s = this.selection;
|
||||||
|
if (s?.kind !== 'asset') return null;
|
||||||
|
return findAssetOf(this.project, s.assetId, 'pattern')?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The timeline row the selection is about; undefined when it is about something else. */
|
/** The timeline row the selection is about; undefined when it is about something else. */
|
||||||
get selectedTrackId(): string | undefined {
|
get selectedTrackId(): string | undefined {
|
||||||
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
|
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ export interface PlannedNote extends ScheduledNote {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The scheduler on the sound library's clock (#332), its notes played by the instruments of the
|
* The scheduler on the sound library's clock (#332), its notes played by the instruments of the
|
||||||
* project. Nothing in the editor plays a sequence yet – the piano roll (#333) will; until then the
|
* project: what the Pattern tab plays (#333), and what the check scripts drive through `__gm.music`.
|
||||||
* check scripts drive it through `__gm.music`.
|
|
||||||
*/
|
*/
|
||||||
export class Music {
|
export class Music {
|
||||||
private scheduler: Scheduler | null = null;
|
private scheduler: Scheduler | null = null;
|
||||||
private sequence: Sequence = { notes: [] };
|
private sequence: Sequence = { notes: [] };
|
||||||
|
/** Counts starts and stops: a start still loading its files gives up once another came after it. */
|
||||||
|
private run = 0;
|
||||||
/** The last notes planned, newest last, for a check to hold against the clock. */
|
/** The last notes planned, newest last, for a check to hold against the clock. */
|
||||||
readonly planned: PlannedNote[] = [];
|
readonly planned: PlannedNote[] = [];
|
||||||
|
|
||||||
@@ -41,9 +42,11 @@ export class Music {
|
|||||||
/** Loads what the notes need, then plays from `from` ticks. */
|
/** Loads what the notes need, then plays from `from` ticks. */
|
||||||
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
|
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
const run = this.run;
|
||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
this.planned.length = 0;
|
this.planned.length = 0;
|
||||||
await soundLibrary.prepare(this.layersOf(sequence));
|
await soundLibrary.prepare(this.layersOf(sequence));
|
||||||
|
if (run !== this.run) return;
|
||||||
this.scheduler = new Scheduler(
|
this.scheduler = new Scheduler(
|
||||||
{
|
{
|
||||||
now: () => soundLibrary.time(),
|
now: () => soundLibrary.time(),
|
||||||
@@ -61,12 +64,14 @@ export class Music {
|
|||||||
this.scheduler.start(options.from ?? 0);
|
this.scheduler.start(options.from ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A change heard from the next window on. */
|
/** A change heard from the next window on; a note new to it loads what it needs meanwhile. */
|
||||||
edit(sequence: Sequence): void {
|
edit(sequence: Sequence): void {
|
||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
|
void soundLibrary.prepare(this.layersOf(sequence));
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.run += 1;
|
||||||
this.scheduler?.stop();
|
this.scheduler?.stop();
|
||||||
this.scheduler = null;
|
this.scheduler = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||||
|
import { namedNotes } from '../../src/core/instrument/compile';
|
||||||
|
import { starterInstrumentGraph } from '../../src/core/instrument/nodes';
|
||||||
|
import type { PatternAsset } from '../../src/core/model/Asset';
|
||||||
|
import {
|
||||||
|
DEFAULT_GRID,
|
||||||
|
gridTicks,
|
||||||
|
heldPatternNotes,
|
||||||
|
patternGrid,
|
||||||
|
patternLength,
|
||||||
|
patternLoop,
|
||||||
|
patternMeter,
|
||||||
|
type PatternNote,
|
||||||
|
patternSequence,
|
||||||
|
patternTempo,
|
||||||
|
} from '../../src/core/music/pattern';
|
||||||
|
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
|
||||||
|
import { makeProject, roundTrip } from '../helpers';
|
||||||
|
|
||||||
|
function makePattern() {
|
||||||
|
const f = makeProject();
|
||||||
|
const create = new CreateAssetCommand('pattern', 'Beat');
|
||||||
|
f.stack.execute(create);
|
||||||
|
const asset = create.asset;
|
||||||
|
const pattern = () => f.project.assets.find((a) => a.id === asset.id) as PatternAsset;
|
||||||
|
const set = (key: string, value: string | number) =>
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(asset.id, key, value));
|
||||||
|
return { ...f, id: asset.id, pattern, set };
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({
|
||||||
|
id,
|
||||||
|
tick,
|
||||||
|
length,
|
||||||
|
note: n,
|
||||||
|
velocity,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pattern asset (#333)', () => {
|
||||||
|
it('starts empty: four bars of 4/4 at 120 BPM on a 1/16 grid, no instrument', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
const p = f.pattern();
|
||||||
|
expect(p.notes).toEqual([]);
|
||||||
|
expect(p.loop).toBeUndefined();
|
||||||
|
expect(patternMeter(p.props)).toEqual({ beats: 4, unit: 4 });
|
||||||
|
expect(patternTempo(p.props)).toBe(120);
|
||||||
|
expect(patternGrid(p.props)).toBe(120);
|
||||||
|
expect(patternLength(p)).toBe(4 * 1920);
|
||||||
|
expect(schemaForAsset(f.project, p).map((d) => d.key)).toEqual([
|
||||||
|
'instrument',
|
||||||
|
'bars',
|
||||||
|
'grid',
|
||||||
|
'beats',
|
||||||
|
'beat',
|
||||||
|
'tempo',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts its grids in whole ticks, triplets too', () => {
|
||||||
|
expect(['1/1', '1/4', '1/16', '1/32', '1/4T', '1/8T', '1/16T', '1/32T'].map(gridTicks)).toEqual([
|
||||||
|
1920, 480, 120, 60, 320, 160, 80, 40,
|
||||||
|
]);
|
||||||
|
expect(gridTicks('nonsense')).toBe(gridTicks(DEFAULT_GRID));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures its length in bars of its meter: 3 bars of 6/8 are 4320 ticks', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
f.set('bars', 3);
|
||||||
|
f.set('beats', 6);
|
||||||
|
f.set('beat', '8');
|
||||||
|
expect(patternLength(f.pattern())).toBe(3 * 6 * 240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds notes to whole ticks, a tick at least, the keyboard and 0 to 1, sorted, ids unique', () => {
|
||||||
|
const held = heldPatternNotes([
|
||||||
|
{ id: 'b', tick: 480.4, length: 0.2, note: 64, velocity: 2 },
|
||||||
|
{ id: 'a', tick: 0, length: 240, note: 60, velocity: 0.5 },
|
||||||
|
{ id: 'a', tick: 0, length: 240, note: 67, velocity: 0.5 },
|
||||||
|
{ id: 'c', tick: -10, length: 240, note: 60 },
|
||||||
|
{ id: 'd', tick: 0, length: 240, note: 128 },
|
||||||
|
'junk',
|
||||||
|
]);
|
||||||
|
expect(held.map((n) => [n.tick, n.length, n.note, n.velocity])).toEqual([
|
||||||
|
[0, 240, 60, 0.5],
|
||||||
|
[0, 240, 67, 0.5],
|
||||||
|
[480, 1, 64, 1],
|
||||||
|
]);
|
||||||
|
expect(new Set(held.map((n) => n.id)).size).toBe(3);
|
||||||
|
expect(held[0]!.id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays nothing without an instrument; with one, the notes before its end, cut off there', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
f.set('bars', 1);
|
||||||
|
f.stack.execute(
|
||||||
|
new SetPatternNotesCommand(f.id, [
|
||||||
|
note('a', 0, 480),
|
||||||
|
note('b', 1800, 480, 62),
|
||||||
|
note('c', 1920, 240, 64),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(patternSequence(f.pattern()).notes).toEqual([]);
|
||||||
|
const ins = new CreateAssetCommand('instrument', 'Piano');
|
||||||
|
f.stack.execute(ins);
|
||||||
|
f.set('instrument', ins.asset.id);
|
||||||
|
expect(patternSequence(f.pattern()).notes).toEqual([
|
||||||
|
{ tick: 0, length: 480, note: 60, velocity: 0.8, instrument: ins.asset.id },
|
||||||
|
{ tick: 1800, length: 120, note: 62, velocity: 0.8, instrument: ins.asset.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the notes in one undo step, and none when they read the same', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
const notes = [note('a', 0, 120)];
|
||||||
|
f.stack.execute(new SetPatternNotesCommand(f.id, notes, 'Draw note'));
|
||||||
|
expect(f.pattern().notes).toEqual(notes);
|
||||||
|
expect(SetPatternNotesCommand.changes(f.pattern(), notes)).toBe(false);
|
||||||
|
expect(SetPatternNotesCommand.changes(f.pattern(), [note('a', 120, 120)])).toBe(true);
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.pattern().notes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loops the whole pattern until a loop is set, and a loop is cut at its end', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 7680 });
|
||||||
|
f.stack.execute(new SetPatternLoopCommand(f.id, { start: 1920, end: 9600 }));
|
||||||
|
expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 });
|
||||||
|
expect(patternLoop(f.pattern())).toEqual({ start: 1920, end: 7680 });
|
||||||
|
f.set('bars', 1);
|
||||||
|
expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 1920 });
|
||||||
|
f.stack.execute(new SetPatternLoopCommand(f.id, null));
|
||||||
|
expect(f.pattern().loop).toBeUndefined();
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a round trip with its notes and its loop', () => {
|
||||||
|
const f = makePattern();
|
||||||
|
f.stack.execute(
|
||||||
|
new SetPatternNotesCommand(f.id, [note('a', 0, 120, 36, 1), note('b', 240, 60, 38, 0.25)]),
|
||||||
|
);
|
||||||
|
f.stack.execute(new SetPatternLoopCommand(f.id, { start: 0, end: 960 }));
|
||||||
|
f.set('grid', '1/8T');
|
||||||
|
const back = roundTrip(f.project).assets.find((a) => a.id === f.id) as PatternAsset;
|
||||||
|
expect(back.notes).toEqual(f.pattern().notes);
|
||||||
|
expect(back.loop).toEqual({ start: 0, end: 960 });
|
||||||
|
expect(patternGrid(back.props)).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the names a drum kit gives its notes, for the keys of the roll', () => {
|
||||||
|
const graph = starterInstrumentGraph();
|
||||||
|
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes')!;
|
||||||
|
Object.assign(notes.params, { from: 36, to: 38, singleNotes: true, name36: 'Kick', name38: ' Snare ' });
|
||||||
|
expect([...namedNotes(graph)]).toEqual([
|
||||||
|
[36, 'Kick'],
|
||||||
|
[38, 'Snare'],
|
||||||
|
]);
|
||||||
|
notes.params.singleNotes = false;
|
||||||
|
expect(namedNotes(graph).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { PatternNote } from '../../src/core/music/pattern';
|
||||||
|
import {
|
||||||
|
barLabelStep,
|
||||||
|
clampTimePan,
|
||||||
|
fitTime,
|
||||||
|
timeLines,
|
||||||
|
xToTick,
|
||||||
|
zoomTime,
|
||||||
|
} from '../../src/editor/music/timeView';
|
||||||
|
import {
|
||||||
|
clampShift,
|
||||||
|
draggedLength,
|
||||||
|
drawnNote,
|
||||||
|
duplicateOffset,
|
||||||
|
moveDelta,
|
||||||
|
placedCopies,
|
||||||
|
resized,
|
||||||
|
shifted,
|
||||||
|
withVelocity,
|
||||||
|
} from '../../src/editor/pattern/rollEdit';
|
||||||
|
import {
|
||||||
|
centreRows,
|
||||||
|
clampRows,
|
||||||
|
noteAt,
|
||||||
|
noteAtY,
|
||||||
|
noteTop,
|
||||||
|
notesWithin,
|
||||||
|
type RollView,
|
||||||
|
ROWS,
|
||||||
|
velocityAtY,
|
||||||
|
velocityBarAt,
|
||||||
|
zoomRows,
|
||||||
|
} from '../../src/editor/pattern/rollView';
|
||||||
|
|
||||||
|
const note = (id: string, tick: number, length: number, n = 60, velocity = 0.8): PatternNote => ({
|
||||||
|
id,
|
||||||
|
tick,
|
||||||
|
length,
|
||||||
|
note: n,
|
||||||
|
velocity,
|
||||||
|
});
|
||||||
|
/** 0.1 px per tick: a 1/16 is 12 px, a bar of 4/4 192. Rows of 10 px, C4 (60) at the top. */
|
||||||
|
const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHeight: 10 };
|
||||||
|
const COMMON = { beats: 4, unit: 4 };
|
||||||
|
|
||||||
|
describe('time view of the piano roll (#333)', () => {
|
||||||
|
it('zooms about the pointer and never shows time before 0', () => {
|
||||||
|
const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300);
|
||||||
|
expect(xToTick(zoomed, 300)).toBeCloseTo(4000);
|
||||||
|
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0);
|
||||||
|
expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws bars, beats and the grid, each tick at its strongest, the grid only with room', () => {
|
||||||
|
const lines = timeLines(VIEW, COMMON, 120, 200);
|
||||||
|
expect(lines.filter((l) => l.level === 0).map((l) => l.tick)).toEqual([0, 1920]);
|
||||||
|
expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([480, 960, 1440]);
|
||||||
|
expect(lines.filter((l) => l.level === 2)).toHaveLength(12);
|
||||||
|
// At a twentieth of that a 1/16 is 0.6 px: bars only.
|
||||||
|
expect(timeLines({ panX: 0, pxPerTick: 0.005 }, COMMON, 120, 200).every((l) => l.level === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the beats of 6/8 between the lines of a triplet grid', () => {
|
||||||
|
const lines = timeLines({ panX: 0, pxPerTick: 0.1 }, { beats: 6, unit: 8 }, 160, 150);
|
||||||
|
expect(lines.filter((l) => l.level === 1).map((l) => l.tick)).toEqual([240, 480, 720, 960, 1200]);
|
||||||
|
expect(lines.filter((l) => l.level === 2).map((l) => l.tick)).toEqual([160, 320, 640, 800, 1120, 1280]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers every bar while there is room, else every second, fourth …', () => {
|
||||||
|
expect(barLabelStep({ panX: 0, pxPerTick: 0.1 }, COMMON)).toBe(1);
|
||||||
|
expect(barLabelStep({ panX: 0, pxPerTick: 0.01 }, COMMON)).toBe(2);
|
||||||
|
expect(barLabelStep({ panX: 0, pxPerTick: 0.004 }, COMMON)).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rows of the piano roll (#333)', () => {
|
||||||
|
it('puts the highest note on top and finds the note under a point', () => {
|
||||||
|
expect(noteTop(VIEW, 60)).toBe(0);
|
||||||
|
expect(noteTop(VIEW, 59)).toBe(10);
|
||||||
|
expect(noteAtY(VIEW, 5)).toBe(60);
|
||||||
|
expect(noteAtY(VIEW, 15)).toBe(59);
|
||||||
|
expect(noteAtY({ ...VIEW, panY: 0 }, -1)).toBeNull();
|
||||||
|
expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the rows on screen and zooms them about the pointer', () => {
|
||||||
|
expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0);
|
||||||
|
expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - ROWS * 10);
|
||||||
|
const zoomed = zoomRows(VIEW, 2, 25);
|
||||||
|
expect(zoomed.rowHeight).toBe(20);
|
||||||
|
expect(noteAtY(zoomed, 25)).toBe(58);
|
||||||
|
expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres the notes it is fitted to, lower rows where they do not fit', () => {
|
||||||
|
const fitted = centreRows(60, 64, 200);
|
||||||
|
expect(fitted.rowHeight).toBe(14);
|
||||||
|
const view = { ...VIEW, ...fitted };
|
||||||
|
expect(noteAtY(view, 100)).toBe(62);
|
||||||
|
expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27);
|
||||||
|
expect(centreRows(0, 127, 200).rowHeight).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a note body from its right end, and takes the one drawn last', () => {
|
||||||
|
const notes = [note('a', 0, 480), note('b', 240, 480)];
|
||||||
|
expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' });
|
||||||
|
expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' });
|
||||||
|
expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' });
|
||||||
|
expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull();
|
||||||
|
// A note of a tick is still 4 px to the pointer.
|
||||||
|
expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the notes a band touches', () => {
|
||||||
|
const notes = [note('a', 0, 120, 60), note('b', 480, 120, 59), note('c', 960, 120, 55)];
|
||||||
|
expect(notesWithin(VIEW, notes, { x: 5, y: 2 }, { x: 50, y: 18 })).toEqual(['a', 'b']);
|
||||||
|
expect(notesWithin(VIEW, notes, { x: 50, y: 60 }, { x: 0, y: 0 })).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the velocity bar under the pointer, a picked one first', () => {
|
||||||
|
const notes = [note('a', 0, 120), note('b', 0, 120, 64), note('c', 480, 120)];
|
||||||
|
expect(velocityBarAt(VIEW, notes, new Set(), 2)).toBe('b');
|
||||||
|
expect(velocityBarAt(VIEW, notes, new Set(['a']), 2)).toBe('a');
|
||||||
|
expect(velocityBarAt(VIEW, notes, new Set(), 30)).toBeNull();
|
||||||
|
expect(velocityAtY(10, 10, 64)).toBe(1);
|
||||||
|
expect(velocityAtY(42, 10, 64)).toBe(0.5);
|
||||||
|
expect(velocityAtY(100, 10, 64)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editing in the piano roll (#333)', () => {
|
||||||
|
it('draws on the grid line before the pointer, or on the tick with Alt', () => {
|
||||||
|
expect(drawnNote(250, 62, 120, false, 120, 0.8)).toMatchObject({
|
||||||
|
tick: 240,
|
||||||
|
length: 120,
|
||||||
|
note: 62,
|
||||||
|
velocity: 0.8,
|
||||||
|
});
|
||||||
|
expect(drawnNote(250.4, 62, 120, true, 960, 0.5)).toMatchObject({
|
||||||
|
tick: 250,
|
||||||
|
length: 960,
|
||||||
|
velocity: 0.5,
|
||||||
|
});
|
||||||
|
expect(drawnNote(250, 62, 120, false, 120, 1).id).not.toBe(drawnNote(250, 62, 120, false, 120, 1).id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the grabbed note onto the grid, the others along, never before 0 or off the keys', () => {
|
||||||
|
const notes = [note('a', 240, 120, 60), note('b', 480, 120, 120), note('c', 0, 120, 30)];
|
||||||
|
const picked = new Set(['a', 'b']);
|
||||||
|
expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, false)).toEqual({ ticks: 120, notes: 2 });
|
||||||
|
expect(moveDelta(notes, picked, notes[0]!, 370, 62, 120, true)).toEqual({ ticks: 130, notes: 2 });
|
||||||
|
expect(moveDelta(notes, picked, notes[0]!, -500, 80, 120, false)).toEqual({ ticks: -240, notes: 7 });
|
||||||
|
expect(clampShift(notes, new Set(['c']), -10, -40)).toEqual({ ticks: 0, notes: -30 });
|
||||||
|
expect(shifted(notes, picked, 120, 2).map((n) => [n.tick, n.note])).toEqual([
|
||||||
|
[360, 62],
|
||||||
|
[600, 122],
|
||||||
|
[0, 30],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drags a length to the nearest grid line, never back to its own start', () => {
|
||||||
|
const anchor = note('a', 240, 120);
|
||||||
|
expect(draggedLength(anchor, 470, 120, false)).toBe(240);
|
||||||
|
expect(draggedLength(anchor, 250, 120, false)).toBe(120);
|
||||||
|
expect(draggedLength(note('o', 250, 60), 255, 120, false)).toBe(110);
|
||||||
|
expect(draggedLength(anchor, 241.2, 120, true)).toBe(1);
|
||||||
|
expect(resized([anchor, note('b', 0, 60)], new Set(['a', 'b']), -100).map((n) => n.length)).toEqual([
|
||||||
|
20, 1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a duplicate right behind the picked notes, in whole grid steps', () => {
|
||||||
|
const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)];
|
||||||
|
expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360);
|
||||||
|
expect(duplicateOffset(notes, new Set(['b']), 120)).toBe(120);
|
||||||
|
expect(duplicateOffset(notes, new Set(), 120)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pastes copies with ids of their own, the earliest at the start', () => {
|
||||||
|
const copies = placedCopies([note('a', 480, 120, 60), note('b', 720, 60, 64)], 1920);
|
||||||
|
expect(copies.map((n) => [n.tick, n.note])).toEqual([
|
||||||
|
[1920, 60],
|
||||||
|
[2160, 64],
|
||||||
|
]);
|
||||||
|
expect(copies.map((n) => n.id)).not.toContain('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the grabbed velocity and moves the other picked ones by as much', () => {
|
||||||
|
const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)];
|
||||||
|
expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([
|
||||||
|
0.7, 1, 0.2,
|
||||||
|
]);
|
||||||
|
// Unpicked, the grabbed note goes alone.
|
||||||
|
expect(withVelocity(notes, new Set(['b']), notes[0]!, 0.1).map((n) => n.velocity)).toEqual([
|
||||||
|
0.1, 0.9, 0.2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { colourDistance } from '../lib/harness.mjs';
|
|||||||
import { inView } from './lib/scaffold.mjs';
|
import { inView } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
||||||
const ASSET_TYPE_COUNT = 28;
|
const ASSET_TYPE_COUNT = 29;
|
||||||
|
|
||||||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||||||
export async function assets(t) {
|
export async function assets(t) {
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { edge, node } from '../lib/builders.mjs';
|
||||||
|
import { pickAssetType } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its
|
||||||
|
* keys sound where the instrument plays. A click draws a note on the grid, its right end lengthens
|
||||||
|
* it, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind
|
||||||
|
* them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the
|
||||||
|
* start set on the ruler, the lane sets a velocity, a drag on the ruler sets the loop. Space plays the
|
||||||
|
* pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a
|
||||||
|
* restart. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and
|
||||||
|
* the sound counters, as in e2e 34.
|
||||||
|
*
|
||||||
|
* Everything it adds carries a `pt64` id or is the pattern it made, and goes again at the end.
|
||||||
|
*/
|
||||||
|
const BLIP_BYTES = 4044;
|
||||||
|
const INSTRUMENT = 'pt64-ins';
|
||||||
|
|
||||||
|
export async function pattern(t) {
|
||||||
|
const { check, sleep, json, evaluate, mutate, click, clickText, press, drag, wheel, shot, showTab } = t;
|
||||||
|
const stats = () => json('__gm.sound.stats()');
|
||||||
|
const waitFor = async (read, done, tries = 25) => {
|
||||||
|
let value = await read();
|
||||||
|
for (let i = 0; i < tries && !done(value); i++) {
|
||||||
|
await sleep(150);
|
||||||
|
value = await read();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
await press('Escape');
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
await showTab('scene');
|
||||||
|
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
|
||||||
|
const blip = stored.find((item) => item.bytes === BLIP_BYTES);
|
||||||
|
await mutate(`
|
||||||
|
__gm.state.project.assets.push(
|
||||||
|
{ id: 'pt64-snd', type: 'sound', name: 'Blip 64', props: {}, file: ${JSON.stringify(blip?.file ?? '')} },
|
||||||
|
{
|
||||||
|
id: ${JSON.stringify(INSTRUMENT)},
|
||||||
|
type: 'instrument',
|
||||||
|
name: 'Lead 64',
|
||||||
|
props: {},
|
||||||
|
graph: {
|
||||||
|
nodes: ${JSON.stringify([
|
||||||
|
node('pt64-notes', 'InstrumentNotes', 0, 0, { from: 60, to: 71 }),
|
||||||
|
node('pt64-sample', 'InstrumentSample', 200, 0, { sound: 'pt64-snd' }),
|
||||||
|
node('pt64-out', 'InstrumentOutput', 420, 0),
|
||||||
|
])},
|
||||||
|
edges: ${JSON.stringify([
|
||||||
|
edge('pt64-e1', 'pt64-notes', 'any', 'pt64-sample', 'trigger'),
|
||||||
|
edge('pt64-e2', 'pt64-sample', 'out', 'pt64-out', 'in'),
|
||||||
|
])},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);`);
|
||||||
|
await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`);
|
||||||
|
|
||||||
|
// 64a. "+ → Pattern" makes an empty pattern and opens its tab.
|
||||||
|
await clickText('+');
|
||||||
|
await pickAssetType(t, 'Pattern');
|
||||||
|
const created = await json(`{
|
||||||
|
tab: __gm.tab(),
|
||||||
|
current: __gm.pattern.current(),
|
||||||
|
info: __gm.pattern.info(),
|
||||||
|
patterns: __gm.state.project.assets.filter((a) => a.type === 'pattern').map((a) => ({ id: a.id, notes: a.notes.length })),
|
||||||
|
}`);
|
||||||
|
check(
|
||||||
|
'the + menu creates an empty pattern and opens its tab, which has no instrument yet',
|
||||||
|
created.tab === 'pattern' &&
|
||||||
|
created.patterns.length === 1 &&
|
||||||
|
created.current === created.patterns[0].id &&
|
||||||
|
created.patterns[0].notes === 0 &&
|
||||||
|
created.info === '0 notes · no instrument',
|
||||||
|
JSON.stringify(created),
|
||||||
|
);
|
||||||
|
const id = created.patterns[0]?.id;
|
||||||
|
const notes = () => json(`__gm.pattern.notes(${JSON.stringify(id)})`);
|
||||||
|
await mutate(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).props.instrument = ${JSON.stringify(INSTRUMENT)};`,
|
||||||
|
);
|
||||||
|
const keyed = await json('{ info: __gm.pattern.info(), keys: __gm.pattern.keys() }');
|
||||||
|
const sounds = (n) => keyed.keys.find((k) => k.note === n)?.sounds;
|
||||||
|
check(
|
||||||
|
'with an instrument its keys sound from C4 to B4 and the others are grey',
|
||||||
|
keyed.info === '0 notes · Lead 64' &&
|
||||||
|
sounds(60) &&
|
||||||
|
sounds(71) &&
|
||||||
|
sounds(72) === false &&
|
||||||
|
sounds(59) === false,
|
||||||
|
JSON.stringify(keyed),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64b. Zoomed in on the ruler, a click draws a 1/16 on the grid line before it, and sounds.
|
||||||
|
const before = await json('__gm.pattern.view()');
|
||||||
|
const r0 = await json('__gm.pattern.rulerAt(0)');
|
||||||
|
await wheel(r0.x, r0.y, -600);
|
||||||
|
const zoomed = await json('__gm.pattern.view()');
|
||||||
|
check(
|
||||||
|
'the wheel on the ruler zooms the time about the pointer',
|
||||||
|
zoomed.pxPerTick > before.pxPerTick * 2 && Math.abs(zoomed.panX) < 1.5,
|
||||||
|
JSON.stringify({ before, zoomed }),
|
||||||
|
);
|
||||||
|
const heard = await stats();
|
||||||
|
let p = await json('__gm.pattern.gridAt(250, 62)');
|
||||||
|
await click(p.x, p.y);
|
||||||
|
const drawn = await notes();
|
||||||
|
const drawnSound = await waitFor(stats, (s) => s.started > heard.started);
|
||||||
|
check(
|
||||||
|
'a click draws a 1/16 on the grid line before the pointer, picks it and lets it sound',
|
||||||
|
drawn.length === 1 &&
|
||||||
|
drawn[0].tick === 240 &&
|
||||||
|
drawn[0].length === 120 &&
|
||||||
|
drawn[0].note === 62 &&
|
||||||
|
drawn[0].velocity === 0.8 &&
|
||||||
|
(await json('__gm.pattern.picked()')).join() === drawn[0].id &&
|
||||||
|
drawnSound.started > heard.started,
|
||||||
|
JSON.stringify({ drawn, drawnSound, heard }),
|
||||||
|
);
|
||||||
|
const first = drawn[0].id;
|
||||||
|
|
||||||
|
// 64c. Its right end takes it to the grid line nearest the pointer; the next note drawn is as long.
|
||||||
|
const end = await json('__gm.pattern.gridAt(360, 62)');
|
||||||
|
p = await json('__gm.pattern.gridAt(720, 62)');
|
||||||
|
await drag(end.x - 2, end.y, p.x, p.y);
|
||||||
|
await sleep(500);
|
||||||
|
p = await json('__gm.pattern.gridAt(1000, 64)');
|
||||||
|
await click(p.x, p.y);
|
||||||
|
const lengths = await notes();
|
||||||
|
check(
|
||||||
|
'dragging the end lengthens it to 1/4, and the next note drawn takes that length',
|
||||||
|
lengths.length === 2 &&
|
||||||
|
lengths.find((n) => n.id === first)?.length === 480 &&
|
||||||
|
lengths.find((n) => n.id !== first)?.tick === 960 &&
|
||||||
|
lengths.find((n) => n.id !== first)?.length === 480,
|
||||||
|
JSON.stringify(lengths),
|
||||||
|
);
|
||||||
|
const second = lengths.find((n) => n.id !== first)?.id;
|
||||||
|
|
||||||
|
// 64d. Its body moves it onto the grid and to another key; with Alt it lands on the tick.
|
||||||
|
await sleep(500);
|
||||||
|
p = await json('__gm.pattern.gridAt(300, 62)');
|
||||||
|
let to = await json('__gm.pattern.gridAt(780, 60)');
|
||||||
|
await drag(p.x, p.y, to.x, to.y);
|
||||||
|
const moved = (await notes()).find((n) => n.id === first);
|
||||||
|
check(
|
||||||
|
'dragging a note moves it to the grid line and the key under the pointer',
|
||||||
|
moved?.tick === 720 && moved?.note === 60,
|
||||||
|
JSON.stringify(moved),
|
||||||
|
);
|
||||||
|
await sleep(500);
|
||||||
|
p = await json('__gm.pattern.gridAt(800, 60)');
|
||||||
|
to = await json('__gm.pattern.gridAt(850, 60)');
|
||||||
|
await drag(p.x, p.y, to.x, to.y, { modifiers: { alt: true } });
|
||||||
|
const free = (await notes()).find((n) => n.id === first);
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const back = (await notes()).find((n) => n.id === first);
|
||||||
|
check(
|
||||||
|
'with Alt held it moves off the grid, to the tick, and Ctrl+Z takes it back',
|
||||||
|
free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720,
|
||||||
|
JSON.stringify({ free, back }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64e. A band picks both; Ctrl+D puts a copy right behind them, picked; the arrows transpose it.
|
||||||
|
p = await json('__gm.pattern.gridAt(100, 67)');
|
||||||
|
to = await json('__gm.pattern.gridAt(1600, 58)');
|
||||||
|
await drag(p.x, p.y, to.x, to.y);
|
||||||
|
const banded = await json('__gm.pattern.picked()');
|
||||||
|
await press('d', { ctrl: true });
|
||||||
|
const doubled = await notes();
|
||||||
|
const copies = await json('__gm.pattern.picked()');
|
||||||
|
const copyOf = (n) => doubled.find((c) => copies.includes(c.id) && c.note === n);
|
||||||
|
check(
|
||||||
|
'a band picks the notes it touches, and Ctrl+D copies them right behind, picking the copies',
|
||||||
|
banded.length === 2 &&
|
||||||
|
banded.includes(first) &&
|
||||||
|
banded.includes(second) &&
|
||||||
|
doubled.length === 4 &&
|
||||||
|
copies.length === 2 &&
|
||||||
|
!copies.includes(first) &&
|
||||||
|
copyOf(60)?.tick === 1440 &&
|
||||||
|
copyOf(64)?.tick === 1680,
|
||||||
|
JSON.stringify({ banded, doubled, copies }),
|
||||||
|
);
|
||||||
|
await press('ArrowUp');
|
||||||
|
const up = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note);
|
||||||
|
await press('ArrowUp', { shift: true });
|
||||||
|
const octave = (await notes()).filter((n) => copies.includes(n.id)).map((n) => n.note);
|
||||||
|
await press('ArrowDown', { shift: true });
|
||||||
|
check(
|
||||||
|
'an arrow transposes the picked notes a semitone, with Shift an octave',
|
||||||
|
up.join() === '61,65' && octave.join() === '73,77',
|
||||||
|
JSON.stringify({ up, octave }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64f. Delete takes the picked notes, Ctrl+Z brings them back.
|
||||||
|
await press('Delete');
|
||||||
|
const deleted = await notes();
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const restored = await notes();
|
||||||
|
check(
|
||||||
|
'Delete takes the picked notes away and Ctrl+Z brings them back',
|
||||||
|
deleted.length === 2 && restored.length === 4,
|
||||||
|
JSON.stringify({ deleted: deleted.length, restored: restored.length }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64g. Ctrl+A, Ctrl+C; a click on the ruler at bar 2 sets the start; Ctrl+V lands there.
|
||||||
|
await press('a', { ctrl: true });
|
||||||
|
await press('c', { ctrl: true });
|
||||||
|
const bar2 = await json('__gm.pattern.rulerAt(1920)');
|
||||||
|
await click(bar2.x + 3, bar2.y);
|
||||||
|
const startSet = (await json('__gm.pattern.view()')).start;
|
||||||
|
await press('v', { ctrl: true });
|
||||||
|
const pasted = await notes();
|
||||||
|
const pastedIds = await json('__gm.pattern.picked()');
|
||||||
|
const pastedTicks = pasted.filter((n) => pastedIds.includes(n.id)).map((n) => n.tick);
|
||||||
|
check(
|
||||||
|
'a click on the ruler sets where playing starts, and Ctrl+V pastes the copied notes there',
|
||||||
|
startSet === 1920 && pasted.length === 8 && pastedIds.length === 4 && Math.min(...pastedTicks) === 1920,
|
||||||
|
JSON.stringify({ startSet, count: pasted.length, pastedTicks }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64h. A right-click takes a note away; the lane sets the velocity of the one it grabs.
|
||||||
|
p = await json('__gm.pattern.gridAt(1980, 60)');
|
||||||
|
await click(p.x, p.y, 'right');
|
||||||
|
const erased = await notes();
|
||||||
|
const lane = await json('__gm.pattern.laneAt(720, 0.8)');
|
||||||
|
const low = await json('__gm.pattern.laneAt(720, 0.25)');
|
||||||
|
await drag(lane.x + 1, lane.y + 2, low.x + 1, low.y);
|
||||||
|
const soft = await notes();
|
||||||
|
check(
|
||||||
|
'a right-click deletes the note under it, and dragging its bar in the lane sets its velocity alone',
|
||||||
|
erased.length === 7 &&
|
||||||
|
Math.abs(soft.find((n) => n.id === first).velocity - 0.25) < 0.04 &&
|
||||||
|
soft.filter((n) => n.id !== first).every((n) => n.velocity === 0.8),
|
||||||
|
JSON.stringify({ count: erased.length, velocities: soft.map((n) => n.velocity) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64i. A drag on the ruler sets the loop; its menu takes it back to the whole pattern.
|
||||||
|
p = await json('__gm.pattern.rulerAt(1920)');
|
||||||
|
to = await json('__gm.pattern.rulerAt(2880)');
|
||||||
|
await drag(p.x, p.y, to.x, to.y);
|
||||||
|
const looped = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||||||
|
await click(p.x + 20, p.y, 'right');
|
||||||
|
await clickText('Loop the whole pattern');
|
||||||
|
const whole = await json(
|
||||||
|
`{ loop: __gm.pattern.loop(${JSON.stringify(id)}), view: __gm.pattern.view().loop }`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'a drag on the ruler sets the loop on the grid, and "Loop the whole pattern" takes it away',
|
||||||
|
looped?.start === 1920 &&
|
||||||
|
looped?.end === 2880 &&
|
||||||
|
whole.loop === null &&
|
||||||
|
whole.view.start === 0 &&
|
||||||
|
whole.view.end === 7680,
|
||||||
|
JSON.stringify({ looped, whole }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64j. Space plays bar 1 round its loop from the start, each note at its tick on the audio clock;
|
||||||
|
// a note drawn while it plays is planned in the next round, without starting over.
|
||||||
|
p = await json('__gm.pattern.rulerAt(1920)');
|
||||||
|
to = await json('__gm.pattern.rulerAt(0)');
|
||||||
|
await drag(p.x, p.y, to.x, to.y);
|
||||||
|
const bar1 = await json(`__gm.pattern.loop(${JSON.stringify(id)})`);
|
||||||
|
// At the loop's start, but a click: it sets where playing starts and leaves the loop as it is.
|
||||||
|
await click(to.x + 2, to.y);
|
||||||
|
await press('Space');
|
||||||
|
const planned = await waitFor(
|
||||||
|
() => json('__gm.pattern.planned()'),
|
||||||
|
(list) => list.length >= 4,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
const playing = await json('{ playing: __gm.pattern.playing(), button: __gm.pattern.playAt().text }');
|
||||||
|
const at = (tick) => planned.find((n) => Math.abs(n.at - planned[0].at - (tick - 720) / 960) < 1e-6);
|
||||||
|
check(
|
||||||
|
'Space plays the loop of bar 1 from its start: every note planned at its tick on the audio clock',
|
||||||
|
bar1?.start === 0 &&
|
||||||
|
bar1?.end === 1920 &&
|
||||||
|
playing.playing &&
|
||||||
|
playing.button === 'Stop' &&
|
||||||
|
planned[0].note === 60 &&
|
||||||
|
planned[0].instrument === INSTRUMENT &&
|
||||||
|
at(960)?.note === 64 &&
|
||||||
|
at(1440)?.note === 61 &&
|
||||||
|
at(1680)?.note === 65,
|
||||||
|
JSON.stringify({
|
||||||
|
bar1,
|
||||||
|
playing,
|
||||||
|
planned: planned.map((n) => [n.note, +(n.at - planned[0].at).toFixed(4)]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const head = await json('__gm.pattern.position()');
|
||||||
|
p = await json('__gm.pattern.gridAt(1800, 69)');
|
||||||
|
await click(p.x, p.y);
|
||||||
|
const live = await waitFor(
|
||||||
|
() => json('__gm.pattern.planned()'),
|
||||||
|
(list) => list.some((n) => n.note === 69),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'a note drawn while it plays is heard in the next round, the playing goes on',
|
||||||
|
live.some((n) => n.note === 69) && live.length > planned.length && (await json('__gm.pattern.playing()')),
|
||||||
|
JSON.stringify({ head, live: live.map((n) => n.note) }),
|
||||||
|
);
|
||||||
|
await shot('64-pattern');
|
||||||
|
await press('Space');
|
||||||
|
const stopped = await waitFor(
|
||||||
|
async () => ({
|
||||||
|
playing: await json('__gm.pattern.playing()'),
|
||||||
|
stats: await stats(),
|
||||||
|
view: await json('__gm.pattern.view()'),
|
||||||
|
}),
|
||||||
|
(s) => s.stats.playing === 0,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Space again stops it, its voices fade and the playhead goes',
|
||||||
|
!stopped.playing &&
|
||||||
|
stopped.stats.playing === 0 &&
|
||||||
|
stopped.view.playhead === null &&
|
||||||
|
(await json('__gm.pattern.playAt().text')) === 'Play',
|
||||||
|
JSON.stringify(stopped),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64k. A key down the left sounds while it is held.
|
||||||
|
const keyBefore = await stats();
|
||||||
|
const key = await json('__gm.pattern.keyAt(67)');
|
||||||
|
await t.mouse('mouseMoved', key.x, key.y);
|
||||||
|
await t.mouse('mousePressed', key.x, key.y);
|
||||||
|
const held = await waitFor(stats, (s) => s.started > keyBefore.started);
|
||||||
|
const heldKey = (await json('__gm.pattern.view()')).keys;
|
||||||
|
await t.mouse('mouseReleased', key.x, key.y);
|
||||||
|
check(
|
||||||
|
'a key of the roll sounds while it is held and is lit',
|
||||||
|
held.started > keyBefore.started && heldKey === 67 && (await json('__gm.pattern.view()')).keys === null,
|
||||||
|
JSON.stringify({ held, heldKey }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 64l. The step takes back everything it made.
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
await mutate(`
|
||||||
|
const gone = new Set(['pt64-snd', ${JSON.stringify(INSTRUMENT)}, ${JSON.stringify(id)}]);
|
||||||
|
const project = __gm.state.project;
|
||||||
|
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||||||
|
__gm.state.select(null);`);
|
||||||
|
await showTab('scene');
|
||||||
|
check(
|
||||||
|
'the step leaves nothing of its own behind',
|
||||||
|
(await json(
|
||||||
|
`__gm.state.project.assets.filter((a) => a.id.startsWith('pt64') || a.type === 'pattern').length`,
|
||||||
|
)) === 0,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ import { release } from './60-release.mjs';
|
|||||||
import { fullscreen } from './61-fullscreen.mjs';
|
import { fullscreen } from './61-fullscreen.mjs';
|
||||||
import { devicePixels } from './62-device-pixels.mjs';
|
import { devicePixels } from './62-device-pixels.mjs';
|
||||||
import { renderLayers } from './63-render-layers.mjs';
|
import { renderLayers } from './63-render-layers.mjs';
|
||||||
|
import { pattern } from './64-pattern.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -131,4 +132,5 @@ export const STEPS = [
|
|||||||
fullscreen,
|
fullscreen,
|
||||||
devicePixels,
|
devicePixels,
|
||||||
renderLayers,
|
renderLayers,
|
||||||
|
pattern,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user