JP Patches — Phase 2 & 3 Design Spec

Status:


0. Project context

JP Patches is a free, open-source macOS Electron desktop app for managing patches on the Roland JX-3P synthesizer. Author: Daniel Spils (GitHub: danielspils). Target users: the ~1,000 JX-3P owners worldwide. Distribution: GitHub Releases as .dmg. Mac-only for v1.

Phase 1 (librarian core, locked panel UI, knob/switch/button interaction, WAV import via Bruce Oberg’s jx3p Python toolkit) is done.

0.1 Stack & repo

0.2 What already works (Phase 1)

The 32 patch fields (mirrors jx3p/patch.py upstream):

dco1_range:       enum ["16'", "8'", "4'"]
dco1_waveform:    enum ["saw", "pulse", "square"]
dco1_fmod_lfo:    bool
dco1_fmod_env:    bool
dco2_range:       enum ["16'", "8'", "4'"]
dco2_waveform:    enum ["saw", "pulse", "square", "noise"]   ⚠ 'noise' has no MIDI CC value
dco2_crossmod:    enum ["off", "sync", "metal"]
dco2_tune:        uint8 (0-255)
dco2_fine_tune:   uint8
dco2_fmod_lfo:    bool
dco2_fmod_env:    bool
dco_lfo_amount:   uint8
dco_env_amount:   uint8
dco_env_polarity: enum {neg, pos}
vcf_mix:          uint8
vcf_hpf:          uint8
vcf_cutoff:       uint8
vcf_lfo_mod:      uint8
vcf_pitch_follow: uint8
vcf_resonance:    uint8
vcf_env_mod:      uint8
vcf_env_polarity: enum {neg, pos}
vca_mode:         enum {gate, env}
vca_level:        uint8
chorus:           bool
lfo_waveform:     enum ["sine", "square", "random", "fast random"]
lfo_delay:        uint8
lfo_rate:         uint8
env_attack:       uint8
env_decay:        uint8
env_sustain:      uint8
env_release:      uint8
mystery:          uint8 0-15  (preserve on disk; never sent over MIDI)

Phase 2 — Library tab

2.0 As-shipped summary

What actually landed differs from the original §2.1–§2.8 design in several ways. Code is the source of truth (renderer/app.js); §2.1 onwards is preserved as the design-intent record.

Same as designed:

Different from designed:

Adjacent additions (outside §2 but landed in the same pass):

2.1 Concept

2.2 File structure

~/Library/Application Support/jp-patches/
├── library.json                 # app prefs + active bank slot names (existing)
└── collections/
    ├── 2026-05-13.json
    ├── Daniel_Sounds.json
    └── Jessica_Juicy_JX-3P_Patches.json

Each collection JSON:

{
  "name": "Daniel Sounds",
  "created":  "2026-05-13T17:24:00Z",
  "modified": "2026-05-13T18:02:11Z",
  "banks": {
    "C": [ {...patch1}, {...patch2}, ... ],   // 16 patches
    "D": [ {...patch1}, ... ]                  // 16 patches
  }
}

Filename rules:

2.3 Save workflow

2.4 Load workflow

2.5 Rename

2.6 Delete

2.7 Empty state

Tab shows:

Save your current C and D banks to start a library. Use the ‘Save C/D banks to Library’ button at the bottom of the left panel.

2.8 Open question


Name conflict conventions

C/D patch names

Allow duplicates. Patch identity is the slot (C1, D13, etc.), not the name. Hardware-synth convention. No suffix, no warning.

Library collection names

On user rename to an existing name → prompt user to choose a different name. Don’t auto-suffix, don’t offer to overwrite. Modal:

A collection named ‘[name]’ already exists. Choose a different name.

[text input] [Cancel] [Save]

Library file collisions (date-stamped defaults)

On save with default name YYYY-MM-DD when a file with that name already exists → auto-suffix the filename: YYYY-MM-DD_2.json, _3.json, etc. The display name stays "2026-05-13"; the suffix is filename-only and invisible to the user.

Loading a library collection

Load is total replace: all 32 patch parameter sets AND all 32 patch names get overwritten with the collection’s contents. No per-patch collisions to resolve. The existing “save current first” modal in §2.4 covers the only safety case.


Phase 3 — MIDI integration

Deferred to v2 — requires Series Circuits MIDI Upgrade Kit installed on the JX-3P.

Triggered by Daniel installing the Series Circuits JX-3P MIDI Upgrade Kit, which adds CC-based parameter control to the JX-3P.

3.1 Series Circuits MIDI kit — key facts

3.2 CC mapping table — paste into cc-map.js

// Param → CC# (32 entries)
const PARAM_TO_CC = {
  // Continuous, CC 12–29
  dco2_fine_tune:   12,
  dco2_tune:        13,
  dco_env_amount:   14,
  dco_lfo_amount:   15,
  vcf_mix:          16,
  vcf_hpf:          17,
  vcf_resonance:    18,
  vcf_cutoff:       19,
  vcf_env_mod:      20,
  vcf_lfo_mod:      21,
  vcf_pitch_follow: 22,
  vca_level:        23,
  lfo_rate:         24,
  lfo_delay:        25,
  env_attack:       26,
  env_decay:        27,
  env_sustain:      28,
  env_release:      29,
  // Switches, CC 72–85
  dco1_range:       72,
  dco1_waveform:    73,
  dco2_range:       74,
  dco2_waveform:    75,
  dco2_crossmod:    76,
  vcf_env_polarity: 77,
  vca_mode:         78,
  dco2_fmod_env:    79,
  dco2_fmod_lfo:    80,
  dco1_fmod_env:    81,
  dco1_fmod_lfo:    82,
  lfo_waveform:     83,
  dco_env_polarity: 84,
  chorus:           85,
};

// Discrete value → CC value
const ENUM_TO_CC = {
  dco1_range:       { "16'": 0, "8'": 32, "4'": 64 },
  dco1_waveform:    { saw: 0, pulse: 32, square: 64 },
  dco2_range:       { "16'": 0, "8'": 32, "4'": 64 },
  dco2_waveform:    { saw: 0, pulse: 32, square: 64 },           // noise: skip
  dco2_crossmod:    { off: 0, sync: 32, metal: 64 },
  vcf_env_polarity: { neg: 0, pos: 64 },     // Inverted / Normal
  vca_mode:         { gate: 0, env: 64 },
  lfo_waveform:     { sine: 0, square: 32, random: 64, "fast random": 96 },
  dco_env_polarity: { neg: 0, pos: 64 },
};

// Booleans (fmod_*, chorus): false → 0, true → 64
// Continuous: cc = value >> 1   (outbound)
//             value = cc << 1   (inbound, loses bottom bit)

3.3 Architecture — main process owns MIDI

Rationale: Already using IPC for loadPatches, loadLibrary, loadPanelSvg, tapeSave, tapeLoad. MIDI fits the same pattern. Node MIDI bindings on macOS use CoreMIDI directly (battle-tested). Web MIDI in the renderer is doable but more brittle.

Library: easymidi (npm). It wraps @julusian/midi native bindings. Friendly CC/Note/Sysex/PC API. Active maintenance.

New files:

~/JP-Patches-App/
├── midi-host.js     NEW — main-process MIDI driver
├── cc-map.js        NEW — the table above (shared by main & renderer)
├── main.js          MODIFY — add midi-* IPC handlers
├── preload.js       MODIFY — expose window.api.midi*
└── renderer/
    ├── app.js       MODIFY — outbound hooks + inbound listener + settings UI logic
    └── index.html   MODIFY — settings UI shell

IPC surface to add to preload.js:

midiListPorts:   ()                            => ipcRenderer.invoke('midi-list-ports'),
midiOpen:        (inputName, outputName, ch)   => ipcRenderer.invoke('midi-open', inputName, outputName, ch),
midiClose:       ()                            => ipcRenderer.invoke('midi-close'),
midiSend:        (param, value)                => ipcRenderer.invoke('midi-send', param, value),
midiSendPC:      (program)                     => ipcRenderer.invoke('midi-send-pc', program),
onMidiCC:        (handler)                     => ipcRenderer.on('midi-cc', (_e, p, v) => handler(p, v)),
onMidiStatus:    (handler)                     => ipcRenderer.on('midi-status', (_e, s) => handler(s)),

3.4 Data flow

Outbound (UI change → synth):

applyDragAngle / mouseup / handleSwitchClick / snap-knob click
   ↓ window.api.midiSend(param, value)
   ↓ IPC
main: midi-host.sendCC(PARAM_TO_CC[param], encodeValue(param, value))
   ↓ easymidi.Output.send('cc', { controller, value, channel })
   ↓ CoreMIDI → Series Circuits kit → JX-3P

Three hook points in app.js:

  1. applyDragAngle — emit during drag, throttled to 30 Hz per param (lastEmitTime map).
  2. Smooth-knob mouseup — always emit final value (bypass throttle).
  3. handleSwitchClick + snap-knob mousedown — emit once per click.

Inbound (synth/PG-200 → UI):

Physical PG-200 knob moved (or external MIDI source)
   ↓ CC on the configured input port
main: easymidi.Input on('cc') → webContents.send('midi-cc', param, value)
   ↓ IPC
preload: ipcRenderer.on('midi-cc', handler)
   ↓
renderer: receiveCC(ccNum, ccValue)
   • lookup param via CC_TO_PARAM
   • decode value (cc<<1 for continuous, reverse ENUM_TO_CC for enums)
   • set patch[param] = decoded
   • call updateAllControls(patch) with suppressOutbound = true

3.5 Edge cases (must handle)

  1. Echo loop suppression. Inbound CC → UI update → outbound CC → synth → inbound CC → ∞ loop. Fix: a suppressOutbound boolean checked by the drag/click hooks before they call midiSend. Set true at the start of receiveCC, false at the end.

  2. Drag throttling. Without it, a fast drag floods the synth. Fix: per-param lastEmitMs map; only emit if now - last > 33. The mouseup always emits the final value, ignoring the throttle.

  3. Mid-drag inbound conflict. If the user is dragging a smooth knob and the same param’s CC arrives, the drag should win. Fix: in receiveCC, if dragState?.param === incomingParam, ignore the incoming CC.

  4. DCO-2 noise unreachable over MIDI.
    • Outbound: if dco2_waveform === "noise", don’t emit. Optionally show a small “MIDI out of sync” indicator near the noise marker on the panel.
    • Inbound: never possible. WAV import remains the only way dco2_waveform = "noise" enters the app.
  5. Device disconnect. Listen on the input’s 'close' event (or poll); push a midi-status event to the renderer; show a red dot in the settings panel.

  6. No device selected. All midiSend calls silently no-op. App keeps working in offline mode.

  7. Program Change behavior. Bidirectional, opt-in:
    • When user clicks a patch in the left list, optionally send PC = bank*16 + slot.
    • When the JX-3P fires a PC (e.g. user presses a patch button physically), optionally receive and select the matching slot in the left list.
    • Each direction toggled by a checkbox in the MIDI settings.

3.6 MIDI settings UI

Location: TBD. Library tab is reserved for collections (Phase 2) — NOT available for MIDI settings.

Candidate placements:

Decide before Phase 3 implementation.

Settings content (wherever it lives):

MIDI
   Input:    [Series Circuits ▾]
   Output:   [Series Circuits ▾]
   Channel:  [1 ▾]
   Status:   ● Connected
   [Rescan]

Sync
   ☐ Send Program Change on patch select
   ☐ Follow synth Program Change

Persist to library.json:

{
  "version": "1.0",
  "names": { ... },
  "midi": {
    "input":  "Series Circuits JX-3P",
    "output": "Series Circuits JX-3P",
    "channel": 1,
    "sendPC": false,
    "followPC": false
  }
}

3.7 Bottom-row button rewiring (post-MIDI)

Once MIDI is online, the four hardware buttons take on real meaning:

Button Action
Save Unchanged. Writes library + params to a JSON file via file-dialog. Offline backup. (Or repurposed for Library — see Phase 2 open question.)
Load Unchanged. Reads WAV (via jx3p) or JSON file via file-dialog.
Write NEW: broadcasts all 32 CCs for the currently-selected patch to the synth. JX-3P now has it in its edit buffer; user presses the JX-3P’s onboard save to commit.
Manual NEW: arms a one-shot inbound listener for ~5 seconds. Pressing Manual on the physical PG-200 during that window dumps all 32 CCs; we capture them into patches.banks[selBank][selSlot]. Renderer shows a “Listening…” indicator on the button.

3.8 Sub-phasing

Phase 3.0 — pipe-clean smoke test (~half day)

Phase 3.1 — full bidirectional (~1 day)

Phase 3.2 — Write / Manual buttons

Phase 3.3 — polish


Phase 4 — Distribution (v3)

Deferred to v3 — after MIDI integration is stable.

Goal

Package the app as a downloadable .dmg that JX-3P owners can install without cloning a repo, running npm, or using a terminal.

Approach

Use electron-builder (npm package, industry standard). Adds a build script that produces:

Required work

  1. Add electron-builder as a dev dependency
  2. Add "build" configuration to package.json (app id, name, icon, etc.)
  3. Create an app icon (.icns format, 1024×1024 source)
  4. Bundle any required runtime dependencies (Python toolkit if vendored, uv if not pre-installed on user machines — this is the hard part)
  5. Code-sign the app with an Apple Developer ID ($99/year) to avoid the “unidentified developer” warning macOS shows by default
  6. Notarize through Apple’s notarization service so Gatekeeper doesn’t block
  7. Set up GitHub Releases to host the .dmg downloads
  8. Optional: auto-update via electron-updater so users get fixes automatically

Open questions


Constraints to honor

Sequencing

v1 = Phase 1 (done) + tape dump save (verify/complete) + Phase 2 (Library tab) v2 = Phase 3 (MIDI integration), after Series Circuits kit is installed v3 = Phase 4 (Distribution), after MIDI is stable

v1 ships as a usable tape-dump-only librarian for the JX-3P community before any MIDI work begins. Dev friends can clone and run from source during v1 and v2; community-friendly .dmg downloads arrive with v3.

Open decisions (resolve before each phase)

Phase 2:

  1. Tape Memory Save button: keep as user-picks-file export, or repurpose as Library shortcut?

Phase 3:

  1. MIDI settings UI location (gear icon vs File → Preferences vs other).
  2. Throttle rate hardcoded at 30 Hz, or configurable?
  3. Behavior when kit’s port isn’t connected at startup — silent + red status dot, or one-time toast?
  4. Should clicking Write while offline show an error or silently no-op?

End of brief. The next Claude session should be able to start with Phase 2 from this alone.