Skip to content

Numbered (Jianpu) notation: rendering aborts with TypeError on bars containing whole or longer rests #2803

Description

@SheperdHannigan

Summary

When a score is rendered with numbered (Jianpu) notation (staff.showNumbered = true), any bar whose only content is a whole, double-whole or quadruple-whole rest aborts rendering and the page stays blank:

TypeError: Cannot read properties of null (reading 'beat')
    at get beatOfHighestNote  (BeamingHelper)
    at LineBarRenderer.calculateBeamingOverflows
    at NumberedBarRenderer.calculateOverflows

The same score renders fine in standard notation. Since full-bar rests occur in essentially every multi-part arrangement, numbered notation is unusable for most ensemble scores.

Version

Reproduced on 1.8.4 (current stable). The relevant code paths are unchanged on main at the time of writing — beatOfLowestNote / beatOfHighestNote still use non-null assertions there.

Root cause

BeamingHelper.beatOfLowestNote / beatOfHighestNote dereference nullable fields through a non-null assertion:

public get beatOfLowestNote(): Beat {
    return this.lowestNoteInHelper!.beat;
}
public get beatOfHighestNote(): Beat {
    return this.highestNoteInHelper!.beat;
}

lowestNoteInHelper / highestNoteInHelper are only ever assigned by _checkNote(), and checkBeat() only calls it for non-rest beats. For a rest-only helper both remain null.

Normally those getters are unreachable for rest-only helpers, because LineBarRenderer.calculateBeamingOverflows only enters the branch using them when shouldPaintBeamingHelper(h) is true, and the base implementation excludes exactly that case:

protected shouldPaintBeamingHelper(h: BeamingHelper): boolean {
    return !h.isRestBeamHelper;
}

NumberedBarRenderer overrides it unconditionally:

protected override shouldPaintBeamingHelper(_h: BeamingHelper): boolean {
    return true;
}

so rest-only helpers now reach the branch chain in calculateBeamingOverflows:

} else if (h.beats.length === 1 && h.beats[0].duration >= Duration.Half) {
    // flag-based overflow: only touches h.beats[0] — safe
} else {
    // uses h.beatOfLowestNote / h.beatOfHighestNote — null deref for rest-only helpers
}

The duration >= Duration.Half test does shield short single-rest helpers, but Duration is numbered by denominator, so the long durations are negative or below Half:

QuadrupleWhole = -4
DoubleWhole    = -2
Whole          =  1
Half           =  2
Quarter        =  4

Whole, DoubleWhole and QuadrupleWhole therefore all fail >= Duration.Half and fall through into the unsafe else.

Reproduction

Render any score containing a bar whose only content is a whole rest with staff.showNumbered = true — for example a four-part brass arrangement in which individual parts rest for full bars.

Possible fixes

Whichever direction you prefer:

  1. Make the getters null-safe. A rest-only helper always has exactly one beat, so it can serve as the fallback:
    public get beatOfLowestNote(): Beat {
        return this.lowestNoteInHelper?.beat ?? this.beats[0];
    }
    VoiceContainerGlyph.getLowestNoteY / getHighestNoteY already return 0 when no container is found (i.e. contribute no overflow), and for a rest beat they yield the rest's own extent. Both outcomes seem reasonable, and no "helper has notes" case changes behaviour.
  2. Keep the base guard for rest-only helpers in NumberedBarRenderer.shouldPaintBeamingHelper.
  3. Widen the guard in calculateBeamingOverflows so it tests isRestBeamHelper (or ModelUtils.getIndex(duration)) instead of comparing the raw enum value.

I am currently working around this downstream with a build-time patch equivalent to option 1; happy to provide more detail if useful.

Metadata

Metadata

Assignees

Labels

state-template-not-usedIssue Template not used as required or wrong template used

Type

Fields

Priority

Low

Effort

Low

Area

Rendering

Platform

All / Multiple

Work State

Needs More Info

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions