Context
We use pt_api as a reference (the detailed specs and add_volume_node confirmed the 0x260a layout word-for-word — thank you for that work). While cross-checking session frame rates we found what looks like two related problems in TimecodeEngine. Happy to contribute the fix (a PR will follow referencing this issue), and we accept corrections if any layout differs in sessions you have.
Bug 1 — wrong field: family enum instead of exact enum
The loader reads the frame-rate enum from the first byte of the 0x204d payload (frame_block.items[0][0]), which is the family enum (data-relative +2). That enum is ambiguous: family 0x01 covers both 24 fps and 48 fps; family 0x09 covers both 23.976 fps and 47.952 fps. So a 48 fps session currently decodes as 24 fps, and a 47.952 fps session as 23.976 fps.
The exact enum lives at data-relative +42 (u32 LE, payload offset 40) with 19 unambiguous values: 0x01=24, 0x02=25, 0x03=29.97ND, 0x05=29.97DF, 0x07=30, 0x08=30DF, 0x09=23.976, 0x0a=50, 0x0b=59.94, 0x0c=59.94DF, 0x0d=60, 0x0e=60DF, 0x0f=47.952, 0x10=48, 0x11=100, 0x12=119.88, 0x13=119.88DF, 0x14=120, 0x15=120DF (0x04/0x06 reserved). There is no 23.976 DF; DF rates use ; as separator. Validated against multiple sessions authored in Pro Tools.
Bug 2 — incomplete table + hardcoded drop-frame constants
get_frame_rate() maps only three values and raises ValueError("Unsupported frame rate") for everything else (25, 30, 50, 59.94, 60, 100, 119.88, 120 and DF variants). Additionally, samples_to_timecode() hardcodes the nominal-30 drop-frame constants (17982/1798), so nominal-60/120 DF rates are broken. They generalise to decade = 600*N - 18, minute = 60*N - 2 for nominal N.
Suggested fix (PR to follow)
- Loader: read
frame_block.items[0][40] (exact enum at data +42), requiring payload length ≥ 44.
- Replace the 3-value lookup with the full 19-entry exact table.
_nominal_fps(): derive from the resolved rate (ceil(fps - eps)).
- Generalise the forward/reverse drop-frame paths with the
N-based constants above.
No public API signature change — TimecodeEngine(sample_rate, frame_rate_enum) stays as is; callers just pass exact enums.
How to verify
- All 19 enums resolve to the correct fps / drop-frame / nominal.
- Round-trip
timecode_to_samples(samples_to_timecode(x)) at 24, 25, 29.97ND/DF, 30, 30DF, 23.976, 50, 59.94ND/DF, 60, 60DF, 47.952, 48, 100, 119.88, 119.88DF, 120, 120DF.
- Drop-frame label checks at high nominals:
00:01:00:00/00:01:00:01 rejected, 00:10:00:00 accepted.
- Existing
tests/test_timecode.py keeps passing; tests/test_loading.py / tests/test_save_offsets.py fixtures need updating to 44-byte payloads (that update rides with the PR).
All code/layout notes we share are available under your project's license (MIT) — no strings attached.
Context
We use
pt_apias a reference (the detailed specs andadd_volume_nodeconfirmed the0x260alayout word-for-word — thank you for that work). While cross-checking session frame rates we found what looks like two related problems inTimecodeEngine. Happy to contribute the fix (a PR will follow referencing this issue), and we accept corrections if any layout differs in sessions you have.Bug 1 — wrong field: family enum instead of exact enum
The loader reads the frame-rate enum from the first byte of the
0x204dpayload (frame_block.items[0][0]), which is the family enum (data-relative+2). That enum is ambiguous: family0x01covers both 24 fps and 48 fps; family0x09covers both 23.976 fps and 47.952 fps. So a 48 fps session currently decodes as 24 fps, and a 47.952 fps session as 23.976 fps.The exact enum lives at data-relative
+42(u32 LE, payload offset 40) with 19 unambiguous values:0x01=24,0x02=25,0x03=29.97ND,0x05=29.97DF,0x07=30,0x08=30DF,0x09=23.976,0x0a=50,0x0b=59.94,0x0c=59.94DF,0x0d=60,0x0e=60DF,0x0f=47.952,0x10=48,0x11=100,0x12=119.88,0x13=119.88DF,0x14=120,0x15=120DF (0x04/0x06reserved). There is no 23.976 DF; DF rates use;as separator. Validated against multiple sessions authored in Pro Tools.Bug 2 — incomplete table + hardcoded drop-frame constants
get_frame_rate()maps only three values and raisesValueError("Unsupported frame rate")for everything else (25, 30, 50, 59.94, 60, 100, 119.88, 120 and DF variants). Additionally,samples_to_timecode()hardcodes the nominal-30 drop-frame constants (17982/1798), so nominal-60/120 DF rates are broken. They generalise todecade = 600*N - 18,minute = 60*N - 2for nominalN.Suggested fix (PR to follow)
frame_block.items[0][40](exact enum at data+42), requiring payload length ≥ 44._nominal_fps(): derive from the resolved rate (ceil(fps - eps)).N-based constants above.No public API signature change —
TimecodeEngine(sample_rate, frame_rate_enum)stays as is; callers just pass exact enums.How to verify
timecode_to_samples(samples_to_timecode(x))at 24, 25, 29.97ND/DF, 30, 30DF, 23.976, 50, 59.94ND/DF, 60, 60DF, 47.952, 48, 100, 119.88, 119.88DF, 120, 120DF.00:01:00:00/00:01:00:01rejected,00:10:00:00accepted.tests/test_timecode.pykeeps passing;tests/test_loading.py/tests/test_save_offsets.pyfixtures need updating to 44-byte payloads (that update rides with the PR).All code/layout notes we share are available under your project's license (MIT) — no strings attached.