rk86-tape
April 28, 2026 · View on GitHub
Декодирует содержимое магнитофонной записи Радио-86РК (советский домашний
компьютер на базе 8080), оцифрованной в файл .wav, и восстанавливает
исходный поток байтов в том виде, в котором его записал бы (или ожидал
прочитать) ROM-монитор.
В репозитории три взаимозаменяемые реализации одного и того же алгоритма —
Python (main.py), Node/Bun (main.js) и браузерный визуализатор
(docs/index.html), — а также Justfile, который запускает все три на
in.wav и сравнивает их вывод побайтно.
Эталоном кодирования служит исходный текст ROM-монитора Радио-86РК
https://github.com/begoon/rk86-monitor/blob/main/monitor.asm — процедуры
WRBYTE / RDBYTE / WRTAPE / RDTAPE). Этот README написан так, чтобы
формат можно было реализовать заново, опираясь только на этот документ.

Структура проекта
main.py— эталонный декодер на Python. Читаетin.wav, печатает hex-дамп, проверяет заголовок и контрольную сумму.main.js— порт того же алгоритма на Node / Bun. Используетals-wave-parserдля разбора WAV.docs/index.html— самодостаточный браузерный визуализатор (онлайн: https://begoon.github.io/rk86-tape). Перетащите на него.wav, чтобы увидеть осциллограмму, все обнаруженные переходы битов, границы байтов и структурные области (пролог / синхро / заголовок / данные / промежуток / второй синхро / контрольная сумма). Сборка не нужна.in.wav— образец записи (8-битное беззнаковое моно PCM, 22050 Гц, ~13 с, ~290 кБ).in.hex— эталонный hex-дамп декодированного содержимогоin.wav.Justfile—just testзапускает все три декодера и сравнивает результаты.output-python.txt/output-bun.txt/output-node.txt— сохранённый вывод декодеров, используется как эталон сравнения.
Запуск
# Python
python3 main.py
# Node / Bun
node main.js
bun main.js
# Все три, со сравнением между реализациями
just test
# Браузер — локально
open docs/index.html # затем перетащите in.wav на страницу
# Браузер — онлайн
# https://begoon.github.io/rk86-tape
Реализации на Python и Node ищут файл с буквальным именем in.wav в
текущем каталоге. Браузерный визуализатор принимает любой .wav,
перетащенный на страницу.
Требования к WAV-формату
Декодер — это тонкая обёртка над сырыми 8-битными беззнаковыми отсчётами PCM:
- Частота дискретизации: 22050 Гц (константа
BIT_RATE = 1100бит/с даётSAMPLES_PER_BIT = 22050 / 1100 ≈ 20,045). Другие частоты работают, еслиBIT_RATEиSAMPLES_PER_BITпересчитаны согласованно. - Каналы: моно. Реализация на Node и визуализатор берут канал 0, если каналов больше.
- Разрядность: 8 бит без знака (диапазон 0..255, средняя точка 128).
Визуализатор также принимает 16 бит со знаком, беря старший байт:
s8 = (s16 >> 8) + 128. - Порог решения:
0x80(128). Всё>= 128считается логической единицей, ниже — нулём. Прямоугольные сигналы работают; синусоидальные тоже работают, если размах сигнала чисто пересекает порог.
Декодер работает с одномерным массивом 8-битных беззнаковых уровней и после разбора WAV не обращается к его метаданным.
Кодирование битов — Манчестер, конвенция IEEE 802.3 / G.E. Thomas
Каждый бит занимает один битовый период (T = 1 / BIT_RATE ≈ 909 мкс для
прилагаемого образца, ~20 отсчётов при 22050 Гц). Стандартная константа
ROM-монитора tape_write_const = 1Dh = 29 даёт T ≈ 812 мкс
(~1230 бит/с); значение 1100 бит/с в этом декодере — эмпирически
подобрано под прилагаемый in.wav, а смещение в 0,75 T достаточно
терпимо, чтобы поглотить ~10 % дрейфа в любую сторону.
Кодер, согласно entry_outb в monitor.asm (строки 1067-1116),
записывает каждый бит данных b как две полуячейки:
| полуячейка | уровень |
|---|---|
| первая половина | NOT b |
| вторая половина | b |
То есть направление перехода в середине ячейки кодирует бит:
.---. .---.
| | | |
----' '--- vs '---' '---
бит '1' бит '0'
LOW → HIGH HIGH → LOW
(рост в середине) (спад в середине)
1= первая половина LOW, вторая HIGH; в середине ячейки переход снизу вверх; ячейка заканчивается на HIGH.0= первая половина HIGH, вторая LOW; в середине переход сверху вниз; ячейка заканчивается на LOW.
Это конвенция IEEE 802.3 / G.E. Thomas.
Байты передаются старшим битом вперёд (rlc сдвигает старший бит в
позицию младшего перед каждой парой записей полуячеек, см.
monitor.asm:1070).
Когда два соседних бита различаются (например, 10, 01), на границе
битов нет смены уровня — ячейка заканчивается, а следующая начинается на
том же уровне. Когда два соседних бита одинаковы (например, 11, 00),
уровень обязан перебросится обратно на границе, чтобы следующий
переход в середине снова мог пойти в направлении, кодирующем бит; это
порождает дополнительный «граничный» переход в дополнение к
серединному.
Частотный состав совпадает с тем, что описано в комментарии исходника
ROM (monitor.asm:1089-1093):
- Поток одинаковых байтов из
0(или1) → граничный + серединный переход каждые полбита → меандр на битовой частоте (~1,2 кГц при стандартном темпе). - Чередующиеся
0101...→ только серединные переходы, период = 2 бита → меандр на половине битовой частоты (~600 Гц). - Случайные данные → спектр заполняет промежуток между этими частотами.
Декодер должен всегда захватывать серединный переход и игнорировать факультативный граничный. Уловка здесь — продвигаться на 0,75 × битового периода после каждого обнаруженного перехода: это безопасно проскакивает за любой граничный переход (он на +0,5 T от предыдущего серединного), но не доходит до следующего серединного (который на +1,0 T). Первый «отсчёт, чьё значение отличается», найденный после этой точки, и есть следующий серединный переход.
Почему именно 0,75 T?
Это точка приземления с максимальным запасом. После обнаружения серединного перехода декодер должен пропустить вперёд до отсчёта, который:
- за факультативным граничным переходом на
+0,5 T— иначе, когда два соседних бита равны, граничный переброс будет ошибочно прочитан как серединный переход следующего бита; - перед следующим серединным переходом на
+1,0 T— иначе этот переход будет пропущен и декодер потеряет синхронизацию.
Безопасное окно для прыжка — открытый интервал (0,5 T, 1,0 T). Его
середина — 0,75 T, что даёт максимальный запас в обе стороны —
±0,25 T устойчивости к:
- Дрейфу битовой скорости между кодером и декодером (прилагаемый WAV —
~1100 бит/с; стандарт ROM — ~1230 бит/с;
0,75 Tуверенно поглощает это 10 %-ное расхождение). - Дискретизационному джиттеру —
get_bitсообщает о переходе на первом отсчёте, чей уровень отличается от исходного, что может промахнуться на ±1 отсчёт от истинного пересечения. - Медленным фронтам на ленте — фактическое пересечение порога во
времени может быть не строго на
+0,5 T. - Растяжению ленты, плаванию скорости и нелинейности фазы аналогового тракта.
При 22050 Гц / 1100 бит/с T ≈ 20 отсчётов, поэтому допуск на бит —
примерно ±5 отсчётов, с большим запасом.
ROM Радио-86РК на самом деле использует ~0,66 T
(tape_read_const = 2Ah = 42 итерации × 14 мкс ≈ 588 мкс против
стандартного периода в 909 мкс). Это тоже внутри безопасного окна,
просто чуть смещено к «сразу за границей»; работает по тем же причинам.
Любое значение в (0,5 T, 1,0 T) декодировало бы корректно — 0,75 T
просто центрированный выбор с максимальным запасом.
Разбор getBit по шагам
def get_bit(data, i):
v = data[i] # текущий уровень
while i < len(data) and data[i] == v: # пропустить равные отсчёты
i += 1
if i >= len(data):
return None, i
bit = 1 if data[i] >= 0x80 else 0 # уровень после перехода = бит
return bit, i + int(SAMPLES_PER_BIT * 0.75)
Конкретно, при SAMPLES_PER_BIT = 20,045:
floor(0,75 × 20,045) = 15отсчётов вперёд после каждого обнаруженного перехода.- Начав где-то внутри битовой ячейки, процедура сканирует вперёд до первого отсчёта, чей уровень отличается от уровня на входе. Этот индекс — серединный переход. Новый уровень там и есть значение бита.
- Затем прыжок на 15 отсчётов вперёд — за любой факультативный граничный переход — чтобы оказаться глубоко в следующей битовой ячейке, готовым искать следующий серединный переход тем же способом.
Поскольку процедура ищет первое неравенство после входного отсчёта, мелкие пульсации и джиттер внутри полубита её не сбивают: пока сигнал чисто остаётся выше или ниже порога в пределах полуячейки, процедура сработает только когда полуячейка сменит сторону.
Для тех, кто пишет кодер
Чтобы получить ленту, читаемую этим декодером (и ROM-монитором Радио-86РК):
- Для каждого входного бита
bвыводите полуячейкуnot b, затем полуячейкуb. (b == 1→ LOW в течение T/2, затем HIGH в течение T/2;b == 0→ HIGH в течение T/2, затем LOW в течение T/2.) - Байты идут старшим битом вперёд.
- Эквивалентно: разместите серединный переход на +T/2 в направлении,
кодирующем бит; граница (начало следующей ячейки) переходит к
начальному уровню следующего бита, что порождает граничный переход
тогда и только тогда, когда
b_curr == b_next.
Именно это и делает entry_outb в monitor.asm, и ROM использует
tape_write_const (по умолчанию 1Dh = 29, что даёт задержку
полуячейки ~406 мкс при тактовой частоте ЦП Радио-86РК 1,78 МГц).
Структура кадра
Полный блок ленты, ровно как его пишет entry_outblock в
monitor.asm (строка 838 и далее; авторитетной спецификацией является
комментарий в строках 834-836):
$\text{text} +---------------------------+ | 0\text{x00} \times 256 | ракорд ("раккорд") — 256 нулевых байтов +---------------------------+ | 0\text{xE6} (1 байт) | первый синхробайт +---------------------------+ | \text{start\_hi} (1 байт) | заголовок: адрес загрузки, \text{big}-\text{endian} | \text{start\_lo} (1 байт) | | \text{end\_hi} (1 байт) | конечный адрес (включительно), \text{big}-\text{endian} | \text{end\_lo} (1 байт) | +---------------------------+ | данные (\text{size} Б) | \text{size} = \text{end} - \text{start} + 1 +---------------------------+ | 0\text{x00} (1 байт) | завершающий ракорд (2 нулевых байта) | 0\text{x00} (1 байт) | +---------------------------+ | 0\text{xE6} (1 байт) | второй синхробайт +---------------------------+ | \text{checksum\_hi} (1 байт) | 16-битная контрольная сумма, \text{big}-\text{endian} | \text{checksum\_lo} (1 байт) | +---------------------------+ $
Полная длина: 256 + 1 + 4 + size + 2 + 1 + 2 = size + 266 байт на
ленте, или (size + 266) × 8 битовых ячеек, или
(size + 266) × 8 × T секунд.
Нулевой ракорд (256 × 0x00)
Прогон фиксированной длины из 256 нулевых байтов — entry_outblock
пишет их буквальным циклом dcr b после lxi b, 0
(строки 840-848), так что число детерминированное, а не «сколько
пользователь держит клавишу».
Почему 256 и почему нули?
Ничто в протоколе не требует ровно 256 — декодер не считает байты
ракорда, он просто ищет первый 0xE6. Выбор 256 — это
«удобство реализации», а не магия протокола, и он даёт примерно
правильное время по часам для механики ленты:
- 256 — это то, что даёт
dcr bбесплатно. У 8080 нет «сравнить с непосредственным со счётчиком»; самый дешёвый цикл — «декрементировать 8-битный регистр пока не обнулится», что всегда считает ровно 256. Любой другой счёт потребовал бы явногоmvi b, Nи не более естественен, чем 256. - 256 байт ≈ 1,66 с преамбулы при стандартной скорости ROM (256 × 8 × 812 мкс). Этого достаточно, чтобы лентопротяжный механизм набрал устойчивую скорость после PLAY, чтобы успокоился входной АРУ/уровневый тракт, чтобы прошёл любой склейка или зазор лидерной плёнки и чтобы пользователь услышал и подтвердил тон. Другие кассетные форматы 8-битной эпохи (ZX Spectrum, BBC Micro, MSX, Apple II) используют сравнимые пилот-тоны 1-5 с по тем же механическим причинам.
- Самому декодеру почти ничего не нужно. Без PLL — просто «жди следующий переход» — даже ~10-20 нулевых битов достаточно для захвата битовой синхронизации. Остальные ~2030 битов существуют чисто ради аналогово-механической стабилизации, а не ради приёмника.
Почему именно нулевые байты, а не, например, 0xAA:
- Безопасность синхро. Битовый паттерн ракорда, видимый в
8-битном скользящем окне приёмника, никогда не должен совпасть с
0xE6(прямой синхро) или0x19(инвертированный синхро — см. раздел про полярность ниже), иначе приёмник ложно захватится на ракорде. Все нули — простейший такой паттерн: каждое 8-битное окно равно0x00. - Самая высокая чистая несущая частота. Все нули дают самый периодический меандр, на который способен кодер (~1,2 кГц при стандартном темпе — граничный + серединный переход каждые полбита, см. «Кодирование битов» выше). Это самый простой возможный сигнал для стабилизации входного АРУ и компаратора.
- Состояние покоя — LOW. Бит
0заканчивается на LOW, поэтому линия стоит на стандартном уровне покоя, когда ракорд заканчивается и приходит первый бит синхро.
Декодер в этом репозитории не считает байты ракорда — он просто
ищет первый 0xE6. Так что лента с более длинным или коротким
ракордом тоже принимается, лишь бы он был достаточно длинным для
захвата битовой синхронизации приёмника.
Поиск синхробайта — 0xE6 = 0b11100110
Приёмник вдвигает биты по одному в 8-битное скользящее окно и
останавливается в момент, когда окно равно 0xE6:
def seek_sync_byte(data, i):
byte = 0
while True:
bit, i = get_bit(data, i)
if bit is None:
return None, i
byte = ((byte << 1) | bit) & 0xFF
if byte == 0xE6:
return byte, i
Выбор 0xE6 неслучаен: его битовый паттерн 1110 0110 не может
встретиться внутри нулевого ракорда (который порождает только окно
0000 0000), а ведущая 111 — первый момент, когда приёмник видит
три подряд бита 1.
После первого 0xE6 приёмник переключается в байтовый режим:
читает 8 бит, выдаёт байт, повторяет.
Инверсия полярности (только в ROM)
ROM-монитор Радио-86РК (entry_inpb, строки 988-1000) на самом деле
принимает два синхробайта: 0xE6 (прямая полярность) и
0x19 = ~0xE6 (инвертированная полярность). Какой паттерн совпал
первым, определяет флаг полярности (строки 992-1000), и каждый
последующий декодированный байт XOR-ится с 0xFF, если флаг
установлен (строки 1031-1032). Это компенсирует записи, у которых
аудиотракт инвертирует фазу (например, через определённые кабели,
микшеры или звуковые карты).
Декодеры на Python и JS в этом репозитории совпадают только с
0xE6 напрямую. Если у вас инвертированная запись, можно либо
сначала инвертировать отсчёты WAV, либо расширить
seek_sync_byte, чтобы он также совпадал с 0x19 и инвертировал
все последующие байты.
Заголовок (4 байта)
Два big-endian 16-битных слова: начальный и конечный адрес
(включительно) переносимого блока памяти. Следующий за ними блок
данных имеет ровно size = end - start + 1 байт. Отдельного поля
длины нет — длина подразумевается диапазоном адресов.
В прилагаемом in.wav это 1100..129F → size = 0x1A0 = 416 байт.
Данные
size непрозрачных байтов, в порядке загрузки (байт, загружаемый по
адресу start, идёт первым, затем start+1, …, затем end).
Хвост: промежуток + 2-й синхро + контрольная сумма
После данных формат пишет:
- Два байта
0x00. Они не строго обязательны для декодера — служат кратким затишьем, позволяющим кодеру/декодеру выйти из данных-плотных байтов перед следующим синхро. Декодер на Python утверждает, что они равны ровно0x0000. - Второй синхробайт
0xE6. Ресинхронизирует границу байта перед чтением контрольной суммы на случай, если за длинный блок данных накопился дрейф побитного счёта. - 16-битная big-endian контрольная сумма блока данных.
Длина потока
Декодеры на Python и Node просто читают, пока WAV не закончится, а затем используют заголовок, чтобы вырезать ровно осмысленные байты. Всё после 2-байтовой контрольной суммы (тишина, шум, остаточный шорох ленты) игнорируется.
Контрольная сумма — rk86_check_sum
Считается только по данным (decoded[4 .. 4+size]), не по
заголовку и не по хвосту.
def rk86_check_sum(v):
s = 0
j = 0
while j < len(v) - 1: # все байты, кроме последнего
c = v[j]
s = (s + c + (c << 8)) & 0xFFFF # добавить c в ОБЕ половины
j += 1
s_hi = s & 0xFF00
s_lo = s & 0xFF
s = s_hi | ((s_lo + v[j]) & 0xFF) # последний байт → только в LOW
return s
Если читать результат по половинам:
checksum_hi = (сумма v[0 .. n-2]) mod 256checksum_lo = (сумма v[0 .. n-1]) mod 256(то есть(checksum_hi + v[n-1]) mod 256)
Эквивалентно: checksum_lo - checksum_hi ≡ v[n-1] (mod 256). Это
стандартная контрольная сумма ROM-мониторов Радио-86РК / Микроши /
Апогея: дёшево считается инкрементально на 8080 (один ADD A, один
ADC H на байт, со специальным случаем для последнего байта,
пропускающим прибавление к старшему байту).
Для прилагаемого образца контрольная сумма равна 0x3FB0 для блока
данных размером 0x1A0 байт.
Эталонный декодер (псевдокод)
Чистая минимальная реализация читает поток беззнаковых байтов-отсчётов
и выдаёт список декодированных байтов плюс разобранные заголовок и
контрольную сумму. Реализатору нужны два хука ниже; всё в main.py —
тонкая буквальная версия этого.
# Константы
BIT_RATE = 1100 # бит в секунду
SAMPLE_RATE = 22050 # отсчётов в секунду
SAMPLES_PER_BIT = SR / BR # ≈ 20.045 здесь
STEP = floor(0.75 * SAMPLES_PER_BIT) # 15 отсчётов
THRESHOLD = 128 # средняя точка беззнакового 8-бит диапазона
# Битовый уровень: сканируем вперёд до смены уровня; возвращаем новый
# уровень как бит, затем прыжок на 0,75 битового периода.
function getBit(samples, i):
v = samples[i]
while i < len(samples) and samples[i] == v:
i += 1
if i >= len(samples):
return None, i
bit = 1 if samples[i] >= THRESHOLD else 0
return bit, i + STEP
# Ищем первый 0xE6, вдвигая биты в 8-битное скользящее окно.
function seekSync(samples, i):
byte = 0
while True:
bit, i = getBit(samples, i)
if bit is None: return None, i
byte = ((byte << 1) | bit) & 0xFF
if byte == 0xE6: return 0xE6, i
# Читаем целые байты после первого синхро.
function getByte(samples, i):
byte = 0
for j from 7 downto 0:
bit, i = getBit(samples, i)
if bit is None: return None, i
byte |= bit << j
return byte, i
# Верхний уровень
samples = parseWav(input) # 0..255 беззнаковое, моно
i = 0
sync, i = seekSync(samples, i) # съедает пролог + первый 0xE6
bytes = []
while True:
b, i = getByte(samples, i)
if b is None: break
bytes.append(b)
start = (bytes[0] << 8) | bytes[1]
end = (bytes[2] << 8) | bytes[3]
size = end - start + 1
data = bytes[4 : 4 + size]
trailer0 = (bytes[4 + size] << 8) | bytes[4 + size + 1]
trailerE = bytes[4 + size + 2]
checksum = (bytes[4 + size + 3] << 8) | bytes[4 + size + 4]
assert trailer0 == 0x0000
assert trailerE == 0xE6
assert rk86_check_sum(data) == checksum
Замечания по визуализатору (docs/index.html)
Перетащите WAV на страницу. Визуализатор:
- Разбирает WAV вручную, чтобы сохранить исходную частоту дискретизации
(браузерный
decodeAudioDataпересемплирует к частоте audio context, что изменило быSAMPLES_PER_BITи сломало декодер). - Запускает тот же декодер, что
main.py/main.js, но инструментованный, чтобы записывать каждый обнаруженный серединный переход и позицию отсчёта каждого бита. - Рисует:
- Полосу областей (обзор всего файла) с одним цветным сегментом на каждую структурную область — пролог, 1-й синхро, заголовок, данные, промежуток, 2-й синхро, контрольная сумма, хвост.
- Масштабируемую и прокручиваемую осциллограмму с порогом решения пунктирной линией, жёлтыми штрихами в каждом обнаруженном переходе, точками в позиции отсчёта каждого бита (при увеличении), вертикальными линиями на границах байтов и значением декодированного байта подписанным сверху каждой ячейки.
- Hex-дамп, в котором каждый байт окрашен по области; клик по байту центрирует осциллограмму на его диапазоне отсчётов.
Полезно для визуального понимания того, как пролог, поиск синхро и правило серединного перехода Манчестера сходятся вместе.
Проверка совпадения реализаций
just test запускает main.py, bun main.js и node main.js на
in.wav, захватывает строки дампа с префиксом смещения и сравнивает их.
Любое побитовое или побайтовое расхождение между тремя реализациями
обнаруживается сразу.
just test
Полезно при портировании декодера на новый язык: добавьте четвёртый столбец в Justfile, направьте его на новый инструмент и убедитесь в чистом diff.
Ссылки
monitor.asm(исходник ROM-монитора Радио-86РК) — авторитетная ссылка по кодированию. В частности:entry_outblock(строка 838) — полная структура кадра на уровне блока, со сводным комментарием в строках 834-836.entry_outb(строка 1052) — кодер на уровне бита: выдаёт(NOT b), затемbдля каждого бита.entry_inpb/seek_change/next_bit(строка 906 и далее) — декодер на уровне бита: ждёт смену уровня, сэмплирует новый уровень как бит, задержкаtape_read_const × 14 мкс(~588 мкс при стандартном2Ah).- Обработка инверсии полярности: строки 988-1000 (детектирование синхро), 1031-1032 (XOR байта с полярностью).
- Комментарии о таймингах: строки 1080-1093.
- Конвенция Манчестер-кодирования IEEE 802.3 / G.E. Thomas (та, что
использует этот декодер:
1= подъём в середине ячейки,0= спад в середине ячейки).