Created
February 10, 2026 21:06
-
-
Save damieng/2dbd82ee88fffa3ad6fcb798aa42a77d to your computer and use it in GitHub Desktop.
Convert Atari ST YM files to ZX Spectrum TAP files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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