mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-28 15:11:13 +08:00
feat: add MuseScore 4 CLI wrapper for music notation
First music notation tool in the CLI-Anything ecosystem. Wraps MuseScore 4's mscore backend following the HARNESS.md 7-phase methodology. Supports: transpose (by key/interval/diatonic), export (PDF/PNG/SVG/MP3/FLAC/ WAV/MIDI/MusicXML/Braille), part extraction, instrument management, score analysis (probe/diff/stats), and interactive REPL with undo/redo. 56 tests (39 unit + 17 E2E) — all passing on macOS with MuseScore 4.6.5.
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
!/adguardhome/
|
||||
!/novita/
|
||||
!/ollama/
|
||||
!/musescore/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
@@ -74,6 +75,8 @@
|
||||
/adguardhome/.*
|
||||
/ollama/*
|
||||
/ollama/.*
|
||||
/musescore/*
|
||||
/musescore/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -92,6 +95,7 @@
|
||||
!/adguardhome/agent-harness/
|
||||
!/novita/agent-harness/
|
||||
!/ollama/agent-harness/
|
||||
!/musescore/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
**/__pycache__/
|
||||
|
||||
@@ -589,6 +589,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>🎵 MuseScore</strong></td>
|
||||
<td>Music Notation</td>
|
||||
<td><code>cli-anything-musescore</code></td>
|
||||
<td>mscore CLI (MSCX/MusicXML)</td>
|
||||
<td align="center">✅ 56</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📐 Draw.io</strong></td>
|
||||
<td>Diagramming</td>
|
||||
<td><code>cli-anything-drawio</code></td>
|
||||
@@ -737,6 +744,7 @@ cli-anything/
|
||||
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests)
|
||||
├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests)
|
||||
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
|
||||
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
|
||||
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
|
||||
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
|
||||
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# MUSESCORE.md — Software-Specific Analysis and SOP
|
||||
|
||||
## 1. Software Overview
|
||||
|
||||
**MuseScore 4** is a free, open-source music notation editor. It reads and writes `.mscz` (native), `.mxl` (compressed MusicXML), `.mid` (MIDI), and `.musicxml` formats.
|
||||
|
||||
- Homepage: https://musescore.org
|
||||
- Version tested: 4.6.5
|
||||
- License: GPL v3
|
||||
|
||||
## 2. Backend Engine
|
||||
|
||||
The `mscore` binary provides all rendering, transposition, and conversion capabilities.
|
||||
|
||||
### Binary Locations
|
||||
|
||||
| Platform | Path |
|
||||
|----------|------|
|
||||
| macOS | `/Applications/MuseScore 4.app/Contents/MacOS/mscore` |
|
||||
| Linux | `/usr/bin/mscore4` or `/usr/local/bin/mscore4` |
|
||||
| Windows | `C:\Program Files\MuseScore 4\bin\MuseScore4.exe` |
|
||||
|
||||
### Data Model
|
||||
|
||||
- `.mscz` = ZIP archive containing:
|
||||
- `.mscx` XML (score data)
|
||||
- `score_style.mss` (style overrides)
|
||||
- `audiosettings.json`
|
||||
- `viewsettings.json`
|
||||
- `Thumbnails/thumbnail.png`
|
||||
- `.mxl` = ZIP archive containing MusicXML `score.xml`
|
||||
|
||||
## 3. CLI Capabilities
|
||||
|
||||
### Export (`-o`)
|
||||
```bash
|
||||
mscore -o output.pdf input.mscz # PDF
|
||||
mscore -o output.mid input.mscz # MIDI
|
||||
mscore -o output.mp3 --bitrate 192 input.mscz # MP3
|
||||
mscore -o output.png -r 150 input.mscz # PNG (per page)
|
||||
mscore -o output.musicxml input.mscz # MusicXML
|
||||
```
|
||||
|
||||
### Transpose (`--transpose` + `-o`)
|
||||
JSON format:
|
||||
```json
|
||||
{
|
||||
"mode": "to_key|by_interval|diatonically",
|
||||
"direction": "up|down|closest",
|
||||
"targetKey": 0,
|
||||
"transposeInterval": 0,
|
||||
"transposeKeySignatures": true,
|
||||
"transposeChordNames": true,
|
||||
"useDoubleSharpsFlats": false
|
||||
}
|
||||
```
|
||||
|
||||
### Key Signature Integer Mapping
|
||||
```
|
||||
-7=Cb -6=Gb -5=Db -4=Ab -3=Eb -2=Bb -1=F
|
||||
0=C 1=G 2=D 3=A 4=E 5=B 6=F# 7=C#
|
||||
```
|
||||
|
||||
### Metadata (`--score-meta`)
|
||||
Returns JSON: title, composer, keysig, timesig, tempo, duration, measures, pages, parts.
|
||||
|
||||
### Parts (`--score-parts`)
|
||||
Returns JSON with part names and base64-encoded .mscz data per part.
|
||||
|
||||
### Media (`--score-media`)
|
||||
Returns JSON with pngs, svgs, pdf, midi, mxml, metadata.
|
||||
|
||||
### Batch Jobs (`-j`)
|
||||
```json
|
||||
[{"in": "/path/input.mscz", "out": "/path/output.pdf"}]
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
- `0` — success
|
||||
- `31` — invalid transpose options
|
||||
- `23` — invalid batch job format
|
||||
|
||||
### Output Verification (Magic Bytes)
|
||||
| Format | Magic |
|
||||
|--------|-------|
|
||||
| PDF | `%PDF-` |
|
||||
| MIDI | `MThd` |
|
||||
| MP3 | `0xfffb` or `ID3` |
|
||||
| PNG | `\x89PNG` |
|
||||
| MSCZ | `PK` (ZIP) |
|
||||
|
||||
## 4. GUI-to-CLI Mapping
|
||||
|
||||
| GUI Action | CLI Equivalent |
|
||||
|-----------|---------------|
|
||||
| File → Export → PDF | `mscore -o output.pdf input.mscz` |
|
||||
| Tools → Transpose | `mscore --transpose '{...}' -o out.mscz input.mscz` |
|
||||
| File → Parts | `mscore --score-parts input.mscz` |
|
||||
| File → Score Properties | `mscore --score-meta input.mscz` |
|
||||
|
||||
## 5. CLI Architecture
|
||||
|
||||
### Command Groups (v1 MVP)
|
||||
|
||||
| Group | Purpose | Backend |
|
||||
|-------|---------|---------|
|
||||
| `project` | open, info, save | MusicXML/MSCX parsing |
|
||||
| `transpose` | by-key, by-interval, diatonic | `--transpose` + `-o` |
|
||||
| `parts` | list, extract, generate | `--score-parts` |
|
||||
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | `-o` |
|
||||
| `instruments` | list, add, remove, reorder | MSCX XML manipulation |
|
||||
| `media` | probe, diff, stats | `--score-meta`, `--diff` |
|
||||
| `session` | status, undo, redo, history | In-memory state + JSON persistence |
|
||||
|
||||
### State Model
|
||||
- In-memory `Session` dataclass with undo/redo stacks
|
||||
- `fcntl.flock()` for safe concurrent JSON writes
|
||||
- Session singleton via `get_session()`
|
||||
@@ -0,0 +1,49 @@
|
||||
# cli-anything-musescore
|
||||
|
||||
CLI wrapper for **MuseScore 4** — the first music notation tool in the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
- **Transpose** scores by key, interval, or diatonically
|
||||
- **Export** to PDF, PNG, SVG, MP3, FLAC, WAV, MIDI, MusicXML, Braille
|
||||
- **Extract parts** from multi-instrument scores
|
||||
- **Manage instruments** — list, add, remove, reorder
|
||||
- **Analyze scores** — metadata, diff, statistics
|
||||
- **Interactive REPL** with undo/redo session management
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- [MuseScore 4](https://musescore.org/en/download) installed
|
||||
- macOS, Linux, or Windows
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Interactive REPL
|
||||
cli-anything-musescore
|
||||
|
||||
# One-shot commands with JSON output
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore --json parts list -i score.mscz
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Commands | Description |
|
||||
|-------|----------|-------------|
|
||||
| `project` | open, info, save | Score file management |
|
||||
| `transpose` | by-key, by-interval, diatonic | Transposition |
|
||||
| `parts` | list, extract, generate | Part extraction |
|
||||
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | Rendering |
|
||||
| `instruments` | list, add, remove, reorder | Instrument management |
|
||||
| `media` | probe, diff, stats | Score analysis |
|
||||
| `session` | status, undo, redo, history | Session management |
|
||||
@@ -0,0 +1,2 @@
|
||||
"""cli-anything-musescore — CLI wrapper for MuseScore 4."""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python3 -m cli_anything.musescore"""
|
||||
from cli_anything.musescore.musescore_cli import main
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Export/render pipeline via mscore backend."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
|
||||
|
||||
# ── Supported export formats ──────────────────────────────────────────
|
||||
|
||||
EXPORT_FORMATS = {
|
||||
"pdf": {"ext": ".pdf", "magic": b"%PDF-", "desc": "PDF document"},
|
||||
"png": {"ext": ".png", "magic": b"\x89PNG", "desc": "PNG image (per page)"},
|
||||
"svg": {"ext": ".svg", "magic": None, "desc": "SVG vector (per page)"},
|
||||
"mp3": {"ext": ".mp3", "magic": None, "desc": "MP3 audio"},
|
||||
"flac": {"ext": ".flac", "magic": b"fLaC", "desc": "FLAC audio"},
|
||||
"wav": {"ext": ".wav", "magic": b"RIFF", "desc": "WAV audio"},
|
||||
"midi": {"ext": ".mid", "magic": b"MThd", "desc": "MIDI file"},
|
||||
"musicxml": {"ext": ".musicxml", "magic": None, "desc": "MusicXML"},
|
||||
"mscz": {"ext": ".mscz", "magic": b"PK", "desc": "MuseScore file"},
|
||||
"braille": {"ext": ".brf", "magic": None, "desc": "Braille music notation"},
|
||||
}
|
||||
|
||||
|
||||
def export_score(input_path: str, output_path: str, *,
|
||||
fmt: str | None = None,
|
||||
dpi: int | None = None,
|
||||
bitrate: int | None = None,
|
||||
trim: int | None = None,
|
||||
style: str | None = None,
|
||||
sound_profile: str | None = None,
|
||||
export_parts: bool = False) -> dict:
|
||||
"""Export a score to the specified format.
|
||||
|
||||
Format is auto-detected from output_path extension, or can be
|
||||
specified explicitly via fmt.
|
||||
|
||||
Returns:
|
||||
Dict with export result info.
|
||||
"""
|
||||
if not os.path.isfile(input_path):
|
||||
raise FileNotFoundError(f"Score file not found: {input_path}")
|
||||
|
||||
# Determine format
|
||||
if fmt is None:
|
||||
ext = Path(output_path).suffix.lower()
|
||||
fmt = _ext_to_format(ext)
|
||||
if fmt not in EXPORT_FORMATS:
|
||||
raise ValueError(f"Unsupported format: {fmt}. Supported: {list(EXPORT_FORMATS.keys())}")
|
||||
|
||||
# Ensure output directory exists
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run export
|
||||
result_path = backend.export_score(
|
||||
input_path, output_path,
|
||||
dpi=dpi, bitrate=bitrate, trim=trim,
|
||||
style=style, sound_profile=sound_profile,
|
||||
export_parts=export_parts,
|
||||
)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
|
||||
def batch_export(input_path: str, outputs: list[str]) -> list[dict]:
|
||||
"""Export a score to multiple formats at once via batch job.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
outputs: List of output file paths.
|
||||
|
||||
Returns:
|
||||
List of dicts with per-output results.
|
||||
"""
|
||||
if not os.path.isfile(input_path):
|
||||
raise FileNotFoundError(f"Score file not found: {input_path}")
|
||||
|
||||
job_list = [{"in": str(input_path), "out": str(o)} for o in outputs]
|
||||
result_paths = backend.batch_convert(job_list)
|
||||
|
||||
return [
|
||||
{
|
||||
"input": input_path,
|
||||
"output": str(p),
|
||||
"format": _ext_to_format(p.suffix),
|
||||
}
|
||||
for p in result_paths
|
||||
]
|
||||
|
||||
|
||||
def verify_output(path: str, expected_format: str | None = None) -> dict:
|
||||
"""Verify an exported file using magic bytes.
|
||||
|
||||
Args:
|
||||
path: Path to the output file.
|
||||
expected_format: Expected format name (e.g., "pdf", "midi").
|
||||
|
||||
Returns:
|
||||
Dict with verification results.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return {"path": path, "exists": False, "valid": False}
|
||||
|
||||
size = os.path.getsize(path)
|
||||
if size == 0:
|
||||
return {"path": path, "exists": True, "size": 0, "valid": False}
|
||||
|
||||
result = {
|
||||
"path": path,
|
||||
"exists": True,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
# Determine expected format from extension if not specified
|
||||
if expected_format is None:
|
||||
expected_format = _ext_to_format(Path(path).suffix)
|
||||
|
||||
fmt_info = EXPORT_FORMATS.get(expected_format)
|
||||
if fmt_info and fmt_info["magic"]:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(max(len(fmt_info["magic"]), 5))
|
||||
|
||||
magic = fmt_info["magic"]
|
||||
# Special handling for MP3 (can start with ID3 tag or sync bytes)
|
||||
if expected_format == "mp3":
|
||||
result["valid"] = (
|
||||
header[:2] == b"\xff\xfb"
|
||||
or header[:3] == b"ID3"
|
||||
)
|
||||
else:
|
||||
result["valid"] = header[:len(magic)] == magic
|
||||
else:
|
||||
# No magic bytes to check; just verify non-empty
|
||||
result["valid"] = size > 0
|
||||
|
||||
result["format"] = expected_format
|
||||
return result
|
||||
|
||||
|
||||
def _ext_to_format(ext: str) -> str:
|
||||
"""Map file extension to format name."""
|
||||
ext = ext.lower().lstrip(".")
|
||||
mapping = {
|
||||
"pdf": "pdf",
|
||||
"png": "png",
|
||||
"svg": "svg",
|
||||
"mp3": "mp3",
|
||||
"flac": "flac",
|
||||
"wav": "wav",
|
||||
"mid": "midi",
|
||||
"midi": "midi",
|
||||
"musicxml": "musicxml",
|
||||
"xml": "musicxml",
|
||||
"mscz": "mscz",
|
||||
"brf": "braille",
|
||||
}
|
||||
return mapping.get(ext, ext)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Instrument management — list, add, remove, reorder.
|
||||
|
||||
For listing, uses mscore --score-meta. For add/remove/reorder,
|
||||
manipulates the MSCX XML directly.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
|
||||
def list_instruments(path: str) -> list[dict]:
|
||||
"""List instruments in a score.
|
||||
|
||||
Tries mscore --score-meta first, falls back to XML parsing.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
# Try mscore metadata
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
parts = meta.get("parts", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": p.get("name", f"Instrument {i+1}"),
|
||||
"instrumentId": p.get("instrumentId", ""),
|
||||
"program": p.get("program", 0),
|
||||
}
|
||||
for i, p in enumerate(parts)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: XML parsing
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
instruments = xml_utils.get_instruments(tree)
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": inst.get("name") or inst.get("part_name", f"Instrument {i+1}"),
|
||||
"instrumentId": inst.get("id", ""),
|
||||
}
|
||||
for i, inst in enumerate(instruments)
|
||||
]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list instruments: {e}")
|
||||
|
||||
|
||||
def add_instrument(path: str, output_path: str, instrument_id: str,
|
||||
name: str) -> dict:
|
||||
"""Add an instrument to a .mscz score via MSCX XML manipulation.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
instrument_id: MuseScore instrument ID (e.g., "keyboard.piano").
|
||||
name: Display name for the instrument.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
# Find or create the Score element
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Create a new Part element
|
||||
import xml.etree.ElementTree as ET
|
||||
part = ET.SubElement(score, "Part")
|
||||
staff = ET.SubElement(part, "Staff")
|
||||
staff_id = str(len(root.findall(".//Part")) + 1)
|
||||
staff.set("id", staff_id)
|
||||
|
||||
instrument = ET.SubElement(part, "Instrument")
|
||||
instrument.set("id", instrument_id)
|
||||
long_name = ET.SubElement(instrument, "longName")
|
||||
long_name.text = name
|
||||
short_name = ET.SubElement(instrument, "shortName")
|
||||
short_name.text = name[:3]
|
||||
|
||||
# Also add a Staff element at the score level
|
||||
score_staff = ET.SubElement(score, "Staff")
|
||||
score_staff.set("id", staff_id)
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "add",
|
||||
"instrument_id": instrument_id,
|
||||
"name": name,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
|
||||
|
||||
def remove_instrument(path: str, output_path: str,
|
||||
instrument_name: str) -> dict:
|
||||
"""Remove an instrument from a .mscz score.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
instrument_name: Name of the instrument to remove (case-insensitive).
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Find the part to remove
|
||||
removed = False
|
||||
for part in score.findall("Part"):
|
||||
inst = part.find("Instrument")
|
||||
if inst is not None:
|
||||
ln = inst.find("longName")
|
||||
name = ln.text if ln is not None else ""
|
||||
if name.lower() == instrument_name.lower():
|
||||
# Get staff ID before removing
|
||||
staff_elem = part.find("Staff")
|
||||
staff_id = staff_elem.get("id") if staff_elem is not None else None
|
||||
|
||||
score.remove(part)
|
||||
|
||||
# Also remove corresponding score-level Staff
|
||||
if staff_id:
|
||||
for s in score.findall("Staff"):
|
||||
if s.get("id") == staff_id:
|
||||
score.remove(s)
|
||||
break
|
||||
|
||||
removed = True
|
||||
break
|
||||
|
||||
if not removed:
|
||||
raise ValueError(f"Instrument '{instrument_name}' not found")
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "remove",
|
||||
"instrument_name": instrument_name,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
|
||||
|
||||
def reorder_instruments(path: str, output_path: str,
|
||||
new_order: list[str]) -> dict:
|
||||
"""Reorder instruments in a .mscz score.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
new_order: List of instrument names in desired order.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Collect parts by name
|
||||
parts_by_name = {}
|
||||
for part in score.findall("Part"):
|
||||
inst = part.find("Instrument")
|
||||
if inst is not None:
|
||||
ln = inst.find("longName")
|
||||
name = ln.text if ln is not None else ""
|
||||
parts_by_name[name.lower()] = part
|
||||
|
||||
# Remove all parts
|
||||
for part in score.findall("Part"):
|
||||
score.remove(part)
|
||||
|
||||
# Re-add in new order
|
||||
for name in new_order:
|
||||
part = parts_by_name.get(name.lower())
|
||||
if part is None:
|
||||
raise ValueError(f"Instrument '{name}' not found in score")
|
||||
score.append(part)
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "reorder",
|
||||
"new_order": new_order,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Media operations — probe, diff, stats."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
|
||||
def probe_score(path: str) -> dict:
|
||||
"""Get comprehensive metadata about a score.
|
||||
|
||||
Combines mscore --score-meta with XML parsing for a rich result.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
result = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
# mscore metadata
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
result["metadata"] = meta
|
||||
except Exception:
|
||||
# XML fallback
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
result["metadata"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def diff_scores(path_a: str, path_b: str, raw: bool = False) -> dict:
|
||||
"""Diff two scores using mscore --diff.
|
||||
|
||||
Args:
|
||||
path_a: Path to first score.
|
||||
path_b: Path to second score.
|
||||
raw: If True, use --raw-diff.
|
||||
|
||||
Returns:
|
||||
Dict with diff results.
|
||||
"""
|
||||
for p in [path_a, path_b]:
|
||||
if not os.path.isfile(p):
|
||||
raise FileNotFoundError(f"Score file not found: {p}")
|
||||
|
||||
diff_data = backend.diff_scores(path_a, path_b, raw=raw)
|
||||
|
||||
return {
|
||||
"file_a": str(Path(path_a).resolve()),
|
||||
"file_b": str(Path(path_b).resolve()),
|
||||
"raw": raw,
|
||||
"diff": diff_data,
|
||||
}
|
||||
|
||||
|
||||
def score_stats(path: str) -> dict:
|
||||
"""Compute statistics about a score from XML analysis.
|
||||
|
||||
Returns note count, measure count, instrument count,
|
||||
key signature, time signature, etc.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
result = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
key_int = xml_utils.get_key_signature(tree)
|
||||
result["stats"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
"instruments": len(xml_utils.get_instruments(tree)),
|
||||
"key_signature": key_int,
|
||||
"key_name": xml_utils.key_int_to_name(key_int) if key_int is not None else None,
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Part extraction and management."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
|
||||
|
||||
def list_parts(path: str) -> list[dict]:
|
||||
"""List all parts in a score.
|
||||
|
||||
Returns:
|
||||
List of dicts with part name, instrumentId, etc.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
# Try score-meta first (lighter)
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
parts = meta.get("parts", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": p.get("name", f"Part {i+1}"),
|
||||
"instrumentId": p.get("instrumentId", ""),
|
||||
"program": p.get("program", 0),
|
||||
"lyricCount": p.get("lyricCount", 0),
|
||||
"harmonyCount": p.get("harmonyCount", 0),
|
||||
}
|
||||
for i, p in enumerate(parts)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: score-parts (parts = list of names, partsMeta = list of dicts)
|
||||
try:
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_meta = parts_data.get("partsMeta", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": part_names[i] if i < len(part_names) else f"Part {i+1}",
|
||||
"id": part_meta[i].get("id", "") if i < len(part_meta) else "",
|
||||
}
|
||||
for i in range(max(len(part_names), len(part_meta)))
|
||||
]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list parts: {e}")
|
||||
|
||||
|
||||
def extract_part(path: str, part_name: str, output_path: str) -> dict:
|
||||
"""Extract a single part from a score.
|
||||
|
||||
Uses --score-parts to get base64-encoded .mscz data for each part,
|
||||
then writes the matching part to the output file.
|
||||
|
||||
Args:
|
||||
path: Path to the input score.
|
||||
part_name: Name of the part to extract.
|
||||
output_path: Path to write the extracted part.
|
||||
|
||||
Returns:
|
||||
Dict with extraction result info.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_bins = parts_data.get("partsBin", [])
|
||||
|
||||
# Find matching part index (case-insensitive)
|
||||
match_idx = None
|
||||
for i, name in enumerate(part_names):
|
||||
if name.lower() == part_name.lower():
|
||||
match_idx = i
|
||||
break
|
||||
|
||||
if match_idx is None:
|
||||
raise ValueError(
|
||||
f"Part '{part_name}' not found. Available parts: {part_names}"
|
||||
)
|
||||
|
||||
if match_idx >= len(part_bins):
|
||||
raise RuntimeError(f"No binary data for part '{part_name}'")
|
||||
|
||||
part_data = part_bins[match_idx]
|
||||
if not part_data:
|
||||
raise RuntimeError(f"No data for part '{part_name}'")
|
||||
|
||||
decoded = base64.b64decode(part_data)
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(decoded)
|
||||
|
||||
return {
|
||||
"part_name": part_names[match_idx],
|
||||
"output": str(Path(output_path).resolve()),
|
||||
"size_bytes": len(decoded),
|
||||
}
|
||||
|
||||
|
||||
def generate_all_parts(path: str, output_dir: str) -> list[dict]:
|
||||
"""Extract all parts from a score into separate files.
|
||||
|
||||
Args:
|
||||
path: Path to the input score.
|
||||
output_dir: Directory to write part files into.
|
||||
|
||||
Returns:
|
||||
List of dicts with extraction results.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_bins = parts_data.get("partsBin", [])
|
||||
results = []
|
||||
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, name in enumerate(part_names):
|
||||
if i >= len(part_bins) or not part_bins[i]:
|
||||
continue
|
||||
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
output_path = os.path.join(output_dir, f"{safe_name}.mscz")
|
||||
|
||||
decoded = base64.b64decode(part_bins[i])
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(decoded)
|
||||
|
||||
results.append({
|
||||
"part_name": name,
|
||||
"output": output_path,
|
||||
"size_bytes": len(decoded),
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Project management — create, open, save, info."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
|
||||
def open_project(path: str) -> dict:
|
||||
"""Open a score file and return project data.
|
||||
|
||||
Supports .mscz, .mxl, .musicxml, .mid formats.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
fmt = xml_utils.detect_format(path)
|
||||
project = {
|
||||
"name": Path(path).stem,
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
# Try to get metadata from mscore
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
project["metadata"] = meta
|
||||
if meta.get("title"):
|
||||
project["name"] = meta["title"]
|
||||
except Exception:
|
||||
# Fall back to XML parsing for metadata
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
title = xml_utils.get_score_title(tree)
|
||||
if title:
|
||||
project["name"] = title
|
||||
project["metadata"] = {
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def save_project(project: dict, path: str | None = None) -> str:
|
||||
"""Save a project. Currently just returns the path (scores are
|
||||
saved via mscore export, not by rewriting the file directly)."""
|
||||
save_path = path or project.get("path")
|
||||
if not save_path:
|
||||
raise RuntimeError("No save path specified.")
|
||||
return str(save_path)
|
||||
|
||||
|
||||
def project_info(path: str) -> dict:
|
||||
"""Get comprehensive info about a score file."""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
info = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
# Try mscore --score-meta first (most complete)
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
info["metadata"] = meta
|
||||
return info
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to XML parsing
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
info["metadata"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"key_name": _key_sig_name(xml_utils.get_key_signature(tree)),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
info["error"] = f"Could not parse score: {e}"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _key_sig_name(key_int: int | None) -> str | None:
|
||||
if key_int is None:
|
||||
return None
|
||||
try:
|
||||
return xml_utils.key_int_to_name(key_int)
|
||||
except ValueError:
|
||||
return f"keysig={key_int}"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Session management with undo/redo and JSON persistence.
|
||||
|
||||
Maintains in-memory state for the currently open project, with
|
||||
undo/redo stacks and safe file locking for concurrent access.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Stateful session for the MuseScore CLI."""
|
||||
|
||||
project_path: str | None = None
|
||||
project_data: dict | None = None
|
||||
modified: bool = False
|
||||
undo_stack: list[dict] = field(default_factory=list)
|
||||
redo_stack: list[dict] = field(default_factory=list)
|
||||
history: list[str] = field(default_factory=list)
|
||||
|
||||
def has_project(self) -> bool:
|
||||
return self.project_data is not None
|
||||
|
||||
def get_project(self) -> dict:
|
||||
if self.project_data is None:
|
||||
raise RuntimeError("No project is open. Use 'project open' first.")
|
||||
return self.project_data
|
||||
|
||||
def set_project(self, data: dict, path: str | None = None):
|
||||
self.project_data = data
|
||||
self.project_path = path
|
||||
self.modified = False
|
||||
self.undo_stack.clear()
|
||||
self.redo_stack.clear()
|
||||
self.history.clear()
|
||||
|
||||
def is_modified(self) -> bool:
|
||||
return self.modified
|
||||
|
||||
def snapshot(self, description: str):
|
||||
"""Save current state to undo stack before a modification."""
|
||||
if self.project_data is not None:
|
||||
self.undo_stack.append({
|
||||
"description": description,
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
})
|
||||
self.redo_stack.clear()
|
||||
self.history.append(description)
|
||||
self.modified = True
|
||||
|
||||
def undo(self) -> str:
|
||||
"""Undo the last operation."""
|
||||
if not self.undo_stack:
|
||||
raise RuntimeError("Nothing to undo.")
|
||||
entry = self.undo_stack.pop()
|
||||
self.redo_stack.append({
|
||||
"description": entry["description"],
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
})
|
||||
self.project_data = entry["data"]
|
||||
self.modified = True
|
||||
return entry["description"]
|
||||
|
||||
def redo(self) -> str:
|
||||
"""Redo the last undone operation."""
|
||||
if not self.redo_stack:
|
||||
raise RuntimeError("Nothing to redo.")
|
||||
entry = self.redo_stack.pop()
|
||||
self.undo_stack.append({
|
||||
"description": entry["description"],
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
})
|
||||
self.project_data = entry["data"]
|
||||
self.modified = True
|
||||
return entry["description"]
|
||||
|
||||
def list_history(self) -> list[str]:
|
||||
return list(self.history)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"project_path": self.project_path or "(none)",
|
||||
"modified": self.modified,
|
||||
"undo_depth": len(self.undo_stack),
|
||||
"redo_depth": len(self.redo_stack),
|
||||
"history_length": len(self.history),
|
||||
}
|
||||
|
||||
def save_session(self, path: str | None = None) -> str:
|
||||
"""Save session state to a JSON file with file locking."""
|
||||
save_path = path or self.project_path
|
||||
if not save_path:
|
||||
raise RuntimeError("No save path specified.")
|
||||
|
||||
session_file = str(save_path) + ".session.json"
|
||||
data = {
|
||||
"project_path": self.project_path,
|
||||
"modified": self.modified,
|
||||
"history": self.history,
|
||||
}
|
||||
_locked_save_json(session_file, data)
|
||||
return session_file
|
||||
|
||||
|
||||
def _locked_save_json(path: str, data: Any):
|
||||
"""Save JSON data with file locking (fcntl.flock).
|
||||
|
||||
Opens in r+ mode (never w which truncates before lock).
|
||||
Creates the file first if it doesn't exist.
|
||||
"""
|
||||
# Ensure file exists
|
||||
if not os.path.exists(path):
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("{}")
|
||||
|
||||
with open(path, "r+") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────────────
|
||||
|
||||
_session: Session | None = None
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
"""Get or create the global session singleton."""
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = Session()
|
||||
return _session
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Transposition logic — by key, by interval, diatonic."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils.mscx_xml import key_name_to_int
|
||||
|
||||
|
||||
# ── Interval enum mapping (from MuseScore source) ────────────────────
|
||||
# The transposeInterval field is an index into this enum, NOT semitones.
|
||||
# Index: (name, semitones)
|
||||
INTERVAL_ENUM = [
|
||||
("Perfect Unison", 0),
|
||||
("Minor Second", 1),
|
||||
("Major Second", 2),
|
||||
("Minor Third", 3),
|
||||
("Major Third", 4),
|
||||
("Perfect Fourth", 5),
|
||||
("Augmented Fourth", 6),
|
||||
("Perfect Fifth", 7),
|
||||
("Minor Sixth", 8),
|
||||
("Major Sixth", 9),
|
||||
("Minor Seventh", 10),
|
||||
("Major Seventh", 11),
|
||||
("Perfect Octave", 12),
|
||||
("Minor Ninth", 13),
|
||||
("Major Ninth", 14),
|
||||
("Minor Tenth", 15),
|
||||
("Major Tenth", 16),
|
||||
("Perfect Eleventh", 17),
|
||||
("Augmented Eleventh", 18),
|
||||
("Perfect Twelfth", 19),
|
||||
("Minor Thirteenth", 20),
|
||||
("Major Thirteenth", 21),
|
||||
("Minor Fourteenth", 22),
|
||||
("Major Fourteenth", 23),
|
||||
("Perfect Fifteenth", 24),
|
||||
("Double Augmented Unison", 25),
|
||||
]
|
||||
|
||||
# Semitone → best-fit interval index (first match)
|
||||
_SEMITONES_TO_INTERVAL = {}
|
||||
for _idx, (_name, _semi) in enumerate(INTERVAL_ENUM):
|
||||
if _semi not in _SEMITONES_TO_INTERVAL:
|
||||
_SEMITONES_TO_INTERVAL[_semi] = _idx
|
||||
|
||||
|
||||
def semitones_to_interval_index(semitones: int) -> int:
|
||||
"""Convert a semitone count to the mscore transposeInterval index.
|
||||
|
||||
Args:
|
||||
semitones: Number of semitones (0-24).
|
||||
|
||||
Returns:
|
||||
Index into the MuseScore interval enum.
|
||||
"""
|
||||
abs_semi = abs(semitones) % 25
|
||||
if abs_semi in _SEMITONES_TO_INTERVAL:
|
||||
return _SEMITONES_TO_INTERVAL[abs_semi]
|
||||
raise ValueError(f"No interval mapping for {semitones} semitones")
|
||||
|
||||
|
||||
def transpose_by_key(input_path: str, output_path: str, *,
|
||||
target_key: str,
|
||||
direction: str = "closest",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score to a target key.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
output_path: Path to output score.
|
||||
target_key: Key name (e.g., "C major", "Db", "Am").
|
||||
direction: "up", "down", or "closest".
|
||||
transpose_key_signatures: Whether to transpose key signatures.
|
||||
transpose_chord_names: Whether to transpose chord names.
|
||||
use_double_sharps_flats: Whether to use double sharps/flats.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
key_int = key_name_to_int(target_key)
|
||||
|
||||
opts = {
|
||||
"mode": "to_key",
|
||||
"direction": direction,
|
||||
"targetKey": key_int,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "to_key",
|
||||
"target_key": target_key,
|
||||
"target_key_int": key_int,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def transpose_by_interval(input_path: str, output_path: str, *,
|
||||
semitones: int | None = None,
|
||||
interval_index: int | None = None,
|
||||
direction: str = "up",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score by a chromatic interval.
|
||||
|
||||
Specify either semitones or interval_index (not both).
|
||||
|
||||
Args:
|
||||
semitones: Number of semitones to transpose.
|
||||
interval_index: Direct mscore interval enum index (0-25).
|
||||
direction: "up" or "down".
|
||||
"""
|
||||
if semitones is not None and interval_index is not None:
|
||||
raise ValueError("Specify either semitones or interval_index, not both.")
|
||||
if semitones is None and interval_index is None:
|
||||
raise ValueError("Must specify either semitones or interval_index.")
|
||||
|
||||
if semitones is not None:
|
||||
if semitones < 0:
|
||||
direction = "down"
|
||||
semitones = abs(semitones)
|
||||
idx = semitones_to_interval_index(semitones)
|
||||
else:
|
||||
idx = interval_index
|
||||
|
||||
opts = {
|
||||
"mode": "by_interval",
|
||||
"direction": direction,
|
||||
"transposeInterval": idx,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "by_interval",
|
||||
"interval_index": idx,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def transpose_diatonic(input_path: str, output_path: str, *,
|
||||
steps: int,
|
||||
direction: str = "up",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score diatonically by a number of steps.
|
||||
|
||||
Args:
|
||||
steps: Number of diatonic steps.
|
||||
direction: "up" or "down".
|
||||
"""
|
||||
if steps < 0:
|
||||
direction = "down"
|
||||
steps = abs(steps)
|
||||
|
||||
opts = {
|
||||
"mode": "diatonically",
|
||||
"direction": direction,
|
||||
"transposeInterval": steps,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "diatonically",
|
||||
"steps": steps,
|
||||
"direction": direction,
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MuseScore CLI — A stateful command-line interface for music notation.
|
||||
|
||||
This CLI wraps MuseScore 4's mscore backend, providing transposition,
|
||||
export (PDF/audio/MIDI), part extraction, instrument management, and
|
||||
score analysis from the command line.
|
||||
|
||||
Usage:
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore # Enter interactive REPL
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
from cli_anything.musescore.core.session import Session, get_session
|
||||
from cli_anything.musescore.core import project as proj_mod
|
||||
from cli_anything.musescore.core import transpose as trans_mod
|
||||
from cli_anything.musescore.core import parts as parts_mod
|
||||
from cli_anything.musescore.core import export as export_mod
|
||||
from cli_anything.musescore.core import instruments as inst_mod
|
||||
from cli_anything.musescore.core import media as media_mod
|
||||
|
||||
_json_output = False
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def output(data, message: str = ""):
|
||||
"""Output data as JSON or human-readable."""
|
||||
if _json_output:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if message:
|
||||
click.echo(message)
|
||||
if isinstance(data, dict):
|
||||
_print_dict(data)
|
||||
elif isinstance(data, list):
|
||||
_print_list(data)
|
||||
else:
|
||||
click.echo(str(data))
|
||||
|
||||
|
||||
def _print_dict(d: dict, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_dict(v, indent + 1)
|
||||
elif isinstance(v, list):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_list(v, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}{k}: {v}")
|
||||
|
||||
|
||||
def _print_list(items: list, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for i, item in enumerate(items):
|
||||
if isinstance(item, dict):
|
||||
click.echo(f"{prefix}[{i}]")
|
||||
_print_dict(item, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}- {item}")
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
"""Decorator for consistent error handling across commands."""
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except FileNotFoundError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except (ValueError, IndexError, RuntimeError) as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
wrapper.__name__ = func.__name__
|
||||
wrapper.__doc__ = func.__doc__
|
||||
return wrapper
|
||||
|
||||
|
||||
# ── Main CLI Group ────────────────────────────────────────────────────
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--project", "project_path", type=str, default=None,
|
||||
help="Path to score file to open")
|
||||
@click.pass_context
|
||||
def cli(ctx, use_json, project_path):
|
||||
"""MuseScore CLI — Music notation from the command line.
|
||||
|
||||
Run without a subcommand to enter interactive REPL mode.
|
||||
"""
|
||||
global _json_output
|
||||
_json_output = use_json
|
||||
|
||||
if project_path:
|
||||
sess = get_session()
|
||||
if not sess.has_project():
|
||||
proj = proj_mod.open_project(project_path)
|
||||
sess.set_project(proj, project_path)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── Project Commands ──────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def project():
|
||||
"""Project management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@project.command("open")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def project_open(path):
|
||||
"""Open a score file."""
|
||||
proj = proj_mod.open_project(path)
|
||||
sess = get_session()
|
||||
sess.set_project(proj, path)
|
||||
output(proj, f"Opened: {path}")
|
||||
|
||||
|
||||
@project.command("info")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def project_info(path):
|
||||
"""Show score information."""
|
||||
info = proj_mod.project_info(path)
|
||||
output(info)
|
||||
|
||||
|
||||
@project.command("save")
|
||||
@click.option("-o", "--output", "path", default=None, help="Save path")
|
||||
@handle_error
|
||||
def project_save(path):
|
||||
"""Save the current project."""
|
||||
sess = get_session()
|
||||
saved = proj_mod.save_project(sess.get_project(), path)
|
||||
output({"saved": saved}, f"Saved to: {saved}")
|
||||
|
||||
|
||||
# ── Transpose Commands ────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def transpose():
|
||||
"""Transposition commands."""
|
||||
pass
|
||||
|
||||
|
||||
@transpose.command("by-key")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--target-key", required=True, help="Target key (e.g., 'C major', 'Db', 'Am')")
|
||||
@click.option("--direction", type=click.Choice(["up", "down", "closest"]),
|
||||
default="closest", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_by_key(input_path, output_path, target_key, direction,
|
||||
no_key_sig, no_chord_names):
|
||||
"""Transpose to a target key."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Transpose to {target_key}")
|
||||
|
||||
result = trans_mod.transpose_by_key(
|
||||
input_path, output_path,
|
||||
target_key=target_key,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
output(result, f"Transposed to {target_key}")
|
||||
|
||||
|
||||
@transpose.command("by-interval")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--semitones", type=int, default=None, help="Semitones (negative = down)")
|
||||
@click.option("--interval", "interval_index", type=int, default=None,
|
||||
help="MuseScore interval index (0-25)")
|
||||
@click.option("--direction", type=click.Choice(["up", "down"]),
|
||||
default="up", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_by_interval(input_path, output_path, semitones, interval_index,
|
||||
direction, no_key_sig, no_chord_names):
|
||||
"""Transpose by a chromatic interval."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Transpose by interval")
|
||||
|
||||
result = trans_mod.transpose_by_interval(
|
||||
input_path, output_path,
|
||||
semitones=semitones,
|
||||
interval_index=interval_index,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
output(result, "Transposed by interval")
|
||||
|
||||
|
||||
@transpose.command("diatonic")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--steps", type=int, required=True, help="Diatonic steps (negative = down)")
|
||||
@click.option("--direction", type=click.Choice(["up", "down"]),
|
||||
default="up", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_diatonic(input_path, output_path, steps, direction,
|
||||
no_key_sig, no_chord_names):
|
||||
"""Transpose diatonically."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Diatonic transpose by {steps}")
|
||||
|
||||
result = trans_mod.transpose_diatonic(
|
||||
input_path, output_path,
|
||||
steps=steps,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
output(result, f"Diatonic transpose by {steps} steps")
|
||||
|
||||
|
||||
# ── Parts Commands ────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def parts():
|
||||
"""Part extraction and management."""
|
||||
pass
|
||||
|
||||
|
||||
@parts.command("list")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def parts_list(path):
|
||||
"""List all parts in a score."""
|
||||
result = parts_mod.list_parts(path)
|
||||
output(result, "Parts:")
|
||||
|
||||
|
||||
@parts.command("extract")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output file path")
|
||||
@click.option("--part", "part_name", required=True, help="Part name to extract")
|
||||
@handle_error
|
||||
def parts_extract(path, output_path, part_name):
|
||||
"""Extract a single part from a score."""
|
||||
result = parts_mod.extract_part(path, part_name, output_path)
|
||||
output(result, f"Extracted part: {part_name}")
|
||||
|
||||
|
||||
@parts.command("generate")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@click.option("-d", "--output-dir", required=True, help="Output directory")
|
||||
@handle_error
|
||||
def parts_generate(path, output_dir):
|
||||
"""Generate all parts as separate files."""
|
||||
result = parts_mod.generate_all_parts(path, output_dir)
|
||||
output(result, f"Generated {len(result)} parts")
|
||||
|
||||
|
||||
# ── Export Commands ───────────────────────────────────────────────────
|
||||
|
||||
@cli.group("export")
|
||||
def export_group():
|
||||
"""Export/render commands."""
|
||||
pass
|
||||
|
||||
|
||||
def _make_export_cmd(fmt_name, description):
|
||||
"""Factory for format-specific export commands."""
|
||||
@export_group.command(fmt_name)
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output file")
|
||||
@click.option("--dpi", type=int, default=None, help="DPI for PNG export")
|
||||
@click.option("--bitrate", type=int, default=None, help="Bitrate for MP3 (kbps)")
|
||||
@click.option("--trim", type=int, default=None, help="Trim margin for PNG/SVG")
|
||||
@click.option("--style", type=str, default=None, help="Style file (.mss)")
|
||||
@click.option("--sound-profile", type=str, default=None,
|
||||
help="Audio profile (MuseScore Basic or Muse Sounds)")
|
||||
@handle_error
|
||||
def export_cmd(input_path, output_path, dpi, bitrate, trim, style, sound_profile):
|
||||
result = export_mod.export_score(
|
||||
input_path, output_path, fmt=fmt_name,
|
||||
dpi=dpi, bitrate=bitrate, trim=trim,
|
||||
style=style, sound_profile=sound_profile,
|
||||
)
|
||||
output(result, f"Exported {fmt_name}: {output_path}")
|
||||
|
||||
export_cmd.__doc__ = description
|
||||
return export_cmd
|
||||
|
||||
|
||||
# Create format-specific commands
|
||||
_make_export_cmd("pdf", "Export as PDF document")
|
||||
_make_export_cmd("png", "Export as PNG images (one per page)")
|
||||
_make_export_cmd("svg", "Export as SVG vector graphics")
|
||||
_make_export_cmd("mp3", "Export as MP3 audio")
|
||||
_make_export_cmd("flac", "Export as FLAC audio")
|
||||
_make_export_cmd("wav", "Export as WAV audio")
|
||||
_make_export_cmd("midi", "Export as MIDI file")
|
||||
_make_export_cmd("musicxml", "Export as MusicXML")
|
||||
_make_export_cmd("braille", "Export as Braille music notation")
|
||||
|
||||
|
||||
@export_group.command("batch")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "outputs", multiple=True, required=True,
|
||||
help="Output files (specify multiple)")
|
||||
@handle_error
|
||||
def export_batch(input_path, outputs):
|
||||
"""Export to multiple formats at once."""
|
||||
result = export_mod.batch_export(input_path, list(outputs))
|
||||
output(result, f"Batch exported {len(result)} files")
|
||||
|
||||
|
||||
@export_group.command("verify")
|
||||
@click.argument("path")
|
||||
@click.option("--format", "fmt", default=None, help="Expected format")
|
||||
@handle_error
|
||||
def export_verify(path, fmt):
|
||||
"""Verify an exported file using magic bytes."""
|
||||
result = export_mod.verify_output(path, fmt)
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Instruments Commands ──────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def instruments():
|
||||
"""Instrument management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@instruments.command("list")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def instruments_list(path):
|
||||
"""List instruments in a score."""
|
||||
result = inst_mod.list_instruments(path)
|
||||
output(result, "Instruments:")
|
||||
|
||||
|
||||
@instruments.command("add")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--id", "instrument_id", required=True, help="Instrument ID")
|
||||
@click.option("--name", required=True, help="Display name")
|
||||
@handle_error
|
||||
def instruments_add(path, output_path, instrument_id, name):
|
||||
"""Add an instrument to a score."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Add instrument: {name}")
|
||||
result = inst_mod.add_instrument(path, output_path, instrument_id, name)
|
||||
output(result, f"Added instrument: {name}")
|
||||
|
||||
|
||||
@instruments.command("remove")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--name", required=True, help="Instrument name to remove")
|
||||
@handle_error
|
||||
def instruments_remove(path, output_path, name):
|
||||
"""Remove an instrument from a score."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Remove instrument: {name}")
|
||||
result = inst_mod.remove_instrument(path, output_path, name)
|
||||
output(result, f"Removed instrument: {name}")
|
||||
|
||||
|
||||
@instruments.command("reorder")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--order", required=True, help="Comma-separated instrument names")
|
||||
@handle_error
|
||||
def instruments_reorder(path, output_path, order):
|
||||
"""Reorder instruments in a score."""
|
||||
new_order = [n.strip() for n in order.split(",")]
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot("Reorder instruments")
|
||||
result = inst_mod.reorder_instruments(path, output_path, new_order)
|
||||
output(result, "Reordered instruments")
|
||||
|
||||
|
||||
# ── Media Commands ────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def media():
|
||||
"""Media analysis commands."""
|
||||
pass
|
||||
|
||||
|
||||
@media.command("probe")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def media_probe(path):
|
||||
"""Probe score metadata."""
|
||||
result = media_mod.probe_score(path)
|
||||
output(result)
|
||||
|
||||
|
||||
@media.command("diff")
|
||||
@click.option("--reference", required=True, help="Reference score")
|
||||
@click.option("--compare", required=True, help="Comparison score")
|
||||
@click.option("--raw", is_flag=True, help="Use raw diff format")
|
||||
@handle_error
|
||||
def media_diff(reference, compare, raw):
|
||||
"""Diff two scores."""
|
||||
result = media_mod.diff_scores(reference, compare, raw=raw)
|
||||
output(result)
|
||||
|
||||
|
||||
@media.command("stats")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def media_stats(path):
|
||||
"""Show score statistics."""
|
||||
result = media_mod.score_stats(path)
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Session Commands ──────────────────────────────────────────────────
|
||||
|
||||
@cli.group("session")
|
||||
def session_group():
|
||||
"""Session management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@session_group.command("status")
|
||||
@handle_error
|
||||
def session_status():
|
||||
"""Show session status."""
|
||||
sess = get_session()
|
||||
output(sess.status())
|
||||
|
||||
|
||||
@session_group.command("undo")
|
||||
@handle_error
|
||||
def session_undo():
|
||||
"""Undo the last operation."""
|
||||
sess = get_session()
|
||||
desc = sess.undo()
|
||||
output({"undone": desc}, f"Undone: {desc}")
|
||||
|
||||
|
||||
@session_group.command("redo")
|
||||
@handle_error
|
||||
def session_redo():
|
||||
"""Redo the last undone operation."""
|
||||
sess = get_session()
|
||||
desc = sess.redo()
|
||||
output({"redone": desc}, f"Redone: {desc}")
|
||||
|
||||
|
||||
@session_group.command("history")
|
||||
@handle_error
|
||||
def session_history():
|
||||
"""Show undo history."""
|
||||
sess = get_session()
|
||||
history = sess.list_history()
|
||||
output(history, "History:")
|
||||
|
||||
|
||||
# ── REPL ──────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@handle_error
|
||||
def repl():
|
||||
"""Start interactive REPL session."""
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
from cli_anything.musescore.utils.repl_skin import ReplSkin
|
||||
skin = ReplSkin("musescore", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
while True:
|
||||
try:
|
||||
sess = get_session()
|
||||
proj_name = ""
|
||||
modified = False
|
||||
if sess.has_project():
|
||||
proj = sess.get_project()
|
||||
proj_name = proj.get("name", "")
|
||||
modified = sess.is_modified()
|
||||
|
||||
line = skin.get_input(pt_session, project_name=proj_name,
|
||||
modified=modified)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as e:
|
||||
skin.error(f"Usage error: {e}")
|
||||
except Exception as e:
|
||||
skin.error(str(e))
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def _repl_help(skin=None):
|
||||
commands = {
|
||||
"project open|info|save": "Project management",
|
||||
"transpose by-key|by-interval|diatonic": "Transposition",
|
||||
"parts list|extract|generate": "Part extraction",
|
||||
"export pdf|png|svg|mp3|wav|midi|...": "Export/render",
|
||||
"instruments list|add|remove|reorder": "Instrument management",
|
||||
"media probe|diff|stats": "Score analysis",
|
||||
"session status|undo|redo|history": "Session management",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
if skin is not None:
|
||||
skin.help(commands)
|
||||
else:
|
||||
click.echo("\nCommands:")
|
||||
for cmd, desc in commands.items():
|
||||
click.echo(f" {cmd:50s} {desc}")
|
||||
click.echo()
|
||||
|
||||
|
||||
# ── Entry Point ───────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: musescore
|
||||
display_name: MuseScore
|
||||
version: 1.0.0
|
||||
description: CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments
|
||||
requires: MuseScore 4 (musescore.org)
|
||||
entry_point: cli-anything-musescore
|
||||
category: music
|
||||
---
|
||||
|
||||
# MuseScore CLI Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Wraps MuseScore 4's `mscore` backend for music notation tasks: transposition, export to multiple formats (PDF, PNG, MP3, MIDI, MusicXML, Braille), part extraction, instrument management, and score analysis.
|
||||
|
||||
## Commands
|
||||
|
||||
### Project Management
|
||||
```bash
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json project open -i score.mscz
|
||||
```
|
||||
|
||||
### Transposition
|
||||
```bash
|
||||
# Transpose to a target key
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major" --direction closest
|
||||
|
||||
# Transpose by semitones
|
||||
cli-anything-musescore --json transpose by-interval -i score.mscz -o out.mscz --semitones 3
|
||||
|
||||
# Diatonic transposition
|
||||
cli-anything-musescore --json transpose diatonic -i score.mscz -o out.mscz --steps 2
|
||||
```
|
||||
|
||||
### Part Extraction
|
||||
```bash
|
||||
cli-anything-musescore --json parts list -i score.mscz
|
||||
cli-anything-musescore --json parts extract -i score.mscz -o piano.mscz --part "Piano"
|
||||
cli-anything-musescore --json parts generate -i score.mscz -d ./parts/
|
||||
```
|
||||
|
||||
### Export
|
||||
```bash
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore --json export mp3 -i score.mscz -o score.mp3 --bitrate 192
|
||||
cli-anything-musescore --json export png -i score.mscz -o score.png --dpi 300
|
||||
cli-anything-musescore --json export midi -i score.mscz -o score.mid
|
||||
cli-anything-musescore --json export musicxml -i score.mscz -o score.musicxml
|
||||
cli-anything-musescore --json export braille -i score.mscz -o score.brf
|
||||
cli-anything-musescore --json export batch -i score.mscz -o score.pdf -o score.mid
|
||||
```
|
||||
|
||||
### Instrument Management
|
||||
```bash
|
||||
cli-anything-musescore --json instruments list -i score.mscz
|
||||
cli-anything-musescore --json instruments add -i score.mscz -o out.mscz --id keyboard.piano --name "Piano"
|
||||
cli-anything-musescore --json instruments remove -i score.mscz -o out.mscz --name "Violin"
|
||||
```
|
||||
|
||||
### Score Analysis
|
||||
```bash
|
||||
cli-anything-musescore --json media probe -i score.mscz
|
||||
cli-anything-musescore --json media stats -i score.mscz
|
||||
cli-anything-musescore --json media diff --reference a.mscz --compare b.mscz
|
||||
```
|
||||
|
||||
### Session
|
||||
```bash
|
||||
cli-anything-musescore --json session status
|
||||
cli-anything-musescore --json session undo
|
||||
cli-anything-musescore --json session redo
|
||||
cli-anything-musescore --json session history
|
||||
```
|
||||
|
||||
## Supported Input Formats
|
||||
- `.mscz` (MuseScore native)
|
||||
- `.mxl` (compressed MusicXML)
|
||||
- `.musicxml` / `.xml` (MusicXML)
|
||||
- `.mid` / `.midi` (MIDI)
|
||||
|
||||
## Key Names for Transposition
|
||||
Major: Cb, Gb, Db, Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#
|
||||
Minor: Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#, G#, D#, A#
|
||||
|
||||
Accepted formats: "C", "C major", "Am", "A minor", "Db major", "F# minor"
|
||||
|
||||
## Agent Guidance
|
||||
- Always use `--json` flag for machine-readable output
|
||||
- Verify exports with `export verify` after rendering
|
||||
- Use `media probe` to inspect an unknown score before operating on it
|
||||
- Transposition requires both `-i` (input) and `-o` (output)
|
||||
- Part names are case-insensitive for `parts extract`
|
||||
@@ -0,0 +1,130 @@
|
||||
# TEST.md — Test Plan and Results
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests (`test_core.py`)
|
||||
|
||||
| # | Test | Module | Description |
|
||||
|---|------|--------|-------------|
|
||||
| 1 | TestSession::test_create_session | session | Create empty session |
|
||||
| 2 | TestSession::test_set_project | session | Set project data and path |
|
||||
| 3 | TestSession::test_get_project_raises | session | Error when no project open |
|
||||
| 4 | TestSession::test_undo_redo | session | Undo/redo state transitions |
|
||||
| 5 | TestSession::test_undo_empty_raises | session | Error on empty undo |
|
||||
| 6 | TestSession::test_redo_empty_raises | session | Error on empty redo |
|
||||
| 7 | TestSession::test_snapshot_clears_redo | session | New edit clears redo |
|
||||
| 8 | TestSession::test_history | session | History tracking |
|
||||
| 9 | TestSession::test_status | session | Status dict format |
|
||||
| 10 | TestSession::test_modified_flag | session | Modified flag tracking |
|
||||
| 11 | TestSession::test_save_session | session | JSON persistence with locking |
|
||||
| 12 | TestKeySignature::test_major_keys | mscx_xml | Key name → int (majors) |
|
||||
| 13 | TestKeySignature::test_minor_keys | mscx_xml | Key name → int (minors) |
|
||||
| 14 | TestKeySignature::test_case_insensitive | mscx_xml | Case-insensitive lookup |
|
||||
| 15 | TestKeySignature::test_invalid_key | mscx_xml | Error on invalid key |
|
||||
| 16 | TestKeySignature::test_int_to_name | mscx_xml | Int → key name |
|
||||
| 17 | TestKeySignature::test_all_major_keys_roundtrip | mscx_xml | Full roundtrip |
|
||||
| 18 | TestTranspose::test_semitones_* | transpose | Semitone → interval mapping |
|
||||
| 19 | TestXMLParsing::test_get_key_signature | mscx_xml | XML key sig extraction |
|
||||
| 20 | TestXMLParsing::test_get_time_signature | mscx_xml | XML time sig extraction |
|
||||
| 21 | TestXMLParsing::test_get_instruments | mscx_xml | XML instrument extraction |
|
||||
| 22 | TestXMLParsing::test_get_score_title | mscx_xml | XML title extraction |
|
||||
| 23 | TestXMLParsing::test_count_measures | mscx_xml | Measure counting |
|
||||
| 24 | TestXMLParsing::test_count_notes | mscx_xml | Note counting |
|
||||
| 25 | TestXMLParsing::test_detect_format | mscx_xml | Format detection |
|
||||
| 26 | TestXMLParsing::test_mscz_roundtrip | mscx_xml | MSCZ write + read |
|
||||
| 27 | TestExportVerification::test_* | export | Magic byte verification |
|
||||
| 28 | TestMediaStats::test_score_stats | media | Synthetic MXL stats |
|
||||
|
||||
### E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
| # | Test | Requires | Description |
|
||||
|---|------|----------|-------------|
|
||||
| 1 | TestExportE2E::test_export_pdf | mscore + samples | Export PDF, verify magic |
|
||||
| 2 | TestExportE2E::test_export_midi | mscore + samples | Export MIDI, verify magic |
|
||||
| 3 | TestExportE2E::test_export_mp3 | mscore + samples | Export MP3, verify magic |
|
||||
| 4 | TestExportE2E::test_export_musicxml | mscore + samples | Export MusicXML, verify XML |
|
||||
| 5 | TestExportE2E::test_export_png | mscore + samples | Export PNG pages |
|
||||
| 6 | TestTransposeE2E::test_transpose_db_to_c | mscore + samples | Db→C key verification |
|
||||
| 7 | TestTransposeE2E::test_transpose_by_interval | mscore + samples | Interval transpose |
|
||||
| 8 | TestPartsE2E::test_list_parts | mscore + samples | Part listing |
|
||||
| 9 | TestPartsE2E::test_extract_part | mscore + samples | Part extraction |
|
||||
| 10 | TestMediaE2E::test_probe_mxl | mscore + samples | MXL metadata probe |
|
||||
| 11 | TestMediaE2E::test_probe_mscz | mscore + samples | MSCZ metadata probe |
|
||||
| 12 | TestMediaE2E::test_stats_mxl | mscore + samples | Score statistics |
|
||||
| 13 | TestCLISubprocess::test_help | none | --help flag |
|
||||
| 14 | TestCLISubprocess::test_json_project_info | mscore + samples | Subprocess JSON output |
|
||||
| 15 | TestCLISubprocess::test_json_export_pdf | mscore + samples | Subprocess PDF export |
|
||||
| 16 | TestCLISubprocess::test_json_transpose_by_key | mscore + samples | Subprocess transpose |
|
||||
| 17 | TestCLISubprocess::test_full_workflow | mscore + samples | Info→transpose→export→verify |
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
$ python3 -m pytest cli_anything/musescore/tests/ -v --tb=short
|
||||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.12.2, pytest-7.4.4
|
||||
rootdir: /Users/vickytam/Study/cli-anything/musescore/agent-harness
|
||||
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_create_session PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_set_project PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_get_project_raises_without_open PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_redo PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_empty_raises PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_redo_empty_raises PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_snapshot_clears_redo PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_history PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_status PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_modified_flag PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_save_session PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_major_keys PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_minor_keys PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_case_insensitive PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_invalid_key PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_int_to_name PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_all_major_keys_roundtrip PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_unison PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_minor_second PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_octave PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_fifth PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_interval_enum_count PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_key_signature PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_time_signature PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_instruments PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_score_title PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_measures PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_notes PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_detect_format PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_mscz_roundtrip PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_ext_to_format PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_nonexistent PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_pdf PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_midi PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_sync PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_id3 PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_png PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_empty_file PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestMediaStats::test_score_stats_from_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_pdf PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_midi PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_mp3 PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_musicxml PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_png PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_db_to_c PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_by_interval PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_list_parts PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_extract_part PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mscz PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_stats_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_project_info PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_export_pdf PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_transpose_by_key PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_full_workflow PASSED
|
||||
|
||||
============================= 56 passed in 15.10s ==============================
|
||||
```
|
||||
|
||||
**Environment**: macOS Darwin 24.1.0, Python 3.12.2, MuseScore 4.6.5
|
||||
**Date**: 2026-03-19
|
||||
**Status**: 56/56 PASSED (39 unit + 17 E2E)
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Unit tests for cli-anything-musescore core modules.
|
||||
|
||||
These tests use synthetic data and do NOT require mscore to be installed.
|
||||
They test key name resolution, session management, XML parsing, etc.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Session Tests ─────────────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.session import Session
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_create_session(self):
|
||||
s = Session()
|
||||
assert not s.has_project()
|
||||
assert s.project_data is None
|
||||
|
||||
def test_set_project(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
assert s.has_project()
|
||||
assert s.project_data["name"] == "test"
|
||||
assert s.project_path == "/tmp/test.mscz"
|
||||
|
||||
def test_get_project_raises_without_open(self):
|
||||
s = Session()
|
||||
with pytest.raises(RuntimeError, match="No project"):
|
||||
s.get_project()
|
||||
|
||||
def test_undo_redo(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "v1"})
|
||||
s.snapshot("edit 1")
|
||||
s.project_data["name"] = "v2"
|
||||
s.snapshot("edit 2")
|
||||
s.project_data["name"] = "v3"
|
||||
|
||||
# Undo edit 2
|
||||
desc = s.undo()
|
||||
assert desc == "edit 2"
|
||||
assert s.project_data["name"] == "v2"
|
||||
|
||||
# Undo edit 1
|
||||
desc = s.undo()
|
||||
assert desc == "edit 1"
|
||||
assert s.project_data["name"] == "v1"
|
||||
|
||||
# Redo edit 1
|
||||
desc = s.redo()
|
||||
assert desc == "edit 1"
|
||||
assert s.project_data["name"] == "v2"
|
||||
|
||||
def test_undo_empty_raises(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
with pytest.raises(RuntimeError, match="Nothing to undo"):
|
||||
s.undo()
|
||||
|
||||
def test_redo_empty_raises(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
with pytest.raises(RuntimeError, match="Nothing to redo"):
|
||||
s.redo()
|
||||
|
||||
def test_snapshot_clears_redo(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "v1"})
|
||||
s.snapshot("edit 1")
|
||||
s.project_data["name"] = "v2"
|
||||
s.undo()
|
||||
# New edit should clear redo stack
|
||||
s.snapshot("edit 2")
|
||||
assert len(s.redo_stack) == 0
|
||||
|
||||
def test_history(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
s.snapshot("action 1")
|
||||
s.snapshot("action 2")
|
||||
s.snapshot("action 3")
|
||||
assert s.list_history() == ["action 1", "action 2", "action 3"]
|
||||
|
||||
def test_status(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
s.snapshot("edit")
|
||||
status = s.status()
|
||||
assert status["project_path"] == "/tmp/test.mscz"
|
||||
assert status["undo_depth"] == 1
|
||||
assert status["redo_depth"] == 0
|
||||
|
||||
def test_modified_flag(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
assert not s.is_modified()
|
||||
s.snapshot("edit")
|
||||
assert s.is_modified()
|
||||
|
||||
def test_save_session(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
s.snapshot("edit")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test.mscz")
|
||||
result = s.save_session(path)
|
||||
assert os.path.isfile(result)
|
||||
with open(result) as f:
|
||||
data = json.load(f)
|
||||
assert data["history"] == ["edit"]
|
||||
|
||||
|
||||
# ── Key Signature Tests ───────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import (
|
||||
key_name_to_int, key_int_to_name, KEY_INT_TO_MAJOR,
|
||||
)
|
||||
|
||||
|
||||
class TestKeySignature:
|
||||
def test_major_keys(self):
|
||||
assert key_name_to_int("C") == 0
|
||||
assert key_name_to_int("C major") == 0
|
||||
assert key_name_to_int("G") == 1
|
||||
assert key_name_to_int("Db") == -5
|
||||
assert key_name_to_int("Db major") == -5
|
||||
assert key_name_to_int("F#") == 6
|
||||
|
||||
def test_minor_keys(self):
|
||||
assert key_name_to_int("A minor") == 0
|
||||
assert key_name_to_int("Am") == 0
|
||||
assert key_name_to_int("D minor") == -1
|
||||
assert key_name_to_int("F# minor") == 3
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert key_name_to_int("c major") == 0
|
||||
assert key_name_to_int("DB MAJOR") == -5
|
||||
assert key_name_to_int("am") == 0
|
||||
|
||||
def test_invalid_key(self):
|
||||
with pytest.raises(ValueError, match="Unrecognized key"):
|
||||
key_name_to_int("X major")
|
||||
|
||||
def test_int_to_name(self):
|
||||
assert key_int_to_name(0) == "C major"
|
||||
assert key_int_to_name(-5) == "Db major"
|
||||
assert key_int_to_name(0, minor=True) == "A minor"
|
||||
|
||||
def test_all_major_keys_roundtrip(self):
|
||||
for i, name in KEY_INT_TO_MAJOR.items():
|
||||
assert key_name_to_int(name) == i
|
||||
assert key_name_to_int(f"{name} major") == i
|
||||
|
||||
|
||||
# ── Transpose Option Building Tests ───────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.transpose import (
|
||||
semitones_to_interval_index, INTERVAL_ENUM,
|
||||
)
|
||||
|
||||
|
||||
class TestTranspose:
|
||||
def test_semitones_to_interval_unison(self):
|
||||
assert semitones_to_interval_index(0) == 0 # Perfect Unison
|
||||
|
||||
def test_semitones_to_interval_minor_second(self):
|
||||
assert semitones_to_interval_index(1) == 1 # Minor Second
|
||||
|
||||
def test_semitones_to_interval_octave(self):
|
||||
assert semitones_to_interval_index(12) == 12 # Perfect Octave
|
||||
|
||||
def test_semitones_to_interval_fifth(self):
|
||||
assert semitones_to_interval_index(7) == 7 # Perfect Fifth
|
||||
|
||||
def test_interval_enum_count(self):
|
||||
assert len(INTERVAL_ENUM) == 26
|
||||
|
||||
|
||||
# ── XML Parsing Tests ─────────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import (
|
||||
get_key_signature, get_time_signature, get_instruments,
|
||||
get_score_title, count_measures, count_notes,
|
||||
detect_format, read_mscz, write_mscz,
|
||||
)
|
||||
|
||||
|
||||
class TestXMLParsing:
|
||||
def _make_musicxml(self, fifths=-5, beats="4", beat_type="4",
|
||||
title="Test Score", num_measures=4, num_notes=16):
|
||||
"""Create a synthetic MusicXML tree for testing."""
|
||||
root = ET.Element("score-partwise", version="4.0")
|
||||
# Work title
|
||||
work = ET.SubElement(root, "work")
|
||||
ET.SubElement(work, "work-title").text = title
|
||||
# Part list
|
||||
part_list = ET.SubElement(root, "part-list")
|
||||
sp = ET.SubElement(part_list, "score-part", id="P1")
|
||||
ET.SubElement(sp, "part-name").text = "Piano"
|
||||
si = ET.SubElement(sp, "score-instrument", id="P1-I1")
|
||||
ET.SubElement(si, "instrument-name").text = "Piano"
|
||||
# Part with measures
|
||||
part = ET.SubElement(root, "part", id="P1")
|
||||
for m in range(num_measures):
|
||||
measure = ET.SubElement(part, "measure", number=str(m + 1))
|
||||
if m == 0:
|
||||
attrs = ET.SubElement(measure, "attributes")
|
||||
key = ET.SubElement(attrs, "key")
|
||||
ET.SubElement(key, "fifths").text = str(fifths)
|
||||
time = ET.SubElement(attrs, "time")
|
||||
ET.SubElement(time, "beats").text = beats
|
||||
ET.SubElement(time, "beat-type").text = beat_type
|
||||
for n in range(num_notes // num_measures):
|
||||
note = ET.SubElement(measure, "note")
|
||||
pitch = ET.SubElement(note, "pitch")
|
||||
ET.SubElement(pitch, "step").text = "C"
|
||||
ET.SubElement(pitch, "octave").text = "4"
|
||||
return ET.ElementTree(root)
|
||||
|
||||
def test_get_key_signature(self):
|
||||
tree = self._make_musicxml(fifths=-5)
|
||||
assert get_key_signature(tree) == -5
|
||||
|
||||
def test_get_time_signature(self):
|
||||
tree = self._make_musicxml(beats="3", beat_type="4")
|
||||
assert get_time_signature(tree) == "3/4"
|
||||
|
||||
def test_get_instruments(self):
|
||||
tree = self._make_musicxml()
|
||||
instruments = get_instruments(tree)
|
||||
assert len(instruments) == 1
|
||||
assert instruments[0]["name"] == "Piano"
|
||||
|
||||
def test_get_score_title(self):
|
||||
tree = self._make_musicxml(title="My Score")
|
||||
assert get_score_title(tree) == "My Score"
|
||||
|
||||
def test_count_measures(self):
|
||||
tree = self._make_musicxml(num_measures=8)
|
||||
assert count_measures(tree) == 8
|
||||
|
||||
def test_count_notes(self):
|
||||
tree = self._make_musicxml(num_measures=4, num_notes=16)
|
||||
assert count_notes(tree) == 16
|
||||
|
||||
def test_detect_format(self):
|
||||
assert detect_format("score.mscz") == "mscz"
|
||||
assert detect_format("score.mxl") == "mxl"
|
||||
assert detect_format("score.musicxml") == "musicxml"
|
||||
assert detect_format("score.mid") == "mid"
|
||||
assert detect_format("score.txt") == "unknown"
|
||||
|
||||
def test_mscz_roundtrip(self):
|
||||
"""Test writing and reading a .mscz file."""
|
||||
tree = self._make_musicxml()
|
||||
data = {
|
||||
"mscx": tree,
|
||||
"mscx_filename": "score.mscx",
|
||||
"style": "<Style></Style>",
|
||||
"audio_settings": '{"master_gain": 1.0}',
|
||||
"view_settings": '{"zoom": 100}',
|
||||
"other_files": {},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
write_mscz(tmp_path, data)
|
||||
assert os.path.isfile(tmp_path)
|
||||
|
||||
# Verify it's a valid ZIP
|
||||
assert zipfile.is_zipfile(tmp_path)
|
||||
|
||||
# Read back
|
||||
read_data = read_mscz(tmp_path)
|
||||
assert read_data["mscx"] is not None
|
||||
assert read_data["style"] == "<Style></Style>"
|
||||
assert get_key_signature(read_data["mscx"]) == -5
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# ── Export Verification Tests ─────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.export import verify_output, _ext_to_format
|
||||
|
||||
|
||||
class TestExportVerification:
|
||||
def test_ext_to_format(self):
|
||||
assert _ext_to_format(".pdf") == "pdf"
|
||||
assert _ext_to_format(".mid") == "midi"
|
||||
assert _ext_to_format(".mp3") == "mp3"
|
||||
assert _ext_to_format(".musicxml") == "musicxml"
|
||||
|
||||
def test_verify_nonexistent(self):
|
||||
result = verify_output("/nonexistent/file.pdf")
|
||||
assert not result["exists"]
|
||||
assert not result["valid"]
|
||||
|
||||
def test_verify_pdf(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(b"%PDF-1.4 test content")
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "pdf")
|
||||
assert result["exists"]
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_midi(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
|
||||
f.write(b"MThd\x00\x00\x00\x06")
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "midi")
|
||||
assert result["exists"]
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_mp3_sync(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"\xff\xfb\x90\x00" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "mp3")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_mp3_id3(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"ID3" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "mp3")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_png(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "png")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_empty_file(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "pdf")
|
||||
assert result["exists"]
|
||||
assert not result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
# ── Media Stats Tests (synthetic) ─────────────────────────────────────
|
||||
|
||||
class TestMediaStats:
|
||||
def test_score_stats_from_mxl(self):
|
||||
"""Create a synthetic .mxl and test stats extraction."""
|
||||
tree = TestXMLParsing()._make_musicxml(
|
||||
fifths=0, num_measures=8, num_notes=32, title="Stats Test"
|
||||
)
|
||||
xml_str = ET.tostring(tree.getroot(), encoding="unicode",
|
||||
xml_declaration=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mxl", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w") as zf:
|
||||
zf.writestr("score.xml", xml_str)
|
||||
|
||||
from cli_anything.musescore.core.media import score_stats
|
||||
result = score_stats(tmp_path)
|
||||
assert result["format"] == "mxl"
|
||||
assert result["stats"]["measures"] == 8
|
||||
assert result["stats"]["notes"] == 32
|
||||
assert result["stats"]["title"] == "Stats Test"
|
||||
assert result["stats"]["key_signature"] == 0
|
||||
assert result["stats"]["key_name"] == "C major"
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
@@ -0,0 +1,364 @@
|
||||
"""End-to-end tests for cli-anything-musescore.
|
||||
|
||||
These tests require a real MuseScore 4 (mscore) installation and
|
||||
use the sample files in test-mscore/sia-snowman/.
|
||||
|
||||
Run with: pytest cli_anything/musescore/tests/test_full_e2e.py -v -s
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Test fixture paths ────────────────────────────────────────────────
|
||||
|
||||
# Walk up from this file to find the test-mscore directory
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_REPO_ROOT = _THIS_DIR.parent.parent.parent.parent.parent
|
||||
_SAMPLE_DIR = _REPO_ROOT / "test-mscore" / "sia-snowman"
|
||||
_SAMPLE_MXL = _SAMPLE_DIR / "sia-snowman-Db.mxl"
|
||||
_SAMPLE_MSCZ = _SAMPLE_DIR / "sia-snowman-C.mscz"
|
||||
_SAMPLE_MID = _SAMPLE_DIR / "sia-snowman-Db.mid"
|
||||
|
||||
|
||||
def _resolve_cli(name: str) -> list[str]:
|
||||
"""Resolve the CLI command for subprocess tests.
|
||||
|
||||
If CLI_ANYTHING_FORCE_INSTALLED is set, use the installed command.
|
||||
Otherwise, use python -m.
|
||||
"""
|
||||
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED"):
|
||||
import shutil
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return [path]
|
||||
raise RuntimeError(f"{name} not found on PATH")
|
||||
return [sys.executable, "-m", "cli_anything.musescore"]
|
||||
|
||||
|
||||
def _has_mscore() -> bool:
|
||||
"""Check if mscore is available."""
|
||||
try:
|
||||
from cli_anything.musescore.utils.musescore_backend import find_musescore
|
||||
find_musescore()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_samples() -> bool:
|
||||
"""Check if sample files exist."""
|
||||
return _SAMPLE_MXL.is_file()
|
||||
|
||||
|
||||
requires_mscore = pytest.mark.skipif(
|
||||
not _has_mscore(), reason="mscore not installed"
|
||||
)
|
||||
requires_samples = pytest.mark.skipif(
|
||||
not _has_samples(), reason="sample files not found"
|
||||
)
|
||||
|
||||
|
||||
# ── Export E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestExportE2E:
|
||||
def test_export_pdf(self):
|
||||
"""Export MXL to PDF, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="pdf")
|
||||
result = verify_output(out, "pdf")
|
||||
assert result["valid"], f"PDF verification failed: {result}"
|
||||
print(f" PDF output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_midi(self):
|
||||
"""Export MXL to MIDI, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="midi")
|
||||
result = verify_output(out, "midi")
|
||||
assert result["valid"], f"MIDI verification failed: {result}"
|
||||
print(f" MIDI output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_mp3(self):
|
||||
"""Export MXL to MP3, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="mp3", bitrate=128)
|
||||
result = verify_output(out, "mp3")
|
||||
assert result["valid"], f"MP3 verification failed: {result}"
|
||||
print(f" MP3 output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_musicxml(self):
|
||||
"""Export MSCZ to MusicXML, verify XML structure."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(str(_SAMPLE_MSCZ), out, fmt="musicxml")
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse(out)
|
||||
assert tree.getroot().tag == "score-partwise"
|
||||
print(f" MusicXML output: {out}")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_png(self):
|
||||
"""Export MXL to PNG, verify at least one page produced."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
out = os.path.join(tmpdir, "output.png")
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="png", dpi=72)
|
||||
# mscore produces output-1.png, output-2.png, etc.
|
||||
pngs = list(Path(tmpdir).glob("*.png"))
|
||||
assert len(pngs) >= 1, f"No PNG files produced in {tmpdir}"
|
||||
# Verify first PNG magic bytes
|
||||
with open(pngs[0], "rb") as f:
|
||||
header = f.read(4)
|
||||
assert header == b"\x89PNG", f"Invalid PNG header: {header}"
|
||||
print(f" PNG pages: {len(pngs)}")
|
||||
|
||||
|
||||
# ── Transpose E2E Tests ──────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestTransposeE2E:
|
||||
def test_transpose_db_to_c(self):
|
||||
"""Transpose Db major MXL to C major, verify key signature."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.transpose import transpose_by_key
|
||||
result = transpose_by_key(
|
||||
str(_SAMPLE_MXL), out,
|
||||
target_key="C major",
|
||||
direction="closest",
|
||||
)
|
||||
assert result["target_key_int"] == 0
|
||||
|
||||
# Export to MusicXML and check key signature
|
||||
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as fx:
|
||||
xml_out = fx.name
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(out, xml_out, fmt="musicxml")
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import read_score_tree, get_key_signature
|
||||
tree = read_score_tree(xml_out)
|
||||
keysig = get_key_signature(tree)
|
||||
assert keysig == 0, f"Expected keysig=0 (C major), got {keysig}"
|
||||
print(f" Transposed Db→C: keysig={keysig}")
|
||||
os.unlink(xml_out)
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_transpose_by_interval(self):
|
||||
"""Transpose by 2 semitones (major second up)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.transpose import transpose_by_interval
|
||||
result = transpose_by_interval(
|
||||
str(_SAMPLE_MXL), out,
|
||||
semitones=2,
|
||||
direction="up",
|
||||
)
|
||||
assert result["mode"] == "by_interval"
|
||||
assert os.path.isfile(out)
|
||||
print(f" Transposed by +2 semitones: {out}")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
# ── Parts E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestPartsE2E:
|
||||
def test_list_parts(self):
|
||||
"""List parts in the sample score."""
|
||||
from cli_anything.musescore.core.parts import list_parts
|
||||
parts = list_parts(str(_SAMPLE_MXL))
|
||||
assert len(parts) >= 1
|
||||
print(f" Parts: {[p['name'] for p in parts]}")
|
||||
|
||||
def test_extract_part(self):
|
||||
"""Extract the first part."""
|
||||
from cli_anything.musescore.core.parts import list_parts, extract_part
|
||||
parts = list_parts(str(_SAMPLE_MXL))
|
||||
if not parts:
|
||||
pytest.skip("No parts found")
|
||||
|
||||
first_part = parts[0]["name"]
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
result = extract_part(str(_SAMPLE_MXL), first_part, out)
|
||||
assert os.path.isfile(out)
|
||||
assert result["size_bytes"] > 0
|
||||
print(f" Extracted '{first_part}': {result['size_bytes']} bytes")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
# ── Media E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestMediaE2E:
|
||||
def test_probe_mxl(self):
|
||||
"""Probe sample MXL file."""
|
||||
from cli_anything.musescore.core.media import probe_score
|
||||
result = probe_score(str(_SAMPLE_MXL))
|
||||
assert result["format"] == "mxl"
|
||||
assert "metadata" in result
|
||||
print(f" Probe: {json.dumps(result.get('metadata', {}), indent=2, default=str)[:200]}")
|
||||
|
||||
def test_probe_mscz(self):
|
||||
"""Probe sample MSCZ file."""
|
||||
from cli_anything.musescore.core.media import probe_score
|
||||
result = probe_score(str(_SAMPLE_MSCZ))
|
||||
assert result["format"] == "mscz"
|
||||
assert "metadata" in result
|
||||
|
||||
def test_stats_mxl(self):
|
||||
"""Get stats for sample MXL file."""
|
||||
from cli_anything.musescore.core.media import score_stats
|
||||
result = score_stats(str(_SAMPLE_MXL))
|
||||
assert "stats" in result
|
||||
assert result["stats"]["measures"] > 0
|
||||
assert result["stats"]["notes"] > 0
|
||||
print(f" Stats: {result['stats']}")
|
||||
|
||||
|
||||
# ── CLI Subprocess Tests ─────────────────────────────────────────────
|
||||
|
||||
class TestCLISubprocess:
|
||||
def test_help(self):
|
||||
"""Test --help flag."""
|
||||
cmd = _resolve_cli("cli-anything-musescore") + ["--help"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0
|
||||
assert "MuseScore CLI" in result.stdout or "musescore" in result.stdout.lower()
|
||||
print(f" --help: OK ({len(result.stdout)} chars)")
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_project_info(self):
|
||||
"""Test --json project info via subprocess."""
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "project", "info", "-i", str(_SAMPLE_MXL)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "metadata" in data or "path" in data
|
||||
print(f" JSON project info: OK")
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_export_pdf(self):
|
||||
"""Test --json export pdf via subprocess."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "export", "pdf",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", out,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data.get("format") == "pdf"
|
||||
# Verify the output file
|
||||
assert os.path.isfile(out)
|
||||
with open(out, "rb") as f:
|
||||
header = f.read(5)
|
||||
assert header == b"%PDF-"
|
||||
print(f" JSON export pdf: OK")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_transpose_by_key(self):
|
||||
"""Test --json transpose by-key via subprocess."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "transpose", "by-key",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", out,
|
||||
"--target-key", "C major",
|
||||
"--direction", "closest",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data.get("target_key_int") == 0
|
||||
print(f" JSON transpose by-key: OK")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_full_workflow(self):
|
||||
"""Test a full workflow: info → transpose → export PDF → verify."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
transposed = os.path.join(tmpdir, "transposed.mscz")
|
||||
pdf_out = os.path.join(tmpdir, "output.pdf")
|
||||
|
||||
# 1. Transpose Db → C
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "transpose", "by-key",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", transposed,
|
||||
"--target-key", "C major",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, f"Transpose failed: {r.stderr}"
|
||||
|
||||
# 2. Export to PDF
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "export", "pdf",
|
||||
"-i", transposed,
|
||||
"-o", pdf_out,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, f"Export failed: {r.stderr}"
|
||||
|
||||
# 3. Verify PDF
|
||||
with open(pdf_out, "rb") as f:
|
||||
assert f.read(5) == b"%PDF-"
|
||||
|
||||
print(f" Full workflow: transpose → export → verify: OK")
|
||||
@@ -0,0 +1,349 @@
|
||||
"""MSCX/MusicXML parsing utilities.
|
||||
|
||||
Handles reading and writing .mscz (ZIP containing .mscx XML) and
|
||||
.mxl (ZIP containing MusicXML) files, plus XML inspection helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Key Signature Mapping ─────────────────────────────────────────────
|
||||
|
||||
# Integer key signatures: -7 (Cb) to +7 (C#)
|
||||
# Negative = flats, positive = sharps, 0 = C major / A minor
|
||||
KEY_INT_TO_MAJOR = {
|
||||
-7: "Cb", -6: "Gb", -5: "Db", -4: "Ab", -3: "Eb", -2: "Bb", -1: "F",
|
||||
0: "C", 1: "G", 2: "D", 3: "A", 4: "E", 5: "B", 6: "F#", 7: "C#",
|
||||
}
|
||||
|
||||
KEY_INT_TO_MINOR = {
|
||||
-7: "Ab", -6: "Eb", -5: "Bb", -4: "F", -3: "C", -2: "G", -1: "D",
|
||||
0: "A", 1: "E", 2: "B", 3: "F#", 4: "C#", 5: "G#", 6: "D#", 7: "A#",
|
||||
}
|
||||
|
||||
# Reverse: name → integer (case-insensitive, supports "C major", "C", "Cm", "C minor")
|
||||
_KEY_NAME_TO_INT: dict[str, int] = {}
|
||||
for _i, _name in KEY_INT_TO_MAJOR.items():
|
||||
_KEY_NAME_TO_INT[_name.lower()] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()} major"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}maj"] = _i
|
||||
for _i, _name in KEY_INT_TO_MINOR.items():
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()} minor"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}m"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}min"] = _i
|
||||
|
||||
|
||||
def key_name_to_int(name: str) -> int:
|
||||
"""Convert a key name to its integer representation.
|
||||
|
||||
Accepts: "C", "C major", "Db", "Db major", "A minor", "Am", etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key name is not recognized.
|
||||
"""
|
||||
normalized = name.strip().lower()
|
||||
if normalized in _KEY_NAME_TO_INT:
|
||||
return _KEY_NAME_TO_INT[normalized]
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognized key name: '{name}'. "
|
||||
f"Examples: C, Db major, F# minor, Bb, Am"
|
||||
)
|
||||
|
||||
|
||||
def key_int_to_name(key_int: int, minor: bool = False) -> str:
|
||||
"""Convert a key integer to its name."""
|
||||
table = KEY_INT_TO_MINOR if minor else KEY_INT_TO_MAJOR
|
||||
if key_int not in table:
|
||||
raise ValueError(f"Invalid key integer: {key_int}. Must be -7 to 7.")
|
||||
suffix = " minor" if minor else " major"
|
||||
return table[key_int] + suffix
|
||||
|
||||
|
||||
# ── MSCZ (MuseScore ZIP) I/O ─────────────────────────────────────────
|
||||
|
||||
def read_mscz(path: str) -> dict:
|
||||
"""Read a .mscz file (ZIP archive).
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- "mscx": ElementTree of the .mscx XML
|
||||
- "mscx_filename": name of the .mscx file inside the ZIP
|
||||
- "style": content of score_style.mss (str or None)
|
||||
- "audio_settings": content of audiosettings.json (str or None)
|
||||
- "view_settings": content of viewsettings.json (str or None)
|
||||
- "other_files": dict of other filename → bytes
|
||||
"""
|
||||
result = {
|
||||
"mscx": None,
|
||||
"mscx_filename": None,
|
||||
"style": None,
|
||||
"audio_settings": None,
|
||||
"view_settings": None,
|
||||
"other_files": {},
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".mscx"):
|
||||
result["mscx_filename"] = name
|
||||
xml_bytes = zf.read(name)
|
||||
result["mscx"] = ET.ElementTree(ET.fromstring(xml_bytes))
|
||||
elif name == "score_style.mss" or name.endswith("/score_style.mss"):
|
||||
result["style"] = zf.read(name).decode("utf-8")
|
||||
elif name == "audiosettings.json" or name.endswith("/audiosettings.json"):
|
||||
result["audio_settings"] = zf.read(name).decode("utf-8")
|
||||
elif name == "viewsettings.json" or name.endswith("/viewsettings.json"):
|
||||
result["view_settings"] = zf.read(name).decode("utf-8")
|
||||
else:
|
||||
result["other_files"][name] = zf.read(name)
|
||||
|
||||
if result["mscx"] is None:
|
||||
raise ValueError(f"No .mscx file found inside {path}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def write_mscz(path: str, data: dict) -> Path:
|
||||
"""Write a .mscz file from component data.
|
||||
|
||||
Args:
|
||||
path: Output .mscz path.
|
||||
data: Dict as returned by read_mscz().
|
||||
|
||||
Returns:
|
||||
Path to the written file.
|
||||
"""
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# Write the .mscx XML
|
||||
mscx_filename = data.get("mscx_filename", "score.mscx")
|
||||
xml_str = ET.tostring(data["mscx"].getroot(), encoding="unicode",
|
||||
xml_declaration=True)
|
||||
zf.writestr(mscx_filename, xml_str)
|
||||
|
||||
# Write style
|
||||
if data.get("style"):
|
||||
zf.writestr("score_style.mss", data["style"])
|
||||
|
||||
# Write settings
|
||||
if data.get("audio_settings"):
|
||||
zf.writestr("audiosettings.json", data["audio_settings"])
|
||||
if data.get("view_settings"):
|
||||
zf.writestr("viewsettings.json", data["view_settings"])
|
||||
|
||||
# Write other files
|
||||
for name, content in data.get("other_files", {}).items():
|
||||
zf.writestr(name, content)
|
||||
|
||||
return Path(path)
|
||||
|
||||
|
||||
# ── MXL (MusicXML ZIP) I/O ───────────────────────────────────────────
|
||||
|
||||
def read_mxl(path: str) -> ET.ElementTree:
|
||||
"""Read a .mxl file (compressed MusicXML).
|
||||
|
||||
Returns:
|
||||
ElementTree of the MusicXML content.
|
||||
"""
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
# Look for the MusicXML file
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".xml") and not name.startswith("META-INF"):
|
||||
xml_bytes = zf.read(name)
|
||||
return ET.ElementTree(ET.fromstring(xml_bytes))
|
||||
|
||||
raise ValueError(f"No MusicXML file found inside {path}")
|
||||
|
||||
|
||||
# ── XML Inspection Helpers ────────────────────────────────────────────
|
||||
|
||||
def get_key_signature(tree: ET.ElementTree) -> int | None:
|
||||
"""Extract the first key signature from a MusicXML or MSCX tree.
|
||||
|
||||
Returns:
|
||||
Integer key signature (-7 to 7), or None if not found.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <key><fifths>-5</fifths></key>
|
||||
fifths = root.find(".//{*}fifths")
|
||||
if fifths is not None and fifths.text:
|
||||
return int(fifths.text)
|
||||
|
||||
# MSCX: <KeySig><accidental>-5</accidental></KeySig>
|
||||
# or <KeySig><concertKey>-5</concertKey></KeySig>
|
||||
for tag in ["accidental", "concertKey"]:
|
||||
elem = root.find(f".//KeySig/{tag}")
|
||||
if elem is not None and elem.text:
|
||||
return int(elem.text)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_time_signature(tree: ET.ElementTree) -> str | None:
|
||||
"""Extract the first time signature.
|
||||
|
||||
Returns:
|
||||
String like "4/4", "3/4", etc., or None.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
beats = root.find(".//{*}beats")
|
||||
beat_type = root.find(".//{*}beat-type")
|
||||
if beats is not None and beat_type is not None:
|
||||
return f"{beats.text}/{beat_type.text}"
|
||||
|
||||
# MSCX: <TimeSig><sigN>4</sigN><sigD>4</sigD></TimeSig>
|
||||
sig_n = root.find(".//TimeSig/sigN")
|
||||
sig_d = root.find(".//TimeSig/sigD")
|
||||
if sig_n is not None and sig_d is not None:
|
||||
return f"{sig_n.text}/{sig_d.text}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_instruments(tree: ET.ElementTree) -> list[dict]:
|
||||
"""Extract instrument info from a MusicXML or MSCX tree.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'id', 'name', 'part_name' keys.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
instruments = []
|
||||
|
||||
# MusicXML: <score-part id="P1"><part-name>Piano</part-name>
|
||||
# <score-instrument id="P1-I1"><instrument-name>Piano</instrument-name>
|
||||
for sp in root.findall(".//{*}score-part"):
|
||||
inst = {"id": sp.get("id", ""), "name": "", "part_name": ""}
|
||||
pn = sp.find("{*}part-name")
|
||||
if pn is None:
|
||||
pn = sp.find("part-name")
|
||||
if pn is not None:
|
||||
inst["part_name"] = pn.text or ""
|
||||
sin = sp.find(".//{*}instrument-name")
|
||||
if sin is not None:
|
||||
inst["name"] = sin.text or ""
|
||||
else:
|
||||
inst["name"] = inst["part_name"]
|
||||
instruments.append(inst)
|
||||
|
||||
if instruments:
|
||||
return instruments
|
||||
|
||||
# MSCX: <Part><Instrument id="keyboard.piano"><longName>Piano</longName>
|
||||
for part in root.findall(".//Part"):
|
||||
inst_elem = part.find("Instrument")
|
||||
if inst_elem is not None:
|
||||
inst = {
|
||||
"id": inst_elem.get("id", ""),
|
||||
"name": "",
|
||||
"part_name": "",
|
||||
}
|
||||
ln = inst_elem.find("longName")
|
||||
if ln is not None:
|
||||
inst["name"] = ln.text or ""
|
||||
sn = inst_elem.find("shortName")
|
||||
if sn is not None:
|
||||
inst["part_name"] = sn.text or inst["name"]
|
||||
else:
|
||||
inst["part_name"] = inst["name"]
|
||||
instruments.append(inst)
|
||||
|
||||
return instruments
|
||||
|
||||
|
||||
def get_score_title(tree: ET.ElementTree) -> str:
|
||||
"""Extract score title from XML."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <work><work-title>...</work-title></work>
|
||||
# or <movement-title>...</movement-title>
|
||||
wt = root.find(".//{*}work-title")
|
||||
if wt is not None and wt.text:
|
||||
return wt.text
|
||||
mt = root.find(".//{*}movement-title")
|
||||
if mt is not None and mt.text:
|
||||
return mt.text
|
||||
|
||||
# MSCX: <metaTag name="workTitle">...</metaTag>
|
||||
for meta in root.findall(".//metaTag"):
|
||||
if meta.get("name") == "workTitle" and meta.text:
|
||||
return meta.text
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def count_measures(tree: ET.ElementTree) -> int:
|
||||
"""Count the number of measures in a score."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: count <measure> elements in the first part
|
||||
measures = root.findall(".//{*}measure")
|
||||
if measures:
|
||||
# Each part has its own measures; count the first part's
|
||||
first_part = root.find(".//{*}part")
|
||||
if first_part is not None:
|
||||
return len(first_part.findall("{*}measure"))
|
||||
return len(measures)
|
||||
|
||||
# MSCX: count <Measure> elements in the first staff
|
||||
mscx_measures = root.findall(".//Measure")
|
||||
if mscx_measures:
|
||||
first_staff = root.find(".//Staff")
|
||||
if first_staff is not None:
|
||||
return len(first_staff.findall("Measure"))
|
||||
return len(mscx_measures)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def count_notes(tree: ET.ElementTree) -> int:
|
||||
"""Count the number of notes in a score."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML
|
||||
notes = root.findall(".//{*}note")
|
||||
if notes:
|
||||
return len(notes)
|
||||
|
||||
# MSCX
|
||||
return len(root.findall(".//Note"))
|
||||
|
||||
|
||||
def detect_format(path: str) -> str:
|
||||
"""Detect score file format from extension.
|
||||
|
||||
Returns:
|
||||
One of: "mscz", "mxl", "musicxml", "mid", "unknown"
|
||||
"""
|
||||
ext = Path(path).suffix.lower()
|
||||
return {
|
||||
".mscz": "mscz",
|
||||
".mxl": "mxl",
|
||||
".musicxml": "musicxml",
|
||||
".xml": "musicxml",
|
||||
".mid": "mid",
|
||||
".midi": "mid",
|
||||
}.get(ext, "unknown")
|
||||
|
||||
|
||||
def read_score_tree(path: str) -> ET.ElementTree:
|
||||
"""Read a score file and return its XML tree.
|
||||
|
||||
Supports .mscz, .mxl, .musicxml, .xml formats.
|
||||
"""
|
||||
fmt = detect_format(path)
|
||||
if fmt == "mscz":
|
||||
data = read_mscz(path)
|
||||
return data["mscx"]
|
||||
elif fmt == "mxl":
|
||||
return read_mxl(path)
|
||||
elif fmt == "musicxml":
|
||||
return ET.parse(path)
|
||||
else:
|
||||
raise ValueError(f"Cannot read XML tree from format: {fmt} ({path})")
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Backend interface for MuseScore 4 CLI (mscore).
|
||||
|
||||
Finds the mscore binary and provides Python wrappers for all CLI operations:
|
||||
export, transpose, metadata, parts, media, diff, batch jobs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_musescore() -> str:
|
||||
"""Locate the mscore executable.
|
||||
|
||||
Search order:
|
||||
1. MUSESCORE_PATH environment variable
|
||||
2. shutil.which("mscore")
|
||||
3. macOS app bundle: /Applications/MuseScore 4.app/Contents/MacOS/mscore
|
||||
4. Common Linux paths: /usr/bin/mscore4, /usr/local/bin/mscore4
|
||||
5. Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe
|
||||
|
||||
Returns:
|
||||
Absolute path to the mscore binary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If mscore cannot be found.
|
||||
"""
|
||||
# 1. Environment variable override
|
||||
env_path = os.environ.get("MUSESCORE_PATH")
|
||||
if env_path and os.path.isfile(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. On PATH
|
||||
which = shutil.which("mscore")
|
||||
if which:
|
||||
return which
|
||||
|
||||
# 3. Platform-specific paths
|
||||
system = platform.system()
|
||||
candidates = []
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
"/Applications/MuseScore 4.app/Contents/MacOS/mscore",
|
||||
os.path.expanduser("~/Applications/MuseScore 4.app/Contents/MacOS/mscore"),
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
"/usr/bin/mscore4",
|
||||
"/usr/local/bin/mscore4",
|
||||
"/usr/bin/mscore",
|
||||
"/usr/local/bin/mscore",
|
||||
"/snap/musescore/current/bin/mscore4",
|
||||
]
|
||||
elif system == "Windows":
|
||||
candidates = [
|
||||
r"C:\Program Files\MuseScore 4\bin\MuseScore4.exe",
|
||||
r"C:\Program Files (x86)\MuseScore 4\bin\MuseScore4.exe",
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
raise RuntimeError(
|
||||
"MuseScore 4 (mscore) not found.\n\n"
|
||||
"Install MuseScore 4 from https://musescore.org/en/download\n\n"
|
||||
"Or set the MUSESCORE_PATH environment variable:\n"
|
||||
" export MUSESCORE_PATH=/path/to/mscore\n\n"
|
||||
"Expected locations:\n"
|
||||
" macOS: /Applications/MuseScore 4.app/Contents/MacOS/mscore\n"
|
||||
" Linux: /usr/bin/mscore4\n"
|
||||
" Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe"
|
||||
)
|
||||
|
||||
|
||||
def _filter_qt_noise(stderr: str) -> str:
|
||||
"""Filter harmless Qt/QML warnings from mscore stderr."""
|
||||
if not stderr:
|
||||
return ""
|
||||
lines = []
|
||||
for line in stderr.splitlines():
|
||||
# Skip known Qt noise
|
||||
if any(pat in line for pat in [
|
||||
"qt.qml.typeregistration",
|
||||
"QML",
|
||||
"Qt WebEngine",
|
||||
"Fontconfig",
|
||||
"MESA-LOADER",
|
||||
"libpng warning",
|
||||
"IMKClient",
|
||||
"IMKInputSession",
|
||||
]):
|
||||
continue
|
||||
if line.strip():
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_mscore(args: list[str], capture_stdout: bool = True,
|
||||
timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
"""Run mscore with the given arguments.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments (not including the mscore binary itself).
|
||||
capture_stdout: Whether to capture stdout.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
CompletedProcess result.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If mscore exits with a non-zero code.
|
||||
"""
|
||||
mscore = find_musescore()
|
||||
cmd = [mscore] + args
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
filtered_stderr = _filter_qt_noise(result.stderr)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"mscore exited with code {result.returncode}\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"stderr: {filtered_stderr or result.stderr}"
|
||||
)
|
||||
|
||||
result.stderr = filtered_stderr
|
||||
return result
|
||||
|
||||
|
||||
def export_score(input_path: str, output_path: str, *,
|
||||
dpi: int | None = None,
|
||||
bitrate: int | None = None,
|
||||
trim: int | None = None,
|
||||
style: str | None = None,
|
||||
sound_profile: str | None = None,
|
||||
export_parts: bool = False) -> Path:
|
||||
"""Export a score to the specified format via mscore -o.
|
||||
|
||||
Format is inferred from the output file extension.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score (.mscz, .mxl, .mid).
|
||||
output_path: Path to output file (extension determines format).
|
||||
dpi: PNG resolution in DPI.
|
||||
bitrate: MP3 bitrate in kbps.
|
||||
trim: PNG/SVG whitespace trim margin.
|
||||
style: Path to .mss style file to apply.
|
||||
sound_profile: Audio profile ("MuseScore Basic" or "Muse Sounds").
|
||||
export_parts: Whether to append parts to PDF export.
|
||||
|
||||
Returns:
|
||||
Path to the output file.
|
||||
"""
|
||||
args = []
|
||||
|
||||
if style:
|
||||
args.extend(["-S", style])
|
||||
if dpi is not None:
|
||||
args.extend(["-r", str(dpi)])
|
||||
if bitrate is not None:
|
||||
args.extend(["-b", str(bitrate)])
|
||||
if trim is not None:
|
||||
args.extend(["-T", str(trim)])
|
||||
if sound_profile:
|
||||
args.extend(["--sound-profile", sound_profile])
|
||||
if export_parts:
|
||||
args.append("-P")
|
||||
|
||||
args.extend(["-o", str(output_path), str(input_path)])
|
||||
run_mscore(args)
|
||||
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def transpose_score(input_path: str, output_path: str,
|
||||
transpose_opts: dict) -> Path:
|
||||
"""Transpose a score and save the result.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
output_path: Path to output score.
|
||||
transpose_opts: Transpose options dict with keys:
|
||||
mode, direction, targetKey, transposeInterval,
|
||||
transposeKeySignatures, transposeChordNames,
|
||||
useDoubleSharpsFlats.
|
||||
|
||||
Returns:
|
||||
Path to the output file.
|
||||
"""
|
||||
opts_json = json.dumps(transpose_opts)
|
||||
args = ["--transpose", opts_json, "-o", str(output_path), str(input_path)]
|
||||
run_mscore(args)
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def get_score_meta(input_path: str) -> dict:
|
||||
"""Get score metadata via --score-meta.
|
||||
|
||||
Returns parsed JSON with title, composer, keysig, timesig,
|
||||
tempo, duration, measures, pages, parts, etc.
|
||||
The raw output wraps everything in a "metadata" key; we unwrap it.
|
||||
"""
|
||||
result = run_mscore(["--score-meta", str(input_path)])
|
||||
data = json.loads(result.stdout)
|
||||
# Unwrap the outer "metadata" envelope if present
|
||||
if "metadata" in data and isinstance(data["metadata"], dict):
|
||||
return data["metadata"]
|
||||
return data
|
||||
|
||||
|
||||
def get_score_parts(input_path: str) -> dict:
|
||||
"""Get score parts via --score-parts.
|
||||
|
||||
Returns parsed JSON with part names and base64-encoded .mscz data.
|
||||
"""
|
||||
result = run_mscore(["--score-parts", str(input_path)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def get_score_media(input_path: str) -> dict:
|
||||
"""Get all score media via --score-media.
|
||||
|
||||
Returns parsed JSON with pngs, svgs, pdf, midi, mxml, metadata, etc.
|
||||
"""
|
||||
result = run_mscore(["--score-media", str(input_path)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def diff_scores(file_a: str, file_b: str, raw: bool = False) -> dict:
|
||||
"""Diff two scores.
|
||||
|
||||
Args:
|
||||
file_a: Path to first score.
|
||||
file_b: Path to second score.
|
||||
raw: If True, use --raw-diff instead of --diff.
|
||||
|
||||
Returns:
|
||||
Parsed JSON diff result.
|
||||
"""
|
||||
flag = "--raw-diff" if raw else "--diff"
|
||||
result = run_mscore([flag, str(file_a), str(file_b)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def batch_convert(job_list: list[dict]) -> list[Path]:
|
||||
"""Run batch conversion via mscore -j.
|
||||
|
||||
Args:
|
||||
job_list: List of dicts with "in" and "out" keys.
|
||||
|
||||
Returns:
|
||||
List of output paths.
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False
|
||||
) as f:
|
||||
json.dump(job_list, f)
|
||||
job_file = f.name
|
||||
|
||||
try:
|
||||
run_mscore(["-j", job_file])
|
||||
finally:
|
||||
os.unlink(job_file)
|
||||
|
||||
return [Path(job["out"]) for job in job_list]
|
||||
@@ -0,0 +1,498 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner()
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""setup.py for cli-anything-musescore
|
||||
|
||||
Install with: pip install -e .
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
with open("cli_anything/musescore/README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="cli-anything-musescore",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI harness for MuseScore 4 — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Multimedia :: Sound/Audio",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-musescore=cli_anything.musescore.musescore_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.musescore": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
@@ -254,6 +254,20 @@
|
||||
"category": "ai",
|
||||
"contributor": "Alex-wuhu",
|
||||
"contributor_url": "https://github.com/Alex-wuhu"
|
||||
},
|
||||
{
|
||||
"name": "musescore",
|
||||
"display_name": "MuseScore",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
|
||||
"requires": "MuseScore 4 (musescore.org)",
|
||||
"homepage": "https://musescore.org",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=musescore/agent-harness",
|
||||
"entry_point": "cli-anything-musescore",
|
||||
"skill_md": "musescore/agent-harness/cli_anything/musescore/skills/SKILL.md",
|
||||
"category": "music",
|
||||
"contributor": "tamvicky",
|
||||
"contributor_url": "https://github.com/tamvicky"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user