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 написан так, чтобы формат можно было реализовать заново, опираясь только на этот документ.

Визуализатор WAV-лент Радио-86РК


Структура проекта

  • 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.
  • Justfilejust 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:

  1. Безопасность синхро. Битовый паттерн ракорда, видимый в 8-битном скользящем окне приёмника, никогда не должен совпасть с 0xE6 (прямой синхро) или 0x19 (инвертированный синхро — см. раздел про полярность ниже), иначе приёмник ложно захватится на ракорде. Все нули — простейший такой паттерн: каждое 8-битное окно равно 0x00.
  2. Самая высокая чистая несущая частота. Все нули дают самый периодический меандр, на который способен кодер (~1,2 кГц при стандартном темпе — граничный + серединный переход каждые полбита, см. «Кодирование битов» выше). Это самый простой возможный сигнал для стабилизации входного АРУ и компаратора.
  3. Состояние покоя — 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..129Fsize = 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 256
  • checksum_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 на страницу. Визуализатор:

  1. Разбирает WAV вручную, чтобы сохранить исходную частоту дискретизации (браузерный decodeAudioData пересемплирует к частоте audio context, что изменило бы SAMPLES_PER_BIT и сломало декодер).
  2. Запускает тот же декодер, что main.py / main.js, но инструментованный, чтобы записывать каждый обнаруженный серединный переход и позицию отсчёта каждого бита.
  3. Рисует:
    • Полосу областей (обзор всего файла) с одним цветным сегментом на каждую структурную область — пролог, 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 = спад в середине ячейки).