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.
@@ -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)
@@ -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,9 @@
1
+ """Exceptions raised by pyfuturecomposer."""
2
+
3
+
4
+ class FutureComposerError(Exception):
5
+ """Base class for all pyfuturecomposer errors."""
6
+
7
+
8
+ class SidParseError(FutureComposerError):
9
+ """A PSID/PRG image (or byte string) could not be parsed."""
@@ -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