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
91 changes: 22 additions & 69 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,88 +1,41 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code working in this repository.

## Project Overview
## Project

Loop Machine is a browser-based drum sequencer built with vanilla JavaScript and the Web Audio API. It features a 16-step sequencer with 3 instrument tracks (hi-hat, snare, kick), per-instrument reverb/delay effects, URL state persistence, and a JSON editor sidebar.
Loop Machine (LM-919) is a browser drum machine + synth: a 16-step drum sequencer (hi-hat, snare, kick) and an arpeggiated synth on a two-octave keyboard. React 19, TypeScript, Vite, Web Audio API. pnpm only.

## Architecture

### Core Files

- `index.html` - Main HTML structure with sequencer grid, sidebar, and modal
- `script.js` - All application logic (audio, UI, state management)
- `style.css` - CSS Grid-based layout and styling

### Audio System

The application uses the Web Audio API with this signal flow per instrument:
```
BufferSource -> mainGain -> [dry path to output]
-> reverbNode -> output
-> delayNode -> feedbackNode -> delayNode (feedback loop)
-> delayWetGain -> output
src/instruments.ts drum registry (id, label, synthesized voice, firing color) and keyboard notes
src/session/ Session types, pure commands (applyCommand), store, share-link format
src/engine/ audio engine; must not import React (ESLint enforces this)
src/ui/ React components, CSS Modules, tokens.css, vendored fonts
```

Key audio variables:
- `audioContext` - Main Web Audio context
- `audioBuffers` - Decoded audio samples per instrument
- `effectNodes` - Gain/effect nodes per instrument
- `sequenceState` - Boolean arrays (16 steps) per instrument

### State Management
- **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.
- **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, shared by the live engine and offline renders. 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.

State is persisted in the URL using a compact encoding:
- Notes: 4-char hex per instrument (16 boolean steps as binary)
- Effects: 2 chars per instrument (reverb/delay values 0-10)
- Sidebar: `1` if visible
## Adding a drum

Functions:
- `updateUrlState()` - Writes current state to URL
- `loadUrlState()` - Restores state from URL on load
- `getCurrentStateAsJson()` - Returns state for JSON editor
Write the voice in `src/engine/drums.ts`, then add an entry to `DRUMS` in `src/instruments.ts`. The step row, knobs, firing color, and link field follow.

### Timing System
## Design

Uses a scheduler pattern for accurate audio timing:
- `scheduler()` - Schedules notes ahead of playback time
- `updatePlayheadVisuals()` - RAF loop for visual step highlighting
- `scheduleAheadTime` - How far ahead to schedule (100ms)
- `stepTime` - Duration of one 16th note at current BPM
The look is documented in the "Loop Machine Revival" proposal: TR-909 identity pieces (cream step keys with LEDs, chrome knobs, red 7-segment tempo, Michroma nameplate, navy Archivo labels) on modern glass surfaces. Tokens live in `src/ui/tokens.css`. Knobs snap to 11 notches and the fader to 9; both accept drag, click, wheel, and arrow keys.

## Development Commands
## Commands

Serve locally:
```bash
npx serve .
pnpm dev # http://localhost:3000
pnpm build
pnpm lint
pnpm typecheck
pnpm test
```

No build step required - vanilla JS served directly.

## Common Tasks

### Adding a New Instrument

1. Add entry to `instrumentsData` array with `name`, `id`, and `path`
2. Place audio file in `808 Samples/` directory
3. UI is generated automatically from `instrumentsData`

### Modifying Effects

Effect parameters are in `updateEffect()`:
- Reverb: Simple gain control (0-1)
- Delay: Time (0-0.5s), feedback (0-0.7), wet gain (0-0.5)

### Changing BPM

Modify the `bpm` constant (line 14). `stepTime` is calculated from it.

## Testing

No automated tests currently. Manual testing:
1. Toggle notes and verify visual feedback
2. Start/stop playback
3. Adjust effect sliders
4. Reload page and verify URL state restoration
5. Edit JSON and apply changes
6. Test reset functionality
CI runs lint, typecheck, test, and build on every PR.
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

Expand Down
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ Thanks for your interest in contributing to Loop Machine!
```bash
git clone https://github.com/brsbl/loop-machine.git
cd loop-machine
npm install
npm run dev
pnpm install
pnpm dev
```

## Before Submitting a PR

```bash
npm run lint
npm test
npm run build
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```

All checks must pass before merging.
97 changes: 38 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,79 +1,58 @@
# Loop Machine
# Loop Machine · LM-919

A browser-based drum sequencer/loop machine built with vanilla JavaScript and the Web Audio API.

<img width="1379" height="868" alt="Screenshot 2026-02-03 at 12 01 15 AM" src="https://github.com/user-attachments/assets/294b555e-6bd5-4b21-b31c-ce46df9f1b31" />
A drum machine + synth in your browser, styled after the Roland TR-909. Program a beat, hold a chord, and share the whole loop as a link.

## Features

- **16-step sequencer** with 3 instrument tracks (hi-hat, snare, kick)
- **Real-time playback** with visual step indicator
- **Per-instrument effects**: Reverb and Delay with adjustable levels (0-10)
- **URL state persistence**: Share patterns via URL
- **JSON editor sidebar**: View and edit sequencer state directly as JSON
- **Reset functionality**: Clear all patterns and effects with confirmation modal

## Getting Started

### Prerequisites

- A modern web browser with Web Audio API support
- A local web server (for loading audio samples)

### Running Locally

1. Clone the repository
2. Serve the directory with any static file server:
```bash
npx serve .
```
3. Open `http://localhost:3000` in your browser
- **Drums:** 16-step sequencer for hi-hat, snare, and kick, synthesized 909-style in the browser (no samples), each with volume, reverb, and tone knobs
- **Synth:** arpeggiator over a two-octave keyboard, with a detuned two-oscillator voice, a sub, and a plucked filter. Keys choose which notes play; the synth's step row chooses when. Direction (up, down, up-down), speed (1/4, 1/8, 1/16), and four waveforms
- **Playback:** on steps carry a soft tint of their instrument's color and light up fully when they fire; a beat band marks the four beats; the synth's key flashes with its step
- **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

### Audio Samples
## Run it

The project uses 808 drum samples located in the `808 Samples/` directory:
- `hi hat (30).wav`
- `snare.wav`
- `kick.wav`
```bash
pnpm install
pnpm dev
```

## Usage
Then open http://localhost:3000.

1. Click **START/STOP** to begin/stop playback
2. Click on sequencer pads to toggle notes on/off
3. Adjust **REVERB** and **DELAY** sliders for each instrument
4. Use the sidebar toggle to open the JSON editor for direct state manipulation
5. Click **RESET** to clear all patterns (requires confirmation)
## Project layout

## URL State Format
```
src/
instruments.ts registry: drums (id, label, voice, color) and keyboard notes
engine/ audio engine, no React: clock, channel strips, reverb, drum voices, synth, arpeggiator
session/ session types, commands, store, and share-link format
ui/ React components, design tokens, and fonts
```

The sequencer state is encoded in the URL using a compact format:
- `s` parameter: `{notes_hex}_{sliders_chars}`
- Notes: 4-character hex per instrument (16 steps as binary)
- Sliders: 2 characters per instrument (reverb + delay, 0-9 or 'a' for 10)
- `sidebar` parameter: `1` if sidebar is visible
The UI only renders the session and dispatches commands. The engine follows the session and reports the playhead back.

## Project Structure
Adding a drum: write its voice in `src/engine/drums.ts`, then add one entry in `src/instruments.ts`:

```ts
{ id: 'clap', label: 'Clap', voice: 'clap', fire: { top: '…', bottom: '…', glow: '…' } }
```
loop-machine/
├── index.html # Main HTML structure
├── script.js # Core application logic
├── style.css # Styling
├── 808 Samples/ # Drum audio samples
└── src/
└── utils/ # Utility functions
```

## Tech Stack
Its step row, knobs, firing color, and share-link field follow automatically.

## Scripts

- Vanilla JavaScript (ES6+)
- Web Audio API for audio playback and effects
- CSS Grid for layout
- No external dependencies
| Command | What it does |
| --- | --- |
| `pnpm dev` | Dev server on port 3000 |
| `pnpm build` | Production build |
| `pnpm lint` | ESLint |
| `pnpm typecheck` | TypeScript |
| `pnpm test` | Vitest |

## Contributing
## Credits

See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. For bugs or feature requests, please [open an issue](https://github.com/brsbl/loop-machine/issues/new).
- Fonts: Michroma, Archivo, and DSEG7 Classic, under the SIL Open Font License (see `src/ui/fonts/`)

## License

Expand Down
6 changes: 0 additions & 6 deletions babel.config.js

This file was deleted.

22 changes: 0 additions & 22 deletions components.json

This file was deleted.

66 changes: 14 additions & 52 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,29 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'

export default [
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx}'],
plugins: {
react,
'react-hooks': reactHooks,
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
window: 'readonly',
document: 'readonly',
console: 'readonly',
AudioContext: 'readonly',
fetch: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
},
},
files: ['src/**/*.{ts,tsx}'],
plugins: { react, 'react-hooks': reactHooks },
languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
settings: { react: { version: 'detect' } },
rules: {
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
settings: {
react: {
version: 'detect',
},
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
{
files: ['**/__tests__/**/*.{js,jsx}', '**/*.test.{js,jsx}', '**/*.spec.{js,jsx}'],
languageOptions: {
globals: {
describe: 'readonly',
it: 'readonly',
expect: 'readonly',
jest: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
global: 'readonly',
},
// The engine stays framework-free so the web app, Mixtape, and tests can share it.
files: ['src/engine/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [{ group: ['react', 'react-dom', 'react/*'], message: 'The engine must not depend on React.' }] }],
},
},
{
ignores: ['dist/**', 'node_modules/**'],
},
]
)
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drum Machine</title>
<title>LM-919 · Loop Machine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading