Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/EZ-MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Words in EZ are the plain ones; each maps onto a machine concept, and the manual
| Pattern 1 to 8 | sequences 1 to 8 | Bars and tempo shown per pattern; the other 91 stay in OG |
| Steps | events at 1/16 grid ticks on the DRUM1 track | A 16-step row per pad; longer bars scroll; off-grid notes show as dots |
| Swing | `Timing` swing % | One slider |
| Record | REC + PLAY START with count-in and loop on | The red button; UNDO is a button too |
| Record | OVERDUB + PLAY START, loop on | The red button; press it again to punch out and keep playing. UNDO is a button too |
| Sounds | the sounds in memory, LOAD for files | Add files or drop them anywhere; audition; Put on pad; Chop |
| Chop | TRIM, ZONE, SLICE SOUND | Drag the region on the waveform, choose 4/8/16 slices, put on pads in a new kit or this one |
| Chop | TRIM, ZONE, SLICE SOUND | Drag the region on the waveform, choose 4/8/16 slices, put on pads in a new kit or this one. Placing is armed with "Put on pads"; otherwise a pad tap is just a pad tap |
| Edit pads, New kit, Kit name | PROGRAM mode | Tap a pad to choose its sound or clear it; a fresh program on DRUM1; its name |
| Mixer | per-note level and pan on the kit | A fader and a pan knob per pad |
| Song | song 1 | A row of pattern chips in order; later |
Expand Down Expand Up @@ -103,3 +103,10 @@ Each ships on its own and is usable.
EZ component, so OG and EZ cannot disagree about what a step or a slice is.
- **Two panels to keep in the design system's voice.** EZ uses the same components (`src/ds`) and
tokens; no new visual language.
- **Destructive machine operations reaching EZ without OG's guards.** OG asks DO IT before anything
that throws work away; EZ has no such window, so a plain-worded button can wrap an operation that
quietly destroys a pattern or a kit. It already happened twice: EZ's red button was wired to REC,
which replaces the track as the playhead passes, and a pad tap in Chop reassigned the pad it hit.
The rule EZ works to now: **a destructive action is either armed first, or it is not destructive.**
Recording layers, placing is armed, and opening a sound in the chopper shows suggested chops
without writing them. `e2e/ez-record-and-chop.spec.ts` holds both edges down.
113 changes: 113 additions & 0 deletions e2e/ez-record-and-chop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// EZ's two destructive edges, held down by tests: the red button must never quietly eat a pattern, and a
// pad tap must never quietly rewrite the kit. Both shipped broken once; these are the cases that caught it.
import { test, expect } from '@playwright/test';
import type { Locator } from '@playwright/test';

test.use({ launchOptions: { args: ['--autoplay-policy=no-user-gesture-required'] } });

function wav(secs = 2): Buffer {
const rate = 22050, n = Math.floor(rate * secs);
const buf = Buffer.alloc(44 + n * 2);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVE', 8);
buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) buf.writeInt16LE(Math.round(Math.sin((2 * Math.PI * 440 * i) / rate) * 12000), 44 + i * 2);
return buf;
}

async function notes(ez: Locator): Promise<number> {
const txt = await ez.getByText(/\d+ notes\./).innerText();
return Number(txt.match(/(\d+) notes/)![1]);
}

test('VERIFY: Rec no longer eats the pattern, and punches out without stopping', async ({ page }) => {
await page.goto('/?panel=ez');
const ez = page.getByRole('region', { name: 'EZ panel' });
const rec = ez.getByRole('button', { name: 'Rec', exact: true });
const play = ez.getByRole('button', { name: 'Play', exact: true });
await ez.getByRole('button', { name: 'Sequence' }).click();
const start = await notes(ez);
expect(start).toBeGreaterThan(0);

await rec.click();
await page.waitForTimeout(6000); // more than one full pass of the 2-bar pattern
expect(await notes(ez)).toBe(start); // nothing was erased
expect(await play.getAttribute('aria-pressed')).toBe('true'); // the transport reads as rolling

// Rec again punches out but keeps playing
await rec.click();
expect(await rec.getAttribute('aria-pressed')).toBe('false');
expect(await play.getAttribute('aria-pressed')).toBe('true');
const afterPunch = await notes(ez);
const pads = ez.getByRole('region', { name: 'Pads' });
for (const n of ['Pad 1', 'Pad 2', 'Pad 3']) { await pads.getByRole('button', { name: new RegExp(`^${n}[:,]`) }).click(); await page.waitForTimeout(150); }
await page.waitForTimeout(300);
expect(await notes(ez)).toBe(afterPunch); // pads after punch-out do not record
await ez.getByRole('button', { name: 'Stop', exact: true }).click();
});

test('VERIFY: recording still records when it is armed', async ({ page }) => {
await page.goto('/?panel=ez');
const ez = page.getByRole('region', { name: 'EZ panel' });
await ez.getByRole('button', { name: 'Sequence' }).click();
await ez.getByRole('button', { name: 'Clear' }).click();
expect(await notes(ez)).toBe(0);
await ez.getByRole('button', { name: 'Rec', exact: true }).click();
const pads = ez.getByRole('region', { name: 'Pads' });
for (const n of ['Pad 1', 'Pad 2', 'Pad 3']) { await pads.getByRole('button', { name: new RegExp(`^${n}[:,]`) }).click(); await page.waitForTimeout(200); }
await page.waitForTimeout(300);
await ez.getByRole('button', { name: 'Stop', exact: true }).click();
expect(await notes(ez)).toBeGreaterThanOrEqual(3);
});

test('VERIFY: pads play in the Chop panel until Put on pads is armed', async ({ page }) => {
await page.goto('/?panel=ez');
const ez = page.getByRole('region', { name: 'EZ panel' });
const pads = ez.getByRole('region', { name: 'Pads' });
await ez.getByRole('button', { name: 'Library' }).click();
await ez.locator('input[aria-label="Add sounds"]').setInputFiles({ name: 'break.wav', mimeType: 'audio/wav', buffer: wav() });
await expect(ez.getByRole('heading', { name: 'Chop' })).toBeVisible({ timeout: 15_000 });

// the kit's own pads are untouched by a tap while not armed
const before = await pads.getByRole('button', { name: /^Pad 1[:,]/ }).getAttribute('aria-label');
await pads.getByRole('button', { name: /^Pad 1[:,]/ }).click();
expect(await pads.getByRole('button', { name: /^Pad 1[:,]/ }).getAttribute('aria-label')).toBe(before);
expect(before).toContain('KICK');

// opening a sound must not silently chop it: the kit sounds still carry one zone
await ez.getByLabel('Sound to chop').selectOption({ label: 'KICK' });
await expect(ez.getByText(/8 chops\./)).toBeVisible(); // shown as a suggestion
await ez.getByLabel('Sound to chop').selectOption({ label: 'BREAK' });

// arm, then tap: the chop lands and the selection walks on
await ez.getByRole('button', { name: '4 slices' }).click();
await ez.getByRole('button', { name: 'Put on pads' }).click();
await expect(ez.getByRole('button', { name: 'Put on pads' })).toHaveAttribute('aria-pressed', 'true');
await pads.getByRole('button', { name: /^Pad 5[:,]/ }).click();
await expect(pads.getByRole('button', { name: /^Pad 5: BREAK1/ })).toBeVisible();
await pads.getByRole('button', { name: /^Pad 6[:,]/ }).click();
await expect(pads.getByRole('button', { name: /^Pad 6: BREAK2/ })).toBeVisible();

// placing the same chop repeatedly must not pile up sounds in memory
await ez.getByRole('button', { name: 'Library' }).click();
const n1 = Number((await ez.getByText(/Your sounds · \d+/).innerText()).match(/(\d+)/)![1]);
await ez.getByRole('button', { name: 'Chop', exact: true }).click();
await ez.getByRole('button', { name: 'Put on pads' }).click();
for (const p of ['Pad 9', 'Pad 10', 'Pad 11', 'Pad 12']) { await pads.getByRole('button', { name: new RegExp(`^${p}[:,]`) }).click(); }
await ez.getByRole('button', { name: 'Library' }).click();
const n2 = Number((await ez.getByText(/Your sounds · \d+/).innerText()).match(/(\d+)/)![1]);
expect(n2 - n1).toBeLessThanOrEqual(4); // four taps, and only chops we had not cut before are new
});

test('VERIFY: All to pads keeps the chopper on the break', async ({ page }) => {
await page.goto('/?panel=ez');
const ez = page.getByRole('region', { name: 'EZ panel' });
await ez.getByRole('button', { name: 'Library' }).click();
await ez.locator('input[aria-label="Add sounds"]').setInputFiles({ name: 'break.wav', mimeType: 'audio/wav', buffer: wav() });
await expect(ez.getByRole('heading', { name: 'Chop' })).toBeVisible({ timeout: 15_000 });
await ez.getByRole('button', { name: '8 slices' }).click();
await ez.getByRole('button', { name: 'All to pads' }).click();
await expect(ez.getByLabel('Sound to chop').locator('option:checked')).toHaveText('BREAK');
await expect(ez.getByText(/8 chops\./)).toBeVisible();
});
11 changes: 9 additions & 2 deletions e2e/ez.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,18 @@ test('EZ: add a sound, chop it, put chops on pads, name the kit; OG has it all',
await ez.getByRole('button', { name: '4 slices' }).click();
await expect(ez.getByRole('slider', { name: 'Chop 3 start' })).toBeVisible();
await expect(ez.getByRole('slider', { name: 'Chop 5 start' })).toHaveCount(0);
// select chop 2 and tap pad 13: the slice lands there
await ez.getByRole('slider', { name: 'Chop 2 start' }).click();
// a pad tap is just a pad tap until placing is armed
const pads = ez.getByRole('region', { name: 'Pads' });
await ez.getByRole('slider', { name: 'Chop 2 start' }).click();
await pads.getByRole('button', { name: 'Pad 13, empty' }).click();
await expect(pads.getByRole('button', { name: 'Pad 13, empty' })).toBeVisible();
// arm it, and the selected chop lands on the pad we tap
await ez.getByRole('button', { name: 'Put on pads' }).click();
await pads.getByRole('button', { name: 'Pad 13, empty' }).click();
await expect(pads.getByRole('button', { name: 'Pad 13: TONE_A42' })).toBeVisible();
// placing walks on to the next chop, so the next pad gets chop 3
await pads.getByRole('button', { name: 'Pad 14, empty' }).click();
await expect(pads.getByRole('button', { name: 'Pad 14: TONE_A43' })).toBeVisible();
// all chops onto the pads from pad 1
await ez.getByRole('button', { name: 'All to pads' }).click();
await expect(pads.getByRole('button', { name: 'Pad 1: TONE_A41' })).toBeVisible();
Expand Down
1 change: 1 addition & 0 deletions node_modules
Loading
Loading