Phase Modulation Matrix — User Guide

Layered sinusoidal time displacement: each processing layer adds a time-shifted copy of the current signal, using a different modulation rate and depth, then passes the accumulated result into the next layer.

Author: Shai Cohen Affiliation: Department of Music, Bar-Ilan University, Israel Version: 0.5 (2026) License: MIT License Repo: GitHub
Contents:

What this does

Phase Modulation Matrix creates chorus-, vibrato-, and phaser-like motion by repeatedly adding sinusoidally displaced reads of the signal to itself. The selected Sound is copied, then processed through a cascade of modulation layers. Each layer uses its own modulation frequency, depth, displaced-tap gain, and a frozen snapshot of the signal as it exists at the start of that layer.

selected Sound → layer 1: dry + displaced tap → layer 2: accumulated result + new displaced tap → ... → layer N → target peak scaling
Channel behavior: the original channel count is preserved. Every channel is processed independently with the same displacement trajectory and layer coefficients; there is no mono fold in the DSP path. Mono conversion is used only for the spectrogram displays.

What “phase modulation” means here

The title does not describe canonical oscillator phase-modulation synthesis. The script does not generate a sinusoidal carrier and alter its oscillator phase. Instead, it performs time-domain variable-delay / sample-displacement processing:

readTime(t) = t + depth × sin(2π × modulatorFrequency × (t - sourceStart))

The displaced sample is read by continuous Sound-time interpolation and added to the current signal. Because the sinusoid swings positive and negative, the read path moves both later and earlier than the unshifted time position. This is an offline bidirectional time displacement, not a causal delay line.

Important distinction: there is no FFT phase manipulation, phase vocoder, all-pass phaser network, or oscillator-carrier PM equation in this implementation. The audible motion comes from layered, sinusoidally moving read positions and their interference with the dry signal.

Quick start

  1. Select exactly one Sound object.
  2. Run Phase_Modulation_Matrix.praat.
  3. Choose Default, Subtle Chorus, Deep Phase Sweep, Vibrato / Whirl, or Custom.
  4. Choose whether the base carrier value is fixed or randomly drawn from a range.
  5. Choose duration-relative depth or a fixed depth in milliseconds.
  6. Set output peak, visualization, and playback options.
  7. Run the script. The result is named <source>_phasemod.
Named presets overwrite the layer count, carrier range, fixed-carrier value, duration-relative depth parameters, feedback base, and layer-gain parameters. They do not change Use_fixed_carrier, Use_fixed_ms_depth, Fixed_depth_ms, Scale_peak, visualization, or playback.

Layer engine

At the beginning of every layer, the current accumulated Sound is copied to a temporary snapshot. All displaced reads for that layer come from this frozen snapshot rather than from samples already modified earlier in the same Formula pass.

For layer L: snapshot = current accumulated result if displaced read is inside source domain: result(t,ch) = result(t,ch) + snapshot(readTime(t),ch) × wetGain[L] else: result(t,ch) = result(t,ch)

This makes each individual layer feed-forward. However, the layers form a cascade: layer 2 receives the completed output of layer 1, layer 3 receives the completed output of layer 2, and so on.

Boundary behavior

If the sinusoidal read position falls before the Sound start or after its end, that layer contributes no displaced tap at that time; only the current dry/accumulated sample remains. No wrapping, reflection, or zero-valued wet sample is mixed in.

Carrier value & per-layer modulation rates

The form retains the historical name Carrier_freq, but operationally this value is the base modulation rate from which each layer's sinusoidal displacement rate is derived.

Base value

If Use_fixed_carrier is enabled:

carrierFreq = Fixed_carrier_freq

Otherwise, one value is drawn uniformly in Hz at the start of the run:

carrierFreq = uniform(Carrier_freq_min, Carrier_freq_max)

The same selected carrier value is then used for all layers in that run.

Layer rates

modulatorFrequency[L] = carrierFreq × (L + 1) Layer 1 = 2 × carrier Layer 2 = 3 × carrier ... Layer N = (N + 1) × carrier

These values are true cycles per second. For example, a 0.3 Hz carrier produces layer rates of 0.6, 0.9, 1.2, 1.5 Hz, and so on.

When the carrier is drawn from a range, the script exposes no random-seed control. Otherwise identical runs can therefore use a different base rate.

Modulation depth

Depth is always converted to seconds before the displaced read is calculated.

Fixed-ms depth

When Use_fixed_ms_depth is enabled, every layer receives the same time excursion:

depth[L] = Fixed_depth_ms / 1000

Duration-relative depth — default

When fixed-ms depth is off, depth is a fraction of the entire source duration:

depth[L] = sourceDuration / (Mod_depth_base + L × Mod_depth_increment)

Because the denominator increases with layer number, later layers normally use shallower excursions. With the Default preset, for example:

Layer 1 depth = duration / 10 Layer 2 depth = duration / 12 Layer 3 depth = duration / 14 Layer 4 depth = duration / 16 Layer 5 depth = duration / 18
Duration-relative depth can be very large on long files. A 10-second source with Default settings gives a 1-second displacement depth in layer 1. This is the intended historical behavior of the current mode, not a millisecond-scaled delay.

“Feedback” & layer gain

The public parameter Feedback_base retains its historical name, but there is no within-layer feedback loop in v0.5. It controls the gain of the feed-forward displaced tap:

layerFeedback[L] = Feedback_base / L

The second gain term falls linearly with layer number and is clamped at zero:

layerGain[L] = max(0, Layer_gain_base - Layer_gain_rate × L)

The actual displaced contribution is:

wetGain[L] = layerFeedback[L] × layerGain[L]

So early layers generally contribute more strongly; sufficiently high layer numbers can become dry-only when layerGain reaches zero.

Layer gain is broadband. It is a scalar on the displaced copy, not spectral tilt, EQ, or frequency-dependent attenuation.

Presets

PresetLayersCarrier rangeFixed carrierDepth base / incrementFeedbackLayer gain base / rate
Default (balanced)50.10–0.50 Hz0.30 Hz8 / 20.701.10 / 0.10
Subtle Chorus30.05–0.20 Hz0.15 Hz10 / 10.401.05 / 0.05
Deep Phase Sweep60.10–0.40 Hz0.28 Hz6 / 20.801.15 / 0.12
Vibrato / Whirl70.20–0.80 Hz0.45 Hz5 / 30.901.20 / 0.15
Custom form defaults50.10–0.50 Hz0.30 Hz8 / 20.701.10 / 0.10
Preset + fixed-depth interaction: named presets do not overwrite Fixed_depth_ms. If fixed-ms depth is enabled, the selected preset still changes its other layer/frequency/gain values, but the user's current fixed depth remains in force.

Parameters & limits

ParameterDefaultExact role / validation
PresetDefaultFour named presets plus Custom.
Modulation_layers5Number of cascaded layers; 1–128.
Carrier_freq_min0.1 HzLower bound of random base-rate draw; must be >0 and ≤ maximum.
Carrier_freq_max0.5 HzUpper bound of random base-rate draw; must be >0.
Use_fixed_carrierOffOff = one uniform random draw per run; On = use Fixed_carrier_freq.
Fixed_carrier_freq0.3 HzPositive fixed base rate.
Mod_depth_base8Positive denominator term for duration-relative depth.
Mod_depth_increment2Positive layer-dependent denominator increment.
Use_fixed_ms_depthOffSwitch from duration-relative depth to one fixed depth for every layer.
Fixed_depth_ms20 msPositive displacement depth when fixed-ms mode is active.
Feedback_base0.7Positive base coefficient; actual feed-forward tap term is divided by layer number.
Layer_gain_base1.1Positive starting value for layer gain.
Layer_gain_rate0.1Positive amount subtracted per layer; effective layer gain is clamped at zero.
Scale_peak0.93Target final peak, >0 and ≤1.
Draw_visualizationOnDraw the v0.5 process view.
Play_resultOnPlay the completed Sound.

Level handling

Every successful layer adds a displaced copy to the already accumulated signal, so the unscaled result can exceed the source level substantially.

After all layers, the script measures the Sinc70 absolute extremum. If the result is non-silent, it calls:

Scale peak: Scale_peak
This is target peak normalization, not an attenuate-only ceiling. A non-silent result below the target is boosted; a result above the target is attenuated. The default target is 0.93.

Visualization

The v0.5 Picture view is organized around the actual time-displacement process:

  1. Input waveform — original source copy.
  2. Output waveform — processed result.
  3. Shared amplitude scale — Input and Output use the same vertical range for direct level comparison.
  4. Phase-displacement field — one lane per displayed modulation layer. The gray line is the unshifted read position; the amber sinusoid is the actual displaced read trajectory.
  5. Curve rate — follows the real layer modulation frequency carrier × (layer+1).
  6. Curve excursion — preserves each displayed layer's depth relative to the deepest displayed layer.
  7. Input / Output spectrograms — mono display folds, up to min(5 kHz, Nyquist).
  8. Summary strip — preset, layer count, carrier, depth mode, feedback, layer-gain law, and target peak.

More than eight layers

The displacement field shows at most eight lanes. If the processor uses more than eight layers, the visualization samples layer numbers across the complete 1…N range rather than drawing all 128 possible lanes.

The phase-displacement field visualizes read-position geometry. It does not encode the wet-gain magnitude of each layer in the curve thickness or height beyond the relative depth mapping.

Output behavior

The original Sound remains unchanged. Temporary layer snapshots and visualization copies are removed.