Skip to content

fix(v2.3): apply unsynchronisation at tag level per §5 - #666

Merged
eidoriantan merged 2 commits into
eidoriantan:mainfrom
joust:fix/v23-tag-level-unsync
Jun 22, 2026
Merged

fix(v2.3): apply unsynchronisation at tag level per §5#666
eidoriantan merged 2 commits into
eidoriantan:mainfrom
joust:fix/v23-tag-level-unsync

Conversation

@joust

@joust joust commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Summary

ID3v2.2 §6.1 and ID3v2.3 §5 define unsynchronisation as a tag-level operation: when the flag is set, the entire concatenated frame stream is unsynchronised once and the reader reverses it before parsing. Frame sizes declared in headers refer to raw (pre-unsync) lengths.

The previous implementation unsynchronised each v2.3 frame body individually AND set the tag-level flag. Internally consistent (self-roundtrippable), but:

  • Frame size fields declared unsynched lengths
  • Frame headers were never unsynchronised despite sitting in a stream the tag-level flag claimed was unsynched
  • Any compliant v2.3 reader that un-unsyncs at tag level would see misaligned frames and corrupt data

Changes

  • encode(): for v2.3/v2.2, write frame bodies raw and apply unsynch() once to the concatenated frame stream before setting the tag-level flag. v2.4 keeps per-frame unsync.
  • decode(): when reading v2.3/v2.2 with tag-level flag set, un-unsynchronise the whole post-header region before frame parsing; track the post-synch length separately from the declared size.
  • decodeFrame(): drop the dead tag-level fallback; only the v2.4 per-frame flag governs per-frame un-unsync. This subsumes the one-line fix in PR fix(v2.4): honor per-frame unsynchronisation flag #665 (version === 4version[0] === 4).

Relationship to #665

This PR's decodeFrame refactor supersedes the one-line fix in #665. The regression test from #665 (per-frame unsync with tag-level bit cleared, v2.4) has been mirrored into this PR's test file as a companion assertion, so that merging this PR first does not lose that coverage.

If both PRs are merged, #666 should go first; #665's one-line change then becomes a no-op and the PR can be closed.

Backward compatibility note

Tags written by earlier mp3tag.js releases with the old per-frame-unsync-plus-tag-level-bit layout will NOT be correctly read by this version. That format was non-compliant and unreadable by every other v2.3 parser anyway; any tag that can be read by mutagen, TagLib, eyeD3, Kid3, or Picard will continue to work.

Test plan

  • npm test — 80 passing (78 → 80: two new tests)
  • v2.3 test: on-disk GEOB size field equals the raw body length (49), not the unsynched length (51). Fails on old code, passes on new.
  • v2.4 test (mirrored from fix(v2.4): honor per-frame unsynchronisation flag #665): per-frame unsync decodes correctly when tag-level bit is cleared
  • Pre-existing "Write data (unsynched)" roundtrip test still passes

ID3v2.2 §6.1 and ID3v2.3 §5 define unsynchronisation as a TAG-level
operation: when the flag is set, the entire concatenated frame
stream (headers plus bodies) is unsynchronised once and the reader
reverses it before parsing frames. Frame bodies must NOT carry the
unsync byte-stuffing individually — sizes declared in frame headers
refer to raw (pre-unsync) body lengths.

Previously the writer:
1. Unsynchronised each v2.3 frame body individually.
2. Set the tag-level unsync flag anyway.

This was internally consistent because the decoder also applied
unsync per-frame (due to a separate bug where `version === 4`
compared an array), but it violated the spec: the frame size fields
declared unsynched lengths, frame headers were never unsynchronised
even though they sat inside a stream the tag-level flag claimed was
unsynched, and any compliant v2.3 reader that un-unsyncs at tag
level would see misaligned frames and corrupt data.

Changes:
- encode(): for v2.3/v2.2, write frame bodies raw and apply
  `unsynch()` once to the concatenated frame stream before the
  tag-level flag is set. For v2.4, preserve per-frame unsync.
- decode(): when reading v2.3/v2.2 with the tag-level flag set,
  un-unsynchronise the whole post-header region before frame
  parsing; track the post-synch length separately from the
  unsynched size declared in the tag header.
- decodeFrame(): drop the now-dead tag-level fallback; only the
  v2.4 per-frame flag governs per-frame un-unsync.

The new test asserts the GEOB frame size declared on disk equals
the raw body length (49 bytes), not the unsynched length (51).
Fails on the old code, passes on the new.
@joust
joust force-pushed the fix/v23-tag-level-unsync branch from 64e05d8 to 6222ef5 Compare April 15, 2026 10:18
@joust
joust marked this pull request as ready for review June 22, 2026 13:57
@eidoriantan

Copy link
Copy Markdown
Owner

Hi @joust ,

There are some changes merged into main with the last PR. Could you please pull the latest changes and resolve the merge conflicts?

Thanks!

@eidoriantan

Copy link
Copy Markdown
Owner

Actually, seems like these are just simple conflicts. I'll handle this.

Thanks

@eidoriantan
eidoriantan merged commit 8c5f88a into eidoriantan:main Jun 22, 2026
4 checks passed
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