MediaWiki:Gadget-Tci.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
Line 1: | Line 1: | ||
// Gadget: TCI Calculator ( | // Gadget: TCI Calculator (beat-accurate: one note per rhythmic beat) | ||
(function ($, mw, OO) { | (function ($, mw, OO) { | ||
"use strict"; | "use strict"; | ||
Line 96: | Line 96: | ||
const meter = meterLine.split(':')[1].trim(); | const meter = meterLine.split(':')[1].trim(); | ||
const [lNum, lDen] = lengthLine.split(':')[1].split('/').map(Number); | const [lNum, lDen] = lengthLine.split(':')[1].split('/').map(Number); | ||
const | const beatDuration = lNum / lDen; | ||
const scale = getScaleForKey(key); | const scale = getScaleForKey(key); | ||
Line 104: | Line 104: | ||
const tokenRegex = /((=|\^|_)?[a-gA-GzZ][',/]*)(\d*\/?\d*)/g; | const tokenRegex = /((=|\^|_)?[a-gA-GzZ][',/]*)(\d*\/?\d*)/g; | ||
let match; | let match; | ||
let | let beatAcc = 0, beatAlt = 0, sum4 = 0, sum2 = 0; | ||
function parseDuration(str) { | function parseDuration(str) { | ||
if (!str) return | if (!str) return beatDuration; | ||
if (str.includes('/')) { | if (str.includes('/')) { | ||
const [n, d] = str.split('/').map(Number); | const [n, d] = str.split('/').map(Number); | ||
Line 118: | Line 118: | ||
const baseRaw = normalizeNote(note.replace(/[',0-9\/]/g, '')); | const baseRaw = normalizeNote(note.replace(/[',0-9\/]/g, '')); | ||
const base = baseRaw.toUpperCase(); | const base = baseRaw.toUpperCase(); | ||
const refIdx = refScale.findIndex(n => n.replace(/[#b]/g, '') === base); | const refIdx = refScale.findIndex(n => n.replace(/[#b]/g, '') === base); | ||
const idxInMinor = scale.findIndex(n => n.replace(/[#b]/g, '') === base); | const idxInMinor = scale.findIndex(n => n.replace(/[#b]/g, '') === base); | ||
let c = refIdx !== -1 ? (refIdx + 1).toString() : '?'; | let c = refIdx !== -1 ? (refIdx + 1).toString() : '?'; | ||
if (refIdx !== -1 && (idxInMinor === -1 || scale[idxInMinor] !== refScale[refIdx])) { | if (refIdx !== -1 && (idxInMinor === -1 || scale[idxInMinor] !== refScale[refIdx])) { | ||
if (refScale[refIdx].includes('#') && (!scale[idxInMinor] || !scale[idxInMinor].includes('#'))) | if (refScale[refIdx].includes('#') && (!scale[idxInMinor] || !scale[idxInMinor].includes('#'))) c += 'b'; | ||
else if (refScale[refIdx].includes('b') && (!scale[idxInMinor] || !scale[idxInMinor].includes('b'))) c += '#'; | |||
} | } | ||
Line 141: | Line 136: | ||
} | } | ||
while ((match = tokenRegex.exec(abcText)) !== null && ( | while ((match = tokenRegex.exec(abcText)) !== null && (beatAcc < 8 || beatAlt < 4)) { | ||
const [full, note, , durStr] = match; | const [full, note, , durStr] = match; | ||
const dur = parseDuration(durStr || `${lNum}/${lDen}`); | const dur = parseDuration(durStr || `${lNum}/${lDen}`); | ||
const val = /z/i.test(note) ? '0' : code(note); | |||
const plain = note.replace(/\d.*$/, ''); | const plain = note.replace(/\d.*$/, ''); | ||
if ( | if (beatAcc < 8 && sum4 < 1) { | ||
beats4.push(val); | beats4.push(val); | ||
debug4.push(`B${ | debug4.push(`B${beatAcc + 1}: ${plain} → ${val}`); | ||
beatAcc++; | |||
sum4 += dur; | |||
} else if (sum4 >= 1) { | |||
sum4 = dur; | |||
if (beatAcc < 8) { | |||
beats4.push(val); | |||
debug4.push(`B${beatAcc + 1}: ${plain} → ${val}`); | |||
beatAcc++; | |||
} | |||
} else { | |||
sum4 += dur; | |||
} | } | ||
if ( | if (beatAlt < 4 && sum2 < 2) { | ||
beats2.push(val); | beats2.push(val); | ||
debug2.push(`B${ | debug2.push(`B${beatAlt + 1}: ${plain} → ${val}`); | ||
beatAlt++; | |||
sum2 += dur; | |||
} else if (sum2 >= 2) { | |||
sum2 = dur; | |||
if (beatAlt < 4) { | |||
beats2.push(val); | |||
debug2.push(`B${beatAlt + 1}: ${plain} → ${val}`); | |||
beatAlt++; | |||
} | |||
} else { | |||
sum2 += dur; | |||
} | } | ||
} | } |
Revision as of 17:07, 10 April 2025
// Gadget: TCI Calculator (beat-accurate: one note per rhythmic beat)
(function ($, mw, OO) {
"use strict";
mw.loader.using(["oojs-ui-core", "oojs-ui-widgets", "oojs-ui-windows"]).done(function () {
function TCICalculatorDialog(config) {
TCICalculatorDialog.super.call(this, config);
}
OO.inheritClass(TCICalculatorDialog, OO.ui.ProcessDialog);
TCICalculatorDialog.static.name = 'tciCalculatorDialog';
TCICalculatorDialog.static.title = 'Theme Code Index Calculator';
TCICalculatorDialog.static.actions = [
{ action: 'cancel', label: 'Cancel', flags: 'safe' },
{ action: 'calculate', label: 'Calculate TCI', flags: ['primary', 'progressive'] }
];
TCICalculatorDialog.prototype.initialize = function () {
TCICalculatorDialog.super.prototype.initialize.call(this);
this.content = new OO.ui.PanelLayout({ padded: true, expanded: false, scrollable: true });
this.$body.append(this.content.$element);
this.abcInput = new OO.ui.MultilineTextInputWidget({ placeholder: 'Paste ABC notation here...', autosize: true, rows: 10 });
this.octaveChoice = new OO.ui.DropdownInputWidget({
options: [
{ data: 'standard', label: 'Standard fiddle range (default)' },
{ data: 'visual', label: 'Based on ABC visual octave' },
{ data: 'shiftUp', label: 'Force high octave (+1)' },
{ data: 'shiftDown', label: 'Force low octave (-1)' }
]
});
this.resultOutput = new OO.ui.MultilineTextInputWidget({ readOnly: true, autosize: true, rows: 4 });
this.content.$element.append(
new OO.ui.FieldsetLayout({
label: 'TCI Calculator Options',
items: [
new OO.ui.FieldLayout(this.abcInput, { label: 'ABC Notation', align: 'top' }),
new OO.ui.FieldLayout(this.octaveChoice, { label: 'Octave Interpretation', align: 'top' }),
new OO.ui.FieldLayout(this.resultOutput, { label: 'Theme Code Index (TCI)', align: 'top' })
]
}).$element
);
};
const majorScales = {
C: ['C', 'D', 'E', 'F', 'G', 'A', 'B'],
G: ['G', 'A', 'B', 'C', 'D', 'E', 'F#'],
D: ['D', 'E', 'F#', 'G', 'A', 'B', 'C#'],
A: ['A', 'B', 'C#', 'D', 'E', 'F#', 'G#'],
E: ['E', 'F#', 'G#', 'A', 'B', 'C#', 'D#'],
B: ['B', 'C#', 'D#', 'E', 'F#', 'G#', 'A#'],
F: ['F', 'G', 'A', 'Bb', 'C', 'D', 'E']
};
const minorScales = {
Am: ['A', 'B', 'C', 'D', 'E', 'F', 'G'],
Em: ['E', 'F#', 'G', 'A', 'B', 'C', 'D'],
Dm: ['D', 'E', 'F', 'G', 'A', 'Bb', 'C'],
Bm: ['B', 'C#', 'D', 'E', 'F#', 'G', 'A'],
Gm: ['G', 'A', 'Bb', 'C', 'D', 'Eb', 'F'],
Fsm: ['F#', 'G#', 'A', 'B', 'C#', 'D', 'E']
};
const parallelMajors = {
Em: 'E', Am: 'A', Dm: 'D', Bm: 'B', Gm: 'G', Fsm: 'F#'
};
function normalizeKey(key) {
return key.replace(/[^a-zA-Z#bm]/g, '').trim();
}
function getScaleForKey(keyRaw) {
const key = normalizeKey(keyRaw);
return majorScales[key] || minorScales[key] || majorScales['D'];
}
function getComparativeMajorScale(keyRaw) {
const key = normalizeKey(keyRaw);
return majorScales[parallelMajors[key]] || majorScales['D'];
}
function normalizeNote(note) {
return note.replace(/=/g, '').replace(/\^/g, '#').replace(/_/g, 'b');
}
function parseABC(abcText, octaveMode) {
const lines = abcText.split(/\r?\n/);
const keyLine = lines.find(line => line.startsWith('K:')) || 'K:D';
const meterLine = lines.find(line => line.startsWith('M:')) || 'M:4/4';
const lengthLine = lines.find(line => line.startsWith('L:')) || 'L:1/8';
const key = keyLine.split(':')[1].trim();
const meter = meterLine.split(':')[1].trim();
const [lNum, lDen] = lengthLine.split(':')[1].split('/').map(Number);
const beatDuration = lNum / lDen;
const scale = getScaleForKey(key);
const refScale = getComparativeMajorScale(key) || scale;
const beats4 = [], beats2 = [], debug4 = [], debug2 = [];
const tokenRegex = /((=|\^|_)?[a-gA-GzZ][',/]*)(\d*\/?\d*)/g;
let match;
let beatAcc = 0, beatAlt = 0, sum4 = 0, sum2 = 0;
function parseDuration(str) {
if (!str) return beatDuration;
if (str.includes('/')) {
const [n, d] = str.split('/').map(Number);
return n / d;
}
return parseFloat(str);
}
function code(note) {
const baseRaw = normalizeNote(note.replace(/[',0-9\/]/g, ''));
const base = baseRaw.toUpperCase();
const refIdx = refScale.findIndex(n => n.replace(/[#b]/g, '') === base);
const idxInMinor = scale.findIndex(n => n.replace(/[#b]/g, '') === base);
let c = refIdx !== -1 ? (refIdx + 1).toString() : '?';
if (refIdx !== -1 && (idxInMinor === -1 || scale[idxInMinor] !== refScale[refIdx])) {
if (refScale[refIdx].includes('#') && (!scale[idxInMinor] || !scale[idxInMinor].includes('#'))) c += 'b';
else if (refScale[refIdx].includes('b') && (!scale[idxInMinor] || !scale[idxInMinor].includes('b'))) c += '#';
}
const isHigh = /[a-g]/.test(note);
const isLow = /,/.test(note);
if (octaveMode === 'shiftUp') return c + 'H';
if (octaveMode === 'shiftDown') return c + 'L';
if (octaveMode === 'visual') return isHigh ? c + 'H' : isLow ? c + 'L' : c;
if (octaveMode === 'standard') return isHigh ? c + 'H' : c;
return c;
}
while ((match = tokenRegex.exec(abcText)) !== null && (beatAcc < 8 || beatAlt < 4)) {
const [full, note, , durStr] = match;
const dur = parseDuration(durStr || `${lNum}/${lDen}`);
const val = /z/i.test(note) ? '0' : code(note);
const plain = note.replace(/\d.*$/, '');
if (beatAcc < 8 && sum4 < 1) {
beats4.push(val);
debug4.push(`B${beatAcc + 1}: ${plain} → ${val}`);
beatAcc++;
sum4 += dur;
} else if (sum4 >= 1) {
sum4 = dur;
if (beatAcc < 8) {
beats4.push(val);
debug4.push(`B${beatAcc + 1}: ${plain} → ${val}`);
beatAcc++;
}
} else {
sum4 += dur;
}
if (beatAlt < 4 && sum2 < 2) {
beats2.push(val);
debug2.push(`B${beatAlt + 1}: ${plain} → ${val}`);
beatAlt++;
sum2 += dur;
} else if (sum2 >= 2) {
sum2 = dur;
if (beatAlt < 4) {
beats2.push(val);
debug2.push(`B${beatAlt + 1}: ${plain} → ${val}`);
beatAlt++;
}
} else {
sum2 += dur;
}
}
return {
normal: beats4.join(' '),
dual: beats2.join(' '),
debug: '4-beat mode:\n' + debug4.join('\n') + '\n\n2-beat mode:\n' + debug2.join('\n')
};
}
TCICalculatorDialog.prototype.getActionProcess = function (action) {
if (action === 'calculate') {
return new OO.ui.Process(() => {
const abc = this.abcInput.getValue();
const mode = this.octaveChoice.getValue();
const result = parseABC(abc, mode);
this.resultOutput.setValue(
'Standard (4 beats): ' + result.normal +
'\nAlternate (2 beats): ' + result.dual +
'\n\n' + result.debug
);
});
} else if (action === 'cancel') {
return new OO.ui.Process(() => this.close({ action: action }));
}
return TCICalculatorDialog.super.prototype.getActionProcess.call(this, action);
};
function openTCICalculatorDialog() {
const windowManager = new OO.ui.WindowManager();
$(document.body).append(windowManager.$element);
const dialog = new TCICalculatorDialog();
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
}
function addTCICalculatorLink() {
const $actions = $('#p-cactions ul');
if ($actions.length && !$('#tci-calculator').length) {
$actions.append($('<li><a href="#" id="tci-calculator">🎼 Calculate TCI</a></li>'));
}
}
$(document).on('click', '#tci-calculator', function (e) {
e.preventDefault();
openTCICalculatorDialog();
});
$(addTCICalculatorLink);
});
})(jQuery, mediaWiki, OO);