Skip to content

Carry the pitch LFO (vibrato) through the model#216

Open
douglas-carmichael wants to merge 8 commits into
git-moss:mainfrom
douglas-carmichael:add-pitch-lfo
Open

Carry the pitch LFO (vibrato) through the model#216
douglas-carmichael wants to merge 8 commits into
git-moss:mainfrom
douglas-carmichael:add-pitch-lfo

Conversation

@douglas-carmichael

Copy link
Copy Markdown
Contributor

This adds a low frequency oscillator to the model, scoped to the pitch destination (vibrato) only.

That scope is deliberate. A general LFO is hard to carry because most formats route it through a modulation matrix to arbitrary destinations, and those routings differ per device. Vibrato is the exception: nearly every format expresses it as a fixed pitch-LFO with a rate, a depth and a delay, so it does have a portable representation. This PR carries that slice and nothing more.

Model

ILfo (waveform, rate in Hertz, delay, fade-in, start phase, key sync) and ILfoModulator extends IModulator, mirroring the existing IEnvelope / IEnvelopeModulator pair. It hangs off the zone as ISampleZone.getPitchLfoModulator (), next to the pitch envelope modulator, and its depth uses the same MAX_ENVELOPE_DEPTH scaling.

The depth defaults to zero (no modulation), so a format which does not fill it writes exactly what it wrote before. DefaultSampleZone is the only implementation of ISampleZone, so nothing else needed touching.

Formats

Only formats whose LFO units are documented were wired, so no value is guessed:

Format Direction Fields
SFZ read + write pitchlfo_freq (Hz), pitchlfo_depth (cent), pitchlfo_delay, pitchlfo_fade (s)
SoundFont 2 read + write vibrato generators vibLfoToPitch, freqVibLFO, delayVibLFO
DecentSampler read + write <lfo> bound to GROUP_TUNING (rate Hz, delay s, depth over the 120 semi-tone range)
DLS read vibrato connection LFO→pitch (frequency from absolute pitch cents)

Formats whose LFO rate is a device-internal value without a documented mapping to Hertz (Reason NN-XT, Logic EXS24, Yamaha YSFC, the Roland and Akai hardware samplers, Ableton, TX16Wx, ...) were left unwired rather than guessed, in the same spirit as the still-unwired IModulator.getCurve.

Verification

  • Round-trips preserve the vibrato: SFZ → SF2 → SFZ, SFZ → DecentSampler → SFZ, and the three-format chain SFZ → SF2 → DecentSampler → SFZ all return the original depth and delay exactly and the rate within the SoundFont's absolute-cent quantization (5.2 Hz → 5.201 Hz).
  • No regression, measured against this base by converting real material through both: SoundFont 2 read (30 SoundFonts → SFZ), DecentSampler write (100 SFZ → dspreset) and read, and SFZ write are all byte-identical. The DLS read differs only by the added pitchlfo lines and nothing else.
  • DLS was validated against real data: the Roland GS set that ships with macOS (gs_instruments.dls) has a vibrato on 126 of its 235 instruments; all read back at plausible rates of 5.2 to 6.0 Hz with depths of 1 to 9 cent, and carry through to SoundFont 2 and DecentSampler.

Documentation

Change log entry plus a note in the SFZ, SoundFont 2, DecentSampler and DLS sections of README-FORMATS.md. The feature-support spreadsheet is not updated yet — it would want a new "pitch LFO / vibrato" capability, and I would rather agree the column layout with you first.

Noticed while doing this (not fixed here)

While decoding the DLS scale values I noticed that DlsDetector.normalizeEG2ToPitch clamps the raw 32-bit connection value to ±1200 without first dividing by the 65536 fixed-point factor, so every non-zero DLS pitch envelope depth (and the tuning and EG2 sustain that share the helper) saturates. It is a pre-existing issue independent of this PR, the three call sites each expect a different unit, so I left it untouched here and can send a separate fix if you would like.

Each format is its own commit and can be dropped independently.

@git-moss

Copy link
Copy Markdown
Owner

Since LFOs with pitch are mostly used in combination with the modulation wheel ("via"-modulation), could this cause some unwanted "wobbly" sounds?

@douglas-carmichael

Copy link
Copy Markdown
Contributor Author

Good catch — it turned out to matter, for DLS.

DLS defines the connection from the low frequency oscillator to the pitch twice: once without a controller, which is the vibrato that always sounds, and once controlled by the modulation wheel, which is the amount the wheel can dial in. My lookup matched only the source and the destination, so it returned whichever of the two came first in the file — the wheel amount could therefore have been applied as a permanently sounding vibrato, exactly the wobble you describe.

Both are present for nearly every region of the Roland GS set which ships with macOS:

connection count depth
uncontrolled (always sounds) 762 1–10 cent
controlled by CC 1 (wheel) 761 45–49 cent

The wheel amount is roughly five to ten times the always sounding one, so converting the wrong one would have been plainly audible. Fixed in 34d4606: the vibrato is now read from an uncontrolled connection only. The converted output of that set does not change, because the uncontrolled connection happens to come first there — so this is a guarantee rather than a repair of something visible.

The other three formats do not have the ambiguity:

  • SFZ — only pitchlfo_depth is read and written, which is the always sounding opcode. The wheel routed variants (pitchlfo_depthcc1 and friends) are neither read nor written.
  • SoundFont 2 — only the vibLfoToPitch generator is read, which is the always sounding amount. The wheel vibrato of a SoundFont comes from the default modulator (CC 1 to vibLfoToPitch), which is left untouched. Worth noting: across the 30 SoundFonts I tested there is not a single non-zero vibLfoToPitch, so for those files the feature writes nothing at all — their vibrato is entirely the default wheel modulator and stays that way.
  • DecentSampler — the oscillator is written bound to GROUP_TUNING, and the reader matches only GROUP_TUNING, so the modulation wheel oscillator on GLOBAL_TUNING which the template contributes is never mistaken for a vibrato.

The restriction is now also stated in the DLS section of README-FORMATS.md.

The model had no low frequency oscillator at all, so a vibrato was
dropped on every conversion even though nearly every format stores one
as a dedicated, fixed-destination parameter.

Adds ILfo (waveform, rate in Hertz, delay, fade-in, start phase, key
sync) and ILfoModulator, mirroring the existing IEnvelope and
IEnvelopeModulator pair. The modulator hangs off the sample zone as
getPitchLfoModulator, next to the existing pitch envelope modulator, and
its depth uses the same scaling as the latter.

The depth defaults to zero, which means no modulation, so a format which
does not fill it writes exactly what it wrote before.

SFZ reads and writes the pitchlfo_depth, pitchlfo_freq, pitchlfo_delay
and pitchlfo_fade opcodes, whose units are already the ones of the model.
The vibrato low frequency oscillator (generators vibLfoToPitch,
freqVibLFO and delayVibLFO) maps directly to the pitch LFO modulator:
its depth is in cent like the pitch envelope, its frequency in absolute
cent like the filter cutoff, and its delay in time-cent like the
envelope times, so all three reuse the existing conversions.

The waveform (always a triangle in Sf2) and the fade-in have no
equivalent and are not carried. A preset without a vibrato has a depth
of zero and writes no generators, so existing output is unchanged.
The pitch LFO is written as an <lfo> bound to GROUP_TUNING, mirroring the
existing pitch envelope writer. The frequency is stored in Hertz, the
delay in seconds and the depth as the modulation amount over the same
120 semi-tone range as the pitch envelope, so all three round-trip.

The oscillator the template contributes is bound to the global tuning
via the mod wheel; the reader matches only GROUP_TUNING, so it is not
mistaken for a vibrato. The fade-in has no equivalent and is dropped,
and the waveform is mapped to the nearest of sine, square and saw.
Change log entry plus a note in the SFZ, SoundFont 2 and DecentSampler format sections.
The low frequency oscillator which modulates the pitch (connection
source LFO to destination pitch) is read as the pitch LFO. The depth is
a relative pitch in cent, the frequency an absolute pitch converted to
Hertz with the same reference as SoundFont, and the delay is the LFO
start delay in seconds.

Verified against the Roland GS set which ships with macOS
(gs_instruments.dls): 126 of 235 instruments carry a vibrato, all read
back at plausible rates of 5.2 to 6.0 Hertz with depths of 1 to 9 cent.

The two new conversions in DlsArticulation divide the raw scale by the
65536 fixed-point factor; the existing normalizeEG2ToPitch does not and
therefore saturates every non-zero pitch envelope depth, but that is a
separate issue and left untouched here.
New "LFO (Vibrato)" column in the Pitch section, filled green with their
field names for the four formats which carry it - DecentSampler, DLS,
SFZ and SoundFont 2 - and red for the rest.
The format defines the connection from the low frequency oscillator to
the pitch twice: once without a controller, which is the vibrato that
always sounds, and once controlled by the modulation wheel, which is the
amount the wheel can dial in. The lookup matched only the source and the
destination, so it returned whichever of the two came first in the file
and could have applied the wheel amount as a permanently sounding
vibrato.

The vibrato is now read from an uncontrolled connection only. In the
Roland GS set that ships with macOS both connections are present for
almost every region - 762 uncontrolled ones of 1 to 10 cent and 761
wheel controlled ones of 45 to 49 cent - and the converted output is
unchanged, since the uncontrolled connection happened to come first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants