Răsfoiți Sursa

M5: Pads tab

Assign by dialog and file drop, replace, clear, tuning, mute group,
project name and export (folder and zip, with overwrite confirmation)
through the Go service. Preview pads with Web Audio, applying tuning,
the slice region and mute-group choking; Go serves the samples and
decodes formats the webview cannot. Add tools/xpjdiff to compare
projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 zi în urmă
părinte
comite
d8ef3d4559

+ 1 - 1
README.md

@@ -25,7 +25,7 @@ The first run installs the frontend packages and generates `frontend/bindings` f
 
 Tests: `go test ./...`. On Linux without the GTK packages, use `CGO_ENABLED=0 go test -tags purego ./...` instead.
 
-`go run ./cmd/mpckit-cli` builds a project from the command line, and `go run ./tools/checkpoint-a` builds the device test pack (see its README).
+`go run ./cmd/mpckit-cli` builds a project from the command line, `go run ./tools/checkpoint-a` builds the device test pack (see its README), and `go run ./tools/xpjdiff A.xpj B.xpj` compares two projects (the .xpj contents and the data folders).
 
 ## Disclaimer
 

+ 43 - 0
dialogs.go

@@ -0,0 +1,43 @@
+package main
+
+import (
+	"github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// dialogs implements app.Dialogs with Wails' native file dialogs.
+type dialogs struct {
+	a *application.App
+}
+
+// cancelled maps a dialog the user closed to "", nil. On Windows Wails
+// returns an error with this text instead of an empty path.
+func cancelled(path string, err error) (string, error) {
+	if err != nil && err.Error() == "cancelled by user" {
+		return "", nil
+	}
+	return path, err
+}
+
+func (d *dialogs) OpenWAV() (string, error) {
+	return cancelled(d.a.Dialog.OpenFile().
+		SetTitle("Choose a WAV file").
+		CanChooseFiles(true).
+		AddFilter("WAV files", "*.wav").
+		PromptForSingleSelection())
+}
+
+func (d *dialogs) ChooseFolder() (string, error) {
+	return cancelled(d.a.Dialog.OpenFile().
+		SetTitle("Export to folder").
+		CanChooseFiles(false).
+		CanChooseDirectories(true).
+		CanCreateDirectories(true).
+		PromptForSingleSelection())
+}
+
+func (d *dialogs) SaveZip(defaultName string) (string, error) {
+	return cancelled(d.a.Dialog.SaveFile().
+		SetFilename(defaultName).
+		AddFilter("Zip files", "*.zip").
+		PromptForSingleSelection())
+}

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

@@ -1,6 +1,20 @@
 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
 // This file is automatically generated. DO NOT EDIT
 
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+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 "error".
+ */
+export const Error = "error";
+
+/**
+ * Name of the custom event "state".
+ */
+export const State = "state";
+

+ 1 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/index.ts

@@ -8,6 +8,7 @@ export {
 
 export type {
     About,
+    ExportResult,
     Limits,
     Pad,
     Sequence,

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

@@ -10,6 +10,16 @@ export interface About {
     "repo": string;
 }
 
+/**
+ * ExportResult is what an export call returns. If Exists is not empty,
+ * nothing was written: the frontend asks the user, then calls again with
+ * overwrite set.
+ */
+export interface ExportResult {
+    "path": string;
+    "exists": string[] | null;
+}
+
 /**
  * Limits are the ranges the UI offers, from the device checks.
  */
@@ -51,6 +61,19 @@ export interface Pad {
     "fine": number;
     "muteGroup": number;
     "hasNotes": boolean;
+
+    /**
+     * Audio is the URL the preview loads the sample from; empty for an
+     * empty pad. It changes when the pad's source file changes.
+     */
+    "audio": string;
+
+    /**
+     * Start and End are the slice's first and last frame. The preview
+     * plays only this region, as the device does.
+     */
+    "start": number;
+    "end": number;
 }
 
 /**

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

@@ -22,6 +22,58 @@ export function About(): $CancellablePromise<$models.About> {
     return $Call.ByID(790948986);
 }
 
+/**
+ * AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
+ * Cancelling the dialog changes nothing.
+ */
+export function AssignSampleDialog(slot: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(254710842, slot);
+}
+
+/**
+ * AssignSamplePath puts the WAV at path on the pad in slot. File drop uses it.
+ */
+export function AssignSamplePath(slot: number, path: string): $CancellablePromise<$models.State> {
+    return $Call.ByID(614211513, slot, path);
+}
+
+/**
+ * ChooseExportFolder asks for the folder to export into; "" if cancelled.
+ */
+export function ChooseExportFolder(): $CancellablePromise<string> {
+    return $Call.ByID(3758813274);
+}
+
+/**
+ * ChooseZipPath asks where to save the zip; "" if cancelled.
+ */
+export function ChooseZipPath(): $CancellablePromise<string> {
+    return $Call.ByID(2500682668);
+}
+
+/**
+ * ClearPad empties the pad in slot.
+ */
+export function ClearPad(slot: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(3159325479, slot);
+}
+
+/**
+ * ExportFolder writes the project into dir. If the project is already
+ * there and overwrite is false, nothing is written and Exists lists it.
+ */
+export function ExportFolder(dir: string, overwrite: boolean): $CancellablePromise<$models.ExportResult> {
+    return $Call.ByID(230458675, dir, overwrite);
+}
+
+/**
+ * ExportZip writes the project as a zip at path. If the file exists and
+ * overwrite is false, nothing is written and Exists lists it.
+ */
+export function ExportZip(path: string, overwrite: boolean): $CancellablePromise<$models.ExportResult> {
+    return $Call.ByID(3485442350, path, overwrite);
+}
+
 /**
  * New replaces the current project with a new, empty one.
  */
@@ -29,6 +81,27 @@ export function New(): $CancellablePromise<$models.State> {
     return $Call.ByID(869901757);
 }
 
+/**
+ * SetMuteGroup sets a pad's mute group; 0 is off.
+ */
+export function SetMuteGroup(slot: number, group: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(2111298513, slot, group);
+}
+
+/**
+ * SetName renames the project.
+ */
+export function SetName(name: string): $CancellablePromise<$models.State> {
+    return $Call.ByID(1595495796, name);
+}
+
+/**
+ * SetTune sets a pad's tuning in semitones and cents.
+ */
+export function SetTune(slot: number, coarse: number, fine: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(2579740609, slot, coarse, fine);
+}
+
 /**
  * State returns the current project's state.
  */

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

@@ -1,2 +1,19 @@
 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
 // This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import type { Events } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import type * as app$0 from "../../../../../git.monoexp.com/bajdev/mpckit/internal/app/models.js";
+
+declare module "@wailsio/runtime" {
+    namespace Events {
+        interface CustomEvents {
+            "error": string;
+            "state": app$0.State;
+        }
+    }
+}

+ 71 - 11
frontend/src/App.svelte

@@ -1,14 +1,20 @@
 <script lang="ts">
   import { onMount } from "svelte";
   import AboutModal from "./lib/AboutModal.svelte";
+  import { Service } from "./lib/api";
+  import { play } from "./lib/audio";
   import BankBar from "./lib/BankBar.svelte";
+  import ConfirmModal from "./lib/ConfirmModal.svelte";
   import PadPanel from "./lib/PadPanel.svelte";
   import SequencePanel from "./lib/SequencePanel.svelte";
   import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
-  import { app, newProject, refresh, ui, type Tab } from "./lib/store.svelte";
+  import { app, exportFolder, exportZip, listen, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
   import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
 
-  onMount(refresh);
+  onMount(() => {
+    refresh();
+    return listen();
+  });
 
   const st = $derived(app.state);
   // The bindings type Go slices as `T[] | null`; Go always sends arrays.
@@ -60,6 +66,27 @@
       : [],
   );
 
+  // Clicking a pad selects it and, if it has a sample, plays it.
+  async function pickPad(slot: number) {
+    ui.padSlot = slot;
+    const p = pads[slot];
+    if (p?.audio) {
+      try {
+        await play(p);
+      } catch (e) {
+        app.error = message(e);
+      }
+    }
+  }
+
+  async function rename(e: Event & { currentTarget: HTMLInputElement }) {
+    const input = e.currentTarget;
+    const name = input.value;
+    if (name === st?.name) return;
+    await run(() => Service.SetName(name));
+    input.value = app.state?.name ?? name; // shows the old name again if refused
+  }
+
   function pickTab(t: Tab) {
     ui.tab = t;
   }
@@ -78,19 +105,22 @@
     <h1>mpckit</h1>
     <div class="name">
       <label class="eyebrow" for="project-name">Project name</label>
-      <input id="project-name" type="text" value={st?.name ?? ""} readonly />
+      <input id="project-name" type="text" value={st?.name ?? ""} spellcheck="false" autocomplete="off" onchange={rename} />
     </div>
     <div class="actions">
       <button type="button" class="btn" onclick={newProject}>New</button>
       <button type="button" class="btn" disabled>Load</button>
-      <button type="button" class="btn" disabled>Export zip</button>
-      <button type="button" class="btn primary" disabled>Export to folder</button>
+      <button type="button" class="btn" onclick={exportZip}>Export zip</button>
+      <button type="button" class="btn primary" onclick={exportFolder}>Export to folder</button>
     </div>
     <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
   </header>
 
   {#if app.error}
-    <p class="error" role="alert">{app.error}</p>
+    <div class="error" role="alert">
+      <span>{app.error}</span>
+      <button type="button" class="dismiss" aria-label="Dismiss" onclick={() => (app.error = "")}>×</button>
+    </div>
   {/if}
 
   <div class="tabs" role="tablist" aria-label="Views">
@@ -115,8 +145,8 @@
               ui.padSlot = b * PER_BANK;
             }}
           />
-          <SlotGrid cells={padCells} selected={ui.padSlot} onpick={(s) => (ui.padSlot = s)} />
-          <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
+          <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget />
+          <p class="hint grid-hint">Click a pad to select and hear it. Drop a WAV file on any pad to assign it. (Dragging pads to move, swap and copy them comes in M6.)</p>
         </section>
         <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
       </div>
@@ -147,6 +177,8 @@
       <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.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>
   {:else if !app.error}
     <p class="hint">Loading…</p>
@@ -155,6 +187,9 @@
   {#if ui.aboutOpen}
     <AboutModal onclose={() => (ui.aboutOpen = false)} />
   {/if}
+  {#if ui.confirm}
+    <ConfirmModal c={ui.confirm} />
+  {/if}
 </div>
 
 <style>
@@ -259,10 +294,20 @@
   footer {
     flex: none;
     display: flex;
-    flex-wrap: wrap;
     gap: 8px 24px;
     font-size: 12px;
     color: var(--muted);
+    white-space: nowrap;
+    min-width: 0;
+  }
+  .dirty {
+    color: var(--accent);
+  }
+  .notice {
+    margin-left: auto;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
   /* Narrow window: drop the visible name label (the input keeps it). */
   @media (max-width: 1100px) {
@@ -282,10 +327,25 @@
   }
   .error {
     flex: none;
-    margin: 0;
-    padding: 10px 12px;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 6px 6px 6px 12px;
     border: 1px solid #e5484d;
     border-radius: 6px;
     color: #ffb4b4;
+    white-space: pre-line; /* missing-file lists come one per line */
+  }
+  .error span {
+    flex: 1;
+  }
+  .dismiss {
+    width: 32px;
+    height: 32px;
+    background: transparent;
+    color: inherit;
+    border: 0;
+    font-size: 20px;
+    cursor: pointer;
   }
 </style>

+ 82 - 0
frontend/src/lib/ConfirmModal.svelte

@@ -0,0 +1,82 @@
+<script lang="ts">
+  import type { Confirm } from "./store.svelte";
+
+  let { c }: { c: Confirm } = $props();
+
+  let cancelButton: HTMLButtonElement | undefined = $state();
+
+  $effect(() => {
+    cancelButton?.focus();
+  });
+
+  function keydown(e: KeyboardEvent) {
+    if (e.key === "Escape") c.resolve(false);
+  }
+</script>
+
+<svelte:window onkeydown={keydown} />
+
+<div class="backdrop">
+  <div class="dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
+    <h2 id="confirm-title">{c.title}</h2>
+    <p id="confirm-message">{c.message}</p>
+    {#if c.items.length}
+      <ul class="mono">
+        {#each c.items as item (item)}
+          <li>{item}</li>
+        {/each}
+      </ul>
+    {/if}
+    <div class="buttons">
+      <button type="button" class="btn secondary" bind:this={cancelButton} onclick={() => c.resolve(false)}>Cancel</button>
+      <button type="button" class="btn primary" onclick={() => c.resolve(true)}>{c.action}</button>
+    </div>
+  </div>
+</div>
+
+<style>
+  .backdrop {
+    position: fixed;
+    inset: 0;
+    padding: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(10, 11, 13, 0.78);
+  }
+  .dialog {
+    width: 100%;
+    max-width: 520px;
+    padding: 24px;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    background: var(--surface);
+    border: 1px solid var(--edge);
+    border-radius: 12px;
+  }
+  h2 {
+    margin: 0;
+    font-family: var(--display);
+    font-size: 20px;
+    font-weight: 700;
+  }
+  p {
+    margin: 0;
+    color: var(--soft);
+  }
+  ul {
+    margin: 0;
+    padding-left: 18px;
+    font-size: 12px;
+    color: var(--soft);
+    overflow-wrap: anywhere;
+    max-height: 160px;
+    overflow-y: auto;
+  }
+  .buttons {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+  }
+</style>

+ 66 - 14
frontend/src/lib/PadPanel.svelte

@@ -1,12 +1,34 @@
 <script lang="ts">
-  import type { Limits, Pad } from "./api";
+  import { Service, type Limits, type Pad } from "./api";
+  import { play } from "./audio";
+  import { app, message, run } from "./store.svelte";
   import { signed } from "./slots";
 
-  // Settings for the selected pad. Shows the pad's values; editing is wired
-  // up in M5, so the controls are disabled for now.
+  // Settings for the selected pad.
   let { pad, limits }: { pad: Pad; limits: Limits } = $props();
 
+  // Slider values follow the pad, and track the thumb while it is dragged;
+  // Go is told when it is let go.
+  let coarse = $derived(pad.coarse);
+  let fine = $derived(pad.fine);
+
   const groups = $derived(Array.from({ length: limits.maxMuteGroup }, (_, i) => i + 1));
+
+  async function preview(p: Pad) {
+    try {
+      await play(p);
+    } catch (e) {
+      app.error = message(e);
+    }
+  }
+
+  async function tune() {
+    const slot = pad.slot;
+    if (await run(() => Service.SetTune(slot, coarse, fine))) {
+      const p = app.state?.pads?.[slot];
+      if (p) preview(p); // hear the new tuning
+    }
+  }
 </script>
 
 <section class="panel settings" aria-label="Pad settings">
@@ -19,19 +41,19 @@
   </div>
 
   {#if pad.file === ""}
-    <div class="empty-box">
+    <div class="empty-box drop" data-file-drop-target data-slot={pad.slot}>
       <p>This pad is empty. Drop a WAV file on it, or choose one.</p>
-      <button type="button" class="btn primary" disabled>Choose WAV file</button>
+      <button type="button" class="btn primary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}>Choose WAV file</button>
     </div>
   {:else}
     <div class="stack">
       <div class="row">
-        <button type="button" class="btn primary" disabled>
+        <button type="button" class="btn primary" 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>
-        <button type="button" class="btn secondary" disabled>Replace</button>
-        <button type="button" class="btn secondary" disabled>Clear</button>
+        <button type="button" class="btn secondary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}>Replace</button>
+        <button type="button" class="btn secondary" onclick={() => run(() => Service.ClearPad(pad.slot))}>Clear</button>
       </div>
 
       <div class="control-group stack">
@@ -40,9 +62,18 @@
           <div class="control">
             <div class="top">
               <label class="label" for="tune-coarse">Semitones</label>
-              <span class="value">{signed(pad.coarse)} st</span>
+              <span class="value">{signed(coarse)} st</span>
             </div>
-            <input id="tune-coarse" type="range" min={limits.minCoarse} max={limits.maxCoarse} step="1" value={pad.coarse} disabled />
+            <input
+              id="tune-coarse"
+              type="range"
+              min={limits.minCoarse}
+              max={limits.maxCoarse}
+              step="1"
+              value={coarse}
+              oninput={(e) => (coarse = e.currentTarget.valueAsNumber)}
+              onchange={tune}
+            />
             <div class="scale" aria-hidden="true">
               <span>{limits.minCoarse}</span><span>0</span><span>+{limits.maxCoarse}</span>
             </div>
@@ -50,9 +81,18 @@
           <div class="control">
             <div class="top">
               <label class="label" for="tune-fine">Cents</label>
-              <span class="value">{signed(pad.fine)} ct</span>
+              <span class="value">{signed(fine)} ct</span>
             </div>
-            <input id="tune-fine" type="range" min={limits.minFine} max={limits.maxFine} step="1" value={pad.fine} disabled />
+            <input
+              id="tune-fine"
+              type="range"
+              min={limits.minFine}
+              max={limits.maxFine}
+              step="1"
+              value={fine}
+              oninput={(e) => (fine = e.currentTarget.valueAsNumber)}
+              onchange={tune}
+            />
             <div class="scale" aria-hidden="true">
               <span>{limits.minFine}</span><span>0</span><span>+{limits.maxFine}</span>
             </div>
@@ -62,7 +102,15 @@
 
       <div class="control">
         <label class="eyebrow" for="mute-group">Mute group</label>
-        <select id="mute-group" class="field" value={pad.muteGroup} disabled>
+        <select
+          id="mute-group"
+          class="field"
+          value={pad.muteGroup}
+          onchange={(e) => {
+            const group = Number(e.currentTarget.value);
+            run(() => Service.SetMuteGroup(pad.slot, group));
+          }}
+        >
           <option value={0}>Off</option>
           {#each groups as g (g)}
             <option value={g}>Group {g}</option>
@@ -77,7 +125,7 @@
       </div>
     </div>
   {/if}
-  <p class="pending">Pad editing is not connected yet (milestones M5 and M6).</p>
+  <p class="pending">Move and copy are not connected yet (milestone M6).</p>
 </section>
 
 <style>
@@ -90,4 +138,8 @@
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 16px;
   }
+  .drop:global(.file-drop-target-active) {
+    border-color: var(--accent);
+    background: rgba(245, 165, 36, 0.08);
+  }
 </style>

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

@@ -14,10 +14,12 @@
     cells,
     selected,
     onpick,
+    dropTarget = false,
   }: {
     cells: Cell[]; // 16 cells in grid order, top-left first
     selected: number;
     onpick: (slot: number) => void;
+    dropTarget?: boolean; // accept WAV files dropped from the system
   } = $props();
 </script>
 
@@ -29,6 +31,8 @@
       class:on={c.slot === selected}
       aria-pressed={c.slot === selected}
       aria-label={c.aria}
+      data-file-drop-target={dropTarget ? "" : undefined}
+      data-slot={c.slot}
       onclick={() => onpick(c.slot)}
     >
       <span class="num">{c.label}</span>
@@ -72,6 +76,11 @@
   button.on {
     border-color: var(--accent);
   }
+  /* Wails adds this class while a file is dragged over a drop target. */
+  button:global(.file-drop-target-active) {
+    border-color: var(--accent);
+    background: rgba(245, 165, 36, 0.12);
+  }
   .num {
     font-family: var(--mono);
     font-size: 11px;

+ 3 - 1
frontend/src/lib/api.ts

@@ -1,9 +1,11 @@
-// The Go service and its types, from the generated bindings.
+// The Go service, its types and its events, from the generated bindings.
 export { Service } from "../../bindings/git.monoexp.com/bajdev/mpckit/internal/app";
 export type {
   About,
+  ExportResult,
   Limits,
   Pad,
   Sequence,
   State,
 } from "../../bindings/git.monoexp.com/bajdev/mpckit/internal/app";
+export * as EventNames from "../../bindings/events";

+ 115 - 0
frontend/src/lib/audio.ts

@@ -0,0 +1,115 @@
+// Pad preview (build plan 4.5). Plays a pad's sample with its tuning and
+// slice region, and cuts off other pads in the same mute group. A preview
+// only: no effects.
+
+export type Playable = {
+  slot: number;
+  audio: string; // URL from Go; empty for an empty pad
+  coarse: number;
+  fine: number;
+  muteGroup: number;
+  start: number; // first frame
+  end: number; // last frame
+};
+
+type Voice = { slot: number; group: number; src: AudioBufferSourceNode; gain: GainNode };
+
+// Fade used when a pad is cut off, so the cut does not click. Our choice,
+// not measured from the device.
+const FADE = 0.008;
+
+let ctx: AudioContext | null = null;
+const buffers = new Map<string, Promise<AudioBuffer>>();
+const voices = new Map<number, Voice>(); // sounding pads, by slot
+
+function context(): AudioContext {
+  ctx ??= new AudioContext();
+  if (ctx.state === "suspended") void ctx.resume();
+  return ctx;
+}
+
+function load(url: string): Promise<AudioBuffer> {
+  let p = buffers.get(url);
+  if (!p) {
+    p = fetchBuffer(url);
+    buffers.set(url, p);
+    p.catch(() => buffers.delete(url)); // try again next time
+  }
+  return p;
+}
+
+async function fetchBuffer(url: string): Promise<AudioBuffer> {
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`Could not load the sample (${res.status}).`);
+  try {
+    return await context().decodeAudioData(await res.arrayBuffer());
+  } catch {
+    // The webview cannot decode this format: ask Go to decode it.
+    return fetchDecoded(url);
+  }
+}
+
+// Go's decoded format: sample rate, channels, frames (uint32 LE), then each
+// channel's frames as float32 LE.
+async function fetchDecoded(url: string): Promise<AudioBuffer> {
+  const res = await fetch(url + (url.includes("?") ? "&" : "?") + "pcm=1");
+  if (!res.ok) throw new Error((await res.text()) || `Could not decode the sample (${res.status}).`);
+  const bytes = await res.arrayBuffer();
+  const head = new DataView(bytes, 0, 12);
+  const rate = head.getUint32(0, true);
+  const channels = head.getUint32(4, true);
+  const frames = head.getUint32(8, true);
+  const buf = context().createBuffer(channels, frames, rate);
+  for (let c = 0; c < channels; c++) {
+    buf.copyToChannel(new Float32Array(bytes, 12 + c * frames * 4, frames), c);
+  }
+  return buf;
+}
+
+function stop(slot: number) {
+  const v = voices.get(slot);
+  if (!v || !ctx) return;
+  voices.delete(slot);
+  const t = ctx.currentTime;
+  v.gain.gain.setValueAtTime(v.gain.gain.value, t);
+  v.gain.gain.linearRampToValueAtTime(0, t + FADE);
+  v.src.stop(t + FADE);
+}
+
+/** Plays a pad. Throws if the sample cannot be loaded or decoded. */
+export async function play(p: Playable): Promise<void> {
+  if (!p.audio) return;
+  const buf = await load(p.audio);
+  const c = context();
+
+  stop(p.slot); // pads are monophonic: a new hit restarts the pad
+  if (p.muteGroup) {
+    for (const v of [...voices.values()]) {
+      if (v.group === p.muteGroup) stop(v.slot);
+    }
+  }
+
+  const gain = c.createGain();
+  gain.connect(c.destination);
+  const src = c.createBufferSource();
+  src.buffer = buf;
+  src.detune.value = p.coarse * 100 + p.fine; // pitch and speed together, as on the device
+  src.connect(gain);
+
+  const last = buf.length - 1;
+  const start = Math.min(Math.max(p.start, 0), last);
+  const end = p.end > start ? Math.min(p.end, last) : last;
+  src.start(0, start / buf.sampleRate, (end - start + 1) / buf.sampleRate);
+
+  const voice: Voice = { slot: p.slot, group: p.muteGroup, src, gain };
+  voices.set(p.slot, voice);
+  src.onended = () => {
+    if (voices.get(p.slot) === voice) voices.delete(p.slot);
+    gain.disconnect();
+  };
+}
+
+/** Stops everything, e.g. when the project changes. */
+export function stopAll() {
+  for (const slot of [...voices.keys()]) stop(slot);
+}

+ 98 - 7
frontend/src/lib/store.svelte.ts

@@ -1,15 +1,26 @@
 // Go is the single source of truth: every service call returns a fresh State
 // and the store replaces its copy. UI-only state (tab, bank, selection,
-// About) lives here too and never goes to Go (build plan 4.6).
-import { Service, type State } from "./api";
+// dialogs) lives here too and never goes to Go (build plan 4.6).
+import { Events } from "@wailsio/runtime";
+import { EventNames, Service, type ExportResult, type State } from "./api";
+import { stopAll } from "./audio";
 
 export type Tab = "pads" | "sequences";
 
 export const app = $state({
   state: null as State | null,
-  error: "",
+  error: "", // shown until dismissed or the next successful call
+  notice: "", // short success message, e.g. where an export went
 });
 
+export type Confirm = {
+  title: string;
+  message: string;
+  items: string[];
+  action: string; // label of the confirming button
+  resolve: (ok: boolean) => void;
+};
+
 export const ui = $state({
   tab: "pads" as Tab,
   padBank: 0,
@@ -17,23 +28,103 @@ export const ui = $state({
   seqBank: 0,
   seqSlot: 0,
   aboutOpen: false,
+  confirm: null as Confirm | null,
 });
 
-async function run(call: () => Promise<State>): Promise<void> {
+export function message(e: unknown): string {
+  return e instanceof Error ? e.message : String(e);
+}
+
+/**
+ * Runs a service call that returns State. Go returns the state even when
+ * the edit fails, but a rejected call loses it, so fetch it again then.
+ */
+export async function run(call: () => Promise<State>): Promise<boolean> {
   try {
     app.state = await call();
     app.error = "";
+    return true;
   } catch (e) {
-    app.error = e instanceof Error ? e.message : String(e);
+    app.error = message(e);
+    try {
+      app.state = await Service.State();
+    } catch {
+      // keep the old state; the error is already shown
+    }
+    return false;
   }
 }
 
 /** Fetches the current project from Go. */
-export function refresh(): Promise<void> {
+export function refresh(): Promise<boolean> {
   return run(() => Service.State());
 }
 
 /** Starts a new, empty project. */
-export function newProject(): Promise<void> {
+export function newProject(): Promise<boolean> {
+  stopAll();
+  app.notice = "";
   return run(() => Service.New());
 }
+
+/** Listens for state and errors Go sends on its own (e.g. after a file drop). */
+export function listen(): () => void {
+  const offState = Events.On(EventNames.State, (e) => {
+    app.state = e.data;
+    app.error = "";
+  });
+  const offError = Events.On(EventNames.Error, (e) => {
+    app.error = e.data;
+  });
+  return () => {
+    offState();
+    offError();
+  };
+}
+
+/** Shows the confirm dialog and resolves with the user's answer. */
+export function confirm(c: Omit<Confirm, "resolve">): Promise<boolean> {
+  return new Promise((resolve) => {
+    ui.confirm = {
+      ...c,
+      resolve: (ok) => {
+        ui.confirm = null;
+        resolve(ok);
+      },
+    };
+  });
+}
+
+async function exportWith(
+  choose: () => Promise<string>,
+  write: (target: string, overwrite: boolean) => Promise<ExportResult>,
+): Promise<void> {
+  try {
+    const target = await choose();
+    if (!target) return; // dialog cancelled
+    let res = await write(target, false);
+    if (res.exists?.length) {
+      const ok = await confirm({
+        title: "Replace existing files?",
+        message: "These already exist and will be replaced:",
+        items: res.exists,
+        action: "Replace",
+      });
+      if (!ok) return;
+      res = await write(target, true);
+    }
+    app.error = "";
+    app.notice = "Exported to " + res.path;
+    await refresh();
+  } catch (e) {
+    app.error = message(e);
+  }
+}
+
+export function exportFolder(): Promise<void> {
+  return exportWith(() => Service.ChooseExportFolder(), (dir, overwrite) => Service.ExportFolder(dir, overwrite));
+}
+
+export function exportZip(): Promise<void> {
+  return exportWith(() => Service.ChooseZipPath(), (path, overwrite) => Service.ExportZip(path, overwrite));
+}

+ 133 - 5
internal/app/app.go

@@ -4,9 +4,15 @@
 package app
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
 	"fmt"
+	"path/filepath"
+	"strings"
 	"sync"
 
+	"git.monoexp.com/bajdev/mpckit/internal/export"
 	"git.monoexp.com/bajdev/mpckit/internal/project"
 )
 
@@ -27,6 +33,13 @@ type Pad struct {
 	Fine      int    `json:"fine"`
 	MuteGroup int    `json:"muteGroup"`
 	HasNotes  bool   `json:"hasNotes"`
+	// Audio is the URL the preview loads the sample from; empty for an
+	// empty pad. It changes when the pad's source file changes.
+	Audio string `json:"audio"`
+	// Start and End are the slice's first and last frame. The preview
+	// plays only this region, as the device does.
+	Start int64 `json:"start"`
+	End   int64 `json:"end"`
 }
 
 // Sequence is one existing sequence.
@@ -72,16 +85,33 @@ type About struct {
 	Repo    string `json:"repo"`
 }
 
+// ExportResult is what an export call returns. If Exists is not empty,
+// nothing was written: the frontend asks the user, then calls again with
+// overwrite set.
+type ExportResult struct {
+	Path   string   `json:"path"`
+	Exists []string `json:"exists"`
+}
+
+// Dialogs are the native file dialogs. Each returns "" when the user
+// cancels. main.go implements them with Wails; tests use a fake.
+type Dialogs interface {
+	OpenWAV() (string, error)
+	ChooseFolder() (string, error)
+	SaveZip(defaultName string) (string, error)
+}
+
 // Service is registered with Wails; its exported methods become the
 // frontend bindings.
 type Service struct {
-	mu sync.Mutex
-	p  *project.Project
+	mu      sync.Mutex
+	p       *project.Project
+	dialogs Dialogs
 }
 
 // NewService returns a service holding a new, empty project.
-func NewService() *Service {
-	s := &Service{}
+func NewService(d Dialogs) *Service {
+	s := &Service{dialogs: d}
 	s.reset()
 	return s
 }
@@ -157,9 +187,10 @@ func (s *Service) state() State {
 			continue
 		}
 		pad := Pad{Slot: slot, Label: Label(slot), Name: v.SampleName, File: v.File,
-			Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup}
+			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])
 		}
 		st.Pads = append(st.Pads, pad)
 	}
@@ -170,3 +201,100 @@ func (s *Service) state() State {
 	st.SequenceCount = len(st.Sequences)
 	return st
 }
+
+// audioURL is the preview URL for a pad. The key in the query changes with
+// the source file, so the frontend's cache never plays a stale sample.
+func audioURL(slot int, src string) string {
+	sum := sha256.Sum256([]byte(src))
+	return fmt.Sprintf("%s%d?k=%s", AudioPrefix, slot, hex.EncodeToString(sum[:6]))
+}
+
+// edit runs fn on the project under the lock and returns the new state.
+// The state is returned even on error, so the frontend stays in step.
+func (s *Service) edit(fn func(p *project.Project) error) (State, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	err := fn(s.p)
+	return s.state(), err
+}
+
+// SetName renames the project.
+func (s *Service) SetName(name string) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.SetName(strings.TrimSpace(name)) })
+}
+
+// AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
+// Cancelling the dialog changes nothing.
+func (s *Service) AssignSampleDialog(slot int) (State, error) {
+	path, err := s.dialogs.OpenWAV()
+	if err != nil || path == "" {
+		return s.State(), err
+	}
+	return s.AssignSamplePath(slot, path)
+}
+
+// AssignSamplePath puts the WAV at path on the pad in slot. File drop uses it.
+func (s *Service) AssignSamplePath(slot int, path string) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.AssignSample(slot, path) })
+}
+
+// ClearPad empties the pad in slot.
+func (s *Service) ClearPad(slot int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.ClearPad(slot) })
+}
+
+// SetTune sets a pad's tuning in semitones and cents.
+func (s *Service) SetTune(slot, coarse, fine int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.SetTune(slot, coarse, fine) })
+}
+
+// SetMuteGroup sets a pad's mute group; 0 is off.
+func (s *Service) SetMuteGroup(slot, group int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.SetMuteGroup(slot, group) })
+}
+
+// ChooseExportFolder asks for the folder to export into; "" if cancelled.
+func (s *Service) ChooseExportFolder() (string, error) {
+	return s.dialogs.ChooseFolder()
+}
+
+// ChooseZipPath asks where to save the zip; "" if cancelled.
+func (s *Service) ChooseZipPath() (string, error) {
+	s.mu.Lock()
+	name := s.p.Name
+	s.mu.Unlock()
+	return s.dialogs.SaveZip(name + ".zip")
+}
+
+// ExportFolder writes the project into dir. If the project is already
+// there and overwrite is false, nothing is written and Exists lists it.
+func (s *Service) ExportFolder(dir string, overwrite bool) (ExportResult, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	path, err := export.Folder(s.p, dir, overwrite)
+	return s.exported(path, err)
+}
+
+// ExportZip writes the project as a zip at path. If the file exists and
+// overwrite is false, nothing is written and Exists lists it.
+func (s *Service) ExportZip(path string, overwrite bool) (ExportResult, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !strings.EqualFold(filepath.Ext(path), ".zip") {
+		path += ".zip"
+	}
+	err := export.Zip(s.p, path, overwrite)
+	return s.exported(path, err)
+}
+
+func (s *Service) exported(path string, err error) (ExportResult, error) {
+	var exists *export.ExistsError
+	if errors.As(err, &exists) {
+		return ExportResult{Exists: exists.Paths}, nil
+	}
+	if err != nil {
+		return ExportResult{}, err
+	}
+	s.p.Dirty = false
+	return ExportResult{Path: path}, nil
+}

+ 261 - 8
internal/app/app_test.go

@@ -1,13 +1,53 @@
 package app
 
 import (
+	"bytes"
+	"compress/gzip"
+	"encoding/binary"
 	"encoding/json"
+	"io"
+	"math"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
 )
 
+const testdata = "../../testdata"
+
+// fakeDialogs returns preset answers; "" is a cancelled dialog.
+type fakeDialogs struct {
+	wav, folder, zip string
+	zipDefault       string
+}
+
+func (f *fakeDialogs) OpenWAV() (string, error)      { return f.wav, nil }
+func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
+func (f *fakeDialogs) SaveZip(name string) (string, error) {
+	f.zipDefault = name
+	return f.zip, nil
+}
+
+func newService() (*Service, *fakeDialogs) {
+	d := &fakeDialogs{}
+	return NewService(d), d
+}
+
+func must(t *testing.T, err error) {
+	t.Helper()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+var kickDir = filepath.Join(testdata, "accepted", "MpckitT1"+project.DataSuffix)
+
 func TestNewState(t *testing.T) {
-	s := NewService()
+	s, _ := newService()
 	st := s.State()
 	if st.Name != DefaultName || st.Dirty || st.SampleCount != 0 {
 		t.Errorf("state %q dirty %v samples %d", st.Name, st.Dirty, st.SampleCount)
@@ -16,7 +56,7 @@ func TestNewState(t *testing.T) {
 		t.Fatalf("pads: %d", len(st.Pads))
 	}
 	for _, p := range st.Pads {
-		if p.File != "" || p.HasNotes {
+		if p.File != "" || p.HasNotes || p.Audio != "" {
 			t.Fatalf("pad %s not empty", p.Label)
 		}
 	}
@@ -32,11 +72,10 @@ func TestNewState(t *testing.T) {
 }
 
 func TestStateJSON(t *testing.T) {
-	b, err := json.Marshal(NewService().State())
-	if err != nil {
-		t.Fatal(err)
-	}
-	for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`} {
+	s, _ := newService()
+	b, err := json.Marshal(s.State())
+	must(t, err)
+	for _, key := range []string{`"name":"NewKit"`, `"pads":[{"slot":0,"label":"A01"`, `"sequences":[{"slot":0`, `"limits":{`, `"muteGroup":0`, `"audio":""`} {
 		if !strings.Contains(string(b), key) {
 			t.Errorf("JSON lacks %s", key)
 		}
@@ -44,8 +83,222 @@ func TestStateJSON(t *testing.T) {
 }
 
 func TestAbout(t *testing.T) {
-	a := NewService().About()
+	s, _ := newService()
+	a := s.About()
 	if a.Version != Version || !strings.Contains(a.License, "2026 bajdev") || !strings.HasPrefix(a.Repo, "https://") {
 		t.Errorf("%+v", a)
 	}
 }
+
+func TestPadEdits(t *testing.T) {
+	s, d := newService()
+	kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
+
+	// Cancelled dialog: nothing changes, no error.
+	st, err := s.AssignSampleDialog(0)
+	must(t, err)
+	if st.Pads[0].File != "" || st.Dirty {
+		t.Error("cancelled dialog changed the project")
+	}
+
+	d.wav = kick
+	st, err = s.AssignSampleDialog(0)
+	must(t, err)
+	p := st.Pads[0]
+	if p.File != "Linn 9000 kick.wav" || p.Name != "Linn 9000 kick" || p.End != 8212 || p.Start != 0 || !strings.HasPrefix(p.Audio, "/audio/0?k=") || !st.Dirty || st.SampleCount != 1 {
+		t.Errorf("after assign: %+v", p)
+	}
+
+	st, err = s.AssignSamplePath(16, kick)
+	must(t, err)
+	if st.Pads[16].File != "Linn 9000 kick.wav" || st.SampleCount != 1 {
+		t.Error("drop of the same file did not share the pool entry")
+	}
+	if st.Pads[16].Audio == st.Pads[0].Audio || !strings.HasSuffix(st.Pads[16].Audio, st.Pads[0].Audio[strings.Index(st.Pads[0].Audio, "?"):]) {
+		t.Errorf("audio URLs %q %q: want same key, different slot", st.Pads[0].Audio, st.Pads[16].Audio)
+	}
+
+	st, err = s.SetTune(16, 5, -20)
+	must(t, err)
+	if st.Pads[16].Coarse != 5 || st.Pads[16].Fine != -20 {
+		t.Error("tune")
+	}
+	st, err = s.SetMuteGroup(16, 3)
+	must(t, err)
+	if st.Pads[16].MuteGroup != 3 {
+		t.Error("mute group")
+	}
+	st, err = s.SetMuteGroup(16, 17)
+	if err == nil || st.Pads[16].MuteGroup != 3 {
+		t.Error("mute group 17 accepted, or state lost on error")
+	}
+
+	st, err = s.ClearPad(0)
+	must(t, err)
+	if st.Pads[0].File != "" || st.SampleCount != 1 {
+		t.Error("clear")
+	}
+
+	// Errors still return the current state.
+	st, err = s.AssignSamplePath(1, filepath.Join(testdata, "nope.wav"))
+	if err == nil || st.Pads[16].File == "" {
+		t.Errorf("bad path: %v", err)
+	}
+}
+
+func TestSetName(t *testing.T) {
+	s, _ := newService()
+	st, err := s.SetName("  My Kit ")
+	must(t, err)
+	if st.Name != "My Kit" || !st.Dirty {
+		t.Errorf("name %q", st.Name)
+	}
+	st, err = s.SetName("bad/name")
+	if err == nil || st.Name != "My Kit" {
+		t.Errorf("bad name: %v, name now %q", err, st.Name)
+	}
+}
+
+func gunzip(t *testing.T, path string) []byte {
+	t.Helper()
+	f, err := os.Open(path)
+	must(t, err)
+	defer f.Close()
+	zr, err := gzip.NewReader(f)
+	must(t, err)
+	b, err := io.ReadAll(zr)
+	must(t, err)
+	return b
+}
+
+// The M5 done-when in miniature: the UI's calls build the same file as the
+// prototype and the CLI (MpckitT1 without its sequences).
+func TestExport(t *testing.T) {
+	s, d := newService()
+	_, err := s.SetName("Kit")
+	must(t, err)
+	_, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
+	must(t, err)
+
+	d.folder = t.TempDir()
+	dir, err := s.ChooseExportFolder()
+	must(t, err)
+	res, err := s.ExportFolder(dir, false)
+	must(t, err)
+	if res.Path != filepath.Join(dir, "Kit.xpj") || len(res.Exists) != 0 {
+		t.Fatalf("export: %+v", res)
+	}
+	if s.State().Dirty {
+		t.Error("still dirty after export")
+	}
+	if _, err := os.Stat(filepath.Join(dir, "Kit"+project.DataSuffix, "Linn 9000 kick.wav")); err != nil {
+		t.Error(err)
+	}
+
+	// Second time: reported, not written, until overwrite.
+	res, err = s.ExportFolder(dir, false)
+	must(t, err)
+	if res.Path != "" || len(res.Exists) != 2 {
+		t.Errorf("re-export: %+v", res)
+	}
+	res, err = s.ExportFolder(dir, true)
+	must(t, err)
+	if res.Path == "" {
+		t.Error("overwrite export failed")
+	}
+
+	d.zip = filepath.Join(dir, "kit-export") // no extension: .zip is added
+	zpath, err := s.ChooseZipPath()
+	must(t, err)
+	if d.zipDefault != "Kit.zip" {
+		t.Errorf("zip dialog default %q", d.zipDefault)
+	}
+	res, err = s.ExportZip(zpath, false)
+	must(t, err)
+	if res.Path != filepath.Join(dir, "kit-export.zip") {
+		t.Errorf("zip: %+v", res)
+	}
+	if res, _ = s.ExportZip(zpath, false); len(res.Exists) != 1 {
+		t.Errorf("zip re-export: %+v", res)
+	}
+
+	// Missing folder: a real error.
+	if _, err := s.ExportFolder(filepath.Join(dir, "missing", "deeper"), false); err == nil {
+		t.Error("export into a missing folder succeeded")
+	}
+}
+
+func TestExportMatchesCLIRecipe(t *testing.T) {
+	// Same edits as MpckitT1's pads, through the service.
+	s, _ := newService()
+	_, err := s.SetName("MpckitT1")
+	must(t, err)
+	_, err = s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav"))
+	must(t, err)
+	_, err = s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav"))
+	must(t, err)
+	_, err = s.SetTune(16, 5, 0)
+	must(t, err)
+	dir := t.TempDir()
+	res, err := s.ExportFolder(dir, false)
+	must(t, err)
+
+	p, err := project.New("MpckitT1")
+	must(t, err)
+	must(t, p.AssignSample(0, filepath.Join(kickDir, "Linn 9000 kick.wav")))
+	must(t, p.AssignSample(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")))
+	must(t, p.SetTune(16, 5, 0))
+	want, err := p.File().Encode()
+	must(t, err)
+	if !bytes.Equal(gunzip(t, res.Path), want) {
+		t.Error("service export differs from the model's")
+	}
+}
+
+func TestAudio(t *testing.T) {
+	s, _ := newService()
+	kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
+	st, err := s.AssignSamplePath(3, kick)
+	must(t, err)
+
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("asset")) })
+	h := s.AudioMiddleware(next)
+	get := func(url string) *httptest.ResponseRecorder {
+		rec := httptest.NewRecorder()
+		h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
+		return rec
+	}
+
+	if rec := get("/index.html"); rec.Body.String() != "asset" {
+		t.Error("other paths are not passed on")
+	}
+	rec := get(st.Pads[3].Audio)
+	want, _ := os.ReadFile(kick)
+	if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), want) {
+		t.Errorf("wav: %d, %d bytes", rec.Code, rec.Body.Len())
+	}
+
+	rec = get("/audio/3?pcm=1")
+	b := rec.Body.Bytes()
+	if rec.Code != 200 || len(b) < 12 {
+		t.Fatalf("pcm: %d", rec.Code)
+	}
+	rate, ch, frames := binary.LittleEndian.Uint32(b), binary.LittleEndian.Uint32(b[4:]), binary.LittleEndian.Uint32(b[8:])
+	if rate != 44100 || ch != 1 || frames != 8213 || len(b) != 12+4*8213 {
+		t.Errorf("pcm header %d Hz, %d ch, %d frames, %d bytes", rate, ch, frames, len(b))
+	}
+	peak := float32(0)
+	for i := 12; i < len(b); i += 4 {
+		v := math.Float32frombits(binary.LittleEndian.Uint32(b[i:]))
+		peak = max(peak, v, -v)
+	}
+	if peak < 0.1 || peak > 1 {
+		t.Errorf("pcm peak %v", peak)
+	}
+
+	for _, url := range []string{"/audio/4", "/audio/x", "/audio/128", "/audio/-1"} {
+		if rec := get(url); rec.Code != 404 {
+			t.Errorf("%s: %d, want 404", url, rec.Code)
+		}
+	}
+}

+ 77 - 0
internal/app/audio.go

@@ -0,0 +1,77 @@
+package app
+
+import (
+	"encoding/binary"
+	"math"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+	"git.monoexp.com/bajdev/mpckit/internal/wav"
+)
+
+// AudioPrefix is the URL path the preview loads samples from:
+// /audio/<slot> serves the pad's source WAV as it is, and
+// /audio/<slot>?pcm=1 serves it decoded, for formats the webview cannot
+// decode (build plan 4.5 and section 9).
+const AudioPrefix = "/audio/"
+
+// AudioMiddleware serves AudioPrefix and passes every other request on.
+// It is for main.go's asset server, not the frontend.
+//
+//wails:ignore
+func (s *Service) AudioMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !strings.HasPrefix(r.URL.Path, AudioPrefix) {
+			next.ServeHTTP(w, r)
+			return
+		}
+		s.serveAudio(w, r)
+	})
+}
+
+func (s *Service) serveAudio(w http.ResponseWriter, r *http.Request) {
+	slot, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, AudioPrefix))
+	if err != nil || slot < 0 || slot >= project.Pads {
+		http.NotFound(w, r)
+		return
+	}
+	s.mu.Lock()
+	v, err := s.p.Pad(slot)
+	src := s.p.Sources[v.File]
+	s.mu.Unlock()
+	if err != nil || v.Empty() || src == "" {
+		http.NotFound(w, r)
+		return
+	}
+	w.Header().Set("Cache-Control", "no-store")
+
+	if r.URL.Query().Get("pcm") == "" {
+		w.Header().Set("Content-Type", "audio/wav")
+		http.ServeFile(w, r, src)
+		return
+	}
+
+	// Decoded: a 12-byte header (sample rate, channels, frames as uint32
+	// little-endian), then each channel's frames as float32 little-endian.
+	info, chans, err := wav.DecodeFile(src)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+		return
+	}
+	frames := len(chans[0])
+	buf := make([]byte, 12+4*frames*len(chans))
+	binary.LittleEndian.PutUint32(buf[0:], uint32(info.SampleRate))
+	binary.LittleEndian.PutUint32(buf[4:], uint32(len(chans)))
+	binary.LittleEndian.PutUint32(buf[8:], uint32(frames))
+	off := 12
+	for _, ch := range chans {
+		for _, v := range ch {
+			binary.LittleEndian.PutUint32(buf[off:], math.Float32bits(v))
+			off += 4
+		}
+	}
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Write(buf)
+}

+ 47 - 2
main.go

@@ -4,6 +4,9 @@ package main
 import (
 	"embed"
 	"log"
+	"path/filepath"
+	"strconv"
+	"strings"
 
 	"github.com/wailsapp/wails/v3/pkg/application"
 	"github.com/wailsapp/wails/v3/pkg/events"
@@ -23,21 +26,37 @@ const (
 	screenShare         = 0.9       // default size: this share of the screen's work area
 )
 
+// Events Go sends to the frontend.
+const (
+	eventState = "state" // a new app.State after a change the frontend did not ask for
+	eventError = "error" // a message to show
+)
+
+func init() {
+	application.RegisterEvent[app.State](eventState)
+	application.RegisterEvent[string](eventError)
+}
+
 func main() {
+	d := &dialogs{}
+	svc := app.NewService(d)
 	a := application.New(application.Options{
 		Name:        "mpckit",
 		Description: "Desktop kit editor for the MPC Sample",
 		Services: []application.Service{
-			application.NewService(app.NewService()),
+			application.NewService(svc),
 		},
 		Assets: application.AssetOptions{
-			Handler: application.AssetFileServerFS(assets),
+			Handler:    application.AssetFileServerFS(assets),
+			Middleware: svc.AudioMiddleware,
 		},
 		Mac: application.MacOptions{
 			ApplicationShouldTerminateAfterLastWindowClosed: true,
 		},
 	})
 
+	d.a = a
+
 	// Start hidden and size the window once the screens are known, so it
 	// fits the display instead of being clamped by it.
 	w := a.Window.NewWithOptions(application.WebviewWindowOptions{
@@ -47,6 +66,7 @@ func main() {
 		MinWidth:         minWidth,
 		MinHeight:        minHeight,
 		Hidden:           true,
+		EnableFileDrop:   true,
 		BackgroundColour: application.NewRGB(0x15, 0x17, 0x1A),
 		URL:              "/",
 	})
@@ -60,6 +80,31 @@ func main() {
 		w.Show()
 	})
 
+	// 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) {
+		ctx := e.Context()
+		target := ctx.DropTargetDetails()
+		if target == nil {
+			return
+		}
+		slot, err := strconv.Atoi(target.Attributes["data-slot"])
+		if err != nil {
+			return
+		}
+		for _, f := range ctx.DroppedFiles() {
+			if strings.EqualFold(filepath.Ext(f), ".wav") {
+				st, err := svc.AssignSamplePath(slot, f)
+				a.Event.Emit(eventState, st)
+				if err != nil {
+					a.Event.Emit(eventError, err.Error())
+				}
+				return
+			}
+		}
+		a.Event.Emit(eventError, "Only WAV files can be assigned to a pad.")
+	})
+
 	if err := a.Run(); err != nil {
 		log.Fatal(err)
 	}

+ 58 - 0
pull-requests/m5-pads-tab/m5-pads-tab.md

@@ -0,0 +1,58 @@
+# M5: Pads tab
+
+Branch: `m5-pads-tab` → `main`
+
+## What changed
+
+**Go service** (`internal/app`), new methods:
+- `SetName`, `AssignSampleDialog`, `AssignSamplePath` (used by file drop), `ClearPad`, `SetTune`, `SetMuteGroup`.
+- Export: `ChooseExportFolder` / `ExportFolder(dir, overwrite)` and `ChooseZipPath` / `ExportZip(path, overwrite)`.
+  - If the target exists, nothing is written and `exists` lists it; the frontend asks, then calls again with overwrite.
+  - A zip path without `.zip` gets it added.
+  - A successful export clears the unsaved-changes flag.
+- Every edit returns the new state, even when it fails. The frontend refetches the state when a call is rejected.
+- `internal/app` still imports nothing from Wails, so its tests run anywhere. The native dialogs are behind a small `Dialogs` interface; `dialogs.go` implements it with Wails.
+
+**Preview audio** (`internal/app/audio.go`, `frontend/src/lib/audio.ts`), build plan 4.5:
+- Go serves each pad's source WAV at `/audio/<slot>?k=…` through the asset server's middleware. The key changes with the source file, so the preview never plays a stale cached sample.
+- The frontend decodes with Web Audio. If the webview can't decode a format, it fetches `/audio/<slot>?pcm=1`, which Go decodes (the M3 decoder).
+- Tuning is applied as detune (coarse × 100 + fine cents), so pitch and speed change together as on the device. Only the slice region is played.
+- Pads are monophonic: a new click restarts the pad. Starting a pad cuts off every sounding pad in the same non-zero mute group with an 8 ms fade.
+
+**File drop** (`main.go`): the window accepts files. Pad cells and the empty-pad panel are drop targets carrying `data-slot`, and they highlight while a file is over them. Go assigns the first `.wav` dropped and sends the new state as a `state` event; problems come as an `error` event. Dropping a non-WAV file shows a message.
+
+**Frontend:**
+- Project name is editable (Enter or leaving the field saves; a refused name goes back).
+- Export zip and Export to folder work, with a confirm dialog before replacing files.
+- Clicking a pad selects it and plays it.
+- The pad panel is live: Choose WAV file, Play, Replace, Clear, tuning sliders (they play the pad when let go), mute group (Off, 1–16).
+- Errors show in a dismissable banner. The footer shows "Unsaved changes" and where the last export went.
+- Move/Copy stay disabled until M6. Load stays disabled until M8.
+
+**`tools/xpjdiff`**: `go run ./tools/xpjdiff A.xpj B.xpj` compares two projects (the .xpj values and the data-folder files) and prints the differing paths, or `identical`. It's for checking this milestone.
+
+## Testing
+
+On the server:
+- `go vet` and `go test ./...` pass (Linux with `-tags purego`, and `GOOS=windows go vet`).
+- The service tests cover the dialog-cancel path, assign/clear/tune/mute through the service, name validation, folder and zip export with the overwrite round-trip, an export through the service that matches the model byte for byte, and the audio route (WAV bytes, decoded PCM header and peak, 404s, other paths passed through).
+- `xpjdiff` is tested against T1/T2/T3.
+
+The server has no Node, so the frontend is untested here. **On the laptop**, from the repo root (create `C:\temp\m5` first). Use quotes as shown; in PowerShell an unquoted comma splits an argument.
+
+1. `npm run check` in `frontend`: 0 errors, 0 warnings.
+2. `wails3 dev`. In the app:
+   - Name the project `UiKit`.
+   - A01: **Choose WAV file** → `testdata\device\DemoFile_[ProjectData]\PadLp-London Cm114 SA.wav`.
+   - A02: **drag** `testdata\device\DemoFile_[ProjectData]\Bass-Pure C.wav` from Explorer onto the A02 cell.
+   - A02: Semitones −12, Cents +50.
+   - A01 and A02: mute group 1.
+   - **Export to folder** → `C:\temp\m5`.
+3. The same edits with the CLI:
+   ```
+   go run ./cmd/mpckit-cli -name CliKit -out C:\temp\m5 -pad "A1=testdata\device\DemoFile_[ProjectData]\PadLp-London Cm114 SA.wav" -pad "A2=testdata\device\DemoFile_[ProjectData]\Bass-Pure C.wav" -tune "A2=-12,50" -mute "A1=1" -mute "A2=1"
+   ```
+4. `go run ./tools/xpjdiff C:\temp\m5\UiKit.xpj C:\temp\m5\CliKit.xpj` prints `identical`. **(M5 done-when, part 1.)**
+5. Preview: click A01 (a long pad sound), then A02 while it plays: A01 stops. Set A02's group to Off and repeat: both play together. A02 sounds an octave and a half-semitone lower than unpitched. **(M5 done-when, part 2.)**
+6. Export again to the same folder: a dialog asks before replacing; Cancel writes nothing. Export zip saves `UiKit.zip`.
+7. Clear A02: the pad empties and the footer drops to 1 sample. Rename to `a/b`: an error shows and the name goes back.

+ 163 - 0
tools/xpjdiff/main.go

@@ -0,0 +1,163 @@
+// Command xpjdiff compares two MPC Sample projects: the decompressed .xpj
+// files and the sample files in their data folders. It prints the paths of
+// the values that differ, or "identical". Works the same in cmd,
+// PowerShell and sh:
+//
+//	go run ./tools/xpjdiff A.xpj B.xpj
+//
+// The project name is not part of the comparison (it lives only in the file
+// and folder names), so A and B may be named differently.
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"git.monoexp.com/bajdev/mpckit/internal/project"
+	"git.monoexp.com/bajdev/mpckit/internal/xpj"
+)
+
+const maxShown = 40
+
+func main() {
+	if len(os.Args) != 3 {
+		fmt.Fprintln(os.Stderr, "usage: xpjdiff A.xpj B.xpj")
+		os.Exit(2)
+	}
+	n, err := compare(os.Args[1], os.Args[2])
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "xpjdiff:", err)
+		os.Exit(2)
+	}
+	if n > 0 {
+		os.Exit(1)
+	}
+	fmt.Println("identical")
+}
+
+// compare prints the differences and returns how many there are.
+func compare(a, b string) (int, error) {
+	fa, err := xpj.ReadFile(a)
+	if err != nil {
+		return 0, fmt.Errorf("%s: %w", a, err)
+	}
+	fb, err := xpj.ReadFile(b)
+	if err != nil {
+		return 0, fmt.Errorf("%s: %w", b, err)
+	}
+	ea, err := fa.Encode()
+	if err != nil {
+		return 0, err
+	}
+	eb, err := fb.Encode()
+	if err != nil {
+		return 0, err
+	}
+
+	var diffs []string
+	if !bytes.Equal(ea, eb) {
+		if strings.Join(fa.Header, "\n") != strings.Join(fb.Header, "\n") {
+			diffs = append(diffs, fmt.Sprintf("header: %q vs %q", fa.Header, fb.Header))
+		}
+		la, lb := map[string]string{}, map[string]string{}
+		leaves(fa.Root, "", la)
+		leaves(fb.Root, "", lb)
+		for k, v := range la {
+			if w, ok := lb[k]; !ok {
+				diffs = append(diffs, fmt.Sprintf("%s: only in A (%s)", k, v))
+			} else if v != w {
+				diffs = append(diffs, fmt.Sprintf("%s: %s vs %s", k, v, w))
+			}
+		}
+		for k, v := range lb {
+			if _, ok := la[k]; !ok {
+				diffs = append(diffs, fmt.Sprintf("%s: only in B (%s)", k, v))
+			}
+		}
+		if len(diffs) == 0 {
+			diffs = append(diffs, "same values, different key order or layout")
+		}
+	}
+	diffs = append(diffs, compareData(a, b)...)
+
+	sort.Strings(diffs)
+	for i, d := range diffs {
+		if i == maxShown {
+			fmt.Printf("... and %d more\n", len(diffs)-maxShown)
+			break
+		}
+		fmt.Println(d)
+	}
+	return len(diffs), nil
+}
+
+// leaves flattens a tree into path -> literal value.
+func leaves(n *xpj.Node, path string, out map[string]string) {
+	switch n.Kind {
+	case xpj.Object:
+		for _, m := range n.Members {
+			leaves(m.Value, path+"."+m.Key, out)
+		}
+		if len(n.Members) == 0 {
+			out[path] = "{}"
+		}
+	case xpj.Array:
+		for i, it := range n.Items {
+			leaves(it, fmt.Sprintf("%s[%d]", path, i), out)
+		}
+		if len(n.Items) == 0 {
+			out[path] = "[]"
+		}
+	default:
+		b, _ := n.MarshalJSON()
+		out[path] = string(b)
+	}
+}
+
+func dataDir(xpjPath string) string {
+	return strings.TrimSuffix(xpjPath, filepath.Ext(xpjPath)) + project.DataSuffix
+}
+
+// compareData compares the files in the two data folders.
+func compareData(a, b string) []string {
+	da, db := dataDir(a), dataDir(b)
+	ra, errA := os.ReadDir(da)
+	rb, errB := os.ReadDir(db)
+	if errA != nil || errB != nil {
+		var out []string
+		if errA != nil {
+			out = append(out, "data folder A: "+errA.Error())
+		}
+		if errB != nil {
+			out = append(out, "data folder B: "+errB.Error())
+		}
+		return out
+	}
+	names := map[string]int{}
+	for _, e := range ra {
+		names[e.Name()] |= 1
+	}
+	for _, e := range rb {
+		names[e.Name()] |= 2
+	}
+	var out []string
+	for name, in := range names {
+		switch in {
+		case 1:
+			out = append(out, "data file only in A: "+name)
+		case 2:
+			out = append(out, "data file only in B: "+name)
+		default:
+			x, err1 := os.ReadFile(filepath.Join(da, name))
+			y, err2 := os.ReadFile(filepath.Join(db, name))
+			if err1 != nil || err2 != nil || !bytes.Equal(x, y) {
+				out = append(out, "data file differs: "+name)
+			}
+		}
+	}
+	return out
+}

+ 28 - 0
tools/xpjdiff/main_test.go

@@ -0,0 +1,28 @@
+package main
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+const testdata = "../../testdata"
+
+func TestCompare(t *testing.T) {
+	t1 := filepath.Join(testdata, "accepted", "MpckitT1.xpj")
+	t2 := filepath.Join(testdata, "accepted", "MpckitT2.xpj")
+	t3 := filepath.Join(testdata, "accepted", "MpckitT3.xpj")
+	if n, err := compare(t1, t1); err != nil || n != 0 {
+		t.Errorf("T1 vs T1: %d, %v", n, err)
+	}
+	// T2 has the same .xpj as T1 but stripped WAVs: two data files differ.
+	if n, err := compare(t1, t2); err != nil || n != 2 {
+		t.Errorf("T1 vs T2: %d differences, want 2 (%v)", n, err)
+	}
+	// T3 shares one pool entry: many differences.
+	if n, err := compare(t1, t3); err != nil || n < 3 {
+		t.Errorf("T1 vs T3: %d, %v", n, err)
+	}
+	if _, err := compare(t1, "missing.xpj"); err == nil {
+		t.Error("missing file accepted")
+	}
+}