Browse Source

Add plan docs, mockup and test data

bajdev 2 days ago
parent
commit
183818b2f3
27 changed files with 1218 additions and 0 deletions
  1. 347 0
      docs/build-plan.md
  2. 450 0
      docs/mockup/Main.dc.html
  3. 251 0
      docs/xpj-format-and-scope.md
  4. BIN
      testdata/accepted/MpckitT1.xpj
  5. BIN
      testdata/accepted/MpckitT1_[ProjectData]/Linn 9000 kick.wav
  6. BIN
      testdata/accepted/MpckitT1_[ProjectData]/Linn 9000 kick001.wav
  7. BIN
      testdata/accepted/MpckitT2.xpj
  8. BIN
      testdata/accepted/MpckitT2_[ProjectData]/Linn 9000 kick.wav
  9. BIN
      testdata/accepted/MpckitT2_[ProjectData]/Linn 9000 kick001.wav
  10. BIN
      testdata/accepted/MpckitT3.xpj
  11. BIN
      testdata/accepted/MpckitT3_[ProjectData]/Linn 9000 kick.wav
  12. BIN
      testdata/wav-formats/MpckitW1.xpj
  13. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/22k 16bit mono.wav
  14. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/44k 16bit mono.wav
  15. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/44k 16bit stereo.wav
  16. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/44k 24bit stereo.wav
  17. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/48k 16bit stereo.wav
  18. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/48k 24bit stereo.wav
  19. BIN
      testdata/wav-formats/MpckitW1_[ProjectData]/96k 24bit stereo.wav
  20. BIN
      testdata/wav-formats/MpckitW2.xpj
  21. BIN
      testdata/wav-formats/MpckitW2_[ProjectData]/44k 16bit mono.wav
  22. BIN
      testdata/wav-formats/MpckitW2_[ProjectData]/44k 32bit float stereo.wav
  23. BIN
      testdata/wav-formats/MpckitW2_[ProjectData]/44k 32bit int stereo.wav
  24. BIN
      testdata/wav-formats/MpckitW2_[ProjectData]/44k 8bit mono.wav
  25. 7 0
      tools/prototype/README.md
  26. 82 0
      tools/prototype/build_device_tests.py
  27. 81 0
      tools/prototype/build_wav_tests.py

+ 347 - 0
docs/build-plan.md

@@ -0,0 +1,347 @@
+# mpckit 1.0 build plan
+
+Written 2026-11-10. Companion to `mpckit/xpj-format-and-scope.md` (format reference, verified edit recipes, agreed 1.0 scope) and the "mpckit main screen mockup" design artifact. Read the format doc first; this plan does not repeat the field paths.
+
+## 1. What 1.0 is
+
+A desktop app (Linux, Windows, Mac) for building MPC Sample kits on a computer and exporting them to the device.
+
+- Project: new, load, name, export to folder or zip.
+- Pads, banks A-H x 16: assign a WAV, replace, clear, move, copy, swap.
+- Per pad: tuning (semitones -24 to +24, cents -90 to +90) and mute group.
+- Click a pad to hear it, with tuning and mute-group choking applied.
+- Sequences, 128 slots: add, remove, name, BPM, bar length. No note editing.
+- WAV only.
+
+Not in 1.0: start/end trim and waveform, level and pan, loop and trigger modes, envelopes, filters, effects, pad colours, other audio formats, songs, note editing.
+
+## 2. Stack
+
+- Go for all file work.
+- Wails v3 (released 3.0.0) for the window, dialogs, file drop and the Go-to-frontend bridge.
+- Svelte + TypeScript + Vite for the UI (plain Svelte, not SvelteKit).
+- No database, no network access, no settings file in 1.0.
+
+Wails pieces this plan relies on (confirm against the installed version at setup):
+- Services: Go structs registered with the app; TypeScript bindings generated with `wails3 generate bindings`.
+- Dialogs: `app.Dialog.OpenFile()` and `SaveFile()`; a cancelled dialog returns an empty string and no error.
+- File drop: `EnableFileDrop: true` on the window, `data-file-drop-target` on each drop element, and a `WindowFilesDropped` event carrying real file paths plus the target element's id. HTML drag-and-drop inside the page keeps working alongside it.
+- Setup: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0`, then `wails3 setup`.
+
+## 3. Repository layout
+
+```
+mpckit/
+  main.go                    Wails app entry: window, services, embedded frontend
+  internal/
+    xpj/                     container + ordered JSON tree (no domain knowledge)
+    wav/                     WAV header reader (format, channels, rate, bits, frame count)
+    project/                 domain model and every edit operation
+    export/                  folder and zip writers
+    app/                     Wails services, DTOs, audio route, file-drop handling
+  frontend/                  Svelte + TS + Vite
+    src/lib/                 PadGrid, BankBar, PadPanel, SequencePanel, AboutModal, stores, audio
+  testdata/
+    device/                  DemoBlank, Demo02, DemoFile with their data folders (saved on the device)
+    accepted/                MpckitT1, T2, T3 with their data folders (written by the prototype, loaded on the device)
+  demo-projects/             existing folder; move into testdata/device
+  docs/                      copies of the format doc and this plan
+  Taskfile.yml               build tasks from the Wails template
+```
+
+Rule: `internal/xpj`, `wav`, `project` and `export` import nothing from Wails and are fully testable with `go test`. Only `internal/app` and `main.go` touch Wails.
+
+## 4. Design decisions
+
+### 4.1 Ordered JSON tree (`internal/xpj`)
+
+The device accepts files only if we stay close to what it writes, so the tree must round-trip byte-for-byte.
+
+- Container: gunzip, split off the five header lines, parse the rest. On write: header + body, gzip with modification time 0 and OS byte 3.
+- Parse with `json.Decoder` token by token and `UseNumber()`, building our own tree: objects keep key order (slice of key/value pairs), numbers keep their original text.
+- Write in the device's layout: one key or value per line, no indentation, `": "` after keys, no trailing newline. Do not escape `<`, `>`, `&` or `/`.
+- New numeric values: integer fields as integers (`4`); float fields always with a decimal point (`96.0`, `20.0`, `5.0`, never `96`). Tuning `pitch` is a 32-bit float widened to 64-bit and printed in shortest form (the device shows `12.399999618530273` for 12.4).
+- Float fields written in 1.0: `seq.bpm`, `pad.tempo`, `layer.pitch`, and `metadata.tempo` / `metadata.tune` on new pool entries. Everything else we write is an integer or a string.
+- Helpers: get/set by path, deep clone of a subtree, insert and remove array elements.
+
+Why not `map[string]any` or a path-patching library: maps lose key order, and we need to insert whole objects (pool entries, sequences) in the device's layout.
+
+### 4.2 Template
+
+`DemoBlank.xpj` is embedded in the binary with `go:embed`. It is the source for:
+- New project (the whole file).
+- Clear pad (any of its 128 identical pads).
+- Add sequence (its single empty sequence).
+
+### 4.3 Project model (`internal/project`)
+
+A `Project` wraps one tree plus things the file does not hold:
+- `Name` (file and folder name).
+- `SourceDir`: the data folder of a loaded project, empty for a new one.
+- `Sources`: for each pool file name, the absolute path it will be copied from at export (a file in `SourceDir`, or a WAV the user picked).
+- `Dirty` flag.
+
+Every edit is a method that changes the tree using the recipes in the format doc:
+
+| Operation | Notes |
+|---|---|
+| `AssignSample(slot, path)` | Reads the WAV header; adds or reuses a pool entry; sets layer name/file, `sliceInfo.End`, pad tempo 20.0, `LoopCrossfadeLength` -1. Replacing an occupied pad clears it first. |
+| `ClearPad(slot)` | Replaces the pad with the blank pad; drops the pool entry if no pad still uses it. |
+| `CopyPad(from, to)` / `MovePad` / `SwapPads` | Whole-pad copies. Copies share the pool entry (device-tested). |
+| `PadHasNotes(slot)` | True if any sequence has a note or automation event on note 36 + slot. Drives the move warning. |
+| `SetTune(slot, coarse, fine)` | Writes `coarseTune`, `fineTune`, `pitch` on the layer. Clamps to the 1.0 ranges. |
+| `SetMuteGroup(slot, n)` | `whichMuteGroup`; 0 is off. |
+| `AddSequence(slot)` | Clones the template sequence; name `Sequence NN` (slot + 1); BPM copied from the most recently edited sequence, else 120; 2 bars. |
+| `RemoveSequence(slot)` | Refused when it is the last sequence. If it was `currentSequence`, point that at the lowest remaining slot. |
+| `SetSequence(slot, name, bpm, bars)` | Length change writes all 26 fields. |
+
+Pool rules:
+- Pool name = file name without extension; `path` = file name. The entry goes in both `samples[]` and `tracks[0].samples[]`.
+- Same source file on several pads: one entry, one file.
+- Different source files with the same file name: add a `001`, `002` suffix, as the device does.
+- Entries that came with a loaded project keep their metadata untouched. New entries use `loadImpl` 0, tempo 0.0, root note 60, tune 0.0, key "C Natural Minor" (the values the device wrote for the non-factory sample).
+- Limit: 128 samples. Refuse the 129th with a clear message.
+
+Validation at the edge of the model: WAV must be uncompressed (integer PCM or 32-bit float), mono or stereo, at most 20 minutes; `sliceInfo.End` is the file's own frame count minus 1 at its own sample rate; project and sequence names non-empty and free of characters that are illegal in file names on Windows or FAT; bars at least 1; BPM positive.
+
+### 4.4 Export (`internal/export`)
+
+- Folder: the user picks a destination; write `<Name>.xpj` and `<Name>_[ProjectData]/` with every pool file. Create the data folder even when empty. If either already exists, ask before replacing. Write to a temporary name in the same directory and rename at the end.
+- Zip: the same two entries at the zip root.
+- Before writing, check every source file still exists; report all missing files in one message.
+- There is no separate Save in 1.0: Load reads a project, Export writes one.
+
+### 4.5 Playback preview
+
+- Go serves the selected pad's source WAV to the frontend at a local route (the app's asset handler, or the built-in file-server service).
+- The frontend decodes it with Web Audio and plays it through a buffer source with `detune` = coarse x 100 + fine (cents). Pitch and speed change together, as on the device.
+- For loaded pads that already carry a start/end trim, play only that region so the preview matches the device, even though trim is not editable in 1.0.
+- Mute-group choking: the frontend tracks which pads are sounding and their mute group. Starting a pad first stops every other sounding pad in the same non-zero group, with a 5-10 ms fade so the cut does not click. Group 0 (off) never chokes. The fade length is our choice, not measured from the device.
+- A new click on the same pad restarts it (pads are monophonic on the device).
+- No effects.
+
+### 4.6 Frontend state
+
+- Go is the single source of truth. Each service call returns a fresh state object and the Svelte store replaces its contents.
+- State object: project name, dirty flag, 128 pads (slot, label, file name, coarse, fine, mute group, has notes), sequences (slot, name, BPM, bars, has notes), sample and sequence counts.
+- UI-only state stays in Svelte: active tab, selected bank and slot per tab, About open.
+- Unsaved-changes prompt on New, Load and window close when dirty.
+
+## 5. Service API (`internal/app`)
+
+```
+New() State
+Load() (State, error)                       // open dialog, .xpj filter
+SetName(name) (State, error)
+
+AssignSampleDialog(slot) (State, error)     // open dialog, .wav filter
+AssignSamplePath(slot, path) (State, error) // used by file drop
+ClearPad(slot) State
+MovePad(from, to) State
+CopyPad(from, to) State
+SwapPads(a, b) State
+SetTune(slot, coarse, fine) State
+SetMuteGroup(slot, group) State
+
+AddSequence(slot) State
+RemoveSequence(slot) (State, error)
+SetSequence(slot, name, bpm, bars) (State, error)
+
+ExportFolder() (string, error)              // returns the path written
+ExportZip() (string, error)
+Version() string
+```
+
+File drop: each pad button carries `data-file-drop-target` and an id such as `pad-17`; the Go handler for `WindowFilesDropped` takes the first `.wav` path and calls the same code as `AssignSamplePath`, then emits a state-changed event.
+
+## 6. Milestones
+
+Each ends with something testable. Order matters; nothing in the UI starts until the file layer is proven.
+
+**M1. Container and tree.**
+Done when: all three device projects load and re-write byte-identical after decompression, in `go test`.
+
+**M2. Edit operations.**
+Done when:
+- Building from the blank template with the same edits as the prototype produces output identical to `MpckitT1.xpj` and `MpckitT3.xpj` in `testdata/accepted` (these exact files loaded on the device).
+- Rebuilding `Demo02` from the blank template matches the device's file except the known noise (`tracks[0].colour`, `assignableEnvelopeFollowerAssignments.currentControlIndex`) and recorded events.
+- Unit tests cover clear, copy, move, swap, pool reuse, name collision, the 128 limit, sequence add/remove/length, and `PadHasNotes` against `DemoFile`.
+
+**M3. WAV reader, export, and a small CLI.**
+A throwaway command (`go run ./cmd/mpckit-cli`) that builds a project from flags, for device tests without the UI.
+Done when: folder and zip exports are correct on disk, and Device checkpoint A passes.
+
+**M4. App shell.**
+Wails project, window, embedded Svelte app, the mockup's layout and styling with static data, About modal, generated bindings wired to a store.
+Done when: `wails3 dev` shows the two tabs driven by a `New()` state from Go.
+
+**M5. Pads tab.**
+Assign by dialog and by file drop, replace, clear, tuning sliders, mute group, playback preview with tuning and mute-group choking, counts in the footer.
+Done when: a kit built entirely in the UI exports and matches what the CLI produces for the same edits, and two pads in one mute group cut each other off in the preview.
+
+**M6. Move, copy, swap.**
+HTML drag between pads (move; swap onto a filled pad; Alt for copy), dragging over a bank button switches bank, the "Move to pad" and "Copy to pad" buttons as the non-drag route, and the recorded-notes warning.
+Done when: all three work across banks and the warning appears only for pads with notes.
+
+**M7. Sequences tab.**
+Slot grid, add, remove, name, BPM, bars.
+Done when: edits match the M2 golden outputs through the UI.
+
+**M8. Load and guard rails.**
+Load an existing project, missing-file badges, unsaved-changes prompts, overwrite confirmation, error messages for bad WAVs and limits.
+Done when: Device checkpoint B passes.
+
+**M9. Packaging and release.**
+Icons, version stamping, builds for the three systems, README with install notes, licence.
+Done when: a tagged 1.0 build runs on each system.
+
+## 7. Device checkpoints
+
+Run by hand on the MPC Sample. Record results in the format doc.
+
+**A (after M3), files from the Go writer:**
+1. A kit with samples on A1-A4 and B1 loads and plays.
+2. Tuning at the extremes: -24, +24 semitones and -90, +90 cents load and show those values.
+3. Mute groups: confirm the highest group number the device offers; two pads in one group choke each other.
+4. WAV formats: covered by the separate WAV test pack below, which can be run before any Go code exists.
+5. A sequence at 1 bar and one at a long length (for example 64 bars); BPM at the device's lowest and highest.
+
+**WAV test pack (`mpckit-wav-tests.zip`, built 2026-11-10 with the prototype writer):**
+
+Akai's published spec for the device: file import at 16 or 24-bit and 44.1, 48 or 96 kHz; internal processing at 44.1 kHz, 32-bit float. That describes importing through the device's own browser. mpckit places files straight into the project folder, so the test checks whether the device still converts them when it loads a project.
+
+Every file is the same one-second sound: 0.8 s at 440 Hz, then 0.2 s an octave higher as an end marker. Each pad should match A1 in pitch and length, and the high beep must be heard at the end.
+
+| Project | Pad | File |
+|---|---|---|
+| `MpckitW1` | A1 | 44.1 kHz 16-bit mono (control) |
+| | A2 | 44.1 kHz 16-bit stereo |
+| | A3 | 44.1 kHz 24-bit stereo |
+| | A4 | 48 kHz 16-bit stereo |
+| | A5 | 48 kHz 24-bit stereo |
+| | A6 | 96 kHz 24-bit stereo |
+| | A7 | 22.05 kHz 16-bit mono (outside the spec) |
+| `MpckitW2` | A1 | 44.1 kHz 16-bit mono (control) |
+| | A2 | 44.1 kHz 8-bit mono (outside the spec) |
+| | A3 | 44.1 kHz 32-bit integer stereo (outside the spec) |
+| | A4 | 44.1 kHz 32-bit float stereo (outside the spec) |
+
+How to read the results:
+- Lower pitch and longer than A1: the device played the file as if it were 44.1 kHz. mpckit must then convert such files at export, or refuse them.
+- End beep missing: the slice end is being read in 44.1 kHz frames. mpckit must then scale `sliceInfo.End` for other sample rates.
+- Project fails to load, or a pad is silent: that format is refused; mpckit rejects it at assign time.
+
+Results (2026-11-10, on the device): both projects loaded and every pad in W1 and W2 played at the correct pitch and through to the end beep, including the four formats outside Akai's spec.
+
+Conclusions:
+- The device reads each WAV's own header when it loads a project and converts as needed. mpckit does not have to convert audio.
+- `sliceInfo.End` is counted in the file's own frames at its own sample rate. (The 22.05 kHz pad settles this: read as 44.1 kHz frames it would have stopped halfway, before the beep.)
+- Accepted so far: 8, 16, 24 and 32-bit integer and 32-bit float; mono and stereo; 22.05, 44.1, 48 and 96 kHz.
+
+1.0 rule for assigning a file: accept any uncompressed WAV (integer PCM or 32-bit float), mono or stereo. Refuse compressed WAVs (ADPCM, MP3-in-WAV), more than two channels, and anything over 20 minutes.
+
+Not yet tested: WAVs with the "extensible" header that many audio tools write for 24-bit files; sample rates above 96 kHz or below 22.05 kHz; 64-bit float. Accept the extensible header when it declares PCM or float inside, and add one such file to checkpoint A.
+
+**B (after M8), round trips through the app:**
+1. Load `DemoFile` (recorded notes), change nothing, export: sequences still play the same on the device.
+2. Load `DemoFile`, move A1 to A5 after the warning: A5 plays the kick, the notes still point at A1.
+3. Copy a pad across banks; swap two pads.
+4. Remove a sequence; add one in a different bank.
+5. Shorten a sequence that has notes: note what the device does with notes past the end.
+
+## 8. Builds
+
+- Wails apps are built on each target system. Use one machine or CI runner per system; the Linux VPS alone cannot produce the Mac build.
+- Linux needs the GTK and WebKitGTK development packages that `wails3 setup` reports; Windows needs the WebView2 runtime (present on current Windows); macOS builds are unsigned unless a developer certificate is added, so the README explains the first-run prompt.
+- Version comes from the git tag and shows in the About modal.
+
+## 9. Risks and open questions
+
+| Item | Handling |
+|---|---|
+| Firmware update changes the format | The header version (`1.3.0.12`) and `data.version` (28) are checked on load; warn when they differ from the tested values. |
+| WAV variants the device rejects or plays wrongly | Largely retired by the WAV test pack (section 7): all eleven formats played correctly. Remaining unknowns are listed there. Akai's spec caps a sample at 20 minutes; refuse longer files. |
+| The app's preview cannot decode a WAV the device accepts | Each system's webview has its own audio decoder, and 8-bit, 32-bit integer or extensible files may fail in some. Try the webview's decoder first; if it fails, fall back to a Go route that decodes the PCM itself and returns 32-bit float samples for the frontend to play. `internal/wav` therefore needs a small decoder as well as the header reader. |
+| Mute group count unknown | Checkpoint A3; the UI list is sized from the result. |
+| Shortening sequences with notes | Checkpoint B5; until then warn and leave notes untouched. |
+| Tuning limits taken from a community toolkit | Checkpoint A2. |
+| Very long or unusual file names | Keep names as they are; reject only characters illegal on Windows/FAT. Revisit if the device truncates. |
+| File drop quirks per system | Dialog-based assign is always available as the fallback. |
+
+## 10. Before the first coding session
+
+1. Commit the contents of `mpckit-device-tests.zip` to `testdata/accepted/` and move `demo-projects/` to `testdata/device/`.
+2. Copy this plan and the format doc into `docs/`.
+3. Confirm the repo's branch rules (direct pushes to main, or pull requests only).
+4. Confirm the coding session can reach git.monoexp.com.
+5. Pick a licence, and decide the three build machines or runners.
+
+## Appendix: reference writer
+
+The Python prototype that produced the three device-accepted test projects. It is the behaviour the Go code must reproduce (M2). `F` keeps each decimal's original text.
+
+```python
+import gzip, json, copy, struct, re
+
+class F(str): pass
+
+def read_xpj(path):
+    raw = gzip.decompress(open(path, 'rb').read()).decode('utf-8')
+    lines = raw.split('\n', 5)
+    return lines[:5], json.loads(lines[5], parse_float=lambda s: F('\x00F' + s))
+
+def dump_body(doc):
+    s = json.dumps(doc, indent=0, separators=(',', ': '), ensure_ascii=False)
+    return re.sub(r'"\\u0000F([^"]+)"', r'\1', s)
+
+def write_xpj(path, header, doc):
+    raw = ('\n'.join(header) + '\n' + dump_body(doc)).encode('utf-8')
+    gz = bytearray(gzip.compress(raw, compresslevel=6, mtime=0))
+    gz[8] = 0; gz[9] = 3            # XFL 0, OS = Unix
+    open(path, 'wb').write(bytes(gz))
+
+def f32(x):
+    return struct.unpack('f', struct.pack('f', x))[0]
+
+def sample(name):
+    return {"version": 1, "name": name, "path": name + ".wav", "loadImpl": 0,
+            "metadata": {"tempo": 0.0, "rootNote": 60, "tune": 0.0, "key": "C Natural Minor"}}
+
+def add_sample(d, name):
+    d['samples'].append(sample(name))
+    d['tracks'][0]['samples'].insert(0, sample(name))
+
+def assign(d, slot, name, frames, coarse=0, fine=0):
+    pad = d['tracks'][0]['program']['drum']['instruments'][slot]
+    layer = pad['layersv'][0]
+    pad['tempo'] = 20.0
+    layer['sampleName'] = name
+    layer['sampleFile'] = name + '.wav'
+    layer['sliceInfo']['End'] = frames - 1
+    layer['sliceInfo']['LoopCrossfadeLength'] = -1
+    if coarse or fine:
+        layer['coarseTune'] = coarse
+        layer['fineTune'] = fine
+        layer['pitch'] = f32(coarse + fine / 100)
+
+def set_len(seq, bars):
+    pulses = bars * 3840
+    seq['lengthBars'] = bars; seq['loopEndBar'] = bars
+    seq['lengthPulses'] = pulses; seq['loopEndPulses'] = pulses
+    for clip in seq['trackClipMaps'][0]:
+        clip['value']['endPulses'] = pulses
+        clip['value']['loopEndPulses'] = pulses
+
+# MpckitT1: built from DemoBlank
+header, doc = read_xpj('DemoBlank.xpj'); d = doc['data']
+add_sample(d, 'Linn 9000 kick'); add_sample(d, 'Linn 9000 kick001')
+assign(d, 0, 'Linn 9000 kick', 8213)
+assign(d, 16, 'Linn 9000 kick001', 8213, coarse=5)
+s0 = d['sequences'][0]
+s0['value']['name'] = 'Verse'; s0['value']['bpm'] = 96.0; set_len(s0['value'], 8)
+s1 = copy.deepcopy(s0); s1['key'] = 1; s1['value']['name'] = 'Chorus'; set_len(s1['value'], 4)
+d['sequences'] = [s1, s0]
+write_xpj('MpckitT1.xpj', header, doc)
+# MpckitT3 is the same with one pool entry ('Linn 9000 kick') used by both pads.
+# MpckitT2 is T1 with the WAV files reduced to fmt + data chunks.
+```

+ 450 - 0
docs/mockup/Main.dc.html

@@ -0,0 +1,450 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>mpckit main screen</title>
+<script src="./support.js"></script>
+</head>
+<body>
+<x-dc>
+<helmet>
+<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&amp;family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;700&amp;display=swap" rel="stylesheet">
+<style>
+body{margin:0;background:#15171A}
+*{box-sizing:border-box}
+a{color:#F5A524}a:hover{color:#FFC35A}
+</style>
+</helmet>
+<div style="max-width: 1280px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 20px; background: #15171A; color: #F2F3F5; font-family: Inter, system-ui, sans-serif; font-size: 14px; line-height: 1.4">
+
+<div style="display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid #353A43">
+<h1 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 24px; letter-spacing: 0.02em">mpckit</h1>
+<button type="button" onClick="{{openAbout}}" style="min-height: 44px; padding: 0 8px; background: transparent; color: {{accent}}; border: 0; font: inherit; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; cursor: pointer">About</button>
+</div>
+
+<header style="display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px">
+<div style="display: flex; flex-direction: column; gap: 6px; flex: 1 1 240px; min-width: 0; max-width: 360px">
+<label for="project-name" style="font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Project name</label>
+<input id="project-name" type="text" value="{{name}}" onChange="{{setName}}" style="height: 44px; padding: 0 12px; background: #1E2126; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 600; width: 100%">
+</div>
+<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto">
+<button type="button" style="height: 44px; padding: 0 16px; background: #1E2126; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">New</button>
+<button type="button" style="height: 44px; padding: 0 16px; background: #1E2126; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Load</button>
+<button type="button" style="height: 44px; padding: 0 16px; background: #1E2126; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Export zip</button>
+<button type="button" style="height: 44px; padding: 0 16px; background: {{accent}}; color: #15171A; border: 1px solid {{accent}}; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">Export to folder</button>
+</div>
+</header>
+
+<div role="tablist" aria-label="Views" style="display: flex; gap: 4px; border-bottom: 1px solid #353A43">
+<button type="button" role="tab" aria-selected="{{padsTab.selected}}" onClick="{{padsTab.pick}}" style="height: 48px; padding: 0 22px; background: transparent; color: {{padsTab.fg}}; border: 0; border-bottom: 3px solid {{padsTab.line}}; font-family: 'Space Grotesk', sans-serif; font-size: 16px; font-weight: 700; cursor: pointer">Pads</button>
+<button type="button" role="tab" aria-selected="{{seqsTab.selected}}" onClick="{{seqsTab.pick}}" style="height: 48px; padding: 0 22px; background: transparent; color: {{seqsTab.fg}}; border: 0; border-bottom: 3px solid {{seqsTab.line}}; font-family: 'Space Grotesk', sans-serif; font-size: 16px; font-weight: 700; cursor: pointer">Sequences</button>
+</div>
+
+<sc-if value="{{showPads}}" hint-placeholder-val="{{ true }}">
+<main style="display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start">
+
+<section aria-label="Pads" style="flex: 1.4 1 380px; min-width: 0; background: #1E2126; border: 1px solid #353A43; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 14px">
+<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px">
+<h2 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Pad bank</h2>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: #A9AEB8">{{bankSummary}}</span>
+</div>
+<div style="display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px">
+<sc-for list="{{banks}}" as="b" hint-placeholder-count="8">
+<button type="button" onClick="{{b.pick}}" aria-pressed="{{b.pressed}}" aria-label="{{b.aria}}" style="height: 44px; padding: 0; background: {{b.bg}}; color: {{b.fg}}; border: 1px solid {{b.border}}; border-radius: 6px; font-family: 'Space Grotesk', sans-serif; font-size: 15px; font-weight: 700; cursor: pointer">{{b.label}}</button>
+</sc-for>
+</div>
+<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px">
+<sc-for list="{{cells}}" as="pad" hint-placeholder-count="16">
+<button type="button" onClick="{{pad.pick}}" aria-pressed="{{pad.pressed}}" aria-label="{{pad.aria}}" style="aspect-ratio: 1 / 1; min-height: 72px; padding: 8px; display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; gap: 4px; text-align: left; background: {{pad.bg}}; color: #F2F3F5; border: 2px solid {{pad.border}}; border-radius: 8px; font: inherit; cursor: pointer; overflow: hidden">
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; color: {{pad.numColor}}">{{pad.label}}</span>
+<span style="font-size: 12px; font-weight: 600; line-height: 1.25; overflow: hidden; max-width: 100%; overflow-wrap: anywhere">{{pad.name}}</span>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #C9CDD4; min-height: 15px">{{pad.badges}}</span>
+</button>
+</sc-for>
+</div>
+<p style="margin: 0; font-size: 12px; color: #A9AEB8">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
+</section>
+
+<section aria-label="Pad settings" style="flex: 1 1 320px; min-width: 0; background: #1E2126; border: 1px solid #353A43; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 16px">
+<div style="display: flex; align-items: center; gap: 12px">
+<div style="font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: {{accent}}">{{sel.id}}</div>
+<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
+<h2 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Pad settings</h2>
+<div style="font-weight: 600; overflow-wrap: anywhere">{{sel.file}}</div>
+</div>
+</div>
+
+<sc-if value="{{sel.empty}}" hint-placeholder-val="{{ false }}">
+<div style="display: flex; flex-direction: column; gap: 12px; padding: 20px; border: 1px dashed #4A505B; border-radius: 8px">
+<p style="margin: 0; color: #C9CDD4">This pad is empty. Drop a WAV file on it, or choose one.</p>
+<button type="button" style="height: 44px; padding: 0 16px; align-self: flex-start; background: {{accent}}; color: #15171A; border: 1px solid {{accent}}; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">Choose WAV file</button>
+</div>
+</sc-if>
+
+<sc-if value="{{sel.has}}" hint-placeholder-val="{{ true }}">
+<div style="display: flex; flex-direction: column; gap: 16px">
+<div style="display: flex; flex-wrap: wrap; gap: 8px">
+<button type="button" style="height: 44px; padding: 0 16px; display: flex; align-items: center; gap: 8px; background: {{accent}}; color: #15171A; border: 1px solid {{accent}}; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">
+<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z"></path></svg>
+Play
+</button>
+<button type="button" style="height: 44px; padding: 0 16px; background: #2A2D33; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Replace</button>
+<button type="button" onClick="{{clearPad}}" style="height: 44px; padding: 0 16px; background: #2A2D33; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Clear</button>
+</div>
+
+<div style="display: flex; flex-direction: column; gap: 8px">
+<h3 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Tuning</h3>
+<div style="display: flex; flex-direction: column; gap: 4px">
+<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px">
+<label for="tune-coarse" style="font-size: 12px; color: #C9CDD4">Semitones</label>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 14px; font-weight: 500; color: #F2F3F5">{{sel.coarseText}}</span>
+</div>
+<input id="tune-coarse" type="range" min="-24" max="24" step="1" value="{{sel.coarse}}" onChange="{{setCoarse}}" style="width: 100%; height: 44px; margin: 0; accent-color: {{accent}}; cursor: pointer">
+<div aria-hidden="true" style="display: flex; justify-content: space-between; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #A9AEB8">
+<span>-24</span>
+<span>0</span>
+<span>+24</span>
+</div>
+</div>
+<div style="display: flex; flex-direction: column; gap: 4px">
+<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px">
+<label for="tune-fine" style="font-size: 12px; color: #C9CDD4">Cents</label>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 14px; font-weight: 500; color: #F2F3F5">{{sel.fineText}}</span>
+</div>
+<input id="tune-fine" type="range" min="-90" max="90" step="1" value="{{sel.fine}}" onChange="{{setFine}}" style="width: 100%; height: 44px; margin: 0; accent-color: {{accent}}; cursor: pointer">
+<div aria-hidden="true" style="display: flex; justify-content: space-between; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #A9AEB8">
+<span>-90</span>
+<span>0</span>
+<span>+90</span>
+</div>
+</div>
+</div>
+
+<div style="display: flex; flex-direction: column; gap: 4px">
+<label for="mute-group" style="font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Mute group</label>
+<select id="mute-group" value="{{sel.mute}}" onChange="{{setMute}}" style="height: 44px; padding: 0 10px; width: 100%; background: #15171A; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit">
+<option value="0">Off</option>
+<option value="1">Group 1</option>
+<option value="2">Group 2</option>
+<option value="3">Group 3</option>
+<option value="4">Group 4</option>
+</select>
+<span style="font-size: 12px; color: #A9AEB8">Pads in the same group cut each other off on the device.</span>
+</div>
+
+<div style="display: flex; flex-wrap: wrap; gap: 8px; padding-top: 12px; border-top: 1px solid #353A43">
+<button type="button" style="height: 44px; padding: 0 16px; background: #2A2D33; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Move to pad</button>
+<button type="button" style="height: 44px; padding: 0 16px; background: #2A2D33; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Copy to pad</button>
+</div>
+</div>
+</sc-if>
+</section>
+
+</main>
+</sc-if>
+
+<sc-if value="{{showSeqs}}" hint-placeholder-val="{{ false }}">
+<main style="display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start">
+
+<section aria-label="Sequence slots" style="flex: 1.4 1 380px; min-width: 0; background: #1E2126; border: 1px solid #353A43; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 14px">
+<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px">
+<h2 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Sequence bank</h2>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: #A9AEB8">{{seqBankSummary}}</span>
+</div>
+<div style="display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px">
+<sc-for list="{{seqBanks}}" as="b" hint-placeholder-count="8">
+<button type="button" onClick="{{b.pick}}" aria-pressed="{{b.pressed}}" aria-label="{{b.aria}}" style="height: 44px; padding: 0; background: {{b.bg}}; color: {{b.fg}}; border: 1px solid {{b.border}}; border-radius: 6px; font-family: 'Space Grotesk', sans-serif; font-size: 15px; font-weight: 700; cursor: pointer">{{b.label}}</button>
+</sc-for>
+</div>
+<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px">
+<sc-for list="{{seqCells}}" as="q" hint-placeholder-count="16">
+<button type="button" onClick="{{q.pick}}" aria-pressed="{{q.pressed}}" aria-label="{{q.aria}}" style="aspect-ratio: 1 / 1; min-height: 72px; padding: 8px; display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; gap: 4px; text-align: left; background: {{q.bg}}; color: #F2F3F5; border: 2px solid {{q.border}}; border-radius: 8px; font: inherit; cursor: pointer; overflow: hidden">
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 500; color: {{q.numColor}}">{{q.label}}</span>
+<span style="font-size: 12px; font-weight: 600; line-height: 1.25; overflow: hidden; max-width: 100%; overflow-wrap: anywhere">{{q.name}}</span>
+<span style="font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #C9CDD4; min-height: 15px">{{q.meta}}</span>
+</button>
+</sc-for>
+</div>
+<p style="margin: 0; font-size: 12px; color: #A9AEB8">Each pad is one sequence slot, 128 in total. Click an empty slot to add a sequence there.</p>
+</section>
+
+<section aria-label="Sequence settings" style="flex: 1 1 320px; min-width: 0; background: #1E2126; border: 1px solid #353A43; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 16px">
+<div style="display: flex; align-items: center; gap: 12px">
+<div style="font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: {{accent}}">{{seq.id}}</div>
+<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
+<h2 style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: #A9AEB8">Sequence settings</h2>
+<div style="font-weight: 600; overflow-wrap: anywhere">{{seq.title}}</div>
+</div>
+</div>
+
+<sc-if value="{{seq.empty}}" hint-placeholder-val="{{ false }}">
+<div style="display: flex; flex-direction: column; gap: 12px; padding: 20px; border: 1px dashed #4A505B; border-radius: 8px">
+<p style="margin: 0; color: #C9CDD4">No sequence in this slot.</p>
+<button type="button" onClick="{{addSeq}}" style="height: 44px; padding: 0 16px; align-self: flex-start; background: {{accent}}; color: #15171A; border: 1px solid {{accent}}; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">Add sequence</button>
+</div>
+</sc-if>
+
+<sc-if value="{{seq.has}}" hint-placeholder-val="{{ true }}">
+<div style="display: flex; flex-direction: column; gap: 16px">
+<div style="display: flex; flex-direction: column; gap: 4px">
+<label for="seq-name" style="font-size: 12px; color: #C9CDD4">Name</label>
+<input id="seq-name" type="text" value="{{seq.name}}" onChange="{{setSeqName}}" style="height: 44px; padding: 0 10px; width: 100%; background: #15171A; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit">
+</div>
+<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
+<div style="display: flex; flex-direction: column; gap: 4px">
+<label for="seq-bpm" style="font-size: 12px; color: #C9CDD4">BPM</label>
+<input id="seq-bpm" type="number" min="20" max="300" step="1" value="{{seq.bpm}}" onChange="{{setSeqBpm}}" style="height: 44px; padding: 0 10px; width: 100%; background: #15171A; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font-family: 'IBM Plex Mono', monospace; font-size: 14px">
+</div>
+<div style="display: flex; flex-direction: column; gap: 4px">
+<label for="seq-bars" style="font-size: 12px; color: #C9CDD4">Length (bars)</label>
+<input id="seq-bars" type="number" min="1" step="1" value="{{seq.bars}}" onChange="{{setSeqBars}}" style="height: 44px; padding: 0 10px; width: 100%; background: #15171A; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font-family: 'IBM Plex Mono', monospace; font-size: 14px">
+</div>
+</div>
+<span style="font-size: 12px; color: #A9AEB8">Notes are recorded on the device. This screen sets name, tempo and length only.</span>
+<div style="display: flex; flex-wrap: wrap; gap: 8px; padding-top: 12px; border-top: 1px solid #353A43">
+<button type="button" onClick="{{removeSeq}}" style="height: 44px; padding: 0 16px; background: #2A2D33; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">Remove sequence</button>
+</div>
+</div>
+</sc-if>
+</section>
+
+</main>
+</sc-if>
+
+<footer style="display: flex; flex-wrap: wrap; gap: 8px 24px; font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: #A9AEB8">
+<span>{{sampleCount}}</span>
+<span>{{seqCount}}</span>
+<span>WAV files only</span>
+</footer>
+
+<sc-if value="{{aboutOpen}}" hint-placeholder-val="{{ false }}">
+<div style="position: fixed; top: 0; right: 0; bottom: 0; left: 0; padding: 24px; display: flex; align-items: center; justify-content: center; background: rgba(10, 11, 13, 0.78)">
+<div role="dialog" aria-modal="true" aria-labelledby="about-title" style="width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 14px; background: #1E2126; color: #F2F3F5; border: 1px solid #4A505B; border-radius: 12px">
+<h2 id="about-title" style="margin: 0; font-family: 'Space Grotesk', sans-serif; font-size: 22px; font-weight: 700">About mpckit</h2>
+<p style="margin: 0; color: #C9CDD4">A desktop project and kit editor for the MPC Sample. Build kits on your computer, then export them to the device.</p>
+<div style="display: flex; flex-direction: column; gap: 6px; font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: #C9CDD4">
+<span>Version [VERSION]</span>
+<span>[LICENSE]</span>
+<a href="https://git.monoexp.com/bajdev/mpckit">git.monoexp.com/bajdev/mpckit</a>
+</div>
+<p style="margin: 0; font-size: 12px; color: #A9AEB8">An independent project, not affiliated with Akai Professional.</p>
+<button type="button" onClick="{{closeAbout}}" style="height: 44px; padding: 0 20px; align-self: flex-end; background: {{accent}}; color: #15171A; border: 1px solid {{accent}}; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">Close</button>
+</div>
+</div>
+</sc-if>
+
+</div>
+</x-dc>
+<script type="text/x-dc" data-dc-script data-props='{"accent":{"editor":"color","default":"#F5A524","options":["#F5A524","#5BC0EB","#9BE564","#FF8A5B"]},"$preview":{"width":1280,"height":860}}'>
+class Component extends DCLogic {
+state = {
+name: 'MyKit',
+aboutOpen: false,
+tab: 'pads',
+bank: 0,
+sel: 0,
+seqBank: 0,
+selSeq: 0,
+pads: {
+0: { file: 'Kick.wav', frames: 8213, coarse: 0, fine: 0, start: 0, end: 8212, mute: 0 },
+1: { file: 'Snare.wav', frames: 27000, coarse: 0, fine: 0, start: 0, end: 26999, mute: 0 },
+2: { file: 'Hat Closed.wav', frames: 22446, coarse: 0, fine: 0, start: 0, end: 22445, mute: 1 },
+3: { file: 'Hat Open.wav', frames: 108000, coarse: 0, fine: 0, start: 0, end: 107999, mute: 1 },
+4: { file: 'Kick.wav', frames: 8213, coarse: 12, fine: 40, start: 328, end: 3777, mute: 0 },
+16: { file: 'Pad Loop Cm.wav', frames: 371368, coarse: 0, fine: 0, start: 0, end: 371367, mute: 0 },
+17: { file: 'Bass C.wav', frames: 132557, coarse: -2, fine: 0, start: 0, end: 132556, mute: 0 }
+},
+seqs: {
+0: { name: 'Intro', bpm: 110, bars: 2 },
+1: { name: 'Verse', bpm: 110, bars: 8 },
+2: { name: 'Chorus', bpm: 110, bars: 4 }
+}
+};
+renderVals() {
+var self = this;
+var s = this.state;
+var accent = this.props.accent ?? '#F5A524';
+var letters = 'ABCDEFGH';
+var two = function (n) { return n < 10 ? '0' + n : '' + n; };
+var num = function (e) { var v = parseInt(e.target.value, 10); return isNaN(v) ? 0 : v; };
+var signed = function (n) { return (n > 0 ? '+' : '') + n; };
+var setPad = function (patch) {
+self.setState(function (st) {
+var pads = Object.assign({}, st.pads);
+pads[st.sel] = Object.assign({}, pads[st.sel], patch);
+return { pads: pads };
+});
+};
+var setSeq = function (patch) {
+self.setState(function (st) {
+var seqs = Object.assign({}, st.seqs);
+seqs[st.selSeq] = Object.assign({}, seqs[st.selSeq], patch);
+return { seqs: seqs };
+});
+};
+var tuneText = function (p) {
+if (!p.coarse && !p.fine) return '';
+var v = p.coarse + p.fine / 100;
+return (v > 0 ? '+' : '') + v.toFixed(2);
+};
+var countIn = function (map, bank) {
+var c = 0;
+for (var k = 0; k < 16; k++) { if (map[bank * 16 + k]) c++; }
+return c;
+};
+var bankButtons = function (map, current, what, pick) {
+return letters.split('').map(function (label, i) {
+var on = i === current;
+return {
+label: label,
+pressed: on ? 'true' : 'false',
+aria: 'Bank ' + label + ', ' + countIn(map, i) + ' of 16 ' + what,
+bg: on ? accent : '#2A2D33',
+fg: on ? '#15171A' : '#F2F3F5',
+border: on ? accent : '#4A505B',
+pick: function () { pick(i); }
+};
+});
+};
+var tabVals = function (id) {
+var on = s.tab === id;
+return {
+selected: on ? 'true' : 'false',
+fg: on ? '#F2F3F5' : '#A9AEB8',
+line: on ? accent : 'transparent',
+pick: function () { self.setState({ tab: id }); }
+};
+};
+
+var banks = bankButtons(s.pads, s.bank, 'pads filled', function (i) { self.setState({ bank: i, sel: i * 16 }); });
+var seqBanks = bankButtons(s.seqs, s.seqBank, 'slots used', function (i) { self.setState({ seqBank: i, selSeq: i * 16 }); });
+
+var cells = [];
+var seqCells = [];
+for (var row = 3; row >= 0; row--) {
+for (var col = 0; col < 4; col++) {
+(function (padNo) {
+var index = s.bank * 16 + padNo - 1;
+var p = s.pads[index];
+var on = index === s.sel;
+var label = letters[s.bank] + two(padNo);
+var badges = '';
+if (p) {
+var parts = [];
+var t = tuneText(p);
+if (t) parts.push(t);
+if (p.mute) parts.push('M' + p.mute);
+badges = parts.join(' · ');
+}
+cells.push({
+label: label,
+name: p ? p.file.replace(/\.wav$/i, '') : '',
+badges: badges,
+pressed: on ? 'true' : 'false',
+aria: 'Pad ' + label + (p ? ', ' + p.file : ', empty'),
+bg: p ? '#333842' : '#22252B',
+border: on ? accent : (p ? '#5A616E' : '#353A43'),
+numColor: on ? accent : '#A9AEB8',
+pick: function () { self.setState({ sel: index }); }
+});
+
+var qIndex = s.seqBank * 16 + padNo - 1;
+var q = s.seqs[qIndex];
+var qOn = qIndex === s.selSeq;
+var qLabel = letters[s.seqBank] + two(padNo);
+seqCells.push({
+label: qLabel,
+name: q ? q.name : '',
+meta: q ? q.bpm + ' · ' + q.bars + (q.bars === 1 ? ' bar' : ' bars') : '',
+pressed: qOn ? 'true' : 'false',
+aria: 'Sequence slot ' + qLabel + (q ? ', ' + q.name : ', empty'),
+bg: q ? '#333842' : '#22252B',
+border: qOn ? accent : (q ? '#5A616E' : '#353A43'),
+numColor: qOn ? accent : '#A9AEB8',
+pick: function () { self.setState({ selSeq: qIndex }); }
+});
+})(row * 4 + col + 1);
+}
+}
+
+var sp = s.pads[s.sel];
+var selId = letters[Math.floor(s.sel / 16)] + two(s.sel % 16 + 1);
+var sel = sp ? {
+id: selId, has: true, empty: false, file: sp.file,
+coarse: sp.coarse, fine: sp.fine, mute: '' + sp.mute,
+coarseText: signed(sp.coarse) + ' st', fineText: signed(sp.fine) + ' ct'
+} : {
+id: selId, has: false, empty: true, file: 'Empty',
+coarse: 0, fine: 0, mute: '0', coarseText: '0 st', fineText: '0 ct'
+};
+
+var sq = s.seqs[s.selSeq];
+var seqId = letters[Math.floor(s.selSeq / 16)] + two(s.selSeq % 16 + 1);
+var seq = sq ? {
+id: seqId, has: true, empty: false, title: 'Sequence ' + two(s.selSeq + 1),
+name: sq.name, bpm: sq.bpm, bars: sq.bars
+} : {
+id: seqId, has: false, empty: true, title: 'Empty slot',
+name: '', bpm: 120, bars: 2
+};
+
+var files = {};
+Object.keys(s.pads).forEach(function (k) { if (s.pads[k]) files[s.pads[k].file] = true; });
+var seqTotal = Object.keys(s.seqs).filter(function (k) { return s.seqs[k]; }).length;
+
+return {
+accent: accent,
+name: s.name,
+setName: function (e) { self.setState({ name: e.target.value }); },
+aboutOpen: s.aboutOpen,
+openAbout: function () { self.setState({ aboutOpen: true }); },
+closeAbout: function () { self.setState({ aboutOpen: false }); },
+padsTab: tabVals('pads'),
+seqsTab: tabVals('seqs'),
+showPads: s.tab === 'pads',
+showSeqs: s.tab === 'seqs',
+banks: banks,
+bankSummary: 'Bank ' + letters[s.bank] + ' · ' + countIn(s.pads, s.bank) + ' of 16 filled',
+cells: cells,
+sel: sel,
+setCoarse: function (e) { setPad({ coarse: Math.max(-24, Math.min(24, num(e))) }); },
+setFine: function (e) { setPad({ fine: Math.max(-90, Math.min(90, num(e))) }); },
+setMute: function (e) { setPad({ mute: num(e) }); },
+clearPad: function () {
+self.setState(function (st) {
+var pads = Object.assign({}, st.pads);
+delete pads[st.sel];
+return { pads: pads };
+});
+},
+seqBanks: seqBanks,
+seqBankSummary: 'Bank ' + letters[s.seqBank] + ' · ' + countIn(s.seqs, s.seqBank) + ' of 16 used',
+seqCells: seqCells,
+seq: seq,
+setSeqName: function (e) { setSeq({ name: e.target.value }); },
+setSeqBpm: function (e) { setSeq({ bpm: Math.max(1, num(e)) }); },
+setSeqBars: function (e) { setSeq({ bars: Math.max(1, num(e)) }); },
+addSeq: function () {
+self.setState(function (st) {
+var seqs = Object.assign({}, st.seqs);
+var bpm = 120;
+Object.keys(seqs).forEach(function (k) { if (seqs[k]) bpm = seqs[k].bpm; });
+seqs[st.selSeq] = { name: 'Sequence ' + two(st.selSeq + 1), bpm: bpm, bars: 2 };
+return { seqs: seqs };
+});
+},
+removeSeq: function () {
+self.setState(function (st) {
+var seqs = Object.assign({}, st.seqs);
+delete seqs[st.selSeq];
+return { seqs: seqs };
+});
+},
+sampleCount: Object.keys(files).length + ' of 128 samples',
+seqCount: seqTotal + ' of 128 sequences'
+};
+}
+}
+</script>
+</body>
+</html>

+ 251 - 0
docs/xpj-format-and-scope.md

@@ -0,0 +1,251 @@
+# mpckit: MPC Sample project format and 1.0 / 2.0 scope
+
+Sources: three projects saved on an MPC Sample, in `demo-projects/` of `bajdev/mpckit`, analysed 2026-11-10.
+
+- `DemoBlank.xpj`: new empty project.
+- `DemoFile.xpj`: 6 factory samples on pads A1-A4 and B1-B2, two sequences with recorded notes.
+- `Demo02.xpj`: one non-factory sample on pad 1, and a copy on pad 5 with tuning, start and end changed.
+
+Everything below is observed in those files unless marked as an open question. Three projects written with these recipes have been loaded and played on the device (see section 7).
+
+## Decisions so far
+
+- Desktop only (Linux, Windows, Mac). No hosted website.
+- UI: Wails v3 with a Svelte frontend. Go owns all file work.
+- The `.xpj` reader/writer is its own Go package with no UI code.
+- New projects are built from the blank project as a template; edits change only known fields.
+
+## 1.0 scope (agreed 2026-11-10)
+
+Intended use: set up new kits on the desktop, then move to the device to create sequences and tracks.
+
+In:
+- Project name.
+- Pads, banks A-H x 16: assign a WAV, clear, move and copy between pads (section 3).
+- Per pad: tuning by sliders (semitones -24 to +24, cents -90 to +90) and mute group.
+- Pad playback preview in the app, applying tuning and mute-group choking. A preview only: no effects.
+- Sequences: add, name, BPM, bar length. No note editing.
+- Export to a folder or zip: the `.xpj` plus its `_[ProjectData]` folder.
+- Audio: WAV only.
+
+UI (mockup agreed 2026-11-10, "mpckit main screen mockup" artifact):
+- Title row with an About link that opens a modal.
+- Project name with New, Load, Export zip, Export to folder.
+- Two tabs: Pads and Sequences. Each has bank buttons A-H, a 4x4 grid (pad 1 bottom-left) and a settings panel for the selected slot.
+- Pads: click to select and play; drag to move, drag onto a filled pad to swap, Alt-drag to copy; drop a WAV to assign.
+
+2.0 or later:
+- Start and end trim, together with a waveform view (moved out of 1.0: not useful without the waveform). The file fields are already verified (section 3).
+- Level and pan (fields not yet confirmed from a device file).
+- Trigger and loop modes, envelopes, filters, effects, pad colours.
+- Sequence editing (notes), including making notes follow a moved pad.
+- Other audio formats, songs.
+
+## 1. Container
+
+| Item | Observed |
+|---|---|
+| File | gzip stream; about 47-52 KB on disk, about 3 MB uncompressed |
+| Header | five text lines: `ACVS`, `1.3.0.12`, `SerialisableProjectData`, `json`, `Linux` |
+| Body | one JSON object, `{"data": {...}}`, 66 top-level keys under `data` |
+| Layout | one key or value per line, no indentation, `": "` after keys, ASCII only |
+| Product | `originalCreatorProductIdentifier` and `lastSavedProductIdentifier` are both `AC50`; `data.version` is 28 |
+| Audio | sibling folder `<ProjectName>_[ProjectData]/` holding the sample files; file names keep their spaces |
+| Project name | not stored in the file; the name is the file name plus the folder name |
+| Empty project | the device creates the data folder even when it holds no samples |
+
+## 2. What the blank project establishes
+
+- All 128 pads are identical, and identical to the empty pads in the other two projects. One blank pad is the template for "clear pad".
+- It holds one sequence: slot 0, "Sequence 01", 128 BPM, 2 bars, no events. This is the template for a new sequence.
+- Outside pads, samples and sequences, the three projects differ in only three fields:
+  - `tracks[0].colour` (a different colour per project)
+  - `engineMode` ("Sample" or "Sequence": the mode the device was in when saved)
+  - `assignableEnvelopeFollowerAssignments.currentControlIndex` (varies with no pattern; treat as noise)
+- The same 11 tracks are always present: `Drum 001` (all 128 pads), `Submix 1`-`Submix 8`, `Out 1/2`, `Out 3/4`.
+- Insert effects on the input and `Out 1/2` (LP Filter, Pad-Controlled FXs, Flex Beat Player, Color Compressor) exist even in the blank project. Their settings are opaque text blobs.
+
+## 3. Verified edit recipes
+
+Paths are relative to `data`. `pad[n]` means `tracks[0].program.drum.instruments[n]`, where n = bank x 16 + pad - 1 (pad 5 of bank A is n = 4, confirmed). `layer` means `pad[n].layersv[0]`.
+
+**Check:** applying these recipes to `DemoBlank` reproduces `Demo02` (ignoring recorded notes) with 4 differences in 141,561 values: the track colour, the noise field above, one float rounding (fixed by rule below), and one `LoopCrossfadeLength`.
+
+### Rename project
+Rename the `.xpj` and the `_[ProjectData]` folder together. Nothing inside the file changes.
+
+### Assign a sample to a pad
+1. Put the audio file in the data folder under the exact name used in `path`.
+2. Add a pool entry to both `samples[]` and `tracks[0].samples[]`:
+   `{version: 1, name, path, loadImpl: 0, metadata: {tempo: 0.0, rootNote: 60, tune: 0.0, key}}`
+3. Set `layer.sampleName` and `layer.sampleFile`.
+4. Set `layer.sliceInfo.End` to the frame count minus 1 (true for all 8 samples seen).
+5. Set `pad[n].tempo` to 20.0 (the value the device writes when the sample has no tempo).
+6. Set `layer.sliceInfo.LoopCrossfadeLength` to -1 (what the device wrote for an untouched non-factory sample).
+
+The device gave the second pad its own copy of the file (`Linn 9000 kick001.wav`, byte-identical to the first) and its own pool entry. Sharing also works: a test project with two pads pointing at one file and one pool entry loaded and played (section 7), so mpckit can store each file once.
+
+### Move or copy a pad (proposed, not yet device-tested)
+A pad's settings hold nothing that depends on its slot: all 128 blank pads are identical. So:
+- Copy: duplicate the whole `pad[n]` object into the target slot. Both pads can point at the same file and pool entry (sharing is device-tested).
+- Move: copy, then reset the source slot to the blank pad.
+- Swap: exchange the two `pad[n]` objects.
+
+Recorded notes follow the slot, not the sample (a note stores 36 + n). Moving a pad that has notes in a sequence changes what those notes play. Decided for 1.0: warn before moving a pad that has recorded notes, and leave the notes untouched. Rewriting notes to follow the pad belongs with sequence editing, a later feature.
+
+### Tune a pad
+Three fields on the layer, written together:
+- `layer.coarseTune` (semitones, integer; demo: 12)
+- `layer.fineTune` (cents, integer; demo: 40)
+- `layer.pitch` = coarse + fine / 100, stored as a 32-bit float (demo: 12.399999618530273)
+
+### Trim start and end
+- `layer.sliceInfo.Start` and `layer.sliceInfo.LoopStart` = start frame (demo: 328)
+- `layer.sliceInfo.End` = end frame (demo: 3777)
+- Trimming does not touch the audio file. `layer.sampleStart` and `sampleEnd` stay 0.
+
+### Rename a sequence / change BPM
+One field each: `seq.name`, `seq.bpm` (`seq` = `sequences[].value`).
+
+### Change sequence length
+Exactly 26 fields, confirmed by comparing a 2-bar and a 4-bar sequence:
+- `seq.lengthBars`, `seq.loopEndBar` = bars
+- `seq.lengthPulses`, `seq.loopEndPulses` = bars x 3840 (4/4, 960 pulses per beat)
+- `endPulses` and `loopEndPulses` on each of the 11 clips in `seq.trackClipMaps[0]`
+
+### Add a sequence
+Copy the blank sequence, set `sequences[].key` to the slot (0-based), then name, BPM and length as above. Only sequences that exist are stored, and the device does not write them in slot order.
+
+## 4. Round-trip rules
+
+- Edit only known fields and write everything else back as read.
+- Keep key order. Go maps do not keep order.
+- Keep number text. About 300 of 49,000 decimals per file would be re-spelled by a shortest-form encoder.
+- New float values that the device stores as 32-bit (tuning, levels) should be rounded to 32-bit before writing.
+- Verified: all three files can be parsed and re-written byte-for-byte (after decompression) when key order and number text are kept.
+- Suggested Go approach: path-based edits on the raw JSON bytes, or an ordered decoder that keeps numbers as text. Avoid `map[string]any` with `json.Marshal`.
+
+## 5. Properties by group
+
+### Project
+
+| Property | Path | Seen | Proposed |
+|---|---|---|---|
+| Project name | file name and `_[ProjectData]` folder name | DemoFile | 1.0 |
+| Master tempo | `masterTempo`, `masterTempoEnabled` | 128.0, false in all three | 2.0 (sequences carry their own BPM) |
+| Project key | `key` | "C Major" | 2.0 |
+| Info tags | `info.title`, `artist`, `originalArtist`, `album`, `year`, `genre`, `copyright`, `notes` | all empty | 2.0 |
+| Quantise | `quantiser.enabled`, `timeDivision`, `swingLevel` | true, 5, 0.0 | 2.0 |
+| Master volume | `mixer.volume` | 0.7079 | 2.0 |
+| Track colour | `tracks[0].colour` | differs per project | preserve |
+| Saved mode | `engineMode` | "Sample", "Sequence" | preserve |
+
+### Sample pool
+
+| Property | Path | Seen | Proposed |
+|---|---|---|---|
+| Name | `samples[].name` | "Linn 9000 kick" | 1.0 |
+| File | `samples[].path` | "Linn 9000 kick.wav" | 1.0 |
+| Load mode | `samples[].loadImpl` | 0 for one-shots and the user sample; 1 for three factory loops | 1.0, write 0 |
+| Tempo | `samples[].metadata.tempo` | 0.0 for the user sample; 20-114 for factory | 1.0, write 0.0 |
+| Root note, tune | `samples[].metadata.rootNote`, `tune` | 60, 0.0 | 1.0, defaults |
+| Key | `samples[].metadata.key` | "C Natural Minor" on the user sample | 1.0, default |
+
+### Pad: assignment, tuning, trim (verified)
+
+| Property | Path | Proposed |
+|---|---|---|
+| Sample | `layer.sampleName`, `layer.sampleFile` | 1.0 |
+| Slice end | `layer.sliceInfo.End` | 1.0, computed |
+| Pad tempo | `pad[n].tempo` | 1.0, computed |
+| Tune | `layer.coarseTune`, `fineTune`, `pitch` | 1.0 |
+| Start, end | `layer.sliceInfo.Start`, `LoopStart`, `End` | 2.0, with waveform view |
+
+### Pad: playback
+
+| Property | Path | Seen | Proposed |
+|---|---|---|---|
+| Mute group | `pad[n].whichMuteGroup` | 0 none; 1 on both hats in DemoFile | 1.0 |
+| Level, pan | `pad[n].mixable.volume`, `pan` | 0.7079 (unity), 0.5 centre | 2.0 (not yet seen changed) |
+| Trigger mode | `pad[n].triggerMode` | 0; 3 on the looped pad | 2.0 until mapped |
+| Layer loop | `layer.loopMode`, `sliceInfo.LoopMode` | 0/1 | 2.0 until mapped |
+| One-shot | `pad[n].zonePlayTime` | 1 on every pad | 2.0 |
+| Mono / voices | `pad[n].monophonic`, `polyphony` | true, 1 | 2.0 |
+| Mute, solo | `pad[n].mixable.mute`, `solo` | false | 2.0 |
+
+### Pad: sound shaping (all 2.0)
+
+| Property | Path |
+|---|---|
+| Amp envelope | `pad[n].synthSection.ampEnvelope.Attack`, `Hold`, `Decay`, `Sustain`, `Release`, curves |
+| Filter | `synthSection.filterData.value0/1`: `filterType`, `filterCutoff`, `filterResonance`, `filterEnvelopeAmount` |
+| Other envelopes | `synthSection.filterEnvelope`, `pitchEnvelope`, `auxEnvelope` |
+| LFOs | `synthSection.lfoData.value0/1` |
+| Velocity response | `synthSection.velocitySensitivity`, `velocityToPitch`, `velocityToPan`, `velocityToStart` |
+| Pad effects | `pad[n].padEffects.padEffectsData.value0-7` (`fxType`, `fxValue`) |
+| Sends, routing | `pad[n].mixable.sends[4]`, `audioRoute` |
+| Stretch, chop | `pad[n].bpmLock`, `warpEnable`, `stretchPercentage`, `chopProperties.*` |
+| Loop points | `layer.loopStart`, `loopEnd`, `loopCrossfadeLength` |
+| Velocity range, randomise | `layer.velocityStart`, `velocityEnd`, `pitchRandom`, `VolumeRandom`, `PanRandom`, `OffsetRandom` |
+| Pad colours | `tracks[0].program.programPads` |
+
+### Sequence
+
+| Property | Path | Seen | Proposed |
+|---|---|---|---|
+| Slot | `sequences[].key` | 0, 1 | 1.0 |
+| Name | `seq.name` | "Intro", "Sequence 02" | 1.0 |
+| BPM | `seq.bpm`, `seq.tempoEnable` | 105, 110, 128; true | 1.0 |
+| Length | `seq.lengthBars` plus the 25 dependent fields | 2, 4 | 1.0 |
+| Time signature | `seq.timeSignatureTrack.timeSignatures[]` | 4/4 | 2.0 |
+| Transpose | `seq.transposition` | 0 | 2.0 |
+| Notes | `...eventList.events[]` type 3: `time`, `note`, `velocity`, `length`, `probability`, `ratchet` | up to 32 notes | preserve |
+| Automation | events type 1: `automation.parameter`, `note`, `value` | parameter 131 only | preserve |
+
+### Songs, mixer, controller state
+
+| Property | Path | Proposed |
+|---|---|---|
+| Songs | `songs[0-31]`: `name`, `ignoreTempo`, `items` | 2.0 |
+| Submix and output tracks | `tracks[1-10]`, `mixer.submixes`, `mixer.outputs` | preserve |
+| Insert effects | `...mixable.inserts.effects[].plugin` (`description`, `state` blob) | preserve |
+| Q-Link, XY, crossfader assignments | `qlink*`, `assignable*` | preserve |
+| Scenes, snapshots | `scene`, `parameterSnapshotterData` | preserve |
+| UI state | `tui*`, `mpcControlSurfaceBehaviour`, `padPerformSettings`, `arpeggiatorProperties` | preserve |
+
+## 6. Audio files
+
+- Seen: 44.1 kHz, 16-bit mono (user sample), 16-bit and 24-bit stereo (factory).
+- Every file saved by the device carries extra chunks: `atem` (JSON: default slice, bar count, key), `smpl`, and on factory files `meta`. The non-factory kick has `atem` and `smpl` too, so the device adds them itself when it saves.
+- The sample's key in the project (`C Natural Minor`) matches the `atem` chunk in its file.
+- Device-tested 2026-11-10 with plain WAVs placed straight into the project folder: 8, 16, 24 and 32-bit integer and 32-bit float; mono and stereo; 22.05, 44.1, 48 and 96 kHz all loaded and played at the correct pitch and full length. The device converts on load, so mpckit does not need to. `sliceInfo.End` is in the file's own frames at its own sample rate. Details and remaining unknowns are in the build plan, section 7.
+
+## 7. Device tests
+
+`mpckit-device-tests.zip` holds three projects built from `DemoBlank` with the recipes above. Each has the kick on A1, the kick tuned +5 on B1, sequence 1 "Verse" (96 BPM, 8 bars) and sequence 2 "Chorus" (96 BPM, 4 bars).
+
+| Project | Difference | Answers |
+|---|---|---|
+| `MpckitT1` | device-saved WAVs, one file per pad | does the device load a file written by our writer? |
+| `MpckitT2` | plain WAVs with the Akai chunks removed | can users bring any ordinary WAV? |
+| `MpckitT3` | both pads point at one file and one pool entry | can pads share a sample? |
+
+Results (2026-11-10, on the device): all three projects loaded, and pads A1 and B1 played in each. So the writer's output is accepted, plain WAVs without Akai chunks work, and two pads can share one file and one pool entry.
+
+Tuning confirmed: B1 was written as +5 semitones and the device shows +5.
+
+Sequences confirmed: the device shows "Verse" (96 BPM, 8 bars) and "Chorus" (96 BPM, 4 bars).
+
+Project name confirmed: the device shows `MpckitT1`, taken from the file and folder name.
+
+Every item on the original 1.0 list (project name, pad sample assignment, sequence name, BPM, bar length, adding a sequence) plus pad tuning has now been written by these recipes and accepted by the device.
+
+## 8. Open questions
+
+1. Device-test a moved and a copied pad.
+1a. Confirm the tuning limits on the device. The sliders use -24 to +24 semitones and -90 to +90 cents, taken from a community toolkit (MPC Sample Toolkit) that reports them as tested on hardware. We have only confirmed +5 and +12.40 ourselves.
+2. Level and pan have not been seen changed; confirm they are written to `pad[n].mixable`.
+3. `triggerMode` values (0 and 3 seen) and how they pair with `loopMode`.
+4. When a sequence with notes is shortened, are notes past the new end kept or removed?
+5. WAVs with the "extensible" header and sample rates outside 22.05-96 kHz are untested. (Other WAV bit depths and sample rates are confirmed; non-WAV formats are out of 1.0.)
+6. What key and tempo to write for a sample mpckit imports (the device detects them; mpckit cannot yet).

BIN
testdata/accepted/MpckitT1.xpj


BIN
testdata/accepted/MpckitT1_[ProjectData]/Linn 9000 kick.wav


BIN
testdata/accepted/MpckitT1_[ProjectData]/Linn 9000 kick001.wav


BIN
testdata/accepted/MpckitT2.xpj


BIN
testdata/accepted/MpckitT2_[ProjectData]/Linn 9000 kick.wav


BIN
testdata/accepted/MpckitT2_[ProjectData]/Linn 9000 kick001.wav


BIN
testdata/accepted/MpckitT3.xpj


BIN
testdata/accepted/MpckitT3_[ProjectData]/Linn 9000 kick.wav


BIN
testdata/wav-formats/MpckitW1.xpj


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/22k 16bit mono.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/44k 16bit mono.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/44k 16bit stereo.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/44k 24bit stereo.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/48k 16bit stereo.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/48k 24bit stereo.wav


BIN
testdata/wav-formats/MpckitW1_[ProjectData]/96k 24bit stereo.wav


BIN
testdata/wav-formats/MpckitW2.xpj


BIN
testdata/wav-formats/MpckitW2_[ProjectData]/44k 16bit mono.wav


BIN
testdata/wav-formats/MpckitW2_[ProjectData]/44k 32bit float stereo.wav


BIN
testdata/wav-formats/MpckitW2_[ProjectData]/44k 32bit int stereo.wav


BIN
testdata/wav-formats/MpckitW2_[ProjectData]/44k 8bit mono.wav


+ 7 - 0
tools/prototype/README.md

@@ -0,0 +1,7 @@
+# Prototype scripts
+
+Python scripts used during planning to build the test projects in `testdata/accepted` and `testdata/wav-formats`.
+All of those projects loaded and played correctly on an MPC Sample (2026-11-10).
+
+They are reference only: the paths at the top of each script point at the planning workspace and need changing before they will run.
+The Go writer must produce the same output for the same edits (build plan, milestone M2).

+ 82 - 0
tools/prototype/build_device_tests.py

@@ -0,0 +1,82 @@
+import gzip, json, copy, struct, os, re, shutil, sys, zipfile
+SRC = '/home/claude/work/zip/x/demo-projects'
+OUT = '/home/claude/work/out'
+class F(str): pass   # float literal kept as original text
+def read_xpj(path):
+    raw = gzip.decompress(open(path,'rb').read()).decode('utf-8')
+    lines = raw.split('\n',5)
+    return lines[:5], json.loads(lines[5], parse_float=lambda s: F('\x00F'+s))
+def dump_body(doc):
+    s = json.dumps(doc, indent=0, separators=(',',': '), ensure_ascii=False)
+    return re.sub(r'"\\u0000F([^"]+)"', r'\1', s)
+def write_xpj(path, header, doc):
+    raw = ('\n'.join(header)+'\n'+dump_body(doc)).encode('utf-8')
+    gz = bytearray(gzip.compress(raw, compresslevel=6, mtime=0)); gz[8]=0; gz[9]=3
+    open(path,'wb').write(bytes(gz))
+# 1. round-trip check: blank project must come back byte-identical once decompressed
+for n in ('DemoBlank.xpj','Demo02.xpj','DemoFile.xpj'):
+    h, doc = read_xpj(os.path.join(SRC,n))
+    orig = gzip.decompress(open(os.path.join(SRC,n),'rb').read())
+    again = ('\n'.join(h)+'\n'+dump_body(doc)).encode()
+    print("round-trip identical:", n, orig==again)
+def f32(x): return struct.unpack('f', struct.pack('f', x))[0]
+def sample(name): return {"version":1,"name":name,"path":name+".wav","loadImpl":0,"metadata":{"tempo":0.0,"rootNote":60,"tune":0.0,"key":"C Natural Minor"}}
+def add_sample(d, name):
+    d['samples'].append(sample(name)); d['tracks'][0]['samples'].insert(0, sample(name))
+def assign(d, idx, name, frames, coarse=0, fine=0):
+    pad = d['tracks'][0]['program']['drum']['instruments'][idx]; l = pad['layersv'][0]
+    pad['tempo'] = 20.0
+    l['sampleName']=name; l['sampleFile']=name+'.wav'
+    l['sliceInfo']['End']=frames-1; l['sliceInfo']['LoopCrossfadeLength']=-1
+    if coarse or fine:
+        l['coarseTune']=coarse; l['fineTune']=fine; l['pitch']=f32(coarse+fine/100)
+def set_len(v, bars):
+    p = bars*3840
+    v['lengthBars']=bars; v['loopEndBar']=bars; v['lengthPulses']=p; v['loopEndPulses']=p
+    for c in v['trackClipMaps'][0]: c['value']['endPulses']=p; c['value']['loopEndPulses']=p
+def wav_info(path):
+    b = open(path,'rb').read(); pos=12; fmt=None; data=None
+    while pos+8<=len(b):
+        cid=b[pos:pos+4]; sz=struct.unpack('<I',b[pos+4:pos+8])[0]
+        if cid==b'fmt ': fmt=b[pos+8:pos+8+sz]
+        if cid==b'data': data=b[pos+8:pos+8+sz]
+        pos+=8+sz+(sz&1)
+    return fmt, data
+def plain_wav(src, dst):
+    fmt, data = wav_info(src)
+    body = b'WAVE'+b'fmt '+struct.pack('<I',len(fmt))+fmt+b'data'+struct.pack('<I',len(data))+data+(b'\x00' if len(data)&1 else b'')
+    open(dst,'wb').write(b'RIFF'+struct.pack('<I',len(body))+body)
+KICK = os.path.join(SRC,'Demo02_[ProjectData]','Linn 9000 kick.wav')
+fmt, data = wav_info(KICK); FRAMES = len(data)//struct.unpack('<HHIIHH',fmt[:16])[4]
+def build(name, variant):
+    h, doc = read_xpj(os.path.join(SRC,'DemoBlank.xpj')); d = doc['data']
+    folder = os.path.join(OUT, name+'_[ProjectData]'); os.makedirs(folder, exist_ok=True)
+    if variant=='shared':
+        add_sample(d,'Linn 9000 kick'); assign(d,0,'Linn 9000 kick',FRAMES); assign(d,16,'Linn 9000 kick',FRAMES,coarse=5)
+        shutil.copy(KICK, os.path.join(folder,'Linn 9000 kick.wav'))
+    else:
+        add_sample(d,'Linn 9000 kick'); add_sample(d,'Linn 9000 kick001')
+        assign(d,0,'Linn 9000 kick',FRAMES); assign(d,16,'Linn 9000 kick001',FRAMES,coarse=5)
+        for fn in ('Linn 9000 kick.wav','Linn 9000 kick001.wav'):
+            (plain_wav if variant=='plain' else shutil.copy)(KICK, os.path.join(folder,fn))
+    s0 = d['sequences'][0]; s0['value']['name']='Verse'; s0['value']['bpm']=96.0; set_len(s0['value'],8)
+    s1 = copy.deepcopy(s0); s1['key']=1; s1['value']['name']='Chorus'; set_len(s1['value'],4)
+    d['sequences']=[s1,s0]
+    write_xpj(os.path.join(OUT,name+'.xpj'), h, doc)
+shutil.rmtree(OUT); os.makedirs(OUT)
+build('MpckitT1','baseline'); build('MpckitT2','plain'); build('MpckitT3','shared')
+# verify outputs
+for n in ('MpckitT1','MpckitT2','MpckitT3'):
+    raw = gzip.decompress(open(os.path.join(OUT,n+'.xpj'),'rb').read()).decode()
+    d = json.loads(raw.split('\n',5)[5])['data']
+    pads = [(i,l['sampleFile'],l['coarseTune'],l['pitch'],l['sliceInfo']['End']) for i,x in enumerate(d['tracks'][0]['program']['drum']['instruments']) for l in x['layersv'][:1] if l['sampleName']]
+    files = sorted(os.listdir(os.path.join(OUT,n+'_[ProjectData]')))
+    print(n, "| header ok:", raw.startswith('ACVS\n1.3.0.12\nSerialisableProjectData\njson\nLinux\n{'), "| pool", [s['path'] for s in d['samples']], "| pads", pads,
+          "| seqs", [(s['key'],s['value']['name'],s['value']['bpm'],s['value']['lengthBars'],s['value']['lengthPulses'],{c['value']['endPulses'] for c in s['value']['trackClipMaps'][0]}) for s in d['sequences']],
+          "| files", files, "| all referenced exist:", all(s['path'] in files for s in d['samples']))
+print("plain wav chunks size:", os.path.getsize(os.path.join(OUT,'MpckitT2_[ProjectData]','Linn 9000 kick.wav')), "vs device", os.path.getsize(KICK), "frames", FRAMES)
+z = zipfile.ZipFile('/home/claude/work/mpckit-device-tests.zip','w',zipfile.ZIP_DEFLATED)
+for r,_,fs in os.walk(OUT):
+    for f in fs:
+        p=os.path.join(r,f); z.write(p, os.path.relpath(p, OUT))
+z.close(); print(sorted(zipfile.ZipFile('/home/claude/work/mpckit-device-tests.zip').namelist()))

+ 81 - 0
tools/prototype/build_wav_tests.py

@@ -0,0 +1,81 @@
+import gzip, json, copy, struct, re, os, math, shutil, zipfile
+SRC = '/home/claude/work/zip/x/demo-projects'
+OUT = '/home/claude/work/out_wav'
+class F(str): pass
+def read_xpj(path):
+    raw = gzip.decompress(open(path,'rb').read()).decode('utf-8')
+    lines = raw.split('\n',5)
+    return lines[:5], json.loads(lines[5], parse_float=lambda s: F('\x00F'+s))
+def dump_body(doc):
+    s = json.dumps(doc, indent=0, separators=(',',': '), ensure_ascii=False)
+    return re.sub(r'"\\u0000F([^"]+)"', r'\1', s)
+def write_xpj(path, header, doc):
+    raw = ('\n'.join(header)+'\n'+dump_body(doc)).encode('utf-8')
+    gz = bytearray(gzip.compress(raw, compresslevel=6, mtime=0)); gz[8]=0; gz[9]=3
+    open(path,'wb').write(bytes(gz))
+def sample(name): return {"version":1,"name":name,"path":name+".wav","loadImpl":0,"metadata":{"tempo":0.0,"rootNote":60,"tune":0.0,"key":"C Natural Minor"}}
+def assign(d, idx, name, frames):
+    d['samples'].append(sample(name)); d['tracks'][0]['samples'].insert(0, sample(name))
+    pad = d['tracks'][0]['program']['drum']['instruments'][idx]; l = pad['layersv'][0]
+    pad['tempo'] = 20.0
+    l['sampleName']=name; l['sampleFile']=name+'.wav'
+    l['sliceInfo']['End']=frames-1; l['sliceInfo']['LoopCrossfadeLength']=-1
+def tone(rate):
+    # 0.8 s at 440 Hz, then 0.2 s at 880 Hz as an audible end marker; short fades
+    n = rate; out = []
+    for i in range(n):
+        t = i/rate; f = 440.0 if t < 0.8 else 880.0
+        env = min(1.0, i/(0.005*rate), (n-1-i)/(0.005*rate))
+        out.append(0.5*env*math.sin(2*math.pi*f*t))
+    return out
+def make_wav(path, rate, bits, ch, fmt='int'):
+    mono = tone(rate); frames = len(mono)
+    if fmt == 'float':
+        tag = 3; pack = lambda v: struct.pack('<f', v)
+    else:
+        tag = 1
+        if bits == 8:  pack = lambda v: struct.pack('<B', int(round(127.5 + 127*v)))
+        if bits == 16: pack = lambda v: struct.pack('<h', int(round(32767*v)))
+        if bits == 24: pack = lambda v: struct.pack('<i', int(round(8388607*v)))[:3]
+        if bits == 32: pack = lambda v: struct.pack('<i', int(round(2147483647*v)))
+    data = b''.join(pack(v)*ch for v in mono)
+    align = ch*bits//8
+    fmtc = struct.pack('<HHIIHH', tag, ch, rate, rate*align, align, bits)
+    body = b'WAVE' + b'fmt ' + struct.pack('<I',16) + fmtc + b'data' + struct.pack('<I',len(data)) + data + (b'\x00' if len(data)&1 else b'')
+    open(path,'wb').write(b'RIFF' + struct.pack('<I',len(body)) + body)
+    return frames
+def build(name, specs):
+    h, doc = read_xpj(os.path.join(SRC,'DemoBlank.xpj')); d = doc['data']
+    folder = os.path.join(OUT, name+'_[ProjectData]'); os.makedirs(folder)
+    rows = []
+    for idx,(label, rate, bits, ch, fmt) in enumerate(specs):
+        frames = make_wav(os.path.join(folder, label+'.wav'), rate, bits, ch, fmt)
+        assign(d, idx, label, frames)
+        rows.append((f"A{idx+1:02d}", label, frames, os.path.getsize(os.path.join(folder,label+'.wav'))))
+    write_xpj(os.path.join(OUT, name+'.xpj'), h, doc)
+    return rows
+if os.path.exists(OUT): shutil.rmtree(OUT)
+os.makedirs(OUT)
+W1 = [('44k 16bit mono',44100,16,1,'int'),('44k 16bit stereo',44100,16,2,'int'),('44k 24bit stereo',44100,24,2,'int'),
+      ('48k 16bit stereo',48000,16,2,'int'),('48k 24bit stereo',48000,24,2,'int'),('96k 24bit stereo',96000,24,2,'int'),
+      ('22k 16bit mono',22050,16,1,'int')]
+W2 = [('44k 16bit mono',44100,16,1,'int'),('44k 8bit mono',44100,8,1,'int'),('44k 32bit int stereo',44100,32,2,'int'),('44k 32bit float stereo',44100,32,2,'float')]
+for n,s in (('MpckitW1',W1),('MpckitW2',W2)):
+    for r in build(n,s): print(n, r)
+# verify with an independent reader
+import wave
+for r,_,fs in os.walk(OUT):
+    for f in sorted(fs):
+        p = os.path.join(r,f)
+        if f.endswith('.wav'):
+            try:
+                w = wave.open(p); print("  ok", f, w.getframerate(), w.getsampwidth()*8, w.getnchannels(), w.getnframes()); w.close()
+            except Exception as e: print("  wave module:", f, e)
+        else:
+            raw = gzip.decompress(open(p,'rb').read()).decode(); d = json.loads(raw.split('\n',5)[5])['data']
+            print(" ", f, "pool", len(d['samples']), "pads", [(i, l['sliceInfo']['End']) for i,x in enumerate(d['tracks'][0]['program']['drum']['instruments']) for l in x['layersv'][:1] if l['sampleName']])
+z = zipfile.ZipFile('/home/claude/work/mpckit-wav-tests.zip','w',zipfile.ZIP_DEFLATED)
+for r,_,fs in os.walk(OUT):
+    for f in fs:
+        p=os.path.join(r,f); z.write(p, os.path.relpath(p, OUT))
+z.close(); print(os.path.getsize('/home/claude/work/mpckit-wav-tests.zip'), sorted(zipfile.ZipFile('/home/claude/work/mpckit-wav-tests.zip').namelist()))