From 38a00c5edf4dfc1e90ccc78d2e50f142c77ea2d2 Mon Sep 17 00:00:00 2001 From: Stephan Roos Date: Fri, 27 Feb 2026 19:44:07 +0200 Subject: [PATCH] Tab generator. Settings non-functional --- whistle_tab_generator.qml | 1008 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 whistle_tab_generator.qml diff --git a/whistle_tab_generator.qml b/whistle_tab_generator.qml new file mode 100644 index 0000000..1e7aa99 --- /dev/null +++ b/whistle_tab_generator.qml @@ -0,0 +1,1008 @@ +//============================================================================= +// MuseScore +// Music Composition & Notation +// +// Generalized whistle tab plugin +// Requires the tin whistle font downloaded from Blayne Chastain: +// https://www.blaynechastain.com/tin-whistle-tab-sibelius-plugin/ +// +// Based on the Note Names Plugin which is: +// Copyright (C) 2012 Werner Schweer +// Copyright (C) 2013 - 2016 Joachim Schmitz +// Copyright (C) 2014 Jörn Eichler +// +// and also based on the Recorder Woodwind Tablature plugin: +// Copyright (C)2011 Dario Escobedo, Werner Schweer, Jens Iwanenko and others +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 +// as published by the Free Software Foundation and appearing in +// the file LICENCE +//============================================================================= + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import MuseScore 3.0 +import FileIO 3.0 + +MuseScore { + version: "4.1" + title: "ASCII Whistle Fingering" + description: "Inserts ASCII fingering diagrams using binary dictionary" + pluginType: "dialog" + categoryCode: "composing-arranging-tools" + thumbnailName: "whistle_tab.png" + + width: 790 + height: 644 + + property bool isDarkMode: false + property color textColor: isDarkMode ? "#ffffff" : "#000000" + property color backgroundColor: isDarkMode ? "#333333" : "#ffffff" + + //--------------------------------------------------------- + // USER SETTINGS (defaults) + //--------------------------------------------------------- + + property int userFontSize: 14 + property int userJustification: 1 // 0=left,1=center,2=right + property real userOffsetY: 3.0 + property real userLineSpacing: 0.5 + property string userFormatString: "$1 \n$2\n$3\n$4\n$5\n$6\n$7\n$8\n$9\n$+" + + // For undo/redo + property var history: 0 + property var modified: false + + //--------------------------------------------------------- + // FINGERING DICTIONARY + // Last bit = plus sign indicator + //--------------------------------------------------------- + + //Dictionary for your whistle, specifying the note and the fingering pattern (starting from the fipple end). + //2 indicates a closed hole, 1 indicates a half hole, 0 indicates open hole. The last bit is a reserved flag for whether a (+) symbol should be drawn. + property var fingeringDict: ({ + "G5": "1111111111", + "A5": "1111111100", + "B5": "1111111000", + "C6": "1111110000", + "D6": "1111100000", + "E6": "1111000000", + "F#6": "1110000000", + "G6": "1100000001" + }) + + onRun: { + if (!curScore) { + error("No score open.\nThis plugin requires an open score to run.\n") + quit() + } + } + + function getHistory() { + if (history == 0) { + history = new commandHistory() + } + return history + } + + function error(errorMessage) { + errorDialog.text = qsTr(errorMessage) + errorDialog.open() + } + + SystemPalette { + id: systemPalette + onWindowTextChanged: { + // Detect if we're in dark mode by comparing text and background brightness + var textBrightness = getBrightness(windowText) + var windowBrightness = getBrightness(window) + isDarkMode = textBrightness > windowBrightness + } + Component.onCompleted: { + // Initial detection + var textBrightness = getBrightness(windowText) + var windowBrightness = getBrightness(window) + isDarkMode = textBrightness > windowBrightness + } + } + + function getBrightness(color) { + // Simple brightness calculation (0-255) + return 0.299 * color.r * 255 + 0.587 * color.g * 255 + 0.114 * color.b * 255 + } + + //--------------------------------------------------------- + // MIDI → NOTE NAME + //--------------------------------------------------------- + + function pitchToName(midiPitch) { + var names = ["C","C#","D","D#","E","F", + "F#","G","G#","A","A#","B"] + + var pitchClass = midiPitch % 12 + var octave = Math.floor(midiPitch / 12) - 1 + + return names[pitchClass] + octave + } + + //--------------------------------------------------------- + // BUILD ASCII DIAGRAM + //--------------------------------------------------------- + + //Build fingering text using binary representation of the fingering for a note. + //The last bit is the plus bit, indicating the first octave (0) or the second octave (1), to be annotated with a + + function buildFingeringText(binaryString, formatString) { + + //------------------------------------------------- + // Basic validation + //------------------------------------------------- + + if (!binaryString || binaryString.length < 2) + return "Invalid fingering pattern. Length is wrong" + + if (!formatString || typeof formatString !== "string") + return "ERR" + + var holeCount = binaryString.length - 1 + var plusBit = binaryString[binaryString.length - 1] + + //------------------------------------------------- + // Validate allowed characters + //------------------------------------------------- + + for (var i = 0; i < holeCount; i++) { + if (binaryString[i] !== "0" && + binaryString[i] !== "1" && + binaryString[i] !== "2") + return "Invalid Fingering Pattern Value. Must be one of 0,1,2" + } + + if (plusBit !== "0" && plusBit !== "1") + return "Invalid Plus Bit" + + //------------------------------------------------- + // Clone format string + //------------------------------------------------- + + var output = formatString + + //------------------------------------------------- + // Replace numbered placeholders ($1 … $N) + //------------------------------------------------- + + for (var i = 0; i < holeCount; i++) { + + var symbol + + if (binaryString[i] === "2") + symbol = "●" + else if (binaryString[i] === "1") + symbol = "◐" + else + symbol = "○" + + var token = new RegExp("\\$" + (i+1) + "(?!\\d)", "g") + output = output.replace(token, symbol) + } + + //------------------------------------------------- + // Replace plus placeholder ($+) + //------------------------------------------------- + + var plusSymbol = (plusBit === "1") ? "+" : " " + output = output.replace(/\$\+/g, plusSymbol) + + //------------------------------------------------- + // Final validation + //------------------------------------------------- + + if (/\$\d+|\$\+/.test(output)) + return "Regex Validation Error" + + return output + } + + //--------------------------------------------------------- + // APPLY TEXT FORMATTING + //--------------------------------------------------------- + + function formatText(text) { + text.fontSize = userFontSize + text.placement = Placement.BELOW + text.autoplace = false + text.offsetY = userOffsetY + text.lineSpacing = userLineSpacing + + if (userJustification === 0) + text.align = Align.LEFT + else if (userJustification === 1) + text.align = Align.HCENTER + else + text.align = Align.RIGHT + } + + //--------------------------------------------------------- + // APPLY FINGERINGS + //--------------------------------------------------------- + + function applyFingerings() { + if (typeof curScore === "undefined") { + error("No score selected") + return false + } + + curScore.startCmd() + + var cursor = curScore.newCursor() + cursor.rewind(0) + + while (cursor.segment) { + if (cursor.element && cursor.element.type === Element.CHORD) { + var chord = cursor.element + var midi = chord.notes[0].pitch + var noteName = pitchToName(midi) + + var text = newElement(Element.STAFF_TEXT) + + if (!fingeringDict[noteName]) { //note not in fingering dictionary + text.text = "☒" + } else { + var diagram = buildFingeringText(fingeringDict[noteName], userFormatString) + text.text = diagram + } + + cursor.add(text) + formatText(text) + } + cursor.next() + } + + curScore.endCmd() + return true + } + + function getFirstNoteName() { + for (var note in fingeringDict) { + return note // Returns the first key in the dictionary + } + return "G5" // Fallback if dictionary is empty + } + + function getPreviewText() { + var firstNote = getFirstNoteName() + if (fingeringDict[firstNote]) { + // Use the current formatInput.text instead of userFormatString + // to show real-time updates as the user types + return buildFingeringText(fingeringDict[firstNote], formatInput.text) + } + return "No preview available" + } + + function updatePreview() { + previewLabel.text = getPreviewText() + } + + function setUserFontSize(size) { + var oldSize = userFontSize + getHistory().add( + function() { userFontSize = oldSize }, + function() { userFontSize = size; fontSizeSpin.value = size }, + "font size" + ) + } + + function setUserJustification(just) { + var oldJust = userJustification + getHistory().add( + function() { userJustification = oldJust; justCombo.currentIndex = oldJust }, + function() { userJustification = just; justCombo.currentIndex = just }, + "justification" + ) + } + + function setUserOffsetY(offset) { + var oldOffset = userOffsetY + getHistory().add( + function() { userOffsetY = oldOffset; offsetSpin.value = oldOffset * 10 }, + function() { userOffsetY = offset; offsetSpin.value = offset * 10 }, + "vertical offset" + ) + } + + function setUserLineSpacing(spacing) { + var oldSpacing = userLineSpacing + getHistory().add( + function() { userLineSpacing = oldSpacing; spacingSpin.value = oldSpacing * 10 }, + function() { userLineSpacing = spacing; spacingSpin.value = spacing * 10 }, + "line spacing" + ) + } + + function setUserFormatString(format) { + var oldFormat = userFormatString + getHistory().add( + function() { userFormatString = oldFormat; formatInput.text = oldFormat }, + function() { userFormatString = format; formatInput.text = format }, + "format string" + ) + } + + function setModified(state) { + var oldModified = modified + getHistory().add( + function() { modified = oldModified }, + function() { modified = state }, + "modified" + ) + } + + function formatStringChanged() { + getHistory().begin() + setModified(true) + setUserFormatString(formatInput.text) + getHistory().end() + } + + function fontSizeChanged() { + getHistory().begin() + setModified(true) + setUserFontSize(fontSizeSpin.value) + updatePreview() + getHistory().end() + } + + function justificationChanged() { + getHistory().begin() + setModified(true) + setUserJustification(justCombo.currentIndex) + updatePreview() + getHistory().end() + } + + function offsetYChanged() { + getHistory().begin() + setModified(true) + setUserOffsetY(offsetSpin.value / 10) + updatePreview() + getHistory().end() + } + + function lineSpacingChanged() { + getHistory().begin() + setModified(true) + setUserLineSpacing(spacingSpin.value / 10) + updatePreview() + getHistory().end() + } + + function formatCurrentValues() { + var data = { + fontSize: userFontSize, + justification: userJustification, + offsetY: userOffsetY, + lineSpacing: userLineSpacing, + formatString: userFormatString, + fingeringDict: fingeringDict + } + return JSON.stringify(data) + } + + function restoreSavedValues(data) { + getHistory().begin() + setUserFontSize(data.fontSize) + setUserJustification(data.justification) + setUserOffsetY(data.offsetY) + setUserLineSpacing(data.lineSpacing) + setUserFormatString(data.formatString) + // Restore fingering dict if present + if (data.hasOwnProperty('fingeringDict')) { + fingeringDict = data.fingeringDict + } + getHistory().end() + } + + Item { + anchors.fill: parent + + GridLayout { + columns: 2 + anchors.fill: parent + anchors.margins: 10 + + // Set up system palette for color management + SystemPalette { id: sysPal } + + // TOP ROW - Text Formatting Options + GroupBox { + title: "Text Formatting" + Layout.fillWidth: true + Layout.columnSpan: 2 + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + label: Label { + text: parent.title + color: sysPal.windowText + background: Rectangle { color: sysPal.window } + } + + GridLayout { + columns: 4 + anchors.fill: parent + anchors.margins: 10 + columnSpacing: 20 + rowSpacing: 10 + + Label { + text: "Font Size:" + color: sysPal.windowText + Layout.alignment: Qt.AlignRight + } + // Font Size + TextField { + id: fontSizeField + Layout.preferredWidth: 80 + text: userFontSize + color: sysPal.text + selectionColor: sysPal.highlight + selectedTextColor: sysPal.highlightedText + validator: IntValidator { bottom: 6; top: 72 } + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + + // Handle Enter key press + onAccepted: { + var newValue = parseInt(text) + if (!isNaN(newValue) && newValue >= 6 && newValue <= 72) { + if (newValue !== userFontSize) { + fontSizeChanged(newValue) + } + } else { + text = userFontSize // Revert to previous value if invalid + } + } + + // Handle focus loss + onActiveFocusChanged: { + if (!activeFocus) { + var newValue = parseInt(text) + if (!isNaN(newValue) && newValue >= 6 && newValue <= 72) { + if (newValue !== userFontSize) { + fontSizeChanged(newValue) + } + } else { + text = userFontSize // Revert to previous value if invalid + } + } + } + + // Update preview in real-time as user types + onTextChanged: { + var newValue = parseInt(text) + if (!isNaN(newValue) && newValue >= 6 && newValue <= 72) { + // Temporarily update preview without committing to history + previewLabel.font.pointSize = newValue + } + } + } + Label { + text: "Justification:" + color: sysPal.windowText + Layout.alignment: Qt.AlignRight + } + ComboBox { + id: justCombo + Layout.preferredWidth: 120 + model: ["Left", "Center", "Right"] + currentIndex: userJustification + onActivated: justificationChanged() + contentItem: Text { + text: justCombo.displayText + color: sysPal.windowText + font: justCombo.font + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + } + + Label { + text: "Vertical Offset:" + color: sysPal.windowText + Layout.alignment: Qt.AlignRight + } + // Vertical Offset + TextField { + id: offsetField + Layout.preferredWidth: 80 + text: userOffsetY.toFixed(1) + color: sysPal.text + selectionColor: sysPal.highlight + selectedTextColor: sysPal.highlightedText + validator: DoubleValidator { bottom: -20.0; top: 20.0; decimals: 1 } + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + + onAccepted: { + var newValue = parseFloat(text) + if (!isNaN(newValue) && newValue >= -20.0 && newValue <= 20.0) { + if (newValue !== userOffsetY) { + offsetYChanged(newValue) + } + } else { + text = userOffsetY.toFixed(1) + } + } + + onActiveFocusChanged: { + if (!activeFocus) { + var newValue = parseFloat(text) + if (!isNaN(newValue) && newValue >= -20.0 && newValue <= 20.0) { + if (newValue !== userOffsetY) { + offsetYChanged(newValue) + } + } else { + text = userOffsetY.toFixed(1) + } + } + } + + // Offset doesn't affect preview content, so no onTextChanged needed + } + + Label { + text: "Line Spacing:" + color: sysPal.windowText + Layout.alignment: Qt.AlignRight + } + // Line Spacing + TextField { + id: spacingField + Layout.preferredWidth: 80 + text: userLineSpacing.toFixed(1) + color: sysPal.text + selectionColor: sysPal.highlight + selectedTextColor: sysPal.highlightedText + validator: DoubleValidator { bottom: 0.5; top: 3.0; decimals: 1 } + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + + onAccepted: { + var newValue = parseFloat(text) + if (!isNaN(newValue) && newValue >= 0.5 && newValue <= 3.0) { + if (newValue !== userLineSpacing) { + lineSpacingChanged(newValue) + } + } else { + text = userLineSpacing.toFixed(1) + } + } + + onActiveFocusChanged: { + if (!activeFocus) { + var newValue = parseFloat(text) + if (!isNaN(newValue) && newValue >= 0.5 && newValue <= 3.0) { + if (newValue !== userLineSpacing) { + lineSpacingChanged(newValue) + } + } else { + text = userLineSpacing.toFixed(1) + } + } + } + + // Line spacing doesn't affect preview content, so no onTextChanged needed + } + } + } + + // MIDDLE ROW - Save/Load/Undo/Redo Buttons + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignRight + spacing: 10 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + + Button { + id: saveButton + text: qsTranslate("PrefsDialogBase", "Save Settings") + contentItem: Text { + text: saveButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + saveDialog.folder = filePath + saveDialog.visible = true + } + } + + Button { + id: loadButton + text: qsTranslate("PrefsDialogBase", "Load Settings") + contentItem: Text { + text: loadButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + loadDialog.folder = filePath + loadDialog.visible = true + } + } + + Button { + id: undoButton + text: qsTranslate("PrefsDialogBase", "Undo") + contentItem: Text { + text: undoButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + getHistory().undo() + // Update text fields after undo + fontSizeField.text = userFontSize + offsetField.text = userOffsetY.toFixed(1) + spacingField.text = userLineSpacing.toFixed(1) + justCombo.currentIndex = userJustification + } + } + + Button { + id: redoButton + text: qsTranslate("PrefsDialogBase", "Redo") + contentItem: Text { + text: redoButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + getHistory().redo() + // Update text fields after redo + fontSizeField.text = userFontSize + offsetField.text = userOffsetY.toFixed(1) + spacingField.text = userLineSpacing.toFixed(1) + justCombo.currentIndex = userJustification + } + } + } + + // BOTTOM ROW - Format String and Preview + GroupBox { + title: "Format String & Preview" + Layout.fillWidth: true + Layout.columnSpan: 2 + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + label: Label { + text: parent.title + color: sysPal.windowText + background: Rectangle { color: sysPal.window } + } + + RowLayout { + width: parent.width + spacing: 20 + anchors.margins: 10 + + // Format String Area (left side) + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 5 + + Label { + text: "Format String:" + color: sysPal.windowText + font.bold: true + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 200 + clip: true + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + + TextArea { + id: formatInput + width: parent.width + height: contentHeight + wrapMode: TextEdit.Wrap + text: userFormatString + color: sysPal.text + selectionColor: sysPal.highlight + selectedTextColor: sysPal.highlightedText + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + property var previousText: userFormatString + onEditingFinished: { + formatStringChanged() + updatePreview() + } + onTextChanged: { + updatePreview() + } + } + } + + Label { + text: "Use placeholders like $1, $2, ... and $+ for the plus indicator" + font.pixelSize: 10 + color: sysPal.windowText + } + } + + // Preview Area (right side) + GroupBox { + title: "Preview (" + getFirstNoteName() + ")" + Layout.preferredWidth: 200 + Layout.fillHeight: true + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + label: Label { + text: parent.title + color: sysPal.windowText + background: Rectangle { color: sysPal.window } + } + + ScrollView { + anchors.fill: parent + anchors.margins: 5 + clip: true + background: Rectangle { + color: sysPal.window + border.color: sysPal.mid + } + + Label { + id: previewLabel + text: getPreviewText() + font.family: "Courier" + font.pointSize: userFontSize + color: sysPal.windowText + wrapMode: Text.WordWrap + width: parent.width + } + } + } + } + } + + // BOTTOM BUTTONS - Apply and Cancel + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignRight + spacing: 10 + Layout.topMargin: 10 + + Button { + id: applyButton + text: qsTranslate("PrefsDialogBase", "Apply") + contentItem: Text { + text: applyButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + if (applyFingerings()) { + if (modified) { + quitDialog.open() + } else { + quit() + } + } + } + } + + Button { + id: cancelButton + text: qsTranslate("PrefsDialogBase", "Cancel") + contentItem: Text { + text: cancelButton.text + color: sysPal.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: sysPal.button + border.color: sysPal.mid + } + onClicked: { + if (modified) { + quitDialog.open() + } else { + quit() + } + } + } + } + } + } + + MessageDialog { + id: errorDialog + title: "Error" + text: "" + onAccepted: { + errorDialog.close() + } + } + + MessageDialog { + id: quitDialog + title: "Quit?" + text: "Do you want to quit the plugin?" + detailedText: "You have unsaved changes. You can save your settings to a file before quitting if you like." + standardButtons: [StandardButton.Ok, StandardButton.Cancel] + onAccepted: { + quit() + } + onRejected: { + quitDialog.close() + } + } + + FileIO { + id: saveFile + source: "" + } + + FileIO { + id: loadFile + source: "" + } + + function getFile(dialog) { + return dialog.filePath + } + + FileDialog { + id: loadDialog + title: "Load Settings" + onAccepted: { + loadFile.source = getFile(loadDialog) + var data = JSON.parse(loadFile.read()) + restoreSavedValues(data) + loadDialog.visible = false + } + onRejected: { + loadDialog.visible = false + } + visible: false + } + + FileDialog { + id: saveDialog + title: "Save Settings" + onAccepted: { + saveFile.source = getFile(saveDialog) + saveFile.write(formatCurrentValues()) + saveDialog.visible = false + } + onRejected: { + saveDialog.visible = false + } + visible: false + } + + // Command pattern for undo/redo + function commandHistory() { + function Command(undo_fn, redo_fn, label) { + this.undo = undo_fn + this.redo = redo_fn + this.label = label // for debugging + } + + var history = [] + var index = -1 + var transaction = 0 + var maxHistory = 30 + + function newHistory(commands) { + if (index < maxHistory) { + index++ + history = history.slice(0, index) + } else { + history = history.slice(1, index) + } + history.push(commands) + } + + this.add = function(undo, redo, label) { + var command = new Command(undo, redo, label) + command.redo() + if (transaction) { + history[index].push(command) + } else { + newHistory([command]) + } + } + + this.undo = function() { + if (index != -1) { + history[index].slice().reverse().forEach( + function(command) { + command.undo() + } + ) + index-- + } + } + + this.redo = function() { + if ((index + 1) < history.length) { + index++ + history[index].forEach( + function(command) { + command.redo() + } + ) + } + } + + this.begin = function() { + if (transaction) { + throw new Error("already in transaction") + } + newHistory([]) + transaction = 1 + } + + this.end = function() { + if (!transaction) { + throw new Error("not in transaction") + } + transaction = 0 + } + } +}