Преглед на файлове

Merge branch 'm5-pads-tab' of bajdev/mpckit into main

bajdev преди 1 ден
родител
ревизия
248ee6bc9a

+ 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 11.5 semitones lower than untuned (just under an octave). **(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")
+	}
+}