Bläddra i källkod

Checkpoint A pack: build with Go instead of sh

Replace build.sh and gentone with one Go program, so the pack builds the
same way from cmd, PowerShell or sh with only Go installed. Adds
-folder to write the projects straight onto the card. Output is
byte-identical to the shell-built pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 2 dagar sedan
förälder
incheckning
bee5b278df

+ 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:** built by `tools/checkpoint-a/build.sh`; projects, checklist and results table in `tools/checkpoint-a/README.md`.
+**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.

+ 3 - 2
pull-requests/m3-export-cli/m3-export-and-cli.md

@@ -11,7 +11,7 @@ Branch: `m3-export-cli` → `main`
 - **`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.
+- **`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.
 
@@ -33,7 +33,8 @@ Each risky item is in its own project, so one that fails to load doesn't hide th
 - **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.
+- **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

+ 13 - 3
tools/checkpoint-a/README.md

@@ -1,12 +1,22 @@
 # 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:
+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:`):
 
 ```
-tools/checkpoint-a/build.sh            # writes mpckit-checkpoint-a.zip
+go run ./tools/checkpoint-a -folder E:\
 ```
 
-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).
+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.
 

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

@@ -1,61 +0,0 @@
-#!/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"

+ 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()
+}

+ 3 - 24
tools/checkpoint-a/gentone/main.go → tools/checkpoint-a/tone.go

@@ -1,35 +1,14 @@
-// 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)
-	}
-}
-
+// 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