File size: 19,106 Bytes
e319a7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
"""Engraved sheet music: MIDI → PDF / MusicXML, via the MuseScore CLI.

MuseScore is an external program, not a Python dependency: `--format sheets`
only works on machines that have it installed (see `find_musescore`). Everything
here shells out to it and then checks that the files it was asked for actually
appeared — `mscore` exits 0 when it writes nothing at all, so a plain returncode
check would report success on an empty directory.

MuseScore 4 or newer is required. MuseScore 3 writes a different project format
(no <StringData> on its instruments, so nothing says how a guitar is tuned), and
`convert_to_tab_staves` would quietly produce a score with no tablature in it.

Chord symbols take a detour around MuseScore: it imports a MIDI marker as text
in the score, so the recognized chords the MIDI carries are stripped out before
the import and written back into the exported MusicXML as `<harmony>`, which is
what an engraver actually draws above the staff.
"""

import base64
import io
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

from mido import MidiFile

from muscriptor.utils.chords import read_chord_markers, strip_chord_markers
from muscriptor.utils.harmony import add_chord_symbols

# Names MuseScore 3 and 4 install themselves under, plus the AppImage that the
# Linux downloads page hands out (which people usually leave in $HOME).
_BINARY_NAMES = (
    "mscore",
    "musescore",
    "mscore4portable",
    "MuseScore4",
    "musescore4",
    "mscore3",
    "musescore3",
)
_APP_LOCATIONS = (
    "/Applications/MuseScore 4.app/Contents/MacOS/mscore",
    "/Applications/MuseScore 3.app/Contents/MacOS/mscore",
    "~/MuseScore.AppImage",
    "~/Applications/MuseScore.AppImage",
)
# Checked before anything else, for a MuseScore that isn't on PATH.
MUSESCORE_ENV_VAR = "MUSCRIPTOR_MUSESCORE"

# MuseScore 3 and older are rejected: see the module docstring.
MINIMUM_MAJOR_VERSION = 4

_INSTALL_HINT = (
    "Downloads for every platform: https://musescore.org/en/download\n"
    f"If it is installed somewhere unusual, set ${MUSESCORE_ENV_VAR} to it."
)

# MuseScore's MIDI import settings, passed with -M. HumanPerformance and
# QuantValue are MuseScore 4's own defaults, but spell them out.
# Element names and the QuantValue index are MuseScore's own; 2 == 1/16.
_IMPORT_OPTIONS = """<?xml version="1.0" encoding="UTF-8"?>
<MidiOptions>
  <QuantValue>2</QuantValue>
  <HumanPerformance>true</HumanPerformance>
  <Duplets>false</Duplets>
  <Triplets>{triplets}</Triplets>
  <Quadruplets>false</Quadruplets>
  <Quintuplets>false</Quintuplets>
  <Septuplets>false</Septuplets>
  <Nonuplets>false</Nonuplets>
  <SimplifyDurations>true</SimplifyDurations>
  <DottedNotes>true</DottedNotes>
</MidiOptions>
"""


def import_options(quantized: bool) -> str:
    """The -M import settings. Only search for triplets if we know the grid is accurate
    thanks to quantization.

    This choice wasn't validated very closely, maybe setting `true` always is ok too.
    """
    return _IMPORT_OPTIONS.format(triplets="true" if quantized else "false")


# Tablature staff presets, by string count. MuseScore ships tab4Str…tab9Str;
# anything outside that range (or with no strings at all) gets no tab staff.
TAB_PRESETS = {n: f"tab{n}StrCommon" for n in range(4, 10)}

# From MuseScore's own preset table: tab staves are spaced 1.5x a normal staff.
_TAB_LINE_DISTANCE = "1.5"

# Per MuseScore invocation. Generous next to the ~1.5s a song takes; it is
# here so a wedged subprocess fails the run instead of hanging it forever.
RUN_TIMEOUT_S = 120


class MuseScoreNotFoundError(RuntimeError):
    """No MuseScore executable could be located."""


class MuseScoreError(RuntimeError):
    """MuseScore ran but did not produce what it was asked for."""


def musescore_version(binary: str) -> tuple[int, ...] | None:
    """`binary`'s version as a tuple, or None if it doesn't answer like MuseScore.

    `mscore --version` prints e.g. "MuseScore4 4.7.4"; the AppImage also writes
    shared-library chatter to stderr, so both streams are searched.
    """
    try:
        proc = _run(binary, ["--version"])
    except OSError:
        return None
    match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", f"{proc.stdout}\n{proc.stderr}")
    if match is None:
        return None
    return tuple(int(g) for g in match.groups() if g is not None)


def _candidates() -> list[str]:
    """Every MuseScore-looking executable worth probing, best guess first."""
    found = []
    for name in _BINARY_NAMES:
        path = shutil.which(name)
        if path:
            found.append(path)
    for location in _APP_LOCATIONS:
        path = Path(location).expanduser()
        if path.is_file():
            found.append(str(path))
    return found


def find_musescore() -> str:
    """Path to a MuseScore 4+ executable.

    Checks $MUSCRIPTOR_MUSESCORE first, then PATH, then the places the macOS
    and Linux downloads put it, and returns the first one new enough to use.
    Raises MuseScoreNotFoundError — naming any too-old MuseScore it did find,
    since "not found" is a confusing thing to read with `mscore` on your PATH.
    """
    override = os.environ.get(MUSESCORE_ENV_VAR)
    if override:
        path = Path(override).expanduser()
        if not path.is_file():
            raise MuseScoreNotFoundError(
                f"{MUSESCORE_ENV_VAR} is set to {override!r}, which is not a file."
            )
        version = musescore_version(str(path))
        if version is None or version[0] < MINIMUM_MAJOR_VERSION:
            shown = ".".join(map(str, version)) if version else "an unknown version"
            raise MuseScoreNotFoundError(
                f"{MUSESCORE_ENV_VAR} points at MuseScore {shown}, but "
                f"--format sheets needs MuseScore {MINIMUM_MAJOR_VERSION} or newer."
            )
        return str(path)

    too_old: list[str] = []
    for candidate in _candidates():
        version = musescore_version(candidate)
        if version is None:
            continue
        if version[0] >= MINIMUM_MAJOR_VERSION:
            return candidate
        too_old.append(f"{candidate} (MuseScore {'.'.join(map(str, version))})")

    if too_old:
        raise MuseScoreNotFoundError(
            f"--format sheets needs MuseScore {MINIMUM_MAJOR_VERSION} or newer, "
            "but the only MuseScore installed is:\n  "
            + "\n  ".join(too_old)
            + "\nMuseScore 3 cannot produce the guitar and bass tablature.\n"
            + _INSTALL_HINT
        )
    raise MuseScoreNotFoundError(
        "MuseScore was not found. --format sheets engraves the score with "
        f"MuseScore {MINIMUM_MAJOR_VERSION}+, which has to be installed "
        "separately:\n" + _INSTALL_HINT
    )


def _run(binary: str, args: list[str]) -> subprocess.CompletedProcess:
    """MuseScore with `args`, headless, returning the finished process.

    The returncode is deliberately not checked here: MuseScore exits 0 for
    several failures that write no file (an unreadable -M path, a score with no
    parts), so callers verify their outputs instead.

    A timeout is enforced so a MuseScore that decides to wait for something
    fails the run rather than hanging it indefinitely.
    """
    env = dict(os.environ)
    if platform.system() == "Linux":
        # Without these MuseScore tries to open an X11 display and dies on a
        # headless box ("no Qt platform plugin could be initialized"); harmless
        # when a display is present. MuseScore 4 sets Qt's platform from its own
        # MU_QT_QPA_PLATFORM and ignores QT_QPA_PLATFORM, so the second line is
        # the one that matters on the server; the first still covers MuseScore 3
        # and anything else Qt-based in the chain. Passing `-platform offscreen`
        # instead does not work: MuseScore's command-line parser reads the value
        # as an input file and the conversion silently loses its real argument.
        env.setdefault("QT_QPA_PLATFORM", "offscreen")
        env.setdefault("MU_QT_QPA_PLATFORM", "offscreen")
    try:
        return subprocess.run(
            [binary, *args],
            capture_output=True,
            text=True,
            errors="replace",
            env=env,
            timeout=RUN_TIMEOUT_S,
        )
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(
            args, 1, "", f"timed out after {RUN_TIMEOUT_S}s"
        )


def _fail(what: str, proc: subprocess.CompletedProcess) -> None:
    tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-5:]
    detail = ("\n  " + "\n  ".join(tail)) if tail else ""
    raise MuseScoreError(f"MuseScore failed to {what}.{detail}")


def _normalize(text: str) -> str:
    return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()


def _slug(name: str) -> str:
    """Filename stem for a part, with the repetition in its name dropped.

    MuseScore names a part after the instrument it matched, then appends the
    track name the MIDI carried — which for a transcription usually says the
    same thing twice: "Electric Guitar, clean electric guitar". A segment whose
    words all appear in another one adds nothing, so it goes and the more
    specific name stays. Segments that genuinely differ are both kept, since
    there is no telling which one the reader wants ("Drum Kit, drums").
    """
    segments = [s for s in (_normalize(part) for part in name.split(",")) if s]
    words = [set(segment.split("_")) for segment in segments]
    keep = [
        segment
        for i, segment in enumerate(segments)
        # A strict subset says strictly less; identical segments would each
        # rule the other out, so only the first of those survives.
        if not any(
            words[i] < other or (words[i] == other and j < i)
            for j, other in enumerate(words)
            if j != i
        )
    ]
    return "_".join(keep) or "part"


def _string_counts(mscx_path: Path) -> list[int]:
    """How many strings MuseScore gave each part's instrument, in part order.

    A part counts as fretted when its instrument has a <StringData> block whose
    string count matches a tab preset; that finds guitars and basses (and
    5-string basses, 7-string guitars, …) without hardcoding instrument names.
    """
    score = ET.parse(mscx_path).getroot().find("Score")
    if score is None:
        raise MuseScoreError(f"{mscx_path} has no <Score> element")
    counts = []
    for part in score.findall("Part"):
        instrument = part.find("Instrument")
        strings = (
            len(instrument.findall("StringData/string"))
            if instrument is not None
            else 0
        )
        counts.append(strings)
    return counts


def fretted_parts(mscx_path: Path) -> list[int]:
    """Indices of the parts in `mscx_path` that can be engraved as tablature."""
    return [i for i, n in enumerate(_string_counts(mscx_path)) if n in TAB_PRESETS]


def convert_to_tab_staves(mscx_path: Path) -> list[int]:
    """Retype every fretted part's staff as tablature, in place.

    Rewrites the staff rather than adding one: this score exists only to render
    the tab-only PDFs, and the notation comes from the untouched score it was
    copied from. Returns the indices of the parts converted.
    """
    tree = ET.parse(mscx_path)
    score = tree.getroot().find("Score")
    if score is None:
        raise MuseScoreError(f"{mscx_path} has no <Score> element")

    converted = []
    for index, part in enumerate(score.findall("Part")):
        instrument = part.find("Instrument")
        strings = (
            len(instrument.findall("StringData/string"))
            if instrument is not None
            else 0
        )
        preset = TAB_PRESETS.get(strings)
        staff = part.find("Staff")
        if preset is None or staff is None:
            continue
        _retype_as_tablature(staff, preset, strings)
        converted.append(index)

    tree.write(mscx_path, encoding="UTF-8", xml_declaration=True)
    return converted


def _retype_as_tablature(staff: ET.Element, preset: str, strings: int) -> None:
    """Turn a notation <Staff> into a `strings`-line tablature staff, in place."""
    staff_type = staff.find("StaffType")
    if staff_type is None:
        staff_type = ET.SubElement(staff, "StaffType")
    staff_type.set("group", "tablature")

    name = staff_type.find("name")
    if name is None:
        name = ET.SubElement(staff_type, "name")
    name.text = preset

    # <name> is only a label to MuseScore's reader (it does not look the preset
    # up), so the geometry has to be spelled out or the staff keeps the default
    # 5 lines — a 6-string guitar on 5 lines.
    for tag in ("lines", "lineDistance"):
        for existing in staff_type.findall(tag):
            staff_type.remove(existing)
    ET.SubElement(staff_type, "lines").text = str(strings)
    ET.SubElement(staff_type, "lineDistance").text = _TAB_LINE_DISTANCE


def _split_off_chords(
    midi_bytes: bytes, tmp_dir: Path, fallback: Path
) -> tuple[list[tuple[float, str]], Path]:
    """The chord track in `midi_bytes`, and the MIDI file to engrave from.

    The returned path is a marker-free copy of the upload when there are chords
    to take out, and `fallback` (the untouched file) otherwise — including when
    the bytes aren't MIDI this can parse, since that is MuseScore's problem to
    report, not this function's.
    """
    try:
        midi = MidiFile(file=io.BytesIO(midi_bytes))
        chords = read_chord_markers(midi)
    except Exception:
        return [], fallback
    if not chords:
        return [], fallback
    stripped = tmp_dir / "import.mid"
    strip_chord_markers(midi).save(str(stripped))
    return chords, stripped


def write_sheets(
    midi_bytes: bytes,
    out_dir: Path,
    musescore: str | None = None,
    quantized: bool = False,
) -> list[Path]:
    """Engrave `midi_bytes` into `out_dir`, returning the files written.

    Writes the MIDI, a MusicXML score, one PDF of the full score,
    and one PDF of standard notation per instrument. Guitar and bass parts
    additionally get a tablature PDF of their own, rendered from a second copy
    of the score whose staves are retyped as tab. `out_dir` is created if it
    does not exist.

    Chord symbols recognized from the audio ride along in the MIDI as markers
    (see `muscriptor.utils.chords`); they end up in the MusicXML as `<harmony>`.

    `quantized` says whether the notes are already snapped to a beat grid (by
    `muscriptor.utils.midi.quantized_notes`), which is what the engraving wants:
    it decides the triplet search, and unquantized input engraves the timing
    jitter as tied 128th notes.
    """
    binary = musescore or find_musescore()
    out_dir.mkdir(parents=True, exist_ok=True)

    midi_path = out_dir / "score.mid"
    midi_path.write_bytes(midi_bytes)
    written = [midi_path]

    with tempfile.TemporaryDirectory(prefix="muscriptor-sheets-") as tmp:
        tmp_dir = Path(tmp)
        options = tmp_dir / "import.xml"
        options.write_text(import_options(quantized))

        chords, to_import = _split_off_chords(midi_bytes, tmp_dir, midi_path)

        # MuseScore's own project format, so the score can be copied and edited
        # (staves retyped as tab) before anything is rendered from it.
        mscx = tmp_dir / "score.mscx"
        proc = _run(binary, ["-M", str(options), "-o", str(mscx), str(to_import)])
        if not mscx.is_file():
            _fail("import the MIDI file", proc)

        musicxml = out_dir / "score.musicxml"
        proc = _run(binary, ["-o", str(musicxml), str(mscx)])
        if not musicxml.is_file():
            _fail("write MusicXML", proc)
        if chords:
            add_chord_symbols(musicxml, chords)
        written.append(musicxml)

        full_score = out_dir / "full_score.pdf"
        proc = _run(binary, ["-o", str(full_score), str(mscx)])
        if not full_score.is_file():
            _fail("render the full score", proc)
        written.append(full_score)

        written.extend(_write_part_pdfs(binary, mscx, out_dir))

        fretted = fretted_parts(mscx)
        if fretted:
            tab_mscx = tmp_dir / "tab.mscx"
            shutil.copyfile(mscx, tab_mscx)
            convert_to_tab_staves(tab_mscx)
            written.extend(
                _write_part_pdfs(
                    binary, tab_mscx, out_dir, suffix="_tab", only=set(fretted)
                )
            )

    return written


def _write_part_pdfs(
    binary: str,
    mscx: Path,
    out_dir: Path,
    suffix: str = "",
    only: set[int] | None = None,
) -> list[Path]:
    """One PDF per instrument, extracted from --score-parts-pdf.

    MuseScore only writes per-part files for scores that already carry
    generated parts, which a MIDI import does not; --score-parts-pdf generates
    them on the fly, but hands them back as base64 in a JSON blob on stdout
    rather than writing files.

    `only` keeps just those part indices (the tab pass wants the fretted ones),
    and `suffix` goes on the filename before the extension. Numbering follows
    the part's position in the score either way, so a guitar's notation and
    tablature PDFs sort together.
    """
    proc = _run(binary, ["--score-parts-pdf", str(mscx)])
    try:
        payload = json.loads(proc.stdout)
        names, blobs = payload["parts"], payload["partsBin"]
    except (json.JSONDecodeError, KeyError, TypeError):
        _fail("generate the per-instrument PDFs", proc)

    written = []
    for index, (name, blob) in enumerate(zip(names, blobs)):
        if only is not None and index not in only:
            continue
        path = out_dir / f"{index + 1:02d}_{_slug(name)}{suffix}.pdf"
        path.write_bytes(base64.b64decode(blob))
        written.append(path)
    if not written:
        _fail("generate the per-instrument PDFs", proc)
    return written


def prepare_output_dir(path: Path) -> None:
    """Check `path` can be used as a sheets output directory.

    Wants a path that does not exist yet, or an existing empty directory, so a
    run cannot scatter PDFs among unrelated files or quietly overwrite a
    previous score. Raises ValueError otherwise; the directory itself is created
    later, by write_sheets.
    """
    if not path.exists():
        return
    if not path.is_dir():
        raise ValueError(f"{path} exists and is not a directory")
    if any(path.iterdir()):
        raise ValueError(f"{path} is not empty")


def sheets_are_available() -> bool:
    """Whether MuseScore can be found, for callers that want to check first."""
    try:
        find_musescore()
    except MuseScoreNotFoundError:
        return False
    return True


if __name__ == "__main__":  # pragma: no cover - manual smoke test
    written = write_sheets(Path(sys.argv[1]).read_bytes(), Path(sys.argv[2]))
    for path in written:
        print(path)