Metadata-Version: 2.4
Name: repedal
Version: 0.1.0
Summary: Turn manually over-held MIDI piano notes into real CC64 sustain-pedal data
Author: ssmall256
License-Expression: MIT
Project-URL: Homepage, https://github.com/ssmall256/repedal
Project-URL: Repository, https://github.com/ssmall256/repedal
Project-URL: Issues, https://github.com/ssmall256/repedal/issues
Project-URL: Changelog, https://github.com/ssmall256/repedal/blob/main/CHANGELOG.md
Keywords: midi,piano,sustain,pedal,cc64,articulation
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mido>=1.2
Dynamic: license-file

# repedal

Most DAWs can flatten sustain-pedal (CC64) data into long MIDI note lengths. Going the
other way—recovering plausible key releases and real pedal data from those long notes—usually
requires a custom script. `repedal` does that conversion for piano MIDI.

It infers when the player's fingers could have left each key, shortens the MIDI notes to those
times, and writes damper-pedal events that preserve the input's sounding ends. The assembled
result is parsed again and simulated before anything is written.

```bash
python -m pip install repedal
repedal gymnopedie.mid
```

```text
gymnopedie.mid -> gymnopedie.pedaled.mid
  notes            282  (125 shortened, 157 left as written)
  finger-sustain moved to the pedal: median 857ms per note, 159.3s total
  pedal presses    71 damper (CC64)
  pedal down       ch0 43%, ch1 89% of the 121s piece
  handover         21ms minimum before a key release
  verification     worst sounding-end shift 0ms, 0 note(s) over the 120ms tolerance; exact
```

Python 3.9+ is required; [`mido`](https://mido.readthedocs.io) is installed automatically.
From a source checkout, use `python -m pip install .` to install the command and importable
module. Installing `mido` alone is enough to run `python repedal.py` directly.

Repedal is a public beta. The Python API and versioned JSON schema are intended for integration;
the prose reports printed for people may become clearer over time and should not be parsed by
software.

## Preview before writing

Pedal reconstruction is underdetermined: a long note might have been held by a finger or by a
pedal. Preview the chosen interpretation before committing to it:

```bash
repedal piece.mid --dry-run --report
```

`--dry-run` prints the same verification summary as a real conversion, and `--report` adds
individual presses and rejection reasons. It never writes a file. To compare the three
inference models that make musical judgements:

```bash
repedal piece.mid --compare-articulations
```

```text
piece.mid [articulation comparison; no file written]
  mode       shortened  damper  sostenuto  rejected  worst shift  status
  legato           125      71          0         0          0ms  exact
  voices            33      33          0         0          0ms  exact
  hands             26      26          0         0          0ms  exact
```

### Machine-readable output

Use `--json` when another program needs the result:

```bash
repedal piece.mid --dry-run --json
```

The command prints exactly one JSON object after its arguments have parsed successfully. Schema
version 1 has stable field names; fields may be added compatibly, while a removal or rename will
use a new `schema_version`. Warnings are included in the object instead of being printed
separately. `--json` and the prose-oriented `--report` cannot be combined.

```json
{
  "input": "piece.mid",
  "mode": "dry-run",
  "output": null,
  "result": {
    "notes": {"shortened": 125, "total": 282, "unchanged": 157},
    "pedal": {"damper_presses": 71, "rejected_presses": 0, "sostenuto_presses": 0},
    "verification": {
      "base_tolerance_ms": 120.0,
      "exact": true,
      "fixed_event_errors": 0,
      "out_of_tolerance_notes": 0,
      "unmatched_notes": 0,
      "within_tolerance": true,
      "worst_shift_ms": 0.0
    },
    "warnings": []
  },
  "schema_version": 1,
  "status": "ok",
  "written": false
}
```

For example, a subprocess consumer can replace regular-expression parsing with:

```python
import json
import subprocess

completed = subprocess.run(
    ["repedal", "piece.mid", "--dry-run", "--json"],
    capture_output=True,
    check=False,
    text=True,
)
payload = json.loads(completed.stdout)
shortened = payload["result"]["notes"]["shortened"]
presses = payload["result"]["pedal"]["damper_presses"]
```

Exit status 0 means the requested operation completed, 1 means an input, configuration, or write
error, and 2 means verification was unsafe and no output was written. Articulation comparisons
still exit 0 when every requested analysis completes; inspect their JSON `status` fields for
safety. A deliberate `--allow-lossy` write also exits 0 and reports `"status": "unsafe"`.

## Choosing an articulation model

`--articulation` controls how finger releases are inferred:

| Mode | Behaviour |
| --- | --- |
| `legato` *(default)* | Releases an attack group when the next selected attack group arrives. This recovers the most pedal from ordinary homophonic piano writing. |
| `voices` | Tracks non-crossing melodic streams and releases a note when its own stream moves on. A melody held over a moving bass therefore stays finger-held longer. |
| `hands` | Releases notes only when the held keys exceed `--max-fingers` or `--hand-span`. This is the most conservative musical model. |
| `fixed` | Uses only `--max-hold-beats` and/or `--max-hold-ms`. With neither cap, it is a no-op articulation model. |

`voices` uses a self-contained, pitch-ordered dynamic program; it does not require `music21`.
All attack grouping and hand/voice inference is limited to the selected notes.

## Selecting channels and tracks

Real MIDI files often contain drums, orchestration, or reference tracks that should not affect
piano articulation. Selection indexes are zero-based, matching `mido` and repedal's reports:

```bash
repedal arrangement.mid --channels 0,1
repedal arrangement.mid --tracks 2,3
```

- `--channels` chooses the MIDI channels whose notes may be shortened and whose CC64 stream is
  regenerated. Other channels and their pedal events remain unchanged.
- `--tracks` chooses which tracks supply notes eligible for shortening. Because MIDI pedal is
  channel-wide, unselected notes on the same channel still participate in safety simulation.
- General MIDI percussion channel 9 is ignored by default and reported as a warning. Use
  `--include-percussion`, or select channel 9 explicitly, to process it.
- A selected channel declaring a non-piano General MIDI program is reported as a warning. The
  conversion continues because program maps are conventions, not proof of instrumentation.

MIDI type 0 and type 1 files are supported. Type 2 files contain independent sequences with no
single shared timeline, so repedal rejects them; split them into separate type 0 or type 1 files
first.

## Pedal scope and existing pedal

CC64 is a per-channel controller. `--pedal-scope channel` (the default) plans each selected
channel independently, allowing a bass channel to be pedalled without smearing a melody
channel. `--pedal-scope global` derives one shared pedal plan and copies it to the selected
channels, which is appropriate when those channels will be flattened onto one instrument.

A global plan cannot preserve different existing pedal streams on different channels. Repedal
rejects that combination unless `--existing-pedal ignore` is used or channel scope is selected.

`--existing-pedal` controls existing CC64 on processed channels:

| Value | Behaviour |
| --- | --- |
| `keep` *(default)* | Treats existing pedal as authoritative, keeps its sounding result, and adds any required coverage. |
| `replace` | Reconstructs pedal from what the input currently sounds like. |
| `ignore` | Discards existing CC64 on processed channels and interprets written note lengths literally. |

Existing CC66 is always part of the input sound model. A newly proposed sostenuto span is
rejected if it overlaps existing CC66, because a piano has only one middle pedal.

## Verification and write safety

Repedal verifies two things after assembling the output:

1. Every note is paired with its input note and its simulated sounding end is compared in real
   time, through tempo changes.
2. Every event outside the documented transformation surface retains its track, absolute tick,
   payload, and same-tick order. Note-off timing/velocity and CC64/CC66 on processed channels are
   the only managed event classes.

The default `--blur-tol-ms 120` permits a musically bounded amount of over-ring when exact pedal
expression is impossible. This means “within tolerance” is not necessarily tick-exact.
`--strict` sets that tolerance to zero and requires exact sounding ends.

If verification exceeds the configured tolerance, the CLI prints the analysis, exits with
status 2, and does **not** write an output file. Library saves behave the same way. Output is
written through a temporary file and atomically moved into place only after validation.

`--allow-lossy` (or `Result.save(..., allow_lossy=True)`) is the explicit escape hatch for an
analysed result that the caller has decided to accept.

## Using it as a library

The CLI is a wrapper over the public API:

```python
from repedal import Options, convert, convert_file

result = convert("in.mid", Options(articulation="voices", channels=(0, 1)))
print(result.summary())
if result.exact:
    result.save("out.mid")

# Converts, verifies, and atomically writes. Raises ValueError if verification fails.
result = convert_file("in.mid", "out.mid")
```

`convert` also accepts an already loaded `mido.MidiFile`. It reads but does not modify that
object:

```python
import mido
from repedal import convert

source = mido.MidiFile("in.mid")
result = convert(source)
extra_track = mido.MidiTrack()
result.midi.tracks.append(extra_track)
result.save("out.mid")
```

A `Result` exposes both the file and the evidence behind it:

| Attribute | Contents |
| --- | --- |
| `midi` | Finished `mido.MidiFile` in memory. |
| `within_tolerance` / `lossless` | Whether verification satisfies the configured tolerance. `lossless` remains as a compatibility alias. |
| `exact` | Whether every sounding end is tick-exact and all fixed events match. |
| `presses` / `rejected` | Written and abandoned `Press` objects; rejected presses carry a `.reason`. |
| `presses[i].kind` | `"damper"` (CC64) or `"sostenuto"` (CC66). |
| `notes` / `shortened` | Parsed `Note` objects with `start`, `end`, `sound_end`, `key_end`, and `new_end`. |
| `deviations` / `out_of_tolerance` | `Deviation` objects containing `.ticks`, `.seconds`, and `.note`. |
| `event_errors` | Any change found outside managed note-off and pedal events. |
| `warnings` | Input preflight or planning warnings. |
| `min_handover_seconds` | Smallest press-to-key-release margin. |
| `summary()` / `report()` | The concise and detailed CLI-style reports. |

For analysis without output assembly, use `read_score` and `plan`:

```python
from repedal import Options, plan, read_score

warnings = []
score = read_score("in.mid")
target, scopes = plan(score, Options(channels=(0,)), warnings)
for note in score.notes[:5]:
    print(note.pitch, note.start, note.sound_end, "->", note.key_end)
```

## Sostenuto for pedal points

Damper pedal sustains every released key on its channel. It cannot express a long bass pedal
point while upper chords on that channel remain detached. `--sostenuto` allows repedal to move
eligible cases to CC66, which captures only keys held when the middle pedal is pressed:

```text
input   0:on36                240:on60,64,67  440:off60,64,67  1920:off36
output  0:on36  0:CC66=127    240:off36       240:on60,64,67    1920:CC66=0
```

The capture set, existing CC66, and other generated spans are included in the final simulation.
Use this only with instruments that implement CC66.

## Pedal timing and physical controls

`--pedal-lag-ms 50` delays a press after its associated attack, reproducing syncopated
pedalling: strings are struck with the dampers down, and the foot follows. The requested lag
shrinks when necessary to catch the first key release safely.

`--min-handover-ms` (default 20) is the minimum intended margin between a press and the key
release it catches. The margin protects frame-sampled renderers from quantising both events
into one update. The achieved minimum is reported; dense textures can leave less room than
requested.

`--change-gap-ms` adds time between a lift and the next press. `--damping-ramp-ms` replaces an
instantaneous lift with four sub-threshold values (63 → 42 → 21 → 0) for modelled pianos that
follow continuous CC64. Switch-style instruments still see the lift at the first value below
64. `--release-velocity` changes the note-off velocity only for shortened keys.

Every millisecond option is converted through the complete tempo map rather than sampled at one
tempo. A 50 ms setting therefore remains 50 ms across accelerando or ritardando even though its
tick length changes.

`--blur-tol-alpha` can make the over-ring tolerance stricter in the bass and looser in the
treble. With a 120 ms base and alpha 1.5, the tolerance is approximately 15 ms at C2, 42 ms at
C3, and 339 ms at C5. `--min-note-ms`, `--min-shorten-ms`, and `--min-pedal-ms` suppress
implausibly short notes, edits, and presses.

Run `repedal --help` for the complete option list.

## Development

The regression suite covers event-order preservation, generated/existing sostenuto conflicts,
safe atomic writes, mixed-channel selection, percussion handling, type-2 rejection, preview
commands, and randomized no-op files:

```bash
python -m unittest discover -s tests -v
```

The same suite runs in CI on every Python version from 3.9 through 3.13. CI also builds and checks
the wheel and source distribution, then installs each into a clean environment.

## Versioning and license

Repedal follows semantic versioning. During the 0.x public beta, minor releases may revise the
Python API; changes are documented in the
[changelog](https://github.com/ssmall256/repedal/blob/main/CHANGELOG.md). The JSON interface is
independently versioned by its `schema_version` as described above.

Repedal is released under the
[MIT License](https://github.com/ssmall256/repedal/blob/main/LICENSE). Source, issues, and
releases are hosted at
[github.com/ssmall256/repedal](https://github.com/ssmall256/repedal).
