Metadata-Version: 2.4
Name: md2electraone
Version: 0.2.0
Summary: CLI tool to convert markdown or midi.guide CSV to Electra One format
Author-email: Graham Wheeler <gram@geekraver.com>
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: pyyaml>=6.0
Project-URL: Homepage, https://github.com/gramster/md2electraone
Project-URL: Repository, https://github.com/gramster/md2electraone

# md2electraone

**md2electraone** is a Python tool that converts between **Markdown documents** and **Electra One preset JSON** files. It can also import CSV files from https://midi.guide.

Instead of hand-building Electra presets, you write (or reuse) a clean, readable Markdown spec of CC/NRPN mappings — and this tool turns it into an importable Electra One preset JSON. You can also convert existing Electra One presets back to Markdown for documentation or editing.

Example input specs live in the `specs/` folder.

---

## What this does (and why)

- ✔ Convert Markdown tables → Electra One preset JSON
- ✔ Import midi.guide CSV files → Electra One preset JSON
- ✔ Fetch device data directly from midi.guide by name (e.g. `"Supercritical/Redshift 6"`)
- ✔ Convert Electra One preset JSON → Markdown (reverse conversion)
- ✔ Enforce consistent layout and labeling
- ✔ Make MIDI implementations readable *and* executable
- ✔ Enable rapid iteration and sharing of controller mappings

I prefer this approach to designing a preset in a GUI editor, as it is easier to audit, make
quick bulk rearrangements, and potentially use the markdown doc elsewhere.


## A Note about Group Labels

For some reason, the JSON generated by this app can get modified in
when imported into the Electra One editor. I have seen groups get 
their bounding boxes switched with other groups.

Tracking this here: https://github.com/gramster/md2electraone/issues/2

This has been confirmed as a bug in Electra One's editor, and is apparently 
being fixed (perhaps it is fixed by the time you read this).

---

## Quick Start

If you already use Python:

```bash
python3 -m venv .venv
source .venv/bin/activate      # macOS / Linux
# .venv\Scripts\activate       # Windows

pip install -e .
```

Then:

```bash
python3 -m md2electraone specs/ndlr2.md \
  -o NDLR_ElectraOne_Preset.json \
  --debug
```

Upload the generated JSON to the Electra One web editor and sync it to your device.

---

## Setting up Python (beginner-friendly)

If you’re not especially tech-savvy, follow this step by step:

1. **Install Python**  
   Download from https://python.org (Python 3.12+ required).

2. **Download this repository**
   - If you know Git:  
     ```bash
     git clone https://github.com/gramster/md2electraone.git
     ```
   - Otherwise:  
     Download and unzip  
     https://github.com/gramster/md2electraone/archive/refs/heads/main.zip

3. **Open a terminal / shell**
   - macOS: Terminal
   - Windows: PowerShell
   - Linux: your terminal of choice

4. **Change into the project folder**
   ```bash
   cd md2electraone
   ```

5. **Create and activate a virtual environment**
   ```bash
   python3 -m venv .venv
   ```

   Activate it:
   - macOS / Linux:
     ```bash
     source .venv/bin/activate
     ```
   - Windows:
     ```bat
     .venv\Scripts\activate
     ```

7. **Install the tool**
   ```bash
   python3 -m ensurepip
   python3 -m pip install -e .
   ```

After this, the commands below should work as long as you run that within that folder.
If you need to use the tool again later, change to the folder and run the activate line only
first. If you want to get the latest update first:

6. **Update the tool**
   ```bash
   git pull
   python3 -m pip install -e .
   ```

If this seems like a big hassle to you, go vote on https://github.com/gramster/md2electraone/issues/1

---

## Usage

### Markdown → JSON (Generate preset)

Generate preset JSON only:

```bash
python3 -m md2electraone specs/ndlr2.md \
  -o NDLR_ElectraOne_Preset.json \
  --debug
```

Generate preset JSON **and** cleaned Markdown:

```bash
python3 -m md2electraone specs/ndlr2.md \
  -o NDLR_ElectraOne_Preset.json \
  --clean-md NDLR_MIDI.cleaned.md \
  --debug
```

The cleaned Markdown is useful if your source spec is messy or inconsistent.

### midi.guide CSV → JSON

You can import midi.guide data in two ways:

**By device name (fetches directly from GitHub):**

```bash
python3 -m md2electraone "Supercritical/Redshift 6" \
  -o preset.json
```

The argument is treated as a `Manufacturer/Device Name` path and the corresponding CSV is fetched from:
`https://raw.githubusercontent.com/pencilresearch/midi/refs/heads/main/Manufacturer/Device%20Name.csv`

The device name must match the path used in the [pencilresearch/midi](https://github.com/pencilresearch/midi) repository exactly (case-sensitive). Spaces are URL-encoded automatically.

**From a local CSV file:**

```bash
python3 -m md2electraone midi.guide.template.csv \
  -o preset.json \
  --clean-md imported.md
```

The importer maps midi.guide sections to Markdown sections, preserves manufacturer and device metadata, and converts usage notes into Electra One choices when the usage definition is discrete. If a midi.guide row defines both CC and NRPN access for the same parameter, the importer uses the CC.

### JSON → Markdown (Reverse conversion)

Convert an Electra One preset JSON back to Markdown:

```bash
python3 -m md2electraone NDLR_ElectraOne_Preset.json \
  --to-markdown \
  -o NDLR_spec.md \
  --debug
```

This is useful for:
- Documenting existing presets
- Extracting MIDI implementation from presets
- Editing presets in Markdown format
- Sharing preset specifications in a readable format

**Note:** The reverse conversion supports the subset of Electra One features that md2electraone can generate (7-bit CC messages, faders, lists, and pads). Any unsupported features will trigger warnings on stderr and will be dropped from the output.

### JSON output formatting

By default, the generated JSON is **minified** (compact, no whitespace) for optimal file size. For debugging or readability, use the `--pretty` flag to format the JSON with indentation:

```bash
python3 -m md2electraone specs/ndlr2.md \
  -o NDLR_ElectraOne_Preset.json \
  --pretty
```

**Note:** The Electra One accepts both minified and pretty-printed JSON, so use whichever format suits your workflow.

---

## Markdown Format

Each Markdown file should consist of:

- Optional **YAML frontmatter** (for metadata)
- Section headers (`## Pad`, `## Drone`, etc.)
- Followed by **Markdown tables**

### Optional frontmatter

You can include YAML frontmatter at the start of your Markdown file to specify device metadata:

```yaml
---
name: Moog Subsequent 37        # Device name (used in preset and devices array)
version: 2                       # Preset version (default: 2)
port: 1                          # MIDI port (default: 1)
channel: 5                       # MIDI channel (default: 1)
manufacturer: Moog Music         # Manufacturer (informational)
groups: highlighted              # Group variant (e.g., "highlighted")
---
```

All fields are optional. If not specified, defaults will be used.

#### Multi-Device Support

For presets that control multiple devices (e.g., two synths on different MIDI channels), you can specify multiple devices in the frontmatter:

```yaml
---
devices:
  - name: Synth A
    port: 1
    channel: 1
  - name: Synth B
    port: 1
    channel: 2
---
```

When using multiple devices, you have three options for specifying which device a control belongs to:

1. **Device prefix in Control column**: `deviceIndex:ccNumber` (e.g., `1:10`, `2:42`)
2. **Device declaration at page level**: `device: Device Name` before the table
3. **Device expansion with `<device>` token** (see below)

```markdown
| Control (Dec) | Label | Range | Choices | Color  |
|---------------|-------|-------|---------|--------|
| 1:10 | Synth A Filter | 0-127 |         | FF0000 |
| 2:10 | Synth B Filter | 0-127 |         | 00FF00 |
| 1:20 | Synth A Res    | 0-127 |         | FF0000 |
| 2:20 | Synth B Res    | 0-127 |         | 00FF00 |
```

**Device prefix syntax:**
- Format: `deviceIndex:ccNumber` (e.g., `1:10`, `2:42`)
- Device indices are 1-based and correspond to the order in the `devices` list
- Device prefixes are only required when you have multiple devices
- For single-device presets, no prefix is needed (backward compatible)
- Device prefixes work with all message types (e.g., `2:N100` for NRPN on device 2)

**Device declaration syntax:**

You can declare a device for an entire page by adding `device: Device Name` before the table:

```markdown
## Part 1 Controls

device: Synth A

| Control (Dec) | Label | Range | Choices | Color  |
|---------------|-------|-------|---------|--------|
| 10 | Filter | 0-127 |         | FF0000 |
| 20 | Resonance | 0-127 |         | FF0000 |
```

All controls in that section will use the specified device (unless they have an explicit device prefix).

#### Device Expansion with `<device>` Token

For devices with multiple identical parts (e.g., a 6-voice synth where each voice has the same controls), you can use the `<device>` token to avoid duplication:

```yaml
---
name: Redshift 6
devices:
  device count: 6
  - name: Redshift 6 Part <device>
    port: 1
    channel: <device>
  - name: Redshift 6 Global
    port: 1
    channel: 15
    id: 7
---

## Part <device>: Waveform

device: Redshift 6 Part <device>

| Control (Dec) | Label | Range | Choices | Color  |
|---------------|-------|-------|---------|--------|
| N:128 | Osc 1 Wave | 0-4 | Saw, Pulse, Saw R, Pulse R, None | |
| N:129 | Osc 2 Wave | 0-4 | Saw, Pulse, Saw R, Pulse R, None | |
```

This will automatically expand to:
- 6 device entries (Redshift 6 Part 1 through Part 6, plus Global)
- 6 sections (Part 1: Waveform through Part 6: Waveform)
- Each section's controls will be assigned to the corresponding device

**Device expansion features:**
- `device count: N` specifies how many times to expand `<device>` tokens
- `<device>` in device names and channels gets replaced with 1, 2, 3, etc.
- `<device>` in section headers causes the section to be duplicated N times
- Consecutive sections with `<device>` are grouped and expanded together
- `device: Name <device>` declarations are expanded and mapped to device IDs
- Explicit `id:` fields in device entries are preserved and checked for conflicts
- Use `--expand-only` flag to see the expanded markdown for debugging

**Requirements:**
- PyYAML is required for proper YAML parsing of complex frontmatter (automatically installed with `pip install -e .`)

**Group variants:**
- The `groups` field sets the visual variant for all group labels in the preset
- Common values: `highlighted` (makes group labels more prominent)
- This is applied to all groups in the preset

### Required table columns

| Column              | Description |
|---------------------|-------------|
| **Control (Dec)**   | MIDI CC/NRPN number in decimal, or group identifier. Optional prefix: `C` for CC (default), `N` for NRPN, `S` for SysEx (future). For envelope controls, use comma-separated CC numbers (e.g. `1,2,3,4` for ADSR). Hexadecimal format also supported (e.g. `0x10`). |
| **Label**           | Label shown on the Electra One control, or group display name |
| **Range**           | Numeric range (e.g. `0-127` for 7-bit, `0-16383` for 14-bit). Optional default value in parentheses (e.g. `0-127 (64)`). If not specified, defaults to 0 if in range, otherwise the minimum value. For groups: number of contiguous controls (optional). |
| **Choices**         | For lists/buttons: comma-separated labels. If needed, specify values in parentheses (`Minor(2)`). For envelope controls: `ADSR` or `ADR`. |
| **Color**           | RGB hex color (e.g. `#FF8800`). Persists until changed. Can be left empty. For groups: inherited by all group members that don't have an explicit color. |

### Groups

You can organize controls into labeled groups that appear as headers above the controls. Groups use an internal identifier (in the CC column) and a display label (in the Label column). This allows multiple groups to have the same display label while maintaining unique identifiers.

#### Range-based groups (contiguous controls)

For groups where all controls are contiguous in the top row:

```markdown
| Control (Dec) | Label      | Range | Choices | Color   |
|---------------|------------|-------|---------|---------|
| osc           | OSCILLATOR | 3     |         | #FF0000 |
| 10            | Waveform   | 0-3   | Sine,Tri,Saw,Square | |
| 11            | Octave     | -2-2  |         |         |
| 12            | Detune     | 0-127 |         |         |
```

#### Explicit group membership (multi-row or non-contiguous)

For groups that span multiple rows or have non-contiguous controls, use the `groupid:` prefix:

```markdown
| Control (Dec) | Label                  | Range | Choices | Color |
|---------------|------------------------|-------|---------|-------|
| osc           | OSCILLATOR             |       |         |       |
| 10            | osc: Waveform          | 0-3   | Sine,Tri,Saw,Square | |
| 11            | osc: Octave            | -2-2  |         |       |
| 12            | Filter Cutoff          | 0-127 |         |       |
| 13            | osc: Detune            | 0-127 |         |       |
| 14            | Filter Resonance       | 0-127 |         |       |
| 15            | osc: Level             | 0-127 |         |       |
```

**Group syntax:**
- Use an internal group identifier (e.g., `osc`, `grp1`, `target1`) in the Control column to define a group
- The **Label** column specifies the display label shown on the Electra One
- The **Range** column (optional) specifies how many controls in the top row belong to this group
  - If blank, use explicit `groupid:` prefixes on control labels
  - If specified, the next N controls are automatically assigned to the group
- The **Color** column (optional) sets the group label color and is inherited by all group members
  - Group members without an explicit color will inherit the group's color
  - Group members with an explicit color will use their own color (overrides group color)
- Group labels are positioned above the controls, and the group bounding box surrounds all controls in the group

**Note:** The internal group identifier must be a valid identifier (letters, numbers, underscores; must start with a letter or underscore). This allows you to have multiple groups with the same display label (e.g., "TARGET") by using different identifiers (e.g., `target1`, `target2`).

**Backward compatibility:** The old format using `Group` in the Control column is still supported, but the new format with explicit group identifiers is recommended.

Groups are purely visual organizational elements - they don't affect MIDI functionality.

#### Group color inheritance example

```markdown
| Control (Dec) | Label                  | Range | Choices | Color   |
|---------------|------------------------|-------|---------|---------|
| osc           | OSCILLATOR             |       |         | #FF0000 |
| 10            | osc: Waveform          | 0-3   | Sine,Tri,Saw,Square | |
| 11            | osc: Octave            | -2-2  |         |         |
| 12            | Filter Cutoff          | 0-127 |         | #00FF00 |
| 13            | osc: Detune            | 0-127 |         | #0000FF |
| 14            | Filter Resonance       | 0-127 |         |         |
| 15            | osc: Level             | 0-127 |         |         |
```

In this example:
- **Waveform** and **Octave** inherit the group color `#FF0000` (no explicit color)
- **Filter Cutoff** has its own color `#00FF00` (not in the group)
- **Detune** has an explicit color `#0000FF` that overrides the group color
- **Filter Resonance** inherits `#0000FF` from the previous row (standard color persistence)
- **Level** inherits the group color `#FF0000` (no explicit color)

### Message Type Prefixes

The Control column supports optional prefixes to specify the MIDI message type:

- **C:** or **c**: CC message (default if no prefix)
  - Automatically uses 7-bit (`cc7`) if range ≤ 127
  - Automatically uses 14-bit (`cc14`) if range > 127
- **N:** or **n**: NRPN message (always 14-bit)
- **P:**: prgram change message
- **S:** or **s**: SysEx message (future support)

Examples:
- `10` or `C:10` → 7-bit CC #10 (if range ≤ 127)
- `20` → 14-bit CC #20 (if range > 127)
- `N:100` → NRPN #100
- `2:N:100` → NRPN #100 on device 2
- `0x1A` → 7-bit CC #26 (hex notation)

### Default Values

You can specify initial default values for controls by adding them in parentheses after the range:

```markdown
| Control (Dec) | Label  | Range       | Choices             | Color |
|---------------|--------|-------------|---------------------|-------|
| 1             | Volume | 0-127 (64)  |                     |       |
| 2             | Filter | 20-100 (50) |                     |       |
| 3             | Mode   | 0-3 (1)     | Off, Low, Med, High |       |
```

**Default value behavior:**
- If specified (e.g., `0-127 (64)`), that value is used as the initial value
- If not specified, the default is 0 if 0 is within the range, otherwise the minimum value
- Default values are included in the JSON `defaultValue` field and used in startup messages
- When converting JSON back to Markdown, default values are only shown if they differ from the automatic default

### Layout notes

- Blank rows in tables create **blank spaces** in the Electra layout
- Color values persist until overridden
- Section boundaries define logical control groupings

See the `specs/` directory for complete, working examples.

---

## Supported Control Types

md2electraone supports the following Electra One control types:

- **Faders** - Continuous value controls (default for controls without choices)
- **Lists** - Multi-valued selection controls (when choices are specified)
- **Pads** - Toggle buttons for on/off controls (when exactly 2 choices with on/off semantics)
- **ADSR Envelopes** - Attack, Decay, Sustain, Release envelope controls (specify `ADSR` in Choices column with 4 comma-separated CCs)
- **ADR Envelopes** - Attack, Decay, Release envelope controls (specify `ADR` in Choices column with 3 comma-separated CCs)

### Control Modes

Control modes are automatically inferred from the control's characteristics:

- **Unipolar** - Default mode for faders with non-negative ranges (e.g., `0-127`)
- **Bipolar** - Automatically applied to faders with negative minimum values (e.g., `-64-63`)
- **Toggle** - Automatically applied to 2-choice controls with on/off semantics (e.g., `Off, On`)
- **Momentary** - Automatically applied to 2-choice controls with momentary semantics (e.g., `Released, Momentary`)
- **Default** - Used for list controls and envelopes

The mode is inferred during conversion and preserved in roundtrip conversions (Markdown → JSON → Markdown).

### Control Mode Examples

```markdown
| Control (Dec) | Label | Range | Choices | Color |
|---------------|-------|-------|---------|-------|
| 1 | Volume | 0-127 | | |
| 2 | Pan | -64-63 | | |
| 3 | Mute | 0-127 | Off, On | |
| 4 | Sustain Pedal | 0-127 | Released, Momentary | |
| 5 | Waveform | 0-3 | Sine, Triangle, Saw, Square | |
```

This creates:
- **Volume**: Unipolar fader (0-127)
- **Pan**: Bipolar fader (-64 to 63, automatically inferred from negative min)
- **Mute**: Toggle pad (Off/On)
- **Sustain Pedal**: Momentary pad (automatically inferred from Released/Momentary labels)
- **Waveform**: List control with 4 options

### Envelope Control Example

```markdown
| Control (Dec) | Label  | Range | Choices | Color |
|---------------|--------|-------|---------|-------|
| 1,2,3,4       | Filter | 0-127 | ADSR    |       |
| 5,6,7         | Amp    | 0-127 | ADR     |       |
```

Envelope controls automatically span 2 grid positions and create the appropriate multi-value structure with inputs mapped to the envelope components.

---

## Current Limitations

### Markdown → JSON conversion:
- **SysEx** messages not yet supported (CC7, CC14, and NRPN are supported)
- **One-way output only**
  (does not read or sync from instruments)

### JSON → Markdown conversion:
- Only supports the subset of Electra One features that md2electraone can generate
- Unsupported features (SysEx, etc.) will be dropped with warnings
- Control positioning information is lost (regenerated based on page order)

---

## Testing

The project includes a comprehensive test suite using pytest.

### Running Tests

To run all tests:

```bash
pytest tests/ -v
```

To run specific test files:

```bash
pytest tests/test_parser.py -v
pytest tests/test_roundtrip.py -v
pytest tests/test_json2md.py -v
```

### Test Coverage

The test suite includes:

- **Parser tests** ([`tests/test_parser.py`](tests/test_parser.py)) - Test markdown parsing functionality including:
  - CC number parsing (decimal, hex, NRPN)
  - Range parsing with default values
  - Choices/options parsing
  - Color parsing
  - Frontmatter parsing
  - Control mode inference
  - Group color inheritance

- **Roundtrip tests** ([`tests/test_roundtrip.py`](tests/test_roundtrip.py)) - Test MD → JSON → MD conversions preserve:
  - Default values
  - Message types (CC7, CC14, NRPN)
  - Control modes (bipolar, toggle, momentary)
  - Group structures
  - Blank rows

- **JSON to Markdown tests** ([`tests/test_json2md.py`](tests/test_json2md.py)) - Test JSON → MD conversion:
  - Simple presets
  - Presets with groups
  - NRPN messages
  - Control count preservation

### Installing Test Dependencies

If you haven't already installed pytest:

```bash
pip install pytest
```

Or install the project in development mode (recommended):

```bash
pip install -e .
```

---

## Contributing

Contributions are welcome!

- New Markdown specs
- Bug reports
- Improvements to layout rules or parsing
- New tests for edge cases

