Multi-instrument music transcription for Swift: a recording in, a MIDI file with one track per instrument out, entirely on this machine. It is MuScriptor — Kyutai and Mirelo's open transcription model — re-authored for Apple's Core AI runtime.
import MusicTranscriber
let transcriber = try await MusicTranscriber(model: try ModelLocator.resolve()) // medium, fp32
let result = try await transcriber.transcribe(url) // any audio AVFoundation reads
result.notes.count // 173
result.instruments // ["distorted_electric_guitar", "electric_bass", "drums"]
result.beatGrid?.bpm // 77.5, or nil when no steady beat was found
// A loop whose name carries its tempo: skip the tracker, write 123 BPM in 4/4, never shift it.
let bpm = FilenameTempo.bpm(inFilename: url.lastPathComponent) // 123 for "VENDOR_PK2_123_bass_loop_C.wav"
let loop = try await transcriber.transcribe(url, fixedTempo: bpm)
let midi = try MIDIAssembly.data(notes: result.notes, grid: result.beatGrid)
try midi.write(to: url.deletingPathExtension().appendingPathExtension("mid"))Or as events, as they are decoded:
for try await event in try transcriber.events(url) {
if case .noteStart(let note) = event { print(note.startTime, note.instrument, note.pitch) }
}A mix goes in as 5-second chunks of 16 kHz mono. For each chunk the model writes a stream of
note events — pitch, on, off and one of 36 instrument groups — at 10 ms resolution, and each chunk
opens by declaring which notes are still sounding from the one before, so a sustained chord
survives the boundary. There is no velocity, pedal or pitch bend: the model's vocabulary has
one bit for on-versus-off, so every note is written at velocity 100. The type says so
(TranscribedNote has no velocity field) rather than inventing a number.
Three published checkpoints, all the same architecture:
| layers / width | asset (fp32) | on the 10 s demo, M3 Max | |
|---|---|---|---|
small |
14 / 768 | 412 MB | 4.5 ms per token; hits the token budget on dense material |
medium (default) |
24 / 1024 | 1.2 GB | 10 ms per token, 2.3× realtime, 173 notes |
large |
48 / 1536 | 5.5 GB | fp16 1.1× realtime, fp32 0.5× — the one size where fp16 pays |
Every stage was held to the Python code, not to a description of it:
| stage | test | result |
|---|---|---|
| log-mel front end (vDSP) | upstream's mel through the checkpoint's own window and filterbank | 99 dB PSNR on real clips |
| sinc resampler (julius port) | fixtures at 44.1 and 48 kHz | > 90 dB PSNR |
| decoder logits, teacher-forced | upstream's CPU fp32 logits at every step of 4 chunks | ≥ 116 dB with upstream's mel, ≥ 94 dB with ours; no argmax flips |
| token decoder, tie prologue, note cleanup | upstream's event stream for 3 real chunks | identical, 346 events |
| MIDI layout | upstream's file for the same notes | same notes, ticks, tracks, tempo |
| whole pipeline, all three sizes, fp32 | upstream's greedy CPU decode on 7 clips (bass, piano, drums, disco, a 2-minute makina track) | token for token in all 19 runs, 25,000+ tokens |
| large fp16 | same, demo clip | token for token |
| small fp16 | same | diverges after ~50 tokens — where upstream's own fp16 run diverges |
One thing had to be taken from the checkpoint rather than recomputed: the STFT window. Every
checkpoint stores a periodic Hann window rounded to half precision, and a float32 Hann window
lifts the near-empty mel bins above 7 kHz by whole log units. Before the stored window was used,
8 of the 19 runs flipped a single near-tie token (top-two margin 0.01–0.09 logits). The window is
tabulated in MelWindow.swift and checked bit for bit against the checkpoint.
So fp32 is the default. On this hardware it was no slower than fp16 for medium; for large,
which is bandwidth-bound at 5.5 GB, fp16 is twice as fast and still exact on the demo.
The MP3 decoder is a bigger variable than the precision. The parity rows above feed the
model the same 16 kHz samples upstream decoded with libsndfile. Given the MP3 itself,
AVFoundation decodes it slightly differently, and on large that moved the transcription from
190 notes to 161 (medium was unchanged). For reproducible results feed WAV or FLAC.
Tempo can be an octave out, and the notes can fix it. The tracker hears the half-time pulse
on fast electronic music — 89 for a 178 BPM Makina track, 77.5 for the 155 BPM demo — and
two-thirds on some basslines. tempoRange: declares where the tempo plausibly lives and the
detection is snapped in by the smallest musical ratio; when two ratios land (89 reaches
120…190 as 133.5 and as 178) the transcribed drum hits decide, because at the true tempo they
sit on the beats. That is a measurement on the model's own output, not a guess: 178 and 155
on those two, and a 123 BPM bassline heard at 82 comes back as 123.
The beat tracker is upstream's too, when installed. MuScriptor finds tempo and metre with
Beat This! (CPJKU, MIT), and Tools/export_beat_this.py
puts that model's final0 checkpoint on Core AI as well: 20M parameters, 80 MB, a few seconds
per song. Its spectrogram front end, chunking and peak-picking are ported and held to fixtures
from the Python code, and on the demo, the Makina track and two loops it returns upstream's
beats to the frame — including a loop where upstream's fit fails and falls back to 120 BPM, which
this does too, because matching upstream includes matching its misses. Without the asset, Apple's
MusicUnderstanding (via swift-music-analysis)
stands in; on the demo it hears the half-time pulse, 77.5 where Beat This! says 154.9.
BeatTracker picks: .automatic (Beat This! when installed), .beatThis, .apple.
| tracker | demo | Makina track (178) | bass loop (123) | piano loop (123) |
|---|---|---|---|---|
| Beat This! (upstream's, ported) | 154.9 = upstream | 178.0 = upstream | 122.9 = upstream | no fit = upstream |
| Apple MusicUnderstanding | 77.5 | 89.0 | 81.9 | 123.1 |
A sample of the user's loops from a commercial pack (7.6 s, 44.1 kHz, BPM and root in the
filename), medium fp32, defaults, one scribe run per folder. Tempo is MusicUnderstanding's;
key root is the fleet's midi key run over the transcription against the filename.
| folder | files | notes / file | tempo within 1 BPM | no grid | key root | what the model called it |
|---|---|---|---|---|---|---|
| bass loops | 40 | 32 | 31 | 0 | 26 / 38 | electric_bass 32, piano 4, guitar 5 |
| full drum loops | 8 | 21 | 8 | 0 | — | drums 7 |
| stripped drum loops | 8 | 23 | 8 | 0 | — | drums 6 |
| hat loops | 8 | 18 | 5 | 3 | — | drums 6 |
| bongo / shaker loops | 16 | 1–4 | 4 | 12 | — | mostly nothing |
| synth loops | 20 | 37 | 16 | 1 | 8 / 16 | acoustic_guitar 7, distorted guitar 4, piano 3 |
| filtered disco loops | 20 | 90 | 13 | 3 | — | drums 9, electric_bass 8, guitar 7 |
| fx loops | 15 | 62 | 10 | 2 | 4 / 9 | distorted guitar 8, drums 6 |
| vocal loops | 40 | 18 | 16 | 20 | — | guitar 4, voice 3, drums 3 |
| all | 175 | 111 | 41 | 38 / 63 | 4.5× realtime, load and beat tracking included |
Three things the table says plainly:
- Two synths in one class share one track, and only a better model hears them apart. On a
Makina track,
mediumput the lead and the pad on one piano track;large, unforced, wrote a separatesynth_leadtrack of 338 notes. Forcing a synth-only mask onmediumsplit them too, but shoved 931 melodic notes onto the bass — that is coercion, not accuracy. For full mixes where parts merge, uselarge. - Synths are not a class the model reaches for. Twenty synth loops, zero
synth_leadorsynth_padlabels; they come back as guitars and pianos. With--instruments synth_lead,synth_pad,electric_bass,drumsthe same loops produce 48 notes per file instead of 37, labelled by construction. On electronic material, mask. - Untuned percussion barely registers. Bongo and shaker loops yield one to four notes; kit
loops yield twenty and are labelled drums. Vocals are transcribed as pitch (18 notes per loop)
but rarely labelled
voice(3 of 40). - On eight-second loops, Apple's tracker beats upstream's. Rerun with Beat This!: within
1 BPM on 48 loops instead of 111, and no steady grid on 118 instead of 41 — its beats wobble
past the 5% residual rule on short material that MusicUnderstanding fits. On the two-minute
track the order reverses (178 versus 89). Songs: Beat This!. Loops:
--bpm name. - Tempo holds where there is a beat. With Apple's tracker, within 1 BPM on every kit loop and 31 of 40 bass loops;
the 41 loops with no grid are mostly shakers, bongos and half the vocals, where a tracker has
nothing to hold on to. Of the 23 wrong tempos, 8 were exactly 2/3 (a triplet bassline tracked
on its subdivision) and 5 were half. Loops carry their tempo in the name, so
fixedTempo:withFilenameTemposidesteps the tracker entirely — and makesquantizeexact. - The model's timing is not a constant offset. On a 123 BPM piano loop its onsets run up to
35 ms early for the first three seconds of a chunk and settle about 10 ms late after that.
Upstream's lag correction, which fits one shift, measures the two halves as cancelling and
applies nothing; nothing else could either.
quantizeagainst the loop's own grid put every one of its 43 onsets exactly on a sixteenth. For loops, quantize.
712 loops from four commercial sample packs that ship the MIDI they were played from: bass
loops, acid lines, pads, synth lines and stabs, plus a dozen construction-kit stems. Medium fp32,
defaults, raw events (--format json, no tracker), scored with mir_eval the standard way —
a note is correct when its onset is within 50 ms and its pitch within 50 cents; offsets are
ignored. Tools/score_midi.py is the scorer.
Two things about the ground truth first, because they set the ceiling:
- The MIDI is what was played, not what sounds. A bass patch plays one or two octaves below the written note on 340 of 712 files, so the honest pitch score allows a per-file whole-octave shift (the "octave" column; "exact" is there to show the gap). Chord-stab, gated and octave-layered patches make more notes sound than the MIDI holds, which the model transcribes and the score counts against it: the model writes 1.8 notes for every MIDI note.
- The model does not hear everything. 59 files produced nothing: sub-only basslines (70–93% of their energy below 120 Hz) and quiet, bright, percussive synth textures. Peak-normalising three of the latter changed nothing, so it is not a level problem.
| loops | files | octave F1 | precision | recall | onset-only F1 | first note found |
|---|---|---|---|---|---|---|
| bass (pack A) | 90 | 0.69 | 0.64 | 0.81 | 0.74 | 73% |
| bass (pack B) | 90 | 0.63 | 0.53 | 0.87 | 0.68 | 71% |
| synth lines (pack B) | 90 | 0.57 | 0.49 | 0.74 | 0.62 | 67% |
| synth loops, several parts each (pack C) | 160 | 0.57 | 0.50 | 0.77 | 0.62 | 71% |
| acid lines | 90 | 0.49 | 0.44 | 0.63 | 0.57 | 64% |
| synth loops (pack A) | 90 | 0.42 | 0.38 | 0.57 | 0.46 | 47% |
| pads | 90 | 0.33 | 0.28 | 0.44 | 0.38 | 72% |
| construction-kit stems | 12 | 0.31 | 0.30 | 0.33 | 0.35 | 0% |
| all | 712 | 0.53 | 0.47 | 0.69 | 0.58 | 69% |
Matched onsets sit a median 6 ms late. Recall is the number to read: on monophonic bass the model finds 81–87% of the written notes, and most of what it adds is the patch's own sub-octave layer. Pads are the weak class — sustained chords come back as repeated notes, three for one.
The same MIDI rendered with a General MIDI piano scores 0.95. To separate the model from the
patches, every MIDI file was rendered through the library's own Auralizer (Apple's GM
synthesiser, the file's program, no effects) and transcribed: 749 renders, exact pitch, note
F1 0.947, precision 0.96, recall 0.94, 0.97 notes written per note played, first note found
91%, 14 silent files (very sparse or very high parts). Pads 0.93, everything else 0.93–0.98. So
when the audio contains exactly the written notes the model finds them; the gap to 0.53 on the
packs is what the patches add and what the timbres hide.
Large versus medium, same 120 real loops: 0.50 → 0.52 overall, but on the cleanest bass pack 0.63 → 0.78 with precision 0.60 → 0.79 — large writes 1.06 notes per MIDI note where medium writes 1.32, because it stops transcribing the sub-octave layer as a second note. Pads and the harder synth pack lose a little. Large costs 4× the time; on bass it pays.
The first note of a loop was a decoding rule, not a hearing problem. The model reports a
note that is already sounding at a chunk's first frame in the chunk's tie prologue, and a loop
that starts on beat one begins that way. Upstream's event builder ignores a tie for a note that
is not open — which at the start of a recording is every one of them — so with upstream's
reading the first note was found on 3% of 235 loops. leadingTies (--leading-ties) reads it
as a note starting at time zero; the tokens are identical. Same 235 loops: first note 64%,
octave F1 0.54 → 0.56. drop reproduces upstream's stream exactly.
For solo piano there is a second model: High-resolution Piano Transcription with Pedals
(Kong, Li, Song, Hou, Wang — ByteDance, 2020; Apache 2.0; 43M parameters), the one that hears
what MuScriptor cannot: a velocity for every note and the sustain pedal. PianoTranscriber
loads scribe-piano-float32.aimodel (136 MB) and returns notes with velocity plus pedal events;
MIDIAssembly writes them as note velocities and controller 64 on one acoustic-piano track,
with the same tempo-grid handling as the MuScriptor path. In the CLI: --engine piano.
Core AI has no recurrent op, and this model is half GRU: a decomposed GRU unrolls to about 770,000 ops at its size. So the asset carries the convolutional trunks as a graph and every recurrent and head weight as a flat vector, and the sixteen bidirectional GRUs run in Swift on Accelerate. Nothing was re-authored or retrained. Held to upstream's Python on a real piano loop and a 17-second, three-segment piece:
| stage | result |
|---|---|
| trunk through Core AI (GPU) | 155 dB PSNR against upstream's activations |
| Swift GRU on upstream's own trunk activations | 112 dB against PyTorch |
| the seven framewise outputs | 123–147 dB |
| note and pedal events | identical: every note, velocity and pedal, times within 0.001 ms |
The post-processor is a line-for-line port, including a Python quirk (a note starting on the very first frame is never emitted), because identical output is the point.
What it scores, and what it is for. On 540 of the General MIDI piano renders above (exact truth), the piano engine reaches note F1 0.87 (bass lines 0.92, pads 0.79) where MuScriptor medium reaches 0.95 on the same files. That is the model, not the port — its events are upstream's — and it is what a specialist trained on real piano recordings does with a synthesised piano. On real piano its published number is 0.968 on MAESTRO. Its velocities correlate 0.53 with the MIDI velocities of the renders and sit about 20 below them; a GM synthesiser's dynamics are not a Disklavier's, so treat that as a floor. Use it when you need velocity and pedal from a piano recording; use MuScriptor for everything else, including synthesised piano.
The weights are CC BY-NC 4.0 (non-commercial) and gated on Hugging Face; the code is MIT. The conversions described above are published, under the same licence and gate, at huggingface.co/arraypress/scribe-muscriptor (all three sizes, plus large fp16) and the tracker at huggingface.co/arraypress/scribe-beat-this (MIT):
# accept the licence on the repo page, then
hf download arraypress/scribe-muscriptor --include "scribe-medium-float32.aimodel/*" --local-dir models
hf download arraypress/scribe-beat-this --local-dir modelsOr convert yourself, which is what produced those files:
# accept the licence at https://huggingface.co/MuScriptor, then
uvx hf auth login
git clone https://github.com/muscriptor/muscriptor ../muscriptor
git clone https://github.com/apple/coreai-models ../coreai-models
uv run Tools/export.py --size medium --install # → ~/Library/Application Support/scribe/models/Tools/README.md describes the export: one .aimodel carrying main (the decoder, with its
attention cache as Core AI state), prefix (mel and instrument conditioning) and embed
(tokens). ModelLocator finds assets by explicit path, $SCRIBE_MODEL, then the install directory.
TranscriptionOptions carries every knob upstream's transcribe() has, with the same defaults:
greedy decoding, or sampling with a temperature and optional seed; classifier-free
guidance; instruments, a hard mask so nothing else can be decoded; preludeForcing;
beamSize; strictEndOfChunk; the 2,000-token maximumTokens budget per chunk. Batching above
1 is accepted for its semantics (it disables forcing) but chunks are decoded one at a time.
MIDIAssembly writes the file upstream writes: one named track per instrument on channels 1–9
and 11–16, drums on 10, tempo and time signature from the grid, a muscriptor:bar_offset= marker
when the music was shifted to put bar 1 on a downbeat. quantize snaps to the detected
subdivision — what a score needs, not what anyone wants to hear.
SheetMusic engraves MusicXML and PDFs, with tablature for fretted parts, through MuseScore 4+
if it is installed. Auralizer renders a stereo check mix — original left, MIDI right — with
the system's General MIDI synthesiser or a SoundFont, no FluidSynth needed.
macOS 27, Apple silicon, Swift 6.2. Core AI and MusicUnderstanding are the floor; there is no fallback for either.
swift test # 26 tests against golden fixtures, no model needed
SCRIBE_MODEL=~/Library/Application\ Support/scribe/models swift test # + the end-to-end parity checkMIT — see LICENSE. The model weights keep their own licence (CC BY-NC 4.0).