Files
musescore-recorder-tabs/whistle_tab_generator.qml
2026-02-27 20:08:01 +02:00

1062 lines
45 KiB
QML

//=============================================================================
// 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 = "○"
// Use global replace to replace all occurrences
var token = "$" + (i+1)
while (output.indexOf(token) !== -1) {
output = output.replace(token, symbol)
}
}
//-------------------------------------------------
// Replace plus placeholder ($+)
//-------------------------------------------------
var plusSymbol = (plusBit === "1") ? "+" : " "
while (output.indexOf("$+") !== -1) {
output = output.replace("$+", plusSymbol)
}
//-------------------------------------------------
// Final validation
//-------------------------------------------------
if (output.indexOf("$") !== -1) {
console.log("Warning: Unreplaced placeholders in:", output)
return output // Return anyway with what we have
}
return output
}
//---------------------------------------------------------
// APPLY TEXT FORMATTING
//---------------------------------------------------------
function formatText(text) {
console.log("Formatting text - setting fontSize to:", userFontSize)
text.fontSize = userFontSize
text.placement = Placement.BELOW
text.autoplace = false
text.offsetY = userOffsetY
text.lineSpacing = userLineSpacing // Multiply by 10 for better range
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
}
console.log("=== Applying Fingerings ===")
console.log("userFontSize:", userFontSize)
console.log("userFormatString:", userFormatString)
console.log("userJustification:", userJustification)
console.log("userOffsetY:", userOffsetY)
console.log("userLineSpacing:", userLineSpacing)
curScore.startCmd()
var cursor = curScore.newCursor()
cursor.rewind(0)
var count = 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 = "☒"
console.log("Note not in dictionary:", noteName)
} else {
var diagram = buildFingeringText(fingeringDict[noteName], userFormatString)
text.text = diagram
console.log("Note:", noteName, "Diagram:", diagram.replace(/\n/g, "\\n"))
}
cursor.add(text)
formatText(text)
// Verify the text object has the properties set
console.log("Text fontSize after format:", text.fontSize)
console.log("Text offsetY after format:", text.offsetY)
console.log("Text lineSpacing after format:", text.lineSpacing)
count++
}
cursor.next()
}
curScore.endCmd()
console.log("Applied fingerings to", count, "notes")
console.log("=== Done ===")
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
fontSizeField.text = oldSize
previewLabel.font.pointSize = oldSize
},
function() {
userFontSize = size
fontSizeField.text = size
previewLabel.font.pointSize = 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
offsetField.text = oldOffset.toFixed(1)
},
function() {
userOffsetY = offset
offsetField.text = offset.toFixed(1)
},
"vertical offset"
)
}
function setUserLineSpacing(spacing) {
var oldSpacing = userLineSpacing
getHistory().add(
function() {
userLineSpacing = oldSpacing
spacingField.text = oldSpacing.toFixed(1)
},
function() {
userLineSpacing = spacing
spacingField.text = spacing.toFixed(1)
},
"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(size) {
getHistory().begin()
setModified(true)
setUserFontSize(size)
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
// 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: 1; top: 72 }
background: Rectangle {
color: sysPal.window
border.color: sysPal.mid
}
// Handle Enter key press
onAccepted: {
var newValue = parseInt(text)
if (!isNaN(newValue) && newValue >= 1 && 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 >= 1 && 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 >= 1 && 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()
updatePreview()
}
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 >= -200.0 && newValue <= 200.0) {
if (newValue !== userOffsetY) {
offsetYChanged(newValue)
updatePreview()
}
} else {
text = userOffsetY.toFixed(1)
}
}
onActiveFocusChanged: {
if (!activeFocus) {
var newValue = parseFloat(text)
if (!isNaN(newValue) && newValue >= -200.0 && newValue <= 200.0) {
if (newValue !== userOffsetY) {
offsetYChanged(newValue)
updatePreview()
}
} else {
text = userOffsetY.toFixed(1)
}
}
}
}
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; top: 6; decimals: 1 }
background: Rectangle {
color: sysPal.window
border.color: sysPal.mid
}
onAccepted: {
var newValue = parseFloat(text)
if (!isNaN(newValue) && newValue >= 0 && newValue <= 6) {
if (newValue !== userLineSpacing) {
lineSpacingChanged(newValue)
updatePreview()
}
} else {
text = userLineSpacing.toFixed(1)
}
}
onActiveFocusChanged: {
if (!activeFocus) {
var newValue = parseFloat(text)
if (!isNaN(newValue) && newValue >= 0 && newValue <= 6.0) {
if (newValue !== userLineSpacing) {
lineSpacingChanged(newValue)
updatePreview()
}
} else {
text = userLineSpacing.toFixed(1)
}
}
}
}
}
}
// 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
lineHeight: userLineSpacing
lineHeightMode: Text.ProportionalHeight
}
}
}
}
}
// 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
}
}
}