1
0
Эх сурвалжийг харах

Merge branch 'm2-edit-operations' of bajdev/mpckit into main

bajdev 2 өдөр өмнө
parent
commit
e2799e8c33

+ 1 - 1
docs/build-plan.md

@@ -60,7 +60,7 @@ The device accepts files only if we stay close to what it writes, so the tree mu
 - 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).
+- 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 (12.4 is written `12.399999618530273`; the device wrote the same float64 with 17 digits, `12.399999618530274`. Both read back identically, so the shortest form is kept.)
 - 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.
 

+ 2 - 1
docs/xpj-format-and-scope.md

@@ -97,7 +97,7 @@ Recorded notes follow the slot, not the sample (a note stores 36 + n). Moving a
 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)
+- `layer.pitch` = coarse + fine / 100, stored as a 32-bit float (demo: the device wrote 12.399999618530274; mpckit writes the shortest form of the same float64, 12.399999618530273)
 
 ### Trim start and end
 - `layer.sliceInfo.Start` and `layer.sliceInfo.LoopStart` = start frame (demo: 328)
@@ -249,3 +249,4 @@ Every item on the original 1.0 list (project name, pad sample assignment, sequen
 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).
+7. Is `currentSequence` a slot number or an index into `sequences[]`? Every file seen has 0, and slot 0 is stored second, so the files cannot tell. mpckit treats it as a slot (build plan 4.3); checkpoint B4 should remove the current sequence and check which one the device opens.

+ 372 - 0
internal/project/pads.go

@@ -0,0 +1,372 @@
+package project
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"git.monoexp.com/bajdev/mpckit/internal/wav"
+	"git.monoexp.com/bajdev/mpckit/internal/xpj"
+)
+
+// Values the device writes when it assigns a sample with no tempo or key of
+// its own (format doc section 3, build plan 4.3).
+const (
+	padTempo        = 20.0
+	newSampleKey    = "C Natural Minor"
+	newSampleRoot   = 60
+	crossfadeLength = -1
+)
+
+// Pad is a read-only view of one pad.
+type Pad struct {
+	Slot       int
+	Label      string // "A1" .. "H16"
+	SampleName string // empty for an empty pad
+	File       string // pool file name
+	Coarse     int
+	Fine       int
+	MuteGroup  int
+	Start, End int64 // slice start and end frame, for the preview
+}
+
+// Empty reports whether the pad has no sample.
+func (p Pad) Empty() bool { return p.File == "" }
+
+// Pad returns the pad in slot.
+func (p *Project) Pad(slot int) (Pad, error) {
+	if err := checkSlot(slot); err != nil {
+		return Pad{}, err
+	}
+	pad := p.pad(slot)
+	l, err := layer(pad)
+	if err != nil {
+		return Pad{}, fmt.Errorf("project: pad %s: %w", SlotName(slot), err)
+	}
+	v := Pad{Slot: slot, Label: SlotName(slot)}
+	v.SampleName = text(l.Lookup("sampleName"))
+	v.File = text(l.Lookup("sampleFile"))
+	v.Coarse = int(num(l.Lookup("coarseTune")))
+	v.Fine = int(num(l.Lookup("fineTune")))
+	v.MuteGroup = int(num(pad.Lookup("whichMuteGroup")))
+	if si := l.Lookup("sliceInfo"); si != nil {
+		v.Start = num(si.Lookup("Start"))
+		v.End = num(si.Lookup("End"))
+	}
+	return v, nil
+}
+
+func text(n *xpj.Node) string {
+	if n == nil || n.Kind != xpj.String {
+		return ""
+	}
+	return n.Text
+}
+
+func num(n *xpj.Node) int64 {
+	if n == nil {
+		return 0
+	}
+	i, _ := n.Int()
+	return i
+}
+
+// padFiles returns the pool files used by any layer of pad.
+func padFiles(pad *xpj.Node) []string {
+	var files []string
+	layers := pad.Lookup("layersv")
+	if layers == nil {
+		return nil
+	}
+	for _, l := range layers.Items {
+		if f := text(l.Lookup("sampleFile")); f != "" {
+			files = append(files, f)
+		}
+	}
+	return files
+}
+
+// usage counts the pad layers that use the pool file.
+func (p *Project) usage(file string) int {
+	n := 0
+	for _, pad := range p.pads().Items {
+		for _, f := range padFiles(pad) {
+			if f == file {
+				n++
+			}
+		}
+	}
+	return n
+}
+
+// SampleCount returns the number of pool entries.
+func (p *Project) SampleCount() int { return len(p.pool().Items) }
+
+// AssignSample puts the WAV at src on the pad in slot, replacing what was
+// there. A file already in the pool from the same source is reused; a
+// different file with a name already in the pool gets a 001, 002 suffix.
+func (p *Project) AssignSample(slot int, src string) error {
+	if err := checkSlot(slot); err != nil {
+		return err
+	}
+	abs, err := filepath.Abs(src)
+	if err != nil {
+		return err
+	}
+	info, err := wav.ReadFile(abs)
+	if err != nil {
+		return fmt.Errorf("%s: %w", filepath.Base(abs), err)
+	}
+	if err := info.Validate(); err != nil {
+		return fmt.Errorf("%s: %w", filepath.Base(abs), err)
+	}
+
+	file := p.fileForSource(abs)
+	if file == "" {
+		// A new pool entry is needed. Clearing the pad first may free one.
+		freed := 0
+		for _, f := range padFiles(p.pad(slot)) {
+			if p.usage(f) == 1 {
+				freed++
+			}
+		}
+		if p.SampleCount()-freed >= MaxSamples {
+			return fmt.Errorf("the project already has %d samples, the most the device allows", MaxSamples)
+		}
+	}
+	// Keep the reused entry even if this pad was its only user.
+	p.clear(slot, file)
+	if file == "" {
+		file = p.uniqueFile(filepath.Base(abs))
+		if err := p.addPoolEntry(file); err != nil {
+			return err
+		}
+		p.Sources[file] = abs
+	}
+
+	pad := p.pad(slot)
+	l, err := layer(pad)
+	if err != nil {
+		return err
+	}
+	var e edit
+	e.set(pad, "tempo", xpj.NewFloat(padTempo))
+	e.set(l, "sampleName", xpj.NewString(stem(file)))
+	e.set(l, "sampleFile", xpj.NewString(file))
+	e.set(l, "sliceInfo.End", xpj.NewInt(info.Frames-1))
+	e.set(l, "sliceInfo.LoopCrossfadeLength", xpj.NewInt(crossfadeLength))
+	p.Dirty = true
+	return e.err
+}
+
+// fileForSource returns the pool file already copied from src, if any.
+func (p *Project) fileForSource(src string) string {
+	for file, s := range p.Sources {
+		if s == src && p.inPool(file) {
+			return file
+		}
+	}
+	return ""
+}
+
+func (p *Project) inPool(file string) bool {
+	for _, e := range p.pool().Items {
+		if text(e.Lookup("path")) == file {
+			return true
+		}
+	}
+	return false
+}
+
+func stem(file string) string { return strings.TrimSuffix(file, filepath.Ext(file)) }
+
+// uniqueFile returns base, or base with a 001, 002 ... suffix before the
+// extension, so that no two pool files share a name. Names are compared
+// without case, as on Windows, macOS and FAT.
+func (p *Project) uniqueFile(base string) string {
+	taken := map[string]bool{}
+	for _, e := range p.pool().Items {
+		taken[strings.ToLower(text(e.Lookup("path")))] = true
+		taken[strings.ToLower(text(e.Lookup("name")))+"\x00"] = true
+	}
+	free := func(f string) bool {
+		return !taken[strings.ToLower(f)] && !taken[strings.ToLower(stem(f))+"\x00"]
+	}
+	if free(base) {
+		return base
+	}
+	ext := filepath.Ext(base)
+	for i := 1; ; i++ {
+		f := fmt.Sprintf("%s%03d%s", stem(base), i, ext)
+		if free(f) {
+			return f
+		}
+	}
+}
+
+// poolEntry builds a new pool entry in the device's key order.
+func poolEntry(file string) *xpj.Node {
+	meta := xpj.NewObject()
+	meta.Members = []xpj.Member{
+		{Key: "tempo", Value: xpj.NewFloat(0)},
+		{Key: "rootNote", Value: xpj.NewInt(newSampleRoot)},
+		{Key: "tune", Value: xpj.NewFloat(0)},
+		{Key: "key", Value: xpj.NewString(newSampleKey)},
+	}
+	e := xpj.NewObject()
+	e.Members = []xpj.Member{
+		{Key: "version", Value: xpj.NewInt(1)},
+		{Key: "name", Value: xpj.NewString(stem(file))},
+		{Key: "path", Value: xpj.NewString(file)},
+		{Key: "loadImpl", Value: xpj.NewInt(0)},
+		{Key: "metadata", Value: meta},
+	}
+	return e
+}
+
+func (p *Project) addPoolEntry(file string) error {
+	if err := p.pool().Append(poolEntry(file)); err != nil {
+		return err
+	}
+	return p.trackPool().Insert(0, poolEntry(file))
+}
+
+func (p *Project) removePoolEntry(file string) {
+	for _, arr := range []*xpj.Node{p.pool(), p.trackPool()} {
+		for i := len(arr.Items) - 1; i >= 0; i-- {
+			if text(arr.Items[i].Lookup("path")) == file {
+				_ = arr.Remove(i)
+			}
+		}
+	}
+	delete(p.Sources, file)
+}
+
+// ClearPad resets the pad in slot to the blank pad and drops pool entries
+// no other pad uses.
+func (p *Project) ClearPad(slot int) error {
+	if err := checkSlot(slot); err != nil {
+		return err
+	}
+	if len(padFiles(p.pad(slot))) == 0 {
+		return nil
+	}
+	p.clear(slot, "")
+	p.Dirty = true
+	return nil
+}
+
+// clear resets the pad in slot and drops pool entries left unused, except keep.
+func (p *Project) clear(slot int, keep string) {
+	files := padFiles(p.pad(slot))
+	p.pads().Items[slot] = template.pad.Clone()
+	for _, f := range files {
+		if f != keep && p.usage(f) == 0 {
+			p.removePoolEntry(f)
+		}
+	}
+}
+
+// CopyPad copies the whole pad in from onto to, replacing what was there.
+// Both pads share the pool entry.
+func (p *Project) CopyPad(from, to int) error {
+	if err := checkSlot(from); err != nil {
+		return err
+	}
+	if err := checkSlot(to); err != nil {
+		return err
+	}
+	if from == to {
+		return nil
+	}
+	src := p.pad(from).Clone()
+	p.clear(to, "")
+	p.pads().Items[to] = src
+	p.Dirty = true
+	return nil
+}
+
+// MovePad copies from onto to, then resets from to the blank pad. Recorded
+// notes stay on their slot; check PadHasNotes and warn first.
+func (p *Project) MovePad(from, to int) error {
+	if from == to {
+		return checkSlot(from)
+	}
+	if err := p.CopyPad(from, to); err != nil {
+		return err
+	}
+	p.pads().Items[from] = template.pad.Clone()
+	return nil
+}
+
+// SwapPads exchanges two pads.
+func (p *Project) SwapPads(a, b int) error {
+	if err := checkSlot(a); err != nil {
+		return err
+	}
+	if err := checkSlot(b); err != nil {
+		return err
+	}
+	if a == b {
+		return nil
+	}
+	items := p.pads().Items
+	items[a], items[b] = items[b], items[a]
+	p.Dirty = true
+	return nil
+}
+
+// SetTune sets the pad's tuning, clamped to -24..+24 semitones and
+// -90..+90 cents. pitch is stored as a 32-bit float.
+func (p *Project) SetTune(slot, coarse, fine int) error {
+	if err := checkSlot(slot); err != nil {
+		return err
+	}
+	coarse = max(MinCoarse, min(MaxCoarse, coarse))
+	fine = max(MinFine, min(MaxFine, fine))
+	l, err := layer(p.pad(slot))
+	if err != nil {
+		return err
+	}
+	var e edit
+	e.set(l, "coarseTune", xpj.NewInt(int64(coarse)))
+	e.set(l, "fineTune", xpj.NewInt(int64(fine)))
+	e.set(l, "pitch", xpj.NewFloat32(float64(coarse)+float64(fine)/100))
+	p.Dirty = true
+	return e.err
+}
+
+// SetMuteGroup sets the pad's mute group; 0 is off.
+func (p *Project) SetMuteGroup(slot, group int) error {
+	if err := checkSlot(slot); err != nil {
+		return err
+	}
+	if group < 0 || group > MaxMuteGroup {
+		return fmt.Errorf("mute group %d out of range 0-%d", group, MaxMuteGroup)
+	}
+	var e edit
+	e.set(p.pad(slot), "whichMuteGroup", xpj.NewInt(int64(group)))
+	p.Dirty = true
+	return e.err
+}
+
+// PadHasNotes reports whether any sequence has a note or automation event
+// for the pad in slot (note 36 + slot on the drum track).
+func (p *Project) PadHasNotes(slot int) bool {
+	note := int64(FirstNote + slot)
+	found := false
+	p.eachDrumEvent(func(ev *xpj.Node) bool {
+		var n *xpj.Node
+		switch num(ev.Lookup("type")) {
+		case eventNote:
+			n = ev.Lookup("note")
+		case eventAutomation:
+			n = ev.Lookup("automation")
+		}
+		if n != nil && num(n.Lookup("note")) == note {
+			found = true
+		}
+		return !found
+	})
+	return found
+}

+ 253 - 0
internal/project/project.go

@@ -0,0 +1,253 @@
+// Package project is the mpckit domain model: one MPC Sample project and
+// every edit 1.0 makes to it. Edits follow the verified recipes in
+// docs/xpj-format-and-scope.md section 3 and change only the fields those
+// recipes name; everything else is written back as read.
+package project
+
+import (
+	"bytes"
+	_ "embed"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"git.monoexp.com/bajdev/mpckit/internal/xpj"
+)
+
+//go:embed template/DemoBlank.xpj
+var templateXPJ []byte
+
+// Device and format limits.
+const (
+	Pads          = 128 // 8 banks x 16
+	MaxSamples    = 128
+	MaxSequences  = 128
+	PulsesPerBar  = 3840 // 4/4 at 960 pulses per beat
+	FirstNote     = 36   // a note event for pad slot n stores note 36 + n
+	TestedVersion = 28   // data.version of firmware 1.3.0.12
+	DataSuffix    = "_[ProjectData]"
+)
+
+// Tuning ranges for 1.0 (format doc, open question 1a).
+const (
+	MinCoarse, MaxCoarse = -24, 24
+	MinFine, MaxFine     = -90, 90
+)
+
+// MaxMuteGroup is the highest mute group the UI offers. Not yet confirmed
+// on the device (build plan checkpoint A3).
+const MaxMuteGroup = 32
+
+// DefaultBPM is used for a new sequence when no sequence has been edited.
+const DefaultBPM = 120.0
+
+// Project is one project: the parsed file plus what the file does not hold.
+type Project struct {
+	// Name is the project name: the .xpj file name and data folder prefix.
+	Name string
+	// SourceDir is the data folder of a loaded project; empty for a new one.
+	SourceDir string
+	// Sources maps each pool file name to the absolute path it is copied
+	// from at export.
+	Sources map[string]string
+	// Dirty is set by every edit.
+	Dirty bool
+
+	file *xpj.File
+	data *xpj.Node
+
+	lastBPM float64 // BPM of the most recently edited sequence; 0 if none
+}
+
+var template = mustTemplate()
+
+type tmpl struct {
+	pad      *xpj.Node // one blank pad (all 128 are identical)
+	sequence *xpj.Node // the blank project's single sequence entry {key, value}
+}
+
+func mustTemplate() tmpl {
+	f, err := xpj.Read(bytes.NewReader(templateXPJ))
+	if err != nil {
+		panic("project: embedded template: " + err.Error())
+	}
+	d, _ := f.Data()
+	pad, err := d.Get("tracks[0].program.drum.instruments[0]")
+	if err != nil {
+		panic("project: embedded template: " + err.Error())
+	}
+	seq, err := d.Get("sequences[0]")
+	if err != nil {
+		panic("project: embedded template: " + err.Error())
+	}
+	return tmpl{pad: pad, sequence: seq}
+}
+
+// New returns a new project built from the blank template.
+func New(name string) (*Project, error) {
+	if err := ValidName(name); err != nil {
+		return nil, err
+	}
+	f, err := xpj.Read(bytes.NewReader(templateXPJ))
+	if err != nil {
+		return nil, err
+	}
+	return wrap(f, name, "")
+}
+
+// Load reads the .xpj at path. The project name is taken from the file name
+// and samples are expected in the sibling "<name>_[ProjectData]" folder.
+// The returned warnings describe differences from the tested firmware;
+// the project is still usable.
+func Load(path string) (*Project, []string, error) {
+	abs, err := filepath.Abs(path)
+	if err != nil {
+		return nil, nil, err
+	}
+	f, err := xpj.ReadFile(abs)
+	if err != nil {
+		return nil, nil, err
+	}
+	name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
+	dataDir := filepath.Join(filepath.Dir(abs), name+DataSuffix)
+	p, err := wrap(f, name, dataDir)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var warnings []string
+	if f.Header[1] != xpj.TestedHeader[1] {
+		warnings = append(warnings, fmt.Sprintf("saved by firmware %s; mpckit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
+	}
+	if v, err := p.data.Lookup("version").Int(); err != nil || v != TestedVersion {
+		warnings = append(warnings, fmt.Sprintf("project data version %d; mpckit is tested with %d", v, TestedVersion))
+	}
+	for _, e := range p.pool().Items {
+		file := e.Lookup("path").Text
+		p.Sources[file] = filepath.Join(dataDir, file)
+	}
+	return p, warnings, nil
+}
+
+func wrap(f *xpj.File, name, sourceDir string) (*Project, error) {
+	d, err := f.Data()
+	if err != nil {
+		return nil, err
+	}
+	p := &Project{Name: name, SourceDir: sourceDir, Sources: map[string]string{}, file: f, data: d}
+	for _, path := range []string{"samples", "tracks[0].samples", "tracks[0].program.drum.instruments", "sequences"} {
+		n, err := d.Get(path)
+		if err != nil {
+			return nil, fmt.Errorf("project: %w", err)
+		}
+		if n.Kind != xpj.Array {
+			return nil, fmt.Errorf("project: %s is not a list", path)
+		}
+	}
+	if n := len(p.pads().Items); n != Pads {
+		return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
+	}
+	return p, nil
+}
+
+// File returns the project file for writing. Callers must not edit it.
+func (p *Project) File() *xpj.File { return p.file }
+
+// SetName renames the project. Nothing inside the file changes.
+func (p *Project) SetName(name string) error {
+	if err := ValidName(name); err != nil {
+		return err
+	}
+	if name != p.Name {
+		p.Name = name
+		p.Dirty = true
+	}
+	return nil
+}
+
+// ValidName checks a project or sequence name: not empty, and free of
+// characters that are illegal in file names on Windows or FAT.
+func ValidName(name string) error {
+	if strings.TrimSpace(name) == "" {
+		return errors.New("name is empty")
+	}
+	for _, r := range name {
+		if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
+			if r < 0x20 {
+				return errors.New("name contains a control character")
+			}
+			return fmt.Errorf("name contains %q, which is not allowed in file names", r)
+		}
+	}
+	if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
+		return errors.New("name cannot end with a dot or a space")
+	}
+	base := strings.ToUpper(name)
+	if i := strings.IndexByte(base, '.'); i >= 0 {
+		base = base[:i]
+	}
+	switch base {
+	case "CON", "PRN", "AUX", "NUL",
+		"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+		"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
+		return fmt.Errorf("%q is a reserved name on Windows", name)
+	}
+	return nil
+}
+
+// Slot helpers. A slot is 0-127: bank x 16 + pad - 1.
+
+// SlotName returns the pad label for a slot, e.g. 0 -> "A1", 17 -> "B2".
+func SlotName(slot int) string {
+	return fmt.Sprintf("%c%d", 'A'+slot/16, slot%16+1)
+}
+
+func checkSlot(slot int) error {
+	if slot < 0 || slot >= Pads {
+		return fmt.Errorf("pad slot %d out of range 0-%d", slot, Pads-1)
+	}
+	return nil
+}
+
+func (p *Project) pads() *xpj.Node {
+	n, _ := p.data.Get("tracks[0].program.drum.instruments")
+	return n
+}
+
+func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
+
+func layer(pad *xpj.Node) (*xpj.Node, error) {
+	return pad.Get("layersv[0]")
+}
+
+// pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
+// the same entries in both, appending to the first and prepending to the
+// second.
+func (p *Project) pool() *xpj.Node {
+	n, _ := p.data.Get("samples")
+	return n
+}
+
+func (p *Project) trackPool() *xpj.Node {
+	n, _ := p.data.Get("tracks[0].samples")
+	return n
+}
+
+func (p *Project) sequences() *xpj.Node {
+	n, _ := p.data.Get("sequences")
+	return n
+}
+
+// edit applies several Sets and keeps the first error, so an operation can
+// list its writes plainly. Paths come from the template; a failure means the
+// loaded file has a different shape.
+type edit struct{ err error }
+
+func (e *edit) set(n *xpj.Node, path string, v *xpj.Node) {
+	if e.err == nil {
+		if err := n.Set(path, v); err != nil {
+			e.err = fmt.Errorf("project: %w", err)
+		}
+	}
+}

+ 563 - 0
internal/project/project_test.go

@@ -0,0 +1,563 @@
+package project
+
+import (
+	"bytes"
+	"compress/gzip"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"git.monoexp.com/bajdev/mpckit/internal/xpj"
+)
+
+const testdata = "../../testdata"
+
+func decompress(t *testing.T, path string) []byte {
+	t.Helper()
+	fh, err := os.Open(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer fh.Close()
+	zr, err := gzip.NewReader(fh)
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, err := io.ReadAll(zr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return b
+}
+
+func encode(t *testing.T, p *Project) []byte {
+	t.Helper()
+	b, err := p.File().Encode()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return b
+}
+
+func must(t *testing.T, err error) {
+	t.Helper()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func newProject(t *testing.T) *Project {
+	t.Helper()
+	p, err := New("Test")
+	must(t, err)
+	return p
+}
+
+// leaves flattens a tree into path -> literal text, for comparing files.
+func leaves(n *xpj.Node, path string, out map[string]string) map[string]string {
+	if out == nil {
+		out = map[string]string{}
+	}
+	switch n.Kind {
+	case xpj.Object:
+		for _, m := range n.Members {
+			leaves(m.Value, path+"."+m.Key, out)
+		}
+	case xpj.Array:
+		for i, it := range n.Items {
+			leaves(it, fmt.Sprintf("%s[%d]", path, i), out)
+		}
+		if len(n.Items) == 0 {
+			out[path] = "[]"
+		}
+	default:
+		b, _ := n.MarshalJSON()
+		out[path] = string(b)
+	}
+	return out
+}
+
+func diffPaths(a, b *xpj.Node) []string {
+	la, lb := leaves(a, "", nil), leaves(b, "", nil)
+	var diff []string
+	for k, v := range la {
+		if lb[k] != v {
+			diff = append(diff, k)
+		}
+	}
+	for k := range lb {
+		if _, ok := la[k]; !ok {
+			diff = append(diff, k)
+		}
+	}
+	return diff
+}
+
+// The prototype's edits for MpckitT1 and T3 (build plan appendix), made
+// through the model. The output must equal the files the device accepted.
+func buildAccepted(t *testing.T, name string, shared bool) *Project {
+	t.Helper()
+	p, err := New(name)
+	must(t, err)
+	dir := filepath.Join(testdata, "accepted", name+DataSuffix)
+	must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
+	second := "Linn 9000 kick001.wav"
+	if shared {
+		second = "Linn 9000 kick.wav"
+	}
+	must(t, p.AssignSample(16, filepath.Join(dir, second)))
+	must(t, p.SetTune(16, 5, 0))
+	must(t, p.SetSequence(0, "Verse", 96, 8))
+	must(t, p.AddSequence(1))
+	must(t, p.SetSequence(1, "Chorus", 96, 4))
+	return p
+}
+
+func TestGoldenMpckitT1(t *testing.T) {
+	p := buildAccepted(t, "MpckitT1", false)
+	want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))
+	if got := encode(t, p); !bytes.Equal(got, want) {
+		f, _ := xpj.Decode(want)
+		t.Fatalf("differs from MpckitT1.xpj at %v", diffPaths(p.File().Root, f.Root))
+	}
+	if p.SampleCount() != 2 {
+		t.Errorf("%d samples, want 2", p.SampleCount())
+	}
+}
+
+func TestGoldenMpckitT3(t *testing.T) {
+	p := buildAccepted(t, "MpckitT3", true)
+	want := decompress(t, filepath.Join(testdata, "accepted", "MpckitT3.xpj"))
+	if got := encode(t, p); !bytes.Equal(got, want) {
+		f, _ := xpj.Decode(want)
+		t.Fatalf("differs from MpckitT3.xpj at %v", diffPaths(p.File().Root, f.Root))
+	}
+	if p.SampleCount() != 1 {
+		t.Errorf("%d samples, want 1 shared", p.SampleCount())
+	}
+}
+
+// Rebuild Demo02 from the blank template and compare with the device's file.
+func TestRebuildDemo02(t *testing.T) {
+	dir := filepath.Join(testdata, "device", "Demo02"+DataSuffix)
+	p := newProject(t)
+	must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
+	must(t, p.AssignSample(4, filepath.Join(dir, "Linn 9000 kick001.wav")))
+	must(t, p.SetTune(4, 12, 40))
+	// Start/end trim is a 2.0 feature; its fields are verified, so set them
+	// directly to complete the rebuild.
+	l, err := layer(p.pad(4))
+	must(t, err)
+	must(t, l.Set("sliceInfo.Start", xpj.NewInt(328)))
+	must(t, l.Set("sliceInfo.LoopStart", xpj.NewInt(328)))
+	must(t, l.Set("sliceInfo.End", xpj.NewInt(3777)))
+	must(t, p.SetSequence(0, "Sequence 01", 105, 2))
+	must(t, p.AddSequence(1))
+	must(t, p.SetSequence(1, "Sequence 02", 105, 4))
+
+	want, err := xpj.ReadFile(filepath.Join(testdata, "device", "Demo02.xpj"))
+	must(t, err)
+	got, wantLeaves := leaves(p.File().Root, "", nil), leaves(want.Root, "", nil)
+	var unexpected []string
+	for _, path := range diffPaths(p.File().Root, want.Root) {
+		switch {
+		case sameFloat(got[path], wantLeaves[path]):
+			// The device prints some floats with 17 digits where we print
+			// the shortest form: pitch 12.399999618530274 vs ...273 is the
+			// same float64.
+		case path == ".data.tracks[0].colour",
+			path == ".data.assignableEnvelopeFollowerAssignments.currentControlIndex",
+			strings.Contains(path, ".eventList.events"):
+			// Known noise and recorded notes (build plan M2).
+		case path == ".data.tracks[0].program.drum.instruments[4].layersv[0].sliceInfo.LoopCrossfadeLength":
+			// The device wrote a different value on the pad it trimmed; we
+			// write -1 as for every assigned sample (format doc section 3).
+		default:
+			unexpected = append(unexpected, path)
+		}
+	}
+	if len(unexpected) > 0 {
+		t.Errorf("unexpected differences from Demo02: %v", unexpected)
+	}
+}
+
+func sameFloat(a, b string) bool {
+	fa, errA := strconv.ParseFloat(a, 64)
+	fb, errB := strconv.ParseFloat(b, 64)
+	return errA == nil && errB == nil && fa == fb
+}
+
+// writeWAV writes a short 16-bit mono WAV of frames frames.
+func writeWAV(t *testing.T, path string, frames int) string {
+	t.Helper()
+	data := make([]byte, frames*2)
+	var b bytes.Buffer
+	le := binary.LittleEndian
+	b.WriteString("RIFF")
+	binary.Write(&b, le, uint32(4+8+16+8+len(data)))
+	b.WriteString("WAVEfmt ")
+	for _, v := range []any{uint32(16), uint16(1), uint16(1), uint32(44100), uint32(88200), uint16(2), uint16(16)} {
+		binary.Write(&b, le, v)
+	}
+	b.WriteString("data")
+	binary.Write(&b, le, uint32(len(data)))
+	b.Write(data)
+	must(t, os.MkdirAll(filepath.Dir(path), 0o755))
+	must(t, os.WriteFile(path, b.Bytes(), 0o644))
+	return path
+}
+
+func padOf(t *testing.T, p *Project, slot int) Pad {
+	t.Helper()
+	v, err := p.Pad(slot)
+	must(t, err)
+	return v
+}
+
+func TestAssignAndPoolReuse(t *testing.T) {
+	dir := t.TempDir()
+	kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 100)
+	p := newProject(t)
+	must(t, p.AssignSample(0, kick))
+	must(t, p.AssignSample(1, kick))
+	if p.SampleCount() != 1 {
+		t.Fatalf("same source on two pads: %d pool entries, want 1", p.SampleCount())
+	}
+	a := padOf(t, p, 1)
+	if a.File != "kick.wav" || a.SampleName != "kick" || a.End != 99 {
+		t.Errorf("pad A2 = %+v", a)
+	}
+	if !p.Dirty {
+		t.Error("not dirty after assign")
+	}
+	// Reassigning the same source to its only pad keeps the entry.
+	must(t, p.ClearPad(1))
+	must(t, p.AssignSample(0, kick))
+	if p.SampleCount() != 1 || padOf(t, p, 0).File != "kick.wav" {
+		t.Errorf("reassign same source: %d entries, pad %+v", p.SampleCount(), padOf(t, p, 0))
+	}
+	if got := p.Sources["kick.wav"]; got != kick {
+		t.Errorf("source = %q, want %q", got, kick)
+	}
+	// Replacing drops the old entry when no pad uses it.
+	snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 50)
+	must(t, p.AssignSample(0, snare))
+	if p.SampleCount() != 1 || p.inPool("kick.wav") || p.Sources["kick.wav"] != "" {
+		t.Errorf("replace did not drop kick: %d entries", p.SampleCount())
+	}
+}
+
+func TestNameCollision(t *testing.T) {
+	dir := t.TempDir()
+	p := newProject(t)
+	must(t, p.AssignSample(0, writeWAV(t, filepath.Join(dir, "a", "kick.wav"), 10)))
+	must(t, p.AssignSample(1, writeWAV(t, filepath.Join(dir, "b", "kick.wav"), 20)))
+	must(t, p.AssignSample(2, writeWAV(t, filepath.Join(dir, "c", "Kick.WAV"), 30)))
+	want := []string{"kick.wav", "kick001.wav", "Kick002.WAV"}
+	for i, w := range want {
+		if got := padOf(t, p, i); got.File != w || got.SampleName != strings.TrimSuffix(w, filepath.Ext(w)) {
+			t.Errorf("pad %d = %q/%q, want %q", i, got.SampleName, got.File, w)
+		}
+	}
+	// Both pools hold the same entries; the track pool in reverse order.
+	var pool, track []string
+	for _, e := range p.pool().Items {
+		pool = append(pool, e.Lookup("path").Text)
+	}
+	for _, e := range p.trackPool().Items {
+		track = append(track, e.Lookup("path").Text)
+	}
+	if strings.Join(pool, ",") != "kick.wav,kick001.wav,Kick002.WAV" || strings.Join(track, ",") != "Kick002.WAV,kick001.wav,kick.wav" {
+		t.Errorf("pool %v, track pool %v", pool, track)
+	}
+}
+
+func TestSampleLimit(t *testing.T) {
+	dir := t.TempDir()
+	p := newProject(t)
+	// With one sample per pad the pool can only fill up when it also holds
+	// entries no pad uses, as a loaded project may. Simulate one.
+	must(t, p.addPoolEntry("unused.wav"))
+	for i := 0; i < MaxSamples-1; i++ {
+		must(t, p.AssignSample(i, writeWAV(t, filepath.Join(dir, fmt.Sprintf("s%03d.wav", i)), 4)))
+	}
+	if p.SampleCount() != MaxSamples {
+		t.Fatalf("%d samples, want %d", p.SampleCount(), MaxSamples)
+	}
+	err := p.AssignSample(127, writeWAV(t, filepath.Join(dir, "x.wav"), 4))
+	if err == nil || !strings.Contains(err.Error(), "128") {
+		t.Fatalf("129th sample: %v", err)
+	}
+	if !padOf(t, p, 127).Empty() || p.SampleCount() != MaxSamples {
+		t.Error("refused assign changed the project")
+	}
+	// Reusing a file already in the pool needs no new entry.
+	must(t, p.AssignSample(127, filepath.Join(dir, "s000.wav")))
+	// Replacing a pad's only sample frees its entry first.
+	must(t, p.AssignSample(1, filepath.Join(dir, "x.wav")))
+	if p.SampleCount() != MaxSamples || padOf(t, p, 1).File != "x.wav" {
+		t.Errorf("replace at the limit: %d samples, pad %+v", p.SampleCount(), padOf(t, p, 1))
+	}
+}
+
+func TestAssignRejectsBadFiles(t *testing.T) {
+	dir := t.TempDir()
+	p := newProject(t)
+	bad := filepath.Join(dir, "bad.wav")
+	must(t, os.WriteFile(bad, []byte("not a wav"), 0o644))
+	if err := p.AssignSample(0, bad); err == nil {
+		t.Error("non-WAV accepted")
+	}
+	if err := p.AssignSample(0, filepath.Join(dir, "missing.wav")); err == nil {
+		t.Error("missing file accepted")
+	}
+	if err := p.AssignSample(128, writeWAV(t, filepath.Join(dir, "ok.wav"), 4)); err == nil {
+		t.Error("slot 128 accepted")
+	}
+	if p.Dirty || p.SampleCount() != 0 {
+		t.Error("failed assigns changed the project")
+	}
+}
+
+func TestClearCopyMoveSwap(t *testing.T) {
+	dir := t.TempDir()
+	p := newProject(t)
+	kick := writeWAV(t, filepath.Join(dir, "kick.wav"), 10)
+	snare := writeWAV(t, filepath.Join(dir, "snare.wav"), 20)
+	must(t, p.AssignSample(0, kick))
+	must(t, p.AssignSample(1, snare))
+	must(t, p.SetTune(0, 3, -20))
+	must(t, p.SetMuteGroup(0, 2))
+
+	// Copy across banks keeps tuning and mute group and shares the entry.
+	must(t, p.CopyPad(0, 40))
+	c := padOf(t, p, 40)
+	if c.File != "kick.wav" || c.Coarse != 3 || c.Fine != -20 || c.MuteGroup != 2 || p.SampleCount() != 2 {
+		t.Errorf("copy: %+v, %d samples", c, p.SampleCount())
+	}
+	// The copy is independent of its source.
+	must(t, p.SetTune(40, 0, 0))
+	if padOf(t, p, 0).Coarse != 3 {
+		t.Error("copy shares data with its source")
+	}
+
+	// Move onto a filled pad replaces it; the snare's entry goes.
+	must(t, p.MovePad(0, 1))
+	if !padOf(t, p, 0).Empty() || padOf(t, p, 1).File != "kick.wav" || p.inPool("snare.wav") {
+		t.Errorf("move: A1 %+v, A2 %+v", padOf(t, p, 0), padOf(t, p, 1))
+	}
+	blank, _ := template.pad.MarshalJSON()
+	if got, _ := p.pad(0).MarshalJSON(); !bytes.Equal(got, blank) {
+		t.Error("moved-from pad is not the blank pad")
+	}
+
+	// Swap.
+	must(t, p.AssignSample(5, snare))
+	must(t, p.SwapPads(1, 5))
+	if padOf(t, p, 1).File != "snare.wav" || padOf(t, p, 5).File != "kick.wav" {
+		t.Error("swap")
+	}
+
+	// Clearing the last user drops the entry; clearing a shared one keeps it.
+	must(t, p.ClearPad(5))
+	if !p.inPool("kick.wav") {
+		t.Error("cleared a shared entry (kick is still on pad 40)")
+	}
+	must(t, p.ClearPad(40))
+	if p.inPool("kick.wav") || p.SampleCount() != 1 {
+		t.Errorf("kick entry kept after its last pad was cleared (%d samples)", p.SampleCount())
+	}
+
+	// No-ops and range checks.
+	p.Dirty = false
+	must(t, p.ClearPad(100))
+	must(t, p.CopyPad(1, 1))
+	must(t, p.MovePad(1, 1))
+	must(t, p.SwapPads(1, 1))
+	if p.Dirty {
+		t.Error("no-op marked the project dirty")
+	}
+	for _, err := range []error{p.CopyPad(-1, 0), p.MovePad(0, 128), p.SwapPads(200, 0), p.ClearPad(128), p.SetTune(128, 0, 0), p.SetMuteGroup(-1, 0)} {
+		if err == nil {
+			t.Error("out-of-range slot accepted")
+		}
+	}
+}
+
+func TestTuneAndMuteGroup(t *testing.T) {
+	p := newProject(t)
+	must(t, p.SetTune(0, 12, 40))
+	l, _ := layer(p.pad(0))
+	if got := l.Lookup("pitch").Text; got != "12.399999618530273" {
+		t.Errorf("pitch = %s", got)
+	}
+	must(t, p.SetTune(0, 99, -500))
+	if v := padOf(t, p, 0); v.Coarse != 24 || v.Fine != -90 {
+		t.Errorf("clamped tune = %d %d", v.Coarse, v.Fine)
+	}
+	if got := l.Lookup("pitch").Text; got != "23.100000381469727" {
+		t.Errorf("pitch = %s", got)
+	}
+	must(t, p.SetMuteGroup(3, 1))
+	if padOf(t, p, 3).MuteGroup != 1 {
+		t.Error("mute group")
+	}
+	if err := p.SetMuteGroup(3, MaxMuteGroup+1); err == nil {
+		t.Error("mute group out of range accepted")
+	}
+}
+
+func TestSequences(t *testing.T) {
+	p := newProject(t)
+	must(t, p.AddSequence(4))
+	s := p.Sequences()
+	if len(s) != 2 || s[1] != (Sequence{Slot: 4, Name: "Sequence 05", BPM: DefaultBPM, Bars: 2}) {
+		t.Fatalf("after add: %+v", s)
+	}
+	if err := p.AddSequence(4); err == nil {
+		t.Error("duplicate slot accepted")
+	}
+	if err := p.AddSequence(MaxSequences); err == nil {
+		t.Error("slot 128 accepted")
+	}
+
+	must(t, p.SetSequence(4, "Drop", 140.5, 16))
+	must(t, p.AddSequence(127))
+	s = p.Sequences()
+	if s[1].Name != "Drop" || s[1].BPM != 140.5 || s[1].Bars != 16 || s[2].Name != "Sequence 128" || s[2].BPM != 140.5 {
+		t.Errorf("after edit: %+v", s)
+	}
+	// Newest first in the file, as the device writes.
+	if num(p.sequences().Items[0].Lookup("key")) != 127 {
+		t.Error("new sequence not inserted first")
+	}
+
+	// All 26 length fields.
+	v := p.sequences().Items[p.findSequence(4)].Lookup("value")
+	n := 0
+	for path, val := range leaves(v, "", nil) {
+		if strings.HasSuffix(path, "lengthBars") || strings.HasSuffix(path, "loopEndBar") {
+			n++
+			if val != "16" {
+				t.Errorf("%s = %s", path, val)
+			}
+		}
+		if strings.HasSuffix(path, ".lengthPulses") || strings.HasSuffix(path, "loopEndPulses") || strings.HasSuffix(path, ".endPulses") {
+			n++
+			if val != "61440" {
+				t.Errorf("%s = %s", path, val)
+			}
+		}
+	}
+	if n != 26 {
+		t.Errorf("%d length fields, want 26", n)
+	}
+
+	for _, bad := range []struct {
+		name string
+		bpm  float64
+		bars int
+	}{{"", 120, 2}, {"a/b", 120, 2}, {"ok", 0, 2}, {"ok", -1, 2}, {"ok", 120, 0}, {"ok", 120, MaxBars + 1}} {
+		if err := p.SetSequence(4, bad.name, bad.bpm, bad.bars); err == nil {
+			t.Errorf("SetSequence(%q, %v, %d) accepted", bad.name, bad.bpm, bad.bars)
+		}
+	}
+	if err := p.SetSequence(9, "x", 120, 2); err == nil {
+		t.Error("missing sequence accepted")
+	}
+
+	// Remove: current sequence moves to the lowest remaining slot.
+	must(t, p.data.SetKey("currentSequence", xpj.NewInt(4)))
+	must(t, p.RemoveSequence(4))
+	if cur := num(p.data.Lookup("currentSequence")); cur != 0 {
+		t.Errorf("currentSequence = %d, want 0", cur)
+	}
+	must(t, p.RemoveSequence(0))
+	if cur := num(p.data.Lookup("currentSequence")); cur != 127 {
+		t.Errorf("currentSequence = %d, want 127", cur)
+	}
+	if err := p.RemoveSequence(127); err == nil {
+		t.Error("removed the last sequence")
+	}
+	if err := p.RemoveSequence(3); err == nil {
+		t.Error("removed a missing sequence")
+	}
+}
+
+func TestLoadDemoFile(t *testing.T) {
+	p, warnings, err := Load(filepath.Join(testdata, "device", "DemoFile.xpj"))
+	must(t, err)
+	if len(warnings) != 0 {
+		t.Errorf("warnings: %v", warnings)
+	}
+	if p.Name != "DemoFile" || p.Dirty || p.SampleCount() != 6 {
+		t.Errorf("name %q dirty %v samples %d", p.Name, p.Dirty, p.SampleCount())
+	}
+	for file, src := range p.Sources {
+		if _, err := os.Stat(src); err != nil {
+			t.Errorf("source for %s: %v", file, err)
+		}
+	}
+	// The sequences have notes on A1-A4 (36-39); the other pads have none.
+	for slot := 0; slot < Pads; slot++ {
+		if got, want := p.PadHasNotes(slot), slot < 4; got != want {
+			t.Errorf("PadHasNotes(%s) = %v", SlotName(slot), got)
+		}
+	}
+	s := p.Sequences()
+	if len(s) != 2 || s[0].Name != "Intro" || !s[0].HasNotes || s[1].Name != "Sequence 02" || !s[1].HasNotes {
+		t.Errorf("sequences %+v", s)
+	}
+	if v := padOf(t, p, 2); v.MuteGroup != 1 || v.File != "80s-Hat-Dreamer1 SA.wav" {
+		t.Errorf("A3 = %+v", v)
+	}
+	// Loaded and unchanged: writes back byte-identical.
+	want := decompress(t, filepath.Join(testdata, "device", "DemoFile.xpj"))
+	if !bytes.Equal(encode(t, p), want) {
+		t.Error("loaded project does not write back unchanged")
+	}
+
+	// Assigning a file already in the project folder reuses its entry.
+	must(t, p.AssignSample(50, filepath.Join(testdata, "device", "DemoFile"+DataSuffix, "Bass-Pure C.wav")))
+	if p.SampleCount() != 6 {
+		t.Errorf("%d samples after reusing a loaded file", p.SampleCount())
+	}
+}
+
+func TestBlankPadsAndTemplate(t *testing.T) {
+	p := newProject(t)
+	if !bytes.Equal(encode(t, p), decompress(t, filepath.Join(testdata, "device", "DemoBlank.xpj"))) {
+		t.Error("New() is not DemoBlank (is internal/project/template out of date?)")
+	}
+	for slot := 0; slot < Pads; slot++ {
+		if !padOf(t, p, slot).Empty() || p.PadHasNotes(slot) {
+			t.Fatalf("pad %d not empty", slot)
+		}
+	}
+}
+
+func TestNames(t *testing.T) {
+	p := newProject(t)
+	must(t, p.SetName("My Kit 2"))
+	if p.Name != "My Kit 2" || !p.Dirty {
+		t.Error("SetName")
+	}
+	for _, bad := range []string{"", "  ", "a:b", "a?", "end.", "end ", "CON", "com1.txt", "tab\there"} {
+		if err := ValidName(bad); err == nil {
+			t.Errorf("ValidName(%q) accepted", bad)
+		}
+	}
+	if _, err := New("bad|name"); err == nil {
+		t.Error("New with a bad name accepted")
+	}
+	for slot, want := range map[int]string{0: "A1", 4: "A5", 16: "B1", 17: "B2", 127: "H16"} {
+		if got := SlotName(slot); got != want {
+			t.Errorf("SlotName(%d) = %s, want %s", slot, got, want)
+		}
+	}
+}

+ 217 - 0
internal/project/sequences.go

@@ -0,0 +1,217 @@
+package project
+
+import (
+	"fmt"
+	"math"
+	"sort"
+
+	"git.monoexp.com/bajdev/mpckit/internal/xpj"
+)
+
+// Event types in a clip's event list.
+const (
+	eventAutomation = 1
+	eventNote       = 3
+)
+
+// MaxBars is the longest sequence mpckit writes. Not confirmed on the device
+// (build plan checkpoint A5).
+const MaxBars = 999
+
+// Sequence is a read-only view of one sequence.
+type Sequence struct {
+	Slot     int
+	Name     string
+	BPM      float64
+	Bars     int
+	HasNotes bool
+}
+
+// Sequences returns the project's sequences in slot order.
+func (p *Project) Sequences() []Sequence {
+	var out []Sequence
+	for _, s := range p.sequences().Items {
+		v := s.Lookup("value")
+		bpm, _ := v.Lookup("bpm").Float()
+		out = append(out, Sequence{
+			Slot:     int(num(s.Lookup("key"))),
+			Name:     text(v.Lookup("name")),
+			BPM:      bpm,
+			Bars:     int(num(v.Lookup("lengthBars"))),
+			HasNotes: hasNotes(v),
+		})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
+	return out
+}
+
+// findSequence returns the index in sequences[] of the sequence in slot, or -1.
+func (p *Project) findSequence(slot int) int {
+	for i, s := range p.sequences().Items {
+		if num(s.Lookup("key")) == int64(slot) {
+			return i
+		}
+	}
+	return -1
+}
+
+func checkSeqSlot(slot int) error {
+	if slot < 0 || slot >= MaxSequences {
+		return fmt.Errorf("sequence slot %d out of range 0-%d", slot, MaxSequences-1)
+	}
+	return nil
+}
+
+// AddSequence adds an empty sequence in slot, cloned from the blank project:
+// named "Sequence NN", 2 bars, with the BPM of the most recently edited
+// sequence (else 120). Like the device, the newest sequence goes first.
+func (p *Project) AddSequence(slot int) error {
+	if err := checkSeqSlot(slot); err != nil {
+		return err
+	}
+	if p.findSequence(slot) >= 0 {
+		return fmt.Errorf("sequence %d already exists", slot+1)
+	}
+	bpm := p.lastBPM
+	if bpm == 0 {
+		bpm = DefaultBPM
+	}
+	s := template.sequence.Clone()
+	var e edit
+	e.set(s, "key", xpj.NewInt(int64(slot)))
+	v := s.Lookup("value")
+	e.set(v, "name", xpj.NewString(fmt.Sprintf("Sequence %02d", slot+1)))
+	e.set(v, "bpm", xpj.NewFloat(bpm))
+	if e.err != nil {
+		return e.err
+	}
+	if err := setLength(v, 2); err != nil {
+		return err
+	}
+	p.Dirty = true
+	return p.sequences().Insert(0, s)
+}
+
+// RemoveSequence deletes the sequence in slot. The last sequence cannot be
+// removed. If it was the current sequence, the lowest remaining slot
+// becomes current.
+func (p *Project) RemoveSequence(slot int) error {
+	i := p.findSequence(slot)
+	if i < 0 {
+		return fmt.Errorf("sequence %d does not exist", slot+1)
+	}
+	if len(p.sequences().Items) == 1 {
+		return fmt.Errorf("cannot remove the only sequence")
+	}
+	if err := p.sequences().Remove(i); err != nil {
+		return err
+	}
+	p.Dirty = true
+	if cur := p.data.Lookup("currentSequence"); cur != nil && num(cur) == int64(slot) {
+		lowest := p.Sequences()[0].Slot
+		return p.data.SetKey("currentSequence", xpj.NewInt(int64(lowest)))
+	}
+	return nil
+}
+
+// SetSequence sets a sequence's name, BPM and length in bars. Recorded
+// notes are left as they are, including any past a new, shorter end.
+func (p *Project) SetSequence(slot int, name string, bpm float64, bars int) error {
+	i := p.findSequence(slot)
+	if i < 0 {
+		return fmt.Errorf("sequence %d does not exist", slot+1)
+	}
+	if err := ValidName(name); err != nil {
+		return fmt.Errorf("sequence name: %w", err)
+	}
+	if !(bpm > 0) || math.IsInf(bpm, 0) {
+		return fmt.Errorf("BPM must be a positive number")
+	}
+	if bars < 1 || bars > MaxBars {
+		return fmt.Errorf("length must be 1-%d bars", MaxBars)
+	}
+	v := p.sequences().Items[i].Lookup("value")
+	var e edit
+	e.set(v, "name", xpj.NewString(name))
+	e.set(v, "bpm", xpj.NewFloat(bpm))
+	if e.err != nil {
+		return e.err
+	}
+	if err := setLength(v, bars); err != nil {
+		return err
+	}
+	p.lastBPM = bpm
+	p.Dirty = true
+	return nil
+}
+
+// setLength writes the 26 fields that hold a sequence's length: four on the
+// sequence and two on each of its 11 clips.
+func setLength(v *xpj.Node, bars int) error {
+	pulses := int64(bars) * PulsesPerBar
+	var e edit
+	e.set(v, "lengthBars", xpj.NewInt(int64(bars)))
+	e.set(v, "loopEndBar", xpj.NewInt(int64(bars)))
+	e.set(v, "lengthPulses", xpj.NewInt(pulses))
+	e.set(v, "loopEndPulses", xpj.NewInt(pulses))
+	clips, err := v.Get("trackClipMaps[0]")
+	if err != nil {
+		return fmt.Errorf("project: %w", err)
+	}
+	for _, c := range clips.Items {
+		e.set(c, "value.endPulses", xpj.NewInt(pulses))
+		e.set(c, "value.loopEndPulses", xpj.NewInt(pulses))
+	}
+	return e.err
+}
+
+// hasNotes reports whether a sequence value holds any note event.
+func hasNotes(v *xpj.Node) bool {
+	maps := v.Lookup("trackClipMaps")
+	if maps == nil {
+		return false
+	}
+	for _, m := range maps.Items {
+		for _, c := range m.Items {
+			evs, err := c.Get("value.eventList.events")
+			if err != nil {
+				continue
+			}
+			for _, ev := range evs.Items {
+				if num(ev.Lookup("type")) == eventNote {
+					return true
+				}
+			}
+		}
+	}
+	return false
+}
+
+// eachDrumEvent calls fn for every event in every sequence's drum-track
+// clip (the clip keyed by tracks[0]'s name) until fn returns false.
+func (p *Project) eachDrumEvent(fn func(ev *xpj.Node) bool) {
+	track, _ := p.data.Get("tracks[0].name")
+	drum := text(track)
+	for _, s := range p.sequences().Items {
+		maps, err := s.Get("value.trackClipMaps")
+		if err != nil {
+			continue
+		}
+		for _, m := range maps.Items {
+			for _, c := range m.Items {
+				if text(c.Lookup("key")) != drum {
+					continue
+				}
+				evs, err := c.Get("value.eventList.events")
+				if err != nil {
+					continue
+				}
+				for _, ev := range evs.Items {
+					if !fn(ev) {
+						return
+					}
+				}
+			}
+		}
+	}
+}

BIN
internal/project/template/DemoBlank.xpj


+ 173 - 0
internal/wav/wav.go

@@ -0,0 +1,173 @@
+// Package wav reads the header of a WAV file: format, channels, sample rate,
+// bit depth and frame count. It does not decode audio.
+package wav
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"time"
+)
+
+// Format codes from the fmt chunk.
+const (
+	FormatPCM        = 1
+	FormatFloat      = 3
+	FormatExtensible = 0xFFFE
+)
+
+// Limits for a sample mpckit will assign (build plan 4.3 and section 7).
+const (
+	MaxChannels = 2
+	MaxDuration = 20 * time.Minute
+)
+
+// Info describes a WAV file.
+type Info struct {
+	Format     int // FormatPCM or FormatFloat (the sub-format of an extensible header)
+	Extensible bool
+	Channels   int
+	SampleRate int
+	Bits       int
+	BlockAlign int
+	Frames     int64
+	DataOffset int64 // byte offset of the first sample
+	DataSize   int64 // bytes of sample data actually present
+}
+
+// Duration returns the length of the audio.
+func (i Info) Duration() time.Duration {
+	if i.SampleRate == 0 {
+		return 0
+	}
+	return time.Duration(i.Frames) * time.Second / time.Duration(i.SampleRate)
+}
+
+// ReadFile reads the header of the WAV file at path.
+func ReadFile(path string) (Info, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return Info{}, err
+	}
+	defer f.Close()
+	st, err := f.Stat()
+	if err != nil {
+		return Info{}, err
+	}
+	return Read(f, st.Size())
+}
+
+// Read reads a WAV header from r, which holds size bytes.
+func Read(r io.ReaderAt, size int64) (Info, error) {
+	var hdr [12]byte
+	if _, err := r.ReadAt(hdr[:], 0); err != nil {
+		return Info{}, errors.New("not a WAV file: too short")
+	}
+	if string(hdr[0:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" {
+		if string(hdr[0:4]) == "RF64" {
+			return Info{}, errors.New("RF64 WAV files are not supported")
+		}
+		return Info{}, errors.New("not a WAV file")
+	}
+
+	var info Info
+	var haveFmt, haveData bool
+	pos := int64(12)
+	for pos+8 <= size && !(haveFmt && haveData) {
+		var ch [8]byte
+		if _, err := r.ReadAt(ch[:], pos); err != nil {
+			return Info{}, fmt.Errorf("reading chunk header: %w", err)
+		}
+		id := string(ch[0:4])
+		n := int64(binary.LittleEndian.Uint32(ch[4:8]))
+		body := pos + 8
+		switch id {
+		case "fmt ":
+			if n < 16 || body+n > size {
+				return Info{}, errors.New("fmt chunk is too short")
+			}
+			buf := make([]byte, n)
+			if _, err := r.ReadAt(buf, body); err != nil {
+				return Info{}, fmt.Errorf("reading fmt chunk: %w", err)
+			}
+			if err := info.parseFmt(buf); err != nil {
+				return Info{}, err
+			}
+			haveFmt = true
+		case "data":
+			info.DataOffset = body
+			// Some writers leave the size at 0 or 0xFFFFFFFF; count what is there.
+			info.DataSize = min(n, size-body)
+			if n == 0 || n == 0xFFFFFFFF {
+				info.DataSize = size - body
+			}
+			haveData = true
+		}
+		pos = body + n + n&1
+	}
+	if !haveFmt {
+		return Info{}, errors.New("WAV file has no fmt chunk")
+	}
+	if !haveData {
+		return Info{}, errors.New("WAV file has no data chunk")
+	}
+	info.Frames = info.DataSize / int64(info.BlockAlign)
+	return info, nil
+}
+
+func (i *Info) parseFmt(b []byte) error {
+	le := binary.LittleEndian
+	i.Format = int(le.Uint16(b[0:2]))
+	i.Channels = int(le.Uint16(b[2:4]))
+	i.SampleRate = int(le.Uint32(b[4:8]))
+	i.BlockAlign = int(le.Uint16(b[12:14]))
+	i.Bits = int(le.Uint16(b[14:16]))
+	if i.Format == FormatExtensible {
+		// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16): the
+		// first two bytes of the GUID are the real format code.
+		if len(b) < 40 {
+			return errors.New("extensible fmt chunk is too short")
+		}
+		i.Extensible = true
+		i.Format = int(le.Uint16(b[24:26]))
+	}
+	if i.Channels == 0 || i.SampleRate == 0 || i.BlockAlign == 0 {
+		return errors.New("WAV header has zero channels, rate or block size")
+	}
+	if i.BlockAlign != i.Channels*((i.Bits+7)/8) {
+		return fmt.Errorf("WAV block size %d does not match %d channels of %d bits", i.BlockAlign, i.Channels, i.Bits)
+	}
+	return nil
+}
+
+// Validate reports why the device or mpckit cannot use the file, or nil.
+// Accepted: uncompressed integer PCM (8, 16, 24, 32-bit) or 32-bit float,
+// mono or stereo, at most 20 minutes.
+func (i Info) Validate() error {
+	switch i.Format {
+	case FormatPCM:
+		switch i.Bits {
+		case 8, 16, 24, 32:
+		default:
+			return fmt.Errorf("%d-bit PCM is not supported (use 8, 16, 24 or 32-bit)", i.Bits)
+		}
+	case FormatFloat:
+		if i.Bits != 32 {
+			return fmt.Errorf("%d-bit float is not supported (use 32-bit float)", i.Bits)
+		}
+	default:
+		return fmt.Errorf("compressed WAV (format code %#x) is not supported; use uncompressed PCM or float", i.Format)
+	}
+	if i.Channels > MaxChannels {
+		return fmt.Errorf("%d channels: only mono and stereo are supported", i.Channels)
+	}
+	if i.Frames == 0 {
+		return errors.New("WAV file holds no audio")
+	}
+	if i.Duration() > MaxDuration {
+		return fmt.Errorf("sample is %s long; the limit is 20 minutes", i.Duration().Round(time.Second))
+	}
+	return nil
+}

+ 200 - 0
internal/wav/wav_test.go

@@ -0,0 +1,200 @@
+package wav
+
+import (
+	"bytes"
+	"encoding/binary"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+const testdata = "../../testdata"
+
+// The WAV test pack: every file is one second long at its own rate.
+func TestWavFormatsPack(t *testing.T) {
+	cases := map[string]struct{ format, ch, rate, bits int }{
+		"MpckitW1/22k 16bit mono.wav":         {FormatPCM, 1, 22050, 16},
+		"MpckitW1/44k 16bit mono.wav":         {FormatPCM, 1, 44100, 16},
+		"MpckitW1/44k 16bit stereo.wav":       {FormatPCM, 2, 44100, 16},
+		"MpckitW1/44k 24bit stereo.wav":       {FormatPCM, 2, 44100, 24},
+		"MpckitW1/48k 16bit stereo.wav":       {FormatPCM, 2, 48000, 16},
+		"MpckitW1/48k 24bit stereo.wav":       {FormatPCM, 2, 48000, 24},
+		"MpckitW1/96k 24bit stereo.wav":       {FormatPCM, 2, 96000, 24},
+		"MpckitW2/44k 8bit mono.wav":          {FormatPCM, 1, 44100, 8},
+		"MpckitW2/44k 32bit int stereo.wav":   {FormatPCM, 2, 44100, 32},
+		"MpckitW2/44k 32bit float stereo.wav": {FormatFloat, 2, 44100, 32},
+	}
+	for name, want := range cases {
+		proj, file, _ := strings.Cut(name, "/")
+		info, err := ReadFile(filepath.Join(testdata, "wav-formats", proj+"_[ProjectData]", file))
+		if err != nil {
+			t.Errorf("%s: %v", name, err)
+			continue
+		}
+		if info.Format != want.format || info.Channels != want.ch || info.SampleRate != want.rate || info.Bits != want.bits {
+			t.Errorf("%s: %+v", name, info)
+		}
+		if info.Frames != int64(want.rate) || info.Duration() != time.Second {
+			t.Errorf("%s: %d frames, %v", name, info.Frames, info.Duration())
+		}
+		if err := info.Validate(); err != nil {
+			t.Errorf("%s: %v", name, err)
+		}
+	}
+}
+
+// Device-saved files carry extra chunks (atem, smpl, meta) around the data.
+func TestDeviceSavedFiles(t *testing.T) {
+	cases := map[string]int64{
+		"device/Demo02_[ProjectData]/Linn 9000 kick.wav":          8213,
+		"device/DemoFile_[ProjectData]/80s-Kik-Dreamer2 SA.wav":   44100,
+		"device/DemoFile_[ProjectData]/80s-Snr-Dreamer SA.wav":    27000,
+		"device/DemoFile_[ProjectData]/80s-Hat-Dreamer1 SA.wav":   22446,
+		"device/DemoFile_[ProjectData]/80s-Hat-Dreamer2 SA.wav":   108000,
+		"device/DemoFile_[ProjectData]/PadLp-London Cm114 SA.wav": 371368,
+		"device/DemoFile_[ProjectData]/Bass-Pure C.wav":           132557,
+		"accepted/MpckitT2_[ProjectData]/Linn 9000 kick.wav":      8213,
+	}
+	for name, frames := range cases {
+		info, err := ReadFile(filepath.Join(testdata, name))
+		if err != nil {
+			t.Errorf("%s: %v", name, err)
+			continue
+		}
+		if info.Frames != frames {
+			t.Errorf("%s: %d frames, want %d (sliceInfo.End + 1)", name, info.Frames, frames)
+		}
+		if err := info.Validate(); err != nil {
+			t.Errorf("%s: %v", name, err)
+		}
+	}
+}
+
+type chunk struct {
+	id   string
+	body []byte
+}
+
+func riff(form string, chunks ...chunk) []byte {
+	var body bytes.Buffer
+	body.WriteString(form)
+	for _, c := range chunks {
+		body.WriteString(c.id)
+		binary.Write(&body, binary.LittleEndian, uint32(len(c.body)))
+		body.Write(c.body)
+		if len(c.body)%2 == 1 {
+			body.WriteByte(0)
+		}
+	}
+	var b bytes.Buffer
+	b.WriteString("RIFF")
+	binary.Write(&b, binary.LittleEndian, uint32(body.Len()))
+	b.Write(body.Bytes())
+	return b.Bytes()
+}
+
+func fmtChunk(format, ch, rate, bits int) []byte {
+	var b bytes.Buffer
+	align := ch * ((bits + 7) / 8)
+	for _, v := range []any{uint16(format), uint16(ch), uint32(rate), uint32(rate * align), uint16(align), uint16(bits)} {
+		binary.Write(&b, binary.LittleEndian, v)
+	}
+	return b.Bytes()
+}
+
+func extensible(sub, ch, rate, bits int) []byte {
+	b := fmtChunk(FormatExtensible, ch, rate, bits)
+	ext := make([]byte, 24)
+	binary.LittleEndian.PutUint16(ext[0:2], 22)
+	binary.LittleEndian.PutUint16(ext[2:4], uint16(bits))
+	binary.LittleEndian.PutUint16(ext[8:10], uint16(sub))
+	copy(ext[10:], "\x00\x00\x00\x00\x10\x00\x80\x00\x00\xaa\x00\x38\x9b\x71")
+	return append(b, ext...)
+}
+
+func read(b []byte) (Info, error) { return Read(bytes.NewReader(b), int64(len(b))) }
+
+func TestSyntheticHeaders(t *testing.T) {
+	data := make([]byte, 600)
+	ok := []struct {
+		name   string
+		file   []byte
+		format int
+		frames int64
+	}{
+		{"extensible PCM 24-bit", riff("WAVE", chunk{"fmt ", extensible(FormatPCM, 2, 48000, 24)}, chunk{"data", data}), FormatPCM, 100},
+		{"extensible float", riff("WAVE", chunk{"fmt ", extensible(FormatFloat, 1, 44100, 32)}, chunk{"data", data}), FormatFloat, 150},
+		{"odd chunk before fmt", riff("WAVE", chunk{"LIST", []byte("abc")}, chunk{"fmt ", fmtChunk(1, 1, 44100, 16)}, chunk{"data", data}), FormatPCM, 300},
+		{"8-bit mono", riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 22050, 8)}, chunk{"data", data[:599]}), FormatPCM, 599},
+	}
+	for _, c := range ok {
+		info, err := read(c.file)
+		if err != nil {
+			t.Errorf("%s: %v", c.name, err)
+			continue
+		}
+		if info.Format != c.format || info.Frames != c.frames {
+			t.Errorf("%s: %+v", c.name, info)
+		}
+		if err := info.Validate(); err != nil {
+			t.Errorf("%s: %v", c.name, err)
+		}
+	}
+
+	// Data size left at zero by a streaming writer: count what is there.
+	f := riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 44100, 16)}, chunk{"data", data})
+	binary.LittleEndian.PutUint32(f[len(f)-len(data)-4:], 0)
+	if info, err := read(f); err != nil || info.Frames != 300 {
+		t.Errorf("zero data size: %+v %v", info, err)
+	}
+	// Truncated data: count what is there.
+	f = riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 44100, 16)}, chunk{"data", data})
+	if info, err := read(f[:len(f)-100]); err != nil || info.Frames != 250 {
+		t.Errorf("truncated: %+v %v", info, err)
+	}
+
+	unreadable := map[string][]byte{
+		"empty":     nil,
+		"not riff":  []byte("OggS0000WAVE"),
+		"RF64":      append([]byte("RF64\xff\xff\xff\xffWAVE"), make([]byte, 20)...),
+		"no fmt":    riff("WAVE", chunk{"data", data}),
+		"no data":   riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 44100, 16)}),
+		"short fmt": riff("WAVE", chunk{"fmt ", make([]byte, 10)}, chunk{"data", data}),
+		"zero rate": riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 0, 16)}, chunk{"data", data}),
+		"bad align": riff("WAVE", chunk{"fmt ", append(fmtChunk(1, 2, 44100, 16)[:12], 3, 0, 16, 0)}, chunk{"data", data}),
+		"short ext": riff("WAVE", chunk{"fmt ", fmtChunk(FormatExtensible, 1, 44100, 16)}, chunk{"data", data}),
+	}
+	for name, f := range unreadable {
+		if _, err := read(f); err == nil {
+			t.Errorf("%s: read succeeded", name)
+		}
+	}
+
+	refused := map[string][]byte{
+		"ADPCM":        riff("WAVE", chunk{"fmt ", fmtChunk(2, 1, 44100, 4)}, chunk{"data", data}),
+		"3 channels":   riff("WAVE", chunk{"fmt ", fmtChunk(1, 3, 44100, 16)}, chunk{"data", data}),
+		"64-bit float": riff("WAVE", chunk{"fmt ", fmtChunk(3, 1, 44100, 64)}, chunk{"data", data}),
+		"12-bit":       riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 44100, 12)}, chunk{"data", data}),
+		"no audio":     riff("WAVE", chunk{"fmt ", fmtChunk(1, 1, 44100, 16)}, chunk{"data", nil}),
+	}
+	for name, f := range refused {
+		info, err := read(f)
+		if err != nil {
+			t.Errorf("%s: %v", name, err)
+			continue
+		}
+		if info.Validate() == nil {
+			t.Errorf("%s: accepted", name)
+		}
+	}
+
+	long := Info{Format: FormatPCM, Channels: 1, SampleRate: 44100, Bits: 16, BlockAlign: 2, Frames: 44100*20*60 + 1}
+	if long.Validate() == nil {
+		t.Error("over 20 minutes accepted")
+	}
+	long.Frames--
+	if err := long.Validate(); err != nil {
+		t.Errorf("exactly 20 minutes: %v", err)
+	}
+}

+ 38 - 0
pull-requests/m2-edit-operations/m2-edit-operations.md

@@ -0,0 +1,38 @@
+# M2: edit operations
+
+Branch: `m2-edit-operations` → `main`
+
+## What changed
+
+- **`internal/project`**: the domain model (build plan 4.2, 4.3). It has no Wails code.
+  - `New(name)` builds from `DemoBlank.xpj`, embedded with `go:embed` (copy in `internal/project/template/`; a test checks it still matches `testdata/device/DemoBlank.xpj`).
+  - `Load(path)` reads a project, takes the name from the file name, maps each pool file to `<name>_[ProjectData]/`, and warns when the firmware header or `data.version` differ from the tested 1.3.0.12 / 28.
+  - Pads: `AssignSample`, `ClearPad`, `CopyPad`, `MovePad`, `SwapPads`, `SetTune` (clamped to ±24 st / ±90 ct, pitch rounded to 32-bit), `SetMuteGroup`, `PadHasNotes`, plus a read-only `Pad(slot)` view.
+  - Sequences: `AddSequence`, `RemoveSequence`, `SetSequence` (name, BPM, bars; writes all 26 length fields), plus `Sequences()` in slot order.
+  - `SetName`, `ValidName` (no empty names, no `<>:"/\|?*` or control characters, no trailing dot/space, no Windows reserved names), `SlotName` (0 → A1, 17 → B2).
+  - Pool rules: the same source file on several pads gives one entry; a different file with a taken name gets `001`, `002`, compared without case; an entry is dropped when its last pad is cleared; 128-sample limit.
+- **`internal/wav`**: WAV header reader (format, channels, rate, bits, frames), moved forward from M3 because `AssignSample` needs the frame count. It handles extensible headers and extra chunks, and `Validate()` applies the 1.0 rule: 8/16/24/32-bit PCM or 32-bit float, mono/stereo, at most 20 minutes. The preview decoder and export stay in M3.
+- **Docs**: corrected the pitch example (see notes); added open question 7 on `currentSequence`.
+
+## Notes
+
+- **Device ordering, copied exactly**: new pool entries are appended to `samples[]` but inserted first in `tracks[0].samples[]`, and a new sequence goes first in `sequences[]`. The T1 golden match depends on both.
+- **Pitch text**: the device wrote 12.4 as `12.399999618530274`. Our writer prints `12.399999618530273`, the same float64 in shortest form, which is what the device-accepted T1/T3 files use. The docs said the device wrote `…273`; corrected.
+- **`currentSequence`**: treated as a slot number, as the plan says. The files cannot tell slot from array index (always 0, and slot 0 is stored second), so I added it as open question 7 for checkpoint B4.
+- **Limits not yet confirmed on the device**: mute groups 0–32 (checkpoint A3) and at most 999 bars (A5). Both are constants in the code (`MaxMuteGroup`, `MaxBars`) and easy to change.
+- **128-sample limit**: with one sample per pad the pool can't overflow through the API, since every pad replaced frees its entry. The limit only matters for loaded projects with unused or multi-layer entries, and the test covers that case.
+- Loaded pool entries keep their metadata; new ones use loadImpl 0, tempo 0.0, root 60, tune 0.0, key "C Natural Minor".
+
+## Testing
+
+`go vet ./...` and `go test ./...` pass (coverage: project 90%, wav 94%, xpj 89%).
+
+M2 done-when:
+- **T1 and T3**: building from the blank template with the prototype's edits gives output **byte-identical** to `testdata/accepted/MpckitT1.xpj` and `MpckitT3.xpj` (decompressed).
+- **Demo02 rebuild**: matches the device's file except `tracks[0].colour`, `assignableEnvelopeFollowerAssignments.currentControlIndex`, recorded events, the pad-5 `LoopCrossfadeLength` (device value on its trimmed pad vs our -1, as the format doc already records) and the pitch spelling above. Trim (a 2.0 feature) is set straight on the tree in the test to complete the rebuild.
+- **Unit tests**: clear, copy, move, swap, pool reuse, name collision, the 128 limit, sequence add/remove/length (all 26 fields), `PadHasNotes` against `DemoFile` (true for A1–A4 only), loading DemoFile and writing it back unchanged, bad WAVs, range checks.
+- **WAV reader**: all 10 files in the WAV test pack and the 7 device-saved samples give the frame counts the projects record (`sliceInfo.End + 1`); synthetic extensible, ADPCM, 3-channel, 64-bit float, RF64, truncated and zero-size-data files.
+
+## Next
+
+M3: export to folder and zip, the preview decoder, and the small CLI for device checkpoint A.