adafruit-circuitpython-ay8912 1.0.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,459 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Liz Clark for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ :py:class:`~adafruit_ay8912.ay8912_emulator.AY8912`
6
+ ================================================================================
7
+
8
+ AY-3-8910 / AY-8912 emulator for CircuitPython with :py:mod:`synthio`.
9
+
10
+ Translates AY-3-8910 register writes into ``synthio.Note`` property updates. Three
11
+ synthesizers (one per channel) feed an :py:class:`audiomixer.Mixer` for
12
+ per-channel volume and stereo panning.
13
+
14
+ * Author(s): Liz Clark
15
+
16
+ Implementation Notes
17
+ --------------------
18
+
19
+ **Software and Dependencies:**
20
+
21
+ * Adafruit CircuitPython firmware for the supported boards:
22
+ https://circuitpython.org/downloads
23
+ """
24
+
25
+ import array
26
+ from random import randint
27
+
28
+ import audiomixer
29
+ import synthio
30
+ import ulab.numpy as np
31
+
32
+ try:
33
+ from typing import Tuple
34
+ except ImportError:
35
+ pass
36
+
37
+ __version__ = "1.0.0"
38
+ __repo__ = "https://github.com/your-org/Adafruit_CircuitPython_AY8912_Emulator.git"
39
+
40
+
41
+ class AY8912:
42
+ """AY-3-8910 / AY-8912 emulator backed by :py:mod:`synthio`.
43
+
44
+ :param int sample_rate: Output sample rate in Hz.
45
+ :param int clock_rate: AY chip clock frequency in Hz. Defaults to the ZX
46
+ Spectrum 128K clock (1773400 Hz).
47
+ :param int waveform_size: Number of samples in the square waveform.
48
+ :param int noise_size: Number of samples in the noise waveform.
49
+ :param int volume: Peak sample amplitude used when building the waveforms.
50
+ """
51
+
52
+ # AY DAC volume table -- 16 levels
53
+ DAC = (
54
+ 0.0,
55
+ 0.00999,
56
+ 0.01445,
57
+ 0.02106,
58
+ 0.03070,
59
+ 0.04555,
60
+ 0.06450,
61
+ 0.10736,
62
+ 0.12659,
63
+ 0.20499,
64
+ 0.29221,
65
+ 0.37284,
66
+ 0.49253,
67
+ 0.63532,
68
+ 0.80558,
69
+ 1.0,
70
+ )
71
+
72
+ # Envelope segment functions per shape: (segment0, segment1)
73
+ # 0=decay (start high), 1=attack (start low), 2=hold, 3=hold-alt
74
+ _ENV_SEG = (
75
+ (0, 2),
76
+ (0, 2),
77
+ (0, 2),
78
+ (0, 2), # shapes 0-3: \___
79
+ (1, 2),
80
+ (1, 2),
81
+ (1, 2),
82
+ (1, 2), # shapes 4-7: /|__
83
+ (0, 0),
84
+ (0, 2),
85
+ (0, 1),
86
+ (0, 3), # shapes 8-11: \\\\ \___ \/\/ \^^^
87
+ (1, 1),
88
+ (1, 3),
89
+ (1, 0),
90
+ (1, 2), # shapes 12-15: //// /^^^ /\/\ /|__
91
+ )
92
+
93
+ def __init__(
94
+ self,
95
+ sample_rate: int = 22050,
96
+ clock_rate: int = 1773400,
97
+ waveform_size: int = 256,
98
+ noise_size: int = 256,
99
+ volume: int = 32000,
100
+ ) -> None:
101
+ self._clock = clock_rate
102
+ self._sample_rate = sample_rate
103
+
104
+ self._regs = bytearray(16)
105
+
106
+ self._square = array.array(
107
+ "h",
108
+ [volume] * (waveform_size // 2) + [-volume] * (waveform_size // 2),
109
+ )
110
+ self._noise = np.array(
111
+ [randint(-volume, volume) for _ in range(noise_size)],
112
+ dtype=np.int16,
113
+ )
114
+
115
+ self._synths = []
116
+ self._notes = []
117
+ for _ in range(3):
118
+ s = synthio.Synthesizer(sample_rate=sample_rate, channel_count=2)
119
+ n = synthio.Note(
120
+ frequency=440.0,
121
+ waveform=self._square,
122
+ amplitude=1.0,
123
+ )
124
+ s.press(n)
125
+ self._synths.append(s)
126
+ self._notes.append(n)
127
+
128
+ self._mixer = audiomixer.Mixer(
129
+ voice_count=3,
130
+ sample_rate=sample_rate,
131
+ channel_count=2,
132
+ buffer_size=2048,
133
+ )
134
+
135
+ self._started = False
136
+
137
+ self._tone_on = [False, False, False]
138
+ self._noise_on = [False, False, False]
139
+ self._env_enabled = [False, False, False]
140
+
141
+ self._env_shape = 0
142
+ self._env_period = 1
143
+ self._env_level = 0 # 0..31
144
+ self._env_segment = 0 # 0 or 1
145
+ self._env_accumulator = 0.0
146
+ self._env_steps_per_tick = 0.0
147
+ self._compute_env_rate()
148
+
149
+ @property
150
+ def mixer(self) -> "audiomixer.Mixer":
151
+ """The :py:class:`audiomixer.Mixer`. Connect to audio output
152
+ with ``audio.play(ay.mixer)`` (or just call :meth:`begin`)."""
153
+ return self._mixer
154
+
155
+ def begin(self, audio_out) -> None:
156
+ """Begin audio signal chain.
157
+
158
+ Handles ``audio_out.play(mixer)`` first, then connects the synth voices::
159
+
160
+ ay = AY8912(sample_rate=22050)
161
+ ay.begin(audio)
162
+
163
+ :param audio_out: The audio output object
164
+ """
165
+ audio_out.play(self._mixer)
166
+
167
+ for i in range(3):
168
+ self._mixer.voice[i].play(self._synths[i], loop=True)
169
+ self._mixer.voice[i].level = 0.0
170
+
171
+ # Default ACB stereo panning
172
+ self._mixer.voice[0].panning = -0.4 # A -> left
173
+ self._mixer.voice[1].panning = 0.0 # B -> center
174
+ self._mixer.voice[2].panning = 0.4 # C -> right
175
+
176
+ self._started = True
177
+
178
+ @property
179
+ def notes(self) -> "Tuple[synthio.Note, ...]":
180
+ """Direct access to the three ``synthio.Note`` objects (read-only
181
+ tuple)."""
182
+ return tuple(self._notes)
183
+
184
+ def read_register(self, reg: int) -> int:
185
+ """Read an AY register.
186
+
187
+ :param int reg: Register index (0-15).
188
+ :return: The stored register value, or 0 if ``reg`` is out of range.
189
+ """
190
+ if reg > 15:
191
+ return 0
192
+ return self._regs[reg]
193
+
194
+ def write_register(self, reg: int, value: int) -> None:
195
+ """Write an AY register (0-13). R14/R15 (I/O ports) are stored but
196
+ otherwise ignored.
197
+
198
+ :param int reg: Register index (0-15). Out-of-range writes are ignored.
199
+ :param int value: Byte value to write (masked to 0-255).
200
+ """
201
+ if reg > 15:
202
+ return
203
+ value &= 0xFF
204
+ self._regs[reg] = value
205
+ self._apply_reg(reg)
206
+
207
+ def set_pan(self, channel: int, pan: float) -> None:
208
+ """Stereo panning for a channel.
209
+
210
+ :param int channel: Channel index (0-2).
211
+ :param float pan: Pan position: ``-1.0`` = hard left, ``0.0`` = center,
212
+ ``1.0`` = hard right. Values are clamped to that range.
213
+ """
214
+ if 0 <= channel <= 2:
215
+ self._mixer.voice[channel].panning = max(-1.0, min(1.0, pan))
216
+
217
+ def set_tone_period(self, channel: int, period: int) -> None:
218
+ """The 12-bit tone period directly (R0/R1, R2/R3, R4/R5).
219
+
220
+ :param int channel: Channel index (0-2).
221
+ :param int period: Tone period, clamped to 1-4095.
222
+ """
223
+ if channel > 2:
224
+ return
225
+ period = max(1, min(4095, period))
226
+ r = channel * 2
227
+ self.write_register(r, period & 0xFF)
228
+ self.write_register(r + 1, (period >> 8) & 0x0F)
229
+
230
+ def set_noise_period(self, period: int) -> None:
231
+ """The 5-bit noise period (R6).
232
+
233
+ :param int period: Noise period (only the low 5 bits are used).
234
+ """
235
+ self.write_register(6, period & 0x1F)
236
+
237
+ def set_volume(self, channel: int, volume: int, envelope: bool = False) -> None:
238
+ """Set a channel's volume (R8/R9/R10).
239
+
240
+ :param int channel: Channel index (0-2).
241
+ :param int volume: Fixed volume level (0-15).
242
+ :param bool envelope: If ``True``, the hardware envelope controls the
243
+ channel volume instead of the fixed level.
244
+ """
245
+ if channel > 2:
246
+ return
247
+ val = min(15, volume) & 0x0F
248
+ if envelope:
249
+ val |= 0x10
250
+ self.write_register(8 + channel, val)
251
+
252
+ def enable_tone(self, channel: int, enable: bool = True) -> None:
253
+ """Enable or disable tone output for a channel via the mixer (R7).
254
+
255
+ :param int channel: Channel index (0-2).
256
+ :param bool enable: ``True`` to enable tone, ``False`` to disable.
257
+ """
258
+ if channel > 2:
259
+ return
260
+ bit = 1 << channel
261
+ mixer = self._regs[7]
262
+ if enable:
263
+ mixer &= ~bit # bit=0 means ON in the AY mixer
264
+ else:
265
+ mixer |= bit
266
+ self.write_register(7, mixer)
267
+
268
+ def enable_noise(self, channel: int, enable: bool = True) -> None:
269
+ """Enable or disable noise output for a channel via the mixer (R7).
270
+
271
+ :param int channel: Channel index (0-2).
272
+ :param bool enable: ``True`` to enable noise, ``False`` to disable.
273
+ """
274
+ if channel > 2:
275
+ return
276
+ bit = 1 << (channel + 3)
277
+ mixer = self._regs[7]
278
+ if enable:
279
+ mixer &= ~bit
280
+ else:
281
+ mixer |= bit
282
+ self.write_register(7, mixer)
283
+
284
+ def set_envelope(self, period: int, shape: int) -> None:
285
+ """Set the envelope period (R11/R12) and shape (R13).
286
+
287
+ Writing R13 resets the envelope generator.
288
+
289
+ :param int period: 16-bit envelope period.
290
+ :param int shape: Envelope shape (0-15).
291
+ """
292
+ self.write_register(11, period & 0xFF)
293
+ self.write_register(12, (period >> 8) & 0xFF)
294
+ self.write_register(13, shape & 0x0F)
295
+
296
+ def reset(self) -> None:
297
+ """Reset all registers and state to power-on defaults."""
298
+ self._regs = bytearray(16)
299
+ self._env_level = 0
300
+ self._env_segment = 0
301
+ self._env_shape = 0
302
+ self._env_period = 1
303
+ self._env_accumulator = 0.0
304
+ self._compute_env_rate()
305
+
306
+ for ch in range(3):
307
+ self._notes[ch].frequency = 440.0
308
+ self._notes[ch].amplitude = 1.0
309
+ self._notes[ch].waveform = self._square
310
+ self._notes[ch].ring_waveform = None
311
+ self._mixer.voice[ch].level = 0.0
312
+ self._tone_on[ch] = False
313
+ self._noise_on[ch] = False
314
+ self._env_enabled[ch] = False
315
+
316
+ def tick(self) -> None:
317
+ """Advance the envelope generator.
318
+
319
+ Call this at ~50 Hz from your main loop or a timer interrupt.
320
+ """
321
+ self._env_accumulator += self._env_steps_per_tick
322
+ while self._env_accumulator >= 1.0:
323
+ self._env_accumulator -= 1.0
324
+ self._step_envelope()
325
+
326
+ for ch in range(3):
327
+ if self._env_enabled[ch]:
328
+ self._apply_volume(ch)
329
+
330
+ def _apply_reg(self, reg: int) -> None:
331
+ """React to a register write."""
332
+ if reg <= 5:
333
+ # Tone period (R0/R1 -> ch0, R2/R3 -> ch1, R4/R5 -> ch2)
334
+ ch = reg // 2
335
+ self._update_channel_freq(ch)
336
+
337
+ elif reg == 6:
338
+ # Noise period -- update noise frequency on relevant channels
339
+ self._update_mixer_state()
340
+
341
+ elif reg == 7:
342
+ # Mixer enable bits
343
+ self._update_mixer_state()
344
+
345
+ elif 8 <= reg <= 10:
346
+ # Volume / envelope-enable
347
+ ch = reg - 8
348
+ vreg = self._regs[reg]
349
+ self._env_enabled[ch] = bool(vreg & 0x10)
350
+ self._apply_volume(ch)
351
+
352
+ elif reg in {11, 12}:
353
+ # Envelope period
354
+ self._env_period = self._regs[11] | (self._regs[12] << 8)
355
+ self._env_period = max(self._env_period, 1)
356
+ self._compute_env_rate()
357
+
358
+ elif reg == 13:
359
+ # Envelope shape -- reset envelope generator
360
+ self._env_shape = self._regs[13] & 0x0F
361
+ self._env_segment = 0
362
+ self._env_accumulator = 0.0
363
+ self._env_reset_segment()
364
+ self._compute_env_rate()
365
+
366
+ def _period_to_hz(self, period: int) -> float:
367
+ """Convert an AY tone/noise period to Hz, clamped to synthio's valid
368
+ range of 0-32767 Hz."""
369
+ period = max(period, 1)
370
+ freq = self._clock / (16.0 * period)
371
+ freq = min(freq, 32767.0)
372
+ freq = max(freq, 1.0)
373
+ return freq
374
+
375
+ def _update_channel_freq(self, ch: int) -> None:
376
+ """Set a note's frequency from the current tone registers, but only if
377
+ tone is active on that channel."""
378
+ if self._tone_on[ch]:
379
+ lo = self._regs[ch * 2]
380
+ hi = self._regs[ch * 2 + 1] & 0x0F
381
+ period = lo | (hi << 8)
382
+ period = max(period, 1)
383
+ self._notes[ch].frequency = self._period_to_hz(period)
384
+
385
+ def _update_mixer_state(self) -> None:
386
+ """Read the mixer register (R7) and update waveforms & frequencies."""
387
+ mixer_reg = self._regs[7]
388
+ noise_period = self._regs[6] & 0x1F
389
+ noise_period = max(noise_period, 1)
390
+ noise_hz = self._period_to_hz(noise_period)
391
+
392
+ for ch in range(3):
393
+ tone_on = not bool(mixer_reg & (1 << ch))
394
+ noise_on = not bool(mixer_reg & (1 << (ch + 3)))
395
+
396
+ self._tone_on[ch] = tone_on
397
+ self._noise_on[ch] = noise_on
398
+
399
+ if tone_on and noise_on:
400
+ self._notes[ch].waveform = self._square
401
+ self._notes[ch].ring_waveform = self._noise
402
+ self._notes[ch].ring_frequency = noise_hz
403
+ self._update_channel_freq(ch)
404
+
405
+ elif tone_on:
406
+ self._notes[ch].waveform = self._square
407
+ self._notes[ch].ring_waveform = None
408
+ self._update_channel_freq(ch)
409
+
410
+ elif noise_on:
411
+ self._notes[ch].waveform = self._noise
412
+ self._notes[ch].ring_waveform = None
413
+ self._notes[ch].frequency = noise_hz
414
+
415
+ self._apply_volume(ch)
416
+
417
+ def _apply_volume(self, ch: int) -> None:
418
+ """Set the mixer voice level for a channel based on current state."""
419
+ # If the channel is fully off (no tone, no noise), silence it
420
+ if not self._tone_on[ch] and not self._noise_on[ch]:
421
+ self._mixer.voice[ch].level = 0.0
422
+ return
423
+
424
+ if self._env_enabled[ch]:
425
+ # Envelope: 0-31 mapped to the 16-entry DAC (pairs)
426
+ self._mixer.voice[ch].level = self.DAC[self._env_level >> 1]
427
+ else:
428
+ vol = self._regs[8 + ch] & 0x0F
429
+ self._mixer.voice[ch].level = self.DAC[vol]
430
+
431
+ def _compute_env_rate(self) -> None:
432
+ """Compute how many envelope steps to advance per 50 Hz tick."""
433
+ # The AY envelope steps once per (256 * period) clock cycles:
434
+ # steps_per_second = clock / (256 * period)
435
+ # steps_per_tick = steps_per_second / 50
436
+ self._env_steps_per_tick = self._clock / (256.0 * self._env_period * 50.0)
437
+
438
+ def _env_reset_segment(self) -> None:
439
+ """Set the envelope level to the starting value for the current
440
+ segment."""
441
+ func = self._ENV_SEG[self._env_shape][self._env_segment & 1]
442
+ # Decay (0) and hold-alt (3) start at max; attack (1) and hold (2)
443
+ # start at 0.
444
+ self._env_level = 31 if func in {0, 3} else 0
445
+
446
+ def _step_envelope(self) -> None:
447
+ """Advance the envelope by one step."""
448
+ func = self._ENV_SEG[self._env_shape][self._env_segment & 1]
449
+ if func == 0: # Decay
450
+ self._env_level -= 1
451
+ if self._env_level < 0:
452
+ self._env_segment ^= 1
453
+ self._env_reset_segment()
454
+ elif func == 1: # Attack
455
+ self._env_level += 1
456
+ if self._env_level > 31:
457
+ self._env_segment ^= 1
458
+ self._env_reset_segment()
459
+ # func 2 & 3 = hold -- do nothing
@@ -0,0 +1,416 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Liz Clark for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ :py:class:`~adafruit_ay8912.vgm_player.VGMFile`
6
+ ================================================================================
7
+
8
+ VGM chiptune file parser and player for CircuitPython.
9
+
10
+ Plays VGM (and gzip-compressed VGZ) files through an
11
+ :py:class:`~adafruit_ay8912.ay8912_emulator.AY8912` instance.
12
+
13
+ Only files with a non-zero AY-3-8910 clock (header offset ``0x74``) are
14
+ supported. Files targeting other chips (SN76489, YM2612, etc.) are rejected.
15
+
16
+ * Author(s): Liz Clark
17
+
18
+ Implementation Notes
19
+ --------------------
20
+
21
+ **Software and Dependencies:**
22
+
23
+ * Adafruit CircuitPython firmware for the supported boards:
24
+ https://circuitpython.org/downloads
25
+ """
26
+
27
+ import struct
28
+ import time
29
+
30
+ try:
31
+ from typing import TYPE_CHECKING, Optional, Tuple
32
+
33
+ if TYPE_CHECKING:
34
+ from .ay8912_emulator import AY8912
35
+ except ImportError:
36
+ pass
37
+
38
+ try:
39
+ import zlib
40
+ except ImportError:
41
+ zlib = None
42
+
43
+ __version__ = "1.0.0"
44
+ __repo__ = "https://github.com/your-org/Adafruit_CircuitPython_AY8912.git"
45
+
46
+ # VGM runs on a 44100 Hz sample clock for all wait commands
47
+ _VGM_RATE = 44100
48
+
49
+
50
+ class VGMFile:
51
+ """Parse and play an AY8912 VGM/VGZ file.
52
+
53
+ :param str filename: Path to a ``.vgm`` or ``.vgz`` file to load
54
+ immediately. Pass ``None`` to create an empty object and call
55
+ :meth:`load` later.
56
+ """
57
+
58
+ def __init__(self, filename: Optional[str] = None) -> None:
59
+ self._data = None
60
+ self._ay = None
61
+
62
+ # Metadata
63
+ self._title = ""
64
+ self._author = ""
65
+ self._game = ""
66
+
67
+ # Header info
68
+ self._clock_hz = 1773400
69
+ self._total_samples = 0
70
+ self._loop_offset = 0
71
+ self._loop_samples = 0
72
+ self._data_offset = 0
73
+ self._eof_offset = 0
74
+ self._version = 0
75
+
76
+ # Playback state
77
+ self._pos = 0 # current byte position in command stream
78
+ self._playing = False
79
+ self._sample_clock = 0 # cumulative samples played
80
+ self._wait_remaining = 0 # samples still to wait
81
+ self._next_time = 0.0 # monotonic time of next command batch
82
+ self._loop_count = 0 # how many times we've looped
83
+
84
+ if filename:
85
+ self.load(filename)
86
+
87
+ @property
88
+ def title(self) -> str:
89
+ """Track title from the GD3 tag, or ``""`` if absent."""
90
+ return self._title
91
+
92
+ @property
93
+ def author(self) -> str:
94
+ """Track author from the GD3 tag, or ``""`` if absent."""
95
+ return self._author
96
+
97
+ @property
98
+ def game(self) -> str:
99
+ """Game/source name from the GD3 tag, or ``""`` if absent."""
100
+ return self._game
101
+
102
+ @property
103
+ def clock_hz(self) -> int:
104
+ """AY chip clock in Hz."""
105
+ return self._clock_hz
106
+
107
+ @property
108
+ def version(self) -> int:
109
+ """VGM format version as a packed BCD integer"""
110
+ return self._version
111
+
112
+ @property
113
+ def total_samples(self) -> int:
114
+ """Total length of the song in 44100 Hz samples."""
115
+ return self._total_samples
116
+
117
+ @property
118
+ def duration(self) -> float:
119
+ """Song duration in seconds."""
120
+ if self._total_samples:
121
+ return self._total_samples / _VGM_RATE
122
+ return 0
123
+
124
+ @property
125
+ def elapsed(self) -> float:
126
+ """Elapsed playback time in seconds."""
127
+ return self._sample_clock / _VGM_RATE
128
+
129
+ @property
130
+ def progress(self) -> float:
131
+ """Playback progress as a fraction from ``0.0`` to ``1.0``."""
132
+ if self._total_samples:
133
+ p = self._sample_clock / self._total_samples
134
+ return p if p < 1.0 else 1.0
135
+ return 0
136
+
137
+ @property
138
+ def loop_count(self) -> int:
139
+ """How many times the song has looped back."""
140
+ return self._loop_count
141
+
142
+ @property
143
+ def loops(self) -> bool:
144
+ """``True`` if this song defines a loop point."""
145
+ return bool(self._loop_offset and self._loop_samples)
146
+
147
+ @property
148
+ def playing(self) -> bool:
149
+ """``True`` while playback is active."""
150
+ return self._playing
151
+
152
+ def load(self, filename: str) -> None:
153
+ """Load a ``.vgm`` or ``.vgz`` file, decompressing gzip natively.
154
+
155
+ :param str filename: Path to the file to load.
156
+ :raises RuntimeError: If the file is not a VGM, cannot be decompressed,
157
+ or does not target the AY-3-8910.
158
+ """
159
+ with open(filename, "rb") as f:
160
+ raw = f.read()
161
+
162
+ # Detect gzip (VGZ) -- magic bytes 0x1F 0x8B
163
+ if raw[0] == 0x1F and raw[1] == 0x8B:
164
+ raw = self._gunzip(raw)
165
+
166
+ # Check the VGM magic
167
+ if bytes(raw[0:4]) != b"Vgm ":
168
+ raise RuntimeError("Not a VGM file (bad magic)")
169
+
170
+ self._data = raw
171
+ self._parse_header()
172
+
173
+ @staticmethod
174
+ def _gunzip(data: bytes) -> bytes:
175
+ """Decompress gzip (VGZ) data"""
176
+ if zlib is None:
177
+ raise RuntimeError(
178
+ "Playing compressed .vgz files needs the 'zlib' module, which is "
179
+ "not present in this build. Use an uncompressed .vgm, or a build "
180
+ "that includes zlib."
181
+ )
182
+ # wbits=31 tells zlib to expect a gzip header/trailer
183
+ return zlib.decompress(data, 31)
184
+
185
+ def _parse_header(self) -> None:
186
+ """Parse the VGM header and locate the command stream."""
187
+ data = self._data
188
+
189
+ eof_rel = struct.unpack("<I", data[0x04:0x08])[0]
190
+ self._eof_offset = 0x04 + eof_rel if eof_rel else len(data)
191
+ self._eof_offset = min(self._eof_offset, len(data))
192
+
193
+ self._version = struct.unpack("<I", data[0x08:0x0C])[0]
194
+ self._total_samples = struct.unpack("<I", data[0x18:0x1C])[0]
195
+
196
+ loop_rel = struct.unpack("<I", data[0x1C:0x20])[0]
197
+ self._loop_offset = (0x1C + loop_rel) if loop_rel else 0
198
+ self._loop_samples = struct.unpack("<I", data[0x20:0x24])[0]
199
+
200
+ # AY-3-8910 clock at offset 0x74 (VGM 1.51+)
201
+ ay_clock = 0
202
+ if len(data) >= 0x78:
203
+ ay_clock = struct.unpack("<I", data[0x74:0x78])[0]
204
+
205
+ # Mask off the top bits (flags), keep the clock value
206
+ ay_clock &= 0x3FFFFFFF
207
+
208
+ if ay_clock == 0:
209
+ raise RuntimeError(
210
+ "No AY-3-8910 clock in this VGM (offset 0x74 is 0). "
211
+ "This file targets a different chip."
212
+ )
213
+
214
+ self._clock_hz = ay_clock
215
+
216
+ if self._version >= 0x150:
217
+ data_rel = struct.unpack("<I", data[0x34:0x38])[0]
218
+ self._data_offset = 0x34 + data_rel if data_rel else 0x40
219
+ else:
220
+ self._data_offset = 0x40
221
+
222
+ gd3_rel = struct.unpack("<I", data[0x14:0x18])[0]
223
+ if gd3_rel:
224
+ self._parse_gd3(0x14 + gd3_rel)
225
+
226
+ self._pos = self._data_offset
227
+
228
+ def _parse_gd3(self, offset: int) -> None:
229
+ """Parse the GD3 metadata tag (UTF-16LE null-terminated strings)."""
230
+ data = self._data
231
+ if bytes(data[offset : offset + 4]) != b"Gd3 ":
232
+ return
233
+
234
+ ptr = offset + 12
235
+
236
+ fields = []
237
+ for _ in range(11):
238
+ s, ptr = self._read_utf16(data, ptr)
239
+ fields.append(s)
240
+
241
+ if len(fields) >= 7:
242
+ self._title = fields[0]
243
+ self._game = fields[2]
244
+ self._author = fields[6]
245
+
246
+ @staticmethod
247
+ def _read_utf16(data: bytes, offset: int) -> "Tuple[str, int]":
248
+ """Read a UTF-16LE null-terminated string.
249
+
250
+ :param bytes data: Buffer to read from.
251
+ :param int offset: Byte offset to start reading at.
252
+ :return: A ``(string, next_offset)`` tuple.
253
+ """
254
+ chars = []
255
+ ptr = offset
256
+ while ptr + 1 < len(data):
257
+ lo = data[ptr]
258
+ hi = data[ptr + 1]
259
+ ptr += 2
260
+ if lo == 0 and hi == 0:
261
+ break
262
+ code = lo | (hi << 8)
263
+ if 32 <= code < 127:
264
+ chars.append(chr(code))
265
+ elif code >= 127:
266
+ chars.append("?")
267
+ return "".join(chars), ptr
268
+
269
+ def play(self, ay: "AY8912") -> None:
270
+ """Start playback through the AY8912 instance.
271
+
272
+ :param ~adafruit_ay8912.ay8912_emulator.AY8912 ay: The emulator that
273
+ register writes will be sent to. It is reset before playback.
274
+ """
275
+ self._ay = ay
276
+ self._pos = self._data_offset
277
+ self._sample_clock = 0
278
+ self._wait_remaining = 0
279
+ self._loop_count = 0
280
+ self._playing = True
281
+ self._next_time = time.monotonic()
282
+ self._ay.reset()
283
+
284
+ def stop(self) -> None:
285
+ """Stop playback and reset the attached AY8912"""
286
+ self._playing = False
287
+ if self._ay:
288
+ self._ay.reset()
289
+
290
+ def update(self) -> None:
291
+ """Process the command stream with correct timing.
292
+
293
+ Call repeatedly in the playback loop. It tracks real time internally
294
+ and only advances the command stream when the accumulated wait has
295
+ elapsed.
296
+ """
297
+ if not self._playing or self._data is None:
298
+ return
299
+
300
+ now = time.monotonic()
301
+ if now < self._next_time:
302
+ return
303
+
304
+ wait_samples = self._process_until_wait()
305
+
306
+ if wait_samples < 0:
307
+ if self._loop_offset and self._loop_samples:
308
+ self._pos = self._loop_offset
309
+ self._loop_count += 1
310
+ self._sample_clock = self._total_samples - self._loop_samples
311
+ wait_samples = 0
312
+ else:
313
+ self._playing = False
314
+ return
315
+
316
+ self._sample_clock += wait_samples
317
+ self._next_time += wait_samples / _VGM_RATE
318
+
319
+ self._next_time = max(self._next_time, now)
320
+
321
+ def _process_until_wait(self) -> int: # noqa: PLR0912
322
+ """Execute commands until a wait.
323
+
324
+ :return: The number of samples to wait, or ``-1`` at end of stream.
325
+ """
326
+ data = self._data
327
+ ay = self._ay
328
+
329
+ end = self._eof_offset if self._eof_offset else len(data)
330
+ while self._pos < end:
331
+ cmd = data[self._pos]
332
+ self._pos += 1
333
+
334
+ # AY-3-8910 register write: 0xA0 aa dd
335
+ if cmd == 0xA0:
336
+ reg = data[self._pos]
337
+ val = data[self._pos + 1]
338
+ self._pos += 2
339
+ reg &= 0x0F if reg < 0x10 else 0xFF
340
+ if reg <= 13:
341
+ ay.write_register(reg, val)
342
+
343
+ # Wait n samples: 0x61 nn nn
344
+ elif cmd == 0x61:
345
+ n = data[self._pos] | (data[self._pos + 1] << 8)
346
+ self._pos += 2
347
+ return n
348
+
349
+ # Wait 1/60 second (735 samples)
350
+ elif cmd == 0x62:
351
+ return 735
352
+
353
+ # Wait 1/50 second (882 samples)
354
+ elif cmd == 0x63:
355
+ return 882
356
+
357
+ # End of sound data
358
+ elif cmd == 0x66:
359
+ return -1
360
+
361
+ # Wait n+1 samples (0x70-0x7F)
362
+ elif 0x70 <= cmd <= 0x7F:
363
+ return (cmd & 0x0F) + 1
364
+
365
+ # --- Commands to skip (other chips / unsupported) ---
366
+
367
+ # 0x4F dd / 0x50 dd -- SN76489 (Game Gear / SMS): 1 data byte
368
+ elif cmd in {0x4F, 0x50}:
369
+ self._pos += 1
370
+
371
+ # 0x51-0x5F -- YM chips: 2 data bytes
372
+ elif 0x51 <= cmd <= 0x5F:
373
+ self._pos += 2
374
+
375
+ # 0xA1-0xBF -- various chip writes: 2 data bytes
376
+ elif 0xA1 <= cmd <= 0xBF:
377
+ self._pos += 2
378
+
379
+ # 0xC0-0xDF -- various: 3 data bytes
380
+ elif 0xC0 <= cmd <= 0xDF:
381
+ self._pos += 3
382
+
383
+ # 0xE0-0xFF -- various: 4 data bytes
384
+ elif 0xE0 <= cmd <= 0xFF:
385
+ self._pos += 4
386
+
387
+ # 0x90-0x95 -- DAC stream control (various lengths)
388
+ elif cmd == 0x90:
389
+ self._pos += 4
390
+ elif cmd == 0x91:
391
+ self._pos += 4
392
+ elif cmd == 0x92:
393
+ self._pos += 5
394
+ elif cmd == 0x93:
395
+ self._pos += 10
396
+ elif cmd == 0x94:
397
+ self._pos += 1
398
+ elif cmd == 0x95:
399
+ self._pos += 4
400
+
401
+ # 0x67 -- data block: 0x67 0x66 tt ss ss ss ss [data]
402
+ elif cmd == 0x67:
403
+ self._pos += 1 # skip 0x66
404
+ self._pos += 1 # skip the type byte
405
+ size = struct.unpack("<I", data[self._pos : self._pos + 4])[0]
406
+ self._pos += 4 + size
407
+
408
+ else:
409
+ # Unknown command
410
+ pass
411
+ return -1
412
+
413
+ def __str__(self) -> str:
414
+ title = self._title or "Untitled"
415
+ author = self._author or "Unknown"
416
+ return f"VGM v{self._version:X} | {title} -- {author} ({self.duration:.1f}s)"
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: adafruit-circuitpython-ay8912
3
+ Version: 1.0.0
4
+ Summary: CircuitPython synthio helper library for AY8912/AY-3-8910 emulation
5
+ Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/adafruit/Adafruit_CircuitPython_AY8912
8
+ Keywords: adafruit,blinka,circuitpython,micropython,ay8912_emulator,ay8912,,ay-3-8910,,synthio,,chiptune
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Topic :: Software Development :: Embedded Systems
12
+ Classifier: Topic :: System :: Hardware
13
+ Classifier: Programming Language :: Python :: 3
14
+ Description-Content-Type: text/x-rst
15
+ License-File: LICENSE
16
+ Requires-Dist: Adafruit-Blinka
17
+ Provides-Extra: optional
18
+ Dynamic: license-file
19
+
20
+ Introduction
21
+ ============
22
+
23
+
24
+ .. image:: https://readthedocs.org/projects/adafruit-circuitpython-ay8912/badge/?version=latest
25
+ :target: https://docs.circuitpython.org/projects/ay8912/en/latest/
26
+ :alt: Documentation Status
27
+
28
+
29
+ .. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg
30
+ :target: https://adafru.it/discord
31
+ :alt: Discord
32
+
33
+
34
+ .. image:: https://github.com/adafruit/Adafruit_CircuitPython_AY8912/workflows/Build%20CI/badge.svg
35
+ :target: https://github.com/adafruit/Adafruit_CircuitPython_AY8912/actions
36
+ :alt: Build Status
37
+
38
+
39
+ .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
40
+ :target: https://github.com/astral-sh/ruff
41
+ :alt: Code Style: Ruff
42
+
43
+ CircuitPython synthio helper library for AY8912/AY-3-8910 emulation
44
+
45
+
46
+ Dependencies
47
+ =============
48
+ This driver depends on:
49
+
50
+ * `Adafruit CircuitPython <https://github.com/adafruit/circuitpython>`_
51
+
52
+ Please ensure all dependencies are available on the CircuitPython filesystem.
53
+ This is easily achieved by downloading
54
+ `the Adafruit library and driver bundle <https://circuitpython.org/libraries>`_
55
+ or individual libraries can be installed using
56
+ `circup <https://github.com/adafruit/circup>`_.
57
+
58
+ Installing from PyPI
59
+ =====================
60
+
61
+ On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
62
+ PyPI <https://pypi.org/project/adafruit-circuitpython-ay8912/>`_.
63
+ To install for current user:
64
+
65
+ .. code-block:: shell
66
+
67
+ pip3 install adafruit-circuitpython-ay8912
68
+
69
+ To install system-wide (this may be required in some cases):
70
+
71
+ .. code-block:: shell
72
+
73
+ sudo pip3 install adafruit-circuitpython-ay8912
74
+
75
+ To install in a virtual environment in your current project:
76
+
77
+ .. code-block:: shell
78
+
79
+ mkdir project-name && cd project-name
80
+ python3 -m venv .venv
81
+ source .env/bin/activate
82
+ pip3 install adafruit-circuitpython-ay8912
83
+
84
+ Installing to a Connected CircuitPython Device with Circup
85
+ ==========================================================
86
+
87
+ Make sure that you have ``circup`` installed in your Python environment.
88
+ Install it with the following command if necessary:
89
+
90
+ .. code-block:: shell
91
+
92
+ pip3 install circup
93
+
94
+ With ``circup`` installed and your CircuitPython device connected use the
95
+ following command to install:
96
+
97
+ .. code-block:: shell
98
+
99
+ circup install adafruit_ay8912
100
+
101
+ Or the following command to update an existing version:
102
+
103
+ .. code-block:: shell
104
+
105
+ circup update
106
+
107
+ Usage Example
108
+ =============
109
+
110
+ .. code-block:: python
111
+
112
+ import time
113
+ import board
114
+ import audiobusio
115
+ from adafruit_ay8912.ay8912_emulator import AY8912
116
+ from adafruit_ay8912.vgm_player import VGMFile
117
+
118
+ audio = audiobusio.I2SOut(board.D9, board.D10, board.D11)
119
+
120
+ VGM_FILE = "song.vgz" # .vgm or .vgz both work
121
+ vgm = VGMFile(VGM_FILE)
122
+
123
+ ay = AY8912(sample_rate=22050, clock_rate=vgm.clock_hz)
124
+ ay.begin(audio)
125
+ vgm.play(ay)
126
+
127
+ last_status = time.monotonic()
128
+
129
+ while vgm.playing:
130
+ vgm.update()
131
+ now = time.monotonic()
132
+ if now - last_status >= 5.0:
133
+ last_status = now
134
+ print(" %.0fs / %.0fs (%d%%)" % (
135
+ vgm.elapsed, vgm.duration, int(vgm.progress * 100)))
136
+
137
+ ay.reset()
138
+
139
+ Documentation
140
+ =============
141
+ API documentation for this library can be found on `Read the Docs <https://docs.circuitpython.org/projects/ay8912/en/latest/>`_.
142
+
143
+ For information on building library documentation, please check out
144
+ `this guide <https://learn.adafruit.com/creating-and-sharing-a-circuitpython-library/sharing-our-docs-on-readthedocs#sphinx-5-1>`_.
145
+
146
+ Contributing
147
+ ============
148
+
149
+ Contributions are welcome! Please read our `Code of Conduct
150
+ <https://github.com/adafruit/Adafruit_CircuitPython_AY8912/blob/HEAD/CODE_OF_CONDUCT.md>`_
151
+ before contributing to help this project stay welcoming.
@@ -0,0 +1,7 @@
1
+ adafruit_ay8912/ay8912_emulator.py,sha256=ah437jIiNVHjmqmd6ABcqn8GlyN4H7ffU4w6ZD8BYYs,15107
2
+ adafruit_ay8912/vgm_player.py,sha256=nzc9HUrEEmyEO0u63U2moxl1cxHV9XZ9RLoh73bf8zw,12792
3
+ adafruit_circuitpython_ay8912-1.0.0.dist-info/licenses/LICENSE,sha256=0JsO0yQOspYeKuhk2Y_s1wan9NO0DYeWSRJBrYJdeko,1100
4
+ adafruit_circuitpython_ay8912-1.0.0.dist-info/METADATA,sha256=V2dS0jaW1EQzsvXOElK6ZMg8-2WY2FKrnRmKAocv0Sk,4696
5
+ adafruit_circuitpython_ay8912-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ adafruit_circuitpython_ay8912-1.0.0.dist-info/top_level.txt,sha256=5hB9mo7MFFlBGB-11CcR1ZIe6tcFtBfN4WHeJ_yTF7E,16
7
+ adafruit_circuitpython_ay8912-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Liz Clark for Adafruit Industries
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ adafruit_ay8912