Skip to content

Zero-length envelope segment divides by zero, poisoning a voice with NaN and silencing all playback #2794

Description

@Nefessom

Summary

A voice can be initialised with an envelope segment of zero samples, which makes
VoiceEnvelope.nextSegment divide by zero. The resulting Infinity turns the
envelope level into NaN, and from then on the voice writes NaN into every
audio block for the rest of the session. Because NaN + x === NaN, a single
affected voice silences the entire mix.

It also cannot recover: a NaN voice is never released, because every
comparison against NaN is false, so it survives note-off, stop() and
play(). Only recreating the synthesizer clears it.

Cause

src/synth/synthesis/VoiceEnvelope.ts, nextSegment, attack segment:

this.samplesUntilNextSegment = this.parameters.attack * outSampleRate | 0;
if (this.samplesUntilNextSegment > 0) {                     // guard
    if (!this.isAmpEnv)
        this.samplesUntilNextSegment =
            this.parameters.attack * ((145 - this.midiVelocity) / 144) * outSampleRate | 0;  // recomputed AFTER the guard
    ...
    this.slope = 1 / this.samplesUntilNextSegment;          // can divide by 0

The positive-length check runs before the value is recomputed with the
velocity factor (145 - velocity) / 144. For a modulation envelope with a short
attack and a high velocity, that factor can round the length down to 0, so the
following division yields Infinity.

The same pattern (a division by samplesUntilNextSegment) appears in the decay
and release branches.

Symptoms

  • Playback goes completely silent from one bar onward, while the cursor keeps
    moving and playerState stays Playing.
  • The output is not quiet but pinned: reading the destination with an
    AnalyserNode gives getByteTimeDomainData all zeros (peak reads full scale,
    mean 0, no movement between samples) and getFloatTimeDomainData is NaN for
    every sample.
  • Stopping and restarting playback does not help.
  • It needs several tracks sounding together — no single track and no pair
    reproduced it in our score.

Reproduction

alphaTab 1.8.1 and 1.8.4, Chromium and iOS Safari, useWorkers: true.
Reproduces reliably on a Guitar Pro import of The Kinks – Lola at bar 12: seek to
the start of bar 11, play, and the output turns to NaN within ~4 seconds.

Suggested fix

Clamp the segment length to at least one sample before dividing, in every branch
that divides by it:

this.slope = 1 / Math.max(1, this.samplesUntilNextSegment);

It would additionally be worth hardening the render loop so a voice whose state
becomes non-finite is killed rather than surviving forever and taking the whole
mix with it.

Workaround

We currently ship a patched build with those divisions clamped, plus a guard in
_fillWorkingBuffer that zeroes a block containing non-finite samples and kills
the voices responsible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Area

    None yet

    Platform

    None yet

    Work State

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions