P2a: eight-bar arrangement with four editable sections
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"project-smoke": "node scripts/project-smoke.mjs",
|
||||
"record-smoke": "node scripts/record-smoke.mjs",
|
||||
"faces-smoke": "node scripts/faces-smoke.mjs",
|
||||
"arrangement-smoke": "node scripts/arrangement-smoke.mjs",
|
||||
"restored-smoke": "node scripts/restored-smoke.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { chromium } from 'playwright-core';
|
||||
const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome';
|
||||
const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });
|
||||
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(2200);
|
||||
await page.locator('.room', { hasText: 'quarry' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
const sectionCount = await page.locator('.section').count();
|
||||
const notesBefore = await page.locator('.section-notes').allTextContents();
|
||||
await page.locator('.section').nth(1).click();
|
||||
await page.waitForTimeout(350);
|
||||
const active = await page.locator('.section.is-on .section-letter').textContent();
|
||||
await page.locator('.pattern .step').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
const notesAfter = await page.locator('.section-notes').allTextContents();
|
||||
await page.locator('.room', { hasText: 'nave' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click();
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: '../preview-p2-arrangement.png' });
|
||||
const report = await page.evaluate(() => ({
|
||||
sections: document.querySelectorAll('.section').length,
|
||||
active: document.querySelector('.section.is-on .section-letter')?.textContent,
|
||||
notes: [...document.querySelectorAll('.section-notes')].map((el) => el.textContent?.trim()),
|
||||
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-6),
|
||||
bar: document.querySelector('.clock-bar')?.textContent ?? document.querySelector('.transport')?.textContent?.slice(0, 40),
|
||||
master: document.querySelector('.master-db')?.textContent
|
||||
}));
|
||||
console.log(JSON.stringify({ sectionCount, notesBefore, active, notesAfter, report, errors }, null, 2));
|
||||
const failures = [];
|
||||
if (sectionCount !== 4) failures.push('expected four sections');
|
||||
if (active !== 'B') failures.push('section B was not selected');
|
||||
if (notesAfter[0] === notesBefore[0] && notesAfter[1] === notesBefore[1]) failures.push('section notes did not change');
|
||||
if (!report.wire.some((label) => label?.startsWith('step'))) failures.push('no step command on the wire');
|
||||
if (errors.length) failures.push('console errors');
|
||||
await browser.close();
|
||||
if (failures.length) { console.error('ARRANGEMENT SMOKE FAILED: ' + failures.join('; ')); process.exit(1); }
|
||||
console.log('arrangement smoke passed');
|
||||
+22
-1
@@ -73,7 +73,7 @@
|
||||
}
|
||||
if (!snapshotLogged) {
|
||||
snapshotLogged = true;
|
||||
addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
|
||||
addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.arrangement.length} sections · ${cores.length} cores · rev ${next.revision}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
} else if (event.kind === 'core_faces') {
|
||||
const index = project.cores.findIndex((core) => core.id === event.core.id);
|
||||
if (index >= 0) project.cores[index] = event.core;
|
||||
} else if (event.kind === 'arrangement') {
|
||||
project.arrangement = event.sections;
|
||||
project.active_section = event.active;
|
||||
project.pattern = event.sections[event.active] ?? project.pattern;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +204,18 @@
|
||||
void run({ type: 'set_core_faces', core, boundaries });
|
||||
}
|
||||
|
||||
function selectSection(index: number) {
|
||||
void run({ type: 'select_section', index });
|
||||
}
|
||||
|
||||
function clearSection(index: number) {
|
||||
void run({ type: 'clear_section', index });
|
||||
}
|
||||
|
||||
function copySection(from: number, to: number) {
|
||||
void run({ type: 'copy_section', from, to });
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (project?.can_undo) void run({ type: 'undo' });
|
||||
}
|
||||
@@ -334,6 +350,11 @@
|
||||
onstrikeface={strikeFace}
|
||||
onstep={toggleStep}
|
||||
onfaces={setCoreFaces}
|
||||
sections={project?.arrangement ?? []}
|
||||
activeSection={project?.active_section ?? 0}
|
||||
onselectsection={selectSection}
|
||||
onclearsection={clearSection}
|
||||
oncopysection={copySection}
|
||||
/>
|
||||
{/if}
|
||||
<Wire {entries} {status} onreconnect={reconnect} />
|
||||
|
||||
@@ -1486,6 +1486,74 @@ button.room {
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 0 7px;
|
||||
border-top: 1px solid rgba(155, 133, 121, 0.12);
|
||||
}
|
||||
|
||||
.sections-label {
|
||||
margin-right: auto;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 7px 4px;
|
||||
border: 1px solid rgba(155, 133, 121, 0.18);
|
||||
background: transparent;
|
||||
color: var(--ash-2);
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.section:hover,
|
||||
.section.is-on {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.section.is-on {
|
||||
border-color: var(--ember);
|
||||
background: rgba(232, 64, 42, 0.05);
|
||||
}
|
||||
|
||||
.section-letter {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-notes {
|
||||
font-size: 8px;
|
||||
color: var(--ash-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid rgba(155, 133, 121, 0.2);
|
||||
font-size: 8px;
|
||||
color: var(--ash);
|
||||
}
|
||||
|
||||
.section-action:hover:not(:disabled) {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.section-action:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pattern-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1497,6 +1565,12 @@ button.room {
|
||||
color: var(--ash-2);
|
||||
}
|
||||
|
||||
.sections + .pattern-head {
|
||||
margin-top: 0;
|
||||
padding-top: 2px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
display: grid;
|
||||
grid-template-columns: 94px minmax(0, 1fr);
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
onassign,
|
||||
onstrikeface,
|
||||
onstep,
|
||||
onfaces
|
||||
onfaces,
|
||||
sections,
|
||||
activeSection,
|
||||
onselectsection,
|
||||
onclearsection,
|
||||
oncopysection
|
||||
}: {
|
||||
cores: CoreSummary[];
|
||||
pattern: Pattern | null;
|
||||
@@ -35,6 +40,11 @@
|
||||
onstrikeface: (core: string, face: number) => void;
|
||||
onstep: (row: number, step: number, on: boolean) => void;
|
||||
onfaces: (core: string, boundaries: number[]) => void;
|
||||
sections: Pattern[];
|
||||
activeSection: number;
|
||||
onselectsection: (index: number) => void;
|
||||
onclearsection: (index: number) => void;
|
||||
oncopysection: (from: number, to: number) => void;
|
||||
} = $props();
|
||||
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
@@ -412,8 +422,36 @@
|
||||
<span class="assign-note">select a face, then lay it on the chosen vein</span>
|
||||
</div>
|
||||
|
||||
<div class="sections" role="group" aria-label="arrangement sections">
|
||||
<span class="sections-label">arrangement · 8 bars</span>
|
||||
{#each sections as section, index}
|
||||
<button
|
||||
class="section"
|
||||
class:is-on={index === activeSection}
|
||||
onclick={() => onselectsection(index)}
|
||||
aria-pressed={index === activeSection}
|
||||
title={`section ${String.fromCharCode(65 + index)}`}
|
||||
>
|
||||
<span class="section-letter">{String.fromCharCode(65 + index)}</span>
|
||||
<span class="section-notes">
|
||||
{section.rows.reduce(
|
||||
(total, row) => total + row.velocities.filter((value) => value > 0).length,
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class="section-action"
|
||||
onclick={() => oncopysection(activeSection, (activeSection + 1) % sections.length)}
|
||||
disabled={sections.length < 2}
|
||||
>copy →</button>
|
||||
<button class="section-action" onclick={() => onclearsection(activeSection)}
|
||||
>clear</button
|
||||
>
|
||||
</div>
|
||||
<div class="pattern-head">
|
||||
<span>pattern</span>
|
||||
<span>pattern · section {String.fromCharCode(65 + activeSection)}</span>
|
||||
<span>{pattern?.steps ?? 16} steps · two bars</span>
|
||||
</div>
|
||||
<div class="pattern">
|
||||
|
||||
+62
-38
@@ -34,6 +34,7 @@ export class Descent {
|
||||
private phaseStart = 0;
|
||||
private wasPlaying = false;
|
||||
private displayStep = 0;
|
||||
private arrangementSteps = LOOP_STEPS;
|
||||
private bpm = 128;
|
||||
private raf = 0;
|
||||
private last = 0;
|
||||
@@ -74,6 +75,7 @@ export class Descent {
|
||||
this.bpm = state.transport.bpm;
|
||||
if (!state.transport.playing) this.wasPlaying = false;
|
||||
}
|
||||
this.arrangementSteps = Math.max(1, state?.arrangement?.length ?? 4) * LOOP_STEPS;
|
||||
this.meter = meter;
|
||||
this.selected = selected;
|
||||
}
|
||||
@@ -338,12 +340,13 @@ export class Descent {
|
||||
this.phaseStart = now;
|
||||
this.wasPlaying = true;
|
||||
}
|
||||
let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS;
|
||||
const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % LOOP_STEPS + LOOP_STEPS) % LOOP_STEPS;
|
||||
let diff = (step - meterStep + LOOP_STEPS * 1.5) % LOOP_STEPS - LOOP_STEPS / 2;
|
||||
const total = this.arrangementSteps;
|
||||
let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % total;
|
||||
const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % total + total) % total;
|
||||
let diff = (step - meterStep + total * 1.5) % total - total / 2;
|
||||
if (Math.abs(diff) > 2.4) {
|
||||
this.phaseStart += (diff / stepsPerSecond) * 1000;
|
||||
step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS;
|
||||
step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % total;
|
||||
}
|
||||
this.displayStep = step;
|
||||
} else {
|
||||
@@ -450,7 +453,7 @@ export class Descent {
|
||||
const { ctx, w } = this;
|
||||
const { cx, bottom, horizon, bhw, thw } = this.road;
|
||||
const levels = this.smooth.veins;
|
||||
const active = Math.floor(this.displayStep) % LOOP_STEPS;
|
||||
const active = Math.floor(this.displayStep) % this.arrangementSteps;
|
||||
|
||||
const roadFill = ctx.createLinearGradient(0, bottom, 0, horizon);
|
||||
roadFill.addColorStop(0, 'rgba(24,10,7,0.66)');
|
||||
@@ -484,13 +487,16 @@ export class Descent {
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
for (let step = 0; step <= LOOP_STEPS; step += 1) {
|
||||
const point = this.roadPoint(step / LOOP_STEPS);
|
||||
for (let step = 0; step <= this.arrangementSteps; step += 1) {
|
||||
const point = this.roadPoint(step / this.arrangementSteps);
|
||||
const sectionLine = step % LOOP_STEPS === 0;
|
||||
const barLine = step % STEPS_PER_BAR === 0;
|
||||
ctx.strokeStyle = barLine
|
||||
? `rgba(190,160,140,${0.18 + master * 0.12})`
|
||||
: `rgba(155,133,121,${0.07 + master * 0.05})`;
|
||||
ctx.lineWidth = barLine ? 1 : 0.65;
|
||||
ctx.strokeStyle = sectionLine
|
||||
? `rgba(232,64,42,${0.18 + master * 0.14})`
|
||||
: barLine
|
||||
? `rgba(190,160,140,${0.18 + master * 0.12})`
|
||||
: `rgba(155,133,121,${0.07 + master * 0.05})`;
|
||||
ctx.lineWidth = sectionLine ? 1.15 : barLine ? 1 : 0.65;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - point.half, point.y);
|
||||
ctx.lineTo(cx + point.half, point.y);
|
||||
@@ -498,7 +504,7 @@ export class Descent {
|
||||
}
|
||||
|
||||
if (this.displayStep > 0) {
|
||||
const z = this.displayStep / LOOP_STEPS;
|
||||
const z = this.displayStep / this.arrangementSteps;
|
||||
const point = this.roadPoint(z);
|
||||
ctx.fillStyle = `rgba(232,64,42,${0.04 + master * 0.08})`;
|
||||
ctx.beginPath();
|
||||
@@ -541,12 +547,12 @@ export class Descent {
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = this.state?.pattern;
|
||||
if (pattern) {
|
||||
this.drawPattern(pattern, active, now);
|
||||
const sections = this.state?.arrangement ?? (this.state?.pattern ? [this.state.pattern] : []);
|
||||
if (sections.length) {
|
||||
this.drawArrangement(sections, active, now);
|
||||
}
|
||||
|
||||
const play = this.roadPoint(this.displayStep / LOOP_STEPS);
|
||||
const play = this.roadPoint(this.displayStep / this.arrangementSteps);
|
||||
const playing = this.state?.transport.playing ?? false;
|
||||
const playAlpha = playing ? 0.5 + master * 0.45 : 0.18;
|
||||
ctx.save();
|
||||
@@ -568,28 +574,44 @@ export class Descent {
|
||||
ctx.lineTo(cx + play.half, play.y + 2);
|
||||
ctx.stroke();
|
||||
|
||||
this.drawBarLabels(ctx, cx, bhw);
|
||||
this.drawBarLabels(ctx);
|
||||
}
|
||||
|
||||
private drawPattern(pattern: Pattern, active: number, now: number): void {
|
||||
private drawArrangement(sections: Pattern[], active: number, now: number): void {
|
||||
const { ctx } = this;
|
||||
const cx = this.road.cx;
|
||||
const total = Math.max(1, sections.length) * LOOP_STEPS;
|
||||
const playing = this.state?.transport.playing ?? false;
|
||||
for (const row of pattern.rows) {
|
||||
const lane = VEINS.indexOf(row.vein);
|
||||
if (lane < 0) continue;
|
||||
for (let step = 0; step < row.velocities.length; step += 1) {
|
||||
const velocity = row.velocities[step];
|
||||
if (!velocity) continue;
|
||||
const z = (step + 0.5) / LOOP_STEPS;
|
||||
const point = this.roadPoint(z);
|
||||
const laneCenter = (lane + 0.5) / 5;
|
||||
const x = cx + (laneCenter - 0.5) * 2 * point.half;
|
||||
const isActive = playing && step === active;
|
||||
const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6);
|
||||
this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, step);
|
||||
const activeSection = this.state?.active_section ?? 0;
|
||||
sections.forEach((pattern, sectionIndex) => {
|
||||
for (const row of pattern.rows) {
|
||||
const lane = VEINS.indexOf(row.vein);
|
||||
if (lane < 0) continue;
|
||||
for (let step = 0; step < row.velocities.length; step += 1) {
|
||||
const velocity = row.velocities[step];
|
||||
if (!velocity) continue;
|
||||
const global = sectionIndex * LOOP_STEPS + step;
|
||||
const z = (global + 0.5) / total;
|
||||
const point = this.roadPoint(z);
|
||||
const laneCenter = (lane + 0.5) / 5;
|
||||
const x = cx + (laneCenter - 0.5) * 2 * point.half;
|
||||
const isActive = playing && global === active;
|
||||
const emphasis = sectionIndex === activeSection ? 1.08 : 0.9;
|
||||
const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6) * emphasis;
|
||||
this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, global);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.save();
|
||||
ctx.font = '9px "JetBrains Mono", monospace';
|
||||
ctx.textAlign = 'left';
|
||||
sections.forEach((_, index) => {
|
||||
const point = this.roadPoint((index * LOOP_STEPS) / total);
|
||||
ctx.fillStyle =
|
||||
index === activeSection ? 'rgba(232,64,42,0.9)' : 'rgba(190,160,140,0.38)';
|
||||
ctx.fillText(String.fromCharCode(65 + index), cx + point.half + 8, point.y + 3);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawStone(x: number, y: number, size: number, lane: number, velocity: number, active: boolean, z: number, now: number, step: number): void {
|
||||
@@ -639,15 +661,17 @@ export class Descent {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawBarLabels(ctx: CanvasRenderingContext2D, cx: number, bhw: number): void {
|
||||
private drawBarLabels(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.save();
|
||||
const cx = this.road.cx;
|
||||
ctx.font = '10px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(190,160,140,0.78)';
|
||||
ctx.fillStyle = 'rgba(190,160,140,0.62)';
|
||||
ctx.textAlign = 'right';
|
||||
const first = this.roadPoint(0.0);
|
||||
ctx.fillText('01', cx - bhw - 10, first.y - 2);
|
||||
const second = this.roadPoint(0.5);
|
||||
ctx.fillText('02', cx - second.half - 10, second.y - 2);
|
||||
const bars = Math.max(1, Math.round(this.arrangementSteps / STEPS_PER_BAR));
|
||||
for (let bar = 0; bar < bars; bar += 1) {
|
||||
const point = this.roadPoint((bar * STEPS_PER_BAR) / this.arrangementSteps);
|
||||
ctx.fillText(String(bar + 1).padStart(2, '0'), cx - point.half - 9, point.y - 2);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export function commandLabel(command: Command): string {
|
||||
case 'set_pattern_step': return `step ${command.row + 1}:${command.step + 1} at ${command.velocity}`;
|
||||
case 'set_pattern_face': return `${command.core} f${command.face + 1} on row ${command.row + 1}`;
|
||||
case 'set_core_faces': return `cut ${command.core}`;
|
||||
case 'select_section': return `section ${String.fromCharCode(65 + command.index)}`;
|
||||
case 'clear_section': return `clear section ${String.fromCharCode(65 + command.index)}`;
|
||||
case 'copy_section': return `copy ${String.fromCharCode(65 + command.from)} → ${String.fromCharCode(65 + command.to)}`;
|
||||
case 'undo': return 'undo';
|
||||
case 'redo': return 'redo';
|
||||
case 'get_snapshot': return 'snapshot';
|
||||
|
||||
+7
-1
@@ -67,6 +67,8 @@ export interface State {
|
||||
transport: TransportState;
|
||||
veins: VeinState[];
|
||||
pattern: Pattern;
|
||||
arrangement: Pattern[];
|
||||
active_section: number;
|
||||
cores: CoreSummary[];
|
||||
audio: AudioInfo;
|
||||
journal_len: number;
|
||||
@@ -81,7 +83,8 @@ export type Event =
|
||||
| { kind: 'pattern_face'; row: number; core: string; face: number }
|
||||
| { kind: 'face_struck'; core: string; face: number; velocity: number }
|
||||
| { kind: 'history'; can_undo: boolean; can_redo: boolean }
|
||||
| { kind: 'core_faces'; core: CoreSummary };
|
||||
| { kind: 'core_faces'; core: CoreSummary }
|
||||
| { kind: 'arrangement'; sections: Pattern[]; active: number };
|
||||
|
||||
export type Command =
|
||||
| { type: 'get_snapshot' }
|
||||
@@ -94,6 +97,9 @@ export type Command =
|
||||
| { type: 'set_pattern_step'; row: number; step: number; velocity: number }
|
||||
| { type: 'set_pattern_face'; row: number; core: string; face: number }
|
||||
| { type: 'set_core_faces'; core: string; boundaries: number[] }
|
||||
| { type: 'select_section'; index: number }
|
||||
| { type: 'clear_section'; index: number }
|
||||
| { type: 'copy_section'; from: number; to: number }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user