Skip to content

Instantly share code, notes, and snippets.

@damieng
Created February 10, 2026 21:06
Show Gist options
  • Select an option

  • Save damieng/2dbd82ee88fffa3ad6fcb798aa42a77d to your computer and use it in GitHub Desktop.

Select an option

Save damieng/2dbd82ee88fffa3ad6fcb798aa42a77d to your computer and use it in GitHub Desktop.
Convert Atari ST YM files to ZX Spectrum TAP files
#!/usr/bin/env python3
"""
ym2aytap.py - Convert YM music files to ZX Spectrum .tap for AY playback.
Reads YM2/YM3/YM5/YM6 files (LHA compressed or raw) and generates a .tap
with a compact Z80 player that writes AY registers at 50Hz using HALT.
14 bytes/frame × 50Hz = 700 bytes/sec → 46s (48K) or ~2:40 (128K).
Damien Guard, MIT License, 2026
"""
import argparse
import os
import struct
import subprocess
import sys
import tempfile
# ─── YM File Parsing ───
def read_ym(filename):
"""Read YM file. Returns (frames, clock, player_freq, loop_frame, title, author).
frames: list of 14-byte bytes objects (R0..R13 per frame).
"""
with open(filename, 'rb') as f:
data = f.read()
# Try raw YM first, then LHA decompression
if data[:3] not in (b'YM2', b'YM3', b'YM5', b'YM6'):
data = _decompress_lha(filename, data)
magic = data[:4]
if magic == b'YM2!':
return _parse_ym2(data)
elif magic == b'YM3!':
return _parse_ym3(data)
elif data[:4] == b'YM3b':
return _parse_ym3b(data)
elif magic in (b'YM5!', b'YM6!'):
return _parse_ym5(data)
else:
sys.exit(f"Unknown YM format: {magic!r}")
def _decompress_lha(filename, raw_data):
"""Decompress LHA-compressed YM file using 7z."""
with tempfile.TemporaryDirectory() as tmpdir:
for cmd in [
['7z', 'e', f'-o{tmpdir}', '-y', filename],
['lhasa', 'e', filename],
]:
try:
subprocess.run(cmd, capture_output=True, check=True,
timeout=10, cwd=tmpdir)
for fn in os.listdir(tmpdir):
with open(os.path.join(tmpdir, fn), 'rb') as f:
return f.read()
except (FileNotFoundError, subprocess.CalledProcessError,
subprocess.TimeoutExpired):
continue
sys.exit("Cannot decompress YM file. Install 7-Zip (7z) on PATH,\n"
"or decompress manually first.")
def _deinterleave(data, num_regs, num_frames):
"""De-interleave YM register data from column-major to row-major."""
frames = []
for f in range(num_frames):
frame = bytes(data[r * num_frames + f] for r in range(num_regs))
frames.append(frame)
return frames
def _parse_ym2(data):
reg_data = data[4:]
n = len(reg_data) // 14
return _deinterleave(reg_data, 14, n), 1773400, 50, 0, "", ""
def _parse_ym3(data):
end = data.find(b'End!')
reg_data = data[4:end] if end > 0 else data[4:]
n = len(reg_data) // 14
return _deinterleave(reg_data, 14, n), 1773400, 50, 0, "", ""
def _parse_ym3b(data):
loop = struct.unpack('>I', data[4:8])[0]
end = data.find(b'End!')
reg_data = data[8:end] if end > 0 else data[8:]
n = len(reg_data) // 14
return _deinterleave(reg_data, 14, n), 1773400, 50, loop, "", ""
def _parse_ym5(data):
if data[4:12] != b'LeOnArD!':
sys.exit(f"Invalid YM5/6 header check string")
num_frames = struct.unpack('>I', data[12:16])[0]
attributes = struct.unpack('>I', data[16:20])[0]
num_dd = struct.unpack('>H', data[20:22])[0]
clock = struct.unpack('>I', data[22:26])[0]
player_freq = struct.unpack('>H', data[26:28])[0]
loop_frame = struct.unpack('>I', data[28:32])[0]
add_size = struct.unpack('>H', data[32:34])[0]
offset = 34 + add_size
# Skip digi-drum samples
for _ in range(num_dd):
dd_sz = struct.unpack('>I', data[offset:offset + 4])[0]
offset += 4 + dd_sz
# Null-terminated strings
def read_nt(off):
end = data.index(b'\x00', off)
return data[off:end].decode('ascii', errors='replace'), end + 1
title, offset = read_nt(offset)
author, offset = read_nt(offset)
_comment, offset = read_nt(offset)
# Register data
interleaved = bool(attributes & 1)
num_regs = 16 # YM5/6 always store 16 regs
if interleaved:
frames = []
for f in range(num_frames):
frame = bytes(data[offset + r * num_frames + f]
for r in range(min(num_regs, 14)))
frames.append(frame)
else:
frames = []
for f in range(num_frames):
base = offset + f * num_regs
frames.append(data[base:base + 14])
return frames, clock, player_freq, loop_frame, title, author
# ─── Z80 Player ───
# Frame-body: PUSH DE through OR E (42 bytes, shared by all halts values).
# Internal JR offsets are self-relative so they don't change.
_PLAY_BODY = bytes([
0xD5, # PUSH DE
0xAF, # XOR A ; reg = 0
0x1E, 0x0D, # LD E, 13
# --- reg loop ---
0xF5, # .reg: PUSH AF
0x06, 0xFF, # LD B, 0xFF
0xED, 0x79, # OUT (C), A ; select reg
0x06, 0xBF, # LD B, 0xBF
0x7E, # LD A, (HL)
0xED, 0x79, # OUT (C), A ; write value
0x23, # INC HL
0xF1, # POP AF
0x3C, # INC A
0x1D, # DEC E
0x20, 0xF0, # JR NZ, .reg (-16)
# --- R13 handling ---
0x7E, # LD A, (HL) ; R13 value
0x23, # INC HL
0xFE, 0xFF, # CP 0xFF ; skip marker?
0x28, 0x0C, # JR Z, .skip (+12)
0x5F, # LD E, A ; save R13 val
0x06, 0xFF, # LD B, 0xFF
0x3E, 0x0D, # LD A, 13
0xED, 0x79, # OUT (C), A ; select R13
0x06, 0xBF, # LD B, 0xBF
0x7B, # LD A, E
0xED, 0x79, # OUT (C), A ; write R13
# --- skip_r13 ---
0xD1, # .skip: POP DE
0x1B, # DEC DE
0x7A, # LD A, D
0xB3, # OR E
])
def generate_play_frames(halts=1):
"""Generate PlayFrames subroutine with configurable HALTs per frame.
Input: HL=data, DE=frame_count, C=0xFD.
halts=1: 50Hz (46 bytes), halts=2: 25Hz (50 bytes), etc.
"""
code = bytearray()
if halts > 1:
# .frame: LD B, halts / .wait: HALT / DJNZ .wait
code += bytes([0x06, halts, 0x76, 0x10, 0xFD])
else:
code.append(0x76) # .frame: HALT
code += _PLAY_BODY # 42 bytes (internal JRs unchanged)
# JR NZ, .frame (back to offset 0)
code += bytes([0x20, (-(len(code) + 2)) & 0xFF])
code.append(0xC9) # RET
return bytes(code)
# Default (halts=1) for backward-compat references
PLAY_FRAMES_BIN = generate_play_frames(1)
# Silence AY (R8=R9=R10=0) + EI + RET. 21 bytes, position-independent.
SILENCE_BIN = bytes([
0x3E, 0x08, # LD A, 8
0x1E, 0x03, # LD E, 3
# --- sil loop (offset 4) ---
0x57, # .sil: LD D, A
0x06, 0xFF, # LD B, 0xFF
0xED, 0x79, # OUT (C), A ; select reg
0x06, 0xBF, # LD B, 0xBF
0xAF, # XOR A
0xED, 0x79, # OUT (C), A ; write 0
0x7A, # LD A, D
0x3C, # INC A
0x1D, # DEC E
0x20, 0xF1, # JR NZ, .sil (-15)
0xFB, # EI
0xC9, # RET
])
CODE_ADDR = 0x8000
BYTES_PER_FRAME = 14
BANK_SIZE = 16384
BANK_ORDER = [0, 1, 3, 4, 6, 7]
def generate_player_48k(halts=1):
"""Generate 48K player. Returns bytes.
Layout: [DEFW data_addr][DEFW frame_count][DI..CALL PlayFrames..Silence..PlayFrames]
"""
play_bin = generate_play_frames(halts)
code = bytearray()
code += struct.pack('<HH', 0, 0) # +0: data_addr, +2: frame_count
# Entry (+4)
code.append(0xF3) # DI
code += bytes([0xED, 0x56]) # IM 1
code.append(0xFB) # EI
code += bytes([0x2A, 0x00, 0x80]) # LD HL, (0x8000)
code += bytes([0xED, 0x5B, 0x02, 0x80]) # LD DE, (0x8002)
code += bytes([0x0E, 0xFD]) # LD C, 0xFD
play_addr = CODE_ADDR + len(code) + 3 + len(SILENCE_BIN)
code += bytes([0xCD, play_addr & 0xFF, (play_addr >> 8) & 0xFF])
code += SILENCE_BIN # falls through after CALL returns
code += play_bin
return bytes(code)
def generate_player_128k(chunk_frame_counts, bank_order=BANK_ORDER, halts=1):
"""Generate 128K straight-line player. Returns (code_bytes, bank2_data_addr)."""
play_bin = generate_play_frames(halts)
num_paged = len(chunk_frame_counts) - 1
# Calculate layout to find PlayFrames address
entry_len = (1 + 2 + 1 + 2 # DI, IM1, EI, LD C
+ 9 # play bank2: LD HL + LD DE + CALL
+ num_paged * 13 # per paged bank
+ 4 # restore bank 0
+ len(SILENCE_BIN))
play_addr = CODE_ADDR + entry_len
bank2_data_addr = play_addr + len(play_bin)
code = bytearray()
code.append(0xF3) # DI
code += bytes([0xED, 0x56]) # IM 1
code.append(0xFB) # EI
code += bytes([0x0E, 0xFD]) # LD C, 0xFD
# Play bank 2 chunk
fc = chunk_frame_counts[0]
code += bytes([0x21, bank2_data_addr & 0xFF, (bank2_data_addr >> 8) & 0xFF])
code += bytes([0x11, fc & 0xFF, (fc >> 8) & 0xFF])
code += bytes([0xCD, play_addr & 0xFF, (play_addr >> 8) & 0xFF])
# Paged banks
for i in range(num_paged):
bank_val = bank_order[i] | 0x10
fc = chunk_frame_counts[i + 1]
code += bytes([0x3E, bank_val])
code += bytes([0xD3, 0xFD])
code += bytes([0x21, 0x00, 0xC0])
code += bytes([0x11, fc & 0xFF, (fc >> 8) & 0xFF])
code += bytes([0xCD, play_addr & 0xFF, (play_addr >> 8) & 0xFF])
# Restore bank 0
code += bytes([0x3E, 0x10])
code += bytes([0xD3, 0xFD])
code += SILENCE_BIN
code += play_bin
return bytes(code), bank2_data_addr
def split_frames_128k(frames, bank2_capacity):
"""Split frame list into per-bank chunks."""
chunks = []
idx = 0
b2_count = min(bank2_capacity // BYTES_PER_FRAME, len(frames) - idx)
chunks.append(frames[idx:idx + b2_count])
idx += b2_count
per_bank = BANK_SIZE // BYTES_PER_FRAME
while idx < len(frames):
n = min(per_bank, len(frames) - idx)
chunks.append(frames[idx:idx + n])
idx += n
return chunks
def frames_to_bytes(frame_list):
"""Concatenate frame list into raw bytes."""
return b''.join(frame_list)
# ─── TAP Generation ───
def tap_block(flag, data):
payload = bytes([flag]) + data
chk = 0
for b in payload:
chk ^= b
return struct.pack("<H", len(payload) + 1) + payload + bytes([chk])
def tap_header(block_type, name, data_length, param1, param2):
name_bytes = name.encode("ascii")[:10].ljust(10, b" ")
hdr = struct.pack("<B10sHHH", block_type, name_bytes, data_length, param1, param2)
return tap_block(0x00, hdr)
def tap_data(data):
return tap_block(0xFF, data)
def num_token(n):
ascii_part = str(n).encode("ascii")
lo, hi = n & 0xFF, (n >> 8) & 0xFF
return ascii_part + b"\x0e\x00\x00" + bytes([lo, hi, 0x00])
def basic_program(lines):
prog = bytearray()
for line_num, content in lines:
body = content + b"\x0d"
prog += struct.pack(">H", line_num)
prog += struct.pack("<H", len(body))
prog += body
return bytes(prog)
def build_tap_48k(frame_data, player_bin):
"""Build 48K .tap with BASIC loader + player + frame data."""
data_addr = CODE_ADDR + len(player_bin)
data_len = len(frame_data)
frame_count = data_len // BYTES_PER_FRAME
entry_addr = CODE_ADDR + 4
line10 = b"\xfd" + num_token(32767)
line20 = (b"\xef\x22\x22\xaf" + num_token(CODE_ADDR)
+ b"," + num_token(len(player_bin)))
line30 = (b"\xef\x22\x22\xaf" + num_token(data_addr)
+ b"," + num_token(data_len))
line40 = (b"\xf4" + num_token(CODE_ADDR) + b","
+ num_token(data_addr & 0xFF) + b":"
+ b"\xf4" + num_token(CODE_ADDR + 1) + b","
+ num_token((data_addr >> 8) & 0xFF))
line50 = (b"\xf4" + num_token(CODE_ADDR + 2) + b","
+ num_token(frame_count & 0xFF) + b":"
+ b"\xf4" + num_token(CODE_ADDR + 3) + b","
+ num_token((frame_count >> 8) & 0xFF))
line60 = b"\xf9\xc0" + num_token(entry_addr)
prog = basic_program([(10, line10), (20, line20), (30, line30),
(40, line40), (50, line50), (60, line60)])
tap = bytearray()
tap += tap_header(0, "ymplay", len(prog), 10, len(prog))
tap += tap_data(prog)
tap += tap_header(3, "ymplayer", len(player_bin), CODE_ADDR, 0x8000)
tap += tap_data(player_bin)
tap += tap_header(3, "ymdata", data_len, data_addr, 0x8000)
tap += tap_data(frame_data)
return bytes(tap), len(prog), len(player_bin)
def build_tap_128k(chunks, bank_order=BANK_ORDER, halts=1):
"""Build 128K .tap."""
frame_counts = [len(c) for c in chunks]
player_bin, b2_addr = generate_player_128k(frame_counts, bank_order, halts)
num_paged = len(chunks) - 1
b2_data = frames_to_bytes(chunks[0])
code_block = player_bin + b2_data
lines = []
ln = 10
lines.append((ln, b'\xfd' + num_token(32767)))
ln += 10
lines.append((ln, b'\xef\x22\x22\xaf' + num_token(CODE_ADDR)
+ b',' + num_token(len(code_block))))
ln += 10
for i in range(num_paged):
port_val = bank_order[i] | 0x10
chunk_data = frames_to_bytes(chunks[i + 1])
line = (b'\xf4' + num_token(23388) + b',' + num_token(port_val)
+ b':' + b'\xdf' + num_token(32765) + b',' + num_token(port_val)
+ b':' + b'\xef\x22\x22\xaf' + num_token(0xC000)
+ b',' + num_token(len(chunk_data)))
lines.append((ln, line))
ln += 10
lines.append((ln, b'\xf4' + num_token(23388) + b',' + num_token(0x10)
+ b':' + b'\xdf' + num_token(32765) + b',' + num_token(0x10)))
ln += 10
lines.append((ln, b'\xf9\xc0' + num_token(CODE_ADDR)))
prog = basic_program(lines)
tap = bytearray()
tap += tap_header(0, 'ym-128k', len(prog), 10, len(prog))
tap += tap_data(prog)
tap += tap_header(3, 'ymplay128', len(code_block), CODE_ADDR, 0x8000)
tap += tap_data(code_block)
for i in range(num_paged):
bank = bank_order[i]
chunk_data = frames_to_bytes(chunks[i + 1])
tap += tap_header(3, f'bank{bank}', len(chunk_data), 0xC000, 0x8000)
tap += tap_data(chunk_data)
return bytes(tap), len(prog), len(player_bin), num_paged, b2_addr
# ─── Main ───
def main():
parser = argparse.ArgumentParser(
description="Convert YM music files to ZX Spectrum .tap for AY playback"
)
parser.add_argument("input", help="Input .ym file")
parser.add_argument("output", nargs="?", default=None,
help="Output .tap file (default: input name with .tap)")
parser.add_argument("--loops", type=int, default=1,
help="Number of times to play (default: 1)")
parser.add_argument("--skip", type=float, default=0,
help="Skip first N seconds")
parser.add_argument("--seconds", type=float, default=None,
help="Trim to N seconds (after skip)")
parser.add_argument("--128k", action="store_true", dest="mode_128k",
help="128K mode (~2:40 capacity)")
parser.add_argument("--halts", type=int, default=1,
help="HALTs per frame: 1=50Hz, 2=25Hz, 3=17Hz (default: 1)")
args = parser.parse_args()
if args.output is None:
args.output = os.path.splitext(args.input)[0] + '.tap'
# Read YM
print(f"Reading {args.input}...")
frames, clock, freq, loop_frame, title, author = read_ym(args.input)
duration = len(frames) / max(freq, 1)
if title:
print(f" Title: {title}")
if author:
print(f" Author: {author}")
print(f" {len(frames)} frames at {freq}Hz = {duration:.1f}s")
print(f" AY clock: {clock}Hz, loop frame: {loop_frame}")
if freq != 50:
print(f" WARNING: Player freq is {freq}Hz but Spectrum runs at 50Hz")
print(f" Playback will be {'slower' if freq > 50 else 'faster'} than original")
# Apply loops
if args.loops > 1 and loop_frame < len(frames):
loop_section = frames[loop_frame:]
for _ in range(args.loops - 1):
frames.extend(loop_section)
print(f" Looped {args.loops}x: {len(frames)} frames = {len(frames)/50:.1f}s")
# Trim
if args.skip > 0:
skip_frames = int(args.skip * 50)
frames = frames[skip_frames:]
if args.seconds:
max_frames = int(args.seconds * 50)
frames = frames[:max_frames]
# Decimate for --halts
halts = max(1, args.halts)
if halts > 1:
orig_count = len(frames)
frames = frames[::halts]
eff_hz = 50 / halts
print(f" Decimated {orig_count} → {len(frames)} frames "
f"({eff_hz:.0f}Hz, {halts} halts/frame)")
# Calculate capacity
if args.mode_128k:
# Estimate bank2 capacity for max banks
play_bin = generate_play_frames(halts)
num_paged_max = len(BANK_ORDER)
entry_len = (1 + 2 + 1 + 2 + 9 + num_paged_max * 13 + 4
+ len(SILENCE_BIN) + len(play_bin))
bank2_cap = 0xC000 - (CODE_ADDR + entry_len)
max_frames_128k = (bank2_cap // 14
+ num_paged_max * (BANK_SIZE // 14))
if len(frames) > max_frames_128k:
frames = frames[:max_frames_128k]
print(f" Truncated to {max_frames_128k} frames ({max_frames_128k*halts/50:.1f}s)")
else:
player_bin = generate_player_48k(halts)
data_addr = CODE_ADDR + len(player_bin)
max_data = 0x10000 - data_addr
max_frames_48k = max_data // BYTES_PER_FRAME
if len(frames) > max_frames_48k:
frames = frames[:max_frames_48k]
print(f" Truncated to {max_frames_48k} frames ({max_frames_48k*halts/50:.1f}s)")
frame_data = frames_to_bytes(frames)
play_secs = len(frames) * halts / 50.0
print(f"\n Output: {len(frames)} frames, {len(frame_data)} bytes, {play_secs:.1f}s")
eff_hz = 50 / halts
rate_str = f"{eff_hz:.0f}Hz" + (f" ({halts} halts/frame)" if halts > 1 else "")
# Build output
if args.mode_128k:
# Re-estimate with actual paged count needed
# First pass: figure out chunk layout
play_bin = generate_play_frames(halts)
test_entry = (1 + 2 + 1 + 2 + 9 + len(BANK_ORDER) * 13 + 4
+ len(SILENCE_BIN) + len(play_bin))
b2_cap = 0xC000 - (CODE_ADDR + test_entry)
chunks = split_frames_128k(frames, b2_cap)
max_banks = len(BANK_ORDER) + 1
if len(chunks) > max_banks:
chunks = chunks[:max_banks]
tap_bytes, prog_len, player_len, num_paged, b2_addr = \
build_tap_128k(chunks, halts=halts)
with open(args.output, "wb") as f:
f.write(tap_bytes)
total_frames = sum(len(c) for c in chunks)
print(f"\nWritten {args.output} ({len(tap_bytes)} bytes) [128K mode]")
print(f" BASIC loader: {prog_len} bytes")
print(f" Player code: {player_len} bytes at 0x{CODE_ADDR:04X}")
print(f" Bank 2 data: {len(chunks[0])} frames at 0x{b2_addr:04X}")
for i in range(num_paged):
bank = BANK_ORDER[i]
print(f" Bank {bank} data: {len(chunks[i+1])} frames at 0xC000")
print(f" Total: {total_frames} frames = {total_frames*halts/50:.1f}s"
+ (f" at {rate_str}" if halts > 1 else ""))
else:
player_bin = generate_player_48k(halts)
tap_bytes, prog_len, player_len = build_tap_48k(frame_data, player_bin)
with open(args.output, "wb") as f:
f.write(tap_bytes)
data_addr = CODE_ADDR + len(player_bin)
print(f"\nWritten {args.output} ({len(tap_bytes)} bytes)")
print(f" BASIC loader: {prog_len} bytes")
print(f" Player code: {player_len} bytes at 0x{CODE_ADDR:04X}")
print(f" Frame data: {len(frame_data)} bytes at 0x{data_addr:04X}")
print(f" Playback: {play_secs:.1f}s ({len(frames)} frames at {rate_str})")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment