skoolkit 10.0__cp314-cp314-win_amd64.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.
Files changed (87) hide show
  1. skoolkit/__init__.py +239 -0
  2. skoolkit/audio.py +186 -0
  3. skoolkit/ay.py +251 -0
  4. skoolkit/basic.py +350 -0
  5. skoolkit/bin2sna.py +127 -0
  6. skoolkit/bin2tap.py +280 -0
  7. skoolkit/ccmiosimulator.cp314-win_amd64.pyd +0 -0
  8. skoolkit/cmiosimulator.py +1510 -0
  9. skoolkit/comment.py +648 -0
  10. skoolkit/components.py +69 -0
  11. skoolkit/config.py +216 -0
  12. skoolkit/csimulator.cp314-win_amd64.pyd +0 -0
  13. skoolkit/ctlparser.py +421 -0
  14. skoolkit/defaults.py +722 -0
  15. skoolkit/disassembler.py +1151 -0
  16. skoolkit/graphics.py +428 -0
  17. skoolkit/image.py +332 -0
  18. skoolkit/kbtracer.py +426 -0
  19. skoolkit/loadsample.py +1213 -0
  20. skoolkit/loadtracer.py +564 -0
  21. skoolkit/opcodes.py +946 -0
  22. skoolkit/pagingtracer.py +66 -0
  23. skoolkit/pngwriter.py +504 -0
  24. skoolkit/refparser.py +190 -0
  25. skoolkit/resources/128-0.rom +0 -0
  26. skoolkit/resources/128-1.rom +0 -0
  27. skoolkit/resources/48.rom +0 -0
  28. skoolkit/resources/plus2-0.rom +0 -0
  29. skoolkit/resources/plus2-1.rom +0 -0
  30. skoolkit/resources/skoolkit-dark.css +67 -0
  31. skoolkit/resources/skoolkit-green.css +67 -0
  32. skoolkit/resources/skoolkit-plum.css +67 -0
  33. skoolkit/resources/skoolkit-wide.css +6 -0
  34. skoolkit/resources/skoolkit.css +212 -0
  35. skoolkit/rst.py +49 -0
  36. skoolkit/rzxinfo.py +182 -0
  37. skoolkit/rzxplay.py +449 -0
  38. skoolkit/screen.py +202 -0
  39. skoolkit/simtables.py +281 -0
  40. skoolkit/simulator.py +2841 -0
  41. skoolkit/simutils.py +200 -0
  42. skoolkit/skool2asm.py +143 -0
  43. skoolkit/skool2bin.py +352 -0
  44. skoolkit/skool2ctl.py +79 -0
  45. skoolkit/skool2html.py +410 -0
  46. skoolkit/skoolasm.py +631 -0
  47. skoolkit/skoolctl.py +727 -0
  48. skoolkit/skoolhtml.py +1390 -0
  49. skoolkit/skoolmacro.py +1653 -0
  50. skoolkit/skoolparser.py +974 -0
  51. skoolkit/skoolutils.py +714 -0
  52. skoolkit/sna2ctl.py +91 -0
  53. skoolkit/sna2img.py +174 -0
  54. skoolkit/sna2skool.py +114 -0
  55. skoolkit/snactl.py +501 -0
  56. skoolkit/snapinfo.py +525 -0
  57. skoolkit/snapmod.py +71 -0
  58. skoolkit/snapshot.py +889 -0
  59. skoolkit/snaskool.py +642 -0
  60. skoolkit/tap2sna.py +1098 -0
  61. skoolkit/tape.py +787 -0
  62. skoolkit/tapinfo.py +142 -0
  63. skoolkit/textutils.py +64 -0
  64. skoolkit/trace.py +452 -0
  65. skoolkit/traceutils.py +1945 -0
  66. skoolkit/z80.py +1514 -0
  67. skoolkit-10.0.data/scripts/bin2sna.py +26 -0
  68. skoolkit-10.0.data/scripts/bin2tap.py +26 -0
  69. skoolkit-10.0.data/scripts/rzxinfo.py +26 -0
  70. skoolkit-10.0.data/scripts/rzxplay.py +26 -0
  71. skoolkit-10.0.data/scripts/skool2asm.py +26 -0
  72. skoolkit-10.0.data/scripts/skool2bin.py +26 -0
  73. skoolkit-10.0.data/scripts/skool2ctl.py +26 -0
  74. skoolkit-10.0.data/scripts/skool2html.py +26 -0
  75. skoolkit-10.0.data/scripts/sna2ctl.py +26 -0
  76. skoolkit-10.0.data/scripts/sna2img.py +26 -0
  77. skoolkit-10.0.data/scripts/sna2skool.py +26 -0
  78. skoolkit-10.0.data/scripts/snapinfo.py +26 -0
  79. skoolkit-10.0.data/scripts/snapmod.py +26 -0
  80. skoolkit-10.0.data/scripts/tap2sna.py +26 -0
  81. skoolkit-10.0.data/scripts/tapinfo.py +26 -0
  82. skoolkit-10.0.data/scripts/trace.py +26 -0
  83. skoolkit-10.0.dist-info/METADATA +145 -0
  84. skoolkit-10.0.dist-info/RECORD +87 -0
  85. skoolkit-10.0.dist-info/WHEEL +5 -0
  86. skoolkit-10.0.dist-info/licenses/COPYING +674 -0
  87. skoolkit-10.0.dist-info/top_level.txt +1 -0
skoolkit/__init__.py ADDED
@@ -0,0 +1,239 @@
1
+ # © 2011-2026 Richard Dymond (rjdymond@gmail.com)
2
+ #
3
+ # This file is part of SkoolKit.
4
+ #
5
+ # SkoolKit is free software: you can redistribute it and/or modify it under the
6
+ # terms of the GNU General Public License as published by the Free Software
7
+ # Foundation, either version 3 of the License, or (at your option) any later
8
+ # version.
9
+ #
10
+ # SkoolKit is distributed in the hope that it will be useful, but WITHOUT ANY
11
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12
+ # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along with
15
+ # SkoolKit. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import argparse
18
+ import html
19
+ import sys
20
+ import os
21
+ import posixpath
22
+ import textwrap
23
+ import importlib
24
+
25
+ try:
26
+ from skoolkit.csimulator import CSimulator
27
+ except ImportError: # pragma: C no cover
28
+ CSimulator = None
29
+
30
+ try:
31
+ from skoolkit.ccmiosimulator import CCMIOSimulator
32
+ except ImportError: # pragma: C no cover
33
+ CCMIOSimulator = None
34
+
35
+ VERSION = '10.0'
36
+ PACKAGE_DIR = os.path.dirname(__file__)
37
+
38
+ BASE_10 = 10
39
+ BASE_16 = 16
40
+
41
+ CASE_LOWER = 1
42
+ CASE_UPPER = 2
43
+
44
+ WRAPPER = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False)
45
+
46
+ AE_CHARS = frozenset(' !=+-*/<>&|^%$ABCDEFabcdef0123456789()')
47
+
48
+ ROM48 = os.path.join(PACKAGE_DIR, 'resources', '48.rom')
49
+
50
+ ROM128 = (
51
+ os.path.join(PACKAGE_DIR, 'resources', '128-0.rom'),
52
+ os.path.join(PACKAGE_DIR, 'resources', '128-1.rom')
53
+ )
54
+
55
+ ROM_PLUS2 = (
56
+ os.path.join(PACKAGE_DIR, 'resources', 'plus2-0.rom'),
57
+ os.path.join(PACKAGE_DIR, 'resources', 'plus2-1.rom')
58
+ )
59
+
60
+ def error(msg):
61
+ sys.stderr.write('ERROR: {0}\n'.format(msg))
62
+ sys.exit(1)
63
+
64
+ def warn(msg):
65
+ sys.stderr.write('WARNING: {0}\n'.format(msg))
66
+
67
+ def info(msg):
68
+ sys.stderr.write('{0}\n'.format(msg))
69
+
70
+ def show_package_dir():
71
+ write_line(PACKAGE_DIR)
72
+ sys.exit(0)
73
+
74
+ def write(text):
75
+ sys.stdout.write(text)
76
+ sys.stdout.flush()
77
+
78
+ def write_line(text):
79
+ sys.stdout.write('{0}\n'.format(text))
80
+
81
+ def write_text(text):
82
+ sys.stdout.write(text)
83
+
84
+ def wrap(text, width):
85
+ WRAPPER.width = width
86
+ return WRAPPER.wrap(text)
87
+
88
+ def get_int_param(num_str, accept0x=False):
89
+ try:
90
+ return int(num_str)
91
+ except ValueError:
92
+ pass
93
+ if num_str.startswith('$'):
94
+ return int(num_str[1:], 16)
95
+ if accept0x and num_str.startswith('0x'):
96
+ return int(num_str[2:], 16)
97
+ if num_str.startswith('%'):
98
+ return int(num_str[1:], 2)
99
+ if num_str.startswith('"') and num_str.endswith('"'):
100
+ try:
101
+ if num_str.startswith('"\\'):
102
+ return ord(num_str[2:-1])
103
+ return ord(num_str[1:-1])
104
+ except TypeError:
105
+ pass
106
+ raise ValueError
107
+
108
+ def parse_int(num_str, default=None):
109
+ try:
110
+ return get_int_param(num_str)
111
+ except ValueError:
112
+ return default
113
+
114
+ def evaluate(param, safe=False):
115
+ try:
116
+ return get_int_param(param)
117
+ except ValueError:
118
+ pass
119
+ param = html.unescape(param)
120
+ if safe or set(param) <= AE_CHARS:
121
+ try:
122
+ return int(eval(param.replace('$', '0x').replace('/', '//').replace('&&', ' and ').replace('||', ' or ')))
123
+ except:
124
+ pass
125
+ raise ValueError
126
+
127
+ def get_address_format(hexadecimal=False, lower=False):
128
+ if hexadecimal:
129
+ if lower:
130
+ return '${:04x}'
131
+ return '${:04X}'
132
+ return '{:05d}'
133
+
134
+ def get_object(name_spec, default_path=''):
135
+ path, sep, name = name_spec.rpartition(':')
136
+ if sep:
137
+ sys.path.insert(0, os.path.expanduser(path) or default_path)
138
+ try:
139
+ return importlib.import_module(name)
140
+ except ImportError:
141
+ pass
142
+ mod_name, sep, attr_name = name.rpartition('.')
143
+ try:
144
+ m = importlib.import_module(mod_name or attr_name)
145
+ except ImportError as e:
146
+ raise SkoolKitError("Failed to import object {}: {}".format(name, e.args[0]))
147
+ try:
148
+ return getattr(m, attr_name)
149
+ except AttributeError:
150
+ raise SkoolKitError("No object named '{}' in module '{}'".format(attr_name, mod_name))
151
+
152
+ def find_file(fname, search_dirs=('',)):
153
+ for f in [os.path.join(d, fname) for d in search_dirs]:
154
+ if os.path.isfile(f):
155
+ return f
156
+
157
+ def open_file(fname, mode='r'):
158
+ if fname == '-':
159
+ if 'w' in mode:
160
+ return sys.stdout.buffer
161
+ return sys.stdin
162
+ try:
163
+ if 'b' in mode:
164
+ return open(fname, mode)
165
+ return open(fname, mode, encoding='utf-8')
166
+ except IOError as e:
167
+ if e.errno == 2:
168
+ raise SkoolKitError('{0}: file not found'.format(fname))
169
+ raise
170
+ except TypeError:
171
+ # Assume this is already a file-like object
172
+ return fname
173
+
174
+ def read_bin_file(fname, size=-1):
175
+ if fname == '-':
176
+ return sys.stdin.buffer.read(size)
177
+ try:
178
+ with open(fname, 'rb') as f:
179
+ return f.read(size)
180
+ except IOError as e:
181
+ if e.errno == 2:
182
+ raise SkoolKitError('{0}: file not found'.format(fname))
183
+ raise
184
+
185
+ def format_template(template__, name__, **fields):
186
+ try:
187
+ return template__.format(**fields)
188
+ except ValueError as e:
189
+ raise SkoolKitError('Failed to format {} template: {}'.format(name__, e.args[0]))
190
+ except KeyError as e:
191
+ raise SkoolKitError("Unknown field '{}' in {} template".format(e.args[0], name__))
192
+
193
+ def normpath(*paths):
194
+ return posixpath.normpath(posixpath.join(*[p.replace('\\', '/') for p in paths]))
195
+
196
+ def get_word(data, index):
197
+ return data[index] + 256 * data[index + 1]
198
+
199
+ def get_word3(data, index):
200
+ return get_word(data, index) + 65536 * data[index + 2]
201
+
202
+ def as_dword(num):
203
+ return (num % 256, (num >> 8) % 256, (num >> 16) % 256, (num >> 24) % 256)
204
+
205
+ def get_dword(data, index):
206
+ return get_word3(data, index) + 16777216 * data[index + 3]
207
+
208
+ def integer(arg):
209
+ try:
210
+ s_arg = arg.strip()
211
+ if s_arg.startswith('0x'):
212
+ return int(s_arg[2:], 16)
213
+ return int(s_arg)
214
+ except ValueError:
215
+ raise argparse.ArgumentTypeError("invalid integer: '{}'".format(arg))
216
+
217
+ def eval_variable(name, value):
218
+ if name.endswith('$'):
219
+ return value
220
+ return evaluate(value)
221
+
222
+ def variable(arg):
223
+ name, sep, value = arg.partition('=')
224
+ if name and sep:
225
+ try:
226
+ return name, eval_variable(name, value)
227
+ except ValueError:
228
+ raise argparse.ArgumentTypeError("invalid arithmetic expression: '{}'".format(arg))
229
+ elif name:
230
+ raise argparse.ArgumentTypeError("missing variable value: '{}'".format(arg))
231
+ else:
232
+ raise argparse.ArgumentTypeError("missing variable name: '{}'".format(arg))
233
+
234
+ class SkoolKitError(Exception):
235
+ pass
236
+
237
+ # API (ControlDirectiveComposer)
238
+ class SkoolParsingError(SkoolKitError):
239
+ """Raised when an error occurs while parsing a skool file."""
skoolkit/audio.py ADDED
@@ -0,0 +1,186 @@
1
+ # © 2013, 2014, 2022-2024, 2026 Richard Dymond (rjdymond@gmail.com)
2
+ #
3
+ # This file is part of SkoolKit.
4
+ #
5
+ # SkoolKit is free software: you can redistribute it and/or modify it under the
6
+ # terms of the GNU General Public License as published by the Free Software
7
+ # Foundation, either version 3 of the License, or (at your option) any later
8
+ # version.
9
+ #
10
+ # SkoolKit is distributed in the hope that it will be useful, but WITHOUT ANY
11
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12
+ # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along with
15
+ # SkoolKit. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from math import ceil
18
+ from struct import pack
19
+
20
+ from skoolkit.simutils import CLOCK_SPEEDS, CONTENTION_INTERVALS, FRAME_DURATIONS
21
+
22
+ CLOCK_SPEED = 'ClockSpeed'
23
+ CONTENTION_BEGIN = 'ContentionBegin'
24
+ CONTENTION_END = 'ContentionEnd'
25
+ CONTENTION_FACTOR = 'ContentionFactor'
26
+ FRAME_DURATION = 'FrameDuration'
27
+ INTERRUPT_DELAY = 'InterruptDelay'
28
+ SAMPLE_RATE = 'SampleRate'
29
+
30
+ def moving_average_filter(delays, options, volume=1):
31
+ sample_delay = options[CLOCK_SPEED] / options[SAMPLE_RATE]
32
+ s0, s1 = 0, sample_delay
33
+ t, t1 = 0, ceil(s1)
34
+ bit = bits = 0
35
+ samples = []
36
+ for d in delays:
37
+ while True:
38
+ if t + d < t1:
39
+ if bit:
40
+ bits += d
41
+ t += d
42
+ break
43
+ i = t1 - t
44
+ if bit:
45
+ bits += i
46
+ d -= i
47
+ samples.append(volume * bits / (t1 - s0))
48
+ s0 = t = t1
49
+ s1 += sample_delay
50
+ t1 = ceil(s1)
51
+ bits = 0
52
+ bit = 1 - bit
53
+ return samples
54
+
55
+ def write_wav(audio_file, samples, sample_rate, channels=1):
56
+ bits_per_sample = 16
57
+ bytes_per_sample = (bits_per_sample // 8) * channels
58
+ byte_rate = bytes_per_sample * sample_rate
59
+ data_length = bytes_per_sample * len(samples) // channels
60
+ header = bytearray()
61
+ header.extend(b'RIFF')
62
+ header.extend(pack('<I', 36 + data_length))
63
+ header.extend(b'WAVEfmt ')
64
+ header.extend(pack('<IH', 16, 1)) # length of fmt chunk, format (PCM)
65
+ header.extend(pack('<HIIHH', channels, sample_rate, byte_rate, bytes_per_sample, bits_per_sample))
66
+ header.extend(b'data')
67
+ header.extend(pack('<I', data_length))
68
+ audio_file.write(header)
69
+ for sample in samples:
70
+ audio_file.write(pack('<h', round(sample * 0xFFFF) - 0x8000))
71
+
72
+ class AudioWriter:
73
+ """
74
+ Initialise the audio writer.
75
+
76
+ :param config: A dictionary constructed from the contents of the
77
+ :ref:`ref-AudioWriter` section of the ref file.
78
+ """
79
+ # Component API
80
+ def __init__(self, config=None):
81
+ self.options = ({
82
+ CLOCK_SPEED: CLOCK_SPEEDS[0],
83
+ CONTENTION_BEGIN: CONTENTION_INTERVALS[0][0],
84
+ CONTENTION_END: CONTENTION_INTERVALS[0][1],
85
+ CONTENTION_FACTOR: 51,
86
+ FRAME_DURATION: FRAME_DURATIONS[0],
87
+ INTERRUPT_DELAY: (895,),
88
+ SAMPLE_RATE: 44100
89
+ }, {
90
+ CLOCK_SPEED: CLOCK_SPEEDS[1],
91
+ CONTENTION_BEGIN: CONTENTION_INTERVALS[1][0],
92
+ CONTENTION_END: CONTENTION_INTERVALS[1][1],
93
+ CONTENTION_FACTOR: 51,
94
+ FRAME_DURATION: FRAME_DURATIONS[1],
95
+ INTERRUPT_DELAY: (1385, 1565),
96
+ SAMPLE_RATE: 44100
97
+ })
98
+ if config:
99
+ for k, v in config.items():
100
+ try:
101
+ if k == INTERRUPT_DELAY:
102
+ self.options[0][k] = self.options[1][k] = tuple(int(n) for n in v.split(','))
103
+ else:
104
+ self.options[0][k] = self.options[1][k] = int(v)
105
+ except ValueError:
106
+ pass
107
+
108
+ # Component API
109
+ def write_audio(self, audio_file, delays, contention=False, interrupts=False, offset=0, is128k=False):
110
+ """
111
+ Write an audio file.
112
+
113
+ :param audio_file: The file object to write the audio to.
114
+ :param delays: A sequence of integers representing the intervals (in
115
+ T-states) between speaker flips.
116
+ :param contention: Whether to simulate additional delays due to memory
117
+ and I/O contention.
118
+ :param interrupts: Whether to simulate additional delays due to
119
+ interrupts.
120
+ :param offset: The offset (in T-states) of the first speaker flip from
121
+ the start of the frame. Used when simulating delays due
122
+ to contention and interrupts.
123
+ :param is128k: Whether to use 128K timings.
124
+ """
125
+ options = self.options[is128k]
126
+ if contention or interrupts:
127
+ self._add_contention(delays, contention, interrupts, offset, options)
128
+ samples = moving_average_filter(delays, options)
129
+ write_wav(audio_file, samples, options[SAMPLE_RATE])
130
+
131
+ # Component API
132
+ def formats(self):
133
+ """
134
+ Return a sequence of filename extensions (including the '.')
135
+ corresponding to the audio formats supported by this writer.
136
+ """
137
+ return ('.wav',)
138
+
139
+ def _add_contention(self, delays, contention, interrupts, cycle, options):
140
+ c_begin, c_end = options[CONTENTION_BEGIN], options[CONTENTION_END]
141
+ c_factor = 1 + options[CONTENTION_FACTOR] / 100
142
+ i_delays = options[INTERRUPT_DELAY]
143
+ i_delays_idx = 0
144
+ f_duration = options[FRAME_DURATION]
145
+
146
+ for i in range(len(delays)):
147
+ d_offset = 0
148
+ while 1:
149
+ if interrupts and cycle == 0:
150
+ cycle = i_delays[i_delays_idx]
151
+ if i:
152
+ delays[i] += i_delays[i_delays_idx]
153
+ d_offset += i_delays[i_delays_idx]
154
+ i_delays_idx = (i_delays_idx + 1) % len(i_delays)
155
+ d_remaining = delays[i] - d_offset
156
+ if contention and cycle < c_end:
157
+ if cycle < c_begin:
158
+ gap = c_begin - cycle
159
+ if d_offset + gap >= delays[i]:
160
+ # Delay ends before next contended interval starts
161
+ cycle += d_remaining
162
+ break
163
+ # Delay crosses into contended interval
164
+ d_offset += gap
165
+ cycle = c_begin
166
+ else:
167
+ c_period = c_end - cycle
168
+ d_rem_contended = int(d_remaining * c_factor)
169
+ if d_rem_contended < c_period:
170
+ # Delay ends within contended interval
171
+ delays[i] = d_offset + d_rem_contended
172
+ cycle += d_rem_contended
173
+ break
174
+ # Delay crosses into uncontended interval
175
+ cd_cycles = int(c_period / c_factor)
176
+ delays[i] += c_period - cd_cycles
177
+ d_offset += cd_cycles
178
+ cycle = c_end
179
+ else:
180
+ if d_remaining < f_duration - cycle:
181
+ # Delay ends before frame boundary
182
+ cycle += d_remaining
183
+ break
184
+ # Delay crosses frame boundary
185
+ d_offset += f_duration - cycle
186
+ cycle = 0
skoolkit/ay.py ADDED
@@ -0,0 +1,251 @@
1
+ # © 2026 Richard Dymond (rjdymond@gmail.com)
2
+ #
3
+ # This file is part of SkoolKit.
4
+ #
5
+ # SkoolKit is free software: you can redistribute it and/or modify it under the
6
+ # terms of the GNU General Public License as published by the Free Software
7
+ # Foundation, either version 3 of the License, or (at your option) any later
8
+ # version.
9
+ #
10
+ # SkoolKit is distributed in the hope that it will be useful, but WITHOUT ANY
11
+ # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12
+ # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along with
15
+ # SkoolKit. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from skoolkit.audio import (CLOCK_SPEED, FRAME_DURATION, SAMPLE_RATE,
18
+ moving_average_filter, write_wav)
19
+ from skoolkit.simutils import CLOCK_SPEEDS, FRAME_DURATIONS
20
+
21
+ AY_CLOCK_RATE = 1773400
22
+ AY_DAC_TABLE = tuple(v / 0xFFFF for v in (
23
+ 0x0000, 0x0385, 0x053D, 0x0770, 0x0AD7, 0x0FD5, 0x15B0, 0x230C,
24
+ 0x2B4C, 0x43C1, 0x5A4B, 0x732F, 0x9204, 0xAFF1, 0xD921, 0xFFFF
25
+ ))
26
+
27
+ class Channel:
28
+ def __init__(self, pan):
29
+ self.volume = 0
30
+ self.tone = 0
31
+ self.tone_counter = 0
32
+ self.tone_period = 1
33
+ self.t_off = 0
34
+ self.n_off = 0
35
+ self.e_on = 0
36
+ self.pan_left = 1 - pan
37
+ self.pan_right = pan
38
+
39
+ class Interpolator:
40
+ def __init__(self):
41
+ self.c = [0] * 4
42
+ self.y = [0] * 4
43
+
44
+ class AY:
45
+ def __init__(self, sample_rate):
46
+ self.interpolator_left = Interpolator()
47
+ self.interpolator_right = Interpolator()
48
+ self.x = 0
49
+ self.step = AY_CLOCK_RATE / (sample_rate * 64)
50
+ self.noise = 1
51
+ self.noise_counter = 0
52
+ self.noise_period = 0
53
+ self.left = 0
54
+ self.right = 0
55
+ self.envelope = 0
56
+ self.envelope_shape = 0
57
+ self.envelope_counter = 0
58
+ self.envelope_segment = 0
59
+ self.envelope_period = 1
60
+ self.sample_rate = sample_rate
61
+ slide_down = (self.slide_down, 31)
62
+ slide_up = (self.slide_up, 0)
63
+ hold_bottom = (self.hold, 0)
64
+ hold_top = (self.hold, 31)
65
+ self.envelopes = (
66
+ (slide_down, hold_bottom),
67
+ (slide_down, hold_bottom),
68
+ (slide_down, hold_bottom),
69
+ (slide_down, hold_bottom),
70
+ (slide_up, hold_bottom),
71
+ (slide_up, hold_bottom),
72
+ (slide_up, hold_bottom),
73
+ (slide_up, hold_bottom),
74
+ (slide_down, slide_down),
75
+ (slide_down, hold_bottom),
76
+ (slide_down, slide_up),
77
+ (slide_down, hold_top),
78
+ (slide_up, slide_up),
79
+ (slide_up, hold_top),
80
+ (slide_up, slide_down),
81
+ (slide_up, hold_bottom)
82
+ )
83
+ self.channels = (Channel(0.5), Channel(0.5), Channel(0.5))
84
+ self.update_state([0] * 16)
85
+
86
+ def update_mixer(self):
87
+ self.noise_counter += 1
88
+ if self.noise_counter >= self.noise_period * 2:
89
+ self.noise_counter = 0
90
+ self.noise = (self.noise // 2) | (((self.noise ^ (self.noise // 8)) & 1) * 65536)
91
+ noise = self.noise & 1
92
+
93
+ self.envelope_counter += 1
94
+ if self.envelope_counter >= self.envelope_period:
95
+ self.envelope_counter = 0
96
+ self.envelopes[self.envelope_shape][self.envelope_segment][0]()
97
+
98
+ self.left = 0
99
+ self.right = 0
100
+
101
+ for channel in self.channels:
102
+ channel.tone_counter += 1
103
+ if channel.tone_counter >= channel.tone_period:
104
+ channel.tone_counter = 0
105
+ channel.tone ^= 1
106
+ out = (channel.tone | channel.t_off) & (noise | channel.n_off)
107
+ out *= self.envelope // 2 if channel.e_on else channel.volume
108
+ self.left += AY_DAC_TABLE[out] * channel.pan_left
109
+ self.right += AY_DAC_TABLE[out] * channel.pan_right
110
+
111
+ def update_state(self, r):
112
+ for i, channel in enumerate(self.channels):
113
+ channel.tone_period = ((r[2 * i] + 256 * r[2 * i + 1]) & 0x0FFF) or 1
114
+ channel.t_off = (r[7] >> i) & 1
115
+ channel.n_off = (r[7] >> (i + 3)) & 1
116
+ channel.e_on = r[8 + i] & 16
117
+ channel.volume = r[8 + i] % 16
118
+
119
+ self.noise_period = (r[6] & 0x1F) or 1
120
+ self.envelope_period = (r[11] + 256 * r[12]) or 1
121
+
122
+ if r[13] >= 0:
123
+ self.envelope_shape = r[13] % 16
124
+ self.envelope_counter = 0
125
+ self.envelope_segment = 0
126
+ self.envelope = self.envelopes[self.envelope_shape][0][1]
127
+ r[13] = -1
128
+
129
+ def frames(self, ay_log, ay_res):
130
+ ay = [0] * 16
131
+ frame = -1
132
+ t0 = ay_log[0][0]
133
+ for tstates, reg, value in ay_log:
134
+ if frame >= 0:
135
+ nframe = tstates // ay_res
136
+ while nframe > frame:
137
+ yield ay
138
+ nframe -= 1
139
+ frame = tstates // ay_res
140
+ ay[reg] = value
141
+
142
+ def slide_up(self):
143
+ self.envelope += 1
144
+ if self.envelope > 31:
145
+ self.envelope_segment ^= 1
146
+ self.envelope = self.envelopes[self.envelope_shape][self.envelope_segment][1]
147
+
148
+ def slide_down(self):
149
+ self.envelope -= 1
150
+ if self.envelope < 0:
151
+ self.envelope_segment ^= 1
152
+ self.envelope = self.envelopes[self.envelope_shape][self.envelope_segment][1]
153
+
154
+ def hold(self):
155
+ return
156
+
157
+ def render(self, ay_log, frame_duration, ay_res, volume):
158
+ samples = []
159
+ frame_rate = 50 * frame_duration / ay_res
160
+ fstep = frame_rate / self.sample_rate
161
+ fcounter = 1
162
+ frames = self.frames(ay_log, ay_res)
163
+ while True:
164
+ fcounter += fstep
165
+ while fcounter >= 1:
166
+ # Load next frame of data to AY registers
167
+ fcounter -= 1
168
+ frame = next(frames, None)
169
+ if frame:
170
+ self.update_state(frame)
171
+ else:
172
+ break
173
+ if frame is None:
174
+ break
175
+
176
+ # Generate and store next sample
177
+ for i in range(8):
178
+ self.x += self.step
179
+ if self.x >= 1:
180
+ self.x -= 1
181
+ self.update_mixer()
182
+ samples.append(self.left * volume)
183
+ samples.append(self.right * volume)
184
+
185
+ return samples
186
+
187
+ class Options:
188
+ def __init__(self, volume=100, ay_res=None, beeper=False):
189
+ self.volume = volume
190
+ self.ay_res = ay_res
191
+ self.beeper = beeper
192
+
193
+ class AYAudioWriter:
194
+ def __init__(self, config=None):
195
+ self.options = {
196
+ CLOCK_SPEED: CLOCK_SPEEDS[1],
197
+ FRAME_DURATION: FRAME_DURATIONS[1],
198
+ SAMPLE_RATE: 44100
199
+ }
200
+ if config:
201
+ for k, v in config.items():
202
+ try:
203
+ self.options[k] = int(v)
204
+ except ValueError:
205
+ pass
206
+
207
+ def write_audio(self, audio_file, audio_log, options):
208
+ volume = min(max(0, options.volume), 100) / 100
209
+ ay_volume = volume * 2 / 3
210
+ ay_log, beeper_log = self._parse_log(audio_log)
211
+ if options.beeper and beeper_log:
212
+ # Synchronise log start times
213
+ if ay_log and ay_log[0][0] < beeper_log[0]:
214
+ beeper_log.insert(0, ay_log[0][0])
215
+ else:
216
+ ay_log.insert(0, (beeper_log[0], 0, 0))
217
+ ay = AY(self.options[SAMPLE_RATE])
218
+ frame_duration = self.options[FRAME_DURATION]
219
+ ay_res = options.ay_res or 622 # 70908/622=114 (5700Hz)
220
+ ay_samples = ay.render(ay_log, frame_duration, ay_res, ay_volume)
221
+ if options.beeper and beeper_log:
222
+ delays = [t1 - t0 for t0, t1 in zip(beeper_log, beeper_log[1:])]
223
+ beeper_samples = moving_average_filter(delays, self.options, volume)
224
+ samples = self._combine(ay_samples, beeper_samples)
225
+ else:
226
+ samples = ay_samples
227
+ write_wav(audio_file, samples[::2], self.options[SAMPLE_RATE])
228
+
229
+ def _parse_log(self, audio_log):
230
+ ay_log = []
231
+ beeper_log = []
232
+ for record in audio_log:
233
+ if record[1] < 16:
234
+ ay_log.append(record)
235
+ else:
236
+ beeper_log.append(record[0])
237
+ return ay_log, beeper_log
238
+
239
+ def _combine(self, ay_samples, beeper_samples):
240
+ samples = []
241
+ ay_len = len(ay_samples) // 2
242
+ beeper_len = len(beeper_samples)
243
+ max_len = max(ay_len, beeper_len)
244
+ ay_samples.extend([0] * ((max_len - ay_len) * 2))
245
+ beeper_samples.extend([0] * (max_len - beeper_len))
246
+ for i in range(max_len):
247
+ left = (ay_samples[2 * i] + beeper_samples[i]) / 2
248
+ right = (ay_samples[2 * i + 1] + beeper_samples[i]) / 2
249
+ samples.append(left)
250
+ samples.append(right)
251
+ return samples