trackmod 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trackmod/__init__.py +79 -0
- trackmod/binary/__init__.py +0 -0
- trackmod/binary/bits.py +35 -0
- trackmod/binary/cursor.py +101 -0
- trackmod/binary/layout.py +12 -0
- trackmod/binary/nibble.py +46 -0
- trackmod/binary/pcm/__init__.py +0 -0
- trackmod/binary/pcm/blocks.py +20 -0
- trackmod/binary/pcm/codec.py +53 -0
- trackmod/binary/pcm/encoding.py +13 -0
- trackmod/binary/pcm/quantize.py +19 -0
- trackmod/binary/pcm/sign.py +39 -0
- trackmod/binary/records/__init__.py +0 -0
- trackmod/binary/records/field.py +50 -0
- trackmod/binary/records/record.py +99 -0
- trackmod/binary/records/values.py +44 -0
- trackmod/binary/text.py +31 -0
- trackmod/binary/volume.py +114 -0
- trackmod/binary/warnings.py +40 -0
- trackmod/core/__init__.py +0 -0
- trackmod/core/effects/__init__.py +0 -0
- trackmod/core/effects/catalog.py +35 -0
- trackmod/core/effects/effect.py +17 -0
- trackmod/core/envelopes/__init__.py +0 -0
- trackmod/core/envelopes/curve.py +85 -0
- trackmod/core/envelopes/envelope.py +43 -0
- trackmod/core/envelopes/kind.py +10 -0
- trackmod/core/envelopes/point.py +16 -0
- trackmod/core/envelopes/repair.py +85 -0
- trackmod/core/envelopes/span.py +22 -0
- trackmod/core/instruments/__init__.py +0 -0
- trackmod/core/instruments/behavior.py +30 -0
- trackmod/core/instruments/fade.py +61 -0
- trackmod/core/instruments/instrument.py +122 -0
- trackmod/core/instruments/keymap.py +38 -0
- trackmod/core/instruments/repair.py +49 -0
- trackmod/core/instruments/transfer.py +95 -0
- trackmod/core/instruments/unit.py +42 -0
- trackmod/core/notes/__init__.py +0 -0
- trackmod/core/notes/checks.py +33 -0
- trackmod/core/notes/codec.py +24 -0
- trackmod/core/notes/command.py +20 -0
- trackmod/core/notes/pitch.py +93 -0
- trackmod/core/patterns/__init__.py +0 -0
- trackmod/core/patterns/builder.py +59 -0
- trackmod/core/patterns/cell.py +22 -0
- trackmod/core/patterns/codec.py +38 -0
- trackmod/core/patterns/column.py +19 -0
- trackmod/core/patterns/grid.py +148 -0
- trackmod/core/patterns/repair.py +36 -0
- trackmod/core/repairs/__init__.py +0 -0
- trackmod/core/repairs/report.py +47 -0
- trackmod/core/samples/__init__.py +0 -0
- trackmod/core/samples/depth.py +36 -0
- trackmod/core/samples/loop.py +58 -0
- trackmod/core/samples/repair.py +86 -0
- trackmod/core/samples/sample.py +140 -0
- trackmod/core/samples/vibrato.py +22 -0
- trackmod/core/songs/__init__.py +0 -0
- trackmod/core/songs/order.py +37 -0
- trackmod/core/songs/playback.py +17 -0
- trackmod/core/songs/repair.py +20 -0
- trackmod/core/songs/song.py +67 -0
- trackmod/core/timing/__init__.py +0 -0
- trackmod/core/timing/clock.py +50 -0
- trackmod/core/timing/lattice.py +122 -0
- trackmod/core/timing/timing.py +14 -0
- trackmod/core/timing/timings.py +54 -0
- trackmod/core/voices/__init__.py +0 -0
- trackmod/core/voices/convert.py +110 -0
- trackmod/core/voices/voices.py +68 -0
- trackmod/core/volumes/__init__.py +0 -0
- trackmod/core/volumes/checks.py +41 -0
- trackmod/core/volumes/codec.py +37 -0
- trackmod/core/volumes/command.py +60 -0
- trackmod/limits/__init__.py +0 -0
- trackmod/limits/bound.py +42 -0
- trackmod/limits/capability.py +45 -0
- trackmod/limits/capacity.py +41 -0
- trackmod/limits/checklist.py +30 -0
- trackmod/limits/compliance.py +22 -0
- trackmod/limits/error.py +31 -0
- trackmod/limits/guard.py +13 -0
- trackmod/limits/reach.py +35 -0
- trackmod/limits/table.py +99 -0
- trackmod/limits/tier.py +14 -0
- trackmod/limits/violation.py +40 -0
- trackmod/module/__init__.py +0 -0
- trackmod/module/instrument.py +96 -0
- trackmod/module/protocol.py +112 -0
- trackmod/module/provenance.py +39 -0
- trackmod/module/reaching.py +44 -0
- trackmod/module/size.py +33 -0
- trackmod/module/storage.py +106 -0
- trackmod/py.typed +0 -0
- trackmod/schema/__init__.py +0 -0
- trackmod/schema/array.py +52 -0
- trackmod/schema/config.py +6 -0
- trackmod/schema/scalars.py +39 -0
- trackmod/spec/__init__.py +0 -0
- trackmod/spec/application.py +13 -0
- trackmod/spec/clock.py +7 -0
- trackmod/spec/grid.py +14 -0
- trackmod/spec/levels.py +17 -0
- trackmod/spec/pitch.py +24 -0
- trackmod/spec/text.py +5 -0
- trackmod/spec/volumes.py +8 -0
- trackmod/spec/width.py +15 -0
- trackmod/trackers/amiga/__init__.py +0 -0
- trackmod/trackers/amiga/checks.py +65 -0
- trackmod/trackers/amiga/layout/__init__.py +0 -0
- trackmod/trackers/amiga/layout/file.py +10 -0
- trackmod/trackers/amiga/layout/sample.py +17 -0
- trackmod/trackers/amiga/note.py +122 -0
- trackmod/trackers/amiga/patterns/__init__.py +0 -0
- trackmod/trackers/amiga/patterns/packer.py +94 -0
- trackmod/trackers/amiga/patterns/parser.py +109 -0
- trackmod/trackers/amiga/patterns/sizing.py +11 -0
- trackmod/trackers/amiga/reading.py +140 -0
- trackmod/trackers/amiga/samples/__init__.py +0 -0
- trackmod/trackers/amiga/samples/parser.py +87 -0
- trackmod/trackers/amiga/samples/writer.py +122 -0
- trackmod/trackers/amiga/sizing.py +32 -0
- trackmod/trackers/amiga/spec/__init__.py +0 -0
- trackmod/trackers/amiga/spec/cells.py +19 -0
- trackmod/trackers/amiga/spec/defaults.py +7 -0
- trackmod/trackers/amiga/spec/periods.py +34 -0
- trackmod/trackers/amiga/spec/ranges.py +21 -0
- trackmod/trackers/amiga/spec/sizes.py +11 -0
- trackmod/trackers/amiga/spec/storage.py +9 -0
- trackmod/trackers/amiga/tuning.py +31 -0
- trackmod/trackers/amiga/writing.py +50 -0
- trackmod/trackers/it/__init__.py +0 -0
- trackmod/trackers/it/addressing.py +29 -0
- trackmod/trackers/it/checks.py +120 -0
- trackmod/trackers/it/detection.py +15 -0
- trackmod/trackers/it/effects/__init__.py +0 -0
- trackmod/trackers/it/effects/catalog.py +115 -0
- trackmod/trackers/it/effects/command.py +59 -0
- trackmod/trackers/it/extensions.py +118 -0
- trackmod/trackers/it/fade.py +19 -0
- trackmod/trackers/it/instrument_file.py +182 -0
- trackmod/trackers/it/instruments/__init__.py +0 -0
- trackmod/trackers/it/instruments/envelope.py +109 -0
- trackmod/trackers/it/instruments/keymap.py +53 -0
- trackmod/trackers/it/instruments/parser.py +100 -0
- trackmod/trackers/it/instruments/writer.py +82 -0
- trackmod/trackers/it/layout/__init__.py +0 -0
- trackmod/trackers/it/layout/envelope.py +51 -0
- trackmod/trackers/it/layout/file.py +38 -0
- trackmod/trackers/it/layout/instrument.py +54 -0
- trackmod/trackers/it/layout/pattern.py +14 -0
- trackmod/trackers/it/layout/sample.py +34 -0
- trackmod/trackers/it/limits.py +8 -0
- trackmod/trackers/it/message.py +50 -0
- trackmod/trackers/it/module.py +229 -0
- trackmod/trackers/it/note.py +43 -0
- trackmod/trackers/it/panning.py +12 -0
- trackmod/trackers/it/parser.py +309 -0
- trackmod/trackers/it/patterns/__init__.py +0 -0
- trackmod/trackers/it/patterns/cost.py +16 -0
- trackmod/trackers/it/patterns/encoded.py +9 -0
- trackmod/trackers/it/patterns/memory.py +19 -0
- trackmod/trackers/it/patterns/packer.py +178 -0
- trackmod/trackers/it/patterns/parser.py +211 -0
- trackmod/trackers/it/patterns/sizing.py +117 -0
- trackmod/trackers/it/patterns/width.py +36 -0
- trackmod/trackers/it/samples/__init__.py +0 -0
- trackmod/trackers/it/samples/compression.py +159 -0
- trackmod/trackers/it/samples/parser.py +288 -0
- trackmod/trackers/it/samples/writer.py +97 -0
- trackmod/trackers/it/settings.py +68 -0
- trackmod/trackers/it/sizing.py +55 -0
- trackmod/trackers/it/spec/__init__.py +0 -0
- trackmod/trackers/it/spec/capacities.py +122 -0
- trackmod/trackers/it/spec/cells.py +52 -0
- trackmod/trackers/it/spec/defaults.py +24 -0
- trackmod/trackers/it/spec/effects.py +19 -0
- trackmod/trackers/it/spec/extensions.py +13 -0
- trackmod/trackers/it/spec/flags.py +62 -0
- trackmod/trackers/it/spec/identity.py +14 -0
- trackmod/trackers/it/spec/orders.py +5 -0
- trackmod/trackers/it/spec/ranges.py +56 -0
- trackmod/trackers/it/spec/sizes.py +20 -0
- trackmod/trackers/it/spec/storage.py +27 -0
- trackmod/trackers/it/spec/volume.py +51 -0
- trackmod/trackers/it/timing.py +10 -0
- trackmod/trackers/it/version.py +100 -0
- trackmod/trackers/it/writer.py +133 -0
- trackmod/trackers/mod/__init__.py +0 -0
- trackmod/trackers/mod/detection.py +11 -0
- trackmod/trackers/mod/dialect.py +60 -0
- trackmod/trackers/mod/effects/__init__.py +0 -0
- trackmod/trackers/mod/effects/catalog.py +106 -0
- trackmod/trackers/mod/effects/command.py +63 -0
- trackmod/trackers/mod/layout/__init__.py +0 -0
- trackmod/trackers/mod/layout/file.py +17 -0
- trackmod/trackers/mod/limits.py +8 -0
- trackmod/trackers/mod/module.py +261 -0
- trackmod/trackers/mod/parser.py +102 -0
- trackmod/trackers/mod/settings.py +27 -0
- trackmod/trackers/mod/sizing.py +9 -0
- trackmod/trackers/mod/spec/__init__.py +0 -0
- trackmod/trackers/mod/spec/capacities.py +58 -0
- trackmod/trackers/mod/spec/dialects.py +34 -0
- trackmod/trackers/mod/spec/effects.py +19 -0
- trackmod/trackers/mod/spec/identity.py +12 -0
- trackmod/trackers/mod/spec/ranges.py +21 -0
- trackmod/trackers/mod/spec/sizes.py +13 -0
- trackmod/trackers/mod/spec/storage.py +17 -0
- trackmod/trackers/mod/tag.py +79 -0
- trackmod/trackers/mod/timing.py +6 -0
- trackmod/trackers/mod/writer.py +58 -0
- trackmod/trackers/registry.py +277 -0
- trackmod/trackers/s3m/__init__.py +0 -0
- trackmod/trackers/s3m/channels.py +38 -0
- trackmod/trackers/s3m/checks.py +91 -0
- trackmod/trackers/s3m/detection.py +10 -0
- trackmod/trackers/s3m/effects/__init__.py +0 -0
- trackmod/trackers/s3m/effects/catalog.py +105 -0
- trackmod/trackers/s3m/effects/command.py +54 -0
- trackmod/trackers/s3m/layout/__init__.py +0 -0
- trackmod/trackers/s3m/layout/file.py +36 -0
- trackmod/trackers/s3m/layout/instrument.py +28 -0
- trackmod/trackers/s3m/layout/pattern.py +10 -0
- trackmod/trackers/s3m/limits.py +8 -0
- trackmod/trackers/s3m/module.py +252 -0
- trackmod/trackers/s3m/note.py +101 -0
- trackmod/trackers/s3m/panning.py +22 -0
- trackmod/trackers/s3m/parapointers.py +41 -0
- trackmod/trackers/s3m/parser.py +233 -0
- trackmod/trackers/s3m/patterns/__init__.py +0 -0
- trackmod/trackers/s3m/patterns/packer.py +103 -0
- trackmod/trackers/s3m/patterns/parser.py +144 -0
- trackmod/trackers/s3m/patterns/sizing.py +37 -0
- trackmod/trackers/s3m/placement.py +89 -0
- trackmod/trackers/s3m/samples/__init__.py +0 -0
- trackmod/trackers/s3m/samples/parser.py +211 -0
- trackmod/trackers/s3m/samples/writer.py +102 -0
- trackmod/trackers/s3m/settings.py +67 -0
- trackmod/trackers/s3m/sizing.py +44 -0
- trackmod/trackers/s3m/spec/__init__.py +0 -0
- trackmod/trackers/s3m/spec/capacities.py +100 -0
- trackmod/trackers/s3m/spec/cells.py +45 -0
- trackmod/trackers/s3m/spec/defaults.py +17 -0
- trackmod/trackers/s3m/spec/effects.py +19 -0
- trackmod/trackers/s3m/spec/flags.py +57 -0
- trackmod/trackers/s3m/spec/identity.py +18 -0
- trackmod/trackers/s3m/spec/keys.py +16 -0
- trackmod/trackers/s3m/spec/orders.py +4 -0
- trackmod/trackers/s3m/spec/ranges.py +58 -0
- trackmod/trackers/s3m/spec/sizes.py +20 -0
- trackmod/trackers/s3m/spec/storage.py +35 -0
- trackmod/trackers/s3m/spec/volume.py +24 -0
- trackmod/trackers/s3m/timing.py +10 -0
- trackmod/trackers/s3m/version.py +98 -0
- trackmod/trackers/s3m/writer.py +176 -0
- trackmod/trackers/st/__init__.py +0 -0
- trackmod/trackers/st/detection.py +36 -0
- trackmod/trackers/st/effects/__init__.py +0 -0
- trackmod/trackers/st/effects/catalog.py +110 -0
- trackmod/trackers/st/effects/command.py +25 -0
- trackmod/trackers/st/layout/__init__.py +0 -0
- trackmod/trackers/st/layout/file.py +15 -0
- trackmod/trackers/st/limits.py +8 -0
- trackmod/trackers/st/module.py +244 -0
- trackmod/trackers/st/parser.py +95 -0
- trackmod/trackers/st/settings.py +20 -0
- trackmod/trackers/st/sizing.py +9 -0
- trackmod/trackers/st/spec/__init__.py +0 -0
- trackmod/trackers/st/spec/capacities.py +42 -0
- trackmod/trackers/st/spec/defaults.py +3 -0
- trackmod/trackers/st/spec/effects.py +10 -0
- trackmod/trackers/st/spec/identity.py +3 -0
- trackmod/trackers/st/spec/ranges.py +21 -0
- trackmod/trackers/st/spec/sizes.py +14 -0
- trackmod/trackers/st/spec/storage.py +17 -0
- trackmod/trackers/st/timing.py +10 -0
- trackmod/trackers/st/writer.py +51 -0
- trackmod/trackers/xm/__init__.py +0 -0
- trackmod/trackers/xm/addressing.py +22 -0
- trackmod/trackers/xm/checks.py +122 -0
- trackmod/trackers/xm/detection.py +20 -0
- trackmod/trackers/xm/effects/__init__.py +0 -0
- trackmod/trackers/xm/effects/catalog.py +113 -0
- trackmod/trackers/xm/effects/command.py +62 -0
- trackmod/trackers/xm/fade.py +19 -0
- trackmod/trackers/xm/instrument_file.py +188 -0
- trackmod/trackers/xm/instruments/__init__.py +0 -0
- trackmod/trackers/xm/instruments/envelope.py +118 -0
- trackmod/trackers/xm/instruments/group.py +45 -0
- trackmod/trackers/xm/instruments/grouping.py +100 -0
- trackmod/trackers/xm/instruments/keymap.py +26 -0
- trackmod/trackers/xm/instruments/parser.py +83 -0
- trackmod/trackers/xm/instruments/writer.py +144 -0
- trackmod/trackers/xm/layout/__init__.py +0 -0
- trackmod/trackers/xm/layout/envelope.py +75 -0
- trackmod/trackers/xm/layout/file.py +30 -0
- trackmod/trackers/xm/layout/instrument.py +79 -0
- trackmod/trackers/xm/layout/pattern.py +15 -0
- trackmod/trackers/xm/layout/sample.py +21 -0
- trackmod/trackers/xm/limits.py +8 -0
- trackmod/trackers/xm/module.py +246 -0
- trackmod/trackers/xm/note.py +83 -0
- trackmod/trackers/xm/parser.py +166 -0
- trackmod/trackers/xm/patterns/__init__.py +0 -0
- trackmod/trackers/xm/patterns/encoded.py +32 -0
- trackmod/trackers/xm/patterns/packer.py +80 -0
- trackmod/trackers/xm/patterns/parser.py +163 -0
- trackmod/trackers/xm/patterns/sizing.py +37 -0
- trackmod/trackers/xm/samples/__init__.py +0 -0
- trackmod/trackers/xm/samples/parser.py +139 -0
- trackmod/trackers/xm/samples/writer.py +73 -0
- trackmod/trackers/xm/settings.py +23 -0
- trackmod/trackers/xm/sizing.py +52 -0
- trackmod/trackers/xm/spec/__init__.py +0 -0
- trackmod/trackers/xm/spec/capacities.py +110 -0
- trackmod/trackers/xm/spec/cells.py +36 -0
- trackmod/trackers/xm/spec/defaults.py +13 -0
- trackmod/trackers/xm/spec/effects.py +19 -0
- trackmod/trackers/xm/spec/flags.py +37 -0
- trackmod/trackers/xm/spec/identity.py +13 -0
- trackmod/trackers/xm/spec/ranges.py +70 -0
- trackmod/trackers/xm/spec/sizes.py +32 -0
- trackmod/trackers/xm/spec/storage.py +27 -0
- trackmod/trackers/xm/spec/tuning.py +12 -0
- trackmod/trackers/xm/spec/volume.py +67 -0
- trackmod/trackers/xm/timing.py +10 -0
- trackmod/trackers/xm/tuning.py +73 -0
- trackmod/trackers/xm/writer.py +62 -0
- trackmod/utils/__init__.py +0 -0
- trackmod/utils/arithmetic.py +47 -0
- trackmod/wave/__init__.py +0 -0
- trackmod/wave/chunks.py +77 -0
- trackmod/wave/layout.py +88 -0
- trackmod/wave/parser.py +225 -0
- trackmod/wave/settings.py +25 -0
- trackmod/wave/spec.py +64 -0
- trackmod/wave/writer.py +213 -0
- trackmod-0.2.0.dist-info/METADATA +170 -0
- trackmod-0.2.0.dist-info/RECORD +343 -0
- trackmod-0.2.0.dist-info/WHEEL +4 -0
- trackmod-0.2.0.dist-info/licenses/LICENSE +21 -0
trackmod/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
|
|
3
|
+
from trackmod.core.instruments.instrument import Instrument
|
|
4
|
+
from trackmod.core.instruments.transfer import combine, extract, units
|
|
5
|
+
from trackmod.core.instruments.unit import InstrumentUnit
|
|
6
|
+
from trackmod.core.samples.depth import BitDepth
|
|
7
|
+
from trackmod.core.samples.loop import Loop, LoopMode
|
|
8
|
+
from trackmod.core.samples.sample import Sample
|
|
9
|
+
from trackmod.core.songs.song import Song
|
|
10
|
+
from trackmod.core.voices.convert import flattened, raised
|
|
11
|
+
from trackmod.core.voices.voices import InstrumentVoices, SampleVoices, Voices
|
|
12
|
+
from trackmod.limits.compliance import Compliance
|
|
13
|
+
from trackmod.limits.error import LimitError
|
|
14
|
+
from trackmod.limits.violation import Violation
|
|
15
|
+
from trackmod.module.instrument import InstrumentFile
|
|
16
|
+
from trackmod.module.protocol import TrackerModule
|
|
17
|
+
from trackmod.trackers.it.instrument_file import ITInstrumentFile
|
|
18
|
+
from trackmod.trackers.it.module import ITModule
|
|
19
|
+
from trackmod.trackers.mod.module import MODModule
|
|
20
|
+
from trackmod.trackers.registry import (
|
|
21
|
+
EXTENSIONS,
|
|
22
|
+
INSTRUMENT_EXTENSIONS,
|
|
23
|
+
MODULE_EXTENSIONS,
|
|
24
|
+
detected,
|
|
25
|
+
load_module,
|
|
26
|
+
load_voices,
|
|
27
|
+
parse_module,
|
|
28
|
+
parse_voices,
|
|
29
|
+
)
|
|
30
|
+
from trackmod.trackers.s3m.module import S3MModule
|
|
31
|
+
from trackmod.trackers.st.module import STModule
|
|
32
|
+
from trackmod.trackers.xm.instrument_file import XMInstrumentFile
|
|
33
|
+
from trackmod.trackers.xm.module import XMModule
|
|
34
|
+
from trackmod.wave.parser import load_sample, parse_sample
|
|
35
|
+
from trackmod.wave.writer import save_sample, write_sample
|
|
36
|
+
|
|
37
|
+
__version__ = importlib.metadata.version("trackmod")
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"EXTENSIONS",
|
|
41
|
+
"INSTRUMENT_EXTENSIONS",
|
|
42
|
+
"MODULE_EXTENSIONS",
|
|
43
|
+
"BitDepth",
|
|
44
|
+
"Compliance",
|
|
45
|
+
"ITInstrumentFile",
|
|
46
|
+
"ITModule",
|
|
47
|
+
"Instrument",
|
|
48
|
+
"InstrumentFile",
|
|
49
|
+
"InstrumentUnit",
|
|
50
|
+
"InstrumentVoices",
|
|
51
|
+
"LimitError",
|
|
52
|
+
"Loop",
|
|
53
|
+
"LoopMode",
|
|
54
|
+
"MODModule",
|
|
55
|
+
"S3MModule",
|
|
56
|
+
"STModule",
|
|
57
|
+
"Sample",
|
|
58
|
+
"SampleVoices",
|
|
59
|
+
"Song",
|
|
60
|
+
"TrackerModule",
|
|
61
|
+
"Violation",
|
|
62
|
+
"Voices",
|
|
63
|
+
"XMInstrumentFile",
|
|
64
|
+
"XMModule",
|
|
65
|
+
"combine",
|
|
66
|
+
"detected",
|
|
67
|
+
"extract",
|
|
68
|
+
"flattened",
|
|
69
|
+
"load_module",
|
|
70
|
+
"load_sample",
|
|
71
|
+
"load_voices",
|
|
72
|
+
"parse_module",
|
|
73
|
+
"parse_sample",
|
|
74
|
+
"parse_voices",
|
|
75
|
+
"raised",
|
|
76
|
+
"save_sample",
|
|
77
|
+
"units",
|
|
78
|
+
"write_sample",
|
|
79
|
+
]
|
|
File without changes
|
trackmod/binary/bits.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from trackmod.spec.width import BITS_PER_BYTE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BitReader:
|
|
5
|
+
"""Reads variable-width fields from a byte stream, least significant bit first.
|
|
6
|
+
|
|
7
|
+
A compressed waveform packs its values at a width that changes as it runs, so the reader keeps a
|
|
8
|
+
running buffer of whole bytes and hands out whatever width is asked for next. Bytes are drawn in as
|
|
9
|
+
the buffer runs dry, which is what makes one field cost a shift rather than a walk over its bits.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, data: bytes) -> None:
|
|
13
|
+
self._data = data
|
|
14
|
+
self._index = 0
|
|
15
|
+
self._buffer = 0
|
|
16
|
+
self._available = 0
|
|
17
|
+
|
|
18
|
+
def take(self, width: int) -> int:
|
|
19
|
+
"""The next ``width`` bits, as an unsigned integer.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: when the stream holds fewer bits than were asked for.
|
|
23
|
+
"""
|
|
24
|
+
while self._available < width:
|
|
25
|
+
if self._index >= len(self._data):
|
|
26
|
+
raise ValueError(f"the stream holds {self._available} bits, short of the {width} asked for")
|
|
27
|
+
|
|
28
|
+
self._buffer |= self._data[self._index] << self._available
|
|
29
|
+
self._index += 1
|
|
30
|
+
self._available += BITS_PER_BYTE
|
|
31
|
+
|
|
32
|
+
value = self._buffer & ((1 << width) - 1)
|
|
33
|
+
self._buffer >>= width
|
|
34
|
+
self._available -= width
|
|
35
|
+
return value
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from trackmod.binary.records.record import Record
|
|
2
|
+
from trackmod.binary.records.values import ArrayValue, FieldValue
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Cursor:
|
|
6
|
+
"""Walks a module's bytes from front to back, which is how every format here is laid out.
|
|
7
|
+
|
|
8
|
+
A parser reads sections in the order the file stores them, so a cursor that remembers where it is
|
|
9
|
+
keeps every offset out of the parsing code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, data: bytes) -> None:
|
|
13
|
+
self._data = data
|
|
14
|
+
self._position = 0
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def position(self) -> int:
|
|
18
|
+
"""How many bytes have been consumed."""
|
|
19
|
+
return self._position
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def remaining(self) -> int:
|
|
23
|
+
"""How many bytes are left."""
|
|
24
|
+
return len(self._data) - self._position
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def at_end(self) -> bool:
|
|
28
|
+
"""Whether every byte has been consumed."""
|
|
29
|
+
return self.remaining == 0
|
|
30
|
+
|
|
31
|
+
def take(self, count: int) -> bytes:
|
|
32
|
+
"""Consume and return the next ``count`` bytes.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: when fewer than ``count`` bytes remain.
|
|
36
|
+
"""
|
|
37
|
+
chunk = self.peek(count)
|
|
38
|
+
self._position += count
|
|
39
|
+
return chunk
|
|
40
|
+
|
|
41
|
+
def byte(self) -> int:
|
|
42
|
+
"""Consume and return the next byte, which is what a stream of markers and columns is read in.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: when the data is spent.
|
|
46
|
+
"""
|
|
47
|
+
return self.take(1)[0]
|
|
48
|
+
|
|
49
|
+
def peek(self, count: int) -> bytes:
|
|
50
|
+
"""Return the next ``count`` bytes without consuming them.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: when fewer than ``count`` bytes remain.
|
|
54
|
+
"""
|
|
55
|
+
if count < 0:
|
|
56
|
+
raise ValueError(f"cannot read {count} bytes")
|
|
57
|
+
|
|
58
|
+
if count > self.remaining:
|
|
59
|
+
raise ValueError(f"needed {count} bytes at offset {self._position}, {self.remaining} remain")
|
|
60
|
+
|
|
61
|
+
return self._data[self._position : self._position + count]
|
|
62
|
+
|
|
63
|
+
def take_at_most(self, count: int) -> bytes:
|
|
64
|
+
"""Consume and return up to ``count`` bytes, stopping where the data ends.
|
|
65
|
+
|
|
66
|
+
A file may state a section longer than it goes on to hold, and a player sounds the part that is
|
|
67
|
+
there rather than refusing the file, so a reader following one needs to do the same.
|
|
68
|
+
"""
|
|
69
|
+
return self.take(min(count, self.remaining))
|
|
70
|
+
|
|
71
|
+
def peek_padded(self, count: int) -> bytes:
|
|
72
|
+
"""Return the next ``count`` bytes, padded with zeroes where the data ends.
|
|
73
|
+
|
|
74
|
+
This reads a fixed record out of a file that stops inside it: the fields the data reaches are
|
|
75
|
+
the ones it stated, and the rest read as the zeroes an empty record holds.
|
|
76
|
+
"""
|
|
77
|
+
held = self.peek(min(count, self.remaining))
|
|
78
|
+
return held + bytes(count - len(held))
|
|
79
|
+
|
|
80
|
+
def skip(self, count: int) -> None:
|
|
81
|
+
"""Consume ``count`` bytes without returning them.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: when fewer than ``count`` bytes remain.
|
|
85
|
+
"""
|
|
86
|
+
self.take(count)
|
|
87
|
+
|
|
88
|
+
def seek(self, position: int) -> None:
|
|
89
|
+
"""Move to an absolute byte offset.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: when the offset falls outside the data.
|
|
93
|
+
"""
|
|
94
|
+
if not 0 <= position <= len(self._data):
|
|
95
|
+
raise ValueError(f"offset {position} falls outside {len(self._data)} bytes")
|
|
96
|
+
|
|
97
|
+
self._position = position
|
|
98
|
+
|
|
99
|
+
def read(self, record: Record) -> dict[str, FieldValue | ArrayValue]:
|
|
100
|
+
"""Consume one record's worth of bytes and unpack its described fields."""
|
|
101
|
+
return record.unpack(self.take(record.size))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def offsets(blobs: Sequence[bytes], start: int) -> list[int]:
|
|
5
|
+
"""Where each blob lands when they are laid end to end from ``start``."""
|
|
6
|
+
positions = []
|
|
7
|
+
position = start
|
|
8
|
+
for blob in blobs:
|
|
9
|
+
positions.append(position)
|
|
10
|
+
position += len(blob)
|
|
11
|
+
|
|
12
|
+
return positions
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from trackmod.spec.width import (
|
|
2
|
+
BYTE_MAX,
|
|
3
|
+
DECIMAL_BYTE_MAX,
|
|
4
|
+
DECIMAL_RADIX,
|
|
5
|
+
NIBBLE_BITS,
|
|
6
|
+
NIBBLE_MAX,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def split_nibbles(value: int) -> tuple[int, int]:
|
|
11
|
+
"""Split a byte into its high and low nibbles.
|
|
12
|
+
|
|
13
|
+
Raises:
|
|
14
|
+
ValueError: when ``value`` does not fit in a byte.
|
|
15
|
+
"""
|
|
16
|
+
if not 0 <= value <= BYTE_MAX:
|
|
17
|
+
raise ValueError(f"{value} does not fit in a byte")
|
|
18
|
+
|
|
19
|
+
return value >> NIBBLE_BITS, value & NIBBLE_MAX
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def join_nibbles(high: int, low: int) -> int:
|
|
23
|
+
"""Combine two nibbles into a byte.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: when either nibble exceeds four bits.
|
|
27
|
+
"""
|
|
28
|
+
if not 0 <= high <= NIBBLE_MAX or not 0 <= low <= NIBBLE_MAX:
|
|
29
|
+
raise ValueError(f"nibbles {high}, {low} exceed four bits")
|
|
30
|
+
|
|
31
|
+
return (high << NIBBLE_BITS) | low
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decimal_byte(value: int) -> int:
|
|
35
|
+
"""The byte a tracker reads back as the decimal number ``value``, a digit to each nibble.
|
|
36
|
+
|
|
37
|
+
Amiga ProTracker read one parameter as though its two hexadecimal digits were decimal ones, and the
|
|
38
|
+
trackers that followed it kept the reading, so 16 is stored as ``0x16``.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: when ``value`` needs more than two decimal digits.
|
|
42
|
+
"""
|
|
43
|
+
if not 0 <= value <= DECIMAL_BYTE_MAX:
|
|
44
|
+
raise ValueError(f"{value} needs more than two decimal digits")
|
|
45
|
+
|
|
46
|
+
return join_nibbles(*divmod(value, DECIMAL_RADIX))
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from trackmod.core.samples.depth import BitDepth
|
|
2
|
+
from trackmod.core.samples.sample import STEREO_CHANNELS
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def whole_frames(block: bytes, *, depth: BitDepth) -> bytes:
|
|
6
|
+
"""The head of a stored block that holds whole frames, which is as far as a cut-short block sounds."""
|
|
7
|
+
return block[: len(block) - len(block) % depth.bytes_per_frame]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def paired_channels(data: bytes, *, block: int, depth: BitDepth) -> tuple[bytes, bytes]:
|
|
11
|
+
"""The two channel blocks of a stereo waveform, each read as far as both of them reach.
|
|
12
|
+
|
|
13
|
+
A stereo waveform holds each channel's frames in full, the left before the right, so a file stopping
|
|
14
|
+
inside the block holds all of the left channel and a part of the right. One frame is the pair of
|
|
15
|
+
amplitudes a player sounds together, so both channels are read to the length they share.
|
|
16
|
+
"""
|
|
17
|
+
left = whole_frames(data[:block], depth=depth)
|
|
18
|
+
right = whole_frames(data[block : block * STEREO_CHANNELS], depth=depth)
|
|
19
|
+
held = min(len(left), len(right))
|
|
20
|
+
return left[:held], right[:held]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numpy.typing import NDArray
|
|
3
|
+
|
|
4
|
+
from trackmod.binary.pcm.encoding import PcmEncoding
|
|
5
|
+
from trackmod.binary.pcm.quantize import dequantize, quantize
|
|
6
|
+
from trackmod.binary.pcm.sign import PcmSign, bias, dtype_for
|
|
7
|
+
from trackmod.core.samples.depth import BitDepth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def encode_pcm(
|
|
11
|
+
pcm: NDArray[np.float64],
|
|
12
|
+
*,
|
|
13
|
+
depth: BitDepth,
|
|
14
|
+
encoding: PcmEncoding,
|
|
15
|
+
sign: PcmSign,
|
|
16
|
+
) -> bytes:
|
|
17
|
+
"""Serialize float PCM in ``[-1, 1]`` to stored frames.
|
|
18
|
+
|
|
19
|
+
The amplitudes are placed in the stored range before they are differenced, which is the order the
|
|
20
|
+
two axes compose in: a player integrates in the width it reads, so the differences it sums are
|
|
21
|
+
between the values that width holds.
|
|
22
|
+
|
|
23
|
+
Deltas are taken in a width wider than the store and cast back, so a difference that overshoots the
|
|
24
|
+
stored range wraps exactly as the player's running sum unwraps it. The first stored delta is taken
|
|
25
|
+
against zero, which makes it the waveform's first stored amplitude.
|
|
26
|
+
"""
|
|
27
|
+
amplitudes = quantize(pcm, depth) + bias(depth, sign=sign)
|
|
28
|
+
match encoding:
|
|
29
|
+
case PcmEncoding.ABSOLUTE:
|
|
30
|
+
stored = amplitudes
|
|
31
|
+
case PcmEncoding.DELTA:
|
|
32
|
+
stored = np.diff(amplitudes, prepend=0)
|
|
33
|
+
|
|
34
|
+
return stored.astype(dtype_for(depth, sign=sign)).tobytes()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_pcm(
|
|
38
|
+
data: bytes,
|
|
39
|
+
*,
|
|
40
|
+
depth: BitDepth,
|
|
41
|
+
encoding: PcmEncoding,
|
|
42
|
+
sign: PcmSign,
|
|
43
|
+
) -> NDArray[np.float64]:
|
|
44
|
+
"""Read stored frames back as float PCM in ``[-1, 1]``."""
|
|
45
|
+
dtype = dtype_for(depth, sign=sign)
|
|
46
|
+
stored = np.frombuffer(data, dtype=dtype)
|
|
47
|
+
match encoding:
|
|
48
|
+
case PcmEncoding.ABSOLUTE:
|
|
49
|
+
frames = stored
|
|
50
|
+
case PcmEncoding.DELTA:
|
|
51
|
+
frames = np.cumsum(stored, dtype=dtype)
|
|
52
|
+
|
|
53
|
+
return dequantize(frames.astype(np.int64) - bias(depth, sign=sign), depth)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from enum import StrEnum, unique
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@unique
|
|
5
|
+
class PcmEncoding(StrEnum):
|
|
6
|
+
"""How a format lays a waveform's frames out on disk.
|
|
7
|
+
|
|
8
|
+
Absolute storage writes each frame's amplitude; delta storage writes successive differences and the
|
|
9
|
+
player integrates them with a running sum in the stored width.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
ABSOLUTE = "absolute"
|
|
13
|
+
DELTA = "delta"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numpy.typing import NDArray
|
|
3
|
+
|
|
4
|
+
from trackmod.core.samples.depth import BitDepth
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def quantize(pcm: NDArray[np.float64], depth: BitDepth) -> NDArray[np.int64]:
|
|
8
|
+
"""Map float PCM in ``[-1, 1]`` onto the signed integer range of ``depth``."""
|
|
9
|
+
scale = depth.scale
|
|
10
|
+
return np.clip(
|
|
11
|
+
np.round(np.asarray(pcm, dtype=np.float64) * scale),
|
|
12
|
+
-scale,
|
|
13
|
+
scale - 1,
|
|
14
|
+
).astype(np.int64)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dequantize(frames: NDArray[np.int64], depth: BitDepth) -> NDArray[np.float64]:
|
|
18
|
+
"""Map stored integer frames back onto float PCM in ``[-1, 1]``."""
|
|
19
|
+
return np.asarray(frames, dtype=np.float64) / depth.scale
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from enum import StrEnum, unique
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
from trackmod.core.samples.depth import BitDepth
|
|
5
|
+
|
|
6
|
+
NO_BIAS: Final = 0
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@unique
|
|
10
|
+
class PcmSign(StrEnum):
|
|
11
|
+
"""Which stored value a frame of silence sits on.
|
|
12
|
+
|
|
13
|
+
Signed storage centers silence on zero, so a frame runs from ``-scale`` to ``scale - 1``. Unsigned
|
|
14
|
+
storage carries the same amplitudes shifted up by ``scale``, so silence sits in the middle of the
|
|
15
|
+
range and every stored value is positive.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
SIGNED = "signed"
|
|
19
|
+
UNSIGNED = "unsigned"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STORED_DTYPES: Final[dict[PcmSign, dict[BitDepth, str]]] = {
|
|
23
|
+
PcmSign.SIGNED: {BitDepth.EIGHT: "<i1", BitDepth.SIXTEEN: "<i2"},
|
|
24
|
+
PcmSign.UNSIGNED: {BitDepth.EIGHT: "<u1", BitDepth.SIXTEEN: "<u2"},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dtype_for(depth: BitDepth, *, sign: PcmSign) -> str:
|
|
29
|
+
"""The little-endian numpy dtype one stored frame occupies."""
|
|
30
|
+
return STORED_DTYPES[sign][depth]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bias(depth: BitDepth, *, sign: PcmSign) -> int:
|
|
34
|
+
"""The amount that separates a stored frame from the signed amplitude it carries."""
|
|
35
|
+
match sign:
|
|
36
|
+
case PcmSign.SIGNED:
|
|
37
|
+
return NO_BIAS
|
|
38
|
+
case PcmSign.UNSIGNED:
|
|
39
|
+
return int(depth.scale)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic import Field as ModelField
|
|
5
|
+
|
|
6
|
+
from trackmod.schema.config import FROZEN
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Field(BaseModel):
|
|
10
|
+
"""One fixed field of a record: a :mod:`struct` value written at a byte offset.
|
|
11
|
+
|
|
12
|
+
``code`` is a ``struct`` format for a single value, such as ``"<I"`` for a 32-bit word, ``"B"`` for a
|
|
13
|
+
byte, ``"b"`` for a signed byte, or ``"26s"`` for a fixed-length block the caller has already padded.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
model_config = FROZEN
|
|
17
|
+
|
|
18
|
+
name: str = ModelField(min_length=1)
|
|
19
|
+
offset: int = ModelField(ge=0)
|
|
20
|
+
code: str = ModelField(min_length=1)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def size(self) -> int:
|
|
24
|
+
"""How many bytes the field occupies."""
|
|
25
|
+
return struct.calcsize(self.code)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArrayField(BaseModel):
|
|
29
|
+
"""A contiguous run of fixed-stride elements, such as a keyboard note map.
|
|
30
|
+
|
|
31
|
+
``code`` is the ``struct`` format for one element and its packed size is the stride; each value is a
|
|
32
|
+
sequence unpacked into that element.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = FROZEN
|
|
36
|
+
|
|
37
|
+
name: str = ModelField(min_length=1)
|
|
38
|
+
offset: int = ModelField(ge=0)
|
|
39
|
+
count: int = ModelField(ge=0)
|
|
40
|
+
code: str = ModelField(min_length=1)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def stride(self) -> int:
|
|
44
|
+
"""How many bytes one element occupies."""
|
|
45
|
+
return struct.calcsize(self.code)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def size(self) -> int:
|
|
49
|
+
"""How many bytes the whole run occupies."""
|
|
50
|
+
return self.stride * self.count
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic import Field as ModelField
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
|
|
9
|
+
from trackmod.binary.records.field import ArrayField, Field
|
|
10
|
+
from trackmod.binary.records.values import ArrayValue, FieldValue, RecordValues
|
|
11
|
+
from trackmod.schema.config import FROZEN
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Record(BaseModel):
|
|
15
|
+
"""A fixed-size binary record described as data: a byte size plus the fields laid out within it.
|
|
16
|
+
|
|
17
|
+
Stating the layout declaratively keeps every byte offset in one place, so the serializers supply
|
|
18
|
+
field values and never arithmetic. Offsets a record leaves undescribed are reserved and stay zero
|
|
19
|
+
when packing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model_config = FROZEN
|
|
23
|
+
|
|
24
|
+
size: int = ModelField(ge=0)
|
|
25
|
+
fields: tuple[Field, ...]
|
|
26
|
+
arrays: tuple[ArrayField, ...] = ()
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _contained(self) -> Record:
|
|
30
|
+
for field in self.fields:
|
|
31
|
+
if field.offset + field.size > self.size:
|
|
32
|
+
raise ValueError(f"field {field.name!r} runs past the {self.size}-byte record")
|
|
33
|
+
|
|
34
|
+
for array in self.arrays:
|
|
35
|
+
if array.offset + array.size > self.size:
|
|
36
|
+
raise ValueError(f"array {array.name!r} runs past the {self.size}-byte record")
|
|
37
|
+
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def pack(self, values: RecordValues) -> bytes:
|
|
41
|
+
"""Serialize ``values`` into exactly :attr:`size` bytes.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
KeyError: when a described field has no value.
|
|
45
|
+
TypeError: when an array field is given a single value rather than a sequence.
|
|
46
|
+
"""
|
|
47
|
+
buffer = bytearray(self.size)
|
|
48
|
+
for field in self.fields:
|
|
49
|
+
struct.pack_into(field.code, buffer, field.offset, values[field.name])
|
|
50
|
+
|
|
51
|
+
for array in self.arrays:
|
|
52
|
+
rows = values[array.name]
|
|
53
|
+
if isinstance(rows, (int, bytes)):
|
|
54
|
+
raise TypeError(f"array {array.name!r} needs a sequence of elements, got {type(rows).__name__}")
|
|
55
|
+
|
|
56
|
+
for index, row in enumerate(rows):
|
|
57
|
+
struct.pack_into(
|
|
58
|
+
array.code,
|
|
59
|
+
buffer,
|
|
60
|
+
array.offset + index * array.stride,
|
|
61
|
+
*row,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return bytes(buffer)
|
|
65
|
+
|
|
66
|
+
def unpack_at(self, data: bytes, offset: int) -> dict[str, FieldValue | ArrayValue]:
|
|
67
|
+
"""Read this record from ``offset``, filling it out with zeroes where the data stops inside it.
|
|
68
|
+
|
|
69
|
+
A file states where each of its records sits, and one stating an offset it never reaches is read
|
|
70
|
+
to the end of what it holds -- the fields the data covers are the ones it stated, and the rest
|
|
71
|
+
read as the zeroes an empty record holds.
|
|
72
|
+
"""
|
|
73
|
+
return self.unpack(data[offset : offset + self.size].ljust(self.size, b"\0"))
|
|
74
|
+
|
|
75
|
+
def unpack(self, data: bytes) -> dict[str, FieldValue | ArrayValue]:
|
|
76
|
+
"""Read every described field out of ``data``.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: when ``data`` is shorter than the record.
|
|
80
|
+
"""
|
|
81
|
+
if len(data) < self.size:
|
|
82
|
+
raise ValueError(f"record needs {self.size} bytes, got {len(data)}")
|
|
83
|
+
|
|
84
|
+
values: dict[str, FieldValue | ArrayValue] = {}
|
|
85
|
+
for field in self.fields:
|
|
86
|
+
(value,) = struct.unpack_from(field.code, data, field.offset)
|
|
87
|
+
values[field.name] = value
|
|
88
|
+
|
|
89
|
+
for array in self.arrays:
|
|
90
|
+
values[array.name] = tuple(
|
|
91
|
+
struct.unpack_from(
|
|
92
|
+
array.code,
|
|
93
|
+
data,
|
|
94
|
+
array.offset + index * array.stride,
|
|
95
|
+
)
|
|
96
|
+
for index in range(array.count)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return values
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
|
|
3
|
+
FieldValue = int | bytes
|
|
4
|
+
ArrayValue = Sequence[Sequence[int]]
|
|
5
|
+
RecordValues = Mapping[str, FieldValue | ArrayValue]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_int(values: RecordValues, name: str) -> int:
|
|
9
|
+
"""Read one unpacked field as an integer.
|
|
10
|
+
|
|
11
|
+
Raises:
|
|
12
|
+
TypeError: when the field holds a block of bytes or a run of elements instead.
|
|
13
|
+
"""
|
|
14
|
+
value = values[name]
|
|
15
|
+
if not isinstance(value, int):
|
|
16
|
+
raise TypeError(f"field {name!r} holds {type(value).__name__}, expected an integer")
|
|
17
|
+
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_bytes(values: RecordValues, name: str) -> bytes:
|
|
22
|
+
"""Read one unpacked field as a fixed-length byte block.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
TypeError: when the field holds an integer or a run of elements instead.
|
|
26
|
+
"""
|
|
27
|
+
value = values[name]
|
|
28
|
+
if not isinstance(value, bytes):
|
|
29
|
+
raise TypeError(f"field {name!r} holds {type(value).__name__}, expected a byte block")
|
|
30
|
+
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_rows(values: RecordValues, name: str) -> ArrayValue:
|
|
35
|
+
"""Read one unpacked array field as its run of elements.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
TypeError: when the field holds a single value instead.
|
|
39
|
+
"""
|
|
40
|
+
value = values[name]
|
|
41
|
+
if isinstance(value, (int, bytes)):
|
|
42
|
+
raise TypeError(f"field {name!r} holds {type(value).__name__}, expected a run of elements")
|
|
43
|
+
|
|
44
|
+
return value
|
trackmod/binary/text.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from trackmod.spec.text import ENCODING, PADDING, REPLACEMENT
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def encode_name(text: str, length: int) -> bytes:
|
|
5
|
+
"""Encode ``text`` to exactly ``length`` bytes, null-padded, truncating anything longer.
|
|
6
|
+
|
|
7
|
+
Tracker name fields are fixed-width and a player reads the whole width, so a name is padded rather
|
|
8
|
+
than terminated. Characters outside the tracker character set become ``?``.
|
|
9
|
+
"""
|
|
10
|
+
raw = text.encode(ENCODING, errors=REPLACEMENT)[:length]
|
|
11
|
+
return raw + PADDING * (length - len(raw))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def decode_name(raw: bytes) -> str:
|
|
15
|
+
"""Read a fixed-width name field back, dropping the padding a writer added."""
|
|
16
|
+
return raw.split(PADDING, 1)[0].decode(ENCODING, errors=REPLACEMENT)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode_text(text: str) -> bytes:
|
|
20
|
+
"""Encode ``text`` as a block of its own length, closed by the terminator a reader stops at.
|
|
21
|
+
|
|
22
|
+
A block a header points at with an offset and a length is as long as its content, so the terminator
|
|
23
|
+
is what states where the text ends within the length the header reserves for it. Characters outside
|
|
24
|
+
the tracker character set become ``?``.
|
|
25
|
+
"""
|
|
26
|
+
return text.encode(ENCODING, errors=REPLACEMENT) + PADDING
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decode_text(raw: bytes) -> str:
|
|
30
|
+
"""Read a terminated text block back, up to where its terminator closes it."""
|
|
31
|
+
return raw.split(PADDING, 1)[0].decode(ENCODING, errors=REPLACEMENT)
|