doricomcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doricomcp/__init__.py +0 -0
- doricomcp/analysis.py +161 -0
- doricomcp/bridge.py +153 -0
- doricomcp/catalog.py +154 -0
- doricomcp/confirm.py +684 -0
- doricomcp/data/commands.json +14827 -0
- doricomcp/data/commands_not_in_getcommands.json +123 -0
- doricomcp/data/confirmed.json +436 -0
- doricomcp/data/enums.json +15970 -0
- doricomcp/data/library/library_collections.json +58 -0
- doricomcp/data/library/library_kAccidentalDefinitionCollection.json +272 -0
- doricomcp/data/library/library_kAccidentalSystemCollection.json +32 -0
- doricomcp/data/library/library_kBLOBEntityCollection.json +7 -0
- doricomcp/data/library/library_kBrushStyleCollection.json +16 -0
- doricomcp/data/library/library_kCharacterStyleCollection.json +20 -0
- doricomcp/data/library/library_kChordDiagramCollection.json +1924 -0
- doricomcp/data/library/library_kChordSymbolAppearanceCollection.json +7 -0
- doricomcp/data/library/library_kChordSymbolAppearanceComponentCollection.json +1568 -0
- doricomcp/data/library/library_kChordSymbolComponentKerningPairsCollection.json +24 -0
- doricomcp/data/library/library_kClefCollection.json +112 -0
- doricomcp/data/library/library_kCompositeCollection.json +7200 -0
- doricomcp/data/library/library_kCustomChordDataCollection.json +7 -0
- doricomcp/data/library/library_kDrawingPrimitiveCollection.json +12 -0
- doricomcp/data/library/library_kDrumKitNoteMapCollection.json +136 -0
- doricomcp/data/library/library_kEnsembleCollection.json +140 -0
- doricomcp/data/library/library_kExpressionMapCollection.json +172 -0
- doricomcp/data/library/library_kFlagCollection.json +168 -0
- doricomcp/data/library/library_kFlagSetCollection.json +16 -0
- doricomcp/data/library/library_kFontStyleCollection.json +256 -0
- doricomcp/data/library/library_kFrettedInstrumentDataCollection.json +220 -0
- doricomcp/data/library/library_kGlyphPrimitiveCollection.json +5892 -0
- doricomcp/data/library/library_kGraphicPrimitiveCollection.json +7 -0
- doricomcp/data/library/library_kInstrumentCollection.json +2504 -0
- doricomcp/data/library/library_kInstrumentFamilyCollection.json +56 -0
- doricomcp/data/library/library_kInstrumentNameCollection.json +12 -0
- doricomcp/data/library/library_kInstrumentScoreOrderCollection.json +20 -0
- doricomcp/data/library/library_kLineAnnotationCollection.json +84 -0
- doricomcp/data/library/library_kLineBodyStyleCollection.json +144 -0
- doricomcp/data/library/library_kLineStyleCollection.json +192 -0
- doricomcp/data/library/library_kMultiSegmentLineCollection.json +12 -0
- doricomcp/data/library/library_kNoteheadCollection.json +856 -0
- doricomcp/data/library/library_kNoteheadSetCollection.json +156 -0
- doricomcp/data/library/library_kOrnamentCollection.json +188 -0
- doricomcp/data/library/library_kOttavaLineCollection.json +36 -0
- doricomcp/data/library/library_kPageDimensionsPresetCollection.json +84 -0
- doricomcp/data/library/library_kPagePairDefinitionSetCollection.json +16 -0
- doricomcp/data/library/library_kParagraphStyleCollection.json +156 -0
- doricomcp/data/library/library_kPenStyleCollection.json +16 -0
- doricomcp/data/library/library_kPercussionInstrumentDataCollection.json +100 -0
- doricomcp/data/library/library_kPercussionKitDefinitionCollection.json +116 -0
- doricomcp/data/library/library_kPlayingTechniqueAppearanceCollection.json +1238 -0
- doricomcp/data/library/library_kPlayingTechniqueCollection.json +1200 -0
- doricomcp/data/library/library_kRepeatableCompositeCollection.json +408 -0
- doricomcp/data/library/library_kRestCollection.json +72 -0
- doricomcp/data/library/library_kRestSetCollection.json +20 -0
- doricomcp/data/library/library_kRhythmicFeelCollection.json +52 -0
- doricomcp/data/library/library_kTemperamentDefinitionCollection.json +28 -0
- doricomcp/data/library/library_kTempoPresetCollection.json +320 -0
- doricomcp/data/library/library_kTextPrimitiveCollection.json +1136 -0
- doricomcp/data/library/library_kTonalitySystemDefinitionCollection.json +20 -0
- doricomcp/data/property_names.json +1169 -0
- doricomcp/data/rc_property_schemas.json +560 -0
- doricomcp/fixture.py +132 -0
- doricomcp/logtail.py +47 -0
- doricomcp/lua/lib.lua +516 -0
- doricomcp/model.py +326 -0
- doricomcp/ops/__init__.py +47 -0
- doricomcp/ops/__main__.py +121 -0
- doricomcp/ops/common.py +310 -0
- doricomcp/ops/dynamics.py +226 -0
- doricomcp/ops/markings.py +293 -0
- doricomcp/ops/notes.py +498 -0
- doricomcp/probe.py +86 -0
- doricomcp/rc.py +185 -0
- doricomcp/reads.py +288 -0
- doricomcp/server.py +982 -0
- doricomcp/session.py +342 -0
- doricomcp/windows.py +245 -0
- doricomcp-0.1.0.dist-info/METADATA +249 -0
- doricomcp-0.1.0.dist-info/RECORD +83 -0
- doricomcp-0.1.0.dist-info/WHEEL +4 -0
- doricomcp-0.1.0.dist-info/entry_points.txt +2 -0
- doricomcp-0.1.0.dist-info/licenses/LICENSE +21 -0
doricomcp/__init__.py
ADDED
|
File without changes
|
doricomcp/analysis.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Voice-leading check over read_music data: parallel and hidden fifths/octaves, crossings, spacing, leaps and,
|
|
2
|
+
with a chord plan, doublings and missing chord tones. Pure Python on what read_music returns; no Dorico access.
|
|
3
|
+
|
|
4
|
+
Depends on: model.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
from fractions import Fraction
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
|
|
10
|
+
from .model import frac, spelling_to_midi
|
|
11
|
+
|
|
12
|
+
PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
13
|
+
# chord quality -> semitones above the root of (third, fifth, seventh); None = not in the chord
|
|
14
|
+
QUALITIES = {"": (4, 7, None), "m": (3, 7, None), "dim": (3, 6, None), "aug": (4, 8, None), "sus4": (5, 7, None),
|
|
15
|
+
"sus2": (2, 7, None), "7": (4, 7, 10), "maj7": (4, 7, 11), "m7": (3, 7, 10), "m7b5": (3, 6, 10),
|
|
16
|
+
"dim7": (3, 6, 9), "mmaj7": (3, 7, 11)}
|
|
17
|
+
CHORD_RX = re.compile(r"^([A-G])(#|b)?(maj7|mmaj7|m7b5|dim7|dim|aug|sus4|sus2|m7|m|7|ø7|o7|ø|o|)(?:/([A-G](?:#|b)?))?$")
|
|
18
|
+
ALIASES = {"ø7": "m7b5", "ø": "m7b5", "o7": "dim7", "o": "dim"}
|
|
19
|
+
NAMES = {0: "unison/octave", 7: "fifth"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pc(name: str) -> int:
|
|
23
|
+
return (PC[name[0]] + {"#": 1, "b": -1}.get(name[1:2], 0)) % 12
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_chord(sym: str) -> dict:
|
|
27
|
+
m = CHORD_RX.match(sym.strip())
|
|
28
|
+
if not m:
|
|
29
|
+
raise ValueError(f"chord {sym!r}: like D, Bm, A7, F#dim, Gmaj7, Bm7b5, D/F#")
|
|
30
|
+
root = _pc(m.group(1) + (m.group(2) or ""))
|
|
31
|
+
third, fifth, seventh = QUALITIES[ALIASES.get(m.group(3), m.group(3))]
|
|
32
|
+
roles = {root: "root", (root + third) % 12: "third", (root + fifth) % 12: "fifth"}
|
|
33
|
+
if seventh is not None:
|
|
34
|
+
roles[(root + seventh) % 12] = "seventh"
|
|
35
|
+
return {"symbol": sym.strip(), "roles": roles, "bass": _pc(m.group(4)) if m.group(4) else None,
|
|
36
|
+
"major": third == 4}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_plan(plan: str) -> list[tuple[int, Fraction, dict]]:
|
|
40
|
+
""""28 D, 28:2 A7, 29 Bm/F#" -> [(bar, beat, chord)], sorted."""
|
|
41
|
+
out = []
|
|
42
|
+
for item in filter(None, (x.strip() for x in re.split(r"[,;\n]", plan))):
|
|
43
|
+
where, _, sym = item.partition(" ")
|
|
44
|
+
bar, _, beat = where.partition(":")
|
|
45
|
+
out.append((int(bar), frac(beat or 0), parse_chord(sym)))
|
|
46
|
+
return sorted(out, key=lambda x: (x[0], x[1]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lines(read: dict) -> dict[int, list[tuple]]:
|
|
50
|
+
"""voice -> [(start, end, midi, pitch, bar, beat)]; a chord in one voice counts by its top note."""
|
|
51
|
+
out = {}
|
|
52
|
+
for v in read["voices"]:
|
|
53
|
+
notes = []
|
|
54
|
+
for i in v["items"]:
|
|
55
|
+
if i["type"] not in ("note", "chord"):
|
|
56
|
+
continue
|
|
57
|
+
k = max(range(len(i["midi"])), key=lambda j: i["midi"][j] or spelling_to_midi(i["pitches"][j]) or 0)
|
|
58
|
+
dur = max(frac(d) for d in (i["dur"] if isinstance(i["dur"], list) else [i["dur"]]))
|
|
59
|
+
notes.append((frac(i["pos"]), frac(i["pos"]) + dur, i["midi"][k] or spelling_to_midi(i["pitches"][k]),
|
|
60
|
+
i["pitches"][k], i["bar"], i["beat"]))
|
|
61
|
+
out[v["voice"]] = notes
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check(read: dict, order: list[int] | None = None, plan: str | None = None, key: str | None = None,
|
|
66
|
+
max_leap: int = 12, limit: int = 300) -> dict:
|
|
67
|
+
"""order: voices from highest to lowest (default: by average pitch). key: tonic for leading-tone doubling
|
|
68
|
+
("D", "Bm"; minor keys use the raised seventh). plan: chords in force, "bar[:beat] symbol, ..."."""
|
|
69
|
+
lines = _lines(read)
|
|
70
|
+
lines = {v: ns for v, ns in lines.items() if ns}
|
|
71
|
+
order = [v for v in (order or sorted(lines, key=lambda v: -sum(n[2] for n in lines[v]) / len(lines[v]))) if v in lines]
|
|
72
|
+
chords = parse_plan(plan) if plan else []
|
|
73
|
+
leading = (_pc(key.rstrip("m")) - 1) % 12 if key else None
|
|
74
|
+
problems = []
|
|
75
|
+
|
|
76
|
+
def add(kind, at, voices, detail):
|
|
77
|
+
problems.append({"bar": at[0], "beat": at[1], "kind": kind, "voices": voices, "detail": detail})
|
|
78
|
+
|
|
79
|
+
for v in order: # melodic leaps
|
|
80
|
+
for a, b in pairwise(lines[v]):
|
|
81
|
+
d = abs(b[2] - a[2])
|
|
82
|
+
if d > max_leap or d == 6:
|
|
83
|
+
add("leap" if d != 6 else "tritone leap", (b[4], b[5]), [v], f"{a[3]} -> {b[3]} ({d} semitones)")
|
|
84
|
+
|
|
85
|
+
times = sorted({n[0] for ns in lines.values() for n in ns})
|
|
86
|
+
prev = None
|
|
87
|
+
for t in times:
|
|
88
|
+
cur = {v: next((n for n in lines[v] if n[0] <= t < n[1]), None) for v in order}
|
|
89
|
+
at = next((n[4], n[5]) for n in cur.values() if n and n[0] == t)
|
|
90
|
+
here = [v for v in order if cur[v]]
|
|
91
|
+
for hi, lo in pairwise(here):
|
|
92
|
+
if cur[hi][2] < cur[lo][2]:
|
|
93
|
+
add("crossing", at, [hi, lo], f"{cur[hi][3]} below {cur[lo][3]}")
|
|
94
|
+
for hi, lo in pairwise(here[:-1]): # upper voices; the bass may be further away
|
|
95
|
+
if cur[hi][2] - cur[lo][2] > 12:
|
|
96
|
+
add("spacing", at, [hi, lo], f"{cur[hi][3]} - {cur[lo][3]} wider than an octave")
|
|
97
|
+
if prev:
|
|
98
|
+
for i, a in enumerate(order):
|
|
99
|
+
for b in order[i + 1:]:
|
|
100
|
+
pa, pb, ca, cb = prev.get(a), prev.get(b), cur[a], cur[b]
|
|
101
|
+
if not (pa and pb and ca and cb) or (pa is ca and pb is cb):
|
|
102
|
+
continue
|
|
103
|
+
da, db = ca[2] - pa[2], cb[2] - pb[2]
|
|
104
|
+
if not (da and db):
|
|
105
|
+
continue
|
|
106
|
+
i1, i2 = (pa[2] - pb[2]) % 12, (ca[2] - cb[2]) % 12
|
|
107
|
+
if i1 == i2 and i1 in NAMES:
|
|
108
|
+
name = "unisons" if pa[2] == pb[2] and ca[2] == cb[2] else NAMES[i1] + "s"
|
|
109
|
+
motion = "parallel" if da * db > 0 else "contrary"
|
|
110
|
+
add(f"{motion} {name}", at, [a, b], f"{pa[3]}/{pb[3]} -> {ca[3]}/{cb[3]}")
|
|
111
|
+
elif (a, b) == (order[0], order[-1]) and da * db > 0 and i2 in NAMES and abs(da) > 2:
|
|
112
|
+
add(f"hidden {NAMES[i2]}", at, [a, b], f"{pa[3]}/{pb[3]} -> {ca[3]}/{cb[3]}, upper voice leaps")
|
|
113
|
+
if chords and len(here) >= 3:
|
|
114
|
+
ch = next((c for bar, beat, c in reversed(chords) if (bar, beat) <= (at[0], frac(at[1]))), None)
|
|
115
|
+
if ch:
|
|
116
|
+
pcs = [cur[v][2] % 12 for v in here]
|
|
117
|
+
count = {p: pcs.count(p) for p in set(pcs)}
|
|
118
|
+
roles = {ch["roles"].get(p): n for p, n in count.items()}
|
|
119
|
+
if roles.get("third", 0) > 1 and ch["major"]:
|
|
120
|
+
add("doubled third", at, here, f"{ch['symbol']}: major third doubled")
|
|
121
|
+
if roles.get("seventh", 0) > 1:
|
|
122
|
+
add("doubled seventh", at, here, ch["symbol"])
|
|
123
|
+
if leading is not None and count.get(leading, 0) > 1:
|
|
124
|
+
add("doubled leading tone", at, here, ch["symbol"])
|
|
125
|
+
if "third" not in roles:
|
|
126
|
+
add("missing third", at, here, ch["symbol"])
|
|
127
|
+
if foreign := sorted({cur[v][3] for v in here if cur[v][2] % 12 not in ch["roles"]}):
|
|
128
|
+
add("non-chord tone", at, here, f"{ch['symbol']}: {' '.join(foreign)}")
|
|
129
|
+
if ch["bass"] is not None and cur[here[-1]][2] % 12 != ch["bass"]:
|
|
130
|
+
add("bass", at, [here[-1]], f"{ch['symbol']}: bass is {cur[here[-1]][3]}")
|
|
131
|
+
prev = cur
|
|
132
|
+
summary = {}
|
|
133
|
+
for p in problems:
|
|
134
|
+
summary[p["kind"]] = summary.get(p["kind"], 0) + 1
|
|
135
|
+
problems.sort(key=lambda p: (p["bar"], frac(p["beat"])))
|
|
136
|
+
return {"voices_high_to_low": order, "verticals": len(times), "summary": summary, "problems": problems[:limit],
|
|
137
|
+
**({"truncated": len(problems) - limit} if len(problems) > limit else {})}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
def voice(vid, notes):
|
|
142
|
+
items, t = [], Fraction(0)
|
|
143
|
+
for p, d in notes:
|
|
144
|
+
items.append({"type": "note", "pos": str(t), "dur": d, "bar": 1 + int(t // 3), "beat": str(t % 3),
|
|
145
|
+
"pitches": [p], "midi": [spelling_to_midi(p)]})
|
|
146
|
+
t += frac(d)
|
|
147
|
+
return {"voice": vid, "items": items}
|
|
148
|
+
|
|
149
|
+
# outer voices in parallel fifths D5/G4 -> E5/A4; the middle voice drops below the bass, then leaps a ninth
|
|
150
|
+
rd = {"voices": [voice(1, [("D5", "1"), ("E5", "1"), ("F#5", "1")]), voice(2, [("B4", "1"), ("C#4", "1"), ("D5", "1")]),
|
|
151
|
+
voice(3, [("G4", "1"), ("A4", "1"), ("A4", "1")])]}
|
|
152
|
+
r = check(rd, order=[1, 2, 3])
|
|
153
|
+
kinds = r["summary"]
|
|
154
|
+
assert kinds == {"parallel fifths": 1, "crossing": 1, "spacing": 1, "leap": 1}, r
|
|
155
|
+
# a chord plan: D major with a doubled F#, then A7 with no third
|
|
156
|
+
rd = {"voices": [voice(1, [("F#5", "1"), ("G5", "1")]), voice(2, [("F#4", "1"), ("E4", "1")]),
|
|
157
|
+
voice(3, [("D3", "1"), ("A2", "1")])]}
|
|
158
|
+
r = check(rd, order=[1, 2, 3], plan="1 D, 1:1 A7", key="D")
|
|
159
|
+
assert r["summary"].get("doubled third") == 1 and r["summary"].get("missing third") == 1, r
|
|
160
|
+
assert parse_chord("Bm7b5")["roles"] == {11: "root", 2: "third", 5: "fifth", 9: "seventh"}
|
|
161
|
+
print("analysis self-check ok")
|
doricomcp/bridge.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Lua transaction bridge: one call = one generated self-contained Lua script run by Script.RunScript.
|
|
2
|
+
|
|
3
|
+
The script is lua/lib.lua + `local REQ = <request literal>` + a dispatcher. It writes `<id>.json`
|
|
4
|
+
then `<id>.done` (the marker is written last, so a present marker means the JSON is complete).
|
|
5
|
+
Script.RunScript returns before the Lua runs, so Python waits on the marker.
|
|
6
|
+
A script is one undo step in Dorico; a read-only script adds none.
|
|
7
|
+
|
|
8
|
+
Depends on: logtail, rc, windows.
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import pathlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from .logtail import LOG
|
|
18
|
+
from .rc import HOME, RemoteControl
|
|
19
|
+
|
|
20
|
+
RUN_DIR = HOME / "run" # no spaces: the path goes into a command string unescaped
|
|
21
|
+
LIB = pathlib.Path(__file__).with_name("lua") / "lib.lua"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LuaError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StillRunning(LuaError):
|
|
29
|
+
"""The script has not finished: Dorico is blocked (dialog) or very slow. It may still change the score, so
|
|
30
|
+
nothing may be read back or undone against it."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
HARD_LIMIT = 600.0 # one fixed ceiling for a genuinely hung Dorico; dialogs are caught much earlier
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def lua_literal(v) -> str:
|
|
37
|
+
"""Python value -> Lua literal (dicts become tables keyed by string, lists 1-based arrays)."""
|
|
38
|
+
if v is None:
|
|
39
|
+
return "nil"
|
|
40
|
+
if v is True or v is False:
|
|
41
|
+
return "true" if v else "false"
|
|
42
|
+
if isinstance(v, int):
|
|
43
|
+
return str(v)
|
|
44
|
+
if isinstance(v, float):
|
|
45
|
+
return repr(v)
|
|
46
|
+
if isinstance(v, str):
|
|
47
|
+
return '"' + "".join(c if 32 <= ord(c) < 127 and c not in '"\\' else "".join(f"\\{b:03d}" for b in c.encode()) for c in v) + '"'
|
|
48
|
+
if isinstance(v, (list, tuple)):
|
|
49
|
+
return "{" + ",".join(lua_literal(x) for x in v) + "}"
|
|
50
|
+
if isinstance(v, dict):
|
|
51
|
+
return "{" + ",".join(f"[{lua_literal(str(k))}]={lua_literal(x)}" for k, x in v.items()) + "}"
|
|
52
|
+
raise TypeError(f"cannot pass {type(v).__name__} to Lua")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _wait_finished(script: str, offset: int, deadline: float):
|
|
56
|
+
needle = f"notifyPostCommandExecute: Script.RunScript?ScriptPath={script}".encode()
|
|
57
|
+
def seen() -> bool:
|
|
58
|
+
try:
|
|
59
|
+
with open(LOG, "rb") as f:
|
|
60
|
+
f.seek(offset)
|
|
61
|
+
return needle in f.read()
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return True
|
|
64
|
+
while time.monotonic() < deadline:
|
|
65
|
+
if await asyncio.to_thread(seen):
|
|
66
|
+
return
|
|
67
|
+
await asyncio.sleep(0.01)
|
|
68
|
+
raise LuaError(f"{script}: Dorico did not finish the script command in time")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dialogs() -> list[dict]:
|
|
72
|
+
from .windows import dialogs
|
|
73
|
+
try:
|
|
74
|
+
return dialogs()
|
|
75
|
+
except (OSError, RuntimeError, subprocess.SubprocessError, ValueError): # no swiftc / no permission: best effort
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Bridge:
|
|
80
|
+
def __init__(self, rc: RemoteControl):
|
|
81
|
+
self.rc = rc
|
|
82
|
+
self._lock = asyncio.Lock()
|
|
83
|
+
|
|
84
|
+
async def run(self, op: str, timeout: float = 20.0, extra_lua: str = "", **req):
|
|
85
|
+
"""Run lib op `op` with request fields `req`; return its result or raise LuaError."""
|
|
86
|
+
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
rid = uuid.uuid4().hex
|
|
88
|
+
script, out, done = (RUN_DIR / f"{rid}{ext}" for ext in (".lua", ".json", ".done"))
|
|
89
|
+
req["op"] = op
|
|
90
|
+
script.write_text(
|
|
91
|
+
f"local REQ = {lua_literal(req)}\n"
|
|
92
|
+
f"local OUT, DONE = {lua_literal(str(out))}, {lua_literal(str(done))}\n"
|
|
93
|
+
+ LIB.read_text() + "\n" + extra_lua + "\nreturn RUN()\n")
|
|
94
|
+
async with self._lock:
|
|
95
|
+
running = False
|
|
96
|
+
try:
|
|
97
|
+
log0 = LOG.stat().st_size if LOG.exists() else 0
|
|
98
|
+
await self.rc.command(f"Script.RunScript?ScriptPath={script}")
|
|
99
|
+
running = True
|
|
100
|
+
t0 = time.monotonic()
|
|
101
|
+
# Never give up on a running script: Dorico interleaves Remote Control commands with the script's
|
|
102
|
+
# own doCommand calls, so a read or Edit.Undo sent now would land in the middle of it (an undo
|
|
103
|
+
# once landed between two NoteInput.Pitch commands, and the script kept typing).
|
|
104
|
+
check = t0 + timeout
|
|
105
|
+
while not done.exists():
|
|
106
|
+
now = time.monotonic()
|
|
107
|
+
if now > check:
|
|
108
|
+
if d := _dialogs():
|
|
109
|
+
raise StillRunning(f"op {op!r}: Dorico shows a dialog ({d[0]['w']}x{d[0]['h']} '{d[0]['name']}') "
|
|
110
|
+
"and the edit has not finished; answer it with list_dialogs + dialog_click (or in Dorico)")
|
|
111
|
+
if now - t0 > HARD_LIMIT:
|
|
112
|
+
raise StillRunning(f"op {op!r}: Dorico has not finished after {now - t0:.0f}s")
|
|
113
|
+
check = now + 5
|
|
114
|
+
await asyncio.sleep(0.02)
|
|
115
|
+
running = False
|
|
116
|
+
payload = json.loads(out.read_text())
|
|
117
|
+
# The marker is written inside the script; Dorico is still executing RunScript until it logs
|
|
118
|
+
# notifyPostCommandExecute. Commands sent before that (e.g. Edit.Undo) get kCommandNotAllowed.
|
|
119
|
+
await _wait_finished(str(script), log0, time.monotonic() + timeout)
|
|
120
|
+
finally:
|
|
121
|
+
# a script still running writes its markers later; leave them rather than race it
|
|
122
|
+
for p in () if running else (script, out, done):
|
|
123
|
+
p.unlink(missing_ok=True)
|
|
124
|
+
if not payload.get("ok"):
|
|
125
|
+
raise LuaError(f"op {op!r} failed in Dorico: {payload.get('error')}")
|
|
126
|
+
return payload.get("result")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
import tempfile
|
|
131
|
+
|
|
132
|
+
class SlowDorico: # writes the result 0.5 s after RunScript, well past the 0.1 s timeout
|
|
133
|
+
def __init__(self):
|
|
134
|
+
self.sent = []
|
|
135
|
+
|
|
136
|
+
async def command(self, c):
|
|
137
|
+
self.sent.append(c)
|
|
138
|
+
script = pathlib.Path(c.split("ScriptPath=", 1)[1])
|
|
139
|
+
out, done = (script.with_suffix(x) for x in (".json", ".done"))
|
|
140
|
+
asyncio.get_running_loop().call_later(0.5, lambda: (out.write_text('{"ok": true, "result": 42}'), done.write_text("1")))
|
|
141
|
+
|
|
142
|
+
async def main():
|
|
143
|
+
global RUN_DIR, _wait_finished
|
|
144
|
+
RUN_DIR = pathlib.Path(tempfile.mkdtemp())
|
|
145
|
+
|
|
146
|
+
async def finished(*a):
|
|
147
|
+
return None
|
|
148
|
+
_wait_finished = finished
|
|
149
|
+
rc = SlowDorico()
|
|
150
|
+
assert await Bridge(rc).run("tx", timeout=0.1) == 42
|
|
151
|
+
assert len(rc.sent) == 1 and not list(RUN_DIR.iterdir()), (rc.sent, list(RUN_DIR.iterdir()))
|
|
152
|
+
asyncio.run(main())
|
|
153
|
+
print("bridge self-check ok: a slow script is waited for, nothing else is sent meanwhile")
|
doricomcp/catalog.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Dorico's command list, enum values, property schemas and library entries (doricomcp/data/), the live
|
|
2
|
+
confirmation matrix, and command validation.
|
|
3
|
+
|
|
4
|
+
Depends on: nothing else in the package.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
from functools import cache
|
|
10
|
+
from urllib.parse import parse_qsl
|
|
11
|
+
|
|
12
|
+
DATA = pathlib.Path(__file__).resolve().parent / "data"
|
|
13
|
+
CONFIRMED = DATA / "confirmed.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load(p):
|
|
17
|
+
return json.loads(pathlib.Path(p).read_text())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cache
|
|
21
|
+
def commands() -> dict[str, dict]:
|
|
22
|
+
d = _load(DATA / "commands.json")
|
|
23
|
+
out = {c["name"]: c for c in d["commands"]}
|
|
24
|
+
for n in d.get("observed_but_not_listed", []):
|
|
25
|
+
out.setdefault(n, {"name": n, "group": n.split(".")[0], "parameters": {}, "internal": True})
|
|
26
|
+
for n in _load(DATA / "commands_not_in_getcommands.json"):
|
|
27
|
+
out.setdefault(n, {"name": n, "group": n.split(".")[0], "parameters": {}, "internal": True})
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@cache
|
|
32
|
+
def enums() -> dict[str, list]:
|
|
33
|
+
return _load(DATA / "enums.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cache
|
|
37
|
+
def property_names() -> dict[str, list]:
|
|
38
|
+
return _load(DATA / "property_names.json")["by_family"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cache
|
|
42
|
+
def property_schemas() -> dict:
|
|
43
|
+
return _load(DATA / "rc_property_schemas.json")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def property_info(name: str) -> dict | None:
|
|
47
|
+
"""Schema (valueType, enumValues, displayName) for a property name from the live RC dumps, if known."""
|
|
48
|
+
for et, d in property_schemas().items():
|
|
49
|
+
for p in d["propertieslist"]["properties"]:
|
|
50
|
+
if p["name"] == name:
|
|
51
|
+
return {**p, "seen_on": et}
|
|
52
|
+
fam = next((f for f, names in property_names().items() if name in names), None)
|
|
53
|
+
return {"name": name, "family": fam} if fam else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@cache
|
|
57
|
+
def library_dump(collection: str) -> list[dict]:
|
|
58
|
+
p = DATA / "library" / f"library_{collection}.json"
|
|
59
|
+
return _load(p)[0]["libraryentities"] if p.exists() else []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(s: str) -> str:
|
|
63
|
+
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def technique(value: str, lib: list[dict] | None = None) -> dict:
|
|
67
|
+
"""A playing technique by library id ("pta.pizzText"), name or abbreviation ("pizz.", "arco", "con sord.",
|
|
68
|
+
"sul pont.", "ord."): {entityID, name}. lib: the score's live library (with the user's own techniques); default
|
|
69
|
+
the catalogued one. Raises ValueError listing candidates when unknown or ambiguous."""
|
|
70
|
+
lib = lib or library_dump("kPlayingTechniqueAppearanceCollection")
|
|
71
|
+
if any(e["entityID"] == value for e in lib):
|
|
72
|
+
return next(e for e in lib if e["entityID"] == value)
|
|
73
|
+
want = _norm(value)
|
|
74
|
+
|
|
75
|
+
def keys(e):
|
|
76
|
+
abbr = re.findall(r"\(([^)]*)\)", e["name"])
|
|
77
|
+
return {_norm(e["name"]), _norm(e["entityID"].removeprefix("pta.").removesuffix("Text")), *map(_norm, abbr)}
|
|
78
|
+
|
|
79
|
+
hits = [e for e in lib if want in keys(e)] or [e for e in lib if want and any(k.startswith(want) for k in keys(e))]
|
|
80
|
+
# prefer the plain text form over tab/abbreviated/instrument-specific variants
|
|
81
|
+
hits.sort(key=lambda e: (not e["entityID"].endswith("Text"), len(e["name"])))
|
|
82
|
+
if len(hits) == 1 or (hits and len(hits[0]["name"]) < len(hits[1]["name"]) and hits[0]["entityID"].endswith("Text")):
|
|
83
|
+
return hits[0]
|
|
84
|
+
names = ", ".join(f"{e['name']} ({e['entityID']})" for e in hits[:8]) or "none"
|
|
85
|
+
raise ValueError(f"playing technique {value!r}: {'ambiguous' if hits else 'unknown'}; candidates: {names}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def confirmed() -> dict[str, dict]:
|
|
89
|
+
return {r["capability"]: r for r in _load(CONFIRMED)} if CONFIRMED.exists() else {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def status(capability: str) -> str:
|
|
93
|
+
return confirmed().get(capability, {}).get("status", "unconfirmed")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def describe_commands(filter: str = "", limit: int = 60) -> list[dict]:
|
|
97
|
+
rx = re.compile(filter, re.IGNORECASE) if filter else None
|
|
98
|
+
out = []
|
|
99
|
+
for c in commands().values():
|
|
100
|
+
if rx and not (rx.search(c["name"]) or rx.search(c.get("display_name") or "")):
|
|
101
|
+
continue
|
|
102
|
+
params = {k: {kk: vv for kk, vv in v.items() if kk in ("required", "enum_values", "observed_values")}
|
|
103
|
+
for k, v in c.get("parameters", {}).items()}
|
|
104
|
+
out.append({"name": c["name"], "display_name": c.get("display_name"), "parameters": params,
|
|
105
|
+
**({"internal": True} if c.get("internal") else {})})
|
|
106
|
+
if len(out) >= limit:
|
|
107
|
+
break
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_enums(name: str) -> dict:
|
|
112
|
+
e = enums()
|
|
113
|
+
if name in e:
|
|
114
|
+
return {name: [v[0] for v in e[name]]}
|
|
115
|
+
rx = re.compile(name, re.IGNORECASE)
|
|
116
|
+
hits = {k: [v[0] for v in vals] for k, vals in e.items() if rx.search(k)}
|
|
117
|
+
return dict(list(hits.items())[:20])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CommandError(ValueError):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate(command: str) -> str:
|
|
125
|
+
"""Check a `Namespace.Command?Param=value&...` string against the catalogue; returns it unchanged."""
|
|
126
|
+
name, _, query = command.partition("?")
|
|
127
|
+
c = commands().get(name)
|
|
128
|
+
if c is None:
|
|
129
|
+
close = [n for n in commands() if n.lower().startswith(name.lower().split(".")[0] + ".")][:10]
|
|
130
|
+
raise CommandError(f"unknown command {name!r}" + (f"; same namespace: {close}" if close else ""))
|
|
131
|
+
params = c.get("parameters", {})
|
|
132
|
+
for k, v in parse_qsl(query, keep_blank_values=True):
|
|
133
|
+
if params and k not in params and not c.get("internal"):
|
|
134
|
+
raise CommandError(f"{name} has no parameter {k!r}; parameters: {sorted(params)}")
|
|
135
|
+
allowed = params.get(k, {}).get("enum_values")
|
|
136
|
+
if allowed and v not in allowed:
|
|
137
|
+
raise CommandError(f"{name}: {k}={v!r} not in {allowed}")
|
|
138
|
+
missing = [k for k, v in params.items() if v.get("required") and f"{k}=" not in query]
|
|
139
|
+
if missing:
|
|
140
|
+
raise CommandError(f"{name} requires {missing}")
|
|
141
|
+
return command
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
assert validate("NoteInput.SetArticulation?Value=kStaccato")
|
|
146
|
+
for bad in ("Nope.Command", "Edit.ChangeVoice?VoiceDirection=kSideways", "NoteInput.SetArticulation?Bogus=1"):
|
|
147
|
+
try:
|
|
148
|
+
validate(bad)
|
|
149
|
+
raise AssertionError(bad)
|
|
150
|
+
except CommandError:
|
|
151
|
+
pass
|
|
152
|
+
assert describe_enums("LogDuration")["LogDuration"][0] == "k256Note" or describe_enums("LogDuration")
|
|
153
|
+
assert property_info("kNoteVelocity")
|
|
154
|
+
print("catalog self-check ok", len(commands()), "commands")
|