diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 961eb6c..9291962 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,6 +16,7 @@ src/ui/ React components, CSS Modules, tokens.css, vendored fonts ``` - **One session document** is the source of truth. UI dispatches `Command`s; `applyCommand` is pure and tested. The store keeps undo history (knob drags coalesce into one step), notifies the engine (`engine.apply`), and rewrites the share link. +- **Layouts:** windows wider than 700 px get the full `LoopMachine` panel, scaled to fit; phones get `Player`, a listening-only view (start/stop, loop picker, a read-only step grid). Both share `Nameplate`, `Housing.module.css`, and `patterns.ts`. - **Exploring:** `src/session/library.ts` holds the ready-made patterns (the app opens on the first) and the chords. Holding the first key with no synth steps fills in a default rhythm. - **Engine:** `Clock` schedules 100 ms ahead, ticked from a Worker so background tabs keep time. The sound is aimed at dirty French electro (Justice, Daft Punk, a little disco). `mixer.ts` builds the whole signal chain once. Drums are synthesized per hit in `drums.ts` (808 kick with a 909-style snap, driven 909 snare, clean disco open hat; no samples), each with a DECAY tail multiplier, and run through a `ChannelStrip` (volume → mix bus, with a send to the stereo ping-pong delay in `delay.ts`). The `Synth` is a seven-voice detuned stereo unison plus a sub, through a plucked low-pass, light drive, and a 7 kHz high cut; it starts voices at scheduled times and releases the previous arp note at the next note's start. The synth, delay, and reverb run through the `Pump`, which ducks on every kick like a sidechain; drums bypass it. Everything meets in the mix bus (gentle glue compressor with headroom) before a safety limiter. The playhead (`step`, fired `note`) is published when each step actually sounds. - **Share link (v2):** `?s=2~~~…~syn.<…>`, keyed by drum id. See `src/session/url.ts`. Malformed parts fall back to defaults. diff --git a/README.md b/README.md index e04d3d9..a8838f8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A drum machine + synth in your browser, styled after the Roland TR-909. Program - **Controls:** knobs click to 11 notches and the fader to 9. Drag, click a notch, scroll, or use the arrow keys - **Computer keyboard:** A–C and W–\ toggle notes, like a piano layout - **Share links:** the URL holds the whole loop, drums and synth included +- **Phones:** a listening player that plays the shared loop or any of the six patterns; making loops needs a computer ## Run it diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index d5190c4..4ab5856 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -73,3 +73,25 @@ test('undo steps back a change', async ({ page }) => { await page.keyboard.press('ControlOrMeta+Z') await expect(step).toHaveAttribute('aria-pressed', 'false') }) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }) + + test('shows a listening player that plays and switches loops', async ({ page }) => { + const errors = collectErrors(page) + await page.goto('/') + await expect(page.getByRole('button', { name: 'KICK step 1', exact: true })).toHaveCount(0) + await expect(page.getByText('Deep Night')).toBeVisible() + + await page.getByRole('button', { name: 'START' }).tap() + await expect(page.getByRole('button', { name: 'STOP' })).toBeVisible() + await expect(page.locator('[data-now="true"]').first()).toBeAttached() + await page.getByRole('button', { name: 'STOP' }).tap() + await expect(page.locator('[data-now="true"]')).toHaveCount(0) + + await page.getByRole('radio', { name: 'Warehouse' }).tap() + await expect(page.getByText('Warehouse')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + expect(errors).toEqual([]) + }) +}) diff --git a/src/App.module.css b/src/App.module.css index 4928fed..aa4577b 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -1,3 +1,10 @@ .app { position: relative; } + +/* The phone player fills the width the page leaves it. */ +.phone { + display: flex; + justify-content: center; + width: 100%; +} diff --git a/src/App.tsx b/src/App.tsx index fcc1867..9a8330d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,12 +4,17 @@ import { DRUMS, noteForKey } from './instruments' import { createSessionStore } from './session/store' import { readSessionFromLocation, writeSessionToLocation } from './session/url' import { LoopMachine } from './ui/LoopMachine' +import { Player } from './ui/Player' +import { useMediaQuery } from './ui/useMediaQuery' import styles from './App.module.css' /** Panel width plus its glass border, and the page margin on each side. */ const PANEL_WIDTH = 1128 const PAGE_GUTTER = 48 +/** Phones get a listening player; the full panel needs room to edit. */ +const PHONE_QUERY = '(max-width: 700px)' + /** How long the share link may trail the session. Browsers rate-limit address rewrites, and a knob drag changes the session many times a second. */ const LINK_DELAY_MS = 250 @@ -56,6 +61,9 @@ export function App() { return () => window.removeEventListener('resize', fit) }, []) + const phone = useMediaQuery(PHONE_QUERY) + const onStartStop = () => (engine.isPlaying ? engine.stop() : void engine.start()) + // Computer keys toggle notes, like clicking the keyboard; ⌘Z / Ctrl+Z undoes. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -76,13 +84,21 @@ export function App() { return () => window.removeEventListener('keydown', onKeyDown) }, [store]) + if (phone) { + return ( +
+ +
+ ) + } + return (
(engine.isPlaying ? engine.stop() : void engine.start())} + onStartStop={onStartStop} onReset={() => { engine.stop() store.dispatch({ type: 'reset' }) diff --git a/src/engine/engine.ts b/src/engine/engine.ts index b6d645b..13240ee 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -46,6 +46,9 @@ export class Engine { load(): void { if (this.graph) return const ctx = new AudioContext() + // Safari on iPhone silences Web Audio with the ring/silent switch unless the page says it plays media. + const audioSession = (navigator as Navigator & { audioSession?: { type: string } }).audioSession + if (audioSession) audioSession.type = 'playback' const graph: Graph = { ctx, ...buildMixer(ctx, this.drums), diff --git a/src/ui/Housing.module.css b/src/ui/Housing.module.css new file mode 100644 index 0000000..10b5b59 --- /dev/null +++ b/src/ui/Housing.module.css @@ -0,0 +1,22 @@ +/* The polished-chrome frame and the matte cream face inside it, shared by the panel and the phone player. */ +.housing { + padding: 8px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 18%, transparent 82%, rgba(0, 0, 0, 0.12)), + linear-gradient(90deg, #b9bcbf, #eef0f1 12%, #c9ccce 30%, #f6f7f8 50%, #c3c6c9 70%, #eceeef 88%, #b3b6b9); + box-shadow: + 0 30px 60px -28px rgba(32, 38, 56, 0.45), + 0 2px 6px rgba(32, 38, 56, 0.12), + 0 0 0 1px rgba(32, 38, 56, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + inset 0 -1px 0 rgba(0, 0, 0, 0.15); +} + +.face { + border-radius: 13px; + background: #f7f4ee; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 0 0 1px rgba(0, 0, 0, 0.12); +} diff --git a/src/ui/LoopMachine.module.css b/src/ui/LoopMachine.module.css index f9c3599..009bd4e 100644 --- a/src/ui/LoopMachine.module.css +++ b/src/ui/LoopMachine.module.css @@ -1,29 +1,15 @@ -/* Glass housing over the wallpaper; the panel inside carries the 909 layout. */ -/* Polished-chrome housing frame, as on the original direction A, around the matte panel. */ +/* The chrome housing at full panel width; the face inside carries the 909 layout. */ .device { + composes: housing from './Housing.module.css'; width: 1128px; - padding: 8px; - border-radius: 20px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 18%, transparent 82%, rgba(0, 0, 0, 0.12)), - linear-gradient(90deg, #b9bcbf, #eef0f1 12%, #c9ccce 30%, #f6f7f8 50%, #c3c6c9 70%, #eceeef 88%, #b3b6b9); - box-shadow: - 0 30px 60px -28px rgba(32, 38, 56, 0.45), - 0 2px 6px rgba(32, 38, 56, 0.12), - 0 0 0 1px rgba(32, 38, 56, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -1px 0 rgba(0, 0, 0, 0.15); } .face { + composes: face from './Housing.module.css'; padding: 22px 28px 26px; - border-radius: 13px; - background: #f7f4ee; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 0 0 1px rgba(0, 0, 0, 0.12); } + /* Nameplate on the left; the global pattern and undo keys on the right, like the 909's pattern section. */ .top { display: flex; @@ -32,13 +18,6 @@ margin-bottom: 32px; } -/* "DRUM MACHINE + SYNTH" is sized to run exactly as wide as "LM-919". */ -.nameplate { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; -} .globals { display: flex; @@ -69,28 +48,8 @@ gap: 16px; } -.descriptor { - font: 400 9.5px/1 var(--font-nameplate); - letter-spacing: 0.16em; - color: var(--navy); - white-space: nowrap; -} -/* Carved into the panel and paint-filled; see the "carved" filter in LoopMachine.tsx. */ -.model { - margin: 0; - font: 400 34.53px/1 var(--font-nameplate); - letter-spacing: 0.05em; - color: var(--navy); - white-space: nowrap; - filter: url(#carved); -} -.defs { - position: absolute; - width: 0; - height: 0; -} .cols { display: grid; diff --git a/src/ui/LoopMachine.tsx b/src/ui/LoopMachine.tsx index 7f1ad0d..b67703b 100644 --- a/src/ui/LoopMachine.tsx +++ b/src/ui/LoopMachine.tsx @@ -1,15 +1,18 @@ -import { useCallback, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react' +import { useCallback, useSyncExternalStore, type ReactNode } from 'react' import { DRUMS, SYNTH_FIRE, type FireColor } from '../instruments' import type { Playhead } from '../engine/engine' -import { CHORDS, chordFor, PRESETS } from '../session/library' +import { CHORDS, chordFor } from '../session/library' import { OCTAVES, type ArpMode, type ArpRate, type Waveform } from '../session/schema' import { useSession, type SessionStore } from '../session/store' -import { decodeSession, encodeSession } from '../session/url' +import { decodeSession } from '../session/url' import { Fader } from './controls/Fader' import { Knob } from './controls/Knob' import { LedChoice, PlainKey } from './controls/LedButton' import { StepKey } from './controls/StepKey' +import { fireVars } from './fire' import { Keyboard } from './Keyboard' +import { Nameplate } from './Nameplate' +import { PATTERN_OPTIONS, presetFor } from './patterns' import { TempoControl } from './TempoControl' import styles from './LoopMachine.module.css' import transport from './Transport.module.css' @@ -22,8 +25,6 @@ interface LoopMachineProps { onReset: () => void } -const fireVars = (c: FireColor) => ({ '--fire-top': c.top, '--fire-bottom': c.bottom, '--fire-glow': c.glow }) as CSSProperties - /** Four groups of four steps, like the 909's beat groups. */ function StepGroups({ render }: { render: (step: number) => ReactNode }) { return ( @@ -55,8 +56,6 @@ const OCTAVE_OPTIONS = OCTAVES.map((o) => ({ name: o > 0 ? 'Octave up' : o < 0 ? 'Octave down' : 'Normal octave', })) -const PATTERN_OPTIONS = PRESETS.map((p, i) => ({ value: p.link, content: String(i + 1), name: p.name })) - const CHORD_OPTIONS = CHORDS.map((c) => ({ value: c.name, content: c.name, name: c.name })) const undoIcon = ( @@ -65,31 +64,6 @@ const undoIcon = ( ) -/** - * Makes the wordmark look cut into the panel: a shadow inside each stroke - * along its upper wall, and light catching the lower lip just outside it. - */ -const carvedFilter = ( - - - - - - - - - - - - - - - - - - -) - const wave = (d: string) => ( @@ -110,8 +84,7 @@ export function LoopMachine({ store, playhead, isPlaying, onStartStop, onReset } const { dispatch } = store const now = playhead.step // A pattern's LED stays lit until you change something. - const link = encodeSession(session) - const preset = PRESETS.find((p) => p.link === link) + const preset = presetFor(session) const toggleSynthStep = useCallback((step: number) => dispatch({ type: 'toggleSynthStep', step }), [dispatch]) const toggleNote = useCallback((note: string) => dispatch({ type: 'toggleNote', note }), [dispatch]) @@ -120,11 +93,7 @@ export function LoopMachine({ store, playhead, isPlaying, onStartStop, onReset }
-
- {carvedFilter} - DRUM MACHINE + SYNTH -

LM-919

-
+
+ + + + + + + + + + + + + + + + + +) + +/** The LM-919 nameplate: the descriptor over the carved, paint-filled wordmark. */ +export function Nameplate() { + return ( +
+ {carvedFilter} + DRUM MACHINE + SYNTH +

LM-919

+
+ ) +} diff --git a/src/ui/Player.module.css b/src/ui/Player.module.css new file mode 100644 index 0000000..241dd5e --- /dev/null +++ b/src/ui/Player.module.css @@ -0,0 +1,137 @@ +/* Phone listening player: the same chrome housing and 909 pieces, one column. */ +.player { + width: min(100%, 420px); + padding: 6px; + border-radius: 18px; +} + +.face { + display: flex; + flex-direction: column; + align-items: center; + gap: 22px; + padding: 20px 18px 22px; +} + +.face > :first-child { + align-self: flex-start; +} + +.now { + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; + gap: 12px; +} + +.loopName { + overflow: hidden; + text-overflow: ellipsis; +} + +/* The tempo digits are a read-only span here, not the panel's input. */ +.digits { + position: absolute; + right: 10px; +} + +/* The loop running, read-only: one row per part, a cell per step. */ +.grid { + display: flex; + flex-direction: column; + gap: 8px; + align-self: stretch; +} + +.row { + display: grid; + grid-template-columns: 46px 1fr; + align-items: center; + gap: 8px; +} + +.rowLabel { + font: 800 9px/1 var(--font-label); + font-stretch: 108%; + letter-spacing: 0.06em; + color: var(--label); +} + +.beats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 7px; +} + +.beat { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; +} + +.cell { + aspect-ratio: 1; + border-radius: 3px; + background: linear-gradient(#f2eee6, #ded9cf); + box-shadow: 0 0 0 1px rgba(42, 44, 47, 0.35); + transition: + background var(--fade), + box-shadow var(--fade); +} + +.cell[data-on='true'] { + background: linear-gradient(color-mix(in oklab, var(--fire-top) 50%, #ece6da), color-mix(in oklab, var(--fire-bottom) 50%, #dcd4c3)); +} + +/* The playhead: a darker key with a firmer ring, so it reads at phone size. */ +.cell[data-now='true'] { + background: linear-gradient(#d9d5cc, #c4bfb5); + box-shadow: 0 0 0 1px rgba(42, 44, 47, 0.7); + transition-duration: var(--flash); +} + +.cell[data-on='true'][data-now='true'] { + background: linear-gradient(var(--fire-top), var(--fire-bottom)); + box-shadow: + 0 0 0 1px rgba(42, 44, 47, 0.35), + 0 0 8px rgba(var(--fire-glow), 0.6); +} + +/* The one big key: START, orange while playing, like the panel's. */ +.play { + width: 100%; + max-width: 260px; + height: 64px; + border: 1px solid var(--hair); + border-radius: 14px; + background: var(--key-face); + box-shadow: + 0 1px 2px rgba(31, 33, 36, 0.1), + inset 0 -2px 0 rgba(31, 33, 36, 0.06); + font: 800 15px/1 var(--font-label); + font-stretch: 108%; + letter-spacing: 0.12em; + color: var(--label); +} + +.play[data-playing='true'] { + background: linear-gradient(#f2a587, #d9744f); + color: #fff8ef; +} + +.play:active { + transform: translateY(1px); +} + +.play:focus-visible { + outline: 2px solid var(--navy); + outline-offset: 3px; +} + +.hint { + margin: 0; + font: 500 12px/1.4 var(--font-label); + color: var(--muted); + text-align: center; +} diff --git a/src/ui/Player.test.tsx b/src/ui/Player.test.tsx new file mode 100644 index 0000000..dd21231 --- /dev/null +++ b/src/ui/Player.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { STOPPED } from '../engine/engine' +import { PRESETS } from '../session/library' +import { defaultSession } from '../session/schema' +import { createSessionStore } from '../session/store' +import { decodeSession } from '../session/url' +import { Player } from './Player' + +const renderPlayer = (link: string | null, isPlaying = false) => { + const store = createSessionStore(link ? decodeSession(link) : defaultSession()) + const onStartStop = vi.fn() + render() + return { store, onStartStop } +} + +describe('Player', () => { + it('plays and switches loops, with nothing to edit', async () => { + const user = userEvent.setup() + const { onStartStop } = renderPlayer(PRESETS[0].link) + expect(screen.getByText(PRESETS[0].name)).toBeInTheDocument() + expect(screen.queryAllByRole('slider')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /step/ })).toBeNull() + + await user.click(screen.getByRole('button', { name: 'START' })) + expect(onStartStop).toHaveBeenCalledOnce() + + await user.click(screen.getByRole('radio', { name: PRESETS[4].name })) + expect(screen.getByText(PRESETS[4].name)).toBeInTheDocument() + }) + + it('names a loop that is not one of the six a shared loop', () => { + renderPlayer('8888080880a2_000500') + expect(screen.getByText('Shared loop')).toBeInTheDocument() + expect(screen.getAllByRole('radio').every((r) => r.getAttribute('aria-checked') === 'false')).toBe(true) + }) + + it('shows STOP while playing', () => { + renderPlayer(PRESETS[0].link, true) + expect(screen.getByRole('button', { name: 'STOP' })).toBeInTheDocument() + }) +}) diff --git a/src/ui/Player.tsx b/src/ui/Player.tsx new file mode 100644 index 0000000..94e6ca6 --- /dev/null +++ b/src/ui/Player.tsx @@ -0,0 +1,84 @@ +import { DRUMS, SYNTH_FIRE } from '../instruments' +import type { Playhead } from '../engine/engine' +import { useSession, type SessionStore } from '../session/store' +import { decodeSession } from '../session/url' +import { LedChoice } from './controls/LedButton' +import { fireVars } from './fire' +import { Nameplate } from './Nameplate' +import { PATTERN_OPTIONS, presetFor } from './patterns' +import housing from './Housing.module.css' +import styles from './Player.module.css' +import transport from './Transport.module.css' + +interface PlayerProps { + store: SessionStore + playhead: Playhead + isPlaying: boolean + onStartStop: () => void +} + +/** Four beats of four steps, like the panel's beat groups. */ +const BEATS = [0, 4, 8, 12].map((start) => [start, start + 1, start + 2, start + 3]) + +/** + * The phone layout: a listening player, not an editor. It plays the loop a + * link opened or one of the ready-made loops, and shows the pattern running. + */ +export function Player({ store, playhead, isPlaying, onStartStop }: PlayerProps) { + const session = useSession(store, (s) => s) + const preset = presetFor(session) + const rows = [ + ...DRUMS.map((d) => ({ id: d.id, label: d.label.toUpperCase(), steps: session.drums[d.id].steps, fire: d.fire })), + { id: 'synth', label: 'SYNTH', steps: session.synth.steps, fire: SYNTH_FIRE }, + ] + + return ( +
+
+ + +
+ {preset?.name ?? 'Shared loop'} +
+ + 888 + + + {String(session.bpm).padStart(3, '0')} + +
+
+ +
+ {rows.map((row) => ( +
+ {row.label} +
+ {BEATS.map((beat) => ( +
+ {beat.map((i) => ( + + ))} +
+ ))} +
+
+ ))} +
+ + + + store.dispatch({ type: 'loadSession', session: decodeSession(link) })} + /> + +

Open on a computer to make your own loop.

+
+
+ ) +} diff --git a/src/ui/fire.ts b/src/ui/fire.ts new file mode 100644 index 0000000..a005565 --- /dev/null +++ b/src/ui/fire.ts @@ -0,0 +1,5 @@ +import type { CSSProperties } from 'react' +import type { FireColor } from '../instruments' + +/** A row's firing color as the CSS variables its step keys read. */ +export const fireVars = (c: FireColor) => ({ '--fire-top': c.top, '--fire-bottom': c.bottom, '--fire-glow': c.glow }) as CSSProperties diff --git a/src/ui/patterns.ts b/src/ui/patterns.ts new file mode 100644 index 0000000..89f67cc --- /dev/null +++ b/src/ui/patterns.ts @@ -0,0 +1,12 @@ +import { PRESETS } from '../session/library' +import type { Session } from '../session/schema' +import { encodeSession } from '../session/url' + +/** The ready-made loops as LED keys numbered 1–6. */ +export const PATTERN_OPTIONS = PRESETS.map((p, i) => ({ value: p.link, content: String(i + 1), name: p.name })) + +/** The ready-made loop the session exactly matches, if any; changing anything makes it your own. */ +export const presetFor = (session: Session) => { + const link = encodeSession(session) + return PRESETS.find((p) => p.link === link) +} diff --git a/src/ui/tokens.css b/src/ui/tokens.css index e59701b..4212e1d 100644 --- a/src/ui/tokens.css +++ b/src/ui/tokens.css @@ -78,6 +78,13 @@ body { #e9e5dc; } +/* Phones show the compact player; give it most of the width. */ +@media (max-width: 700px) { + body { + padding: 16px 12px; + } +} + button { font: inherit; cursor: pointer; diff --git a/src/ui/useMediaQuery.ts b/src/ui/useMediaQuery.ts new file mode 100644 index 0000000..9f0128f --- /dev/null +++ b/src/ui/useMediaQuery.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react' + +/** Whether a CSS media query matches, kept current as the window changes. */ +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (onChange) => { + const list = window.matchMedia(query) + list.addEventListener('change', onChange) + return () => list.removeEventListener('change', onChange) + }, + () => window.matchMedia(query).matches, + ) +}