// Scorehand -- MuseScore 4 plugin, chat-sidebar edition. // Author: AiHud (https://scorehand.aihud.net, hi@aihud.net) // Copyright (C) 2026 AiHud. Licensed under the GNU GPL v3 or later: // https://scorehand.aihud.net/LICENSE Source: https://scorehand.aihud.net/source // // 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 Scorehand 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/Scorehand/, restart MuseScore, // Home > Plugins > Scorehand > Enable. Run: Plugins > Composing/arranging tools > Scorehand. 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: "1.0.0" title: "Scorehand" description: "Ask in plain language (any language) and the AI edits the open score: write melodies, scales, exercises and countermelodies, copy or clear bars, add dynamics and chord symbols, voice sections, write pads and bass lines. One Ctrl+Z undoes each request." categoryCode: "composing-arranging-tools" pluginType: "dock" requiresScore: true width: 400; height: 760 // ---- Settings ---- // mode "hosted": POST the exported MusicXML to the Scorehand service // (customers need only this plugin + an Scorehand 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 readonly property string oldFolder: "Notation" + "Copilot" // the folder name before the rename property string apiKey: "" // your Scorehand key property int requestTimeoutSec: 480 // > server REQUEST_TIMEOUT 420 s and nginx 450 s, so the server's own reply arrives first // Local mode only -- adjust to your install. property string python: "python3" property string copilot: "" // path to copilot.py (not distributed; hosted mode is the supported path) // Folder for the launcher's temp files; "" = this plugin's own folder (works on every OS). property string workDir: "" // Optional env file with ANTHROPIC_API_KEY (MuseScore's environment usually lacks it). property string envFile: "" 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" | "mode" | "confirm" | "" (mode/confirm get Apply anyway + Cancel) property bool busy: false property var job: ({}) property int elapsed: 0 // Live card (0.9): only what the server reports through /edit-status. No data = only the elapsed time. property string reqId: "" property var progSteps: [] property var progBars: null // {lo, hi, total} property string progTitle: "" property bool statusBusy: false // one poll at a time property int previewRow: -1 // chat row waiting for its bar picture property int previewTries: 0 // First-open example prompts: shown until the first request is sent, again after "New chat" or a score // switch (click fills the box, never sends). Each names bars: a bar-less request gets a "which bars?" question. property int sentCount: 0 readonly property var examples: ["Add a walking bass line to bars 1-8", "Add accents to the melody in bars 1-4", "Simplify bars 1-8 for a beginner"] readonly property var exampleTiles: [{ title: "Add a walking bass", bars: "Bars 1\u20138", icon: "\u266a" }, { title: "Add accents to the melody", bars: "Bars 1\u20134", icon: "\u266b" }, { title: "Simplify for a beginner", bars: "Bars 1\u20138", icon: "\u2669" }] property bool hasScore: true // false when every score was closed while this window stays open readonly property bool showHero: sentCount === 0 && !busy && !needActivation readonly property bool showExamples: showHero && hasScore && (mode !== "hosted" || apiKey !== "") // 402 error=activate: show the "enter your 6-digit code / resend the email" box above the input. property bool needActivation: false property bool actBusy: false property string planLine: "" // 0.8.1: "Pro · 143 edits left" from the server's `account` block property string editsLeft: "" // 0.9: the number alone, for the header property string manageUrl: "" // signed account page (plan, usage, cancel) property string lastBlocked: "" // request that hit the 402; put back in the box after activation // Colours: brand kit (marketing/brand/music-lab). One look: always dark, whatever MuseScore's theme is. // Every control sets its own colours, so nothing here may depend on the host theme. readonly property color cViolet: "#7c3aed" readonly property color cVioletDark: "#6d28d9" readonly property color cBg: "#0b1020" readonly property color cCard: "#0f172a" readonly property color cBorder: "#2b2f55" readonly property color cText: "#e2e8f0" readonly property color cStrong: "#ffffff" readonly property color cMuted: "#94a3b8" readonly property color cFaint: "#7c86a0" readonly property color cLavender: "#ddd6fe" readonly property color cAccent: "#a78bfa" readonly property color cTile: "#1a1540" readonly property color cTileBorder: "#3a3380" readonly property color cTileHover: "#2a1a6e" readonly property color cKbd: "#1e2640" readonly property color cErrorBg: "#2a1524" readonly property color cErrorBorder: "#7f1d3a" // Geist ships in the zip (fonts/, SIL OFL); the single-file .qml download simply uses the system font. readonly property string sysFont: Qt.application.font.family FontLoader { id: geistR; source: "fonts/Geist-Regular.ttf" } FontLoader { id: geistM; source: "fonts/Geist-Medium.ttf" } FontLoader { id: geistS; source: "fonts/Geist-SemiBold.ttf" } FontLoader { id: geistB; source: "fonts/Geist-Bold.ttf" } readonly property string fontRegular: geistR.status === FontLoader.Ready ? geistR.name : sysFont readonly property string fontMedium: geistM.status === FontLoader.Ready ? geistM.name : sysFont readonly property string fontSemi: geistS.status === FontLoader.Ready ? geistS.name : sysFont readonly property string fontBold: geistB.status === FontLoader.Ready ? geistB.name : sysFont readonly property string logoData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAMAAABiM0N1AAAByFBMVEX///8QEi0OESoQEi4QES0SEjMVEzsZFEMZFUQcFUseFlEgFlUjF10iF1oPESsQESwhFlkjGFwnGGcPESolGGMOEComGWMlFmkpGWspGWwtGnQtGnMqF3IoHGQuG3UwG3cwG3kjFWMyG3opIFstImU8MXOAeaO2sslgV4wyKWVYRZcfE1k0KWlMRHlsY5SMha2uqcTJxdnk4uv39vr+/v6Deqk2HIFNQIeDbc0jG1RFOnhcU4ifm7fBvdTc2uXw7vV1YbuhhfI7M2hUTXxzbZSVka/U0t7q6e84HIVJPIKcguyojPx8ZsSKhqSsqL719PhmVaimivkyJ2iemrVsWKqWfORfTp/y8fXe2umhnLrPyuB+daY8HYtSRoc7L3UuH2ymn8Hr6vLV0+C5tMyXkbN0a5qoosR/dagoFm6Bdas/HpB/eaBCH5QvGHhFH5qFdrKqochIIJ40IXlLIaReUJSUiLq0q8/Cutm8tdKckcBjV5O+udKIdreBbrROIan7+/xRIqxBIpBxWqxVI7JIN4Xl4O86KnpQOJSmmMpcQp+MgrE5IIN5a6haJLsxIXQuH3BfJcNdJcFeJMEiF1tfJMJgJcRhJcZiJsh/4BiSAAAAAXRSTlMAQObYZgAABK5JREFUWMOt2GtbE0cUAOARoySRtCvdksgS48YAEkQILJcNULUXC8SWQrWAjaWAodqm2kK9FFLASrG10hoq9u92LjvZ2bkkG+356PP4cmbmzDk7AYCJIw1HA8dIHKfRSCIYDOEIoziBoilyBEjjrbcDAcrUdjQYJ7VIs+i8E5A4jZwT8jgndV1v4ph3pc7x6g6CdL2FdRp85RPmHALpkXqdkNzRoxHvunjIdYLVnWi0hd3nuh0Givo4r0ZmXSck+WAndgrXzxvss+PEYqie/NRhqIYTa4UQk1C950Udw2iD90vtUEixrnic5mMYxmnQUNNR5ROv5AOjFRw9ptjooM98sJM4AwJKJ1SHk0iAGvXDO5pmypxkEohO6KzCgaGn2js6O8+ZfD4U8uTTlRb2OdzdrcXP91zo7cv0D1jWoMnnkxwC4rqGR2xPQmG7KzvaMTb+3kXLCQx58hnCkLeeQ5cut1cK8ez7H3z4UeeVjwcsNhDEORNA2OfJqdxVG0s9n3ya6Z+2xIAQ73BQ0G7/bCo3MzMyPGuHQ59bihjkHQyxCXVdu56Dzkzu8kja9gG5zgTw7o99/ovrULo6mbLDYRGam88s3LCsL6OCkwdCIWancpdsfGAsNN2f6eu9cPOrxejXGOIdB/L0VXhq5NwJNLC0vHKrMJpd1E3ThI1j1bK+ue3WD2IECDvByREb1yGClsc6zmVndXgxTNp/KFQ5L+xgiGsbs2lSzwjqgCUNLwZzTxF0p83jQCb/LRD7oZ1y7gWEvusW7juBuHyKRaDu8+ExmJEm9A0Efc/sM3GKoMq8IBDXDynknhdcFwPJ+jyGhD5PIN6hkHResJDbNzDEOogp3gVqh4WcZTnQvYTgEEjxPYYgk3cwJDoi5PZnCjn1Qy/8DwRyzws7PwL1/KpAzByE9YMg0YGQbH7hzxYCeeZpYi1JIMFZB7L9IePLJBB12tZ+uv/g4aOfMwTKex0IKecggSr5bGzO0Z5SEvKBkHqemgsOhJc1uOQ2p5LosBDraKhxOBDO5w47BEqiQyAuH01LZTcKhZur7rwY7mc77i8TgrMFJPWTKowvbcMGPee2+Vuss5R27lfF2draAaKTXdn2zAvorI2z0MPHQj47EOLX1XPFO3gMHrr4K+fAfCDEO4uePz4YxdfCuM/MkxK3LpTPzhPAfT9rhW3Gmd5w5sXuDfpPv5Uey5wKVKmfPjah5T06v54+QHU0MP/7H9J8MORx4pss9KwyB5N/pp+X7l17mlc4EOLqeYFxNve88xQVtMJ5Avh7MepehUe7nJNXO/tA+F4tOEU8/2yPDuYhSf/hHATx74LJ3pXM+OrzXfIBlGTnqaR+HAdCwvsCXq+//o7dNtx8fDgE4t9x4vdY9f3BUJPivSM6xWrOCxCRvr8MQ/jeqLau/fIB+Od/ccovAdBU75RkjXXtMM4+fEFGpO+vevanXD48gFBzlXwmfDqHr9Azu6mqk/fjvCAP/zeqH+QcOr9EtMgd3+s6/Jf+OBKJvv65sw6WYpJ86nbg6l6zfngHxqnq9bMu7k8ZBj0vNppb29TrWpfu8/7BK/kPf6dbz3jfBY5zV3rfD16y//c/wDGU6kqRcjMAAAAASUVORK5CYII=" 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 } // Switching to another (e.g. new) score shows the example prompts again. property string lastScoreName: "" onScoreStateChanged: { var n = curScore ? String(curScore.scoreName) : "" if (n !== lastScoreName) { if (lastScoreName !== "" && !busy) sentCount = 0; lastScoreName = n } } onRun: { lastScoreName = curScore ? String(curScore.scoreName) : "" hasScore = !!curScore if (mode === "hosted" && apiKey === "") loadKey() sayHello() if (mode === "hosted" && apiKey === "") askForKey() placeTimer.start() } // onScoreStateChanged is not reliable when the last score closes, so look at curScore directly. Timer { interval: 1000; repeat: true; running: true; onTriggered: hasScore = !!curScore } // One "plugin opened" ping per window open (and once more when a key is pasted): plugin version + // MuseScore version, with the key in the Authorization header (never in the URL) so the server can // count opens per account. No score content. Fire and forget: the reply and any error are ignored. function msVersion() { try { if (typeof mscoreMajorVersion !== "undefined") return mscoreMajorVersion + "." + mscoreMinorVersion + "." + mscoreUpdateVersion } catch (e) { } return "" } function sayHello() { if (mode !== "hosted") return try { var x = new XMLHttpRequest() x.open("POST", serverUrl.replace(/\/+$/, "") + "/v1/hello") x.setRequestHeader("Content-Type", "application/json") if (apiKey) x.setRequestHeader("Authorization", "Bearer " + apiKey) x.send(JSON.stringify({ v: root.version, ms: msVersion() })) } catch (e) { } } // ---------- Scorehand key (hosted mode) ---------- // Either set apiKey above, or paste the key into the chat once; it is saved to scorehand-key.txt. // Saved next to this .qml: MuseScore 4.7 blocks plugin writes outside its allowed dirs (e.g. ~). // file:// URL -> local path on any OS: file:///C:/Users/x/Dokumente/a%20b.qml -> C:/Users/x/Dokumente/a b.qml, // file:///home/x/a.qml -> /home/x/a.qml, file://server/share/a -> //server/share/a (Windows network folder). function urlToPath(u) { var s = String(u) if (/^file:\/\/\//i.test(s)) s = s.substring(7) // "/C:/..." or "/home/..." else if (/^file:\/\//i.test(s)) s = "//" + s.substring(7) // UNC else if (/^file:/i.test(s)) s = s.substring(5) try { s = decodeURIComponent(s) } catch (e) { } if (/^\/[A-Za-z]:/.test(s)) s = s.substring(1) // Windows drive letter return s } // This plugin's folder (Documents/MuseScore4/Plugins/Scorehand), with a trailing "/". function pluginDir() { var d = urlToPath(Qt.resolvedUrl(".")); return /\/$/.test(d) ? d : d + "/" } // Where the hosted-mode export goes: the plugin folder; if that is not writable (probe with FileIO, // which fails silently -- writeScore would pop MuseScore's "retry?" dialog), the system temp folder. function exportPath() { var cands = [pluginDir()] try { var t = String(fio.tempPath() || "").replace(/\\/g, "/"); if (t !== "") cands.push(/\/$/.test(t) ? t : t + "/") } catch (e) { } for (var i = 0; i < cands.length; i++) { fio.source = cands[i] + "copilot-export.musicxml" if (fio.write("")) return fio.source } return cands[0] + "copilot-export.musicxml" } function keyPath() { return pluginDir() + "scorehand-key.txt" } // Keys saved by the plugin under its old name are still read: copilot-key.txt in this folder (old zip unpacked over a // renamed folder) or in the old NotationCopilot folder next to this one. A key found there is copied to keyPath(). function loadKey() { var cands = [keyPath(), pluginDir() + "copilot-key.txt", pluginDir() + "../" + oldFolder + "/copilot-key.txt"] for (var i = 0; i < cands.length; i++) { fio.source = cands[i] if (!fio.exists()) continue var k = String(fio.read()).trim() if (!/^nc_live_[A-Za-z0-9_-]{8,}$/.test(k)) continue apiKey = k if (i > 0) { fio.source = keyPath(); fio.write(k) } return } } function askForKey() { say("bot", "Welcome to Scorehand! To start, paste your Scorehand key (it starts with nc_live_) " + "here and press Enter.\n\nNo key yet? Get a free one (100-edit trial, then 10 a month) at https://scorehand.aihud.net", "", "") } // ---------- UI (AiHud Music Lab look; always dark) ---------- component Txt: Text { font.family: fontRegular; font.pixelSize: 13; color: cText; textFormat: Text.PlainText } component Btn: Rectangle { // text button; primary = filled violet id: btn property string text: "" property bool primary: false signal clicked() implicitWidth: btnLabel.implicitWidth + 24; implicitHeight: 30; radius: 9 opacity: enabled ? 1 : 0.45 color: primary ? (btnMouse.containsMouse ? cVioletDark : cViolet) : (btnMouse.containsMouse ? cTile : "transparent") border.color: primary ? "transparent" : cTileBorder Txt { id: btnLabel; anchors.centerIn: parent; text: btn.text; font.pixelSize: 12; font.family: fontMedium font.weight: Font.Medium; color: btn.primary ? "#ffffff" : cLavender } MouseArea { id: btnMouse; anchors.fill: parent; hoverEnabled: true; enabled: btn.enabled cursorShape: Qt.PointingHandCursor; onClicked: btn.clicked() } } component BrandGradient: Canvas { // the brand 135deg gradient (no Qt5/Qt6-specific effects module) onWidthChanged: requestPaint() onHeightChanged: requestPaint() onPaint: { var ctx = getContext("2d"), g = ctx.createLinearGradient(0, 0, width, Math.max(height, width * 0.35)) g.addColorStop(0, "#0b1020"); g.addColorStop(0.55, "#2a1a6e"); g.addColorStop(1, "#6d28d9") ctx.fillStyle = g; ctx.fillRect(0, 0, width, height) } } Rectangle { anchors.fill: parent; color: cBg Item { // header: brand on the left; edits left, New chat, Close on the right id: header; anchors { left: parent.left; right: parent.right; top: parent.top } height: 66 BrandGradient { anchors.fill: parent } Image { id: logo; x: 14; anchors.verticalCenter: parent.verticalCenter; width: 36; height: 36 source: logoData; smooth: true; mipmap: true } Column { anchors { left: logo.right; leftMargin: 10; verticalCenter: parent.verticalCenter right: headerRight.left; rightMargin: 8 } Txt { width: parent.width; elide: Text.ElideRight; text: "Scorehand"; font.family: fontBold font.bold: true; font.pixelSize: 14; color: "#ffffff" } Txt { width: parent.width; elide: Text.ElideRight; text: "Your score, in plain words"; font.family: fontMedium font.weight: Font.Medium; font.pixelSize: 11; color: "#a78bfa" } } Row { id: headerRight; spacing: 6 anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter } Item { // plan + edits left (server `account` block); click = account page id: planBox; visible: planLine !== ""; height: 34; anchors.verticalCenter: parent.verticalCenter width: Math.max(planNum.implicitWidth, planLbl.implicitWidth) + 6 Column { anchors { right: parent.right; verticalCenter: parent.verticalCenter } Txt { id: planNum; anchors.right: parent.right; visible: editsLeft !== ""; text: editsLeft font.family: fontBold; font.bold: true; font.pixelSize: 14; color: "#ffffff" } Txt { id: planLbl; anchors.right: parent.right text: editsLeft !== "" ? "edits left" : planLine; font.pixelSize: 10; color: "#ddd6fe" font.underline: manageUrl !== "" && planMouse.containsMouse } } MouseArea { id: planMouse; anchors.fill: parent; hoverEnabled: true cursorShape: manageUrl !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: if (manageUrl !== "") Qt.openUrlExternally(manageUrl) } ToolTip.visible: planMouse.containsMouse; ToolTip.delay: 400; ToolTip.text: planLine } Repeater { model: [{ g: "+", tip: "New chat", act: "new" }, { g: "×", tip: "Close", act: "close" }] delegate: Rectangle { width: 28; height: 28; radius: 8; anchors.verticalCenter: parent.verticalCenter color: iconMouse.containsMouse ? "#40ffffff" : "#1fffffff" opacity: modelData.act === "new" && busy ? 0.45 : 1 Txt { anchors.centerIn: parent; text: modelData.g; color: "#ffffff"; font.pixelSize: 17 } MouseArea { id: iconMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: modelData.act === "close" ? root.quit() : newChat() } ToolTip.visible: iconMouse.containsMouse; ToolTip.delay: 400; ToolTip.text: modelData.tip } } } } Item { // first open id: hero; visible: showHero; height: visible ? heroCol.height + 40 : 0 anchors { left: parent.left; right: parent.right; top: header.bottom } BrandGradient { anchors.fill: parent } Column { id: heroCol; x: 16; y: 22; width: parent.width - 32; spacing: 7 Column { width: parent.width Txt { text: "Your score,"; font.family: fontBold; font.bold: true; font.pixelSize: 24; color: "#ffffff" } Txt { text: "in plain words."; font.family: fontBold; font.bold: true; font.pixelSize: 24; color: "#a78bfa" } } Txt { width: parent.width; wrapMode: Text.Wrap; font.pixelSize: 13; color: "#ddd6fe"; lineHeight: 1.15 text: "Sheet music tools that do the tedious part. Say what you want, and it lands in the open score." } } } ListView { id: list anchors { left: parent.left; right: parent.right; top: hero.visible ? hero.bottom : header.bottom bottom: showExamples ? examplesBox.top : (needActivation ? actBox.top : (noScoreBox.visible ? noScoreBox.top : composer.top)) leftMargin: 12; rightMargin: 12; topMargin: 12; bottomMargin: 10 } clip: true; spacing: 11; model: chat ScrollBar.vertical: ScrollBar {} onCountChanged: Qt.callLater(function() { list.positionViewAtEnd() }) Gradient { id: barOn; GradientStop { position: 0; color: "#a78bfa" } GradientStop { position: 1; color: "#6d28d9" } } footer: Item { width: list.width; height: busy ? workCard.height + 11 : 0; visible: busy onHeightChanged: Qt.callLater(function() { list.positionViewAtEnd() }) Rectangle { id: workCard; y: 11; width: parent.width - 8; height: workCol.height + 22 radius: 14; color: cCard; border.color: cBorder Column { id: workCol; x: 12; y: 11; width: parent.width - 24; spacing: 8 Item { width: parent.width; height: 18 Txt { anchors { left: parent.left; right: workTime.left; rightMargin: 6; verticalCenter: parent.verticalCenter } elide: Text.ElideRight; text: progTitle !== "" ? progTitle : "Working on your score" font.family: fontSemi; font.weight: Font.DemiBold; color: cStrong } Txt { id: workTime; anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: Math.floor(elapsed / 60) + ":" + ("0" + elapsed % 60).slice(-2) font.pixelSize: 11; font.family: fontMedium; font.weight: Font.Medium; color: cAccent } } Column { // bar map: the bars being edited, as the server reports them width: parent.width; spacing: 3; visible: progBars !== null Row { id: barMap; width: parent.width; height: 34; spacing: 3 property int cells: progBars ? Math.min(progBars.total, 32) : 0 Repeater { model: barMap.cells delegate: Rectangle { property bool on: { if (!progBars) return false var a = 1 + index * progBars.total / barMap.cells, b = 1 + (index + 1) * progBars.total / barMap.cells return a < progBars.hi + 1 && b > progBars.lo } width: (barMap.width - (barMap.cells - 1) * 3) / barMap.cells; height: 34; radius: 3 color: "#1a2138" gradient: on ? barOn : null } } } Txt { font.pixelSize: 11; color: cMuted text: !progBars ? "" : (progBars.lo === progBars.hi ? "Bar " + progBars.lo : "Bars " + progBars.lo + "\u2013" + progBars.hi) + " of " + progBars.total } } Repeater { // the real stages of this request model: progSteps delegate: Item { width: workCol.width; height: Math.max(stepText.height, 16) + 4 Item { id: stepIcon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter Txt { anchors.centerIn: parent; visible: modelData.state === "done"; text: "\u2713"; color: cAccent; font.pixelSize: 12 } Rectangle { anchors.centerIn: parent; visible: modelData.state === "pending"; width: 8; height: 8; radius: 4 color: "transparent"; border.color: cFaint } Canvas { anchors.fill: parent; visible: modelData.state === "active" onPaint: { var c = getContext("2d"); c.reset(); c.strokeStyle = String(cAccent); c.lineWidth = 2 c.beginPath(); c.arc(7.5, 7.5, 6, 0, Math.PI * 1.5); c.stroke() } RotationAnimator on rotation { from: 0; to: 360; duration: 900; loops: Animation.Infinite running: modelData.state === "active" } } } Txt { id: stepText; anchors { left: stepIcon.right; leftMargin: 9; right: parent.right; verticalCenter: parent.verticalCenter } text: modelData.label; wrapMode: Text.Wrap; font.pixelSize: 13 font.family: modelData.state === "active" ? fontSemi : fontRegular font.weight: modelData.state === "active" ? Font.DemiBold : Font.Normal color: modelData.state === "active" ? cStrong : (modelData.state === "done" ? cMuted : cFaint) } } } } } } delegate: Item { id: row width: list.width height: bubble.height property bool mine: model.role === "user" property bool isStatus: model.role === "status" property bool isResult: model.title !== "" && model.undoHint property bool showLog: false property var opts: model.opts === "" ? [] : model.opts.split("\n") // A "Give bar numbers, e.g. ..." entry is typing guidance, not a real choice (it fills // the input box when tapped, it never answers the question by itself): keep it out of // the tile grid and show it as plain hint text instead. property string hintOpt: { var h = opts.filter(function(o) { return /e\.g\./.test(o) }); return h.length ? h[0] : "" } property var tileOpts: opts.filter(function(o) { return !/e\.g\./.test(o) }) property var chips: model.chips === "" ? [] : JSON.parse(model.chips) property bool twoCol: tileOpts.length > 1 && tileOpts.length <= 6 && tileOpts.every(function(o) { return optParts(o).title.length <= 20 }) Rectangle { id: bubble width: mine ? Math.min(list.width * 0.86, body.implicitWidth + 24) : list.width - 8 height: col.height + (mine ? 15 : 22) anchors.right: mine ? parent.right : undefined radius: mine ? 13 : 14 color: mine ? cViolet : (isStatus ? "transparent" : (model.role === "error" ? cErrorBg : (model.role === "limit" ? cTile : cCard))) border.color: mine || isStatus ? "transparent" : (model.role === "error" ? cErrorBorder : (model.role === "limit" ? cViolet : cBorder)) Column { id: col; x: 12; y: mine ? 7 : 11; width: bubble.width - 24; spacing: 8 Item { // card title + small badge ("32 notes") width: parent.width; height: Math.max(cardTitle.height, 16); visible: model.title !== "" Txt { id: cardTitle; anchors { left: parent.left; right: badge.left; rightMargin: 6 } text: model.title; wrapMode: Text.Wrap; font.family: fontSemi font.weight: Font.DemiBold; color: cStrong } Txt { id: badge; anchors.right: parent.right; text: model.badge; font.pixelSize: 11 font.family: fontMedium; font.weight: Font.Medium; color: cAccent } } Txt { id: body; width: parent.width; visible: text !== ""; text: model.text; wrapMode: Text.Wrap font.italic: isStatus; font.family: mine ? fontMedium : fontRegular font.weight: mine ? Font.Medium : Font.Normal font.pixelSize: isResult ? 12 : 13 color: mine ? "#ffffff" : (isStatus || isResult ? cMuted : cText) } Rectangle { // the edited bars (server-rendered; absent if rendering failed or was slow) visible: model.preview !== ""; width: parent.width; color: "#ffffff"; radius: 10; clip: true border.color: "transparent" height: visible ? Math.min(180, Math.max(40, prevImg.implicitWidth > 0 ? (width - 8) * prevImg.implicitHeight / prevImg.implicitWidth + 8 : 60)) : 0 Image { id: prevImg; anchors.fill: parent; anchors.margins: 4; source: model.preview fillMode: Image.PreserveAspectFit; smooth: true; mipmap: true onStatusChanged: if (status === Image.Ready) scrollDown.restart() } } Txt { visible: model.caption !== ""; text: model.caption; font.pixelSize: 10; color: cMuted } Grid { // question options as one-tap tiles (the typing hint, if any, is plain text below) id: optGrid; width: parent.width; columns: row.twoCol ? 2 : 1; spacing: 6; visible: row.tileOpts.length > 0 Repeater { model: row.tileOpts delegate: Rectangle { id: optTile property var parts: optParts(modelData) property bool live: row.isLive() && !busy width: row.twoCol ? (optGrid.width - 6) / 2 : optGrid.width height: optCol.height + 14; radius: 10; opacity: live ? 1 : 0.55 color: optMouse.containsMouse && live ? cTileHover : cTile; border.color: cTileBorder Column { id: optCol; x: 10; y: 7; width: parent.width - 20 Txt { width: parent.width; text: optTile.parts.title; wrapMode: Text.Wrap; font.pixelSize: 12 font.family: fontSemi; font.weight: Font.DemiBold; color: cStrong } Txt { width: parent.width; visible: text !== ""; text: optTile.parts.sub; wrapMode: Text.Wrap font.pixelSize: 11; color: cAccent } } MouseArea { id: optMouse; anchors.fill: parent; hoverEnabled: true; enabled: optTile.live cursorShape: Qt.PointingHandCursor; onClicked: pickOption(modelData) } } } } Txt { // typing hint (e.g. "Give bar numbers, e.g. \"bars 9-16\""): guidance, not a tappable choice visible: row.hintOpt !== ""; width: parent.width; text: row.hintOpt wrapMode: Text.Wrap; font.pixelSize: 11; font.italic: true; color: cMuted } Flow { // follow-up suggestions: fill the box, never send width: parent.width; spacing: 6; visible: row.chips.length > 0 Repeater { model: row.chips delegate: Btn { text: modelData.label; radius: 15; height: 28; onClicked: useExample(modelData.text) } } } Txt { visible: !isResult && model.detail !== ""; width: body.width; text: model.detail wrapMode: Text.Wrap; font.pixelSize: 10; color: cMuted } Item { // Ctrl Z hint; the debug line stays, small, with the log behind "Details" width: parent.width; height: 18; visible: isResult Row { spacing: 4; anchors.verticalCenter: parent.verticalCenter Repeater { model: [Qt.platform.os === "osx" ? "Cmd" : "Ctrl", "Z"] delegate: Rectangle { width: kbdText.implicitWidth + 10; height: 16; radius: 4; color: cKbd Txt { id: kbdText; anchors.centerIn: parent; text: modelData; font.pixelSize: 10; color: cText } } } Txt { anchors.verticalCenter: parent.verticalCenter; text: " undoes the whole edit"; font.pixelSize: 11; color: cMuted } } Txt { anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: model.cost + (model.detail !== "" ? (model.cost !== "" ? " " : "") + (row.showLog ? "Hide" : "Details") : "") font.pixelSize: 10; color: cFaint MouseArea { anchors.fill: parent; enabled: model.detail !== ""; cursorShape: Qt.PointingHandCursor onClicked: row.showLog = !row.showLog } } } Txt { visible: isResult && row.showLog; width: parent.width; text: model.detail wrapMode: Text.Wrap; font.pixelSize: 10; color: cMuted } Btn { // 402 / near the limit: open the plans page in the browser visible: model.url !== ""; text: "Upgrade / buy edits"; primary: true onClicked: Qt.openUrlExternally(model.url) } Row { spacing: 6; visible: model.kind !== "" && row.isLive() Btn { text: "Apply anyway"; primary: true; enabled: !busy visible: model.kind === "mode" || model.kind === "confirm"; onClicked: forceApply() } Btn { text: "Skip this question"; enabled: !busy; onClicked: skipQuestion() } } } } function isLive() { return model.live && pendingKind !== "" } } } Column { // first-open example tiles: click fills the box, never sends id: examplesBox visible: showExamples anchors { left: parent.left; right: parent.right; bottom: composer.top leftMargin: 12; rightMargin: 12; bottomMargin: 10 } spacing: 9 Txt { text: "Try one on this score"; font.pixelSize: 12; font.family: fontMedium; font.weight: Font.Medium; color: cAccent } Repeater { model: examples delegate: Rectangle { width: examplesBox.width; height: 50; radius: 14 property var tile: exampleTiles[index] color: tileMouse.containsMouse ? cTile : cCard; border.color: tileMouse.containsMouse ? cViolet : cBorder Rectangle { id: tileIcon; x: 11; anchors.verticalCenter: parent.verticalCenter; width: 32; height: 32; radius: 9 color: "#2a1a6e" Txt { anchors.centerIn: parent; text: tile.icon; font.pixelSize: 16; color: cAccent } } Column { anchors { left: tileIcon.right; leftMargin: 11; right: tileArrow.left; rightMargin: 6 verticalCenter: parent.verticalCenter } Txt { width: parent.width; elide: Text.ElideRight; text: tile.title; font.family: fontSemi font.weight: Font.DemiBold; color: cStrong } Txt { width: parent.width; text: tile.bars; font.pixelSize: 11; color: cMuted } } Txt { id: tileArrow; anchors { right: parent.right; rightMargin: 13; verticalCenter: parent.verticalCenter } text: "\u2192"; color: cAccent } MouseArea { id: tileMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: useExample(modelData) } } } } Rectangle { // every score was closed while the window stays open id: noScoreBox visible: !hasScore && !needActivation anchors { left: parent.left; right: parent.right; bottom: composer.top leftMargin: 12; rightMargin: 12; bottomMargin: 10 } height: noScoreText.height + 22; radius: 14; color: cCard; border.color: cBorder Txt { id: noScoreText; x: 12; y: 11; width: parent.width - 24; wrapMode: Text.Wrap; color: cMuted text: "No score is open. Open one in MuseScore and I can work on it here." } } Rectangle { // activation: 6-digit code from the email, or resend the email (402 error=activate) id: actBox visible: needActivation anchors { left: parent.left; right: parent.right; bottom: composer.top leftMargin: 12; rightMargin: 12; bottomMargin: 10 } height: actCol.height + 22; radius: 14; color: cCard; border.color: cViolet Column { id: actCol; x: 12; y: 11; width: parent.width - 24; spacing: 8 Txt { width: parent.width; wrapMode: Text.Wrap; font.family: fontSemi; font.weight: Font.DemiBold; color: cStrong text: "Activate your free edits: type the 6-digit code from the email we sent you." } Row { spacing: 6 TextField { id: codeInput; width: 120; height: 34; placeholderText: "123456"; enabled: !actBusy maximumLength: 7; inputMethodHints: Qt.ImhDigitsOnly; font.pixelSize: 15; font.family: fontRegular color: cText; placeholderTextColor: cFaint; selectByMouse: true background: Rectangle { radius: 9; color: cBg border.color: codeInput.activeFocus ? cViolet : cBorder } onAccepted: submitCode() } Btn { text: "Activate"; primary: true; height: 34; onClicked: submitCode() enabled: !actBusy && codeInput.text.replace(/\D/g, "").length === 6 } } Btn { text: "Resend activation email"; enabled: !actBusy; onClicked: resendActivation() } } } Rectangle { // composer id: composer anchors { left: parent.left; right: parent.right; bottom: parent.bottom leftMargin: 12; rightMargin: 12; bottomMargin: 12 } height: inputScroll.height + 18; radius: 14; color: cCard border.color: input.activeFocus ? cViolet : cBorder Rectangle { // soft glow around the box while typing anchors.fill: parent; anchors.margins: -3; radius: 17; color: "transparent"; z: -1 border.color: cViolet; border.width: 3; opacity: input.activeFocus ? 0.22 : 0 } ScrollView { id: inputScroll anchors { left: parent.left; right: sendBtn.left; top: parent.top; leftMargin: 8; rightMargin: 4; topMargin: 9 } height: Math.min(Math.max(input.implicitHeight, 30), 120) TextArea { id: input wrapMode: TextEdit.Wrap; font.pixelSize: 13; font.family: fontRegular; enabled: !busy color: cText; placeholderTextColor: cFaint; selectByMouse: true; padding: 5 background: null placeholderText: isQuestionPending() ? "Answer the question, or tap an option above" : (showHero ? "What should we write?" : "Tell me what to write or change") Keys.onPressed: function(e) { if ((e.key === Qt.Key_Return || e.key === Qt.Key_Enter) && !(e.modifiers & Qt.ShiftModifier)) { e.accepted = true; send() } } } } Rectangle { id: sendBtn; width: 30; height: 30; radius: 10 anchors { right: parent.right; rightMargin: 9; bottom: parent.bottom; bottomMargin: 9 } property bool canSend: !busy && input.text.trim() !== "" color: sendMouse.containsMouse && canSend ? cVioletDark : cViolet; opacity: canSend ? 1 : 0.5 Txt { anchors.centerIn: parent; text: "↑"; color: "#ffffff"; font.pixelSize: 15; font.bold: true } MouseArea { id: sendMouse; anchors.fill: parent; hoverEnabled: true; enabled: sendBtn.canSend cursorShape: Qt.PointingHandCursor; onClicked: send() } ToolTip.visible: sendMouse.containsMouse; ToolTip.delay: 600 ToolTip.text: isQuestionPending() ? "Answer (Enter)" : "Send (Enter)" } } } // ---------- conversation logic ---------- function updatePlan(a) { var name = a.plan_name || "" if (a.edits_left !== undefined && a.edits_left !== null && a.limit) planLine = name + " \u00b7 " + a.edits_left + " of " + a.limit + " edits left" else planLine = name editsLeft = (a.edits_left !== undefined && a.edits_left !== null && a.limit) ? String(a.edits_left) : "" manageUrl = (a.manage_url && /^https:\/\//.test(a.manage_url)) ? a.manage_url : "" } // card (optional): { title, badge, cost, caption, chips (JSON), undoHint } function say(role, text, kind, opts, detail, url, card) { var c = card || {} 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 || "") !== "", url: url || "", title: c.title || "", badge: c.badge || "", cost: c.cost || "", preview: "", caption: "", chips: c.chips || "", undoHint: !!c.undoHint }) } // "Bars 1-8 - Over the new bass" / "Bars 1-8 (over the new bass)" -> tile with a title and a small line. function optParts(o) { var m = /^(.+?)\s+[\u2014\u2013-]\s+(.+)$/.exec(o) || /^(.+?)\s*\((.+)\)$/.exec(o) return m ? { title: m[1], sub: m[2] } : { title: o, sub: "" } } // Example tile / follow-up chip: fill the box (user may change the bar numbers), focus it, cursor at the end. Never sends. function useExample(t) { input.text = t input.forceActiveFocus() input.cursorPosition = input.text.length } function newChat() { if (busy) return chat.clear(); resetRequest(); sentCount = 0 // back to the first-open screen } 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) sayHello() 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. \"write a sustained string pad under bars 9-16\".", "", "") return } if (mode === "hosted" && apiKey === "") { say("user", t, "", ""); askForKey(); return } if (!curScore) { input.text = t; say("error", "Open a score first.", "", ""); return } say("user", t, "", "") if (needActivation && /^\d{3} ?\d{3}$/.test(t)) { codeInput.text = t; submitCode(); return } sentCount++ if (isQuestionPending()) { addAnswer(t) runCopilot(false) } else { resetRequest(); instruction = t; runCopilot(false) } } // "clarify" (free-text planner question) and "mode" (replace-or-voice-2) both take a typed or // tapped answer; "confirm" (plan-is-large) does not -- Apply anyway/Cancel are its only answers. function isQuestionPending() { return pendingKind === "clarify" || pendingKind === "mode" } 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) } // Drops the pending question without calling the server; the next text typed is a new request. function skipQuestion() { resetRequest() say("status", "Question skipped. Tell me what you'd like next.", "", "") input.forceActiveFocus() } function forceApply() { say("user", "Apply anyway", "", "") if (pendingKind === "mode") addAnswer("No more detail; use your best judgement.") runCopilot(true) } function runCopilot(force) { if (!curScore) { say("error", "Open a score first.", "", ""); return } var target = curScore.scoreName if (mode === "hosted") { // One fixed file (plugin folder, else system temp; see exportPath), overwritten each // request and deleted after upload. No hard-coded Unix paths, no shell commands. var src = exportPath() // 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. I tried to save a temporary copy here:\n" + src + "\n\nPlease check that this folder exists and that you are allowed to write to it. " + "If it keeps happening, reinstall the plugin into your Documents\\MuseScore4\\Plugins folder " + "(the Scorehand folder goes inside Plugins) and restart MuseScore.", "", "") return } postHosted(src, "", force, target); return } // ---- local (developer) mode, Linux/macOS ---- var stamp = Date.now() var wd = workDir !== "" ? workDir.replace(/\/+$/, "") + "/" : pluginDir() if (workDir !== "") { proc.startWithArgs("mkdir", ["-p", wd]); proc.waitForFinished(5000) } src = wd + "in-" + stamp + ".musicxml" var dst = wd + "out-" + stamp + ".musicxml" var rep = wd + "report-" + stamp + ".json" var done = wd + "done-" + stamp if (!writeScore(curScore, src, "musicxml")) { say("error", "Could not export the score to " + src + ".", "", ""); return } var sh = wd + "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("sh", [sh]) poll.start() } // ---------- activation (hosted mode) ---------- function actPost(path, body, done) { actBusy = true var x = new XMLHttpRequest() x.onreadystatechange = function() { if (x.readyState !== XMLHttpRequest.DONE) return actBusy = false var r = null try { r = JSON.parse(x.responseText) } catch (e) { r = null } done(x.status, r || {}) } x.open("POST", serverUrl.replace(/\/+$/, "") + path) x.setRequestHeader("Content-Type", "application/json") x.send(JSON.stringify(body)) } function submitCode() { var c = codeInput.text.replace(/\D/g, "") if (c.length !== 6 || actBusy) return actPost("/v1/activate", { key: apiKey, code: c }, function(st, r) { if (st === 0) { say("error", "Could not reach the Scorehand service at " + serverUrl + ".", "", ""); return } if (r.ok) { needActivation = false; codeInput.text = "" say("bot", r.message || "Activated.", "", "") if (lastBlocked !== "" && input.text === "") { input.text = lastBlocked; input.forceActiveFocus() } lastBlocked = "" } else say("error", r.message || ("Activation failed (HTTP " + st + ")."), "", "") }) } function resendActivation() { if (actBusy) return actPost("/v1/resend-activation", { key: apiKey }, function(st, r) { if (st === 0) { say("error", "Could not reach the Scorehand service at " + serverUrl + ".", "", ""); return } if (r.result === "already") needActivation = false say(r.ok ? "bot" : "error", r.message || ("Could not resend (HTTP " + st + ")."), "", "") }) } 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 statusTimer.stop() say("error", "The Scorehand service did not answer within " + requestTimeoutSec + "s.", "", "") resetRequest() } return } fio.source = job.done if (!fio.exists()) { if (elapsed > 300) { stop(); busy = false; say("error", "Scorehand 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() try { fio.remove() } catch (e) { } // don't leave score copies lying around 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() startStatus() xhr.onreadystatechange = function() { if (xhr.readyState !== XMLHttpRequest.DONE || job.xhr !== xhr) return job.xhr = null; poll.stop(); busy = false statusTimer.stop() handleHosted(xhr.status, xhr.responseText) } xhr.open("POST", serverUrl.replace(/\/+$/, "") + "/v1/edit") xhr.setRequestHeader("Content-Type", "application/json") xhr.setRequestHeader("Authorization", "Bearer " + apiKey) xhr.setRequestHeader("X-Copilot-Request-Id", reqId) xhr.send(JSON.stringify({ musicxml: xml, instruction: instruction, history: history, yes: !!force, inplace: true, plugin: root.version })) console.log("copilot hosted request -> " + serverUrl + " (" + xml.length + " bytes)") } // ---- live stages + bar picture: GET /edit-status/. Any failure just means nothing new to show. ---- function startStatus() { reqId = Date.now().toString(36) + "-" + Math.floor(Math.random() * 1e9).toString(36) progSteps = []; progBars = null; progTitle = ""; previewRow = -1 statusTimer.restart() } function pollStatus() { if (statusBusy || reqId === "") return statusBusy = true var x = new XMLHttpRequest() x.onreadystatechange = function() { if (x.readyState !== XMLHttpRequest.DONE) return statusBusy = false var r = null try { r = JSON.parse(x.responseText) } catch (e) { r = null } if (x.status !== 200 || !r || !r.ok) { if (previewRow >= 0) previewWait(); return } if (previewRow >= 0) { var p = r.preview || {} if (p.state === "ready" && previewRow < chat.count) { chat.setProperty(previewRow, "preview", String(p.image || "")) chat.setProperty(previewRow, "caption", String(p.caption || "")) previewRow = -1; statusTimer.stop() Qt.callLater(function() { list.positionViewAtEnd() }) } else if (p.state === "pending") previewWait() else { previewRow = -1; statusTimer.stop() } } else if (busy) { progSteps = r.steps || []; progBars = r.bars || null; progTitle = r.title || "" } } x.open("GET", serverUrl.replace(/\/+$/, "") + "/edit-status/" + reqId) x.setRequestHeader("Authorization", "Bearer " + apiKey) x.send() } function previewWait() { if (++previewTries > 12) { previewRow = -1; statusTimer.stop() } } // about 18 s, then no picture function waitForPreview(row) { previewRow = row; previewTries = 0; statusTimer.restart() } Timer { id: scrollDown; interval: 150; onTriggered: list.positionViewAtEnd() } // after the card has its new height Timer { id: statusTimer; interval: 1500; repeat: true; onTriggered: pollStatus() } 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 Scorehand service at " + serverUrl + "." : httpStatus === 413 ? "This score is too large for the Scorehand service." : "Unexpected reply from the Scorehand service (HTTP " + httpStatus + ").", "", "", String(txt || "").slice(0, 800)) resetRequest(); return } var rep = r.report || {} if (r.account) updatePlan(r.account) var cost = r.usage ? "(" + (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", "", "confirm", "", (r.options || []).join("\n"), "", { title: r.question }) else say("bot", "", pendingKind, (r.options || []).join("\n"), "", "", { title: r.question }) return } if (r.status === "error") { if (r.error === "activate") { needActivation = true if (!r.no_email_help) lastBlocked = instruction } if ((r.error === "no_balance" || r.error === "plan_limit") && editsLeft !== "") editsLeft = "0" say(rep.buy_url ? "limit" : "error", rep.message || ("Error (HTTP " + httpStatus + ")."), "", "", "", rep.buy_url || "") resetRequest(); return } if (r.status === "applied") finishApplied(rep, r.edits || [], r.edit_notes || [], cost, r.edit_summary, r.buy_url || "", r.result, r.preview) else say("bot", rep.message || "Nothing to change.", "", "", [logText(rep), cost].filter(Boolean).join("\n")) 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", "", "confirm", "", (rep.options || []).join("\n"), "", { title: rep.question }) else say("bot", "", rep.kind, opts, "", "", { title: rep.question }) 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, buyUrl, result, previewState) { 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, "♯") + (/^\d/.test(String(ch[k])) ? " bars " : " ") + ch[k]) var msg = where.length ? where.join("; ") : (rep.message || "") var extra = notes.concat(res.problems) var n = res.count.write || 0, card = result || {} say("bot", msg + (extra.length ? "\nNote: " + extra.join("; ") + "." : ""), "", "", logText(rep), buyUrl || "", { title: card.title || "Your edit is in the score", badge: n > 0 ? n + (n === 1 ? " note" : " notes") : "", cost: cost, undoHint: true, chips: (card.suggestions && card.suggestions.length) ? JSON.stringify(card.suggestions) : "" }) if (previewState === "pending") waitForPreview(chat.count - 1) } // ---------- 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 } // Drum staves: MuseScore's addNote moves every note to the voice its drumset entry says (bass drum // and pedal hi-hat = voice 2), ignoring the cursor's voice. A tuplet started on voice 1 then got // its notes in voice 2 and left both voices with the wrong length ("voice too long: 9/8 in 4/4", // nc-f4c7 2026-10-31). So split the events by drumset voice and write each voice on its own track. function drumsetOf(op) { try { var p = curScore.parts[op.part]; return (p && p.hasDrumStaff) ? p.instruments[0].drumset : null } catch (e) { return null } } function splitDrumEvents(ev, ds) { var byV = {}, used = [], i, k for (i = 0; i < ev.length; i++) for (k = 0; ev[i].n && k < ev[i].n.length; k++) { var dv = ds.voice(ev[i].n[k][0]); if (!(dv >= 0 && dv < 4)) dv = 0 if (used.indexOf(dv) < 0) used.push(dv) } if (!used.length) used.push(0) for (var u = 0; u < used.length; u++) { var out = [] for (i = 0; i < ev.length; i++) { var x = ev[i], y = { t: x.t, d: x.d } if (x.tup) y.tup = x.tup var ns = [] for (k = 0; x.n && k < x.n.length; k++) { var v2 = ds.voice(x.n[k][0]); if (!(v2 >= 0 && v2 < 4)) v2 = 0 if (v2 === used[u]) ns.push(x.n[k]) } if (ns.length) y.n = ns; else y.rest = true out.push(y) } byV[used[u]] = collapseRestTuplets(out) } return { voices: used.sort(), events: byV } } // a tuplet group that is all rests in this drum voice -> one plain rest (no empty "3" brackets) function collapseRestTuplets(ev) { var out = [] for (var i = 0; i < ev.length; i++) { var x = ev[i] if (!x.tup) { out.push(x); continue } var j = i, left = x.tup[2], allRest = true while (j < ev.length && left > 0) { if (!ev[j].rest) allRest = false left -= ev[j].d * x.tup[1] / x.tup[0] j++ } if (allRest && left === 0) { out.push({ t: x.t, d: x.tup[2], rest: true }); i = j - 1 } else out.push(x) } return out } function writeEvents(op, st, staff, v, ev) { 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 written = [] for (var i = 0; i < ev.length; i++) { var x = ev[i] if (c.tick !== x.t || c.track !== track) { 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) } return written } function opWrite(op, st) { var staff = staffOf(op), v = 0 var ds = drumsetOf(op) if (ds) { // drum voices are fixed by the drumset: clear the written voices (all of them on replace) var sp = splitDrumEvents(op.events, ds) if (op.replace) for (var k3 = 0; k3 < 4; k3++) { if (sp.voices.indexOf(k3) >= 0) continue // overwritten below anyway for (var pass3 = 0; pass3 < 2; pass3++) { // chords -> rests -> gone var els3 = elementsIn(staff * 4 + k3, op.start, op.end) for (var e3 = 0; e3 < els3.length; e3++) removeElement(els3[e3]) } } var n3 = 0 for (var dvi = 0; dvi < sp.voices.length; dvi++) { var wr = writeEvents(op, st, staff, sp.voices[dvi], sp.events[sp.voices[dvi]]) n3 += wr.length } st.count.write = (st.count.write || 0) + n3 st.touched.push({ staff: staff, start: op.start, end: op.end }) return } 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 written = writeEvents(op, st, staff, v, op.events) st.touched.push({ staff: staff, start: op.start, end: op.end }) // 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)") } // cursor on `staff` at exactly `tick` on any voice that has a chord/rest there function cursorWithElement(staff, tick) { 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) return c } return null } function dynSym(txt) { var map = { p: "dynamicPiano", m: "dynamicMezzo", f: "dynamicForte", s: "dynamicSforzando", z: "dynamicZ", r: "dynamicRinforzando", n: "dynamicNiente" } var out = "" for (var i = 0; i < txt.length; i++) { if (!map[txt[i]]) return txt out += "" + map[txt[i]] + "" } return out } function opMark(op, st) { var staff = staffOf(op), miss = 0 for (var i = 0; i < op.marks.length; i++) { var x = op.marks[i], c = cursorWithElement(staff, x.t) if (!c) { miss++; continue } var el if (x.kind === "dyn") { var ann = c.segment.annotations // a new dynamic replaces the one already there for (var a = 0; ann && a < ann.length; a++) if (ann[a].type === Element.DYNAMIC && ann[a].track - ann[a].track % 4 === c.track - c.track % 4) removeElement(ann[a]) el = newElement(Element.DYNAMIC); c.add(el); el.text = dynSym(String(x.text)) } else if (x.kind === "harmony") { el = newElement(Element.HARMONY); c.add(el); el.text = String(x.text) } // HARMONY: add BEFORE setting text (else MS crashes) else { el = newElement(Element.STAFF_TEXT); el.text = String(x.text); c.add(el) } st.count.mark = (st.count.mark || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " marking(s) skipped (no note there)") } function opHairpin(op, st) { var staff = staffOf(op), miss = 0 for (var i = 0; i < op.pins.length; i++) { var x = op.pins[i], c = cursorWithElement(staff, x.t1) if (!c) { miss++; continue } var hp = newElement(Element.HAIRPIN) hp.hairpinType = x.type === "dim" ? 1 : 0 hp.spannerTick = frac(x.t1) hp.track = c.track c.add(hp) hp.spannerTicks = frac(x.t2 - x.t1) st.count.hairpin = (st.count.hairpin || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " hairpin(s) skipped (no note there)") } // ---- score-level edits. Each one is best-effort: a failure is reported as a note with the manual // steps and never rolls back the note edits of the same request. // Written-minus-concert key offset of a staff in fifths (Bb trumpet +2, F horn +1, Eb alto +3): // tpc2 - tpc1 of the first note on the staff. cursor.add(KEYSIG) does NOT transpose by itself. function keyOffset(s) { var c = curScore.newCursor(); c.staffIdx = s; c.voice = 0; c.rewind(0) while (c.segment) { var e = c.element if (e && e.type === Element.CHORD && e.notes.length) return e.notes[0].tpc2 - e.notes[0].tpc1 if (!c.next()) break } return 0 } function opKeySig(op, st) { var done = 0 for (var s = 0; s < curScore.nstaves; s++) { try { var c = curScore.newCursor(); c.staffIdx = s; c.voice = 0; c.rewindToTick(op.t) if (!c.segment || c.tick !== op.t) continue var ks = newElement(Element.KEYSIG), w = op.fifths + keyOffset(s) if (w > 7) w -= 12; else if (w < -7) w += 12 ks.concertKey = op.fifths ks.actualKey = w c.add(ks); done++ } catch (e) { console.log("copilot keysig staff " + s + ": " + e) } } if (done) st.count.keysig = done else st.problems.push("couldn't set the key signature from here: drag the key from the Key signatures palette onto bar " + op.bar) } readonly property var textMeta: ({ title: "workTitle", subtitle: "subtitle", composer: "composer", lyricist: "lyricist" }) function opText(op, st) { try { curScore.setMetaTag(textMeta[op.field] || op.field, op.text) } catch (e0) {} // An existing title/composer... text in the top frame is rewritten in place (addText alone // would stack a second title on top of the old one). try { var tid = { title: Tid.TITLE, subtitle: Tid.SUBTITLE, composer: Tid.COMPOSER, lyricist: Tid.LYRICIST }[op.field] var mb = curScore.firstMeasure, found = null while (mb && mb.prev) mb = mb.prev for (; tid !== undefined && mb && mb.type !== Element.MEASURE && !found; mb = mb.next) { var els = mb.elements || [] for (var i = 0; i < els.length; i++) { if (els[i].subStyle !== tid) continue if (!found) { found = els[i]; found.text = op.text } else removeElement(els[i]) } } if (found) { st.count.text = (st.count.text || 0) + 1; return } } catch (e1) { console.log("copilot text in frame: " + e1) } try { curScore.addText(op.field, op.text); st.count.text = (st.count.text || 0) + 1 } catch (e) { st.problems.push("couldn't add the " + op.field + " text: use Add > Text > " + op.field) } } function layoutBreakType(t) { try { return t === "page" ? LayoutBreak.PAGE : LayoutBreak.LINE } catch (e) { return t === "page" ? 0 : 1 } } function measureAt(tick) { var c = curScore.newCursor(); c.track = 0; c.rewindToTick(tick) return (c.segment && c.measure) ? { cursor: c, measure: c.measure } : null } function opLayout(op, st) { var miss = 0 if (op.clear) { // drop existing system breaks in the range first try { var m = measureAt(op.clear[0]) ? measureAt(op.clear[0]).measure : null while (m && m.firstSegment && m.firstSegment.tick <= op.clear[1]) { var els = m.elements || [] for (var i = els.length - 1; i >= 0; i--) if (els[i].type === Element.LAYOUT_BREAK && els[i].layoutBreakType === layoutBreakType("line")) removeElement(els[i]) m = m.nextMeasure } } catch (e0) { console.log("copilot layout clear: " + e0) } } for (var b = 0; b < op.breaks.length; b++) { var x = op.breaks[b], at = measureAt(x.t) if (!at) { miss++; continue } try { var lb = newElement(Element.LAYOUT_BREAK) lb.layoutBreakType = layoutBreakType(x.type) at.cursor.add(lb) st.count.layout = (st.count.layout || 0) + 1 } catch (e) { miss++; console.log("copilot layout break: " + e) } } if (miss) st.problems.push(miss + " break(s) not added: select the bar and press Enter (system break) or Ctrl+Enter (page break)") } // Lyrics (0.8): one syllable per existing chord. syllabic: MuseScore SINGLE 0, BEGIN 1, END 2, MIDDLE 3. readonly property var sylCode: ({ single: 0, begin: 1, end: 2, middle: 3 }) function opLyrics(op, st) { var staff = staffOf(op), miss = 0, verse = Math.max(0, (op.verse || 1) - 1) for (var i = 0; i < op.items.length; i++) { var x = op.items[i], ch = findChord(staff, x.t, x.p, false) if (!ch) { miss++; continue } var old = ch.lyrics || [] for (var o = old.length - 1; o >= 0; o--) if (old[o].verse === verse) removeElement(old[o]) var l = newElement(Element.LYRICS) l.text = String(x.text) l.verse = verse l.track = ch.track ch.add(l) try { l.syllabic = sylCode[x.syl] || 0 } catch (e1) { } if (x.ext) { try { l.lyricTicks = frac(x.ext) } catch (e2) { } } st.count.lyrics = (st.count.lyrics || 0) + 1 } if (miss) st.problems.push(op.name + ": " + miss + " syllable(s) skipped (note not found)") } // After the edits, BEFORE endCmd: every voice of every bar a write touched must still fit its bar // (voice 1 exactly, voices 2-4 at most). Otherwise the whole request is rolled back. function measureAtTick(tick) { var c = curScore.newCursor(); c.track = 0; c.rewindToTick(tick) return c.segment ? c.measure : null } function checkBars(st) { var bad = [] for (var i = 0; i < st.touched.length && bad.length < 4; i++) { var r = st.touched[i], m = measureAtTick(r.start), guard = 0 while (m && m.firstSegment && m.firstSegment.tick < r.end && guard++ < 2000) { var t0 = m.firstSegment.tick, len = m.ticks.ticks, t1 = t0 + len for (var v = 0; v < 4; v++) { var c = curScore.newCursor(); c.track = r.staff * 4 + v; c.rewindToTick(t0) var sum = 0, endT = t0 while (c.segment && c.tick < t1) { var e = c.element if (e && c.tick >= t0 && (e.type === Element.CHORD || e.type === Element.REST)) { var a = e.actualDuration ? e.actualDuration.ticks : e.duration.ticks sum += a; endT = Math.max(endT, c.tick + a) } if (!c.next()) break } if (sum > len || endT > t1 || (v === 0 && sum > 0 && sum < len)) bad.push("bar " + (m.no + 1) + " staff " + (r.staff + 1) + " voice " + (v + 1) + " (" + sum + "/" + len + " ticks)") } m = m.nextMeasure } } return bad } // Applies all ops as ONE undo step. Throws nothing; returns {error} or {problems, count}. function applyEdits(ops) { var st = { problems: [], count: {}, touched: [] } curScore.startCmd("Scorehand") 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 if (op.op === "mark") opMark(op, st) else if (op.op === "hairpin") opHairpin(op, st) else if (op.op === "keysig") opKeySig(op, st) else if (op.op === "text") opText(op, st) else if (op.op === "layout") opLayout(op, st) else if (op.op === "lyrics") opLyrics(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) } } var bad = [] try { bad = checkBars(st) } catch (e4) { console.log("copilot bar check: " + e4) } if (bad.length) { curScore.endCmd(true) console.log("copilot bar check failed: " + bad.join("; ")) return { error: "it would have left " + bad.join(", ") + " with the wrong length" } } curScore.endCmd() console.log("copilot applied in place: " + JSON.stringify(st.count)) return st } }