Quellcode durchsuchen

Merge branch 'm8-load-guard-rails' of bajdev/mpckit into main

bajdev vor 1 Tag
Ursprung
Commit
2354f57f07

+ 4 - 2
docs/build-plan.md

@@ -246,13 +246,15 @@ Conclusions:
 
 Not yet tested: sample rates above 96 kHz or below 22.05 kHz; 64-bit float (refused in 0.5.0). The extensible header that many audio tools write was confirmed in checkpoint A (PCM inside, 16 and 24-bit, mono and stereo); mpckit accepts it when it declares PCM or float inside.
 
-**B (after M8), round trips through the app:**
+**B (after M8), round trips through the app:** step-by-step projects and a results table in `docs/checkpoint-b.md`.
 1. Load `DemoFile` (recorded notes), change nothing, export: sequences still play the same on the device.
 2. Load `DemoFile`, move A1 to A5 after the warning: A5 plays the kick, the notes still point at A1.
 3. Copy a pad across banks; swap two pads.
 4. Remove a sequence; add one in a different bank.
 5. Shorten a sequence that has notes: note what the device does with notes past the end.
 
+Results (2026-11-11, on the device): all five passed. Notes past a shortened end are kept and play again when the sequence is lengthened. B4 worked but did not settle whether `currentSequence` is a slot or a list position (format doc, open question 7). Details in `docs/checkpoint-b.md`. M8 is done.
+
 ## 8. Builds
 
 - Wails apps are built on each target system. Use one machine or CI runner per system; the Linux VPS alone cannot produce the Mac build.
@@ -267,7 +269,7 @@ Not yet tested: sample rates above 96 kHz or below 22.05 kHz; 64-bit float (refu
 | WAV variants the device rejects or plays wrongly | Largely retired by the WAV test pack (section 7): all eleven formats played correctly. Remaining unknowns are listed there. Akai's spec caps a sample at 20 minutes; refuse longer files. |
 | The app's preview cannot decode a WAV the device accepts | Each system's webview has its own audio decoder, and 8-bit, 32-bit integer or extensible files may fail in some. Try the webview's decoder first; if it fails, fall back to a Go route that decodes the PCM itself and returns 32-bit float samples for the frontend to play. `internal/wav` therefore needs a small decoder as well as the header reader. |
 | Mute group count | Resolved (checkpoint A): none and 1-16. The UI list offers Off, 1-16. |
-| Shortening sequences with notes | Checkpoint B5; until then warn and leave notes untouched. |
+| Shortening sequences with notes | Resolved (checkpoint B5): the device keeps notes past the end and plays them again when the sequence is lengthened. The app still asks before shortening and leaves the notes in the file. |
 | Tuning limits taken from a community toolkit | Resolved (checkpoint A): ±24 semitones and ±90 cents confirmed on the device. |
 | Very long or unusual file names | Keep names as they are; reject only characters illegal on Windows/FAT. Revisit if the device truncates. |
 | File drop quirks per system | Dialog-based assign is always available as the fallback. |

+ 64 - 0
docs/checkpoint-b.md

@@ -0,0 +1,64 @@
+# Device checkpoint B: round trips through the app
+
+Build plan section 7, after M8. Each project below starts from `testdata\device\DemoFile.xpj`, a project the device saved, and is changed and exported **in the app**. Then it is loaded on the MPC Sample.
+
+DemoFile, for reference:
+- Pads: A01 kick, A02 snare, A03 hat 1, A04 hat 2 (A03 and A04 in mute group 1), B01 pad loop, B02 bass.
+- Sequence 01 "Intro" (110 BPM, 2 bars, notes on A01–A03) and Sequence 02 (110 BPM, 4 bars, notes on A01–A04).
+
+For each project: **Load** DemoFile, make the changes, set the project name, then **Export to folder** (straight onto the card) or **Export zip**. Load DemoFile fresh for each one; don't build them on top of each other.
+
+## Projects
+
+### MpckitB1: no changes (B1)
+
+Only rename to `MpckitB1`.
+
+On the device: both sequences play exactly as DemoFile does.
+
+### MpckitB2: move a pad that has notes (B2)
+
+Drag **A01 → A05**. The app warns that A01's notes stay on A01; confirm.
+
+On the device: A05 plays the kick. In the sequences the kick is gone, because the notes still point at A01, which is now empty.
+
+### MpckitB3: copy across banks, swap pads with notes (B3)
+
+1. **Alt**-drag **B01 → C01** (copy into another bank: drag over the **C** letter first).
+2. Drag **A02 onto A03** (swap; the app warns about both pads' notes; confirm).
+
+On the device:
+- C01 plays the same pad loop as B01, and B01 still plays.
+- A02 plays hat 1 and A03 plays the snare. In the sequences, the snare and hat 1 parts have changed places.
+
+### MpckitB4: remove the current sequence, add one in another bank (B4)
+
+1. Sequences tab: select **A01 "Intro"** → **Remove sequence** → confirm.
+2. Select slot **B01** (sequence 17) → **Add sequence** → name `Bridge`, BPM `100`, length `4`.
+
+On the device:
+- The list shows Sequence 02 and Bridge (slot 17), and no Intro.
+- **Which sequence is selected when the project opens?** Intro was the current sequence; the app made the lowest remaining slot (Sequence 02) current. This answers open question 7 in the format doc (is `currentSequence` a slot or a list position?).
+- Bridge is empty, 4 bars, 100 BPM.
+
+### MpckitB5: shorten a sequence that has notes (B5)
+
+Sequence 02: length **4 → 2** bars (the app asks first; confirm).
+
+On the device:
+- Sequence 02 is 2 bars and plays the notes in bars 1–2.
+- **What happened to the notes from bars 3–4?** Lengthen it back to 4 bars on the device: do they come back, or are they gone?
+
+## Results
+
+Run on the MPC Sample on 2026-11-11 by bajdev. All five loaded and passed.
+
+| Project | Loads | Result |
+|---|---|---|
+| MpckitB1 | yes | Worked: plays as DemoFile. |
+| MpckitB2 | yes | Worked: A05 plays the kick; the notes stayed on A01. |
+| MpckitB3 | yes | Worked: the copy in bank C plays; the swapped pads changed places in the sequences. |
+| MpckitB4 | yes | Worked. Sequence 02 (A02) was selected on open. |
+| MpckitB5 | yes | Sequence 02 plays its 2 bars correctly. Lengthened back to 4 bars on the device, the notes in bars 3 and 4 are still there and play. |
+
+B4 does not settle open question 7. In MpckitB4 the stored list is [Bridge (slot 17), Sequence 02 (slot 2)] and `currentSequence` is 1, so "slot 1" and "position 1 in the list" both mean Sequence 02.

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

@@ -249,13 +249,23 @@ Four projects written by the Go writer (`tools/checkpoint-a`, built from commit
 
 Every item on the original 0.5.0 list (project name, pad sample assignment, sequence name, BPM, bar length, adding a sequence) plus pad tuning has now been written by these recipes and accepted by the device.
 
+### Checkpoint B (2026-11-11)
+
+Five projects made from DemoFile in the app, exported, and loaded on the device (details and results in `docs/checkpoint-b.md`). All loaded and worked:
+
+- **Unchanged round trip** (B1): plays as DemoFile.
+- **Moving a pad with notes** (B2): the sample plays from its new pad; the notes stay on the old slot, as expected.
+- **Copy across banks, swap pads with notes** (B3): as expected.
+- **Remove the current sequence, add one in bank B** (B4): works; Sequence 02 is selected on open (see open question 7).
+- **Shorten a sequence with notes** (B5): the notes past the new end are kept and play again once the sequence is lengthened (open question 4).
+
 ## 8. Open questions
 
 1. Device-test a moved and a copied pad.
 1a. ~~Confirm the tuning limits on the device.~~ Confirmed 2026-11-10 (checkpoint A): -24 to +24 semitones and -90 to +90 cents.
 2. Level and pan have not been seen changed; confirm they are written to `pad[n].mixable`.
 3. `triggerMode` values (0 and 3 seen) and how they pair with `loopMode`.
-4. When a sequence with notes is shortened, are notes past the new end kept or removed?
+4. ~~When a sequence with notes is shortened, are notes past the new end kept or removed?~~ Answered 2026-11-11 (checkpoint B5): kept. They don't play while the sequence is shorter, and play again when it is lengthened on the device.
 5. Sample rates outside 22.05-96 kHz and 64-bit float are untested. (The extensible header was confirmed in checkpoint A; other WAV bit depths and sample rates are confirmed; non-WAV formats are out of 0.5.0.)
 6. What key and tempo to write for a sample mpckit imports (the device detects them; mpckit cannot yet).
-7. Is `currentSequence` a slot number or an index into `sequences[]`? Every file seen has 0, and slot 0 is stored second, so the files cannot tell. mpckit treats it as a slot (build plan 4.3); checkpoint B4 should remove the current sequence and check which one the device opens.
+7. Is `currentSequence` a slot number or an index into `sequences[]`? Every file seen has 0, and slot 0 is stored second, so the files cannot tell. mpckit treats it as a slot (build plan 4.3). Checkpoint B4 (removing the current sequence) worked, with Sequence 02 selected on open, but in that file slot 1 and list position 1 are the same sequence, so it does not tell them apart. Still open.

+ 5 - 0
frontend/bindings/events/index.ts

@@ -8,6 +8,11 @@ import type {} from "../github.com/wailsapp/wails/v3/internal/eventdata.js";
 // Make sure event data creation code is registered with the runtime.
 import "../github.com/wailsapp/wails/v3/internal/eventcreate.js";
 
+/**
+ * Name of the custom event "close".
+ */
+export const Close = "close";
+
 /**
  * Name of the custom event "error".
  */

+ 12 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/models.ts

@@ -84,6 +84,13 @@ export interface Pad {
      */
     "start": number;
     "end": number;
+
+    /**
+     * Source is where the sample is copied from at export; Missing is set
+     * when that file no longer exists.
+     */
+    "source": string;
+    "missing": boolean;
 }
 
 /**
@@ -119,6 +126,11 @@ export interface State {
      */
     "sequences": Sequence[] | null;
     "sampleCount": number;
+
+    /**
+     * pool files whose source is missing
+     */
+    "missingCount": number;
     "sequenceCount": number;
     "limits": Limits;
 }

+ 8 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/service.ts

@@ -66,6 +66,14 @@ export function ClearPad(slot: number): $CancellablePromise<$models.State> {
     return $Call.ByID(3159325479, slot);
 }
 
+/**
+ * CloseWindow closes the app without asking about unsaved changes. The
+ * frontend calls it after the user chose to discard them.
+ */
+export function CloseWindow(): $CancellablePromise<void> {
+    return $Call.ByID(2314271135);
+}
+
 /**
  * CopyPad copies the pad in from to slot to, replacing what was there.
  */

+ 1 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts

@@ -12,6 +12,7 @@ import type * as app$0 from "../../../../../git.monoexp.com/bajdev/mpckit/intern
 declare module "@wailsio/runtime" {
     namespace Events {
         interface CustomEvents {
+            "close": void;
             "error": string;
             "state": app$0.State;
         }

+ 15 - 4
frontend/src/App.svelte

@@ -33,6 +33,10 @@
     });
   }
 
+  // Export would fail on a missing sample file, so it is not offered at all.
+  const exportBlocked = $derived((st?.missingCount ?? 0) > 0);
+  const exportBlockedWhy = $derived(exportBlocked ? "Export is disabled while sample files are missing. Replace them, or put the files back and load the project again." : undefined);
+
   const padCounts = $derived(perBank((s) => (pads[s]?.file ?? "") !== ""));
   const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
 
@@ -40,13 +44,16 @@
     st
       ? gridSlots(ui.padBank).map((slot) => {
           const p = pads[slot];
-          const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
+          const badges = p.missing
+            ? ["missing"]
+            : [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
           return {
             slot,
             label: p.label,
             name: p.name,
             meta: p.file ? badges.join(" · ") : "",
-            aria: `Pad ${p.label}, ${p.file || "empty"}${p.hasNotes ? ", has recorded notes" : ""}`,
+            aria: `Pad ${p.label}, ${p.file || "empty"}${p.missing ? ", sample file missing" : ""}${p.hasNotes ? ", has recorded notes" : ""}`,
+            warn: p.missing,
           };
         })
       : [],
@@ -117,8 +124,8 @@
     <div class="actions">
       <button type="button" class="btn" onclick={newProject}>New</button>
       <button type="button" class="btn" onclick={loadProject}>Load</button>
-      <button type="button" class="btn" onclick={exportZip}>Export zip</button>
-      <button type="button" class="btn primary" onclick={exportFolder}>Export to folder</button>
+      <button type="button" class="btn" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportZip}>Export zip</button>
+      <button type="button" class="btn primary" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportFolder}>Export to folder</button>
     </div>
     <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
   </header>
@@ -185,6 +192,7 @@
       <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
       <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
       <span>WAV files only</span>
+      {#if st.missingCount}<span class="missing">{st.missingCount} sample {st.missingCount === 1 ? "file" : "files"} missing: export is disabled</span>{/if}
       {#if st.dirty}<span class="dirty">Unsaved changes: export to keep them</span>{/if}
       {#if app.notice}<span class="notice" title={app.notice}>{app.notice}</span>{/if}
     </footer>
@@ -314,6 +322,9 @@
   .dirty {
     color: var(--accent);
   }
+  .missing {
+    color: #ff8a8a;
+  }
   .notice {
     margin-left: auto;
     min-width: 0;

+ 21 - 1
frontend/src/lib/PadPanel.svelte

@@ -47,8 +47,15 @@
     </div>
   {:else}
     <div class="stack">
+      {#if pad.missing}
+        <div class="missing" role="status">
+          <strong>Sample file not found.</strong>
+          <span class="mono">{pad.source}</span>
+          <span>Replace it, or put the file back and load the project again. Export is disabled until then.</span>
+        </div>
+      {/if}
       <div class="row">
-        <button type="button" class="btn primary" onclick={() => preview(pad)}>
+        <button type="button" class="btn primary" disabled={pad.missing} onclick={() => preview(pad)}>
           <svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z" /></svg>
           Play
         </button>
@@ -143,6 +150,19 @@
   .notes {
     color: var(--accent);
   }
+  .missing {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding: 10px 12px;
+    border: 1px solid #e5484d;
+    border-radius: 6px;
+    color: #ffb4b4;
+    font-size: 12px;
+  }
+  .missing .mono {
+    overflow-wrap: anywhere;
+  }
   .drop:global(.file-drop-target-active) {
     border-color: var(--accent);
     background: rgba(245, 165, 36, 0.08);

+ 1 - 1
frontend/src/lib/SequencePanel.svelte

@@ -25,7 +25,7 @@
     if (seq.hasNotes && l < seq.bars) {
       const ok = await confirm({
         title: "Shorten a sequence with notes?",
-        message: `${seq.name} has recorded notes. Notes after bar ${l} stay in the file; what the device does with them is not yet known.`,
+        message: `${seq.name} has recorded notes. Notes after bar ${l} will not play, but they are kept: lengthening the sequence again brings them back.`,
         items: [],
         action: "Shorten",
       });

+ 5 - 0
frontend/src/lib/SlotGrid.svelte

@@ -5,6 +5,7 @@
     name: string; // sample or sequence name; empty for an empty slot
     meta: string; // small line under the name
     aria: string;
+    warn?: boolean; // something is wrong with this slot, e.g. its sample is missing
   };
 </script>
 
@@ -52,6 +53,7 @@
     <button
       type="button"
       class:filled={c.name !== ""}
+      class:warn={c.warn}
       class:on={c.slot === selected}
       class:over={c.slot === over}
       class:copying={c.slot === over && copying}
@@ -113,6 +115,9 @@
   button.on {
     border-color: var(--accent);
   }
+  button.warn .meta {
+    color: #ff8a8a;
+  }
   /* A pad dragged over this cell: dashed for move or swap, solid for copy. */
   button.over {
     border-style: dashed;

+ 13 - 0
frontend/src/lib/store.svelte.ts

@@ -112,9 +112,21 @@ export function listen(): () => void {
   const offError = Events.On(EventNames.Error, (e) => {
     app.error = e.data;
   });
+  // The window's close button, with unsaved changes: Go waits for us.
+  const offClose = Events.On(EventNames.Close, async () => {
+    if (ui.confirm) return; // already asking something
+    const ok = await confirm({
+      title: "Close without exporting?",
+      message: "This project has changes that have not been exported. They will be lost.",
+      items: [],
+      action: "Close",
+    });
+    if (ok) await Service.CloseWindow();
+  });
   return () => {
     offState();
     offError();
+    offClose();
   };
 }
 
@@ -135,6 +147,7 @@ async function exportWith(
   choose: () => Promise<string>,
   write: (target: string, overwrite: boolean) => Promise<ExportResult>,
 ): Promise<void> {
+  if (app.state?.missingCount) return; // the buttons are disabled too
   try {
     const target = await choose();
     if (!target) return; // dialog cancelled

+ 45 - 1
internal/app/app.go

@@ -8,6 +8,7 @@ import (
 	"encoding/hex"
 	"errors"
 	"fmt"
+	"os"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -40,6 +41,10 @@ type Pad struct {
 	// plays only this region, as the device does.
 	Start int64 `json:"start"`
 	End   int64 `json:"end"`
+	// Source is where the sample is copied from at export; Missing is set
+	// when that file no longer exists.
+	Source  string `json:"source"`
+	Missing bool   `json:"missing"`
 }
 
 // Sequence is one existing sequence.
@@ -74,6 +79,7 @@ type State struct {
 	Pads          []Pad      `json:"pads"`      // all 128, in slot order
 	Sequences     []Sequence `json:"sequences"` // existing ones, in slot order
 	SampleCount   int        `json:"sampleCount"`
+	MissingCount  int        `json:"missingCount"` // pool files whose source is missing
 	SequenceCount int        `json:"sequenceCount"`
 	Limits        Limits     `json:"limits"`
 }
@@ -116,6 +122,29 @@ type Service struct {
 	mu      sync.Mutex
 	p       *project.Project
 	dialogs Dialogs
+	closer  func() // closes the window without asking; set by main.go
+}
+
+// SetCloser sets what CloseWindow calls.
+//
+//wails:ignore
+func (s *Service) SetCloser(f func()) { s.closer = f }
+
+// Dirty reports whether the project has changes not yet exported.
+//
+//wails:ignore
+func (s *Service) Dirty() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.p.Dirty
+}
+
+// CloseWindow closes the app without asking about unsaved changes. The
+// frontend calls it after the user chose to discard them.
+func (s *Service) CloseWindow() {
+	if s.closer != nil {
+		s.closer()
+	}
 }
 
 // NewService returns a service holding a new, empty project.
@@ -188,6 +217,12 @@ func (s *Service) state() State {
 			MaxBars:      project.MaxBars,
 		},
 	}
+	missing := map[string]bool{} // pool file -> source missing
+	for _, f := range p.PoolFiles() {
+		src := p.Sources[f]
+		info, err := os.Stat(src)
+		missing[f] = src == "" || err != nil || !info.Mode().IsRegular()
+	}
 	for slot := 0; slot < project.Pads; slot++ {
 		v, err := p.Pad(slot)
 		if err != nil {
@@ -199,7 +234,11 @@ func (s *Service) state() State {
 			Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup, Start: v.Start, End: v.End}
 		if !v.Empty() {
 			pad.HasNotes = p.PadHasNotes(slot)
-			pad.Audio = audioURL(slot, p.Sources[v.File])
+			pad.Source = p.Sources[v.File]
+			pad.Missing = missing[v.File]
+			if !pad.Missing {
+				pad.Audio = audioURL(slot, pad.Source)
+			}
 		}
 		st.Pads = append(st.Pads, pad)
 	}
@@ -208,6 +247,11 @@ func (s *Service) state() State {
 			Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
 	}
 	st.SequenceCount = len(st.Sequences)
+	for _, m := range missing {
+		if m {
+			st.MissingCount++
+		}
+	}
 	return st
 }
 

+ 76 - 0
internal/app/app_test.go

@@ -434,3 +434,79 @@ func TestGoldenT1ThroughService(t *testing.T) {
 		t.Error("differs from testdata/accepted/MpckitT1.xpj")
 	}
 }
+
+// copyProject copies DemoFile and its data folder into dir.
+func copyProject(t *testing.T, dir string) string {
+	t.Helper()
+	src := filepath.Join(testdata, "device")
+	must(t, os.Mkdir(filepath.Join(dir, "DemoFile"+project.DataSuffix), 0o755))
+	b, err := os.ReadFile(filepath.Join(src, "DemoFile.xpj"))
+	must(t, err)
+	must(t, os.WriteFile(filepath.Join(dir, "DemoFile.xpj"), b, 0o644))
+	ents, err := os.ReadDir(filepath.Join(src, "DemoFile"+project.DataSuffix))
+	must(t, err)
+	for _, e := range ents {
+		b, err := os.ReadFile(filepath.Join(src, "DemoFile"+project.DataSuffix, e.Name()))
+		must(t, err)
+		must(t, os.WriteFile(filepath.Join(dir, "DemoFile"+project.DataSuffix, e.Name()), b, 0o644))
+	}
+	return filepath.Join(dir, "DemoFile.xpj")
+}
+
+func TestMissingFiles(t *testing.T) {
+	dir := t.TempDir()
+	path := copyProject(t, dir)
+	must(t, os.Remove(filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")))
+
+	s, _ := newService()
+	res, err := s.LoadPath(path)
+	must(t, err)
+	st := res.State
+	if st.MissingCount != 1 {
+		t.Fatalf("missing count %d, want 1", st.MissingCount)
+	}
+	a2 := st.Pads[1]
+	if !a2.Missing || a2.Audio != "" || !strings.HasSuffix(a2.Source, "80s-Snr-Dreamer SA.wav") {
+		t.Errorf("A02 %+v", a2)
+	}
+	if st.Pads[0].Missing || st.Pads[0].Audio == "" {
+		t.Error("A01 marked missing")
+	}
+
+	// Export names the missing file and writes nothing.
+	out := t.TempDir()
+	if _, err := s.ExportFolder(out, false); err == nil || !strings.Contains(err.Error(), "80s-Snr-Dreamer SA.wav") {
+		t.Errorf("export with a missing file: %v", err)
+	}
+	if ents, _ := os.ReadDir(out); len(ents) != 0 {
+		t.Error("export wrote files")
+	}
+
+	// Replacing the pad's sample fixes it.
+	st, err = s.AssignSamplePath(1, filepath.Join(kickDir, "Linn 9000 kick.wav"))
+	must(t, err)
+	if st.MissingCount != 0 || st.Pads[1].Missing {
+		t.Error("still missing after replace")
+	}
+	if _, err := s.ExportFolder(out, false); err != nil {
+		t.Error(err)
+	}
+}
+
+func TestCloseWindow(t *testing.T) {
+	s, _ := newService()
+	s.CloseWindow() // no closer set: does nothing
+	closed := false
+	s.SetCloser(func() { closed = true })
+	if s.Dirty() {
+		t.Error("new project dirty")
+	}
+	_, _ = s.SetName("Other")
+	if !s.Dirty() {
+		t.Error("not dirty after rename")
+	}
+	s.CloseWindow()
+	if !closed {
+		t.Error("closer not called")
+	}
+}

+ 19 - 0
main.go

@@ -7,6 +7,7 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
+	"sync/atomic"
 
 	"github.com/wailsapp/wails/v3/pkg/application"
 	"github.com/wailsapp/wails/v3/pkg/events"
@@ -30,11 +31,13 @@ const (
 const (
 	eventState = "state" // a new app.State after a change the frontend did not ask for
 	eventError = "error" // a message to show
+	eventClose = "close" // the window was asked to close with unsaved changes
 )
 
 func init() {
 	application.RegisterEvent[app.State](eventState)
 	application.RegisterEvent[string](eventError)
+	application.RegisterEvent[application.Void](eventClose)
 }
 
 func main() {
@@ -80,6 +83,22 @@ func main() {
 		w.Show()
 	})
 
+	// Closing with unsaved changes: stop, and let the frontend ask. If the
+	// user chooses to discard them, the frontend calls CloseWindow, which
+	// closes for real.
+	var closeConfirmed atomic.Bool
+	svc.SetCloser(func() {
+		closeConfirmed.Store(true)
+		w.Close()
+	})
+	w.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
+		if closeConfirmed.Load() || !svc.Dirty() {
+			return
+		}
+		e.Cancel()
+		a.Event.Emit(eventClose)
+	})
+
 	// A WAV dropped on a pad (an element with data-file-drop-target and
 	// data-slot) is assigned to that pad.
 	w.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) {

+ 36 - 0
pull-requests/m8-load-guard-rails/m8-load-and-guard-rails.md

@@ -0,0 +1,36 @@
+# M8: load and guard rails
+
+Branch: `m8-load-guard-rails` → `main`
+
+Load itself, the unsaved-changes prompt on New and Load, the overwrite confirmation and Go's error messages for bad WAVs and limits came in M5–M7. This PR adds the rest of M8 and the checklist for device checkpoint B.
+
+## What changed
+
+- **Missing sample files.** A loaded project's samples are expected in its `_[ProjectData]` folder, and a sample assigned from elsewhere can be moved or deleted later. The state now says, per pad, where the sample comes from and whether that file still exists.
+  - The grid shows `missing` (in red) on such pads.
+  - The pad panel shows the expected path and says to replace the sample or put the file back and load again. Play is disabled.
+  - The footer counts missing files.
+  - **Export zip and Export to folder are disabled** while any sample file is missing, and the footer says so ("1 sample file missing: export is disabled"). The buttons' tooltip gives the reason too. Go still refuses such an export and lists every missing file, as since M3, if it's ever asked.
+  - Replacing the sample clears the warning.
+- **Closing the window with unsaved changes.** A Go hook stops the close and sends a `close` event. The app asks "Close without exporting?"; Close discards the changes and quits, Cancel stays. Without unsaved changes the window closes straight away.
+- **Device checkpoint B checklist** (`docs/checkpoint-b.md`, linked from the build plan). Five projects (MpckitB1–B5), each made from DemoFile in the app: no changes, moving a pad with notes, copying across banks and swapping pads with notes, removing the current sequence and adding one in bank B, and shortening a sequence with notes. Each lists what to listen for, plus an empty results table. It also covers open question 7 (`currentSequence`) and B5 (notes past a new end).
+- Go service: `CloseWindow` for the frontend. `Dirty` and `SetCloser` are for `main.go` only and kept out of the bindings.
+
+**Change after the first laptop run** (bajdev, 2026-11-11: checks 1–3 passed): disable the export buttons while a sample is missing. Before, a user could choose a folder and miss the error text.
+
+**Checkpoint B results** (bajdev, 2026-11-11): all five projects loaded and worked on the device, so M8's done-when is met. Recorded in `docs/checkpoint-b.md`, the format doc (section 7, open questions 4 and 7) and the build plan (checkpoint B, risk table).
+- Open question 4 is answered (B5): notes past a shortened end are kept, and play again when the sequence is lengthened. The shorten dialog now says so, instead of "not yet known".
+- Open question 7 (`currentSequence` slot or list position) is still open: in MpckitB4 both readings point at Sequence 02.
+
+## Testing
+
+On the server: `go vet` and `go test ./...` pass. New tests:
+- **Missing files:** load a copy of DemoFile with one sample deleted. The count is 1 and the right pad is marked (no preview URL, source path set). Export fails naming the file and writes nothing. After replacing the sample, the count is 0 and export works.
+- **Close:** `CloseWindow` calls the closer, and `Dirty` follows edits.
+
+The frontend is untested here (no Node). **On the laptop:**
+
+1. `npm run check` in `frontend`: 0 errors, 0 warnings.
+2. **Missing file:** copy `DemoFile.xpj` and its `DemoFile_[ProjectData]` folder somewhere, delete `80s-Snr-Dreamer SA.wav` from the copy, and Load it. A02 shows `missing`, A02's panel shows the path, and Play is disabled. Both export buttons are disabled, and the footer says "1 sample file missing: export is disabled". Replace A02 with any WAV: the warning goes and both export buttons work again.
+3. **Close:** make any change and close the window. The app asks; Cancel stays open. Close again and choose Close: the app quits. With no changes (or right after exporting) the window closes without asking.
+4. **Checkpoint B** (M8 done-when): build the five projects in `docs/checkpoint-b.md`, try them on the device, and send me the results. I'll record them in the format doc and build plan.