浏览代码

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

bajdev 2 天之前
父节点
当前提交
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.
 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.
 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.
 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.
 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
 	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)
 		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()
+}