pyfuturecomposer 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.
- pyfuturecomposer/__init__.py +34 -0
- pyfuturecomposer/audio.py +94 -0
- pyfuturecomposer/cli.py +72 -0
- pyfuturecomposer/constants.py +93 -0
- pyfuturecomposer/errors.py +9 -0
- pyfuturecomposer/model.py +37 -0
- pyfuturecomposer/player.py +648 -0
- pyfuturecomposer/reader.py +81 -0
- pyfuturecomposer/reglog.py +87 -0
- pyfuturecomposer-0.1.0.dist-info/METADATA +303 -0
- pyfuturecomposer-0.1.0.dist-info/RECORD +15 -0
- pyfuturecomposer-0.1.0.dist-info/WHEEL +5 -0
- pyfuturecomposer-0.1.0.dist-info/entry_points.txt +2 -0
- pyfuturecomposer-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyfuturecomposer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Read, play, and render Future Composer (MoN / Deenen) SID songs."""
|
|
2
|
+
|
|
3
|
+
from pyfuturecomposer.audio import render_samples, render_wav, write_wav
|
|
4
|
+
from pyfuturecomposer.errors import FutureComposerError, SidParseError
|
|
5
|
+
from pyfuturecomposer.model import Song
|
|
6
|
+
from pyfuturecomposer.player import Player, iter_frames, render_grid
|
|
7
|
+
from pyfuturecomposer.reader import parse, read
|
|
8
|
+
from pyfuturecomposer.reglog import (
|
|
9
|
+
RegWrite,
|
|
10
|
+
iter_register_writes,
|
|
11
|
+
read_reglog,
|
|
12
|
+
write_reglog,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FutureComposerError",
|
|
19
|
+
"Player",
|
|
20
|
+
"RegWrite",
|
|
21
|
+
"SidParseError",
|
|
22
|
+
"Song",
|
|
23
|
+
"__version__",
|
|
24
|
+
"iter_frames",
|
|
25
|
+
"iter_register_writes",
|
|
26
|
+
"parse",
|
|
27
|
+
"read",
|
|
28
|
+
"read_reglog",
|
|
29
|
+
"render_grid",
|
|
30
|
+
"render_samples",
|
|
31
|
+
"render_wav",
|
|
32
|
+
"write_reglog",
|
|
33
|
+
"write_wav",
|
|
34
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Render songs through an emulated SID to samples or WAV.
|
|
2
|
+
|
|
3
|
+
By default the emulated SID is `pyresidfp <https://pypi.org/project/pyresidfp/>`_
|
|
4
|
+
(install the ``audio`` extra). Any object with ``write_register(reg, value)``,
|
|
5
|
+
``clock(timedelta) -> samples`` and a ``sampling_frequency`` attribute can be
|
|
6
|
+
passed as ``device`` instead (e.g. for tests or a different emulator).
|
|
7
|
+
|
|
8
|
+
Each register write is clocked individually at the same in-frame offset the
|
|
9
|
+
register log uses, so renders line up with :mod:`pyfuturecomposer.reglog` output.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import wave
|
|
13
|
+
from array import array
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pyfuturecomposer import constants
|
|
18
|
+
from pyfuturecomposer.errors import FutureComposerError
|
|
19
|
+
from pyfuturecomposer.model import Song
|
|
20
|
+
from pyfuturecomposer.player import iter_frames
|
|
21
|
+
|
|
22
|
+
CHIP_MODELS = ("6581", "8580")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_device(model: str, sampling_frequency):
|
|
26
|
+
try:
|
|
27
|
+
from pyresidfp import SoundInterfaceDevice
|
|
28
|
+
from pyresidfp.sound_interface_device import ChipModel
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise FutureComposerError(
|
|
31
|
+
"pyresidfp is required to render audio; "
|
|
32
|
+
"install with: pip install pyfuturecomposer[audio]"
|
|
33
|
+
) from exc
|
|
34
|
+
chip = {"6581": ChipModel.MOS6581, "8580": ChipModel.MOS8580}[model]
|
|
35
|
+
if sampling_frequency:
|
|
36
|
+
return SoundInterfaceDevice(
|
|
37
|
+
model=chip, sampling_frequency=float(sampling_frequency)
|
|
38
|
+
)
|
|
39
|
+
return SoundInterfaceDevice(model=chip)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_samples(
|
|
43
|
+
song: Song,
|
|
44
|
+
seconds: float = 60.0,
|
|
45
|
+
model: str = "8580",
|
|
46
|
+
sampling_frequency=None,
|
|
47
|
+
device=None,
|
|
48
|
+
cycles_per_frame: int = constants.PAL_CYCLES_PER_FRAME,
|
|
49
|
+
clock_frequency: float = constants.PAL_CLOCK_HZ,
|
|
50
|
+
):
|
|
51
|
+
"""Render ``song`` on an emulated SID.
|
|
52
|
+
|
|
53
|
+
Returns ``(samples, sampling_frequency)`` where samples are signed 16-bit
|
|
54
|
+
mono. Rendering stops at ``seconds`` (the player loops, so a duration is
|
|
55
|
+
required).
|
|
56
|
+
"""
|
|
57
|
+
if model not in CHIP_MODELS:
|
|
58
|
+
raise FutureComposerError(f"chip model must be one of {CHIP_MODELS}")
|
|
59
|
+
if device is None:
|
|
60
|
+
device = _default_device(model, sampling_frequency)
|
|
61
|
+
write_q = constants.DEFAULT_WRITE_SPACING / clock_frequency
|
|
62
|
+
frame_seconds = cycles_per_frame / clock_frequency
|
|
63
|
+
max_frames = max(1, round(seconds / frame_seconds))
|
|
64
|
+
samples = array("h")
|
|
65
|
+
for writes in iter_frames(song, max_frames=max_frames):
|
|
66
|
+
remainder = frame_seconds
|
|
67
|
+
for reg, val in writes:
|
|
68
|
+
device.write_register(reg, val)
|
|
69
|
+
samples.extend(device.clock(timedelta(seconds=write_q)))
|
|
70
|
+
remainder -= write_q
|
|
71
|
+
if remainder > 0:
|
|
72
|
+
samples.extend(device.clock(timedelta(seconds=remainder)))
|
|
73
|
+
return samples, float(device.sampling_frequency)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_wav(dst, samples, sampling_frequency: float) -> None:
|
|
77
|
+
"""Write signed 16-bit mono samples as a WAV file."""
|
|
78
|
+
if not isinstance(samples, array):
|
|
79
|
+
samples = array("h", samples)
|
|
80
|
+
with wave.open(str(dst), "wb") as out:
|
|
81
|
+
out.setnchannels(1)
|
|
82
|
+
out.setsampwidth(2)
|
|
83
|
+
out.setframerate(round(sampling_frequency))
|
|
84
|
+
out.writeframes(samples.tobytes())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_wav(song: Song, dst, seconds: float = 60.0, **options) -> Path:
|
|
88
|
+
"""Render ``song`` to a WAV file; returns the path written.
|
|
89
|
+
|
|
90
|
+
Keyword options are those of :func:`render_samples`.
|
|
91
|
+
"""
|
|
92
|
+
samples, sampling_frequency = render_samples(song, seconds=seconds, **options)
|
|
93
|
+
write_wav(dst, samples, sampling_frequency)
|
|
94
|
+
return Path(dst)
|
pyfuturecomposer/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command line interface: song info, register logs, and WAV rendering."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from pyfuturecomposer import audio, reglog
|
|
7
|
+
from pyfuturecomposer.errors import FutureComposerError
|
|
8
|
+
from pyfuturecomposer.reader import read
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _info(args) -> None:
|
|
12
|
+
song = read(args.song)
|
|
13
|
+
print(f"name: {song.name}")
|
|
14
|
+
print(f"author: {song.author}")
|
|
15
|
+
print(f"released: {song.released}")
|
|
16
|
+
print(f"load: ${song.load:04X}")
|
|
17
|
+
print(f"init/play: ${song.init:04X} / ${song.play:04X}")
|
|
18
|
+
print(f"image bytes: {len(song.image)}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reglog(args) -> None:
|
|
22
|
+
song = read(args.song)
|
|
23
|
+
frames = round(args.seconds * 50)
|
|
24
|
+
writes = reglog.iter_register_writes(song, max_frames=frames)
|
|
25
|
+
reglog.write_reglog(writes, args.output)
|
|
26
|
+
print(f"wrote {args.output}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _wav(args) -> None:
|
|
30
|
+
song = read(args.song)
|
|
31
|
+
audio.render_wav(song, args.output, seconds=args.seconds, model=args.model)
|
|
32
|
+
print(f"wrote {args.output}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="pyfuturecomposer", description="Future Composer song tools"
|
|
38
|
+
)
|
|
39
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
info = commands.add_parser("info", help="print song metadata")
|
|
42
|
+
info.add_argument("song", help="Future Composer .sid/.prg file")
|
|
43
|
+
info.set_defaults(func=_info)
|
|
44
|
+
|
|
45
|
+
log = commands.add_parser("reglog", help="write a SID register log")
|
|
46
|
+
log.add_argument("song", help="Future Composer .sid/.prg file")
|
|
47
|
+
log.add_argument("output", help="register log file to write")
|
|
48
|
+
log.add_argument("--seconds", type=float, default=60.0)
|
|
49
|
+
log.set_defaults(func=_reglog)
|
|
50
|
+
|
|
51
|
+
wav = commands.add_parser("wav", help="render through an emulated SID")
|
|
52
|
+
wav.add_argument("song", help="Future Composer .sid/.prg file")
|
|
53
|
+
wav.add_argument("output", help="WAV file to write")
|
|
54
|
+
wav.add_argument("--seconds", type=float, default=60.0)
|
|
55
|
+
wav.add_argument("--model", choices=audio.CHIP_MODELS, default="8580")
|
|
56
|
+
wav.set_defaults(func=_wav)
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv=None) -> int:
|
|
61
|
+
"""CLI entry point; returns a process exit code."""
|
|
62
|
+
args = _parser().parse_args(argv)
|
|
63
|
+
try:
|
|
64
|
+
args.func(args)
|
|
65
|
+
except (FutureComposerError, OSError) as exc:
|
|
66
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Constants for the Future Composer (MoN / Deenen) player and song format.
|
|
2
|
+
|
|
3
|
+
Values follow the decompiled Future Composer player (``entry_1806`` of the
|
|
4
|
+
MoN/FutureComposer player, reverse-engineered from the representative tune
|
|
5
|
+
*We R Da Best (tune 2)* by Warren Pilbrough / Jade Tiger), cross-checked
|
|
6
|
+
byte-exact against the ``preframr-sidtrace`` register oracle. The player code is
|
|
7
|
+
identical across tunes of this variant; only its DATA (sequences, patterns,
|
|
8
|
+
instruments, mod tables) differs, carried inline at fixed offsets-from-load.
|
|
9
|
+
|
|
10
|
+
FC is RELOCATABLE (init = load, play = load + 6); every absolute address in the
|
|
11
|
+
disassembly is load-relative, so the constants here are OFFSETS-FROM-LOAD and the
|
|
12
|
+
player reads/writes them at ``load + offset``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# SID register map.
|
|
16
|
+
SID_REGISTERS = 25
|
|
17
|
+
VOICES = 3
|
|
18
|
+
SID_BASE = 0xD400
|
|
19
|
+
MODE_VOL_REG = 0x18
|
|
20
|
+
RES_FILT_REG = 0x17
|
|
21
|
+
FC_HI_REG = 0x16
|
|
22
|
+
# Pulse-width high registers carry only their low nibble (12-bit pulse).
|
|
23
|
+
PW_HI_REGS = (0x03, 0x0A, 0x11)
|
|
24
|
+
|
|
25
|
+
# C64 timing. A PAL frame is 312 rasterlines x 63 cycles.
|
|
26
|
+
PAL_CLOCK_HZ = 985248
|
|
27
|
+
PAL_CYCLES_PER_FRAME = 19656
|
|
28
|
+
NTSC_CLOCK_HZ = 1022727
|
|
29
|
+
NTSC_CYCLES_PER_FRAME = 17095
|
|
30
|
+
|
|
31
|
+
# Cycles between consecutive register writes within one frame (approximates the
|
|
32
|
+
# store instructions of the 6502 playroutine).
|
|
33
|
+
DEFAULT_WRITE_SPACING = 16
|
|
34
|
+
|
|
35
|
+
# Standard Future Composer entry points (the PSID header normally matches).
|
|
36
|
+
DEFAULT_INIT_OFFSET = 0 # init = load
|
|
37
|
+
DEFAULT_PLAY_OFFSET = 6 # play = load + 6
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Player-code / inline-table OFFSETS-FROM-LOAD. The player binary is identical
|
|
41
|
+
# across tunes of this variant, so each table lives at a fixed offset from the
|
|
42
|
+
# load address (relocation-safe -- the same offsets work for any load address).
|
|
43
|
+
# Derived directly from the disassembly (subtract $1800 from the absolute addr).
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
OFF_FREQ_LO = 0x5AB # $1dab note freq-lo table (also the slide-target base)
|
|
46
|
+
OFF_FREQ_HI = 0x60B # $1e0b note freq-hi table
|
|
47
|
+
OFF_SEQ_LO = 0x6EC # $1eec per-voice sequence pointer table, low bytes
|
|
48
|
+
OFF_SEQ_HI = 0x6EF # $1eef per-voice sequence pointer table, high bytes
|
|
49
|
+
OFF_PAT_PTR = 0x6F8 # $1ef8 interleaved pattern pointer table (id*2)
|
|
50
|
+
OFF_INSTR = 0x721 # $1f21 instrument record array (8-byte records)
|
|
51
|
+
OFF_VOICE_BASE = 0x6E9 # $1ee9 per-voice SID register base (0, 7, 14)
|
|
52
|
+
OFF_SPEED = 0x6E8 # $1ee8 song speed (tempo divider reload)
|
|
53
|
+
OFF_PW_STEP = 0x6DC # $1edc pulse-width sweep step table
|
|
54
|
+
OFF_FILTER = 0x6D0 # $1ed0 filter-cutoff walk table
|
|
55
|
+
OFF_WAVE_ARP = 0x679 # $1e79 ($02ac & 0x40) wave/arp ctrl table
|
|
56
|
+
OFF_ARP_DELAY = 0x6CD # $1ecd arp-delay table
|
|
57
|
+
OFF_WAVE_PROG_PTR = 0x685 # $1e85 wave-program pointer table
|
|
58
|
+
OFF_WAVE_PROG_PITCH = 0x68D # $1e8d wave-program pitch table
|
|
59
|
+
OFF_WAVE_PROG_CTRL = 0x69D # $1e9d wave-program control table
|
|
60
|
+
OFF_MOD = 0x726 # $1f26 per-instrument modulation-control bytes
|
|
61
|
+
OFF_SLIDE_LO = 0x2DB # $1adb self-modified slide-lo store
|
|
62
|
+
OFF_PW_CARRY = 0x3B7 # $1bb7 pulse-width-lo carry immediate
|
|
63
|
+
OFF_FILT_LO = 0x6CE # $1ece filter cutoff-lo store
|
|
64
|
+
OFF_FILT_HI = 0x6CF # $1ecf filter cutoff-hi store
|
|
65
|
+
OFF_WAVE_PROG_SELF = 0x492 # $1c92 self-modified wave-program pointer slots
|
|
66
|
+
|
|
67
|
+
# Instrument record (8 bytes) field offsets.
|
|
68
|
+
INSTR_RECORD_SIZE = 8
|
|
69
|
+
INSTR_PULSE_CTRL = 0
|
|
70
|
+
INSTR_WAVEFORM = 1
|
|
71
|
+
INSTR_AD = 2
|
|
72
|
+
INSTR_SR = 3
|
|
73
|
+
INSTR_AUX = 4
|
|
74
|
+
INSTR_VIB_CTRL = 5
|
|
75
|
+
INSTR_PW_CTRL = 6
|
|
76
|
+
INSTR_MASTER_CTRL = 7
|
|
77
|
+
|
|
78
|
+
# Pattern / sequence opcode grammar boundaries.
|
|
79
|
+
NOTE_MAX = 0x7F # < 0x80: a note (indexes the freq tables)
|
|
80
|
+
DUR_MIN = 0x80 # 0x80..0xBF: set note duration (dur = value & 0x3F)
|
|
81
|
+
INSTR_MIN = 0xC0 # 0xC0..0xDF: instrument select (id = value & 0x1F)
|
|
82
|
+
PORTA_MIN = 0xE0 # 0xE0..0xEF: portamento/slide command (+1 arg byte)
|
|
83
|
+
ROWMARK_MIN = 0xF0 # 0xF0..0xFF: row, no new instrument; next byte is the note
|
|
84
|
+
PATTERN_END = 0xFF # pattern terminator (after a note)
|
|
85
|
+
SEQ_END = 0xFE # sequence end-of-song
|
|
86
|
+
SEQ_LOOP = 0xFF # sequence loop-this-voice
|
|
87
|
+
SEQ_TRANSPOSE = 0x80 # bit7 set in a sequence byte = transpose (& 0x1F)
|
|
88
|
+
SEQ_REPEAT = 0x40 # bit6 set = repeat-count (& 0x3F)
|
|
89
|
+
INSTR_MASK = 0x1F
|
|
90
|
+
DUR_MASK = 0x3F
|
|
91
|
+
|
|
92
|
+
# DAT_02c9 filter-routing accumulator base, seeded by SUB_1d65 at init.
|
|
93
|
+
FILTER_ROUTE_SEED = 0xB0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The Future Composer song model.
|
|
2
|
+
|
|
3
|
+
Future Composer carries its authored data (sequences, patterns, instruments and
|
|
4
|
+
the modulation tables) INLINE in the module image at fixed offsets-from-load, and
|
|
5
|
+
the player walks that image directly every frame. A :class:`Song` therefore holds
|
|
6
|
+
the loaded image bytes + the load address + the SID header metadata; the player
|
|
7
|
+
(:mod:`pyfuturecomposer.player`) reads the inline tables from the image as the
|
|
8
|
+
6502 player does, so the model stays a faithful, minimal container.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Song:
|
|
16
|
+
"""A parsed Future Composer tune.
|
|
17
|
+
|
|
18
|
+
``image`` is the raw C64 image (player code + inline song data); ``load`` is
|
|
19
|
+
its load address; ``init`` / ``play`` are the player entry points (init =
|
|
20
|
+
load, play = load + 6 for FC); ``name`` / ``author`` / ``released`` are the
|
|
21
|
+
PSID header strings (empty for a bare ``.prg``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
image: bytes
|
|
25
|
+
load: int
|
|
26
|
+
init: int
|
|
27
|
+
play: int
|
|
28
|
+
name: str = ""
|
|
29
|
+
author: str = ""
|
|
30
|
+
released: str = ""
|
|
31
|
+
|
|
32
|
+
def byte(self, addr: int) -> int:
|
|
33
|
+
"""Read the image byte at absolute C64 ``addr`` (0 outside the image)."""
|
|
34
|
+
idx = addr - self.load
|
|
35
|
+
if 0 <= idx < len(self.image):
|
|
36
|
+
return self.image[idx]
|
|
37
|
+
return 0
|