Răsfoiți Sursa

M3: export, CLI and device checkpoint A pack

Add internal/export (folder and zip, atomic replace, missing-file and
exists errors), a WAV decoder for the preview fallback, the throwaway
mpckit-cli, and tools/checkpoint-a to build the device test pack.
The CLI reproduces MpckitT1 byte-for-byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 2 zile în urmă
părinte
comite
3c2b97b38c

+ 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 `tools/checkpoint-a/build.sh`; 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])
+			}
+		}
+	}
+}

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

@@ -0,0 +1,41 @@
+# 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/`**: `build.sh` builds `mpckit-checkpoint-a.zip` with the CLI. `gentone` writes 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.
+- **gentone**: its 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).

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

@@ -0,0 +1,75 @@
+# Device checkpoint A
+
+Projects written by the Go writer (`internal/xpj`, `internal/project`, `internal/export`) through `mpckit-cli`, for build plan section 7, checkpoint A. Build the pack from the repo root:
+
+```
+tools/checkpoint-a/build.sh            # writes mpckit-checkpoint-a.zip
+```
+
+It needs Go and `python3` (for the zip). Samples come from `testdata/device/DemoFile_[ProjectData]`; the test tones come from `gentone`, which writes 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).
+
+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: 

+ 61 - 0
tools/checkpoint-a/build.sh

@@ -0,0 +1,61 @@
+#!/bin/sh
+# Builds mpckit-checkpoint-a.zip: the projects for device checkpoint A
+# (build plan section 7), written by the Go writer through mpckit-cli.
+# Usage: tools/checkpoint-a/build.sh [output.zip]   (run from the repo root)
+set -eu
+
+out=${1:-mpckit-checkpoint-a.zip}
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+
+cli="$work/mpckit-cli"
+gentone="$work/gentone"
+go build -o "$cli" ./cmd/mpckit-cli
+go build -o "$gentone" ./tools/checkpoint-a/gentone
+
+f="testdata/device/DemoFile_[ProjectData]"
+kick="$f/80s-Kik-Dreamer2 SA.wav"
+snare="$f/80s-Snr-Dreamer SA.wav"
+hat1="$f/80s-Hat-Dreamer1 SA.wav"
+hat2="$f/80s-Hat-Dreamer2 SA.wav"
+bass="$f/Bass-Pure C.wav"
+tones="$work/tones"
+mkdir -p "$tones" "$work/pack"
+
+# A1 kit, A2 tuning extremes, A3 mute group 1 choke, A5 1 and 64 bars.
+"$cli" -name MpckitKit -out "$work/pack" \
+	-pad "A1=$kick" -pad "A2=$snare" -pad "A3=$hat1" -pad "A4=$hat2" -pad "B1=$bass" \
+	-mute A3=1 -mute A4=1 \
+	-copy B1:B2 -tune B2=-24,0 \
+	-copy B1:B3 -tune B3=24,0 \
+	-copy B1:B4 -tune B4=0,-90 \
+	-copy B1:B5 -tune B5=0,90 \
+	-copy B1:B6 -tune B6=-24,-90 \
+	-copy B1:B7 -tune B7=24,90 \
+	-seq "1=One Bar,120,1" -seq "2=Long,120,64"
+
+# A3 high mute groups: does the device accept 16 and 32 in a file?
+"$cli" -name MpckitMute -out "$work/pack" \
+	-pad "A1=$hat1" -pad "A2=$hat2" -pad "A3=$hat1" -pad "A4=$hat2" \
+	-mute A1=16 -mute A2=16 -mute A3=32 -mute A4=32
+
+# A5 BPM extremes.
+"$cli" -name MpckitTempo -out "$work/pack" \
+	-pad "A1=$kick" \
+	-seq "1=Slow,30,2" -seq "2=Fast,300,2" -seq "3=Very Slow,20,2" -seq "4=Very Fast,999,2"
+
+# Extensible WAV headers (build plan section 7, "Not yet tested").
+"$gentone" "$tones/44k 16bit mono.wav"
+"$gentone" -rate 48000 -bits 24 -channels 2 -extensible "$tones/48k 24bit stereo extensible.wav"
+"$gentone" -rate 44100 -bits 16 -channels 2 -extensible "$tones/44k 16bit stereo extensible.wav"
+"$gentone" -rate 44100 -bits 16 -channels 1 -extensible "$tones/44k 16bit mono extensible.wav"
+"$cli" -name MpckitWav -out "$work/pack" \
+	-pad "A1=$tones/44k 16bit mono.wav" \
+	-pad "A2=$tones/48k 24bit stereo extensible.wav" \
+	-pad "A3=$tones/44k 16bit stereo extensible.wav" \
+	-pad "A4=$tones/44k 16bit mono extensible.wav"
+
+rm -f "$out"
+(cd "$work/pack" && python3 -m zipfile -c "$work/out.zip" *)
+mv "$work/out.zip" "$out"
+echo "wrote $out"

+ 90 - 0
tools/checkpoint-a/gentone/main.go

@@ -0,0 +1,90 @@
+// Command gentone writes the WAV test pack's tone (0.8 s at 440 Hz, then
+// 0.2 s at 880 Hz as an end marker) as integer PCM, optionally with the
+// WAVE_FORMAT_EXTENSIBLE header many audio tools write.
+//
+//	go run ./tools/checkpoint-a/gentone -rate 48000 -bits 24 -channels 2 -extensible out.wav
+package main
+
+import (
+	"bytes"
+	"encoding/binary"
+	"flag"
+	"fmt"
+	"math"
+	"os"
+)
+
+func main() {
+	rate := flag.Int("rate", 44100, "sample rate")
+	bits := flag.Int("bits", 16, "bits per sample: 16 or 24")
+	channels := flag.Int("channels", 1, "1 or 2")
+	ext := flag.Bool("extensible", false, "write a WAVE_FORMAT_EXTENSIBLE header (PCM sub-format)")
+	flag.Parse()
+	if flag.NArg() != 1 || (*bits != 16 && *bits != 24) || *channels < 1 || *channels > 2 {
+		fmt.Fprintln(os.Stderr, "usage: gentone [-rate N] [-bits 16|24] [-channels 1|2] [-extensible] out.wav")
+		os.Exit(2)
+	}
+	if err := os.WriteFile(flag.Arg(0), tone(*rate, *bits, *channels, *ext), 0o644); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+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()
+}