// Notation Copilot -- MuseScore 4 plugin, chat-sidebar edition (GitHub-Copilot-chat style). // Previous single-form dialog version: NotationCopilot.dialog.qml. // // DOCKING: MuseScore Studio 4.7.5 cannot dock plugins. The legacy loader maps // pluginType "dock" to the same floating dialog as "dialog" (verified empirically // 2026-10-22), and PluginAPI.dockArea is a deprecated no-op. Real docked panels were // only added to MuseScore's master branch in Oct 2026 (muse_framework ExtensionPanel, // pluginType "dock"); this file already declares pluginType "dock", so on such a // build it should dock automatically. On 4.7.x the window instead re-positions itself // as a full-height sidebar flush with the right edge of the MuseScore window and // stays open across requests (it is non-modal). // // Flow per message: export the OPEN score to MusicXML -> hosted mode: POST it to // /v1/edit with the AiHud key (XMLHttpRequest); local mode: run copilot.py via a // launcher script (QProcess.startWithArgs, non-blocking, polled) -> show reply / clarifying // question with option buttons -> apply the returned edit list IN PLACE to the open score // inside one startCmd()/endCmd(), so a single Ctrl+Z undoes the whole request. Every request // re-exports the score, so follow-ups build on it, including any manual edits. // // Install: copy into ~/Documents/MuseScore4/Plugins/NotationCopilot/, restart MuseScore, // Home > Plugins > Notation Copilot > Enable. Run: Plugins > Composing/arranging tools > Notation Copilot. import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Window 2.15 import MuseScore 3.0 import FileIO 3.0 MuseScore { id: root version: "0.4" title: "Notation Copilot" description: "Chat with a copilot that edits your score; only the requested change is applied." categoryCode: "composing-arranging-tools" pluginType: "dock" requiresScore: true width: 400; height: 760 // ---- Settings ---- // mode "hosted": POST the exported MusicXML to the AiHud Notation Copilot service // (customers need only this plugin + an AiHud Copilot key; no Python, no Claude key). // mode "local": run copilot.py on this machine via a launcher script (developer mode). property string mode: "hosted" property string serverUrl: "https://copilot.aihud.net" // e.g. http://127.0.0.1:8787 for local testing property string apiKey: "" // your AiHud Copilot key property int requestTimeoutSec: 180 // Local mode only -- adjust to your install. property string python: "/tmp/ego/venv/bin/python" property string copilot: "/home/ehude/.openclaw/workspace/projects/notation-copilot/copilot.py" property string workDir: "/tmp/notation-copilot" // Optional env file with ANTHROPIC_API_KEY (MuseScore's environment usually lacks it). property string envFile: "/home/ehude/.openclaw/workspace/projects/notation-copilot/.env" property int sidebarWidth: 400 // Conversation state for the request in flight. property string instruction: "" // original request text property var history: [] // [{question, answer}] for the request in flight property string pendingQuestion: "" property string pendingKind: "" // "clarify" | "confirm" | "" property bool busy: false property var job: ({}) property int elapsed: 0 readonly property color cBg: "#f7f8fa" readonly property color cUser: "#0b6bcb" readonly property color cBot: "#ffffff" readonly property color cBorder: "#d9dde3" readonly property color cText: "#1f2328" readonly property color cMuted: "#656d76" QProcess { id: proc } FileIO { id: fio } ListModel { id: chat } // ---------- sidebar placement (MS 4.7 has no docking for plugins) ---------- Timer { id: placeTimer; interval: 300; repeat: true; property int tries: 0 onTriggered: { tries++; if (placeSidebar() || tries > 10) stop() } } function placeSidebar() { var w = root.Window.window if (!w) return false var p = w.transientParent var x0, y0, ww, hh if (p && p.width > 0) { x0 = p.x; y0 = p.y; ww = p.width; hh = p.height } else { x0 = w.screen.virtualX; y0 = w.screen.virtualY ww = w.screen.desktopAvailableWidth; hh = w.screen.desktopAvailableHeight } var top = 108 // keep the menu bar + toolbars of the main window visible w.width = sidebarWidth w.height = Math.max(400, hh - top) w.x = x0 + ww - sidebarWidth w.y = y0 + top root.width = w.width; root.height = w.height // content follows the window console.log("copilot sidebar placed at " + w.x + "," + w.y + " " + w.width + "x" + w.height + (p ? " (main window " + ww + "x" + hh + ")" : " (screen)")) return true } onRun: { if (mode === "hosted" && apiKey === "") loadKey() if (mode === "hosted" && apiKey === "") askForKey() else say("bot", "Hi! Tell me what to change in this score, e.g. \"double the flute in violin I an octave lower, bars 1-8\". " + "I only touch what you ask for, and I'll ask if something is unclear.", "", "") placeTimer.start() } // ---------- AiHud key (hosted mode) ---------- // Either set apiKey above, or paste the key into the chat once; it is saved to copilot-key.txt. // Saved next to this .qml: MuseScore 4.7 blocks plugin writes outside its allowed dirs (e.g. ~). function keyPath() { return decodeURIComponent(Qt.resolvedUrl("copilot-key.txt").toString().replace(/^file:\/\//, "")) } function loadKey() { fio.source = keyPath() if (fio.exists()) { var k = String(fio.read()).trim(); if (/^nc_live_[A-Za-z0-9_-]{8,}$/.test(k)) apiKey = k } } function askForKey() { say("bot", "Welcome to AiHud Notation Copilot! To start, paste your AiHud Copilot key (it starts with nc_live_) " + "here and press Enter.\n\nNo key yet? Get a free one (20 edits a month) at " + serverUrl, "", "") } // ---------- UI ---------- Rectangle { anchors.fill: parent; color: cBg Rectangle { // header id: header; anchors { left: parent.left; right: parent.right; top: parent.top } height: 40; color: "#ffffff" Rectangle { anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: 1; color: cBorder } Text { anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter } text: "NOTATION COPILOT"; font.bold: true; font.pixelSize: 12; font.letterSpacing: 1; color: cText } Row { anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } spacing: 4 Button { text: "New chat"; flat: true; font.pixelSize: 11; enabled: !busy onClicked: { chat.clear(); resetRequest() say("bot", "New chat. Tell me what to change in the open score.", "", "") } } Button { text: "Close"; flat: true; font.pixelSize: 11; onClicked: root.quit() } } } ListView { id: list anchors { left: parent.left; right: parent.right; top: header.bottom; bottom: composer.top margins: 10 } clip: true; spacing: 10; model: chat ScrollBar.vertical: ScrollBar {} onCountChanged: Qt.callLater(function() { list.positionViewAtEnd() }) delegate: Item { id: row width: list.width height: bubble.height + (who.visible ? who.height + 3 : 0) property bool mine: model.role === "user" property var opts: model.opts === "" ? [] : model.opts.split("\n") Text { id: who; visible: model.role !== "status"; text: mine ? "You" : "Copilot" font.pixelSize: 11; font.bold: true; color: cMuted anchors.right: mine ? parent.right : undefined; anchors.left: mine ? undefined : parent.left } Rectangle { id: bubble y: who.visible ? who.height + 3 : 0 width: Math.min(list.width - (mine ? 40 : 0), Math.max(body.implicitWidth, 60) + 20) height: col.height + 16 anchors.right: mine ? parent.right : undefined radius: 8 color: mine ? cUser : (model.role === "status" ? "transparent" : (model.role === "error" ? "#fff1f0" : (model.kind !== "" ? "#fff8e6" : cBot))) border.color: mine || model.role === "status" ? "transparent" : (model.role === "error" ? "#f5c2c0" : (model.kind !== "" ? "#f0d58c" : cBorder)) Column { id: col; x: 10; y: 8; width: bubble.width - 20; spacing: 6 Text { id: body; width: Math.min(implicitWidth, list.width - 60 - (mine ? 40 : 0)) text: model.text; wrapMode: Text.Wrap; textFormat: Text.PlainText font.pixelSize: 13; font.italic: model.role === "status" color: mine ? "#ffffff" : (model.role === "status" ? cMuted : cText) } Text { visible: model.detail !== ""; width: body.width; text: model.detail wrapMode: Text.Wrap; font.pixelSize: 11; font.family: "monospace"; color: cMuted } Repeater { model: row.opts delegate: Button { width: col.width; enabled: row.isLive() && !busy contentItem: Text { text: modelData; wrapMode: Text.Wrap; font.pixelSize: 12 color: parent.enabled ? cUser : cMuted } onClicked: pickOption(modelData) } } Row { spacing: 6; visible: model.kind !== "" && row.isLive() Button { text: "Apply anyway"; highlighted: true; enabled: !busy onClicked: forceApply() } Button { text: "Cancel"; flat: true; enabled: !busy onClicked: { resetRequest(); say("status", "Request cancelled.", "", "") } } } } } function isLive() { return model.live && pendingKind !== "" } } } Rectangle { // composer id: composer anchors { left: parent.left; right: parent.right; bottom: parent.bottom; margins: 10 } height: input.height + 46; radius: 8; color: "#ffffff" border.color: input.activeFocus ? cUser : cBorder TextArea { id: input anchors { left: parent.left; right: parent.right; top: parent.top; margins: 6 } height: Math.min(Math.max(contentHeight + 12, 40), 120) wrapMode: TextEdit.Wrap; font.pixelSize: 13; enabled: !busy background: null placeholderText: pendingKind === "clarify" ? "Answer the question, or click an option above" : "Ask Copilot to change the score (Enter to send)" Keys.onPressed: function(e) { if ((e.key === Qt.Key_Return || e.key === Qt.Key_Enter) && !(e.modifiers & Qt.ShiftModifier)) { e.accepted = true; send() } } } Text { anchors { left: parent.left; leftMargin: 10; bottom: parent.bottom; bottomMargin: 10 } font.pixelSize: 11; color: cMuted text: busy ? "Working" + ".".repeat(1 + elapsed % 3) + " " + elapsed + "s" : "Edits apply to the open score (Ctrl+Z undoes)" } Button { anchors { right: parent.right; rightMargin: 6; bottom: parent.bottom; bottomMargin: 4 } text: pendingKind === "clarify" ? "Answer" : "Send"; highlighted: true enabled: !busy && input.text.trim() !== "" onClicked: send() } } } // ---------- conversation logic ---------- function say(role, text, kind, opts, detail) { for (var i = 0; i < chat.count; i++) chat.setProperty(i, "live", false) chat.append({ role: role, text: text, kind: kind || "", opts: opts || "", detail: detail || "", live: (kind || "") !== "" }) } function resetRequest() { instruction = ""; history = []; pendingQuestion = ""; pendingKind = "" } function addAnswer(a) { var h = history.slice(); h.push({ question: pendingQuestion, answer: a }); history = h } function historyArgs() { var s = "" for (var i = 0; i < history.length; i++) s += " --question " + shq(history[i].question) + " --answer " + shq(history[i].answer) return s } function shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" } function send() { var t = input.text.trim() if (t === "" || busy) return input.text = "" if (/^nc_live_[A-Za-z0-9_-]{8,}$/.test(t)) { // the user pasted their key apiKey = t fio.source = keyPath() var saved = fio.write(t) say("user", t.substring(0, 12) + "...", "", "") say("bot", "Key " + (saved ? "saved" : "set for this session") + ". Now tell me what to change in this score, " + "e.g. \"transpose the flute up a step in bars 5-8\".", "", "") return } say("user", t, "", "") if (mode === "hosted" && apiKey === "") { askForKey(); return } if (pendingKind === "clarify") { addAnswer(t) runCopilot(false) } else { resetRequest(); instruction = t; runCopilot(false) } } function pickOption(o) { if (/e\.g\./.test(o)) { // template option: let the user fill it in var m = /"([^"]+)"/.exec(o) input.text = m ? m[1] : ""; input.forceActiveFocus(); input.cursorPosition = input.text.length return } say("user", o, "", "") addAnswer(o) runCopilot(false) } function forceApply() { say("user", "Apply anyway", "", "") if (pendingKind === "clarify") addAnswer("No more detail; use your best judgement.") runCopilot(true) } function runCopilot(force) { var stamp = Date.now() var src = workDir + "/in-" + stamp + ".musicxml" var dst = workDir + "/out-" + stamp + ".musicxml" var rep = workDir + "/report-" + stamp + ".json" var done = workDir + "/done-" + stamp proc.startWithArgs("/bin/mkdir", ["-p", workDir]); proc.waitForFinished(5000) if (!curScore) { say("error", "Open a score first.", "", ""); return } // always export the OPEN score: follow-ups see earlier edits and manual changes if (!writeScore(curScore, src, "musicxml")) { say("error", "Could not export the score.", "", ""); return } var target = curScore.scoreName if (mode === "hosted") { postHosted(src, dst, force, target); return } var sh = workDir + "/run-" + stamp + ".sh" fio.source = sh fio.write("[ -f " + shq(envFile) + " ] && { set -a; . " + shq(envFile) + "; set +a; }\n" + shq(python) + " " + shq(copilot) + " " + shq(src) + " " + shq(instruction) + historyArgs() + (force ? " --yes" : "") + " -o " + shq(dst) + " > " + shq(rep) + " 2>&1\n" + "echo $? > " + shq(done) + "\n") console.log("copilot launcher: " + sh) job = { rep: rep, dst: dst, done: done, hosted: false, target: target } busy = true; elapsed = 0 proc.startWithArgs("/bin/sh", [sh]) poll.start() } Timer { id: poll; interval: 1000; repeat: true onTriggered: { elapsed++ if (job.hosted) { if (elapsed > requestTimeoutSec && job.xhr) { var x = job.xhr; job.xhr = null; x.abort(); stop(); busy = false say("error", "The Copilot service did not answer within " + requestTimeoutSec + "s.", "", "") resetRequest() } return } fio.source = job.done if (!fio.exists()) { if (elapsed > 300) { stop(); busy = false; say("error", "Copilot timed out.", "", "") } return } stop(); busy = false var rc = parseInt(fio.read()) fio.source = job.rep handleReport(fio.read(), rc) } } // ---------- hosted mode ---------- function postHosted(src, dst, force, target) { if (apiKey === "") { askForKey(); return } fio.source = src var xml = fio.read() if (!xml || xml.length < 50) { say("error", "Could not read the exported score.", "", ""); return } xml = String(xml).replace(/>\n[ \t]*<") // drop indentation: ~30% smaller upload var xhr = new XMLHttpRequest() job = { dst: dst, hosted: true, xhr: xhr, target: target } busy = true; elapsed = 0; poll.start() xhr.onreadystatechange = function() { if (xhr.readyState !== XMLHttpRequest.DONE || job.xhr !== xhr) return job.xhr = null; poll.stop(); busy = false handleHosted(xhr.status, xhr.responseText) } xhr.open("POST", serverUrl.replace(/\/+$/, "") + "/v1/edit") xhr.setRequestHeader("Content-Type", "application/json") xhr.setRequestHeader("Authorization", "Bearer " + apiKey) xhr.send(JSON.stringify({ musicxml: xml, instruction: instruction, history: history, yes: !!force, inplace: true })) console.log("copilot hosted request -> " + serverUrl + " (" + xml.length + " bytes)") } function handleHosted(httpStatus, txt) { var r = null try { r = JSON.parse(txt) } catch (e) { r = null } if (!r) { say("error", httpStatus === 0 ? "Could not reach the Copilot service at " + serverUrl + "." : httpStatus === 413 ? "This score is too large for the Copilot service." : "Unexpected reply from the Copilot service (HTTP " + httpStatus + ").", "", "", String(txt || "").slice(0, 800)) resetRequest(); return } var rep = r.report || {} var cost = r.usage ? "\n(" + (r.usage.latency_ms / 1000).toFixed(1) + "s, " + r.usage.credits + " credits)" : "" if (r.status === "question") { pendingKind = r.kind || "clarify"; pendingQuestion = r.question if (pendingKind === "confirm") say("bot", r.question, "confirm", "", (r.options || []).join("\n")) else say("bot", r.question, "clarify", (r.options || []).join("\n")) return } if (r.status === "error") { say("error", rep.message || ("Error (HTTP " + httpStatus + ")."), "", "") resetRequest(); return } if (r.status === "applied") finishApplied(rep, r.edits || [], r.edit_notes || [], cost, r.edit_summary) else say("bot", rep.message || "Nothing to change.", "", "", logText(rep) + cost) resetRequest() } function handleReport(txt, rc) { var rep = null var j = txt.search(/^\{/m) if (j >= 0) { try { rep = JSON.parse(txt.substring(j)) } catch (e) { rep = null } } var head = (j >= 0 ? txt.substring(0, j) : txt).trim() if (rep && rep.status === "question") { pendingKind = rep.kind; pendingQuestion = rep.question var opts = (rep.options || []).join("\n") if (rep.kind === "confirm") say("bot", rep.question, "confirm", "", (rep.options || []).join("\n")) else say("bot", rep.question, "clarify", opts) return } if (rc !== 0 || !rep) { say("error", "Something went wrong (exit " + rc + ").", "", "", txt.slice(-1500)) resetRequest(); return } if (rep.status === "applied") finishApplied(rep, rep.edits || [], rep.edit_notes || [], "", rep.edit_summary) else say("bot", rep.message || head || "Nothing to change.", "", "", logText(rep)) resetRequest() } function logText(rep) { return (rep.log || []).map(function(l) { return "- " + l }).join("\n") } function finishApplied(rep, edits, notes, cost, summary) { if (!curScore || curScore.scoreName !== job.target) { say("error", "The score \"" + job.target + "\" is no longer the active score, so nothing was changed. " + "Switch back to it and ask again.", "", ""); return } if (!edits.length) { say("error", "The service returned no edits to apply; nothing was changed.", "", ""); return } var res = applyEdits(edits) if (res.error) { say("error", "Could not apply the change in place (" + res.error + "); the score was left unchanged.", "", "", logText(rep)); return } var where = [] var ch = summary || rep.changed || {} for (var k in ch) where.push(k.replace(/<\/?sym>|accidental/g, "").replace(/Flat/g, "♭").replace(/Sharp/g, "♯") + " bars " + ch[k]) var msg = "Changed " + (where.length ? where.join("; ") : (rep.message || "the score")) var extra = notes.concat(res.problems) say("bot", msg + " — Ctrl+Z to undo." + (extra.length ? "\nNote: " + extra.join("; ") + "." : ""), "", "", logText(rep) + cost) } // ---------- in-place edits (see edits.py for the op format) ---------- readonly property int tpq: 480 function gcd(a, b) { return b ? gcd(b, a % b) : a } function setDur(c, ticks) { var g = gcd(ticks, tpq * 4); c.setDuration(ticks / g, tpq * 4 / g) } function frac(ticks) { var g = gcd(ticks, tpq * 4); return fraction(ticks / g, tpq * 4 / g) } function normName(s) { return String(s || "").toLowerCase().replace(/<\/?sym>/g, "").replace(/accidentalflat|♭/g, "b") .replace(/accidentalsharp|♯/g, "#").replace(/[^a-z0-9#]/g, "") } function staffOf(op) { var p = curScore.parts[op.part] if (!p) throw "part " + (op.part + 1) + " not found" if (p.longName && op.name && normName(p.longName) !== normName(op.name)) throw "part " + (op.part + 1) + " is \"" + p.longName + "\", expected \"" + op.name + "\"" return p.startTrack / 4 + op.staff } // cursor on `track` at `tick`, even in an empty voice (rewind on voice 1, then switch track) function cursorAt(track, tick) { var c = curScore.newCursor() c.track = track - track % 4 c.rewindToTick(tick) c.track = track return c } function sortedPitches(ch) { var a = [] for (var i = 0; i < ch.notes.length; i++) a.push(ch.notes[i].pitch) return a.sort(function(x, y) { return x - y }) } function samePitches(ch, ps) { return sortedPitches(ch).join(",") === ps.slice().sort(function(x, y) { return x - y }).join(",") } // chord on `staff` at `tick` (any voice) with these concert pitches function findChord(staff, tick, ps, grace) { for (var v = 0; v < 4; v++) { var c = curScore.newCursor(); c.track = staff * 4 + v; c.rewindToTick(tick) if (!c.segment || c.tick !== tick || !c.element || c.element.type !== Element.CHORD) continue var ch = c.element if (grace) { for (var g = 0; g < ch.graceNotes.length; g++) if (samePitches(ch.graceNotes[g], ps)) return ch.graceNotes[g] } else if (samePitches(ch, ps)) return ch } return null } function voiceHasChords(track, t0, t1) { var c = curScore.newCursor(); c.track = track; c.rewindToTick(t0) while (c.segment && c.tick < t1) { if (c.tick >= t0 && c.element && c.element.type === Element.CHORD) return true if (!c.next()) break } return false } function elementsIn(track, t0, t1) { var out = [], c = curScore.newCursor(); c.track = track; c.rewindToTick(t0) while (c.segment && c.tick < t1) { if (c.tick >= t0 && c.element) out.push(c.element) if (!c.next()) break } return out } function setSpelling(note, tpc) { var delta = note.tpc2 - note.tpc1 // the staff's transposition, as MuseScore spelled it note.tpc1 = tpc; note.tpc2 = tpc + delta } function opWrite(op, st) { var staff = staffOf(op), v = 0 if (op.voice === "new") { v = -1 for (var k = 1; k < 4; k++) if (!voiceHasChords(staff * 4 + k, op.start, op.end)) { v = k; break } if (v < 0) throw op.name + " has no free voice in that range" } else if (op.replace) { for (var k2 = 1; k2 < 4; k2++) for (var pass = 0; pass < 2; pass++) { // chords -> rests -> gone var els = elementsIn(staff * 4 + k2, op.start, op.end) for (var e = 0; e < els.length; e++) removeElement(els[e]) } } var track = staff * 4 + v var c = cursorAt(track, op.start) if (!c.segment || c.tick !== op.start) throw "cannot place the cursor in " + op.name var ev = op.events, written = [] for (var i = 0; i < ev.length; i++) { var x = ev[i] if (c.tick !== x.t) { c = cursorAt(track, x.t) if (c.tick !== x.t) { st.problems.push(op.name + ": skipped a note at tick " + x.t); continue } } if (x.tup) c.addTuplet(fraction(x.tup[0], x.tup[1]), frac(x.tup[2])) setDur(c, x.d) if (x.rest) { c.addRest(); continue } c.addNote(x.n[0][0]) for (var j = 1; j < x.n.length; j++) c.addNote(x.n[j][0], true) written.push(x) } // spelling (tpc) + stems on what was just written; follow ties MuseScore added at barlines for (var w = 0; w < written.length; w++) { var cc = cursorAt(track, written[w].t), ch = cc.element if (!ch || ch.type !== Element.CHORD || cc.tick !== written[w].t) continue var want = written[w].n for (var n = 0; n < ch.notes.length && n < want.length; n++) { var note = ch.notes[n], hops = 0 while (note && hops++ < 8) { setSpelling(note, want[n][1]) var tie = note.tieForward note = tie ? tie.endNote : null } } if (op.stem && v > 0) ch.stemDirection = op.stem === "up" ? Direction.UP : Direction.DOWN } if (op.stem === "up" && v > 0) { // doubled line sits above: existing voice 1 stems down var olds = elementsIn(staff * 4, op.start, op.end) for (var o = 0; o < olds.length; o++) if (olds[o].type === Element.CHORD) olds[o].stemDirection = Direction.DOWN } st.count.write = (st.count.write || 0) + written.length } function opPitches(op, st) { var staff = staffOf(op), miss = 0 for (var i = 0; i < op.chords.length; i++) { var x = op.chords[i], ch = findChord(staff, x.t, x.old, !!x.grace) if (!ch) { miss++; continue } var notes = [] for (var k = 0; k < ch.notes.length; k++) notes.push(ch.notes[k]) notes.sort(function(a, b) { return a.pitch - b.pitch }) for (var n = 0; n < notes.length && n < x["new"].length; n++) { var delta = notes[n].tpc2 - notes[n].tpc1 notes[n].pitch = x["new"][n][0] notes[n].tpc1 = x["new"][n][1]; notes[n].tpc2 = x["new"][n][1] + delta } st.count.pitches = (st.count.pitches || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " note(s) not found (score changed?), left as they were") } readonly property var articSym: ({ staccato: "articStaccatoAbove", accent: "articAccentAbove", tenuto: "articTenutoAbove" }) function opArtic(op, st) { var staff = staffOf(op), miss = 0, sym = SymId[articSym[op.type]] if (sym === undefined) throw "unknown articulation " + op.type for (var i = 0; i < op.chords.length; i++) { var x = op.chords[i], ch = findChord(staff, x.t, x.p, false) if (!ch) { miss++; continue } var has = false for (var a = 0; a < ch.articulations.length; a++) if (ch.articulations[a].symbol === sym) has = true if (has) continue var art = newElement(Element.ARTICULATION); art.symbol = sym; ch.add(art) st.count.artic = (st.count.artic || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " " + op.type + "(s) skipped (note not found)") } function opSlur(op, st) { var staff = staffOf(op), miss = 0 for (var i = 0; i < op.slurs.length; i++) { var x = op.slurs[i] var a = findChord(staff, x.t1, x.p1, false), b = findChord(staff, x.t2, x.p2, false) if (!a || !b) { miss++; continue } var sl = newElement(Element.SLUR) sl.spannerTick = frac(x.t1) sl.track = a.track var c = curScore.newCursor(); c.track = a.track; c.rewindToTick(x.t1) c.add(sl) sl.spannerTicks = frac(x.t2 - x.t1) sl.spannerTrack2 = b.track st.count.slur = (st.count.slur || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " slur(s) skipped (note not found)") } // Applies all ops as ONE undo step. Throws nothing; returns {error} or {problems, count}. function applyEdits(ops) { var st = { problems: [], count: {} } curScore.startCmd("Notation Copilot") try { for (var i = 0; i < ops.length; i++) { var op = ops[i] if (op.op === "write") opWrite(op, st) else if (op.op === "pitches") opPitches(op, st) else if (op.op === "artic") opArtic(op, st) else if (op.op === "slur") opSlur(op, st) else st.problems.push("unsupported edit \"" + op.op + "\" skipped (update the plugin)") } } catch (e) { curScore.endCmd(true) // roll back everything from this request console.log("copilot apply failed: " + e) return { error: String(e) } } curScore.endCmd() console.log("copilot applied in place: " + JSON.stringify(st.count)) return st } }