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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches-ignore:
- main
pull_request:
branches:
- main

jobs:
ci:
name: Type-check, Lint & Test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Type-check
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build library
run: npm run build

- name: Build Electron (renderer + main, no distributable)
run: npm run electron:build
125 changes: 125 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Release

on:
push:
branches:
- main

jobs:
# ─── Read version from package.json, bail if the tag already exists ───────
prepare:
name: Prepare
runs-on: ubuntu-latest
outputs:
version: ${{ steps.pkg.outputs.version }}
tag: ${{ steps.pkg.outputs.tag }}
skip: ${{ steps.tag_check.outputs.skip }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- id: pkg
name: Read version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"

- id: tag_check
name: Check for existing tag
run: |
VERSION=$(node -p "require('./package.json').version")
if git ls-remote --tags origin "refs/tags/v$VERSION" | grep -q .; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Tag v$VERSION already exists — skipping release."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

# ─── Build platform binaries ──────────────────────────────────────────────
build:
name: Build (${{ matrix.os }})
needs: prepare
if: needs.prepare.outputs.skip == 'false'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Build Mac distributable
if: runner.os == 'macOS'
run: npm run electron:dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false

- name: Build Windows distributable
if: runner.os == 'Windows'
shell: bash
run: npx electron-vite build && npx electron-builder --win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.os }}
path: |
release/*.dmg
release/*.zip
release/*.exe
if-no-files-found: warn
retention-days: 1

# ─── Tag + publish GitHub Release ────────────────────────────────────────
release:
name: Publish Release
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v4

- name: Download Mac binaries
uses: actions/download-artifact@v4
with:
name: binaries-macos-latest
path: dist-artifacts
continue-on-error: true

- name: Download Windows binaries
uses: actions/download-artifact@v4
with:
name: binaries-windows-latest
path: dist-artifacts
continue-on-error: true

- name: List release assets
run: ls -lh dist-artifacts/ 2>/dev/null || echo "No artifacts downloaded"

- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.tag }}
name: "MPC Sample ${{ needs.prepare.outputs.tag }}"
generate_release_notes: true
draft: false
prerelease: false
files: dist-artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,10 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

.DS_Store
out-tsc/
release/
MPC-Sample/
public/kits/
claudedocs/
89 changes: 89 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# AGENTS.md

Project guidance for AI coding assistants working in this repository.

## Commands

```bash
# Development
npm run dev # Vite dev server at http://localhost:4404 (browser build)
npm run electron:dev # Electron dev mode (renderer at port 4406)

# Build
npm run build # Build React library → dist/
npm run electron:build # Build Electron app → out/
npm run electron:dist # Package distributable (Mac)
npm run build-kits # Regenerate public/kits/ from MPC-Sample/Projects/

# Quality
npm run typecheck # tsc --noEmit across lib + node tsconfigs
npm run lint # Biome lint
npm run check # Biome lint + format (auto-fix)

# Tests
npm test # Run all tests once (Vitest, no watch)
```

Run a single test file:

```bash
npx vitest run src/xpj/__tests__/codec.test.ts
```

**Package manager: npm only** — do not use pnpm.

## Architecture

This project is simultaneously a **React component library** (`dist/`) and a **desktop Electron app** (`out/`). The same React source (`src/`) powers both targets; `electron/` contains only the main-process code.

### Dual-build targets

| Target | Entry | Config | Output |
| ----------------- | ---------------------- | ------------------------- | --------------- |
| Library (npm) | `src/index.ts` → `App` | `vite.config.ts` | `dist/` |
| Browser dev | `src/main.tsx` | `vite.config.ts` | served |
| Electron main | `electron/main.ts` | `electron.vite.config.ts` | `out/main/` |
| Electron renderer | `index.html` | `electron.vite.config.ts` | `out/renderer/` |

`package.json` `main` field points to `out/main/index.js` (Electron entry); library consumers use the `exports` map which resolves to `dist/index.js`.

### State: Zustand store (`src/state/store.ts`)

Single global store (`useMPCStore`) is the source of truth. Key state:

- `padMap: Record<GlobalPadIdx, SamplePad | null>` — 128 pad slots (banks A–H). This is the live source of truth; `activeKit.pads` is only the initial snapshot.
- `activeKit`, `activeKitId` — loaded kit metadata
- `engineRef: AudioEngineLike | null` — registered audio engine reference
- `uiScale`, `uiOffset` — pan/zoom transform, persisted to localStorage
- Per-session settings persisted under `mpc.settings` and `mpc.uiTransform` localStorage keys

### Audio: `SampleEngine` (`src/audio/SampleEngine.ts`)

Implements `AudioEngineLike` interface using Tone.js (one `Tone.Player` per pad, lazy-loaded). Audio graph: `Tone.Player → per-pad Tone.Gain → master Tone.Gain → Tone.Waveform + Tone.FFT → destination`. Buffers are loaded on first trigger and inflight fetches are deduplicated.

### .xpj file format (`src/xpj/`)

Akai MPC project files are gzip-compressed with a 5-line ASCII header followed by JSON. Critical constraint: the firmware expects float-typed fields to have decimal points (`1.0` not `1`). `jsonLossless.ts` carries raw number lexemes through parse/stringify to preserve this. `codec.ts` exposes `encodeXpj` / `decodeXpj` and the convenience aliases `floatTag` / `serializeJson`.

### Kit system (`src/kits/`)

- `public/kits/<id>/manifest.json` — generated by `scripts/build-kits.mjs` from `MPC-Sample/Projects/`
- `public/kits/<id>/*.wav` — preset sample files (gitignored, ~300 MB)
- `SampleKit` / `SamplePad` types in `src/kits/kit.types.ts` are frozen contracts shared across all modules
- `GlobalPadIdx` 0..127: `bankIdx = idx >> 4`, `localIdx = idx & 0xf`

### Desktop bridge (`src/desktop/`)

`isDesktop()` / `desktop()` guard renderer code from browser vs Electron divergence. `window.mpcDesktop` is injected by `electron/preload.ts` via contextBridge. Desktop-only features: open/save `.xpj` file dialogs, auto-open export folder, SD card eject.

### Styling

Tailwind CSS v4 via `@tailwindcss/vite` plugin. CSS tokens in `src/styles/tokens.css`. No CSS modules — class names applied directly. Biome enforces formatting (2-space indent, double quotes, 100-char line width); CSS files are excluded from Biome.

## Key conventions

- **Pad addressing**: Always use `GlobalPadIdx` (0–127) internally. Local pad number (1–16) is display only. Bank A = indices 0–15, Bank B = 16–31, etc.
- **`padMap` is the source of truth** for exports — never read `activeKit.pads` when building the export kit; use `buildExportKit(activeKit, padMap)` from the store.
- **Float serialisation**: Any number written to `.xpj` that the firmware treats as a float must go through `floatTag()` before `serializeJson()`.
- **Engine interaction**: Store actions call `engineRef?.method()` directly; components never call the engine — they dispatch store actions.
- Tests live in `__tests__/` subdirectories alongside source; electron tests are under `electron/__tests__/` with their own `vitest.config.ts`.
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CLAUDE.md

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

@AGENTS.md
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,80 @@
# mpcsample
# MPC Sample

A web and desktop app for managing Akai MPC sample projects and building drum kits. Load your existing MPC kits, preview samples on a familiar pad layout, rearrange and customize your pads, then export the result as a ready-to-use `.xpj` project file — drop it straight onto your MPC hardware or SD card and your kit is ready to play.

Runs as a browser app or as a packaged macOS desktop app (Electron). The desktop build adds native file dialogs for opening and saving `.xpj` files and direct SD card workflow support.

![Demo of MPC Sample.app](./demo.jpg)

For a live demo visit [mpcsample.app](mpcsample.app)

---

## Requirements

- Node.js >= 18
- npm

---

## Setup

```bash
git clone <repo-url>
cd mpcsample
npm install
```

---

## Running

**Browser only (Vite, port 4404):**

```bash
npm run dev
```

**Full Electron desktop app (renderer on port 4406):**

```bash
npm run electron:dev
```

**Package a distributable (macOS):**

```bash
npm run electron:dist
```

---

## macOS Note

The app is not code-signed or notarized. macOS Gatekeeper will block it from opening after download. There are two ways to get past this.

### Easiest — right-click to open (one-time)

1. Right-click (or Control-click) **MPC Sample.app**
2. Choose **Open** from the context menu
3. Click **Open** in the dialog that appears

macOS remembers this decision; the app opens normally from then on.

### Via Terminal — remove the quarantine flag

If the right-click method doesn't work, or if you prefer the command line, remove the quarantine extended attribute directly.

**On the `.app` after dragging it to `/Applications`:**

```bash
xattr -dr com.apple.quarantine "/Applications/MPC Sample.app"
```

**On a freshly extracted `.app` before moving it:**

```bash
xattr -cr "MPC Sample.app"
```

Run either command once; no further steps are needed.
Loading
Loading