4. Renaming the MIDI Files

Deterministic filename generation and internal metadata repair

Once the metadata has been extracted (Step 1) and normalized + human‑curated (Steps 2–3), the final stage of the workflow is to apply the new metadata to the actual MIDI files. This renamer script is the engine that transforms a chaotic library into a clean, consistent, future‑proof collection.

The script uses the curated metadata spreadsheet as the single source of truth and performs two major tasks:

  1. Generate clean, deterministic filenames
  2. Rewrite internal MIDI metadata safely and consistently

This step is fully automated, but intentionally includes a dry‑run mode so you can preview all changes before committing them.


What the Renamer Does

✔ Builds filenames using a consistent pattern

The script generates filenames in the form:

composer--title.mid

Composer and title are normalized separately, ensuring the double dash (--) remains a semantic separator rather than being collapsed by normalization.

✔ Ensures collision‑safe naming

If two files normalize to the same base name, the script automatically appends:

_filename_1.mid
_filename_2.mid

This prevents accidental overwrites.

✔ Rewrites internal MIDI metadata

The script updates:

  • Track name
  • Text meta events
  • Copyright
  • Comments

All text is passed through a Latin‑1‑safe filter so Mido can write it without errors.

✔ Produces a rename report

Every run generates:

rename_report.csv

mapping:

old_filename,new_filename

This makes the process reversible and auditable.

✔ Supports dry‑run mode

Before making any changes, you can run:

DRY_RUN = True

to preview all renames without touching any files.


When to Use This Script

Run the renamer only after:

  1. Raw metadata has been extracted
  2. Metadata has been normalized
  3. You have manually reviewed and corrected the metadata
  4. You have saved the curated file as:

    normalized_metadata_edit_fixed.csv
    

This curated file becomes the authoritative source for renaming.


FULL RENAMER SCRIPT

(Copy/paste directly into your Contraptions page)

import csv
import re
import mido
from mido import MetaMessage
from pathlib import Path
from openpyxl import load_workbook

# Base name only — script auto-detects .csv or .xlsx
METADATA_FILE = "normalized_metadata_edit_fixed"

MIDI_FOLDER = Path(".")
REPORT_PATH = "rename_report.csv"

DRY_RUN = True   # ← Set to False to actually rename + modify files


# ------------------------------------------------------------
# Encoding cleanup
# ------------------------------------------------------------

def fix_encoding(s):
    if not isinstance(s, str):
        return ""
    try:
        return s.encode("cp1252").decode("utf-8")
    except:
        return s


def latin1_safe(s):
    """Convert text to something Mido can encode (Latin-1)."""
    if not isinstance(s, str):
        return ""

    replacements = {
        "\u2018": "'",   # left single quote
        "\u2019": "'",   # right single quote
        "\u201C": '"',   # left double quote
        "\u201D": '"',   # right double quote
        "\u2013": "-",   # en dash
        "\u2014": "-",   # em dash
        "\u2026": "...", # ellipsis
    }

    for bad, good in replacements.items():
        s = s.replace(bad, good)

    # Final fallback: replace anything Latin-1 can't encode
    return s.encode("latin-1", "replace").decode("latin-1")


# ------------------------------------------------------------
# Normalization helpers
# ------------------------------------------------------------

def normalize_filename(s):
    s = fix_encoding(s)
    s = re.sub(r"[^\w]+", "_", s)
    s = re.sub(r"_+", "_", s)
    s = s.strip("_")
    return s.lower()


def extract_primary_composer(composer):
    """Use only the first composer name."""
    composer = fix_encoding(composer)
    parts = re.split(r"[\/,&]| and ", composer, flags=re.IGNORECASE)
    primary = parts[0].strip()
    return primary


def ensure_unique_name(base_name, used_names):
    name = base_name
    counter = 1
    while name in used_names:
        name = f"{base_name}_{counter}"
        counter += 1
    used_names.add(name)
    return name


# ------------------------------------------------------------
# MIDI metadata helpers
# ------------------------------------------------------------

def replace_or_insert(track, meta_type, text):
    """Replace or insert a meta message of a given type, using correct attribute."""
    uses_name = meta_type in ("track_name", "instrument_name", "device_name")

    for msg in track:
        if msg.type == meta_type:
            if uses_name:
                msg.name = text
            else:
                msg.text = text
            return

    # Insert new message at top
    if uses_name:
        new_msg = MetaMessage(meta_type, name=text, time=0)
    else:
        new_msg = MetaMessage(meta_type, text=text, time=0)

    track.insert(0, new_msg)


def append_comment_safely(track, comments):
    if comments:
        replace_or_insert(track, "text", comments)


# ------------------------------------------------------------
# File reader (auto-detect CSV vs XLSX)
# ------------------------------------------------------------

def load_metadata_rows(base_name):
    csv_path = Path(base_name + ".csv")
    xlsx_path = Path(base_name + ".xlsx")

    if csv_path.exists():
        # FIX: read as UTF-8
        with open(csv_path, newline="", encoding="utf-8") as f:
            return list(csv.DictReader(f))

    if xlsx_path.exists():
        wb = load_workbook(xlsx_path)
        ws = wb.active
        rows = list(ws.iter_rows(values_only=True))
        headers = rows[0]
        return [dict(zip(headers, row)) for row in rows[1:]]

    raise FileNotFoundError("Neither CSV nor XLSX found.")


# ------------------------------------------------------------
# Main processing
# ------------------------------------------------------------

def process_midi_file(old_path, title, composer, comments, used_names):
    title = fix_encoding(title)
    composer = fix_encoding(composer)
    comments = fix_encoding(comments)

    primary_composer = extract_primary_composer(composer)

    # Normalize separately
    norm_title = normalize_filename(title)
    norm_composer = normalize_filename(primary_composer)

    # Prevent composer == title duplication
    if norm_composer == norm_title or norm_composer == "unknown":
        base = norm_title
    else:
        base = f"{norm_composer}--{norm_title}"

    base = ensure_unique_name(base, used_names)
    new_name = base + ".mid"
    new_path = old_path.with_name(new_name)

    if DRY_RUN:
        return new_name, False

    mid = mido.MidiFile(old_path)
    track = mid.tracks[0]

    safe_title = latin1_safe(title)
    safe_composer = latin1_safe(primary_composer)
    safe_comments = latin1_safe(comments)

    replace_or_insert(track, "track_name", safe_title)
    replace_or_insert(track, "text", safe_title)
    replace_or_insert(track, "copyright", safe_composer)
    append_comment_safely(track, safe_comments)

    mid.save(new_path)

    if new_path != old_path:
        old_path.unlink()

    return new_name, True


def main():
    used_names = set()
    report_rows = []

    rows = load_metadata_rows(METADATA_FILE)

    for row in rows:
        filename = str(row.get("filename", "")).strip()
        title = str(row.get("title", "")).strip()
        composer = str(row.get("composer", "")).strip()
        comments = str(row.get("comments", "")).strip()

        midi_path = MIDI_FOLDER / filename

        if not midi_path.exists():
            report_rows.append([filename, "MISSING"])
            print(f"Missing file: {filename}")
            continue

        new_name, changed = process_midi_file(
            midi_path, title, composer, comments, used_names
        )

        report_rows.append([filename, new_name])
        print(f"{'(dry-run)' if DRY_RUN else ''} {filename} → {new_name}")

    with open(REPORT_PATH, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["old_filename", "new_filename"])
        writer.writerows(report_rows)

    print("\nReport written to:", REPORT_PATH)
    print("Dry-run mode is", DRY_RUN)
    print("\nAll operations completed successfully.")


if __name__ == "__main__":
    main()

Summary

The renamer script is the final, transformative step in the normalization workflow.
It takes your curated metadata and produces a clean, deterministic, collision‑safe set of filenames while repairing internal MIDI metadata. With dry‑run mode and a detailed rename report, the process is transparent, reversible, and safe.

Once this step is complete, your MIDI library is fully normalized and ready for archival, playback, or further processing.

Project type