Skip to content

Require a vocal note for hasVocals - #126

Open
elicwhite wants to merge 1 commit into
Geomitron:masterfrom
elicwhite:fix/has-vocals-requires-vocal-notes
Open

Require a vocal note for hasVocals#126
elicwhite wants to merge 1 commit into
Geomitron:masterfrom
elicwhite:fix/has-vocals-requires-vocal-notes

Conversation

@elicwhite

@elicwhite elicwhite commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The bug

notesData.hasVocals is derived from notePhrases.length > 0 in scan-parsed-chart.ts:

for (const part of Object.values(result.vocalTracks.parts)) {
    if (part.notePhrases.length > 0) hasVocals = true

A notePhrase is only a range marker (MIDI note 105/106). Lyrics live inside it as phrase.lyrics, and a phrase need not contain any vocal note. So any chart that ships lyrics reports hasVocals: true, which trips the check in scan-chart.ts:

Metadata is missing a "diff_vocals" value.

on a chart that has no vocals and no vocals difficulty to declare.

For the .chart format this is unconditional. chart-file-parser.ts hardcodes notes: [] for the vocals part, so a .chart can never express a vocal note — yet every lyric-bearing .chart claims to have vocals.

Why it regressed

7be766d moved the flag from ParsedChart to ScannedChart so it would stay accurate when consumers mutate chart data post-parse. That goal is sound and is preserved here — but the replacement predicate counts phrase markers as vocals.

For reference, the two earlier implementations both keyed on notes rather than phrases: bef6a54 tested for a PART VOCALS event that was not a 105/106 phrase marker, and the version immediately before 7be766d tested for a pitched note in 36–84. This PR is not a restoration of either — it counts vocal percussion, and it considers harmony parts — but it returns to the principle that a phrase marker alone is not vocals.

The fix

Derive hasVocals from the presence of a vocal note inside a phrase, keeping the flag state-derived.

hasLyrics is unchanged.

The noNotes check has its own, deliberately broader test: any vocal phrase — lyrics-only included — is still enough content to keep a chart out of noNotes. Behavior there is unchanged; the local is renamed hasVocalContent so the difference from notesData.hasVocals reads as intentional rather than an oversight.

The trade this makes

Worth being explicit, since it is user-visible in both directions:

chart diff_vocals in ini before after
lyrics-only unset "missing a diff_vocals value" (no issue)
lyrics-only set (no issue) "contains diff_vocals, but vocals are not charted"

The second row is a new issue on charts that previously reported nothing. I believe it is the correct signal — there are no vocals for that value to describe — but since the old behavior effectively encouraged setting diff_vocals on lyrics-only charts, some existing charts will move from one issue to the other rather than to clean. Both rows are pinned by tests. Happy to suppress the extraValue side too if you would rather this be a pure reduction in noise.

Known limitation

hasVocals reads phrase.notes, which is the playable projection rather than every note in the source: groupIntoPhrases drops hidden percussion (MIDI 97), drops a percussion note sitting at its phrase's own start tick when it is the first note, and requires tick >= phrase.tick so notes ahead of their phrase marker are excluded. A vocals chart built only from such notes will report hasVocals: false. These are pre-existing parser behaviors, not introduced here, and I left them alone to keep this PR narrow — but they are the reason the code comment says "a vocal note inside a phrase" rather than "a vocal note". Say the word if you would prefer the flag read from an unfiltered source instead.

Tests

test/unit/derived-flags.test.ts contained an assertion that locked in the buggy behavior (hasVocals = true for a lyrics-only .chart); it is flipped to false with a comment explaining why. Added:

  • a lyrics-only .chart raises no diff_vocals issue
  • a lyrics-only .chart that sets diff_vocals reports it as an extra value
  • a PART VOCALS MIDI with a pitched note inside a phrase still reports hasVocals = true
  • the same MIDI, with a song.ini lacking diff_vocals, still raises the missing-value issue — the positive direction, which needs an ini present or the whole metadata block is skipped

Full suite passes: 319 tests across 11 files.

Note: npm run lint reports pre-existing BigInt / setBigInt64 errors in src/chart/track-hasher.ts. I confirmed these are identical on a pristine checkout of master and unrelated to this change.

`notesData.hasVocals` was derived from `notePhrases.length > 0`. A phrase is
only a range marker, though: lyrics live inside phrases, and a phrase need not
contain any vocal note. Every chart that ships lyrics therefore reported
`hasVocals: true`, which raised a spurious

    Metadata is missing a "diff_vocals" value.

on charts that have no vocals to declare a difficulty for. The `.chart` format
makes this unconditional — its parser hardcodes an empty vocal note list, so
`.chart` can never express a vocal note, yet every lyric-bearing `.chart`
claimed to have vocals.

Derive `hasVocals` from the presence of a vocal note inside a phrase instead,
keeping the flag state-derived.

This cuts both ways, and the tests pin both directions. A lyrics-only chart no
longer asks for a `diff_vocals` value; a lyrics-only chart that *sets*
`diff_vocals` now reports it as an extra value, where previously it reported
nothing.

`hasLyrics` is unchanged. The `noNotes` check keeps its own broader test — any
vocal phrase, lyrics-only included, is still enough content to keep a chart out
of `noNotes` — so it is renamed `hasVocalContent` to mark the difference as
deliberate.
@elicwhite
elicwhite force-pushed the fix/has-vocals-requires-vocal-notes branch from f57f184 to 072fce3 Compare August 21, 2026 14:50
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.

1 participant