Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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~<bpm>~<drum>~…~syn.<…>`, keyed by drum id. See `src/session/url.ts`. Malformed parts fall back to defaults.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
})
})
7 changes: 7 additions & 0 deletions src/App.module.css
Original file line number Diff line number Diff line change
@@ -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%;
}
18 changes: 17 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) => {
Expand All @@ -76,13 +84,21 @@ export function App() {
return () => window.removeEventListener('keydown', onKeyDown)
}, [store])

if (phone) {
return (
<main className={`${styles.app} ${styles.phone}`}>
<Player store={store} playhead={playhead} isPlaying={isPlaying} onStartStop={onStartStop} />
</main>
)
}

return (
<main className={styles.app} style={{ zoom }}>
<LoopMachine
store={store}
playhead={playhead}
isPlaying={isPlaying}
onStartStop={() => (engine.isPlaying ? engine.stop() : void engine.start())}
onStartStop={onStartStop}
onReset={() => {
engine.stop()
store.dispatch({ type: 'reset' })
Expand Down
3 changes: 3 additions & 0 deletions src/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
22 changes: 22 additions & 0 deletions src/ui/Housing.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
49 changes: 4 additions & 45 deletions src/ui/LoopMachine.module.css
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 8 additions & 39 deletions src/ui/LoopMachine.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
Expand Down Expand Up @@ -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 = (
Expand All @@ -65,31 +64,6 @@ const undoIcon = (
</svg>
)

/**
* 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 = (
<svg className={styles.defs} aria-hidden>
<filter id="carved" x="-5%" y="-20%" width="110%" height="140%">
<feOffset in="SourceAlpha" dy="2.2" result="shifted" />
<feGaussianBlur in="shifted" stdDeviation="0.9" result="shiftedSoft" />
<feComposite in="SourceAlpha" in2="shiftedSoft" operator="out" result="upperWall" />
<feFlood floodColor="#05070d" floodOpacity="0.85" />
<feComposite in2="upperWall" operator="in" result="wallShadow" />
<feComposite in="wallShadow" in2="SourceGraphic" operator="over" result="cut" />
<feOffset in="SourceAlpha" dy="1.2" result="lowered" />
<feComposite in="lowered" in2="SourceAlpha" operator="out" result="lowerLip" />
<feFlood floodColor="#fff" floodOpacity="0.95" />
<feComposite in2="lowerLip" operator="in" result="lipLight" />
<feMerge>
<feMergeNode in="lipLight" />
<feMergeNode in="cut" />
</feMerge>
</filter>
</svg>
)

const wave = (d: string) => (
<svg viewBox="0 0 24 14" aria-hidden>
<path d={d} fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
Expand All @@ -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])
Expand All @@ -120,11 +93,7 @@ export function LoopMachine({ store, playhead, isPlaying, onStartStop, onReset }
<div className={styles.device}>
<div className={styles.face}>
<header className={styles.top}>
<div className={styles.nameplate}>
{carvedFilter}
<span className={styles.descriptor}>DRUM MACHINE + SYNTH</span>
<h1 className={styles.model}>LM-919</h1>
</div>
<Nameplate />
<div className={styles.globals}>
<div className={styles.pattern}>
<LedChoice
Expand Down
30 changes: 30 additions & 0 deletions src/ui/Nameplate.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* "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;
}

.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 Nameplate.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;
}
37 changes: 37 additions & 0 deletions src/ui/Nameplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import styles from './Nameplate.module.css'

/**
* 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 = (
<svg className={styles.defs} aria-hidden>
<filter id="carved" x="-5%" y="-20%" width="110%" height="140%">
<feOffset in="SourceAlpha" dy="2.2" result="shifted" />
<feGaussianBlur in="shifted" stdDeviation="0.9" result="shiftedSoft" />
<feComposite in="SourceAlpha" in2="shiftedSoft" operator="out" result="upperWall" />
<feFlood floodColor="#05070d" floodOpacity="0.85" />
<feComposite in2="upperWall" operator="in" result="wallShadow" />
<feComposite in="wallShadow" in2="SourceGraphic" operator="over" result="cut" />
<feOffset in="SourceAlpha" dy="1.2" result="lowered" />
<feComposite in="lowered" in2="SourceAlpha" operator="out" result="lowerLip" />
<feFlood floodColor="#fff" floodOpacity="0.95" />
<feComposite in2="lowerLip" operator="in" result="lipLight" />
<feMerge>
<feMergeNode in="lipLight" />
<feMergeNode in="cut" />
</feMerge>
</filter>
</svg>
)

/** The LM-919 nameplate: the descriptor over the carved, paint-filled wordmark. */
export function Nameplate() {
return (
<div className={styles.nameplate}>
{carvedFilter}
<span className={styles.descriptor}>DRUM MACHINE + SYNTH</span>
<h1 className={styles.model}>LM-919</h1>
</div>
)
}
Loading
Loading