Пишем конвертер из RKM в WAV на Python

21 августа 2023

Мне захотелось попробовать разные программы для Микроши. В наши дни они распространяются в виде файлов RKM. Существуют готовые конвертеры из RKM в аудио, но они имеют различные проблемы. Например, WRKWIN32.EXE как будто работает, но мой Микроша отказывается загружать результирующее аудио. То ли не та версия Wine, то ли не та звуковая карта — об истинной причине остается лишь гадать. В общем, долго ли, коротко ли, было решено написать свой конвертер.

С WAV-файлами мне доводилось работать ранее (раз, два), поэтому здесь проблем не предвиделось. Однако формат файлов RKM мне не был знаком. Также я не знал, каким именно образом Микроша сохраняет данные на кассетах. Разобраться в этих вопросах помогла статья Создание демки специально для HABR, Часть 1 и ссылки из нее, в особенности исходники утилиты bin2wav.

Формат RKM оказался простым:

Обозначение       Размер    Описание
-------------------------------------------------------------------
addr_start       2 байта    Начальный адрес в big-endian
addr_end         2 байта    Конечный адрес в big-endian
data          переменный    (addr_end - addr_start + 1) байт данных
checksum         2 байта    Контрольная сумма в big-endian

Обратите внимание, что данные хранятся с addr_start по addr_end включительно.

На кассете используется уже знакомое нам манчестерское кодирование:

Манчестерское / двухфазное кодирование программ на кассете

Этот метод также называется двухфазным кодированием. Есть заранее заданная скорость кодирования информации. Для Микроши она составляет 700 бит/сек. Аудио нарезается на отрезки соответствующей длины. Если на отрезке задний фронт (спад) сигнала, то это ноль, а если передний фронт — это единица.

В байте кодируются сначала старшие биты, затем младшие. Слова кодируются в порядке байт от старших к младшим, то есть, в big-endian. Тут главное не запутаться при написании конвертера. В формате WAV, к примеру, все наоборот, используется little-endian.

Формат данных на кассете оказался таким:

Обозначение       Размер    Описание
-------------------------------------------------------------------
preamble        256 байт    Нулевые байты
sync              1 байт    Байт синхронизации 0xE6
addr_start       2 байта    Начальный адрес
addr_end         2 байта    Конечный адрес
data          переменный    (addr_end - addr_start + 1) байт данных
checksum         2 байта    Контрольная сумма

Конвертер было решено писать на Python, потому что он не требует компиляции и работает везде. Значит, воспользоваться конвертером смогут все желающие.

Хотите верьте, хотите нет, но работать с бинарными файлами на Python мне ранее не доводилось. Впрочем, это оказалось не сложно. Вот пример чтения:

with open(args.input, 'rb') as fin:
    # чтение 16-и битного слова в big-endian
    addr_start = int.from_bytes(fin.read(2), 'big')
    # чтение одного байта
    next_byte = int.from_bytes(fin.read(1), 'big')

А вот пример записи:

with open(args.output, 'wb') as fout:
    # запись типа bytes
    fout.write(b'\x01\x02\x03')
    # то же самое на списках
    fout.write(bytes([1, 2, 3))

Поначалу скрипт тестировался с эмулятором Emu80. На эмуляторе загружать сконвертированные файлы получилось довольно быстро. На этом этапе я знал, что нигде не ошибся с порядком бит или байт, а также что данные, скорее всего, правильно кодируются в WAV-файле. Однако, ситуацию омрачал тот факт, что конвертер работал только с эмулятором, но не с реальным железом.

Понимание проблемы заняло некоторое время. В ходе внимательного сравнения в Audacity моих WAV-файлов с записями, полученными в результате оцифровки кассеты, было найдено лишь одно отличие. Файлы, которые выдавал конвертер, содержали «прямоугольные» сигналы, с резкими фронтами, тогда как на кассете сигналы имели сглаженные фронты. Было решено применить к моим файлам ФНЧ и посмотреть, что будет.

Форма сигнала до и после:

Результат применения ФНЧ в Audacity

К моей радости, отфильтрованный сигнал был успешно обработан Микрошей. Выходит, что проблема заключалась только в резких фронтах. Я не был уверен в истинных причинах такого поведения и обратился за советом в Telegram. Наиболее правдоподобное, на мой взгляд, объяснение предложил GoodLoki: «Резкие фронты в реальной цепи могут создавать слишком большие пики на переходном процессе, которые будут порождать фальшивые фронты и спады».

Получить сглаженный сигнал можно разными способами. Например, можно в правильном порядке склеивать заранее сглаженные куски аудио. Моей первый мыслью было пойти самым простым путем и считать бегущее среднее. Но быстро выяснилось, что для получения красивой формы сигнала, как в Audacity, нужно подбирать весовые коэффициенты. Фактически, получается FIR-фильтр. Ну раз такое дело, было решено использовать полноценный FIR-фильтр. (Технически, бегущее среднее и есть частный случай FIR-фильтра.)

Коэффициенты фильтра были получены при помощи замечательного онлайн-калькулятора fiiir.com, который разработал Tom Roelandts. Существует похожий калькулятор t-filter.engineerjs.com. Как оказалось, тот факт, что фронты в сигнале как-то сглажены, еще не значит, что Микроша сможет его переварить. Поэтому фильтр пришлось подбирать.

Дополнение: Также DSP-фильтры могут быть рассчитаны при помощи SciPy.

В итоге был использован такой:

Filter type:                  Low pass (windowed-sync FIR)
Sampling rate [Hz]:           44100
Cutoff frequency [Hz]:        1600
Transition bandwidth [Hz]:    2200
Window type:                  Hamming

Фильтру требуется 63 коэффициента. Его реализация на Python такова:

FIR_WEIGHTS = [
    0.000577926080672738,
    0.000459465018502213,
    # ... и так далее, всего 63 штуки ...
]
WINDOW_SIZE = len(FIR_WEIGHTS)
fir_window = [128 for i in range(0,WINDOW_SIZE)]

def fir_low_pass_filter(arr):
    global fir_window, FIR_WEIGHTS
    res = []
    for i in range(0, len(arr)):
        fir_window = [arr[i]] + fir_window[0:WINDOW_SIZE-1]
        filtered = int(sum([fir_window[j] * FIR_WEIGHTS[j]
                            for j in range(0,WINDOW_SIZE)]))
        res += [ filtered ]
    return res

Тут следует учесть, что скрипт генерирует 8-и битные WAV-файлы, в которых 0 соответствует минимальному уровню сигнала, 255 — максимальному уровню, а 128 — нулевому уровню. Так как сигнал выходит из фильтра с задержкой, по окончании конвертации нужно прогнать через фильтр немного тишины:

silence = [128 for i in range(0,WINDOW_SIZE)]
fout.write(bytes(fir_low_pass_filter(silence)))
file_size += WINDOW_SIZE

В остальном же работа скрипта тривиальна, поэтому я не вижу смысла разбирать ее более подробно. Полную версию можно скачать здесь.

Большие программы вроде Шахмат на моем железе скрипт переваривает до одной минуты. Небольшие, вроде Тетриса или Сокобана, занимают порядка 10 секунду. Больше всего времени уходит на фильтрацию. Не быстро, но жить можно. Заинтересованным читателям предлагается произвести оптимизацию программы в качестве упражнения. Если же такая задача вам не интересна, предлагаю написать обратный конвертер из WAV в RKM.

Дополнение: С оптимизацией скрипта первым справился @kimstik. Теперь любой RKM файл конвертируется менее, чем за секунду. Оптимизированную версию можно скачать здесь.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.