Chapter 10.5
Scripts
Scripts do in one go what would take hours by hand: an accent on every downbeat, removing repeated dynamics, transposing a staff, renaming the instruments of a hundred scores, checking ranges. They are written in Lua 5.4, the language of Finale’s JW Lua and RGP Lua plug-ins.
A script sees the whole score (staves, measures, notes, markings, text, layout) and can change it. It works on a copy: if it finishes, all its changes become a single Undo step; if it stops with an error, the score stays as it was. Scripts run in a closed environment: they cannot read or write files, use the network or launch programs.
The Scripts menu
Every .lua file in the scripts folder is an item of the Scripts menu; subfolders are submenus. The first time, Maestro copies some examples there, in the Examples folder: open them in a text editor to see how they work. Some redo the most used scripts of the RGP Lua collection for Finale (keep one note of chords, double at the octave, swap two layers, remove articulations from rests, count the durations).
- Scripts → Open Scripts Folder shows it in the Finder or File Explorer (on the Mac it is
~/Library/Application Support/Maestro/Script, on Windows%LOCALAPPDATA%\Maestro\Script). A new or changed file works right away, without restarting. - A script works on the score in front: on the selection, if the script uses it, or on everything.
- Run Again (⌥⌘U, on Windows Ctrl+Alt+U) repeats the last script with the same answers to its parameters.
- Stop Script stops a script that takes too long; after two minutes it stops anyway.
- Run Script… opens any
.luafile, also outside the folder. - While the script runs the score stays free; but if you change it in the meantime, the script’s changes are not applied (the console says so), so you never lose what you wrote.
On the iPad, the Scripts button of the music stand runs the scripts in Maestro’s Script folder (in the Files app: On My iPad → Maestro → Script), for example to transpose a piece while you read it; the changes are for reading only and the file does not change.
The script console
Scripts → Script Console shows what scripts write with print, Maestro’s messages and errors, with file and line (“Accents.lua:12: Invalid duration…”). It opens by itself after an error.
In the line at the bottom you can type Lua for the score in front and press Enter: an expression shows its value (score.title, #score.staves), a statement runs (score.title = "Sonata"), with one Undo step. The ↑ and ↓ arrows bring back earlier lines.
Writing a script
-- An accent on the first note of every measure of the flute,
-- and the notes above C6 an octave lower
local flute = score.staves:find("Flute")
for _, measure in ipairs(flute.measures) do
local first = measure:layer(1)[1]
if first and first.isNote then first:add("accent") end
end
for e in flute:entries() do
for _, note in ipairs(e.notes) do
if note.midi > 84 then note:transpose("-P8") end
end
end
- Numbers start at 1, as in Lua and as measure numbers:
score.staves[1],measure:layer(1). - Properties are read and written with a dot (
e.duration = "8"), methods are called with a colon (e:add("accent")). - Lists (
score.staves,staff.measures,measure:layer(1),e.notes,measure.dynamics…) are counted with#and walked withipairs; the iteratorsscore:entries(),staff:entries(),measure:entries()give all notes, chords and rests.
| Object | What it is | Some properties and methods |
|---|---|---|
score |
the score | title, composer, tempo, staves, measures, pageFormat; insertMeasures, deleteMeasures, setKey, setTime, transpose, addStaff |
| staff | score.staves[n] |
name, abbreviatedName, clef, measures; setClef, move, delete |
| measure | staff.measures[n] |
number, clef, dynamics, hairpins, texts, chords; layer(n), append, clear, fillWithRests |
| score measure | score.measures[n] |
time, key; setTime, setKey |
| note, chord or rest | measure:layer(1)[n] |
duration, isRest, isChord, tick, tied, articulations, notes; add, remove, transpose, setNotes, delete |
| pitch | e.notes[n] |
name, step, alter, octave, midi; transpose, delete |
| markings | measure.dynamics[n], score.slurs[n]… |
the fields of the marking (marking, tick…); delete; in the lists add({ … }) |
Durations are written "w", "h", "q", "8", "16", "32" with dots ("q."); pitches "C4" (middle C), "F#5", "Bb3"; intervals "P8", "-m3", "M2", "A4"; articulations "accent", "staccato", "tenuto", "fermata"….
Besides the properties in the table, every object reaches every field of the score by the name it has in the .maestro file: e.hidden = true, e:set("pitches.1.notehead", "diamond"), staff:raw() (the whole object as a table) and :replace(table), :fields(). A wrong name gives an error instead of passing silently. The script itself gives the complete list: maestro.schema() and maestro.schema("entry").
Walking through the score
The functions eachentry, eachcell, eachstaff and each (the same as in RGP Lua) walk through a region: without arguments, the selection or, if there is none, the whole score.
-- A staccato on every eighth note of the selection (or of the score)
for e in eachentry() do
if e.isNote and e.duration == "8" then e:add("staccato") end
end
| Function | What it gives |
|---|---|
eachentry(region, layer) |
notes, chords and rests, staff by staff and measure by measure; layer from 1 to 4 (all by default) |
eachcell(region) |
every measure of every staff: for measure, staff in eachcell() do … end |
eachstaff(region) |
the staves |
each(list), eachbackwards(list) |
the elements of a list, or the notes of a chord (each(e)); backwards you can delete while you go |
pairsbykeys(table) |
the pairs of a table in key order |
The region can be selection (only the selection: nothing if there is none), score, a staff, a measure (score.staves[1].measures[4] or score.measures[4]) or a table like { staves = {1, 2}, measures = {5, 8} }: a number, or the first and the last; what is missing means all. There is also eachentrysaved, for those coming from Finale, but it is the same as eachentry: in Maestro changes apply at once.
The editor’s commands
editor.perform does what you would do by hand with the Articulations, Expressions, Lines, Tuplets, Repeats and Layout palettes: slurs, hairpins, octave lines, texts, endings, system breaks… It acts on a region, like a selection: by default the selection, or an entry, a list of entries, a measure, a staff or { staves = …, measures = … }. The questions of the command’s dialog are answered with options.
editor.perform("slur", { staves = 1, measures = {1, 2} }) -- slur from the first note to the last
editor.perform("crescendo", score.staves[1].measures[5])
editor.perform("ending", { measures = 8, numbers = {1} }) -- first ending
editor.perform("tuplet", e, { ratio = "5:4" }) -- quintuplet from entry e
editor.perform("text", measure, { text = "dolce", kind = "expression" })
editor.perform("systemBreak", { measures = 9 }) -- new system from measure 9
editor.commands() lists the commands with their options. Articulations are added or removed, as in the palette. If the editor refuses the command (“Select at least two notes…”) the script stops with an error at that line, and these commands too stay in the script’s single Undo step. Each command lays out the score again: in long loops, one command on a large region is better than one per note.
Name, shortcut and parameters
An optional metadata block at the top of the script gives the name in the menu (in several languages), a description, a shortcut and the parameters to ask before starting:
--[[ maestro
name = { en = "Transpose", it = "Trasponi" }
description = { en = "Transposes the selection by the chosen interval." }
shortcut = "mod+alt+shift+Y" -- mod = ⌘ on the Mac, Ctrl on Windows; or "F9"
needsSelection = true
params = {
{ name = "interval", type = "text", default = "M2", label = { en = "Interval" } },
{ name = "times", type = "number", default = 1, min = 1, max = 4 },
}
]]
for e in selection.entries() do e:transpose(params.interval) end
Maestro shows a form with the parameters (next time it proposes the previous answers) and the script finds them in params. The types are number, text, boolean and choice (with choices = { … }). A shortcut already used by Maestro is not assigned. require("name") loads modules from the lib folder of the scripts folder.
On a whole folder
Scripts → Run on a Folder… applies a script to all the scores in a folder (also in subfolders): Maestro, Finale, MusicXML, MuseScore and MIDI. In the form you choose the script’s parameters and the result: only the report, or the files saved in another folder as Maestro documents, MusicXML, compressed MusicXML, Finale (.musx) or MIDI. The original files never change. The console shows the outcome file by file.
From the terminal: maestro-script
The maestro-script command does the same without windows, for batch processing and for other programs:
maestro-script run Accents.lua Scores/ --recursive --out Converted/ --format musicxml
maestro-script eval 'score.title' Scores/
maestro-script run Transpose.lua piece.musx -p interval=-M2 --out Output/ --json
maestro-script help
On the Mac it is inside Maestro: Scripts → Install maestro-script Command… makes it available in Terminal. On Windows it is maestro-script.exe in Maestro’s folder (Scripts → The maestro-script Command… says where). It is not in the Mac App Store version.
With an assistant (Claude and others)
maestro-script mcp is an MCP server: an assistant like Claude can open your scores, write and run scripts, look at the pages and save the result. It reads and writes only in the folders you give it with --root; it never overwrites the original nor, without --allow-overwrite, a file that already exists.
With Claude Code, on the Mac:
claude mcp add maestro -- /Applications/Maestro.app/Contents/MacOS/maestro-script mcp --root ~/Scores
With Claude Desktop, in the configuration file of the MCP servers:
{
"mcpServers": {
"maestro": {
"command": "/Applications/Maestro.app/Contents/MacOS/maestro-script",
"args": ["mcp", "--root", "/Users/me/Scores"]
}
}
}
On Windows the command is %LOCALAPPDATA%\Programs\Maestro\maestro-script.exe. Then just ask: “open Minuet.musx, put an accent on every downbeat of the flute, show me the first page and save it as Minuet-accents.maestro”.
On the scores open in Maestro
An assistant can also work on the scores open in the app while you look at them. It is off by default: turn it on with Scripts → Allow Assistants to Edit Open Scores and connect the assistant with maestro-script mcp --app; with Claude Code, on the Mac:
claude mcp add maestro-app -- /Applications/Maestro.app/Contents/MacOS/maestro-script mcp --app
The assistant sees the open scores and your selection; each of its scripts is one Undo step named “Assistant” and appears in the script console; you save the files. The connection stays on your computer and your user account, with no network.
Writing scripts with an editor
The scripts folder also contains the description of the interface for editors (.maestro/maestro.d.lua and .luarc.json): open it in Visual Studio Code with the Lua extension to get completion of properties and methods and checks while you type.
The original Finale data
A score opened from a Finale file (.musx or .mus) keeps the original data of the file while it stays open: scripts read it through finale, with the EnigmaXML names (the same as musxdom and Finale’s PDK). Use it to recover what the import does not bring into Maestro, or to compare.
local ms = finale.others("measSpec", 1)[1] -- measure 1 as Finale writes it
print(ms.beats, ms.divbeat, ms.width)
for e in score:entries() do
local n = finale.entnum(e) -- the Finale entry it comes from
if n then
local original = finale.entry(n)
-- … for example bring over something the import does not read
end
end
finale.others(tag, cmper, inci),finale.details(tag, cmper1, cmper2, inci),finale.texts(tag, number): the records (the arguments after the name are optional filters);finale.entry(entnum),finale.header(),finale.options(tag),finale.tags()(the names present).- A record is a table:
_tagis the name, attributes and child elements are fields; an empty element istrue, a repeated one a sequence (finale.list(x)always gives a sequence). finale.entryFor(entnum)andfinale.entnum(entry),finale.staffFor(cmper)andfinale.staffCmper(staff)link Finale’s objects and Maestro’s.- It is read-only and not saved: after Save as
.maestroand reopening,finaleis gone. For.musfiles it contains the records Maestro can read.
Coming from RGP Lua
The language is the same, the objects are Maestro’s:
| RGP Lua (Finale) | Maestro |
|---|---|
plugindef() |
the --[[ maestro … ]] block at the top of the script |
finenv.Region() |
selection; the walking functions without a region use the selection or the whole score |
for e in eachentrysaved(finenv.Region()) |
for e in eachentry() (also eachentrysaved, eachcell, eachstaff, each, pairsbykeys) |
finale.FCStaves, FCStaff |
score.staves, score.staves[n] |
finale.FCMeasures, FCMeasure |
score.measures[n] (time and key), staff.measures[n] (the music) |
FCNoteEntryLayer, FCNoteEntry |
measure:layer(n), measure:layer(n)[i] |
FCNote |
e.notes[i] |
entry.Duration (EDU, quarter 1024) |
e.duration ("q") or e.ticks (quarter 960) |
FCExpression, FCSmartShape |
measure.dynamics, measure.texts, score.slurs, measure.hairpins… |
entry:Save(), measure:Save() |
not needed: changes apply at once (and undo all together) |