Skip to content

Commit 8b43e97

Browse files
committed
initial commit
1 parent d51fa30 commit 8b43e97

File tree

2 files changed

+365
-2
lines changed

2 files changed

+365
-2
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
# MuseScore_TempoStretch
2-
Apply a % change to tempo markers in MuseScore
1+
# MuseScore TempoStretch
2+
Apply a % change to tempo markers in [MuseScore](https://musescore.org).
3+
4+
More info and installation instructions to be found at [the project page on musescore.org](https://musescore.org/project/tempostretch).

TempoStretch.qml

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
//=============================================================================
2+
// TempoStretch Plugin
3+
//
4+
// Apply a % change to (selected) tempo markers
5+
//
6+
// Copyright (C) 2020 Johan Temmerman (jeetee)
7+
//=============================================================================
8+
import QtQuick 2.2
9+
import QtQuick.Controls 1.1
10+
import QtQuick.Controls.Styles 1.3
11+
import QtQuick.Layouts 1.1
12+
import Qt.labs.settings 1.0
13+
14+
import MuseScore 3.0
15+
16+
MuseScore {
17+
menuPath: "Plugins.TempoStretch"
18+
version: "1.0.0"
19+
description: qsTr("Apply a % change to (selected) tempo markers")
20+
pluginType: "dialog"
21+
requiresScore: true
22+
23+
property int startBPMvalue: 120 // Always as 1/4th == this value
24+
property int beatBaseIndex: 5
25+
property var beatBaseList: [
26+
//mult is a tempo-multiplier compared to a crotchet
27+
//{ text: '\uECA0' , mult: 8 , sym: '<sym>metNoteDoubleWhole</sym>' } // 2/1
28+
{ text: '\uECA2' , mult: 4 , sym: '<sym>metNoteWhole</sym>' } // 1/1
29+
//,{ text: '\uECA3\uECB7\uECB7' , mult: 3.5 , sym: '<sym>metNoteHalfUp</sym><sym>metAugmentationDot</sym><sym>metAugmentationDot</sym>' } // 1/2..
30+
,{ text: '\uECA3\uECB7' , mult: 3 , sym: '<sym>metNoteHalfUp</sym><sym>metAugmentationDot</sym>' } // 1/2.
31+
,{ text: '\uECA3' , mult: 2 , sym: '<sym>metNoteHalfUp</sym>' } // 1/2
32+
,{ text: '\uECA5\uECB7\uECB7' , mult: 1.75 , sym: '<sym>metNoteQuarterUp</sym><sym>metAugmentationDot</sym><sym>metAugmentationDot</sym>' } // 1/4..
33+
,{ text: '\uECA5\uECB7' , mult: 1.5 , sym: '<sym>metNoteQuarterUp</sym><sym>metAugmentationDot</sym>' } // 1/4.
34+
,{ text: '\uECA5' , mult: 1 , sym: '<sym>metNoteQuarterUp</sym>' } // 1/4
35+
,{ text: '\uECA7\uECB7\uECB7' , mult: 0.875 , sym: '<sym>metNote8thUp</sym><sym>metAugmentationDot</sym><sym>metAugmentationDot</sym>' } // 1/8..
36+
,{ text: '\uECA7\uECB7' , mult: 0.75 , sym: '<sym>metNote8thUp</sym><sym>metAugmentationDot</sym>' } // 1/8.
37+
,{ text: '\uECA7' , mult: 0.5 , sym: '<sym>metNote8thUp</sym>' } // 1/8
38+
,{ text: '\uECA9\uECB7\uECB7' , mult: 0.4375, sym: '<sym>metNote16thUp</sym><sym>metAugmentationDot</sym><sym>metAugmentationDot</sym>' } //1/16..
39+
,{ text: '\uECA9\uECB7' , mult: 0.375 , sym: '<sym>metNote16thUp</sym><sym>metAugmentationDot</sym>' } //1/16.
40+
,{ text: '\uECA9' , mult: 0.25 , sym: '<sym>metNote16thUp</sym>' } //1/16
41+
]
42+
43+
width: 240
44+
height: 160
45+
46+
onRun: {
47+
if ((mscoreMajorVersion == 3) && (mscoreMinorVersion == 0) && (mscoreUpdateVersion < 5)) {
48+
console.log(qsTr("Unsupported MuseScore version.\nTempoStretch needs v3.0.5 or above.\n"));
49+
Qt.quit();
50+
}
51+
findStartBPM();
52+
// Now show it
53+
var beatBaseItem = beatBaseList[beatBaseIndex];
54+
startTempoTxt.text = beatBaseItem.text.split('').join(' ') + ' = ' + (Math.round(startBPMvalue / beatBaseItem.mult * 10) / 10);
55+
// Force dependency calculation
56+
percentValue.text = '100';
57+
}
58+
59+
function findStartBPM()
60+
{
61+
var segment = getSelection();
62+
if (segment === null) {
63+
//segment = curScore.firstSegment(ChordRest); // only read firstSegment available here
64+
// Rather than forwarding to find the first ChordRest, we can use Cursor instead
65+
// which filters on ChordRests by default
66+
segment = curScore.newCursor();
67+
segment.rewind(Cursor.SCORE_START);
68+
segment = segment.segment;
69+
}
70+
else {
71+
segment = segment.startSeg;
72+
}
73+
// Start Tempo
74+
var foundTempo = undefined;
75+
while ((foundTempo === undefined) && (segment)) {
76+
foundTempo = findExistingTempoElement(segment);
77+
segment = segment.prev;
78+
}
79+
if (foundTempo !== undefined) {
80+
console.log('Found start tempo text = ' + foundTempo.text);
81+
// Try to extract base beat
82+
beatBaseIndex = analyseTempoMarking(foundTempo).beatBase.index;
83+
if (beatBaseIndex == -1) {
84+
// Couldn't identify it from the text, default to 1/4th note
85+
beatBaseIndex = 5;
86+
}
87+
startBPMvalue = Math.round(foundTempo.tempo * 60 * 10) / 10;;
88+
}
89+
}
90+
91+
/// Analyses tempo marking text
92+
/// Split tempo marking into 5 substrings with additional analysis:
93+
/// {startOfString, beatBase{string, index}, middleStringEquals, valueString, endOfString}
94+
/// isMetricModulation
95+
/// isValidBasic
96+
/// A valid basic marking will contain non-empty strings for beatBaseIndex, middleStringEquals and valueString
97+
function analyseTempoMarking(tempoMarking)
98+
{
99+
var tempoInfo = {
100+
startOfString: '',
101+
beatBase: { string: '', index: -1 },
102+
middleStringEquals: '',
103+
valueString: '',
104+
endOfString: '',
105+
isValidBasic: false,
106+
isMetricModulation: false
107+
};
108+
var tempoString = tempoMarking.text;
109+
// Look for metronome marking symbols (<sym>met.*<\/sym>)
110+
// Metronome marking symbols are substituted with their character entity if the text was edited
111+
// UTF-16 range [\uECA0 - \uECB6] (double whole - 1024th)
112+
var foundMetronomeSymbols = tempoString.match(/(<sym>met.*<\/sym>((<sym>space<\/sym>)?<sym>met.*<\/sym>)*)|([\uECA2-\uECB7]( ?[\uECA2-\uECB7])*)/);
113+
if (foundMetronomeSymbols !== null) {
114+
// Everything before the marking
115+
tempoInfo.startOfString = tempoString.slice(0, foundMetronomeSymbols.index);
116+
// beatBase
117+
tempoInfo.beatBase.string = foundMetronomeSymbols[0];
118+
tempoString = tempoString.slice(foundMetronomeSymbols.index + foundMetronomeSymbols[0].length);
119+
if (foundMetronomeSymbols[0][0] == '<') { // xml marking
120+
foundMetronomeSymbols[0] = foundMetronomeSymbols[0].replace('<sym>space</sym>', ''); // Stripped those to match beatBaseList.sym
121+
}
122+
else { // plain text marking
123+
foundMetronomeSymbols[0] = foundMetronomeSymbols[0].replace(' ', ''); // Stripped those to match beatBaseList.text
124+
}
125+
for (tempoInfo.beatBase.index = beatBaseList.length; --tempoInfo.beatBase.index >= 0; ) {
126+
var beatBaseItem = beatBaseList[tempoInfo.beatBase.index];
127+
if ( (beatBaseItem.sym == foundMetronomeSymbols[0])
128+
|| (beatBaseItem.text == foundMetronomeSymbols[0])
129+
) {
130+
break; // Found this marking in the dropdown at current index
131+
}
132+
}
133+
// Continue with remainder, now without beat marking
134+
tempoInfo.middleStringEquals = tempoString.match(/(<.*>)*[^=]*=\s+/);
135+
tempoInfo.middleStringEquals = (tempoInfo.middleStringEquals !== null)? tempoInfo.middleStringEquals[0] : '';
136+
tempoString = tempoString.slice(tempoInfo.middleStringEquals.length);
137+
// Extract value, assume it is a number
138+
var foundValue = tempoString.match(/^(\d+(\.\d+)?)/);
139+
if (foundValue !== null) {
140+
tempoInfo.valueString = foundValue[0];
141+
tempoInfo.endOfString = tempoString.slice(tempoInfo.valueString.length);
142+
tempoInfo.isValidBasic = (tempoInfo.beatBaseIndex !== -1) && (tempoInfo.middleStringEquals.length > 0);
143+
}
144+
else { // No number, perhaps a metronome marking?
145+
foundMetronomeSymbols = tempoString.match(/((<sym>met.*<\/sym>((<sym>space<\/sym>)?<sym>met.*<\/sym>)*)|([\uECA2-\uECB7]( ?[\uECA2-\uECB7])*))/);
146+
if (foundMetronomeSymbols !== null) {
147+
// There might be some markup in front of a 2nd marking, include it in the middle part
148+
tempoInfo.middleStringEquals += tempoString.slice(0, foundMetronomeSymbols.index);
149+
tempoInfo.valueString = foundMetronomeSymbols[0];
150+
tempoInfo.endOfString = tempoString.slice(foundMetronomeSymbols.index + tempoInfo.valueString.length);
151+
tempoInfo.isMetricModulation = true;
152+
}
153+
}
154+
}
155+
else {
156+
// Couldn't find a single metronome mark
157+
tempoInfo.startOfString = tempoString;
158+
}
159+
return tempoInfo;
160+
}
161+
162+
function applyTempoStretch()
163+
{
164+
var sel = getSelection();
165+
if (sel === null) { //no selection
166+
console.log('No selection - using full score');
167+
sel = {
168+
startSeg: curScore.firstSegment(),
169+
endSeg: curScore.lastSegment
170+
}
171+
}
172+
173+
curScore.startCmd();
174+
// Scan through all relevant segments
175+
var segment = sel.startSeg;
176+
do {
177+
if (segment.segmentType == Ms.ChordRest) {
178+
var foundTempoMarking = findExistingTempoElement(segment);
179+
if (foundTempoMarking !== undefined) {
180+
// Found a tempo marking; analyse it
181+
var tempoInfo = analyseTempoMarking(foundTempoMarking);
182+
if (!tempoInfo.isMetricModulation) { // metric modulation can be ignored, will auto-scale
183+
var newTempo = foundTempoMarking.tempo * percentSlider.value / 100;
184+
foundTempoMarking.tempo = newTempo;
185+
if (tempoInfo.isValidBasic) {
186+
// text should be updated
187+
newTempo = newTempo * 60 / beatBaseList[tempoInfo.beatBase.index].mult;
188+
foundTempoMarking.text = tempoInfo.startOfString
189+
+ tempoInfo.beatBase.string
190+
+ tempoInfo.middleStringEquals
191+
+ (Math.round(newTempo * 10) / 10)
192+
+ tempoInfo.endOfString;
193+
}
194+
}
195+
}
196+
}
197+
} while ((segment.tick != sel.endSeg.tick) && (segment = segment.next));
198+
199+
curScore.endCmd(false);
200+
}
201+
202+
function getSelection()
203+
{
204+
var selection = null;
205+
var cursor = curScore.newCursor();
206+
cursor.rewind(Cursor.SELECTION_START); //start of selection
207+
if (!cursor.segment) { //no selection
208+
console.log('No selection');
209+
return selection;
210+
}
211+
selection = {
212+
start: cursor.tick,
213+
startSeg: cursor.segment,
214+
end: null,
215+
endSeg: null
216+
};
217+
cursor.rewind(Cursor.SELECTION_END); //find end of selection
218+
if (cursor.tick == 0) {
219+
// this happens when the selection includes
220+
// the last measure of the score.
221+
// rewind(2) goes behind the last segment (where
222+
// there's none) and sets tick=0
223+
selection.end = curScore.lastSegment.tick + 1;
224+
selection.endSeg = curScore.lastSegment;
225+
}
226+
else {
227+
selection.end = cursor.tick;
228+
selection.endSeg = cursor.segment;
229+
}
230+
return selection;
231+
}
232+
233+
function getFloatFromInput(input)
234+
{
235+
var value = input.text;
236+
if (value == "") {
237+
value = input.placeholderText;
238+
}
239+
return parseFloat(value);
240+
}
241+
242+
function findExistingTempoElement(segment)
243+
{ //look in reverse order, there might be multiple TEMPO_TEXTs attached
244+
// in that case MuseScore uses the last one in the list
245+
if (segment && segment.annotations) {
246+
for (var i = segment.annotations.length; i-- > 0; ) {
247+
if (segment.annotations[i].type === Element.TEMPO_TEXT) {
248+
return (segment.annotations[i]);
249+
}
250+
}
251+
}
252+
return undefined; //invalid - no tempo text found
253+
}
254+
255+
256+
ColumnLayout {
257+
id: 'mainLayout'
258+
anchors.fill: parent
259+
anchors.leftMargin: 10
260+
anchors.rightMargin: 10
261+
anchors.topMargin: 0
262+
anchors.bottomMargin: 10
263+
264+
focus: true
265+
266+
GridLayout {
267+
columns: 2
268+
anchors.leftMargin: 10
269+
anchors.rightMargin: 10
270+
anchors.topMargin: 5
271+
anchors.bottomMargin: 5
272+
273+
Label {
274+
text: qsTr("From:")
275+
Layout.alignment: Qt.AlignRight
276+
}
277+
Label {
278+
id: startTempoTxt
279+
Layout.fillWidth: true
280+
bottomPadding: -10
281+
font.pointSize: 9
282+
}
283+
284+
Label {
285+
text: qsTr("To:")
286+
Layout.alignment: Qt.AlignRight
287+
}
288+
TextField {
289+
id: toBPMvalue
290+
placeholderText: '60'
291+
validator: DoubleValidator { bottom: 0.1;/* top: 512;*/ decimals: 1; notation: DoubleValidator.StandardNotation; }
292+
implicitHeight: 24
293+
onTextChanged: {
294+
percentValue.text = Math.round((getFloatFromInput(toBPMvalue) * beatBaseList[beatBaseIndex].mult * 100 / startBPMvalue) * 10) / 10;
295+
}
296+
}
297+
}
298+
299+
RowLayout {
300+
Slider {
301+
id: percentSlider
302+
Layout.fillWidth: true
303+
304+
minimumValue: 1
305+
maximumValue: 400
306+
value: 100.0
307+
stepSize: 0.1
308+
309+
onValueChanged: {
310+
percentValue.text = Math.round(value * 10) / 10;
311+
}
312+
313+
}
314+
TextField {
315+
id: percentValue
316+
text: '10'
317+
validator: DoubleValidator { bottom: 0.1;/* top: 512;*/ decimals: 1; notation: DoubleValidator.StandardNotation; }
318+
Layout.alignment: Qt.AlignRight
319+
Layout.preferredWidth: 50
320+
implicitHeight: 24
321+
onTextChanged: {
322+
var newValue = getFloatFromInput(percentValue);
323+
if (newValue > percentSlider.maximumValue) {
324+
percentSlider.maximumValue = newValue; // Increase range
325+
}
326+
percentSlider.value = newValue;
327+
// Update BPM field
328+
if (toBPMvalue.text == '') {
329+
toBPMvalue.placeholderText = Math.round((startBPMvalue * newValue / 100) * 10) / 10;
330+
}
331+
else {
332+
toBPMvalue.text = Math.round((startBPMvalue * newValue / 100) * 10) / 10;
333+
}
334+
}
335+
}
336+
Label { text: '%' }
337+
}
338+
339+
Button {
340+
id: applyButton
341+
Layout.alignment: Qt.AlignRight
342+
text: qsTranslate("PrefsDialogBase", "Apply")
343+
onClicked: {
344+
applyTempoStretch();
345+
Qt.quit();
346+
}
347+
}
348+
}
349+
350+
Keys.onEscapePressed: {
351+
Qt.quit();
352+
}
353+
Keys.onReturnPressed: {
354+
applyTempoStretch();
355+
Qt.quit();
356+
}
357+
Keys.onEnterPressed: {
358+
applyTempoStretch();
359+
Qt.quit();
360+
}
361+
}

0 commit comments

Comments
 (0)