midiviz

Experiment #033 – Violin & viola fingering

033_violin_fingering.html fingers a melody (or double stops and chords) for violin, viola or cello and plays it with a simulated left hand and bow.

Files

File What it does
research/01_violinist_rules.md What violinists and violists do: geometry, positions and finger patterns, shifting, string choice, open strings, scale-book fingerings, double stops, hand size, violin vs viola vs cello, a weighted rule list and 32 test cases
research/04_double_bass.md The double bass: tuning in fourths, Simandl’s 1-2-4 and its position names, the 4→3 switch at the crook of the neck, thumb position (Petracchi), the Italian, French, open-hand and Rabbath systems, Morton’s shifting rules, posture, and 24 test cases
research/02_algorithms.md Published methods: Sayegh 1989, Maezawa 2012, Nagata 2014, the TNUA dataset and its BLSTM / VAE models, guitar tablature methods, bowing physics and violinist motion, and a recommended design
fingering.js The engine. Notes are grouped into events (single notes, double stops, chords). Each event’s states are the hand’s frame base plus a string and finger per note, with chord notes on adjacent strings. A time-aware Viterbi picks the path. Distances follow the fret formula, so stretches cost more on the longer viola string
templates.js Scale-book patterns (Galamian, Cooper) for diatonic runs of 15 or more notes. Each run keeps the single pattern that is cheapest for it
motion.js Turns a fingering into motion: shifts that land just before the note, fingers that stay down, vibrato, bow strokes (direction, slurs, hair position, retakes) and string crossings
violin3d.js, hand3d.js A procedural instrument and bow, a left hand driven by #029’s finger IK, a bow hand and arms
board2d.js A scrolling string tablature above a fingerboard map. Click the map to play a note
tnua.js Loader, alignment and scoring for the TNUA violin fingering dataset

Fingering library API

New integrations should call solveFingering(notes, options). It accepts notes shaped as { pitch, on, off, id?, lockString?, skip?, art? }, validates the notes and instrument profile, and does not modify either. Held-note protection is on by default; pass preserveHeldNotes: false to solve onset groups without that second pass.

The result is frozen and keeps one assignment per input note, in input order:

import { solveFingering } from './strings/fingering.js'

const result = solveFingering(notes, {
  instrument: 'violin',       // violin | viola | cello | bass | custom profile
  mode: 'standard',           // beginner | standard | expressive | nearest
  handSize: 1,
})

// { assignments, cost, diagnostics, movedHeldNotes, unresolvedHeldConflicts, ms }
// playable assignment: { index, id?, playable: true, string, finger,
//   base, offset, position, extension, thumb, divisi?, advisory? }
// rejected assignment: { index, id?, playable: false, reason }

Diagnostic codes are range, chord-capacity, constraint, no-state, held-conflict, and skipped. A note removed from the one-player chord solution still receives its cheapest independent fingering with divisi:true and advisory:'chord-capacity'; the diagnostic remains so callers can explain that the complete chord needs divisi. This fallback does not alter one-player motion or ranked-path costs; onePlayerNotes() is the shared filter for one-player animation, audio, statistics and displays. fingerLine and fingerHeld remain as mutating compatibility wrappers for experiments #033, #034, #037 and #038. They write the established string, finger, base, offset, position, ext, thumb, divisi, and unplayable fields. Repeated calls clear all previous solver output, and fingerHeld no longer leaves its generated lockString constraints on notes. Beginner mode limits the frame to first position and removes extension states; the lower high-second cost used for the Sibelius viola approach is therefore scoped to standard/expressive/nearest viola planning. Recognized viola scores also retain each printed note’s diatonic spelling: a soft, viola-scoped continuity cost can distinguish enharmonic C♯ and D♭ finger patterns without changing MIDI-only, violin, cello, bass, or beginner planning.

Custom profiles use the same shape as entries in INSTRUMENTS. Set template: 'violin' to use the Galamian/Cooper scale patterns with an equivalent violin-family frame.

Alternative suggestions

suggestFingerings(notes, options) returns the recommended solution followed by distinct alternatives from an exact k-best Viterbi search. Every candidate uses the same instrument, mode, weights, scale templates and held-note constraints, so costDelta is directly comparable to the recommended result.

const result = suggestFingerings(notes, {
  instrument: 'viola',
  limit: 4,             // 1–10 returned suggestions; default 3
  minChanges: 2,        // changed notes between any two suggestions
  maxCostDelta: 8,      // omit higher-cost paths; default unlimited
  candidatePool: 48,    // k-best paths examined, limit–128
})

// { suggestions, diagnostics, candidatePaths, exhausted, ms }
// suggestion: { rank, label, assignments, cost, costDelta, changes }
// changes: { notes, strings, fingers, positions }

The result, suggestion records, assignments, diagnostics and change summaries are frozen. Suggestions remain aligned with input order, honor lockString, retain base diagnostics, and never introduce more held-string conflicts than the recommended solution. candidatePool bounds work; exhausted is true when filtering by cost, diversity or held-note safety leaves fewer than limit suggestions.

Tests

Double bass

The bass needs a frame that changes along the neck (research 04 §7.2), so its frame is a list of entries, each valid over a range of frame bases:

Other differences from the violin family:

TNUA evaluation

node strings/test/tnua.js [--verbose] [--human] [--mode=…] [--tune --bench=0.08]

The dataset is 10 violinists × 14 excerpts, about 10,900 notes per violinist. Its CSVs are copied in tnua/ (source and credits), so the page lists the pieces with a violinist selector and the script runs without a separate clone. The engine sees only pitch and timing, and each piece is played at an approximate tempo.

Fingering string finger string + finger
Hand-set starting weights 79.3 59.7 57.1
Tuned, 2-fold cross-validation by piece (held out) 81.0 65.2 62.5
Tuned on all pieces (the shipped defaults) 82.1 67.6 64.9
One violinist vs the other nine 96.2 90.0 89.8

Experiment #034 – Violin sound lab

034_violin_sound.html plays the #033 fingering through six engines. The design follows research/03_violin_sound.md §4.

File What it does
sound/performance.js Fingered notes and bow strokes → per-string curves at 250 Hz: f0 (vibrato, slides on shifts, a slow intonation drift), bow velocity, force (kept above the measured Schelleng minimum F ≈ 0.02·(Z/0.3)^1.5·v/β²), contact point β, damping when a finger lifts, finger taps, and loudness (dB) for DDSP
sound/humanize.js Humanization after Embertone’s “Intuition” page, applied to the notes before the curves so every engine plays it: onsets move about 6 ms (less in fast runs; chords together, legato kept), velocity and bow speed / pressure vary per note, a stopped note lands 4–12 cents off (more after a shift, undershooting more often) and settles in 40–120 ms, and a round-robin index per repeated pitch. Seeded: “New take” on the page draws new choices. The physical models and DDSP hear the attack error in f0; the samples bend playbackRate and alternate between the two nearest recorded notes; GM puts each note on its own channel and retunes it with RPN 1 (OxiSynthRaw has no pitch bend). Live notes get the attack error too
sound/bowed.js WaveguideString (friction μs 1.2, μd 0.3, v0 0.3, Friedlander hysteresis), SourceString (band-limited Helmholtz sawtooth + bow noise), Body (signature modes plus 42 bridge-hill modes per ear), EQ, ViolinSynth (four strings, bridge coupling, voicing), renderCurves for offline use
sound/tone.js Fitted tone EQ and per-pitch voicing. Regenerate with node strings/test/fit_tone.js [folder] from the VSCO 2 CE “Arco Vib” violin samples (CC0) in the git-ignored strings/data/VSCO/; the long-term spectrum residual is 4.3 dB rms (waveguide) and 3.3 dB (source) in third-octave bands
sound/violin-worklet.js The physical models in an AudioWorklet: curve playback with transport, plus live notes
sound/ddsp.js, sound/ddsp-worker.js Magenta’s DDSP violin: the decoder in TensorFlow.js (worker), the harmonic + filtered-noise synthesis in JS, 5 s windows with 1 s crossfades
sound/hybrid.js Engine 6, a hybrid sampler after Embertone’s Joshua Bell Violin. Each note plays the University of Iowa recording of that pitch on the string #033 chose (non-vibrato, pp / mf / ff), read at a varying rate so it follows the performance’s f0 curve exactly (the recording’s own pitch wobble is taken out with its pitch track; our vibrato, slides, attack error and drift go in), with a small amplitude flicker tied to the vibrato. The two layers around the loudness curve’s level are crossfaded per sample after aligning them to the same pitch period (no phasing). Detached notes keep 60–150 ms of the recorded attack, then splice to the steady part at the best-matching point; bow changes between connected notes start partway into the attack; slurred notes have no new attack: the old sample glides along the f0 curve while the new one fades in from its steady part, aligned to the old one’s phase. Long notes loop (pitch-synchronous loop points, r ≈ 0.95–0.99); when the bow leaves, the voice crossfades into the recording’s own release and ring. Repeated notes alternate with the neighbouring recording. Pure JS (about 30–70× real time), so the page renders it progressively and Node renders it in tests
sound/samples/iowa/ The banks: per instrument and string, one Ogg Opus file per dynamic (64 kb/s mono, 48 kHz) and manifest.json (onset, end of attack, release tail, sustain RMS, a 100 Hz pitch track and a loop per note). Violin 12 MB, viola 8 MB, cello 10 MB, loaded per string and dynamic as a piece needs them. Built by node strings/test/build_iowa.js --instrument=violin\|viola\|cello (downloads the chromatic scales to the git-ignored strings/data/iowa/, cuts them at the silences or at the noise floor, names notes by scale order checked against a pitch reading, re-reads the viola files whose 96 kHz audio carries a 44.1 kHz header). Source: University of Iowa Musical Instrument Samples, 2012 recordings, “may be downloaded and used for any projects, without restrictions”
sound/engines.js One transport for all engines (pre-rendered ones play in 1 s blocks as they become ready), the #030 hall reverb, limiter, analyser

Measured (Node, 48 kHz): sustained notes are in Helmholtz motion in ≥ 85 % of pitches × dynamics for violin, viola and cello, sound within 20 cents, and four strings + body render at about 30× real time. DDSP’s 5 s window takes about 9 s on a CPU backend (headless Chromium without a GPU); with WebGL it should be well under a second, not yet measured on a real GPU.

Hybrid sampler, measured (Node, strings/test/sound.test.js): pitch within 6 cents of the curve on every string and dynamic of violin, viola and cello without vibrato, and the output’s vibrato depth within 30 % of the curve’s; a 7 s note (longer than any recording) stays within 6 dB and has no clicks; forte is more than 6 dB louder and 15 % brighter (spectral centroid) than piano; slurred joins dip less than 6 dB; the level predicted from the loudness curves is within 2.5 dB of the render. In headless Chromium, Bach’s Adagio BWV 1001 (88 s, 471 notes) renders in 1.4 s and the Presto (806 notes) in 6.2 s, and playback starts with the first rendered second.

Limits: the Iowa recordings are one player, one take per note, and an anechoic room (the #030 hall adds the room). Transitions are synthesized from single notes; recorded note-to-note legato, as in the Bell library, would need interval recordings that no free set has. Some notes in the Iowa scales are up to a semitone off their file names; the engine reads each recording’s measured pitch, so this costs at most a semitone of transposition. The viola’s D string has no mezzo-forte file above B4, so those notes use the nearest layer. Older Safari versions cannot decode Ogg Opus.

Automatic bowing (after SWAM Violin 3)

SWAM’s player only gives notes, velocity and one continuous Expression; the plug-in plays the bow. The Bowing panel in #034 does the same:

SWAM control #034
Expression (CC11) expressionFromCC in performance.js reads the track’s CC11, else CC7 (MuseScore and LilyPond write dynamics and hairpins there), else CC2, through the GM volume curve. The lane moves the bow’s dynamic per frame, so speed, force and contact move together (louder: faster, heavier, nearer the bridge). When all velocities are equal (Mozart K379, the Kreutzer: velocity 127 everywhere, dynamics in CC7) the lane is the dynamic. Sample engines get it as note velocities
Interactive BowPress WaveguideString counts slips per period (a slip counts only after a stick of 0.3 period, which separates Helmholtz motion from double slipping on 338 of 343 good notes). Double slipping and a stuck bow are hysteretic: at the same force the string can settle into either, and the fixed-force scan showed neither more nor less force reliably helps. A short excursion does: a ±14 dB force kick decaying in 25 ms, alternating, at most 12 per stroke. Helmholtz sweep (369 pitch × dynamic notes): 343 → 359 at the default pressure, 312 → 346 at pressure 0.6, 312 → 329 at 1.8
Bow pressure / bow position knobs The Pressure and Contact sliders scale the automatic values
Bow Start, Keep Bow Direction bowStart, keepBow (motion.js options, defaults unchanged for #033 / #037)
Bow Lift: Off String / On String bowLift: the bow leaves and the string rings, or it stays (light force, still) and stops it; the hybrid sampler cuts the recorded ring to ~35 ms
Mono CrossString Muting (1 sem / 2 sem / All) crossMute: in a slur, a crossing by ≤ n semitones damps the old string
Sordino A high shelf (−12 dB above 1.5 kHz) and a small low-mid peak on the bus, all engines
Rosin, String Resonance Stick-slip grain and bow noise; bridge coupling of the other strings (physical models)
Harmonics 2 / 3 / 4 (Left Hand) harmonics (Left hand panel): planHarmonics turns each note into a harmonic touching at 1/n of the string (natural on an open string when possible; Auto only above the top string’s comfortable range). The waveguide models the touch: its nut side is split at the touch point by a lumped damper (resistance 1.2 × 2Z), so modes with a node there pass and the others die, and it is bowed at 60 % force. A 7th control channel (CH.harm) carries n. Test: the n-th partial leads by ≥ 6 dB on every note for n = 2, 3, 4, and the same string without the touch sounds its fundamental. The source model, samples and hybrid play the sounding pitch with the upper partials filtered (no free recorded harmonics)
Tremolo Off / Slow / Fast, Hz / Sync / Sync+Acc, Min Speed tremolo, tremoloMode, tremoloMin: every stroke long enough for two turns becomes a bowed tremolo (rounded square-wave bow velocity, force from the stroke’s speed so each turn bites, 85 % force). Free: 9 / 15 strokes/s; Sync: 16ths / 32nds of the MIDI file’s tempo, doubled up to the minimum; Acc: starts at 55 % and reaches the rate in 0.35 s. The loudness curve pulses with the turns (DDSP, hybrid); the VSCO samples get a gain curve; GM has none
Live: CC11, CC1, pitch bend MIDI in: CC11 / CC2 drive the bow’s dynamic (velocity then only shapes the attack), CC1 sets the vibrato depth, pitch bend ±2 semitones

Not taken over: SWAM’s Gesture Mode “Bowing” (bow direction from a controller), the manual tremolo / bow-change keyswitches, alternate fingering (the #033 fingering styles cover it), and the measured body, which research/05 ranks first.

Measured body (from the Iowa recordings)

The physical models used a synthetic body: 10 signature modes plus 42 random ones, then a 13-band EQ fitted to VSCO. Research 05 ranks a measured body as the largest gap. node strings/test/fit_body.js (6 minutes, needs ffmpeg and the Iowa banks) now estimates one per instrument and model:

  measured modal body + EQ
violin waveguide 3.5 / 9.5 5.5 / 14.9
violin source 3.1 / 8.1 4.4 / 12.2
viola waveguide 3.5 / 9.6 5.1 / 14.2
viola source 2.7 / 7.0 4.1 / 11.7
cello waveguide 3.3 / 9.6 5.4 / 16.7
cello source 2.4 / 6.4 4.5 / 13.4

What remains is mostly the difference between his bowing and our string’s source spectrum, which no body can fix.

Limits:

Automatic bowing and bow-force brightness

After SWAM’s moving Bow Pressure / Bow Position knobs and Schoonderwaldt’s measurements of violinists; research 05 §6 has the background.

pp → ff brightening, cents (median) Philharmonia Iowa target (mean) waveguide before → after (c) source before → after (c)
violin (30 + 91 pairs) 521 279 400 19 → 401 (1.77) 57 → 398 (1.63)
viola (47 + 97) 952 313 633 53 → 633 (2.04) 82 → 635 (2.52)
cello (49 + 85) 162 425 293 13 → 294 (1.55) 40 → 290 (1.83)

A/B against a real violinist

node strings/test/ab_recording.js [--rec=ko-donghwi_bwv1001_mov1] [--from=0] [--secs=20] [--engines=…] [--bodies=…] [--room=recital|dry] downloads the Bach Violin Dataset (Dong et al., ICASSP 2022) into the git-ignored strings/data/BachViolin/. It is 265 MB: 6.5 hours of Bach’s solo sonatas and partitas by 17 violinists, with the score’s notes aligned to each recording. Licences differ per collection (public domain, CC BY 3.0, some NC/ND).

Performance fitted to real violinists

node strings/test/perf_stats.js [--recs=…] [--model=source] [--body=phil] [--style=JSON] measures sustained single notes (≥ 0.5 s) in Bach Violin Dataset recordings, at their aligned times. It tracks f0 every 10 ms and reports:

It then measures our engine on the same notes, at the violinist’s timing and through the same hall. Default: eight slow movements by four violinists (Ko, Garner, Kim, Telmányi). Pooled medians, 401 notes:

  vibrato on rate width onset level spread swell slope
recordings 67 % 6.55 Hz ±13.3 c at once (≤ 0.08 s) 7.8 dB 1.6 dB +0.6 dB
before 41 % 5.60 Hz ±7.4 c 0.31 s 4.6 dB 0.3 dB −0.4 dB
now 68 % 6.45 Hz ±13.2 c ≤ 0.08 s 4.9 dB 1.3 dB +0.5 dB

Changes in sound/performance.js:

The level spread between notes is still short of the players’ (4.9 against 7.8 dB). It is their interpretation (echoes, terraced dynamics, accents), which the plain score notes don’t carry; the Variance slider at 2.5 reaches 5.7 dB.

Waveguide multipliers (WG pressure, WG position, WG limits)

Three Timbre controls for the waveguide only, applied after the automatic bowing in sound/bowed.js:

The meters and lanes show what the string gets. Measured on 117 steady notes (violin, viola, cello; p, mf, f), notes in Helmholtz motion:

WG pressure × position raw kept playable
1 × 1 116 116 (no change)
0.3 × 0.3 4 107
1 × 0.5 85 102
1 × 2 55 110

Measured body (Philharmonia samples)

Body: Measured (Philharmonia): violin, viola and cello bodies estimated as the Iowa ones are, from different instruments and players.

source, dB median / p90 Philharmonia notes: this body (held out) … Iowa body … no body Iowa notes: this body … Iowa body (fitted on them)
violin (151 notes, 1,843 frames) 2.9 / 7.8 4.4 / 11.6 3.7 / 9.7 4.7 / 12.0 3.0 / 8.1
viola (190, 2,343) 3.0 / 7.9 3.6 / 9.5 4.1 / 10.6 3.1 / 8.6 2.5 / 6.7
cello (187, 2,350) 2.9 / 7.6 3.6 / 9.5 4.4 / 11.3 3.3 / 8.4 2.4 / 6.2
waveguide, dB median / p90 Philharmonia notes: this body (held out) … Iowa body … no body Iowa notes: this body … Iowa body (fitted on them)
violin (151 notes) 3.4 / 9.1 4.7 / 12.4 4.6 / 12.0 4.8 / 12.8 3.4 / 9.2
viola (190) 3.5 / 9.5 4.0 / 10.8 4.9 / 12.6 4.2 / 10.1 3.2 / 8.5
cello (183) 3.4 / 9.0 4.1 / 10.7 5.6 / 14.1 3.8 / 9.3 3.0 / 7.3

The waveguide was fitted on the string of 4c76b6b (pitch-tracking end losses). The committed Iowa waveguide body predates that string, so its columns here are from the older fit.

Measured body (NBody hammer responses)

A third choice, Body: Measured (NBody, violin): a violin measured directly, not estimated. Research 05 §5 explains the three bodies in plain terms.

  waveguide source
NBody, all 12 microphones 5.7 / 14.5 5.6 / 14.3
NBody, microphones 3 + 10 only 6.5 / 16.0 6.3 / 15.9
modal body 4.7 / 13.4 4.4 / 12.2

On the lyrical demo, compared with the hybrid sampler’s recorded violin, the 4 kHz band goes from +6 dB to +1 dB and the spectral centroid from 1,935 to 1,149 Hz (recording: 1,478 Hz). It gains about 4 dB around 640 Hz, and stays 15–20 dB short above 8 kHz, where the hammer barely excites the body. Stereo correlation is 0.45 (0.33 with the pair). It is still a different violin from the Iowa one.

Waveguide attacks (Guettler) and the playable window

The waveguide’s attacks crackled. Three scripts measure why, and check the fix:

What they showed: the window was far too narrow on low notes. It was ×1.2 between F_min and F_max on the cello C string and ×1.85 on the violin G, and it grows with pitch, not with string mass. The two end losses were one-pole filters with almost fixed cutoffs, so a low note’s corners were barely rounded within a period. The fix makes both cutoffs track the note: 2.5·f0 at the bridge, 4·f0 at the finger, 12·f0 at the nut. The other candidates widened nothing or made it worse:

Window (geometric mean over 9 contact-point × bow-speed cases, 5 semitones above the open string):

  before after
violin G / A / E ×1.85 / 3.89 / 7.0 ×4.27 / 6.18 / 7.44
cello C / G / A ×1.20 / 1.28 / 2.86 ×2.16 / 2.53 / 4.01

On top of that, performance.js now starts strokes the way Guettler’s diagram asks:

Tuned over a grid of 150 + 192 + 48 + 54 settings on the quick suite.

attack.js, 432 bow starts before after
good 65 317
reach Helmholtz in time 110 330
stay in Helmholtz 244 407
median time to Helmholtz 91 ms 35 ms
violin / viola / cello good 58 / 42 / 12 of 144 129 / 107 / 81 of 144

The cello from rest is still the weakest: 35 of 72. Because the raw tone changed, the waveguide’s measured bodies were refitted. Held-out residual: 3.0–3.4 dB median (it was 3.3–3.5); the modal body’s is 4.7–5.1 dB. With the string settling by itself, interactive bow pressure now rarely has anything to correct (182 vs 181 of 189 sweep notes).

Source model: per-period jitter and bow noise

Through headphones the Source model sounded like raw sawtooth waves. A comparison with the Iowa recordings (same notes, no vibrato) showed that its brightness was not the problem: it had 2–12 dB less energy than the recordings at 2–16 kHz. The difference was between the harmonics. From 3 to 8 kHz, each harmonic peak stood 46 dB above the gap halfway to the next one, against 34 dB in the recordings: a perfectly periodic sawtooth, with clean lines and silence between them. A real string never repeats a period exactly.

SourceString now draws, at every period:

Consecutive periods correlate at 0.5. The steady bow noise in the slip phase is 0.2, up from 0.05. Peak / gap is now 44.5 / 35.3 dB at 1–3 / 3–8 kHz, against the recordings’ 45.2 / 34.1 (it was 57.7 / 45.9). Band levels moved by under 1 dB and pitch wobble is about 1 cent. Rosin scales all of it. The Source bodies were refitted: held-out residual 2.5–3.1 dB.

Measured bodies: matching the energy, not only the median

A long-term comparison over every other Iowa note (third-octave bands, each note normalised to its 300 Hz – 2 kHz power, then averaged as powers, 5 % trimmed) showed that the 2–4 kHz bridge region was roughly right on average (violin −3 dB at 2.5 kHz, viola −3, cello −2 to −5 at 2–4 kHz). An earlier four-note check had said 2–8 dB short. The real gap was higher up: 5–14 dB too little energy at 5–13 kHz on the violin, 4–9 dB on the viola, 3–6 dB on the cello.

The cause is statistical. The body fit matches each harmonic’s median level in dB, and those medians do match (within 1–2 dB). But a real body has sharp resonances that a harmonic now and then lands on, so the recordings’ upper harmonics scatter far more than ours. At an equal median they carry much more energy, and loudness and brightness follow energy.

fit_body.js now ends with an energy match. It renders the notes through the fitted body, compares the band energies with the recordings, and adds a smooth correction above 1.25 kHz (80 % of the difference per step, three steps, ±10 dB per step). It skips the step with --no-energy; --out= writes elsewhere, so instruments can be fitted in parallel.

Worst band above 1.6 kHz, ours − recordings:

  before after
violin waveguide / source −13.8 / −12.4 dB ±2.5 / ±2.6 dB
viola −9.4 / −9.3 dB ±1.7 / ±1.9 dB
cello −6.4 / −5.1 dB ±1.3 / ±1.1 dB

The held-out median residual stays 2.5–3.3 dB. The per-band before / after figures are in each body’s stats.energy.

Most of that gap turned out to be the normalisation, not the body: see the energy match without the top notes.

Waveguide: per-slip jitter

The Source model’s per-period jitter applied to the waveguide too: its upper harmonics stood 56 / 46 dB above the gaps between them at 1–3 / 3–8 kHz, against the Iowa recordings’ 45 / 34. Each slip now draws the next period’s bow force (15 % rms) and static friction (3 % rms), correlated 0.5 between periods and scaled by Rosin. Result: 44 / 36 dB.

Friction jitter costs attacks and Helmholtz stability faster than force jitter, which is why most of it is force:

Two tests now run their check with Rosin 0, because they measure something the jitter blurs on purpose:

The waveguide bodies were refitted with the energy match. Held-out residual: 2.9–3.4 dB. Every band above 1.6 kHz is within 2.4 dB of the recordings.

Short strokes: less press (the Tchaikovsky cracks)

The waveguide cracked in fast forte chord strokes, for example the G♯3 + F4/B4 passage at 4:37 of the Tchaikovsky concerto’s first movement (112 ms strokes). On held notes the automatic bowing sits in the middle of the measured playable window (Schelleng; median 52 % up it, 1.5–3.5× below F_max), within the forces Schoonderwaldt measured (mf 0.94 N, f 1.61 N median). Short strokes were the exception. Three boosts multiplied at their start: the ×2 force scale, the ×2 attack press and the short-stroke bite of ×1.5. Their peaks reached 4–8.7 N at forte, above a player’s 3–4 N accents.

Short strokes (one note under 180 ms) now press ×1.5 instead of ×2 and reach full bow speed half as fast. Of the variants tried (no press, less bite, a cap at a fraction of the Schelleng maximum, a cap in the release, slower ramp alone), this one cut the most crackles without making attacks worse:

  before after
crackles (3–10 kHz bursts), concerto 4:30–4:52 7 1
crackles, concerto 3:20–5:30 15 9
crackles, demos + Bach Adagio (74 s) 6 4
short forte notes: peak force p90 / max 4.1 / 8.7 N 3.1 / 5.9 N
short notes that go raucous (216) 11 5
attack.js quick suite 207 / 288 207 / 288

Articulation planner and score input

research/06_articulation.md explains the background: SWAM and the Joshua Bell Violin choose legato, détaché and portamento from overlaps, gaps and velocity. Our violin MIDIs carry none of that, because 94.9 % of note pairs touch exactly.

score.js reads MusicXML and compressed .mxl, the export format of MuseScore, Finale, Sibelius and Dorico. It keeps what MIDI loses:

It is a small regex XML walker plus a zip reader, so it runs in Node, workers and the browser (readMXLBrowser inflates with DecompressionStream). Mutopia’s LilyPond sources are not read directly. python-ly (ly musicxml piece.ly) converts them, but that path is untested.

articulation.js decides per note whether it continues the previous bow (n.art.slurPrev) and its stroke. It runs before bowing, and motion.js follows the plan whenever notes carry one. Three sources, picked automatically:

Input Layer Rule
a score score its slurs and marks
a timed MIDI (fewer than 85 % of pairs touching) timing SWAM / JBV: an overlap is a slur (a quiet overlap a slide); a gap re-bows; the gap’s size sets détaché / staccato / spiccato
a score-export MIDI (every note touching) inference short notes (≤ 0.34 s) by step (≤ 3 semitones), similar lengths, up to 8 per bow, a new bow on the beat, never across a rest, a repeated pitch or a chord

node strings/test/articulation.js [--tune] [--verbose] checks the inference against the slurs of the Bach Violin Dataset’s MusicXML editions of BWV 1001–1006: 32 movements, 21,570 note pairs, 27.1 % slurred in the editions. Each consecutive pair is scored as slurred or separate bows:

  slurred P / R / F1 agreement
all separate bows 0 / 0 / 0 72.9 %
motion.js’s fixed rule (≤ 4 fast notes) 32.9 / 46.4 / 38.5 59.8 %
planner inference (tuned on even movements) 43.1 / 59.6 / 50.0 67.7 %
— held out (odd movements) 34.4 / 51.8 / 41.3 60.4 % (rule 47.4 %)

Bach’s slurs are an editor’s and vary a lot by movement (training F1 61 % against 41 % held out), so the inference gives a plausible bowing, not the edition’s.

In #034, Open… (or drag-and-drop) accepts .musicxml, .xml and .mxl. Articulation in the Bowing panel offers Auto, Inferred (always infer) or Fixed rule; the label’s tooltip names the source for the current piece. Bach’s Presto BWV 1001 plays as 419 strokes from the MIDI (inferred) and 584 from the MusicXML (its 94 slurs). Tests: node --test strings/test/articulation.test.js (3).

Stroke types. The planner also marks separately bowed notes with a stroke type, which performance.js plays:

Through the waveguide, a 12-note martelé line settles in 6–39 ms per stroke. In a spiccato run most bounces settle within their 45 ms contact, and some stay unsettled, as a real bounced stroke does. Tested in strings/test/articulation.test.js.

Repeats and marks under a slur. In a score, a slur over a repeated pitch (not tied) or over marked notes keeps one bow. The marked note speaks with a pulse in that bow (n.art.portato): the bow slows just before the note, the force falls less than the speed (the bow grips), and the bow presses into the new note.

Slides from the plan (n.art.port). A heard slide into the note, 90–220 ms (scaled by the Slides slider) where a shift’s slide is 35–160 ms, also when the fingering keeps the position. The fingering pays a cost (SLIDE_CROSS in fingering.js) to keep both notes of a planned slide on one string, with the new one stopped. Three sources set it:

Shift slides with no plan behind them are halved across a bow change, where a player hides the shift in the change. On the 48 violin MIDIs (206 min), the planner asks for 1.0 slide per minute (198 heard, the others land on another string), against 10.2 shift slides per minute. On the 32 Bach edition movements it asks for 0.3 per minute. Bach’s Gavotte BWV 1006 gets 38 because its MIDI is at half tempo (60 bpm, 6.7 min), so its notes look lyrical. Tested in strings/test/articulation.test.js.

Not done yet:

Articulation: fewer détaché notes

Two rules made most MIDIs play détaché:

Also, an accented note (a velocity jump of ≥ 0.15 in a file whose velocities vary) and the note after it take their own bows.

slurred note pairs before after
Tchaikovsky concerto I (B. Fisher, 5,599 notes) 0 % 37 %
Bach Bourrée / Gavotte BWV 1006 0 / 1 % 49 / 22 %
Bach Largo BWV 1005 2 % 38 %
Gounod Ave Maria 0 % 14 %
Paganini Caprice 24 1 % 27 %
all 49 MIDIs (52,285 pairs) 24.7 % 31.9 %

Tests: node --test strings/test/articulation.test.js (5).

Legato bow changes

Every stroke used to start and end as if detached: the bow slowed over 25–60 ms into the change, rested on the string, then started again with a new attack (the ×2 press, the bite and a small swell). In a legato line that is a hole at every bow change: the sound fell by a median 27 dB (waveguide) or 22 dB (source) for about 30 ms, even though the notes touch and are marked legato.

Now a stroke that starts where the previous one ended (a gap of at most 8 % of the note, or 15 ms), with neither stroke marked spiccato, martelé or staccato, is a bow change inside the phrase, not a new note:

Legato in the expression sliders scales it (1 by default, 0 = detached bow changes as before); the Baroque preset uses 0.6, for bow changes that speak.

Measured on the demos, Ave Maria and the first 40 s of Bach’s Adagio BWV 1001 (202 bow changes) , and against the Bach Violin Dataset’s recordings at their aligned note changes (10 movements, four violinists, the recital hall on our renders):

sound at a legato bow change before after
waveguide, dry: median dip / time below −10 dB −27 dB / 30 ms −11 dB / 2.5 ms
source, dry −22 dB / 28 ms −9 dB / 0 ms
source in the hall vs the recordings (median dip) −11.8 vs −6.1 dB −6.0 vs −6.1 dB
waveguide crackles per minute (these pieces + the Tchaikovsky passage) 27.1 27.1
waveguide periods in Helmholtz motion 92.6 % 91.3 %
attack.js bow changes reaching Helmholtz motion within 50 ms 175 / 216 162 / 216

The last row measures the string’s motion, not the sound: in a legato change the old tone is still ringing over the new stroke’s first periods.

Loudness across the engines

All six engines play the same performance at the same loudness, measured as K-weighted loudness (ITU-R BS.1770, LUFS) by node strings/test/loudness.mjs (headless Chromium; see the file). A test performance contains scales and long notes at p / mf / f (velocity 0.3 / 0.6 / 0.9), double stops, and low and high notes.

LUFS (pre-hall) test, all Adagio BWV 1001 Hornpipe Dvořák Largo Für Elise
Waveguide −21.0 −13.1 −23.0 −22.3 −30.2
Source −21.0 −13.0 −22.8 −22.2 −30.1
DDSP −21.6        
Samples (VSCO) −20.4 −15.2 −19.7 −25.0 −33.1
GM −21.2 −16.8 −19.8 −25.1 −28.9
Hybrid −21.3 −15.1 −21.2 −25.0 −29.3

Before, the Adagio measured −12.4 / −9.4 / −22.7 / −21.1 (Waveguide / Source / GM / Hybrid). What remains differs by piece. The physical models add more on double stops (+5.6 dB over a single line, Hybrid +4.1). The Samples’ p layer starts slowly, so soft short notes are quieter. The Source engine, the default, plays where it did before (its old gain of 1.2 is now a trim of +1.6 dB).

Held notes no longer cut off

Long notes stopped suddenly in two-voice writing: a note held while the other voice moves (Bach’s sonatas, the concertos’ double stops). Three things cut them, none of them the automatic bowing:

Long notes cut by 30 % or more: 779 → 94 (75 for a third voice, 19 sharing a string). Test: “held notes” in sound.test.js.

MuseScore files

strings/score.js reads MuseScore’s own files directly, with no MusicXML export step: readMSCX(text), readMSCZ(buf, inflateRaw) and readMSCZBrowser(buf). readScoreFile(name, buf) picks the reader by extension (.musicxml / .xml, .mxl, .mscx, .mscz). All of them return what readMusicXML returns, with parts and the index of the part read, so the articulation planner sees the same slurs and marks.

Two MusicXML reader fixes came out of the comparison:

Checked against MuseScore’s own MusicXML. node strings/test/mscz.js compares the two readers on the OpenScore String Quartets corpus (CC0, github.com/OpenScore/StringQuartets). Each score there ships as .mscx / .mscz together with MuseScore’s .mxl export. 35 scores (18 .mscx in format 3.02, 17 .mscz in 3.02 / 4.50 / 4.60), all four parts, 275,499 notes:

  Agreement
Notes found by both (pitch, bar, beat) 99.4 %
Same length 98.7 %
Slur pairs (consecutive notes in one slur or not) 99.8 % of 258,388 (99.5 % before the two fixes below)
Stroke marks 99.9 % of 51,809
Bowings 99.7 % of 1,595
Slides (glissandi) 100 % of 26

Two MusicXML reader fixes came from reading OMR output (ScoreShift) and raised slur agreement here from 99.5 to 99.8 % (Franck 96.8 → 100 %, Gossec 93.9 → 99.4 %): a slur that stops on a grace note now ends there (grace notes were skipped with their slur marks, so the slur stayed open and took in everything after it), and a slur from a lower voice up to voice 1 in the same bar, whose stop comes before its start in the file because voice 1 is written first, now covers the notes of both voices from its start to its stop (test in score.test.js).

Most remaining differences come from the exporter rather than the score. Berg’s Lyric Suite part 1 has 15 notes that differ by two octaves. A few slurs the exporter splits at system breaks. Gradual tempo changes (rit. / accel. lines) are ignored by both readers. Reading all 140 parts takes 29 s in Node, for 16 MB uncompressed per large score. Unit tests are in strings/test/score.test.js.

In #034. Open… and drag-and-drop take .mscz and .mscx. For a score with several parts, the Track menu lists them (Violin 1, Violin 2, Viola, Violoncello). A viola or cello part switches the instrument.

LilyPond input

strings/lilypond.js reads LilyPond .ly files directly, as Mutopia publishes them, without running LilyPond or converting to MusicXML. readLilyPond(text, { include, part }) takes one file and resolves \include through a callback. readLilyPondFiles(map) takes a set of files and finds the main one (a .ly with a \score or \book, highest in the folders, a full score first). readScoreFile('x.ly' | 'x.zip', buf) opens a single file or a Mutopia -lys.zip. The result has the same shape as readMusicXML, with the parts of every score LilyPond would write as MIDI.

It interprets the language engraved music uses, not Scheme:

Checked against LilyPond itself. The 48 Mutopia MIDIs in violinist/midis were written by LilyPond from the .ly sources Mutopia publishes, so reading the sources should give the same violin notes. node strings/test/fetch_mutopia_ly.js fetches the sources into strings/data/MutopiaLy (git-ignored, 2.4 MB: the .ly, or the -lys.zip unpacked, from each piece’s Mutopia page in violinist/midis/index.json). node strings/test/lilypond.js compares the two. Results on the 48 pieces (47,179 violin notes; Bach’s Sonatas and Partitas, sonatas, three concertos with the orchestra in the same score, Paganini, Gounod):

  Agreement with LilyPond’s MIDI
Notes (same pitch, onset within 1/64 of a quarter) 99.5 %
Onsets in seconds within 20 ms (tempo included) 100 %
Same length 99.3 %
Pieces with ≥ 97 % of notes 46 of 48

The two below 97 % are cases where LilyPond’s MIDI departs from the score:

The scores carry their own articulation: 19.7 % of the note pairs are slurred, and 6.4 % of the notes are marked. Reading takes 2–50 ms for a Bach movement and 0.33 s for the whole Tchaikovsky concerto (3 movements, 41 staves). Unit tests are in strings/test/lilypond.test.js.

In #034. Open… and drag-and-drop take a .ly, a Mutopia -lys.zip, or several .ly files at once, for a piece split into \include files. The Track menu lists the staves of every movement, with the solo violin chosen by default. When includes are missing, the status line names them.

Playing like a recorded violinist

For Bach’s Sonatas and Partitas #034 can now play a piece as a real violinist did. Song → a Bach movement, then Performer → Emil Telmányi, John Garner, Ko Donghwi or MinJi Kim (47 recordings from the Bach Violin Dataset, public domain or CC BY; violinist/performances). The bowing still comes from the score or an edition (Articulation), because the audio does not show it; everything else comes from the player:

node strings/test/expression_ab.js [--recs=…] [--secs=300] [--model=source] [--body=phil] compares, on five movements by the four players (whole movements up to 5 min), the recording’s notes with ours at the player’s timing played by our rules, and with the player’s expression:

  Rules (player’s timing only) Player’s expression
Level, 0.25 s frames, correlation with the recording (median) 0.26 0.87
Note levels, correlation (median) 0.21 0.89
Pitch curve error on single notes, cents RMS (median) 13.8 5.1
Vibrato width on long single notes, ours / player’s ±12 / ±4–15 (one width for all) ±4.4 / ±3.9 (Ko), ±12.8 / ±14.7 (Garner), ±10.0 / ±10.9 (Telmányi)

On the Waveguide model (Iowa body), with the lane corrected for Source, the note levels still correlate 0.79. Our rules give every player the same ±12-cent vibrato; the players range from Ko’s near-baroque ±4 to Garner’s ±15. Tests: node --test strings/test/expression.test.js (4: measuring level, vibrato and intonation; storage; the renderer following a pitch curve and a lane; the correction).

Aligning recordings to the score, and a lane per engine

Recordings outside the Bach Violin Dataset come with no alignment. align.js finds the MIDI file’s notes in them: chroma and chroma-onset features (20 ms frames, 93 ms windows, after Müller and after Ewert, Müller & Grosche 2009) for the recording and for the score synthesised at the recording’s average tempo; subsequence DTW at 5 frames a second over the whole file (so silence, applause and talk around it are skipped), steps keeping the tempo within ½–2× of the average; each way of taking the score’s repeats (the LilyPond reader now returns them; \repeat "volta" with the quotes was read as no repeat) and, for old recordings, ±1–2 semitones are tried and the best kept; then the same DTW at 50 frames a second in a ±1.2 s band. Moving each onset to the nearest rise of its pitch’s energy did not help and was dropped. node strings/test/align_eval.js on 12 dataset recordings (8,974 notes): within 32 ms of the dataset’s alignment (median), 87 % within 100 ms, the repeats as the dataset says 12 / 12, and the note’s pitch sounding in the middle of where it is placed 91 % of the time (the dataset’s own alignment 83 %, so part of the difference is the dataset’s). Two seconds a movement. Fifteen recordings are aligned so far (violinist/performances), four beyond Bach: Paganini’s Caprice 24, Beethoven’s Romance No. 2 and two of Vivaldi’s Winter.

Repeats written out. Mutopia’s MIDI files are made with \unfoldRepeats, so a repeat the player skips used to have no plan to match it. The LilyPond reader now keeps a written-out volta too (unfolded: true, with keep, the one pass played when the repeat is not taken), and repeatPlans tries it both ways. Beyond four repeats it tries all of them taken, none, and each one alone flipped from those two (2R + 2 plans, not 2^R): Caprice 24 has 11. On the QMUL Caprice this took the pitch-where-placed check from 39 % to 71 % (chance 13 %): the player skips the first variation’s repeat. The solo part’s notes are now the tracks on the violin’s channel when the part is split across tracks, as Caprice 24’s pizzicato variation is (1,593 notes, was 1,470), the same choice #037 and #038 make.

The dynamics lane is now corrected by listening on the Waveguide model too, and each engine plays its own (applyRecording(…, { engine }); the others take Source’s). On five movements rendered on the Waveguide, the note levels correlate with the player’s 0.86 with its own lane and 0.84 with Source’s (node strings/test/expression_ab.js --model=waveguide [--lane=source]); Source with its own, 0.88. On five of the newly aligned recordings (Heifetz, Menuhin and three others): 0.86, pitch curves within 5.9 cents (14 to 17 with our rules).

Attacks from rest

Every stroke reached full bow speed in 25–70 ms whatever the dynamic, and the waveguide was in clean Helmholtz motion within ~10 ms of being heard. The Iowa violinist’s notes from rest rise far more slowly at soft dynamics, and their first 40–70 ms are not periodic. Measured on the Iowa violin notes (every third semitone, four strings, pp / mf / ff): the level and the periodicity (best normalised correlation within ±60 cents of one period) after the −40 dB onset.

A long stroke from rest (no stroke in the last 0.35 s; not short, spiccato, martelé or tremolo) now sets off slower the softer it is. The bow speed rises over 70 ms × (1 + 2.9·soft), soft = 1 − dynamic (about 0.25 s at pp), along (1 − (1 − x)²)^(1 + 2·soft). The force keeps more of the usual faster rise (× speed ratio^−0.65): the bow’s weight is on the string before its speed is, so the first tens of ms are raucous, as Guettler’s diagram has it for a slow acceleration. Constants REST_* in sound/performance.js, fitted by a search over the onset error.

  Iowa before after
level 40 ms after the onset, pp / mf / ff −19 / −12 / −7 dB −4 / −5 / −5 dB −19 / −12 / −7 dB
periodicity 20 ms after the onset, pp / mf / ff 0.71 / 0.45 / 0.34 0.95 / 0.95 / 0.94 0.26 / 0.47 / 0.87
level error, 5–250 ms (mean)   5.6 dB 1.35 dB

Only the bow speed and force change, so it reaches the waveguide and the Source model. The loudness curve does not change: the hybrid sampler already plays the recorded attack, and DDSP reads that curve. The Source model’s sawtooth stays periodic from its first period; only its rise follows.

Limits:

attack.js, 432 bow starts:

  before after
stay in Helmholtz 409 406
good bow changes 160 / 216 158 / 216
good from rest (Guettler’s 50 ms from the bow start) 138 / 216 105 / 216
violin from rest: median / p90 time to Helmholtz 32 / 74 ms 56 / 93 ms

The bowing regime is chaotic: nearby constants give 405–410 notes in Helmholtz, and the refitted bodies move a few notes too, through the voicing and the bridge coupling.

The recordings themselves would miss Guettler’s 50 ms from rest by this count: they are aperiodic for 40–70 ms after they can be heard. The Guettler test in sound.test.js therefore holds bow changes to it, and strokes from rest to Helmholtz within 120 ms. A new test holds the attacks from rest to the Iowa onsets (level error under 4 dB, it was 6.4 on its 24 notes; periodicity 10–20 ms after the onset under 0.8, it was 0.91).

Measured bodies: the energy match without the top notes

The energy match normalised each note to its own 300 Hz – 2 kHz power. Above 1 kHz, at the top of the E string, that band holds one harmonic at most, and none from 2 kHz. Those notes’ upper bands read 20–33 dB high and set the power mean. E7 ff alone was 61 % of the violin’s 4.5–12 kHz power mean, and E7 ff + pp together 84 %. Matching them lifted the whole upper body: the typical note came out 6–12 dB too bright there.

fit_body.js now leaves notes above 1 kHz out of the energy match; the median fit still uses every note. All six Iowa bodies were refitted (held-out residual unchanged, 2.4–3.3 dB). Measured on notes up to 1 kHz, ours − recordings:

typical note (median), 4.5–6.5 / 6.5–9 / 9–12 kHz before after
violin waveguide +6.2 / +11.2 / +11.6 dB −0.3 / +3.3 / +3.3 dB
violin source +4.9 / +9.9 / +6.4 dB −0.4 / +3.0 / +3.4 dB
viola waveguide / source +6 to +8 dB +3 to +6 dB
cello 0 to +3 dB unchanged

On the same notes, the energy above 4.5 kHz (5 % trimmed power mean) went from +4 to +9 dB to within ±0.9 dB on the violin, and from +2 to +3 dB to within ±0.7 dB on the viola. In the fit’s own check, every band above 1.6 kHz is within ±1.4 dB.

The scatter explanation above was mostly this too. On notes up to 1 kHz, the recordings’ upper bands vary 4.3–5.4 dB from note to note within a string and dynamic, against our 3.7–4.6 dB. A seeded resonance detail in the body (3 dB rms, about one resonance wide) moved our spread by 0.5 dB at most, so it is not in. Its likely use is the shimmer of vibrato across the resonances, which needs vibrato recordings to check.

fit_level.js rendered with the body’s name, so ViolinSynth played the existing voicing while the fit measured. A refit then wrote only the remainder over it: the E string’s +5 dB lift fell to +1.7, and loudness.test.js failed. It now renders the body’s spec object (no voicing), so refits are idempotent; --body=iowa|phil refits one. The Iowa voicing was refitted. The Philharmonia and NBody bodies have no energy match and were not changed.