pygoattracker 0.1.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.
- pygoattracker/__init__.py +92 -0
- pygoattracker/audio.py +106 -0
- pygoattracker/cli.py +118 -0
- pygoattracker/constants.py +170 -0
- pygoattracker/convert.py +710 -0
- pygoattracker/errors.py +25 -0
- pygoattracker/legacy.py +517 -0
- pygoattracker/model.py +197 -0
- pygoattracker/ninja.py +539 -0
- pygoattracker/player.py +661 -0
- pygoattracker/reader.py +195 -0
- pygoattracker/reglog.py +100 -0
- pygoattracker/writer.py +198 -0
- pygoattracker-0.1.0.dist-info/METADATA +465 -0
- pygoattracker-0.1.0.dist-info/RECORD +19 -0
- pygoattracker-0.1.0.dist-info/WHEEL +5 -0
- pygoattracker-0.1.0.dist-info/entry_points.txt +2 -0
- pygoattracker-0.1.0.dist-info/licenses/LICENSE +201 -0
- pygoattracker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Read, write, play, and render GoatTracker 2 (.SNG) songs."""
|
|
2
|
+
|
|
3
|
+
from pygoattracker.audio import render_samples, render_wav, write_wav
|
|
4
|
+
from pygoattracker.convert import gt_to_nt2
|
|
5
|
+
from pygoattracker.errors import (
|
|
6
|
+
ConversionError,
|
|
7
|
+
GoatTrackerError,
|
|
8
|
+
NinjaParseError,
|
|
9
|
+
NinjaValidationError,
|
|
10
|
+
SngParseError,
|
|
11
|
+
SngValidationError,
|
|
12
|
+
)
|
|
13
|
+
from pygoattracker.ninja import (
|
|
14
|
+
NinjaCommand,
|
|
15
|
+
NinjaPattern,
|
|
16
|
+
NinjaRow,
|
|
17
|
+
NinjaSong,
|
|
18
|
+
build_nt2,
|
|
19
|
+
parse_nt2,
|
|
20
|
+
read_nt2,
|
|
21
|
+
validate_nt2,
|
|
22
|
+
write_nt2,
|
|
23
|
+
)
|
|
24
|
+
from pygoattracker.model import (
|
|
25
|
+
Instrument,
|
|
26
|
+
Orderlist,
|
|
27
|
+
Pattern,
|
|
28
|
+
PlayPattern,
|
|
29
|
+
Repeat,
|
|
30
|
+
Row,
|
|
31
|
+
Song,
|
|
32
|
+
Subtune,
|
|
33
|
+
Table,
|
|
34
|
+
Transpose,
|
|
35
|
+
entry_from_byte,
|
|
36
|
+
)
|
|
37
|
+
from pygoattracker.player import Player, iter_frames
|
|
38
|
+
from pygoattracker.reader import parse_sng, read_sng
|
|
39
|
+
from pygoattracker.reglog import (
|
|
40
|
+
RegWrite,
|
|
41
|
+
iter_register_writes,
|
|
42
|
+
read_reglog,
|
|
43
|
+
write_reglog,
|
|
44
|
+
)
|
|
45
|
+
from pygoattracker.writer import build_sng, validate_song, write_sng
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ConversionError",
|
|
51
|
+
"GoatTrackerError",
|
|
52
|
+
"Instrument",
|
|
53
|
+
"NinjaCommand",
|
|
54
|
+
"NinjaParseError",
|
|
55
|
+
"NinjaPattern",
|
|
56
|
+
"NinjaRow",
|
|
57
|
+
"NinjaSong",
|
|
58
|
+
"NinjaValidationError",
|
|
59
|
+
"Orderlist",
|
|
60
|
+
"Pattern",
|
|
61
|
+
"PlayPattern",
|
|
62
|
+
"Player",
|
|
63
|
+
"RegWrite",
|
|
64
|
+
"Repeat",
|
|
65
|
+
"Row",
|
|
66
|
+
"SngParseError",
|
|
67
|
+
"SngValidationError",
|
|
68
|
+
"Song",
|
|
69
|
+
"Subtune",
|
|
70
|
+
"Table",
|
|
71
|
+
"Transpose",
|
|
72
|
+
"__version__",
|
|
73
|
+
"build_nt2",
|
|
74
|
+
"build_sng",
|
|
75
|
+
"entry_from_byte",
|
|
76
|
+
"gt_to_nt2",
|
|
77
|
+
"iter_frames",
|
|
78
|
+
"iter_register_writes",
|
|
79
|
+
"parse_nt2",
|
|
80
|
+
"parse_sng",
|
|
81
|
+
"read_nt2",
|
|
82
|
+
"read_reglog",
|
|
83
|
+
"read_sng",
|
|
84
|
+
"render_samples",
|
|
85
|
+
"render_wav",
|
|
86
|
+
"validate_nt2",
|
|
87
|
+
"validate_song",
|
|
88
|
+
"write_nt2",
|
|
89
|
+
"write_reglog",
|
|
90
|
+
"write_sng",
|
|
91
|
+
"write_wav",
|
|
92
|
+
]
|
pygoattracker/audio.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Render songs through an emulated SID to samples or WAV.
|
|
2
|
+
|
|
3
|
+
By default the emulated SID is `pyresidfp
|
|
4
|
+
<https://pypi.org/project/pyresidfp/>`_ (install the ``audio`` extra).
|
|
5
|
+
Any object with ``write_register(reg, value)``, ``clock(timedelta) ->
|
|
6
|
+
samples`` and a ``sampling_frequency`` attribute can be passed as
|
|
7
|
+
``device`` instead, e.g. for tests or a different emulator.
|
|
8
|
+
|
|
9
|
+
Each register write is clocked individually at the same in-frame offset
|
|
10
|
+
the register log uses, so renders line up with
|
|
11
|
+
:mod:`pygoattracker.reglog` output.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import wave
|
|
15
|
+
from array import array
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pygoattracker import constants
|
|
20
|
+
from pygoattracker.errors import GoatTrackerError
|
|
21
|
+
from pygoattracker.model import Song
|
|
22
|
+
from pygoattracker.player import iter_frames
|
|
23
|
+
from pygoattracker.reglog import DEFAULT_WRITE_SPACING
|
|
24
|
+
|
|
25
|
+
CHIP_MODELS = ("6581", "8580")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_device(model: str, sampling_frequency: float | None):
|
|
29
|
+
try:
|
|
30
|
+
from pyresidfp import SoundInterfaceDevice
|
|
31
|
+
from pyresidfp.sound_interface_device import ChipModel
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise GoatTrackerError(
|
|
34
|
+
"pyresidfp is required to render audio; "
|
|
35
|
+
"install with: pip install pygoattracker[audio]"
|
|
36
|
+
) from exc
|
|
37
|
+
chip = {"6581": ChipModel.MOS6581, "8580": ChipModel.MOS8580}[model]
|
|
38
|
+
if sampling_frequency:
|
|
39
|
+
return SoundInterfaceDevice(
|
|
40
|
+
model=chip, sampling_frequency=float(sampling_frequency)
|
|
41
|
+
)
|
|
42
|
+
return SoundInterfaceDevice(model=chip)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_samples(
|
|
46
|
+
song: Song,
|
|
47
|
+
seconds: float = 60.0,
|
|
48
|
+
subtune: int = 0,
|
|
49
|
+
until_loop: bool = False,
|
|
50
|
+
model: str = "8580",
|
|
51
|
+
sampling_frequency: float | None = None,
|
|
52
|
+
device=None,
|
|
53
|
+
cycles_per_frame: int = constants.PAL_CYCLES_PER_FRAME,
|
|
54
|
+
clock_frequency: float = constants.PAL_CLOCK_HZ,
|
|
55
|
+
**player_options,
|
|
56
|
+
):
|
|
57
|
+
"""Render ``song`` on an emulated SID.
|
|
58
|
+
|
|
59
|
+
Returns ``(samples, sampling_frequency)`` where samples are signed
|
|
60
|
+
16-bit mono. Rendering stops at ``seconds`` (or earlier when the
|
|
61
|
+
song stops, or at the song loop with ``until_loop``).
|
|
62
|
+
"""
|
|
63
|
+
if model not in CHIP_MODELS:
|
|
64
|
+
raise GoatTrackerError(f"chip model must be one of {CHIP_MODELS}")
|
|
65
|
+
if device is None:
|
|
66
|
+
device = _default_device(model, sampling_frequency)
|
|
67
|
+
write_q = DEFAULT_WRITE_SPACING / clock_frequency
|
|
68
|
+
frame_seconds = cycles_per_frame / clock_frequency
|
|
69
|
+
max_frames = max(1, round(seconds / frame_seconds))
|
|
70
|
+
samples = array("h")
|
|
71
|
+
for writes in iter_frames(
|
|
72
|
+
song,
|
|
73
|
+
subtune=subtune,
|
|
74
|
+
max_frames=max_frames,
|
|
75
|
+
until_loop=until_loop,
|
|
76
|
+
**player_options,
|
|
77
|
+
):
|
|
78
|
+
remainder = frame_seconds
|
|
79
|
+
for reg, val in writes:
|
|
80
|
+
device.write_register(reg, val)
|
|
81
|
+
samples.extend(device.clock(timedelta(seconds=write_q)))
|
|
82
|
+
remainder -= write_q
|
|
83
|
+
if remainder > 0:
|
|
84
|
+
samples.extend(device.clock(timedelta(seconds=remainder)))
|
|
85
|
+
return samples, float(device.sampling_frequency)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_wav(dst, samples, sampling_frequency: float) -> None:
|
|
89
|
+
"""Write signed 16-bit mono samples as a WAV file."""
|
|
90
|
+
if not isinstance(samples, array):
|
|
91
|
+
samples = array("h", samples)
|
|
92
|
+
with wave.open(str(dst), "wb") as out:
|
|
93
|
+
out.setnchannels(1)
|
|
94
|
+
out.setsampwidth(2)
|
|
95
|
+
out.setframerate(round(sampling_frequency))
|
|
96
|
+
out.writeframes(samples.tobytes())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_wav(song: Song, dst, seconds: float = 60.0, **options) -> Path:
|
|
100
|
+
"""Render ``song`` to a WAV file; returns the path written.
|
|
101
|
+
|
|
102
|
+
Keyword options are those of :func:`render_samples`.
|
|
103
|
+
"""
|
|
104
|
+
samples, sampling_frequency = render_samples(song, seconds=seconds, **options)
|
|
105
|
+
write_wav(dst, samples, sampling_frequency)
|
|
106
|
+
return Path(dst)
|
pygoattracker/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command line interface: song info, register logs, and WAV rendering."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pygoattracker import audio, convert, ninja, reglog
|
|
8
|
+
from pygoattracker.errors import GoatTrackerError
|
|
9
|
+
from pygoattracker.reader import read_sng
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _nt2_info(song) -> None:
|
|
13
|
+
print("format: NinjaTracker 2")
|
|
14
|
+
print(f"subtunes: {len(song.subtunes)}")
|
|
15
|
+
print(f"patterns: {len(song.patterns)}")
|
|
16
|
+
print(f"commands: {len(song.commands)}")
|
|
17
|
+
print(f"hardrestart: {song.hr_param:02X} / first wave {song.first_wave:02X}")
|
|
18
|
+
for num, command in enumerate(song.commands, start=1):
|
|
19
|
+
print(f" {num:02X}: {command.name}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _info(args) -> None:
|
|
23
|
+
data = Path(args.song).read_bytes()
|
|
24
|
+
if data[: len(ninja.NT2_MAGIC)] == ninja.NT2_MAGIC:
|
|
25
|
+
_nt2_info(ninja.parse_nt2(data))
|
|
26
|
+
return
|
|
27
|
+
song = read_sng(data)
|
|
28
|
+
print(f"name: {song.name}")
|
|
29
|
+
print(f"author: {song.author}")
|
|
30
|
+
print(f"copyright: {song.copyright}")
|
|
31
|
+
print(f"subtunes: {len(song.subtunes)}")
|
|
32
|
+
print(f"patterns: {len(song.patterns)}")
|
|
33
|
+
print(f"instruments: {len(song.instruments)}")
|
|
34
|
+
for num, instrument in enumerate(song.instruments, start=1):
|
|
35
|
+
print(f" {num:02X}: {instrument.name}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reglog(args) -> None:
|
|
39
|
+
song = read_sng(args.song)
|
|
40
|
+
frames = round(args.seconds * 50)
|
|
41
|
+
writes = reglog.iter_register_writes(song, subtune=args.subtune, max_frames=frames)
|
|
42
|
+
reglog.write_reglog(writes, args.output)
|
|
43
|
+
print(f"wrote {args.output}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _wav(args) -> None:
|
|
47
|
+
song = read_sng(args.song)
|
|
48
|
+
audio.render_wav(
|
|
49
|
+
song,
|
|
50
|
+
args.output,
|
|
51
|
+
seconds=args.seconds,
|
|
52
|
+
subtune=args.subtune,
|
|
53
|
+
model=args.model,
|
|
54
|
+
)
|
|
55
|
+
print(f"wrote {args.output}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _nt2(args) -> None:
|
|
59
|
+
song = read_sng(args.song)
|
|
60
|
+
report: list = []
|
|
61
|
+
errors = "drop" if args.lenient else "strict"
|
|
62
|
+
converted = convert.gt_to_nt2(song, errors=errors, report=report)
|
|
63
|
+
ninja.write_nt2(converted, args.output)
|
|
64
|
+
for message in report:
|
|
65
|
+
print(f"dropped: {message}")
|
|
66
|
+
print(f"wrote {args.output}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parser() -> argparse.ArgumentParser:
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="pygoattracker", description="GoatTracker 2 song tools"
|
|
72
|
+
)
|
|
73
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
74
|
+
|
|
75
|
+
info = commands.add_parser("info", help="print song metadata")
|
|
76
|
+
info.add_argument("song", help=".sng file")
|
|
77
|
+
info.set_defaults(func=_info)
|
|
78
|
+
|
|
79
|
+
log = commands.add_parser("reglog", help="write a SID register log")
|
|
80
|
+
log.add_argument("song", help=".sng file")
|
|
81
|
+
log.add_argument("output", help="register log file to write")
|
|
82
|
+
log.add_argument("--subtune", type=int, default=0)
|
|
83
|
+
log.add_argument("--seconds", type=float, default=60.0)
|
|
84
|
+
log.set_defaults(func=_reglog)
|
|
85
|
+
|
|
86
|
+
nt2 = commands.add_parser("nt2", help="convert to a NinjaTracker 2 song")
|
|
87
|
+
nt2.add_argument("song", help=".sng file")
|
|
88
|
+
nt2.add_argument("output", help="NinjaTracker 2 file to write")
|
|
89
|
+
nt2.add_argument(
|
|
90
|
+
"--lenient",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="drop and report inexpressible features instead of failing",
|
|
93
|
+
)
|
|
94
|
+
nt2.set_defaults(func=_nt2)
|
|
95
|
+
|
|
96
|
+
wav = commands.add_parser("wav", help="render through an emulated SID")
|
|
97
|
+
wav.add_argument("song", help=".sng file")
|
|
98
|
+
wav.add_argument("output", help="WAV file to write")
|
|
99
|
+
wav.add_argument("--subtune", type=int, default=0)
|
|
100
|
+
wav.add_argument("--seconds", type=float, default=60.0)
|
|
101
|
+
wav.add_argument("--model", choices=audio.CHIP_MODELS, default="8580")
|
|
102
|
+
wav.set_defaults(func=_wav)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None) -> int:
|
|
107
|
+
"""CLI entry point; returns a process exit code."""
|
|
108
|
+
args = _parser().parse_args(argv)
|
|
109
|
+
try:
|
|
110
|
+
args.func(args)
|
|
111
|
+
except (GoatTrackerError, OSError) as exc:
|
|
112
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Constants shared by the GoatTracker 2 .SNG format and playroutine.
|
|
2
|
+
|
|
3
|
+
Values follow GoatTracker 2.76 (``gcommon.h`` and the format description
|
|
4
|
+
in its ``readme.txt`` section 6.1).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
SNG_MAGIC = b"GTS5"
|
|
8
|
+
# GTS3/GTS4 share GTS5's binary layout (with small post-load value
|
|
9
|
+
# conversions); GTS2 (early 3-table GoatTracker 2) and GTS! (GoatTracker
|
|
10
|
+
# 1.x) are converted on load, exactly as GoatTracker 2 imports them.
|
|
11
|
+
SNG_COMPATIBLE_MAGICS = (b"GTS!", b"GTS2", b"GTS3", b"GTS4", b"GTS5")
|
|
12
|
+
|
|
13
|
+
MAX_STR = 32
|
|
14
|
+
MAX_INSTR = 64
|
|
15
|
+
MAX_CHN = 3
|
|
16
|
+
MAX_PATT = 208
|
|
17
|
+
MAX_TABLES = 4
|
|
18
|
+
MAX_TABLELEN = 255
|
|
19
|
+
MAX_INSTRNAMELEN = 16
|
|
20
|
+
MAX_PATTROWS = 128
|
|
21
|
+
MAX_SONGLEN = 254
|
|
22
|
+
MAX_SONGS = 32
|
|
23
|
+
MAX_NOTES = 96
|
|
24
|
+
|
|
25
|
+
# Orderlist entry encoding.
|
|
26
|
+
REPEAT = 0xD0
|
|
27
|
+
TRANSDOWN = 0xE0
|
|
28
|
+
TRANSUP = 0xF0
|
|
29
|
+
LOOPSONG = 0xFF
|
|
30
|
+
|
|
31
|
+
# Pattern note column encoding.
|
|
32
|
+
ENDPATT = 0xFF
|
|
33
|
+
FIRSTNOTE = 0x60
|
|
34
|
+
LASTNOTE = 0xBC
|
|
35
|
+
REST = 0xBD
|
|
36
|
+
KEYOFF = 0xBE
|
|
37
|
+
KEYON = 0xBF
|
|
38
|
+
|
|
39
|
+
# Pattern commands 0XY-FXY.
|
|
40
|
+
CMD_DONOTHING = 0x0
|
|
41
|
+
CMD_PORTAUP = 0x1
|
|
42
|
+
CMD_PORTADOWN = 0x2
|
|
43
|
+
CMD_TONEPORTA = 0x3
|
|
44
|
+
CMD_VIBRATO = 0x4
|
|
45
|
+
CMD_SETAD = 0x5
|
|
46
|
+
CMD_SETSR = 0x6
|
|
47
|
+
CMD_SETWAVE = 0x7
|
|
48
|
+
CMD_SETWAVEPTR = 0x8
|
|
49
|
+
CMD_SETPULSEPTR = 0x9
|
|
50
|
+
CMD_SETFILTERPTR = 0xA
|
|
51
|
+
CMD_SETFILTERCTRL = 0xB
|
|
52
|
+
CMD_SETFILTERCUTOFF = 0xC
|
|
53
|
+
CMD_SETMASTERVOL = 0xD
|
|
54
|
+
CMD_FUNKTEMPO = 0xE
|
|
55
|
+
CMD_SETTEMPO = 0xF
|
|
56
|
+
|
|
57
|
+
# Table indexes, in on-disk order.
|
|
58
|
+
WTBL = 0
|
|
59
|
+
PTBL = 1
|
|
60
|
+
FTBL = 2
|
|
61
|
+
STBL = 3
|
|
62
|
+
|
|
63
|
+
# Wavetable left-side ranges.
|
|
64
|
+
WAVEDELAY = 0x01
|
|
65
|
+
WAVELASTDELAY = 0x0F
|
|
66
|
+
WAVESILENT = 0xE0
|
|
67
|
+
WAVELASTSILENT = 0xEF
|
|
68
|
+
WAVECMD = 0xF0
|
|
69
|
+
WAVELASTCMD = 0xFE
|
|
70
|
+
TABLEJUMP = 0xFF
|
|
71
|
+
|
|
72
|
+
# SID register map (offsets within the chip's 25 registers).
|
|
73
|
+
SID_REGISTERS = 25
|
|
74
|
+
VOICES = 3
|
|
75
|
+
VOICE_REG_SIZE = 7
|
|
76
|
+
FREQ_LO_REG = 0x00
|
|
77
|
+
FREQ_HI_REG = 0x01
|
|
78
|
+
PULSE_LO_REG = 0x02
|
|
79
|
+
PULSE_HI_REG = 0x03
|
|
80
|
+
CONTROL_REG = 0x04
|
|
81
|
+
AD_REG = 0x05
|
|
82
|
+
SR_REG = 0x06
|
|
83
|
+
FC_LO_REG = 0x15
|
|
84
|
+
FC_HI_REG = 0x16
|
|
85
|
+
RES_FILT_REG = 0x17
|
|
86
|
+
MODE_VOL_REG = 0x18
|
|
87
|
+
|
|
88
|
+
# C64 timing. A PAL frame is 312 rasterlines x 63 cycles.
|
|
89
|
+
PAL_CLOCK_HZ = 985248
|
|
90
|
+
PAL_CYCLES_PER_FRAME = 19656
|
|
91
|
+
NTSC_CLOCK_HZ = 1022727
|
|
92
|
+
NTSC_CYCLES_PER_FRAME = 17095
|
|
93
|
+
|
|
94
|
+
# Default player options, as in the GoatTracker editor.
|
|
95
|
+
DEFAULT_ADPARAM = 0x0F00
|
|
96
|
+
|
|
97
|
+
# PAL note frequency table from the GoatTracker 2 playroutine, notes
|
|
98
|
+
# C-0 to B-7 (the player can reach A-7 to B-7 through transpose only).
|
|
99
|
+
FREQ_LO = (
|
|
100
|
+
# fmt: off
|
|
101
|
+
0x17, 0x27, 0x39, 0x4B, 0x5F, 0x74, 0x8A, 0xA1, 0xBA, 0xD4, 0xF0, 0x0E,
|
|
102
|
+
0x2D, 0x4E, 0x71, 0x96, 0xBE, 0xE8, 0x14, 0x43, 0x74, 0xA9, 0xE1, 0x1C,
|
|
103
|
+
0x5A, 0x9C, 0xE2, 0x2D, 0x7C, 0xCF, 0x28, 0x85, 0xE8, 0x52, 0xC1, 0x37,
|
|
104
|
+
0xB4, 0x39, 0xC5, 0x5A, 0xF7, 0x9E, 0x4F, 0x0A, 0xD1, 0xA3, 0x82, 0x6E,
|
|
105
|
+
0x68, 0x71, 0x8A, 0xB3, 0xEE, 0x3C, 0x9E, 0x15, 0xA2, 0x46, 0x04, 0xDC,
|
|
106
|
+
0xD0, 0xE2, 0x14, 0x67, 0xDD, 0x79, 0x3C, 0x29, 0x44, 0x8D, 0x08, 0xB8,
|
|
107
|
+
0xA1, 0xC5, 0x28, 0xCD, 0xBA, 0xF1, 0x78, 0x53, 0x87, 0x1A, 0x10, 0x71,
|
|
108
|
+
0x42, 0x89, 0x4F, 0x9B, 0x74, 0xE2, 0xF0, 0xA6, 0x0E, 0x33, 0x20, 0xFF,
|
|
109
|
+
# fmt: on
|
|
110
|
+
)
|
|
111
|
+
FREQ_HI = (
|
|
112
|
+
# fmt: off
|
|
113
|
+
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02,
|
|
114
|
+
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04,
|
|
115
|
+
0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x07, 0x07, 0x08,
|
|
116
|
+
0x08, 0x09, 0x09, 0x0A, 0x0A, 0x0B, 0x0C, 0x0D, 0x0D, 0x0E, 0x0F, 0x10,
|
|
117
|
+
0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18, 0x1A, 0x1B, 0x1D, 0x1F, 0x20,
|
|
118
|
+
0x22, 0x24, 0x27, 0x29, 0x2B, 0x2E, 0x31, 0x34, 0x37, 0x3A, 0x3E, 0x41,
|
|
119
|
+
0x45, 0x49, 0x4E, 0x52, 0x57, 0x5C, 0x62, 0x68, 0x6E, 0x75, 0x7C, 0x83,
|
|
120
|
+
0x8B, 0x93, 0x9C, 0xA5, 0xAF, 0xB9, 0xC4, 0xD0, 0xDD, 0xEA, 0xF8, 0xFF,
|
|
121
|
+
# fmt: on
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# The playroutine indexes the frequency table with 7-bit note numbers,
|
|
125
|
+
# so pad to 128 entries exactly like the original player's data segment.
|
|
126
|
+
FREQ_TABLE = tuple((hi << 8) | lo for lo, hi in zip(FREQ_LO, FREQ_HI)) + (0,) * (
|
|
127
|
+
128 - MAX_NOTES
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_NOTE_NAMES = (
|
|
131
|
+
"C-",
|
|
132
|
+
"C#",
|
|
133
|
+
"D-",
|
|
134
|
+
"D#",
|
|
135
|
+
"E-",
|
|
136
|
+
"F-",
|
|
137
|
+
"F#",
|
|
138
|
+
"G-",
|
|
139
|
+
"G#",
|
|
140
|
+
"A-",
|
|
141
|
+
"A#",
|
|
142
|
+
"B-",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def note_name(note: int) -> str:
|
|
147
|
+
"""Tracker-style display name for a pattern note byte."""
|
|
148
|
+
if note == REST:
|
|
149
|
+
return "..."
|
|
150
|
+
if note == KEYOFF:
|
|
151
|
+
return "---"
|
|
152
|
+
if note == KEYON:
|
|
153
|
+
return "+++"
|
|
154
|
+
if note == ENDPATT:
|
|
155
|
+
return "END"
|
|
156
|
+
if FIRSTNOTE <= note <= LASTNOTE:
|
|
157
|
+
offset = note - FIRSTNOTE
|
|
158
|
+
return f"{_NOTE_NAMES[offset % 12]}{offset // 12}"
|
|
159
|
+
raise ValueError(f"not a note byte: {note:#04x}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def note_value(name: str) -> int:
|
|
163
|
+
"""Pattern note byte for a tracker-style name such as ``A-4``."""
|
|
164
|
+
if len(name) == 3:
|
|
165
|
+
prefix, octave = name[:2], name[2]
|
|
166
|
+
if prefix in _NOTE_NAMES and octave.isdigit() and int(octave) < 8:
|
|
167
|
+
note = FIRSTNOTE + int(octave) * 12 + _NOTE_NAMES.index(prefix)
|
|
168
|
+
if note <= LASTNOTE:
|
|
169
|
+
return note
|
|
170
|
+
raise ValueError(f"not a note name: {name!r}")
|