diff --git a/README.md b/README.md
index 2db06fa..afda2b4 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,4 @@
-# MuseScore_TempoStretch
-Apply a % change to tempo markers in MuseScore
+# MuseScore TempoStretch
+Apply a % change to tempo markers in [MuseScore](https://musescore.org).
+
+More info and installation instructions to be found at [the project page on musescore.org](https://musescore.org/project/tempostretch).
diff --git a/TempoStretch.qml b/TempoStretch.qml
new file mode 100644
index 0000000..8c7c97f
--- /dev/null
+++ b/TempoStretch.qml
@@ -0,0 +1,361 @@
+//=============================================================================
+// TempoStretch Plugin
+//
+// Apply a % change to (selected) tempo markers
+//
+// Copyright (C) 2020 Johan Temmerman (jeetee)
+//=============================================================================
+import QtQuick 2.2
+import QtQuick.Controls 1.1
+import QtQuick.Controls.Styles 1.3
+import QtQuick.Layouts 1.1
+import Qt.labs.settings 1.0
+
+import MuseScore 3.0
+
+MuseScore {
+ menuPath: "Plugins.TempoStretch"
+ version: "1.0.0"
+ description: qsTr("Apply a % change to (selected) tempo markers")
+ pluginType: "dialog"
+ requiresScore: true
+
+ property int startBPMvalue: 120 // Always as 1/4th == this value
+ property int beatBaseIndex: 5
+ property var beatBaseList: [
+ //mult is a tempo-multiplier compared to a crotchet
+ //{ text: '\uECA0' , mult: 8 , sym: 'metNoteDoubleWhole' } // 2/1
+ { text: '\uECA2' , mult: 4 , sym: 'metNoteWhole' } // 1/1
+ //,{ text: '\uECA3\uECB7\uECB7' , mult: 3.5 , sym: 'metNoteHalfUpmetAugmentationDotmetAugmentationDot' } // 1/2..
+ ,{ text: '\uECA3\uECB7' , mult: 3 , sym: 'metNoteHalfUpmetAugmentationDot' } // 1/2.
+ ,{ text: '\uECA3' , mult: 2 , sym: 'metNoteHalfUp' } // 1/2
+ ,{ text: '\uECA5\uECB7\uECB7' , mult: 1.75 , sym: 'metNoteQuarterUpmetAugmentationDotmetAugmentationDot' } // 1/4..
+ ,{ text: '\uECA5\uECB7' , mult: 1.5 , sym: 'metNoteQuarterUpmetAugmentationDot' } // 1/4.
+ ,{ text: '\uECA5' , mult: 1 , sym: 'metNoteQuarterUp' } // 1/4
+ ,{ text: '\uECA7\uECB7\uECB7' , mult: 0.875 , sym: 'metNote8thUpmetAugmentationDotmetAugmentationDot' } // 1/8..
+ ,{ text: '\uECA7\uECB7' , mult: 0.75 , sym: 'metNote8thUpmetAugmentationDot' } // 1/8.
+ ,{ text: '\uECA7' , mult: 0.5 , sym: 'metNote8thUp' } // 1/8
+ ,{ text: '\uECA9\uECB7\uECB7' , mult: 0.4375, sym: 'metNote16thUpmetAugmentationDotmetAugmentationDot' } //1/16..
+ ,{ text: '\uECA9\uECB7' , mult: 0.375 , sym: 'metNote16thUpmetAugmentationDot' } //1/16.
+ ,{ text: '\uECA9' , mult: 0.25 , sym: 'metNote16thUp' } //1/16
+ ]
+
+ width: 240
+ height: 160
+
+ onRun: {
+ if ((mscoreMajorVersion == 3) && (mscoreMinorVersion == 0) && (mscoreUpdateVersion < 5)) {
+ console.log(qsTr("Unsupported MuseScore version.\nTempoStretch needs v3.0.5 or above.\n"));
+ Qt.quit();
+ }
+ findStartBPM();
+ // Now show it
+ var beatBaseItem = beatBaseList[beatBaseIndex];
+ startTempoTxt.text = beatBaseItem.text.split('').join(' ') + ' = ' + (Math.round(startBPMvalue / beatBaseItem.mult * 10) / 10);
+ // Force dependency calculation
+ percentValue.text = '100';
+ }
+
+ function findStartBPM()
+ {
+ var segment = getSelection();
+ if (segment === null) {
+ //segment = curScore.firstSegment(ChordRest); // only read firstSegment available here
+ // Rather than forwarding to find the first ChordRest, we can use Cursor instead
+ // which filters on ChordRests by default
+ segment = curScore.newCursor();
+ segment.rewind(Cursor.SCORE_START);
+ segment = segment.segment;
+ }
+ else {
+ segment = segment.startSeg;
+ }
+ // Start Tempo
+ var foundTempo = undefined;
+ while ((foundTempo === undefined) && (segment)) {
+ foundTempo = findExistingTempoElement(segment);
+ segment = segment.prev;
+ }
+ if (foundTempo !== undefined) {
+ console.log('Found start tempo text = ' + foundTempo.text);
+ // Try to extract base beat
+ beatBaseIndex = analyseTempoMarking(foundTempo).beatBase.index;
+ if (beatBaseIndex == -1) {
+ // Couldn't identify it from the text, default to 1/4th note
+ beatBaseIndex = 5;
+ }
+ startBPMvalue = Math.round(foundTempo.tempo * 60 * 10) / 10;;
+ }
+ }
+
+ /// Analyses tempo marking text
+ /// Split tempo marking into 5 substrings with additional analysis:
+ /// {startOfString, beatBase{string, index}, middleStringEquals, valueString, endOfString}
+ /// isMetricModulation
+ /// isValidBasic
+ /// A valid basic marking will contain non-empty strings for beatBaseIndex, middleStringEquals and valueString
+ function analyseTempoMarking(tempoMarking)
+ {
+ var tempoInfo = {
+ startOfString: '',
+ beatBase: { string: '', index: -1 },
+ middleStringEquals: '',
+ valueString: '',
+ endOfString: '',
+ isValidBasic: false,
+ isMetricModulation: false
+ };
+ var tempoString = tempoMarking.text;
+ // Look for metronome marking symbols (met.*<\/sym>)
+ // Metronome marking symbols are substituted with their character entity if the text was edited
+ // UTF-16 range [\uECA0 - \uECB6] (double whole - 1024th)
+ var foundMetronomeSymbols = tempoString.match(/(met.*<\/sym>((space<\/sym>)?met.*<\/sym>)*)|([\uECA2-\uECB7]( ?[\uECA2-\uECB7])*)/);
+ if (foundMetronomeSymbols !== null) {
+ // Everything before the marking
+ tempoInfo.startOfString = tempoString.slice(0, foundMetronomeSymbols.index);
+ // beatBase
+ tempoInfo.beatBase.string = foundMetronomeSymbols[0];
+ tempoString = tempoString.slice(foundMetronomeSymbols.index + foundMetronomeSymbols[0].length);
+ if (foundMetronomeSymbols[0][0] == '<') { // xml marking
+ foundMetronomeSymbols[0] = foundMetronomeSymbols[0].replace('space', ''); // Stripped those to match beatBaseList.sym
+ }
+ else { // plain text marking
+ foundMetronomeSymbols[0] = foundMetronomeSymbols[0].replace(' ', ''); // Stripped those to match beatBaseList.text
+ }
+ for (tempoInfo.beatBase.index = beatBaseList.length; --tempoInfo.beatBase.index >= 0; ) {
+ var beatBaseItem = beatBaseList[tempoInfo.beatBase.index];
+ if ( (beatBaseItem.sym == foundMetronomeSymbols[0])
+ || (beatBaseItem.text == foundMetronomeSymbols[0])
+ ) {
+ break; // Found this marking in the dropdown at current index
+ }
+ }
+ // Continue with remainder, now without beat marking
+ tempoInfo.middleStringEquals = tempoString.match(/(<.*>)*[^=]*=\s+/);
+ tempoInfo.middleStringEquals = (tempoInfo.middleStringEquals !== null)? tempoInfo.middleStringEquals[0] : '';
+ tempoString = tempoString.slice(tempoInfo.middleStringEquals.length);
+ // Extract value, assume it is a number
+ var foundValue = tempoString.match(/^(\d+(\.\d+)?)/);
+ if (foundValue !== null) {
+ tempoInfo.valueString = foundValue[0];
+ tempoInfo.endOfString = tempoString.slice(tempoInfo.valueString.length);
+ tempoInfo.isValidBasic = (tempoInfo.beatBaseIndex !== -1) && (tempoInfo.middleStringEquals.length > 0);
+ }
+ else { // No number, perhaps a metronome marking?
+ foundMetronomeSymbols = tempoString.match(/((met.*<\/sym>((space<\/sym>)?met.*<\/sym>)*)|([\uECA2-\uECB7]( ?[\uECA2-\uECB7])*))/);
+ if (foundMetronomeSymbols !== null) {
+ // There might be some markup in front of a 2nd marking, include it in the middle part
+ tempoInfo.middleStringEquals += tempoString.slice(0, foundMetronomeSymbols.index);
+ tempoInfo.valueString = foundMetronomeSymbols[0];
+ tempoInfo.endOfString = tempoString.slice(foundMetronomeSymbols.index + tempoInfo.valueString.length);
+ tempoInfo.isMetricModulation = true;
+ }
+ }
+ }
+ else {
+ // Couldn't find a single metronome mark
+ tempoInfo.startOfString = tempoString;
+ }
+ return tempoInfo;
+ }
+
+ function applyTempoStretch()
+ {
+ var sel = getSelection();
+ if (sel === null) { //no selection
+ console.log('No selection - using full score');
+ sel = {
+ startSeg: curScore.firstSegment(),
+ endSeg: curScore.lastSegment
+ }
+ }
+
+ curScore.startCmd();
+ // Scan through all relevant segments
+ var segment = sel.startSeg;
+ do {
+ if (segment.segmentType == Ms.ChordRest) {
+ var foundTempoMarking = findExistingTempoElement(segment);
+ if (foundTempoMarking !== undefined) {
+ // Found a tempo marking; analyse it
+ var tempoInfo = analyseTempoMarking(foundTempoMarking);
+ if (!tempoInfo.isMetricModulation) { // metric modulation can be ignored, will auto-scale
+ var newTempo = foundTempoMarking.tempo * percentSlider.value / 100;
+ foundTempoMarking.tempo = newTempo;
+ if (tempoInfo.isValidBasic) {
+ // text should be updated
+ newTempo = newTempo * 60 / beatBaseList[tempoInfo.beatBase.index].mult;
+ foundTempoMarking.text = tempoInfo.startOfString
+ + tempoInfo.beatBase.string
+ + tempoInfo.middleStringEquals
+ + (Math.round(newTempo * 10) / 10)
+ + tempoInfo.endOfString;
+ }
+ }
+ }
+ }
+ } while ((segment.tick != sel.endSeg.tick) && (segment = segment.next));
+
+ curScore.endCmd(false);
+ }
+
+ function getSelection()
+ {
+ var selection = null;
+ var cursor = curScore.newCursor();
+ cursor.rewind(Cursor.SELECTION_START); //start of selection
+ if (!cursor.segment) { //no selection
+ console.log('No selection');
+ return selection;
+ }
+ selection = {
+ start: cursor.tick,
+ startSeg: cursor.segment,
+ end: null,
+ endSeg: null
+ };
+ cursor.rewind(Cursor.SELECTION_END); //find end of selection
+ if (cursor.tick == 0) {
+ // this happens when the selection includes
+ // the last measure of the score.
+ // rewind(2) goes behind the last segment (where
+ // there's none) and sets tick=0
+ selection.end = curScore.lastSegment.tick + 1;
+ selection.endSeg = curScore.lastSegment;
+ }
+ else {
+ selection.end = cursor.tick;
+ selection.endSeg = cursor.segment;
+ }
+ return selection;
+ }
+
+ function getFloatFromInput(input)
+ {
+ var value = input.text;
+ if (value == "") {
+ value = input.placeholderText;
+ }
+ return parseFloat(value);
+ }
+
+ function findExistingTempoElement(segment)
+ { //look in reverse order, there might be multiple TEMPO_TEXTs attached
+ // in that case MuseScore uses the last one in the list
+ if (segment && segment.annotations) {
+ for (var i = segment.annotations.length; i-- > 0; ) {
+ if (segment.annotations[i].type === Element.TEMPO_TEXT) {
+ return (segment.annotations[i]);
+ }
+ }
+ }
+ return undefined; //invalid - no tempo text found
+ }
+
+
+ ColumnLayout {
+ id: 'mainLayout'
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ anchors.topMargin: 0
+ anchors.bottomMargin: 10
+
+ focus: true
+
+ GridLayout {
+ columns: 2
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ anchors.topMargin: 5
+ anchors.bottomMargin: 5
+
+ Label {
+ text: qsTr("From:")
+ Layout.alignment: Qt.AlignRight
+ }
+ Label {
+ id: startTempoTxt
+ Layout.fillWidth: true
+ bottomPadding: -10
+ font.pointSize: 9
+ }
+
+ Label {
+ text: qsTr("To:")
+ Layout.alignment: Qt.AlignRight
+ }
+ TextField {
+ id: toBPMvalue
+ placeholderText: '60'
+ validator: DoubleValidator { bottom: 0.1;/* top: 512;*/ decimals: 1; notation: DoubleValidator.StandardNotation; }
+ implicitHeight: 24
+ onTextChanged: {
+ percentValue.text = Math.round((getFloatFromInput(toBPMvalue) * beatBaseList[beatBaseIndex].mult * 100 / startBPMvalue) * 10) / 10;
+ }
+ }
+ }
+
+ RowLayout {
+ Slider {
+ id: percentSlider
+ Layout.fillWidth: true
+
+ minimumValue: 1
+ maximumValue: 400
+ value: 100.0
+ stepSize: 0.1
+
+ onValueChanged: {
+ percentValue.text = Math.round(value * 10) / 10;
+ }
+
+ }
+ TextField {
+ id: percentValue
+ text: '10'
+ validator: DoubleValidator { bottom: 0.1;/* top: 512;*/ decimals: 1; notation: DoubleValidator.StandardNotation; }
+ Layout.alignment: Qt.AlignRight
+ Layout.preferredWidth: 50
+ implicitHeight: 24
+ onTextChanged: {
+ var newValue = getFloatFromInput(percentValue);
+ if (newValue > percentSlider.maximumValue) {
+ percentSlider.maximumValue = newValue; // Increase range
+ }
+ percentSlider.value = newValue;
+ // Update BPM field
+ if (toBPMvalue.text == '') {
+ toBPMvalue.placeholderText = Math.round((startBPMvalue * newValue / 100) * 10) / 10;
+ }
+ else {
+ toBPMvalue.text = Math.round((startBPMvalue * newValue / 100) * 10) / 10;
+ }
+ }
+ }
+ Label { text: '%' }
+ }
+
+ Button {
+ id: applyButton
+ Layout.alignment: Qt.AlignRight
+ text: qsTranslate("PrefsDialogBase", "Apply")
+ onClicked: {
+ applyTempoStretch();
+ Qt.quit();
+ }
+ }
+ }
+
+ Keys.onEscapePressed: {
+ Qt.quit();
+ }
+ Keys.onReturnPressed: {
+ applyTempoStretch();
+ Qt.quit();
+ }
+ Keys.onEnterPressed: {
+ applyTempoStretch();
+ Qt.quit();
+ }
+}