Просмотр исходного кода

Merge branch 'm7-sequences-tab' of bajdev/mpckit into main

bajdev 1 день назад
Родитель
Сommit
f967b7cc35

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

@@ -22,6 +22,14 @@ export function About(): $CancellablePromise<$models.About> {
     return $Call.ByID(790948986);
 }
 
+/**
+ * AddSequence adds a sequence in an empty slot: "Sequence NN", 2 bars, at
+ * the BPM of the most recently edited sequence (else 120).
+ */
+export function AddSequence(slot: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(1757757973, slot);
+}
+
 /**
  * AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
  * Cancelling the dialog changes nothing.
@@ -111,6 +119,14 @@ export function New(): $CancellablePromise<$models.State> {
     return $Call.ByID(869901757);
 }
 
+/**
+ * RemoveSequence deletes a sequence and its recorded notes. The last
+ * sequence cannot be removed.
+ */
+export function RemoveSequence(slot: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(3423255788, slot);
+}
+
 /**
  * SetMuteGroup sets a pad's mute group; 0 is off.
  */
@@ -125,6 +141,14 @@ export function SetName(name: string): $CancellablePromise<$models.State> {
     return $Call.ByID(1595495796, name);
 }
 
+/**
+ * SetSequence sets a sequence's name, BPM and length in bars. Notes past a
+ * new, shorter end are left in the file.
+ */
+export function SetSequence(slot: number, name: string, bpm: number, bars: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(3880070126, slot, name, bpm, bars);
+}
+
 /**
  * SetTune sets a pad's tuning in semitones and cents.
  */

+ 1 - 1
frontend/src/App.svelte

@@ -61,7 +61,7 @@
             slot,
             label,
             name: q?.name ?? "",
-            meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
+            meta: q ? `${q.bpm} · ${bars(q.bars)}${q.hasNotes ? " · notes" : ""}` : "",
             aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
           };
         })

+ 73 - 10
frontend/src/lib/SequencePanel.svelte

@@ -1,12 +1,70 @@
 <script lang="ts">
-  import type { Limits, Sequence } from "./api";
+  import { Service, type Limits, type Sequence } from "./api";
+  import { app, confirm, run } from "./store.svelte";
   import { slotLabel } from "./slots";
 
-  // Settings for the selected sequence slot. Shows the values; editing is
-  // wired up in M7, so the controls are disabled for now.
+  // Settings for the selected sequence slot: add, name, BPM, length, remove.
   let { slot, seq, limits }: { slot: number; seq: Sequence | undefined; limits: Limits } = $props();
 
   const seqNo = $derived(String(slot + 1).padStart(2, "0"));
+
+  // Field values follow the sequence and are sent to Go when a field is
+  // left or Enter is pressed. A refused value comes back from Go as the old
+  // one, which resets the field.
+  let name = $derived(seq?.name ?? "");
+  let bpm = $derived(seq?.bpm ?? 120);
+  let bars = $derived(seq?.bars ?? 2);
+
+  async function save() {
+    if (!seq) return;
+    const s = seq.slot;
+    const n = name;
+    const b = Number(bpm);
+    const l = Number(bars);
+    if (n === seq.name && b === seq.bpm && l === seq.bars) return;
+    if (seq.hasNotes && l < seq.bars) {
+      const ok = await confirm({
+        title: "Shorten a sequence with notes?",
+        message: `${seq.name} has recorded notes. Notes after bar ${l} stay in the file; what the device does with them is not yet known.`,
+        items: [],
+        action: "Shorten",
+      });
+      if (!ok) {
+        bars = seq.bars;
+        return;
+      }
+    }
+    await run(() => Service.SetSequence(s, n, b, l));
+    // run() replaced the state; if Go refused, the fields still hold the
+    // typed values, so put the stored ones back.
+    const now = app.state?.sequences?.find((q) => q.slot === s);
+    if (now) {
+      name = now.name;
+      bpm = now.bpm;
+      bars = now.bars;
+    }
+  }
+
+  async function remove() {
+    if (!seq) return;
+    const s = seq.slot;
+    const ok = await confirm({
+      title: `Remove ${seq.name}?`,
+      message: seq.hasNotes ? "Its recorded notes will be deleted too." : "The sequence has no recorded notes.",
+      items: [],
+      action: "Remove",
+    });
+    if (ok) await run(() => Service.RemoveSequence(s));
+  }
+
+  function enter(e: KeyboardEvent) {
+    if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); // blur fires change
+  }
+
+  function add() {
+    const s = slot;
+    return run(() => Service.AddSequence(s));
+  }
 </script>
 
 <section class="panel settings" aria-label="Sequence settings">
@@ -21,31 +79,33 @@
   {#if !seq}
     <div class="empty-box">
       <p>No sequence in this slot.</p>
-      <button type="button" class="btn primary" disabled>Add sequence</button>
+      <button type="button" class="btn primary" onclick={add}>Add sequence</button>
     </div>
   {:else}
     <div class="stack">
       <div class="control">
         <label class="label" for="seq-name">Name</label>
-        <input id="seq-name" class="field" type="text" value={seq.name} disabled />
+        <input id="seq-name" class="field" type="text" spellcheck="false" autocomplete="off" value={name} oninput={(e) => (name = e.currentTarget.value)} onchange={save} onkeydown={enter} />
       </div>
       <div class="pair">
         <div class="control">
-          <label class="label" for="seq-bpm">BPM</label>
-          <input id="seq-bpm" class="field mono" type="number" min={limits.minBPM} max={limits.maxBPM} step="1" value={seq.bpm} disabled />
+          <label class="label" for="seq-bpm">BPM ({limits.minBPM}–{limits.maxBPM})</label>
+          <input id="seq-bpm" class="field mono" type="number" min={limits.minBPM} max={limits.maxBPM} step="any" value={bpm} oninput={(e) => (bpm = e.currentTarget.valueAsNumber)} onchange={save} onkeydown={enter} />
         </div>
         <div class="control">
           <label class="label" for="seq-bars">Length (bars)</label>
-          <input id="seq-bars" class="field mono" type="number" min="1" max={limits.maxBars} step="1" value={seq.bars} disabled />
+          <input id="seq-bars" class="field mono" type="number" min="1" max={limits.maxBars} step="1" value={bars} oninput={(e) => (bars = e.currentTarget.valueAsNumber)} onchange={save} onkeydown={enter} />
         </div>
       </div>
       <span class="hint">Notes are recorded on the device. This screen sets name, tempo and length only.</span>
+      {#if seq.hasNotes}
+        <span class="hint notes">This sequence has recorded notes.</span>
+      {/if}
       <div class="row divided">
-        <button type="button" class="btn secondary" disabled>Remove sequence</button>
+        <button type="button" class="btn secondary" onclick={remove}>Remove sequence</button>
       </div>
     </div>
   {/if}
-  <p class="pending">Sequence editing is not connected yet (milestone M7).</p>
 </section>
 
 <style>
@@ -54,4 +114,7 @@
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 10px;
   }
+  .notes {
+    color: var(--accent);
+  }
 </style>

+ 20 - 0
internal/app/app.go

@@ -348,3 +348,23 @@ func (s *Service) LoadPath(path string) (LoadResult, error) {
 	}
 	return LoadResult{Path: path, State: s.state(), Warnings: warnings}, nil
 }
+
+// AddSequence adds a sequence in an empty slot: "Sequence NN", 2 bars, at
+// the BPM of the most recently edited sequence (else 120).
+func (s *Service) AddSequence(slot int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.AddSequence(slot) })
+}
+
+// RemoveSequence deletes a sequence and its recorded notes. The last
+// sequence cannot be removed.
+func (s *Service) RemoveSequence(slot int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.RemoveSequence(slot) })
+}
+
+// SetSequence sets a sequence's name, BPM and length in bars. Notes past a
+// new, shorter end are left in the file.
+func (s *Service) SetSequence(slot int, name string, bpm float64, bars int) (State, error) {
+	return s.edit(func(p *project.Project) error {
+		return p.SetSequence(slot, strings.TrimSpace(name), bpm, bars)
+	})
+}

+ 60 - 0
internal/app/app_test.go

@@ -374,3 +374,63 @@ func TestLoad(t *testing.T) {
 		t.Errorf("bad load: %v", err)
 	}
 }
+
+func TestSequences(t *testing.T) {
+	s, _ := newService()
+	st, err := s.AddSequence(20)
+	must(t, err)
+	if st.SequenceCount != 2 || st.Sequences[1] != (Sequence{Slot: 20, Label: "B05", Name: "Sequence 21", BPM: 120, Bars: 2}) {
+		t.Errorf("add: %+v", st.Sequences)
+	}
+	st, err = s.SetSequence(20, " Drop ", 140.5, 16)
+	must(t, err)
+	if q := st.Sequences[1]; q.Name != "Drop" || q.BPM != 140.5 || q.Bars != 16 {
+		t.Errorf("set: %+v", q)
+	}
+	// Refused edits keep the old values and still return the state.
+	for _, bad := range []struct {
+		name string
+		bpm  float64
+		bars int
+	}{{"", 120, 2}, {"x", 19, 2}, {"x", 1000, 2}, {"x", 120, 0}} {
+		st, err = s.SetSequence(20, bad.name, bad.bpm, bad.bars)
+		if err == nil || st.Sequences[1].Name != "Drop" {
+			t.Errorf("SetSequence(%q, %v, %d): %v", bad.name, bad.bpm, bad.bars, err)
+		}
+	}
+	if _, err := s.AddSequence(20); err == nil {
+		t.Error("added twice")
+	}
+	st, err = s.RemoveSequence(0)
+	must(t, err)
+	if st.SequenceCount != 1 || st.Sequences[0].Slot != 20 {
+		t.Errorf("remove: %+v", st.Sequences)
+	}
+	if _, err := s.RemoveSequence(20); err == nil {
+		t.Error("removed the last sequence")
+	}
+}
+
+// The M7 done-when through the service: MpckitT1, the M2 golden file, made
+// with the calls the UI makes.
+func TestGoldenT1ThroughService(t *testing.T) {
+	s, _ := newService()
+	calls := []func() (State, error){
+		func() (State, error) { return s.SetName("MpckitT1") },
+		func() (State, error) { return s.AssignSamplePath(0, filepath.Join(kickDir, "Linn 9000 kick.wav")) },
+		func() (State, error) { return s.AssignSamplePath(16, filepath.Join(kickDir, "Linn 9000 kick001.wav")) },
+		func() (State, error) { return s.SetTune(16, 5, 0) },
+		func() (State, error) { return s.SetSequence(0, "Verse", 96, 8) },
+		func() (State, error) { return s.AddSequence(1) },
+		func() (State, error) { return s.SetSequence(1, "Chorus", 96, 4) },
+	}
+	for _, c := range calls {
+		_, err := c()
+		must(t, err)
+	}
+	res, err := s.ExportFolder(t.TempDir(), false)
+	must(t, err)
+	if !bytes.Equal(gunzip(t, res.Path), gunzip(t, filepath.Join(testdata, "accepted", "MpckitT1.xpj"))) {
+		t.Error("differs from testdata/accepted/MpckitT1.xpj")
+	}
+}

+ 37 - 0
pull-requests/m7-sequences-tab/m7-sequences-tab.md

@@ -0,0 +1,37 @@
+# M7: Sequences tab
+
+Branch: `m7-sequences-tab` → `main`
+
+## What changed
+
+**Sequences can be added, edited and removed.**
+- Click an empty slot, then **Add sequence**: it is named "Sequence NN" (the slot number), 2 bars long, at the BPM of the sequence edited last (otherwise 120).
+- **Name, BPM and length** are saved when you leave a field or press Enter. Go checks them: a name that isn't allowed in file names is refused, BPM must be 20–999, length 1–999 bars. A refused value shows an error and the field goes back to the stored value.
+- BPM accepts decimals (the device stores BPM as a decimal number).
+- **Remove sequence** asks first, saying whether recorded notes will be deleted. The last sequence can't be removed (Go refuses and the banner says so).
+- **Shortening a sequence that has recorded notes** asks first. The notes past the new end are left in the file, because what the device does with them isn't known yet (checkpoint B5).
+- Sequences with notes show `notes` in the grid and a line in the panel.
+
+**Go service**: `AddSequence`, `RemoveSequence`, `SetSequence`.
+
+## Testing
+
+On the server: `go vet` and `go test ./...` pass.
+- **M7 done-when, through the service:** the calls the UI makes (name, two pads, tuning, set sequence 1, add sequence 2, set it) export a file byte-identical to `testdata/accepted/MpckitT1.xpj`, the M2 golden output.
+- Also tested: add, edit, refused edits (empty name, BPM 19 and 1000, 0 bars) keeping the old values, adding twice, removing, and refusing to remove the last sequence.
+
+The frontend is untested here (no Node). **On the laptop** (create `C:\temp\m7` first):
+
+1. `npm run check` in `frontend`: 0 errors, 0 warnings.
+2. **Done-when, in the app.** New project, named `MpckitT1`.
+   - Pads: A01 = `testdata\accepted\MpckitT1_[ProjectData]\Linn 9000 kick.wav`; B01 = `…\Linn 9000 kick001.wav`, Semitones +5.
+   - Sequences tab: A01: name `Verse`, BPM `96`, length `8`.
+   - Select slot A02 → **Add sequence**: it comes up at 96 BPM. Name `Chorus`, length `4`.
+   - **Export to folder** `C:\temp\m7`, then:
+   ```
+   go run ./tools/xpjdiff C:\temp\m7\MpckitT1.xpj testdata\accepted\MpckitT1.xpj
+   ```
+   prints `identical`.
+3. **Refused values:** BPM `19`, length `0`, or an empty name each show an error, and the field goes back.
+4. **Remove:** remove Chorus (asks, then it's gone); removing Verse then shows "cannot remove the only sequence".
+5. **Notes:** Load `testdata\device\DemoFile.xpj`. Both sequences show `notes`. Shortening "Sequence 02" from 4 to 2 bars asks first; Cancel puts 4 back. Remove says its notes will be deleted.