Kaynağa Gözat

Merge branch 'm3-export-cli' of bajdev/mpckit into main

bajdev 2 gün önce
ebeveyn
işleme
f36cda349e

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+mpckit-checkpoint-a.zip

+ 274 - 0
cmd/mpckit-cli/main.go

@@ -0,0 +1,274 @@
+// Command mpckit-cli builds an MPC Sample project from flags, for device
+// tests before the app exists (build plan M3). Edits run in the order given.
+//
+//	go run ./cmd/mpckit-cli -name Kit -out ~/kits \
+//	    -pad A1=kick.wav -pad A2=snare.wav -tune A2=-24,0 -mute A3=1 \
+//	    -seq 1=Verse,96,8 -seq 2=Chorus,96,4
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+
+	"git.monoexp.com/bajdev/mpckit/internal/export"
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+)
+
+type op struct {
+	flag, arg string
+	run       func(p *project.Project) error
+}
+
+var ops []op
+
+// opFlag is a repeatable flag whose values become ops.
+type opFlag struct {
+	name  string
+	parse func(arg string) (func(p *project.Project) error, error)
+}
+
+func (f opFlag) String() string { return "" }
+func (f opFlag) Set(arg string) error {
+	run, err := f.parse(arg)
+	if err != nil {
+		return err
+	}
+	ops = append(ops, op{flag: f.name, arg: arg, run: run})
+	return nil
+}
+
+func addOp(name, usage string, parse func(arg string) (func(p *project.Project) error, error)) {
+	flag.Var(opFlag{name: name, parse: parse}, name, usage)
+}
+
+// parseSlot reads a pad label such as A1 or h16.
+func parseSlot(s string) (int, error) {
+	s = strings.ToUpper(strings.TrimSpace(s))
+	if len(s) < 2 || s[0] < 'A' || s[0] > 'H' {
+		return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
+	}
+	n, err := strconv.Atoi(s[1:])
+	if err != nil || n < 1 || n > 16 {
+		return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
+	}
+	return int(s[0]-'A')*16 + n - 1, nil
+}
+
+func parsePair(arg string) (int, int, error) {
+	a, b, ok := strings.Cut(arg, ":")
+	if !ok {
+		return 0, 0, fmt.Errorf("want FROM:TO, e.g. A1:B5")
+	}
+	from, err := parseSlot(a)
+	if err != nil {
+		return 0, 0, err
+	}
+	to, err := parseSlot(b)
+	return from, to, err
+}
+
+func parseSeqNum(s string) (int, error) {
+	n, err := strconv.Atoi(strings.TrimSpace(s))
+	if err != nil || n < 1 || n > project.MaxSequences {
+		return 0, fmt.Errorf("bad sequence number %q: want 1-%d", s, project.MaxSequences)
+	}
+	return n - 1, nil
+}
+
+func init() {
+	addOp("pad", "assign a WAV: `PAD=FILE` (e.g. A1=kick.wav)", func(arg string) (func(*project.Project) error, error) {
+		label, file, ok := strings.Cut(arg, "=")
+		if !ok {
+			return nil, errors.New("want PAD=FILE")
+		}
+		slot, err := parseSlot(label)
+		if err != nil {
+			return nil, err
+		}
+		return func(p *project.Project) error { return p.AssignSample(slot, file) }, nil
+	})
+	addOp("tune", "tune a pad: `PAD=SEMITONES,CENTS` (e.g. B1=-24,0)", func(arg string) (func(*project.Project) error, error) {
+		label, val, ok := strings.Cut(arg, "=")
+		c, f, ok2 := strings.Cut(val, ",")
+		if !ok || !ok2 {
+			return nil, errors.New("want PAD=SEMITONES,CENTS")
+		}
+		slot, err := parseSlot(label)
+		if err != nil {
+			return nil, err
+		}
+		coarse, err1 := strconv.Atoi(strings.TrimSpace(c))
+		fine, err2 := strconv.Atoi(strings.TrimSpace(f))
+		if err1 != nil || err2 != nil {
+			return nil, errors.New("semitones and cents must be integers")
+		}
+		if coarse < project.MinCoarse || coarse > project.MaxCoarse || fine < project.MinFine || fine > project.MaxFine {
+			return nil, fmt.Errorf("tuning out of range (%d..%d semitones, %d..%d cents)", project.MinCoarse, project.MaxCoarse, project.MinFine, project.MaxFine)
+		}
+		return func(p *project.Project) error { return p.SetTune(slot, coarse, fine) }, nil
+	})
+	addOp("mute", "set a pad's mute group: `PAD=GROUP` (0 is off)", func(arg string) (func(*project.Project) error, error) {
+		label, val, ok := strings.Cut(arg, "=")
+		if !ok {
+			return nil, errors.New("want PAD=GROUP")
+		}
+		slot, err := parseSlot(label)
+		if err != nil {
+			return nil, err
+		}
+		g, err := strconv.Atoi(val)
+		if err != nil {
+			return nil, errors.New("group must be an integer")
+		}
+		return func(p *project.Project) error { return p.SetMuteGroup(slot, g) }, nil
+	})
+	addOp("clear", "clear a pad: `PAD`", func(arg string) (func(*project.Project) error, error) {
+		slot, err := parseSlot(arg)
+		return func(p *project.Project) error { return p.ClearPad(slot) }, err
+	})
+	for _, name := range []string{"copy", "move", "swap"} {
+		addOp(name, name+" pads: `FROM:TO`", func(arg string) (func(*project.Project) error, error) {
+			from, to, err := parsePair(arg)
+			if err != nil {
+				return nil, err
+			}
+			return func(p *project.Project) error {
+				switch name {
+				case "copy":
+					return p.CopyPad(from, to)
+				case "move":
+					if p.PadHasNotes(from) {
+						fmt.Fprintf(os.Stderr, "warning: %s has recorded notes; they stay on %s\n", project.SlotName(from), project.SlotName(from))
+					}
+					return p.MovePad(from, to)
+				}
+				return p.SwapPads(from, to)
+			}, nil
+		})
+	}
+	addOp("seq", "set a sequence, adding it if needed: `N=NAME,BPM,BARS` (N is 1-128)", func(arg string) (func(*project.Project) error, error) {
+		num, val, ok := strings.Cut(arg, "=")
+		parts := strings.Split(val, ",")
+		if !ok || len(parts) != 3 {
+			return nil, errors.New("want N=NAME,BPM,BARS")
+		}
+		slot, err := parseSeqNum(num)
+		if err != nil {
+			return nil, err
+		}
+		bpm, err1 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
+		bars, err2 := strconv.Atoi(strings.TrimSpace(parts[2]))
+		if err1 != nil || err2 != nil {
+			return nil, errors.New("BPM must be a number and BARS an integer")
+		}
+		return func(p *project.Project) error {
+			exists := false
+			for _, s := range p.Sequences() {
+				exists = exists || s.Slot == slot
+			}
+			if !exists {
+				if err := p.AddSequence(slot); err != nil {
+					return err
+				}
+			}
+			return p.SetSequence(slot, parts[0], bpm, bars)
+		}, nil
+	})
+	addOp("rmseq", "remove a sequence: `N`", func(arg string) (func(*project.Project) error, error) {
+		slot, err := parseSeqNum(arg)
+		return func(p *project.Project) error { return p.RemoveSequence(slot) }, err
+	})
+}
+
+func main() {
+	load := flag.String("load", "", "start from an existing `project.xpj` instead of a blank project")
+	name := flag.String("name", "", "project `name` (default: the loaded project's name)")
+	out := flag.String("out", "", "export to this `folder`")
+	zipPath := flag.String("zip", "", "export to this zip `file`")
+	force := flag.Bool("force", false, "replace an existing export")
+	flag.Usage = func() {
+		fmt.Fprintf(os.Stderr, "usage: mpckit-cli [-load FILE] -name NAME (-out DIR | -zip FILE) [edits...]\n\nEdits run in the order given and may repeat.\n\n")
+		flag.PrintDefaults()
+	}
+	flag.Parse()
+	if flag.NArg() > 0 {
+		fail(fmt.Errorf("unexpected argument %q", flag.Arg(0)))
+	}
+	if (*out == "") == (*zipPath == "") {
+		fail(errors.New("give exactly one of -out or -zip"))
+	}
+
+	var p *project.Project
+	var err error
+	if *load != "" {
+		var warnings []string
+		p, warnings, err = project.Load(*load)
+		for _, w := range warnings {
+			fmt.Fprintln(os.Stderr, "warning:", w)
+		}
+	} else {
+		if *name == "" {
+			fail(errors.New("-name is required for a new project"))
+		}
+		p, err = project.New(*name)
+	}
+	if err != nil {
+		fail(err)
+	}
+	if *name != "" {
+		if err := p.SetName(*name); err != nil {
+			fail(err)
+		}
+	}
+	for _, o := range ops {
+		if err := o.run(p); err != nil {
+			fail(fmt.Errorf("-%s %s: %w", o.flag, o.arg, err))
+		}
+	}
+
+	var dest string
+	if *out != "" {
+		dest, err = export.Folder(p, *out, *force)
+	} else {
+		dest, err = *zipPath, export.Zip(p, *zipPath, *force)
+	}
+	var exists *export.ExistsError
+	if errors.As(err, &exists) {
+		fail(fmt.Errorf("%w (use -force to replace)", err))
+	}
+	if err != nil {
+		fail(err)
+	}
+	summary(p)
+	fmt.Println("wrote", dest)
+}
+
+func summary(p *project.Project) {
+	fmt.Printf("%s: %d samples\n", p.Name, p.SampleCount())
+	for slot := 0; slot < project.Pads; slot++ {
+		v, _ := p.Pad(slot)
+		if v.Empty() {
+			continue
+		}
+		fmt.Printf("  %-4s %s", v.Label, v.File)
+		if v.Coarse != 0 || v.Fine != 0 {
+			fmt.Printf("  tune %+d st %+d ct", v.Coarse, v.Fine)
+		}
+		if v.MuteGroup != 0 {
+			fmt.Printf("  mute group %d", v.MuteGroup)
+		}
+		fmt.Println()
+	}
+	for _, s := range p.Sequences() {
+		fmt.Printf("  seq %-3d %s, %g BPM, %d bars\n", s.Slot+1, s.Name, s.BPM, s.Bars)
+	}
+}
+
+func fail(err error) {
+	fmt.Fprintln(os.Stderr, "mpckit-cli:", err)
+	os.Exit(1)
+}

+ 22 - 0
cmd/mpckit-cli/main_test.go

@@ -0,0 +1,22 @@
+package main
+
+import "testing"
+
+func TestParseSlot(t *testing.T) {
+	for in, want := range map[string]int{"A1": 0, "a5": 4, "B1": 16, "h16": 127, " C3 ": 34} {
+		if got, err := parseSlot(in); err != nil || got != want {
+			t.Errorf("parseSlot(%q) = %d, %v", in, got, err)
+		}
+	}
+	for _, in := range []string{"", "A", "A0", "A17", "I1", "1A", "Ax"} {
+		if _, err := parseSlot(in); err == nil {
+			t.Errorf("parseSlot(%q) succeeded", in)
+		}
+	}
+	if from, to, err := parsePair("A1:b5"); err != nil || from != 0 || to != 20 {
+		t.Errorf("parsePair = %d %d %v", from, to, err)
+	}
+	if _, err := parseSeqNum("129"); err == nil {
+		t.Error("sequence 129 accepted")
+	}
+}

+ 1 - 1
docs/build-plan.md

@@ -199,7 +199,7 @@ Done when: a tagged 1.0 build runs on each system.
 
 Run by hand on the MPC Sample. Record results in the format doc.
 
-**A (after M3), files from the Go writer:**
+**A (after M3), files from the Go writer:** built by `go run ./tools/checkpoint-a`; projects, checklist and results table in `tools/checkpoint-a/README.md`.
 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.

+ 243 - 0
internal/export/export.go

@@ -0,0 +1,243 @@
+// Package export writes a project to a folder or a zip file: the .xpj and
+// its "<Name>_[ProjectData]" folder holding every pool file (build plan 4.4).
+package export
+
+import (
+	"archive/zip"
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+)
+
+// ExistsError is returned when the destination already holds the project
+// and overwriting was not allowed. The caller asks the user, then retries.
+type ExistsError struct {
+	Paths []string
+}
+
+func (e *ExistsError) Error() string {
+	return "already exists: " + strings.Join(e.Paths, ", ")
+}
+
+// MissingError lists pool files whose source can no longer be found.
+type MissingError struct {
+	Files []string // pool file names
+	Paths []string // where each was expected
+}
+
+func (e *MissingError) Error() string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "%d sample file(s) are missing:", len(e.Files))
+	for i := range e.Files {
+		fmt.Fprintf(&b, "\n  %s (%s)", e.Files[i], e.Paths[i])
+	}
+	return b.String()
+}
+
+type item struct {
+	name string // file name inside the data folder
+	src  string
+}
+
+// collect checks that every pool file has a source that exists.
+func collect(p *project.Project) ([]item, error) {
+	var items []item
+	missing := &MissingError{}
+	for _, f := range p.PoolFiles() {
+		src := p.Sources[f]
+		if src == "" {
+			missing.Files = append(missing.Files, f)
+			missing.Paths = append(missing.Paths, "no source recorded")
+			continue
+		}
+		if st, err := os.Stat(src); err != nil || !st.Mode().IsRegular() {
+			missing.Files = append(missing.Files, f)
+			missing.Paths = append(missing.Paths, src)
+			continue
+		}
+		items = append(items, item{name: f, src: src})
+	}
+	if len(missing.Files) > 0 {
+		return nil, missing
+	}
+	return items, nil
+}
+
+func encode(p *project.Project) ([]byte, error) {
+	var b bytes.Buffer
+	if err := p.File().Write(&b); err != nil {
+		return nil, err
+	}
+	return b.Bytes(), nil
+}
+
+func dataDir(p *project.Project) string { return p.Name + project.DataSuffix }
+
+// Folder writes <dest>/<Name>.xpj and <dest>/<Name>_[ProjectData]/ and
+// returns the path of the .xpj. Everything is written under a temporary
+// name in dest first and renamed at the end, so a failure leaves any
+// existing project as it was. If either target exists and overwrite is
+// false, nothing is written and an *ExistsError is returned.
+func Folder(p *project.Project, dest string, overwrite bool) (string, error) {
+	if err := project.ValidName(p.Name); err != nil {
+		return "", fmt.Errorf("project name: %w", err)
+	}
+	items, err := collect(p)
+	if err != nil {
+		return "", err
+	}
+	xpjPath := filepath.Join(dest, p.Name+".xpj")
+	dirPath := filepath.Join(dest, dataDir(p))
+	if !overwrite {
+		if exists := existing(xpjPath, dirPath); len(exists) > 0 {
+			return "", &ExistsError{Paths: exists}
+		}
+	}
+	body, err := encode(p)
+	if err != nil {
+		return "", err
+	}
+
+	tmp, err := os.MkdirTemp(dest, ".mpckit-export-")
+	if err != nil {
+		return "", err
+	}
+	defer os.RemoveAll(tmp)
+	tmpData := filepath.Join(tmp, dataDir(p))
+	if err := os.Mkdir(tmpData, 0o755); err != nil {
+		return "", err
+	}
+	for _, it := range items {
+		if err := copyFile(it.src, filepath.Join(tmpData, it.name)); err != nil {
+			return "", err
+		}
+	}
+	tmpXPJ := filepath.Join(tmp, p.Name+".xpj")
+	if err := os.WriteFile(tmpXPJ, body, 0o644); err != nil {
+		return "", err
+	}
+
+	// Replace: the old pair goes only once the new pair is complete.
+	if overwrite {
+		if err := os.RemoveAll(dirPath); err != nil {
+			return "", err
+		}
+		if err := os.Remove(xpjPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+			return "", err
+		}
+	}
+	if err := os.Rename(tmpData, dirPath); err != nil {
+		return "", err
+	}
+	if err := os.Rename(tmpXPJ, xpjPath); err != nil {
+		return "", err
+	}
+	return xpjPath, nil
+}
+
+func existing(paths ...string) []string {
+	var out []string
+	for _, p := range paths {
+		if _, err := os.Lstat(p); err == nil {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+func copyFile(src, dst string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+	if err != nil {
+		return err
+	}
+	if _, err := io.Copy(out, in); err != nil {
+		out.Close()
+		return err
+	}
+	return out.Close()
+}
+
+// Zip writes a zip file at path holding <Name>.xpj and
+// <Name>_[ProjectData]/ at its root, ready to extract onto the device's
+// card. It writes to a temporary file and renames it at the end. If path
+// exists and overwrite is false, an *ExistsError is returned.
+func Zip(p *project.Project, path string, overwrite bool) error {
+	if err := project.ValidName(p.Name); err != nil {
+		return fmt.Errorf("project name: %w", err)
+	}
+	items, err := collect(p)
+	if err != nil {
+		return err
+	}
+	if !overwrite {
+		if exists := existing(path); len(exists) > 0 {
+			return &ExistsError{Paths: exists}
+		}
+	}
+	body, err := encode(p)
+	if err != nil {
+		return err
+	}
+
+	tmp, err := os.CreateTemp(filepath.Dir(path), ".mpckit-export-*.zip")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tmp.Name())
+	if err := writeZip(tmp, p, body, items); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		return err
+	}
+	if err := os.Chmod(tmp.Name(), 0o644); err != nil {
+		return err
+	}
+	return os.Rename(tmp.Name(), path)
+}
+
+func writeZip(w io.Writer, p *project.Project, body []byte, items []item) error {
+	now := time.Now()
+	zw := zip.NewWriter(w)
+	// The .xpj is already gzip-compressed: store it.
+	fw, err := zw.CreateHeader(&zip.FileHeader{Name: p.Name + ".xpj", Method: zip.Store, Modified: now})
+	if err != nil {
+		return err
+	}
+	if _, err := fw.Write(body); err != nil {
+		return err
+	}
+	dir := dataDir(p) + "/"
+	if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir, Modified: now}); err != nil {
+		return err
+	}
+	for _, it := range items {
+		fw, err := zw.CreateHeader(&zip.FileHeader{Name: dir + it.name, Method: zip.Deflate, Modified: now})
+		if err != nil {
+			return err
+		}
+		in, err := os.Open(it.src)
+		if err != nil {
+			return err
+		}
+		_, err = io.Copy(fw, in)
+		in.Close()
+		if err != nil {
+			return err
+		}
+	}
+	return zw.Close()
+}

+ 216 - 0
internal/export/export_test.go

@@ -0,0 +1,216 @@
+package export
+
+import (
+	"archive/zip"
+	"bytes"
+	"compress/gzip"
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"testing"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+)
+
+const testdata = "../../testdata"
+
+func must(t *testing.T, err error) {
+	t.Helper()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func gunzip(t *testing.T, b []byte) []byte {
+	t.Helper()
+	zr, err := gzip.NewReader(bytes.NewReader(b))
+	must(t, err)
+	out, err := io.ReadAll(zr)
+	must(t, err)
+	return out
+}
+
+func readFile(t *testing.T, path string) []byte {
+	t.Helper()
+	b, err := os.ReadFile(path)
+	must(t, err)
+	return b
+}
+
+// buildT1 makes MpckitT1 through the model (as in the M2 golden test).
+func buildT1(t *testing.T) *project.Project {
+	t.Helper()
+	p, err := project.New("MpckitT1")
+	must(t, err)
+	dir := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
+	must(t, p.AssignSample(0, filepath.Join(dir, "Linn 9000 kick.wav")))
+	must(t, p.AssignSample(16, filepath.Join(dir, "Linn 9000 kick001.wav")))
+	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 listDir(t *testing.T, dir string) []string {
+	t.Helper()
+	ents, err := os.ReadDir(dir)
+	must(t, err)
+	var names []string
+	for _, e := range ents {
+		names = append(names, e.Name())
+	}
+	sort.Strings(names)
+	return names
+}
+
+func TestFolder(t *testing.T) {
+	dest := t.TempDir()
+	p := buildT1(t)
+	path, err := Folder(p, dest, false)
+	must(t, err)
+	if path != filepath.Join(dest, "MpckitT1.xpj") {
+		t.Errorf("path = %s", path)
+	}
+	if got := listDir(t, dest); strings.Join(got, "|") != "MpckitT1.xpj|MpckitT1_[ProjectData]" {
+		t.Errorf("dest holds %v", got)
+	}
+	want := gunzip(t, readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")))
+	if !bytes.Equal(gunzip(t, readFile(t, path)), want) {
+		t.Error("exported .xpj differs from MpckitT1.xpj")
+	}
+	src := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
+	data := filepath.Join(dest, "MpckitT1"+project.DataSuffix)
+	if got := listDir(t, data); strings.Join(got, "|") != "Linn 9000 kick.wav|Linn 9000 kick001.wav" {
+		t.Errorf("data folder holds %v", got)
+	}
+	for _, f := range listDir(t, data) {
+		if !bytes.Equal(readFile(t, filepath.Join(data, f)), readFile(t, filepath.Join(src, f))) {
+			t.Errorf("%s differs from its source", f)
+		}
+	}
+
+	// Again without overwrite: refused, nothing touched.
+	marker := filepath.Join(data, "keep.txt")
+	must(t, os.WriteFile(marker, []byte("x"), 0o644))
+	_, err = Folder(p, dest, false)
+	var exists *ExistsError
+	if !errors.As(err, &exists) || len(exists.Paths) != 2 {
+		t.Fatalf("second export: %v", err)
+	}
+	if _, err := os.Stat(marker); err != nil {
+		t.Error("refused export changed the folder")
+	}
+	// With overwrite: the old data folder is replaced, not merged.
+	_, err = Folder(p, dest, true)
+	must(t, err)
+	if _, err := os.Stat(marker); err == nil {
+		t.Error("overwrite kept a stale file in the data folder")
+	}
+	if got := listDir(t, dest); len(got) != 2 {
+		t.Errorf("temporary files left behind: %v", got)
+	}
+}
+
+func TestFolderEmptyProject(t *testing.T) {
+	dest := t.TempDir()
+	p, err := project.New("Empty")
+	must(t, err)
+	_, err = Folder(p, dest, false)
+	must(t, err)
+	// The device creates the data folder even with no samples; so do we.
+	if got := listDir(t, filepath.Join(dest, "Empty"+project.DataSuffix)); len(got) != 0 {
+		t.Errorf("data folder holds %v", got)
+	}
+}
+
+func TestRenamedLoadedProject(t *testing.T) {
+	p, _, err := project.Load(filepath.Join(testdata, "device", "DemoFile.xpj"))
+	must(t, err)
+	must(t, p.SetName("Renamed"))
+	dest := t.TempDir()
+	_, err = Folder(p, dest, false)
+	must(t, err)
+	got := listDir(t, filepath.Join(dest, "Renamed"+project.DataSuffix))
+	if len(got) != 6 {
+		t.Errorf("data folder holds %v", got)
+	}
+	want := gunzip(t, readFile(t, filepath.Join(testdata, "device", "DemoFile.xpj")))
+	if !bytes.Equal(gunzip(t, readFile(t, filepath.Join(dest, "Renamed.xpj"))), want) {
+		t.Error("renaming changed the file contents")
+	}
+}
+
+func TestMissingSources(t *testing.T) {
+	tmp := t.TempDir()
+	p, err := project.New("Missing")
+	must(t, err)
+	for i, name := range []string{"a.wav", "b.wav", "c.wav"} {
+		src := filepath.Join(tmp, name)
+		must(t, os.WriteFile(src, readFile(t, filepath.Join(testdata, "accepted", "MpckitT2"+project.DataSuffix, "Linn 9000 kick.wav")), 0o644))
+		must(t, p.AssignSample(i, src))
+	}
+	must(t, os.Remove(filepath.Join(tmp, "a.wav")))
+	must(t, os.Remove(filepath.Join(tmp, "c.wav")))
+
+	dest := t.TempDir()
+	_, err = Folder(p, dest, false)
+	var missing *MissingError
+	if !errors.As(err, &missing) || strings.Join(missing.Files, ",") != "a.wav,c.wav" {
+		t.Fatalf("Folder: %v", err)
+	}
+	if !strings.Contains(err.Error(), "2 sample file(s)") {
+		t.Errorf("message: %v", err)
+	}
+	if err := Zip(p, filepath.Join(dest, "m.zip"), false); !errors.As(err, &missing) {
+		t.Fatalf("Zip: %v", err)
+	}
+	if got := listDir(t, dest); len(got) != 0 {
+		t.Errorf("failed export wrote %v", got)
+	}
+}
+
+func TestZip(t *testing.T) {
+	dest := t.TempDir()
+	zpath := filepath.Join(dest, "kit.zip")
+	p := buildT1(t)
+	must(t, Zip(p, zpath, false))
+
+	zr, err := zip.OpenReader(zpath)
+	must(t, err)
+	defer zr.Close()
+	var names []string
+	files := map[string][]byte{}
+	for _, f := range zr.File {
+		names = append(names, f.Name)
+		rc, err := f.Open()
+		must(t, err)
+		b, err := io.ReadAll(rc)
+		rc.Close()
+		must(t, err)
+		files[f.Name] = b
+	}
+	want := "MpckitT1.xpj|MpckitT1_[ProjectData]/|MpckitT1_[ProjectData]/Linn 9000 kick.wav|MpckitT1_[ProjectData]/Linn 9000 kick001.wav"
+	if strings.Join(names, "|") != want {
+		t.Errorf("entries %v", names)
+	}
+	if !bytes.Equal(gunzip(t, files["MpckitT1.xpj"]), gunzip(t, readFile(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj")))) {
+		t.Error("zipped .xpj differs from MpckitT1.xpj")
+	}
+	src := filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix, "Linn 9000 kick001.wav")
+	if !bytes.Equal(files["MpckitT1_[ProjectData]/Linn 9000 kick001.wav"], readFile(t, src)) {
+		t.Error("zipped WAV differs from its source")
+	}
+
+	var exists *ExistsError
+	if err := Zip(p, zpath, false); !errors.As(err, &exists) {
+		t.Errorf("existing zip: %v", err)
+	}
+	must(t, Zip(p, zpath, true))
+	if got := listDir(t, dest); strings.Join(got, "|") != "kit.zip" {
+		t.Errorf("temporary files left behind: %v", got)
+	}
+}

+ 9 - 0
internal/project/pads.go

@@ -370,3 +370,12 @@ func (p *Project) PadHasNotes(slot int) bool {
 	})
 	return found
 }
+
+// PoolFiles returns the file name of every pool entry, in pool order.
+func (p *Project) PoolFiles() []string {
+	var files []string
+	for _, e := range p.pool().Items {
+		files = append(files, text(e.Lookup("path")))
+	}
+	return files
+}

+ 71 - 0
internal/wav/decode.go

@@ -0,0 +1,71 @@
+package wav
+
+import (
+	"encoding/binary"
+	"fmt"
+	"io"
+	"math"
+	"os"
+)
+
+// DecodeFile reads the whole WAV at path and returns its samples as 32-bit
+// floats in [-1, 1], one slice per channel. It is the preview fallback for
+// formats a webview cannot decode (build plan section 9).
+func DecodeFile(path string) (Info, [][]float32, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return Info{}, nil, err
+	}
+	defer f.Close()
+	st, err := f.Stat()
+	if err != nil {
+		return Info{}, nil, err
+	}
+	info, err := Read(f, st.Size())
+	if err != nil {
+		return Info{}, nil, err
+	}
+	if err := info.Validate(); err != nil {
+		return Info{}, nil, err
+	}
+	raw := make([]byte, info.Frames*int64(info.BlockAlign))
+	if _, err := f.ReadAt(raw, info.DataOffset); err != nil && err != io.EOF {
+		return Info{}, nil, fmt.Errorf("reading samples: %w", err)
+	}
+	return info, Decode(info, raw), nil
+}
+
+// Decode converts raw sample data described by info into per-channel floats.
+// info must have passed Validate.
+func Decode(info Info, raw []byte) [][]float32 {
+	le := binary.LittleEndian
+	width := info.BlockAlign / info.Channels
+	frames := len(raw) / info.BlockAlign
+	out := make([][]float32, info.Channels)
+	for c := range out {
+		out[c] = make([]float32, frames)
+	}
+	var sample func(b []byte) float32
+	switch {
+	case info.Format == FormatFloat:
+		sample = func(b []byte) float32 { return math.Float32frombits(le.Uint32(b)) }
+	case info.Bits == 8: // unsigned, centred on 128
+		sample = func(b []byte) float32 { return (float32(b[0]) - 128) / 128 }
+	case info.Bits == 16:
+		sample = func(b []byte) float32 { return float32(int16(le.Uint16(b))) / (1 << 15) }
+	case info.Bits == 24:
+		sample = func(b []byte) float32 {
+			v := int32(uint32(b[0])<<8|uint32(b[1])<<16|uint32(b[2])<<24) >> 8
+			return float32(v) / (1 << 23)
+		}
+	case info.Bits == 32:
+		sample = func(b []byte) float32 { return float32(float64(int32(le.Uint32(b))) / (1 << 31)) }
+	}
+	for i := 0; i < frames; i++ {
+		frame := raw[i*info.BlockAlign:]
+		for c := 0; c < info.Channels; c++ {
+			out[c][i] = sample(frame[c*width:])
+		}
+	}
+	return out
+}

+ 68 - 0
internal/wav/wav_test.go

@@ -198,3 +198,71 @@ func TestSyntheticHeaders(t *testing.T) {
 		t.Errorf("exactly 20 minutes: %v", err)
 	}
 }
+
+// Every file in the WAV test pack holds the same tone; decoded, the 44.1 kHz
+// files must agree with the 16-bit control to within 8-bit precision.
+func TestDecodeAgreesAcrossFormats(t *testing.T) {
+	dir := filepath.Join(testdata, "wav-formats")
+	_, ref, err := DecodeFile(filepath.Join(dir, "MpckitW1_[ProjectData]", "44k 16bit mono.wav"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	peak := float32(0)
+	for _, v := range ref[0] {
+		peak = max(peak, v)
+	}
+	if peak < 0.45 || peak > 0.55 {
+		t.Errorf("control peak %v, want about 0.5", peak)
+	}
+	for _, name := range []string{
+		"MpckitW1_[ProjectData]/44k 16bit stereo.wav",
+		"MpckitW1_[ProjectData]/44k 24bit stereo.wav",
+		"MpckitW2_[ProjectData]/44k 8bit mono.wav",
+		"MpckitW2_[ProjectData]/44k 32bit int stereo.wav",
+		"MpckitW2_[ProjectData]/44k 32bit float stereo.wav",
+	} {
+		info, ch, err := DecodeFile(filepath.Join(dir, name))
+		if err != nil {
+			t.Errorf("%s: %v", name, err)
+			continue
+		}
+		if len(ch) != info.Channels || int64(len(ch[0])) != info.Frames {
+			t.Errorf("%s: %d channels of %d frames", name, len(ch), len(ch[0]))
+			continue
+		}
+		worst := float32(0)
+		for c := range ch {
+			for i, v := range ch[c] {
+				d := v - ref[0][i]
+				worst = max(worst, d, -d)
+			}
+		}
+		if worst > 1.0/64 {
+			t.Errorf("%s: differs from the control by up to %v", name, worst)
+		}
+	}
+}
+
+func TestDecodeExtremes(t *testing.T) {
+	cases := []struct {
+		bits   int
+		format int
+		raw    []byte
+		want   []float32
+	}{
+		{8, FormatPCM, []byte{0, 128, 255}, []float32{-1, 0, 127.0 / 128}},
+		{16, FormatPCM, []byte{0, 0x80, 0, 0, 0xff, 0x7f}, []float32{-1, 0, 32767.0 / 32768}},
+		{24, FormatPCM, []byte{0, 0, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f}, []float32{-1, -1.0 / (1 << 23), 8388607.0 / (1 << 23)}},
+		{32, FormatPCM, []byte{0, 0, 0, 0x80, 0, 0, 0, 0}, []float32{-1, 0}},
+		{32, FormatFloat, []byte{0, 0, 0x80, 0x3f, 0, 0, 0, 0xbf}, []float32{1, -0.5}},
+	}
+	for _, c := range cases {
+		info := Info{Format: c.format, Channels: 1, Bits: c.bits, BlockAlign: c.bits / 8}
+		got := Decode(info, c.raw)[0]
+		for i := range c.want {
+			if got[i] != c.want[i] {
+				t.Errorf("%d-bit format %d sample %d = %v, want %v", c.bits, c.format, i, got[i], c.want[i])
+			}
+		}
+	}
+}

+ 42 - 0
pull-requests/m3-export-cli/m3-export-and-cli.md

@@ -0,0 +1,42 @@
+# M3: export, CLI and device checkpoint A pack
+
+Branch: `m3-export-cli` → `main`
+
+## What changed
+
+- **`internal/export`** (build plan 4.4):
+  - `Folder(p, dest, overwrite)` writes `<Name>.xpj` and `<Name>_[ProjectData]/` with every pool file. The data folder is created even when empty, as the device does. Everything is written under a temporary name in `dest` and renamed at the end; an existing project is only removed once the new one is complete.
+  - `Zip(p, path, overwrite)` puts the same two entries at the zip root. The .xpj is stored (it is already gzip), the WAVs are deflated. It writes to a temp file and renames.
+  - Typed errors for the app: `*ExistsError` (ask, then retry with overwrite) and `*MissingError` (every missing source file in one message, nothing written).
+- **`internal/wav`**: added `DecodeFile` / `Decode`, which turn 8/16/24/32-bit PCM and 32-bit float into float32 per channel. This is the preview fallback from build plan section 9; the header reader came in M2.
+- **`internal/project`**: added `PoolFiles()`.
+- **`cmd/mpckit-cli`**: the throwaway CLI. It starts from a blank or loaded (`-load`) project, then applies `-pad A1=file.wav`, `-tune B1=-24,0`, `-mute A3=1`, `-clear`, `-copy/-move/-swap A1:B5`, `-seq 2=Name,BPM,BARS` (adds the sequence if missing) and `-rmseq` in command-line order, and exports with `-out DIR` or `-zip FILE` (`-force` to replace). `-move` warns when the pad has recorded notes.
+- **`tools/checkpoint-a/`**: a Go program that builds `mpckit-checkpoint-a.zip`, so it runs the same in cmd, PowerShell and sh with only Go installed: `go run ./tools/checkpoint-a [-zip FILE] [-folder E:\] [-force]`. `-folder` also writes the projects unzipped, e.g. straight onto the card. `tone.go` generates the WAV-pack tone with plain or extensible headers. `README.md` has the per-project checklist and an empty results table.
+- **Docs**: build plan checkpoint A points at the checklist.
+- `.gitignore` for the built zip.
+
+## Checkpoint A pack
+
+Each risky item is in its own project, so one that fails to load doesn't hide the rest:
+
+| Project | Covers |
+|---|---|
+| MpckitKit | A1 kit (A1–A4, B1); A2 tuning extremes on B2–B7 (±24 st, ±90 ct, both combined); A3 hats choking in group 1; A5 sequences of 1 and 64 bars |
+| MpckitMute | A3: mute groups 16 and 32 written to the file |
+| MpckitTempo | A5: 30, 300, 20 and 999 BPM |
+| MpckitWav | Extensible-header WAVs (48k/24 stereo, 44.1k/16 stereo and mono) against a plain control |
+
+## Testing
+
+`go vet ./...` and `go test ./...` pass.
+
+- **Export**: the folder and zip exports of the model-built MpckitT1 hold a .xpj that decompresses byte-identical to `testdata/accepted/MpckitT1.xpj` and WAVs byte-identical to their sources, with the expected names. Also covered: empty project (empty data folder), refusing then replacing an existing export (no stale files kept, no temp files left), all missing sources reported together with nothing written, and a loaded DemoFile exported under a new name.
+- **CLI**: `mpckit-cli -name MpckitT1 … -tune B1=5,0 -seq 1=Verse,96,8 -seq 2=Chorus,96,4` reproduces MpckitT1.xpj byte-identical. Pad and sequence parsing is unit-tested.
+- **Decoder**: the five 44.1 kHz files in the WAV test pack decode to within 1/64 of the 16-bit control; boundary values tested for each format.
+- **Windows**: `GOOS=windows go vet ./...` and `go build` of the CLI and the pack builder pass. (Not run on Windows itself.) The first version of the pack was built by a shell script driving the CLI; the Go builder's output is byte-identical to it, both in the zip and with `-folder`. A second `-folder` run refuses to overwrite without `-force`.
+- **Tone**: the plain 44.1k/16 mono output is byte-identical to the WAV pack's control file. Python's `wave` module reads its extensible output.
+- **Pack**: extracted and checked with Python's own json/gzip (not our reader): header, pool lists match the files in each data folder, tuning/pitch, mute groups, and all length fields. `sliceInfo.End` for each DemoFile sample matches the value the device itself wrote.
+
+## Not done here
+
+M3's done-when also needs **device checkpoint A to pass**, which is a run on the MPC Sample. No UI work starts until then. Once results are in, I'll record them in the format doc and build plan and update the limits (`MaxMuteGroup`, `MaxBars`, BPM range).

+ 85 - 0
tools/checkpoint-a/README.md

@@ -0,0 +1,85 @@
+# Device checkpoint A
+
+Projects written by the Go writer (`internal/xpj`, `internal/project`, `internal/export`), for build plan section 7, checkpoint A. The builder is a Go program, so the same command works in cmd, PowerShell and sh. It needs only Go.
+
+From the repo root (in cmd, PowerShell or sh):
+
+```
+go run ./tools/checkpoint-a
+```
+
+That writes `mpckit-checkpoint-a.zip` in the current folder. To also write the projects unzipped, for example straight onto the device's card (here drive `E:`):
+
+```
+go run ./tools/checkpoint-a -folder E:\
+```
+
+Options: `-zip FILE` (zip path), `-folder DIR` (also write the projects there), `-force` (replace projects already in that folder). Without `-force`, projects already in the folder are left alone and nothing is written there.
+
+Samples come from `testdata/device/DemoFile_[ProjectData]`. The test tones are generated in `tone.go` and are the same tone as the WAV test pack: 0.8 s at 440 Hz, then a 0.2 s beep an octave higher as an end marker. The plain 44.1 kHz 16-bit mono tone is byte-identical to that pack's control file.
+
+Each risky item is in its own project, so one that fails to load does not hide the others.
+
+## Projects and what to check
+
+### MpckitKit: kit, tuning extremes, choke, lengths (A1, A2, A3, A5)
+
+| Pad | Sample | Setting |
+|---|---|---|
+| A1 | kick | |
+| A2 | snare | |
+| A3 | hat 1 | mute group 1 |
+| A4 | hat 2 | mute group 1 |
+| B1 | bass (C) | none: the reference pitch |
+| B2 | bass | -24 semitones |
+| B3 | bass | +24 semitones |
+| B4 | bass | -90 cents |
+| B5 | bass | +90 cents |
+| B6 | bass | -24 semitones, -90 cents |
+| B7 | bass | +24 semitones, +90 cents |
+
+Sequences: 1 "One Bar" (120 BPM, 1 bar), 2 "Long" (120 BPM, 64 bars).
+
+1. The project loads; A1-A4 and B1 play.
+2. B2-B7 show the tuning in the table and sound shifted against B1 (two octaves down/up for B2/B3, just under a semitone for B4/B5).
+3. A3 and A4 cut each other off. Note the highest mute group number the device's pad settings offer.
+4. Sequence 1 shows 1 bar and sequence 2 shows 64 bars; both loop at their length.
+
+### MpckitMute: high mute group numbers (A3)
+
+A1 and A2 (hats) in group 16; A3 and A4 in group 32.
+
+1. Does the project load?
+2. What group does each pad show? Do A1/A2 choke each other, and A3/A4?
+
+### MpckitTempo: BPM extremes (A5)
+
+Kick on A1. Sequences: 1 "Slow" 30 BPM, 2 "Fast" 300 BPM, 3 "Very Slow" 20 BPM, 4 "Very Fast" 999 BPM (2 bars each).
+
+1. Does the project load?
+2. What BPM does each sequence show? (20 and 999 are probably outside the device's range: note whether it clamps them, and to what. Also note the device's own BPM range from its tempo control.)
+
+### MpckitWav: extensible WAV headers (section 7, "Not yet tested")
+
+| Pad | File |
+|---|---|
+| A1 | 44.1 kHz 16-bit mono, plain header (control) |
+| A2 | 48 kHz 24-bit stereo, extensible header |
+| A3 | 44.1 kHz 16-bit stereo, extensible header |
+| A4 | 44.1 kHz 16-bit mono, extensible header |
+
+1. The project loads, and A2-A4 match A1 in pitch and length, with the end beep heard.
+
+## Results
+
+To fill in after the device run, then copy into the format doc (section 7) and the build plan (section 7, checkpoint A).
+
+| Project | Loads | Result |
+|---|---|---|
+| MpckitKit | | |
+| MpckitMute | | |
+| MpckitTempo | | |
+| MpckitWav | | |
+
+Device mute group range: 
+Device BPM range: 

+ 272 - 0
tools/checkpoint-a/main.go

@@ -0,0 +1,272 @@
+// Command checkpoint-a builds the projects for device checkpoint A (build
+// plan section 7) with the Go writer, and zips them. Run it from the repo
+// root; it works the same in cmd, PowerShell and sh:
+//
+//	go run ./tools/checkpoint-a
+//	go run ./tools/checkpoint-a -zip pack.zip -folder E:\
+//
+// -folder also writes the projects unzipped, e.g. straight onto the card.
+package main
+
+import (
+	"archive/zip"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"sort"
+	"time"
+
+	"git.monoexp.com/bajdev/mpckit/internal/export"
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+)
+
+func main() {
+	zipPath := flag.String("zip", "mpckit-checkpoint-a.zip", "write the pack to this zip `file`")
+	folder := flag.String("folder", "", "also write the projects into this `folder` (e.g. the device's card)")
+	force := flag.Bool("force", false, "replace projects already in -folder")
+	flag.Parse()
+	if err := run(*zipPath, *folder, *force); err != nil {
+		fmt.Fprintln(os.Stderr, "checkpoint-a:", err)
+		os.Exit(1)
+	}
+}
+
+func run(zipPath, folder string, force bool) error {
+	src := filepath.Join("testdata", "device", "DemoFile"+project.DataSuffix)
+	if _, err := os.Stat(src); err != nil {
+		return errors.New("run this from the mpckit repo root (testdata/device not found)")
+	}
+	work, err := os.MkdirTemp("", "mpckit-checkpoint-a-")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(work)
+
+	projects, err := build(src, work)
+	if err != nil {
+		return err
+	}
+	pack := filepath.Join(work, "pack")
+	if err := os.Mkdir(pack, 0o755); err != nil {
+		return err
+	}
+	for _, p := range projects {
+		if _, err := export.Folder(p, pack, false); err != nil {
+			return fmt.Errorf("%s: %w", p.Name, err)
+		}
+		if folder != "" {
+			path, err := export.Folder(p, folder, force)
+			var exists *export.ExistsError
+			if errors.As(err, &exists) {
+				return fmt.Errorf("%w (use -force to replace)", err)
+			}
+			if err != nil {
+				return fmt.Errorf("%s: %w", p.Name, err)
+			}
+			fmt.Println("wrote", path)
+		}
+		fmt.Printf("%s: %d samples, %d sequences\n", p.Name, p.SampleCount(), len(p.Sequences()))
+	}
+	if err := zipDir(pack, zipPath); err != nil {
+		return err
+	}
+	fmt.Println("wrote", zipPath)
+	return nil
+}
+
+type pad struct {
+	label  string
+	file   string
+	coarse int
+	fine   int
+	mute   int
+}
+
+type seq struct {
+	name string
+	bpm  float64
+	bars int
+}
+
+// spec is one test project: pads (applied in order) and sequences 1, 2, ...
+type spec struct {
+	name string
+	pads []pad
+	seqs []seq
+}
+
+func build(src, work string) ([]*project.Project, error) {
+	f := func(name string) string { return filepath.Join(src, name) }
+	kick, snare := f("80s-Kik-Dreamer2 SA.wav"), f("80s-Snr-Dreamer SA.wav")
+	hat1, hat2 := f("80s-Hat-Dreamer1 SA.wav"), f("80s-Hat-Dreamer2 SA.wav")
+	bass := f("Bass-Pure C.wav")
+
+	tones := filepath.Join(work, "tones")
+	if err := os.Mkdir(tones, 0o755); err != nil {
+		return nil, err
+	}
+	t := func(name string, rate, bits, channels int, ext bool) (string, error) {
+		path := filepath.Join(tones, name)
+		return path, os.WriteFile(path, tone(rate, bits, channels, ext), 0o644)
+	}
+	plain, err1 := t("44k 16bit mono.wav", 44100, 16, 1, false)
+	ext48, err2 := t("48k 24bit stereo extensible.wav", 48000, 24, 2, true)
+	ext44s, err3 := t("44k 16bit stereo extensible.wav", 44100, 16, 2, true)
+	ext44m, err4 := t("44k 16bit mono extensible.wav", 44100, 16, 1, true)
+	if err := errors.Join(err1, err2, err3, err4); err != nil {
+		return nil, err
+	}
+
+	specs := []spec{
+		{
+			// A1 kit, A2 tuning extremes, A3 group-1 choke, A5 1 and 64 bars.
+			name: "MpckitKit",
+			pads: []pad{
+				{label: "A1", file: kick}, {label: "A2", file: snare},
+				{label: "A3", file: hat1, mute: 1}, {label: "A4", file: hat2, mute: 1},
+				{label: "B1", file: bass},
+				{label: "B2", file: bass, coarse: -24}, {label: "B3", file: bass, coarse: 24},
+				{label: "B4", file: bass, fine: -90}, {label: "B5", file: bass, fine: 90},
+				{label: "B6", file: bass, coarse: -24, fine: -90}, {label: "B7", file: bass, coarse: 24, fine: 90},
+			},
+			seqs: []seq{{"One Bar", 120, 1}, {"Long", 120, 64}},
+		},
+		{
+			// A3: does the device accept mute groups 16 and 32 in a file?
+			name: "MpckitMute",
+			pads: []pad{
+				{label: "A1", file: hat1, mute: 16}, {label: "A2", file: hat2, mute: 16},
+				{label: "A3", file: hat1, mute: 32}, {label: "A4", file: hat2, mute: 32},
+			},
+		},
+		{
+			// A5: BPM extremes.
+			name: "MpckitTempo",
+			pads: []pad{{label: "A1", file: kick}},
+			seqs: []seq{{"Slow", 30, 2}, {"Fast", 300, 2}, {"Very Slow", 20, 2}, {"Very Fast", 999, 2}},
+		},
+		{
+			// Extensible WAV headers (section 7, "Not yet tested").
+			name: "MpckitWav",
+			pads: []pad{{label: "A1", file: plain}, {label: "A2", file: ext48}, {label: "A3", file: ext44s}, {label: "A4", file: ext44m}},
+		},
+	}
+
+	var out []*project.Project
+	for _, s := range specs {
+		p, err := s.build()
+		if err != nil {
+			return nil, fmt.Errorf("%s: %w", s.name, err)
+		}
+		out = append(out, p)
+	}
+	return out, nil
+}
+
+func (s spec) build() (*project.Project, error) {
+	p, err := project.New(s.name)
+	if err != nil {
+		return nil, err
+	}
+	for _, pd := range s.pads {
+		slot := int(pd.label[0]-'A')*16 + atoi(pd.label[1:]) - 1
+		if err := p.AssignSample(slot, pd.file); err != nil {
+			return nil, err
+		}
+		if pd.coarse != 0 || pd.fine != 0 {
+			if err := p.SetTune(slot, pd.coarse, pd.fine); err != nil {
+				return nil, err
+			}
+		}
+		if pd.mute != 0 {
+			if err := p.SetMuteGroup(slot, pd.mute); err != nil {
+				return nil, err
+			}
+		}
+	}
+	for i, sq := range s.seqs {
+		if i > 0 {
+			if err := p.AddSequence(i); err != nil {
+				return nil, err
+			}
+		}
+		if err := p.SetSequence(i, sq.name, sq.bpm, sq.bars); err != nil {
+			return nil, err
+		}
+	}
+	return p, nil
+}
+
+func atoi(s string) int {
+	n := 0
+	for _, c := range s {
+		n = n*10 + int(c-'0')
+	}
+	return n
+}
+
+// zipDir zips the contents of dir with forward-slash entry names, folders
+// first, in name order.
+func zipDir(dir, path string) error {
+	var entries []string
+	err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
+		if err != nil || p == dir {
+			return err
+		}
+		rel, err := filepath.Rel(dir, p)
+		if err != nil {
+			return err
+		}
+		rel = filepath.ToSlash(rel)
+		if d.IsDir() {
+			rel += "/"
+		}
+		entries = append(entries, rel)
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	sort.Strings(entries)
+
+	out, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	zw := zip.NewWriter(out)
+	now := time.Now()
+	for _, name := range entries {
+		h := &zip.FileHeader{Name: name, Modified: now, Method: zip.Deflate}
+		if name[len(name)-1] == '/' {
+			h.Method = zip.Store
+		}
+		w, err := zw.CreateHeader(h)
+		if err != nil {
+			out.Close()
+			return err
+		}
+		if h.Method == zip.Store {
+			continue
+		}
+		in, err := os.Open(filepath.Join(dir, filepath.FromSlash(name)))
+		if err != nil {
+			out.Close()
+			return err
+		}
+		_, err = io.Copy(w, in)
+		in.Close()
+		if err != nil {
+			out.Close()
+			return err
+		}
+	}
+	if err := zw.Close(); err != nil {
+		out.Close()
+		return err
+	}
+	return out.Close()
+}

+ 69 - 0
tools/checkpoint-a/tone.go

@@ -0,0 +1,69 @@
+package main
+
+import (
+	"bytes"
+	"encoding/binary"
+	"math"
+)
+
+// tone returns the WAV test pack's tone (0.8 s at 440 Hz, then 0.2 s at
+// 880 Hz as an end marker) as a WAV file of integer PCM, optionally with the
+// WAVE_FORMAT_EXTENSIBLE header many audio tools write. bits is 16 or 24.
+func tone(rate, bits, channels int, ext bool) []byte {
+	le := binary.LittleEndian
+	width := bits / 8
+	var data bytes.Buffer
+	fade := 0.005 * float64(rate)
+	for i := 0; i < rate; i++ {
+		t := float64(i) / float64(rate)
+		f := 440.0
+		if t >= 0.8 {
+			f = 880
+		}
+		env := math.Min(1, math.Min(float64(i)/fade, float64(rate-1-i)/fade))
+		v := 0.5 * env * math.Sin(2*math.Pi*f*t)
+		s := int32(math.Round(v * float64(int32(1)<<(bits-1)-1)))
+		var b [4]byte
+		le.PutUint32(b[:], uint32(s))
+		for c := 0; c < channels; c++ {
+			data.Write(b[:width])
+		}
+	}
+
+	align := channels * width
+	var fmtc bytes.Buffer
+	tag := uint16(1)
+	if ext {
+		tag = 0xFFFE
+	}
+	for _, v := range []any{tag, uint16(channels), uint32(rate), uint32(rate * align), uint16(align), uint16(bits)} {
+		binary.Write(&fmtc, le, v)
+	}
+	if ext {
+		mask := uint32(4) // front centre
+		if channels == 2 {
+			mask = 3 // front left, front right
+		}
+		for _, v := range []any{uint16(22), uint16(bits), mask} {
+			binary.Write(&fmtc, le, v)
+		}
+		// KSDATAFORMAT_SUBTYPE_PCM 00000001-0000-0010-8000-00aa00389b71
+		fmtc.Write([]byte{1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71})
+	}
+
+	var body bytes.Buffer
+	body.WriteString("WAVEfmt ")
+	binary.Write(&body, le, uint32(fmtc.Len()))
+	body.Write(fmtc.Bytes())
+	body.WriteString("data")
+	binary.Write(&body, le, uint32(data.Len()))
+	body.Write(data.Bytes())
+	if data.Len()%2 == 1 {
+		body.WriteByte(0)
+	}
+	var out bytes.Buffer
+	out.WriteString("RIFF")
+	binary.Write(&out, le, uint32(body.Len()))
+	out.Write(body.Bytes())
+	return out.Bytes()
+}