Внутренности ZXTune: быстрая эмуляция AY/YM

Другие статьи цикла:

  1. Анализ форматов
  2. Быстрая эмуляция AY/YM


В данной статье речь пойдет о способах оптимизации программной эмуляции всем известного музыкального сопроцессора. По сложившейся традиции, будет рассказ об общей идее с элементами псевдокода. Моя же реализация изложенного может быть найдена в исходных кодах проекта ZXTune, в результате работы над которым данное исследование и получилось.

Немного матчасти.

Аппаратная составляющая чипа относительно проста: три генератора тона, генератор шума, генератор огибающей, микшер и ЦАП. Значение уровня сигнала каждого канала, задаваемое для каждого канала индивидуальным регистром громкости или общим на всех уровнем огибающей, может модулироваться сигналом индивидуального генератора тона и/или сигналом общего генератора шума. Микшер управляет источником уровня и модуляторами.
Три одинаковых делителя с разрядностью счетчика в 12 бит каждый служат генераторами тона. Делитель разрядностью 5 бит тактирует генератор шума- сдвиговый регистр с обратной связью. Делитель разрядностью 16 бит тактирует генератор огибающей, изменяющий свой уровень по одному из 16 алгоритмов (на самом деле, 9 — часть алгоритмов совпадают).
С помощью ЦАП уровень преобразуется в напряжение на выходе чипа по логарифмической зависимости.
Широко распространены две разновидности чипа- AY-3-8910 и YM2149F. Отличия между ними с точки зрения генерации звука состоят в параметрах ЦАП и удвоенном числе уровней огибающей у YM (32 против 16). Для упрощения эмуляции, используется 32 уровня, а 16 уровней получаются с помощью квантования.

Основы эмуляции.

Как таковая, эмуляция состоит из двух частей — обновление состояния (реакция на сигналы от тактового генератора) и синтез выходного сигнала.
Обновление выполняется обычно с частотой от 1 до 3.5 МГц (в зависимости от модели эмулируемого компьютера), деленной на 8.
Синтез выполняется либо с частотой звука (от 8 до 96кГц), либо с частотой обновления (результат фильтруется и проходит изменение частоты до звуковой).
Алгоритм обновления выглядит следующим образом:

UpdateChip() : void
  //обновление генераторов тона
  ToneA.Update()
  ToneB.Update()
  ToneC.Update()
  //а также шума и огибающей
  Noise.Update()
  Envelope.Update()

Т.е. просто обновление состояния генераторов в соответствии с параметрами, хранящимися в регистрах управления.

Поскольку все генераторы в основе содержат делители частоты, то общий их вид будет следующим:

Update() : void
  //делитель частоты- 
  //персональный счетчик и период для каждого генератора
  Counter++
  if (Counter >= Period)
    Counter = 0
    //какие-то специфичные для генератора действия
    UpdateLevel();

Алгоритм синтеза:

SynthesizeChip() : void
  //получение внутреннего 5-битного уровня сигнала
  //и преобразование во внешний (8/16/32 и т.п.) по таблице громкости
  volA = VolumeTable[GetLevelA()]
  volB = VolumeTable[GetLevelB()]
  volC = VolumeTable[GetLevelC()]
  //микширование в 2 стереоканала
  left = mixerAL * volA + mixerBL * volB + mixerCL * volC
  right = mixerAR * volA + mixerBR * volB + mixerCR * volC

Дальше выходные уровни поступают на ресемплер или напрямую на воспроизводящее устройство.
Функции получения уровня сигнала выполняют микширование в соответствии с режимами:

GetLevelX() : void
  //модулирование тоном
  if (Registers.HasToneX() and ToneX.IsLow())
    return 0
  //модулирование шумом
  else if (Registers.HasNoiseX() and Noise.IsLow())
    return 0
  //выбор уровня- огибающая или громкость
  else if (Registers.HasEnvelopeX())
    return Envelope.GetLevel()
  else
    return Registers.GetLevelX()

Микрооптимизация для улучшения понимания алгоритма: выходной сигнал модулируется выходами генераторов тона и шума (если они включены для канала), поэтому выходной уровень будет нулевым, если выход соответствующего генератора имеет низкий уровень.

Оптимизируй это!

Пристальный взгляд с прищуром позволяет найти первую, очевидную, «жертву» для оптимизации- синтез. При каждом вызове происходит трехкратное вычисление 5-битного внутреннего уровня канала, который по таблице преобразуется во внешний уровень, а потом три канала преобразуются в два умножением на матрицу микшера. Слишком много действий. Два шага из трех можно вынести «за скобки»- если три 5-битых внутренних состояния преобразовать к одному 15-битному, то из таблицы можно будет сразу брать смикшированный результат. 32768 значений по 16+16 бит (128кб) — небольшая цена за отсутствие лишних доступов к таблице громкости и матрице микшера, а также нескольких умножений и сложений.

Если при инициализации рассчитать табличку:

PrecalcTable() : void
  for idx in 0..((1 << 15) - 1)
    //из индекса получются внутренние уровни для каждого из 3 каналов
    levelA = (idx >> 0) & 31
    levelB = (idx >> 5) & 31
    levelC = (idx >> 10) & 31
    //преобразуются во внешние по таблице
    volA = VolumeTable[levelA]
    volB = VolumeTable[levelB]
    volC = VolumeTable[levelC]
    //микшируются
    left = mixerAL * volA + mixerBL * volB + mixerCL * volC
    right = mixerAR * volA + mixerBR * volB + mixerCR * volC
    //упаковываются в значение из таблицы
    Table[idx] = (right << 16) | left

то процедура синтеза превратится в

SynthesizeChip() : void
  idx = GetLevelA() | (GetLevelB() << 5) | (GetLevelC() << 10)
  level = Table[idx]
  left = level & 0xffff
  right = level >> 16

У данного метода есть один “недостаток” — при постоянном чтении управляющих параметров (таблица выходного сигнала и матрица микшера) их изменения моментально отражались на результате. Теперь же требуется пересчитывать таблицу при изменении любого из вышеуказанных параметров. На самом деле, это совсем не трудная задача, не говоря о ничтожной редкости изменения настроек по сравнению с их использованием.

Финальный штрих

Что еще можно ускорить без кардинальных переделок? Например, получение индекса в таблице:

SynthesizeChip() : void
  level = Levels | (EnvelopeMask * Envelope.GetLevel())
  noise = NoiseMask | Noise.GetLevel()
  toneA = ToneMask | ToneA.GetLevel()
  toneB = ToneMask | ToneB.GetLevel()
  toneC = ToneMask | ToneC.GetLevel()

  idx = level & toneA & toneB & toneC & noise
  level = Table[idx]
  left = level & 0xffff
  right = level >> 16

Envelope.GetLevel() возвращает 5-битное значение, которое умножается на маску огибающей, в которой могут быть установлены 0, 5 и 10 биты. В этом случае в маске уровней Levels будут сброшены биты в диапазонах 0..4, 5..9 и 10..14 соответственно. Иначе там будут находиться значения громкости соответствующего канала. Noise.GetLevel() и ToneX.GetLevel() возвращают либо 0, либо 15 установленных бит, а NoiseMask и ToneMask содержат нули в вышеупомянутых диапазонах, если для соответствующего канала разрешен тон и шум соответственно. После этого остается лишь объединить все значения и получить сразу 15-битное значение состояния (индекса в таблице).

А еще быстрее?

Можно! Но приведенный ниже способ носит скорее ознакомительный характер, нежели руководство к действию. И используется в ZXTune в силу исторических причин- был придуман и успешно опробован до оптимизации, описанной выше.

Как можно заметить, алгоритм обновления состояния копирует поведение реальной микросхемы. Только вот то, что в железе выполняется за один такт, в эмуляторе требует нескольких шагов: 5 обновлений счетчиков, 5 сравнений счетчика с пределом, от 0 до 5 обновлений состояний разной степени сложности. И все только для того чтобы отбросить большую часть результата на микшировании. А что если вычислять выходной уровень генераторов только если это действительно нужно?

Функция UpdateChip не изменилась, но функция обновления состояния генераторов сократилась до:

Update() : void
  //просто обновляем внутреннее состояние
  Counter++

Функция синтеза тоже претерпела некоторые изменения:

SynthesizeChip() : void
  level = Levels
  if (EnvelopeMask != 0)
    //если есть огибающая хоть в одном канале, запрашиваем значение
    level |= EnvelopeMask * Envelope.GetLevel()
  noise = NoiseMask
  if (NoiseMask != 0x7fff)
    //если есть шум хоть в одном канале, запрашиваем значение
    noise |= Noise.GetLevel()
  //генераторы тона учитывают свою маску
  toneA = ToneA.GetLevel(%11111 11111 00000)
  toneB = ToneB.GetLevel(%11111 00000 11111)
  toneC = ToneC.GetLevel(%00000 11111 11111)

  idx = level & toneA & toneB & toneC & noise
  level = Table[idx]
  left = level & 0xffff
  right = level >> 16

Самое интересное теперь кроется в функциях GetLevel() генераторов.
Генератор тона- обычный делитель частоты. Можно представить его как генератор меандра с периодом, равным удвоенному значению делителя:

ToneGenerator.GetLevel(low) : void
  //если тон запрещен, возвращаем полную маску
  if (Masked)
    return 0x7fff
  //приводим счетчик в диапазон меандра
  Counter %= Period * 2
  //если счетчик во второй половине- высокий уровень
  if (Counter >= Period)
    return 0x7fff
  //иначе- низкий для текущего канала
  else
    return low

Как видно, можно совершенно бесплатно получить возможность менять скважность сигнала- достаточно сравнивать счетчик не с Period, а любым значением в диапазоне 0..Period*2. Хотя в железе этого нигде не реализовано, эффект получается интересный:)

Генератор шума- делитель частоты и сдвиговый регистр с обратной связью. И для получения текущего состояния необходимо знать число циклов обновления:

NoiseGenerator.GetLevel() : void
  //считаем сколько циклов прошло с прошлого запроса
  toUpdate = Counter / Period
  Counter %= Period
  //обновляем состояние
  for cycles in 0..toUpdate
    UpdateState()
  if (IsLow())
    return 0
  else
    return 0x7fff

Благодаря SAM style выяснилось, что генератор шума имеет период 131072, поэтому значение можно получить из таблицы:

NoiseGenerator.GetLevel() : void
  //обновляем индекс в таблице на число циклов
  Index += Counter / Period
  Counter %= Period
  return NoiseTable[Index & 0x1ffff]

Генератор огибающей- делитель частоты и машина состояний.

EnvelopeGenerator.GetLevel() : void
  //обновлять состояние имеет смысл,
  //если выходной уровень меняется- Decay=+1/-1
  if (Decay != 0)
    //такой же подсчет прошедшего числа циклов
    toUpdate = Counter / Period
    Counter %= Period
    for cycles in 0..toUpdate
      UpdateState()
  return Level

Несколько замечаний:
— для некоторых аппаратных платформ операция взятия остатка по модулю может быть очень тяжелой, посему на практике применяется банальное вычитание с оптимизацией случаев, когда период является степенью двойки.
— при изменении периода делителя частоты также нужно обновлять состояние, в случае генератора тона еще и учесть позицию в меандре.
— наиболее сильный выигрыш в производительности получается при синтезе с частотой дискретизации звука. К сожалению, такой режим дает невысокое качество звука. Немного исправить ситуацию можно с помощью примитивного фильтра, усредняющего отсчеты. Для наилучшего качества необходимо синтезировать сигнал с тактовой частотой чипа использовать ФНЧ и ресемлер.

Как видно, код получается весьма сложный (потому и не рекомендуется:))
По результатам тестирования, этот способ дал от 15 до 50% прирост скорости в разных тестах.
Способ с таблицей гораздо более эффективный и дал прирост от 300 до 900%(!).

А что в мире?

Ниже представлен беглый анализ эмуляторов AY/YM в разных продуктах.
Общими чертами считаются: обновление состояния и синтез звука с частотой чипа после делителя, процедурная генерация шума и огибающей, преобразование внутреннего уровня во внешний по одной таблице, микширование путем умножения матриц, арифметика целочисленная или с фиксированной запятой. Приведены отличающиеся особенности.

Музыкальные плееры
AY Emul. FIR фильтр 56 порядка (для стандартных настроек) с частотой среза 9200 Гц. Микширование результатов чтения из 6 таблиц (3 входных х 2 выходных канала) с оптимизацией случаев, когда канал промодулирован шумом и/или тоном. Отдельные процедуры генерации моно/стерео и 8/16 бит.
AYFly. IIR фильтр 2 порядка с частотой среза 1/4 от звуковой. Микширование в числах с плавающей запятой. Для версии под S60 синтез выполняется с частотой звука, отсутствует фильтр, микшер жестко зашит в виде L=A+B/2, R=B/2+C.
ZXTune. Для режима с оптимизацией по качеству: IIR фильтр 2 порядка с частотой среза 9500 Гц. Для режима с оптимизацией по скорости работы: обновление состояния и синтез со звуковой частотой, простой FIR фильтр 2 порядка (усреднение). Табличная генерация шума. Табличное преобразование уровня с одновременным микшированием.
Game Music Emu. Табличная генерация огибающей. Тон с частотой выше 16кГц заменяется тишиной. Генератор шума не обновляется, если выключен. Микширование в моно с эффектом псевдостерео.
libayemu. Табличная генерация огибающей. Микширование с оптимизацией случаев, когда в канале отключены шум и тон.
ayumi. 8-кратная передискретизация, квадратичная интерполяция, затем децимирующий FIR фильтр 96 порядка (симметричное окно в 192 элемента). Возможен DC фильтр с усреднением по последним 1024 элементам. Микширование в числах с плавающей запятой.

Эмуляторы
Unreal Speccy. FIR фильтр 40 порядка (для стандартных настроек). 64-кратная передискретизация. Логика в исходнике весьма и весьма запутана, поэтому данные могут быть неточными.
Xpeccy. Обновление состояния и синтез звука с частотой звука. Табличная генерация огибающей и шума. Микширование с оптимизацией случаев, когда в канале отключены шум и тон. Микшер жестко зашит в виде L=A+0.7*B, R=0.7*B + C с возможностью менять раскладку каналов.
ZXMAK.NET. Обновление состояния и синтез с частотой звука. Микширование результатов чтения из трех таблиц (по одной на входной канал).
MAME. Обновление состояния и синтез с частотой звука. Табличное преобразование уровня с одновременным микшированием.

34 комментария

avatar
Почему частоты среза при передискретизации в ауемуле и в сабже такие низкие, 10 кГц всего? В vsid'е частота среза около 20 кГц по дефолту и её можно менять.
  • lvd
  • +1
avatar
Поначалу в сабже частота среза была равна четверти частоты дискретизации. Потом понизил- звук вроде бы аккуратнее стал, на мой взгляд.
avatar
Попробую ещё раз. Почему частота среза фильтра всего лишь 10 кГц (даже 9)?
Почему не 12, не 15, не 20? Я бы сам ответил на этот вопрос как 'чтоб меньше цпу жрало', если бы не такая же частота среза в ayemul (если верить твоим словам), ведь на пц явно нет смысла экономить cpu.

«Звук стал аккуратнее» — ты с реальным АУ сравнивал, или просто на свой вкус тюнил частоту среза? Если с реальным, то, например, по какой схеме он был включен?

Для примера, vsid (плеер .sid'ов из комплекта эмулятора C64 vice) имеет по дефолту частоту среза 21 кГц, и её можно менять. При работе жрёт примерно 20% ядра (на атлохе 3.5 Ггц).
avatar
На свой вкус, конечно. На ЦПУ это никак не отражается.

Разницу ведь никто и не заметил бы, если б я об этом не рассказал. Так что эффект аудиофила налицо:)
avatar
кроме частоты среза важна еще крутизна среза. если это 20 дБ/дек, то даже если спад начинается на 10 кГц, к 20 кГц он не успеет заметно всё зарезать — ты все равно услышишь высокие, только чуть слабже. но это также породит алиасы. тут хз, за что сильнее надо бороться, за частоту среза или чтобы к Fs/2 было все зарезано. чем круче срез, тем сложнее фильтр.
avatar
low pass, high pass фильтры тебе в помощь :)
avatar
И ещё, хороший список методов синтеза АУка. Где синтез на частоте звука — там не АУ, а… жалкое подобие :)
  • lvd
  • 0
avatar
Совершенно верно. Правда, эмуляция на частоте синтеза жрет гораздо больше ресурсов. Для примера, бенчмарк проигрывания .pt3 трека в разных режимах:
— без интерполяции x1600
— с оптимизацией по скорости x1400
— с оптимизацией по качеству x320

Итого разница в 5 раз. Так что для слушания музыки через телефон хватает второго режима- все равно вокруг шумно, а батарейка гораздо экономнее расходуется.
avatar
Логика в исходнике весьма и весьма запутана
и, к тому же, сломана:) но скоро будет новый двиг. может быть, через год:)
  • psb
  • +2
avatar
А если резюмировать, вкратце, какой плеер/эмуль выдаёт наиболее близкий к оригиналу результат?
  • sq
  • +2
avatar
z80stealh (досовский), но его нет в списке :)
avatar
В данный момент — ayumi вне конкуренции. Но новый движок Unreal обещает стать не хуже :)
avatar
Как уже сказал introspec , лидером будет ayumi. Но это не плеер, а, скорее движок. В частности, есть рендерер из простых форматов типа psg в wav и VST плагин.
По поводу z80stealth ничего сказать не могу- сорцов нет. Наверное, аффтар стесняеццо:)
avatar
Если я не ошибаюсь, то скважность можно менять в wild sound 2, примочке на место АУ от Робуса

Vitamin , Мощно!
  • VBI
  • +1
avatar
Ну это аппаратная приблуда, софтверно я нигде не видел. Поэтому сделал сам:)
avatar
VBI а можно ссылко где wild2 обсуждаеться. чтото не нагуглил
avatar
Хорошая тема, весёлая! Vitamin, если верить репликам Deathsoft, новый движок звука в последних версиях ZXMAK2 — порт (старого) движка Unreal.
avatar
Надо будет посмотреть как портировалось. Я себе последнюю извилину вывихнул в попытках раскурить че там да как. Автор USP честно признался, что тупо взял код и не разбирался:)
avatar
Vitamin , а есть смысл приделать на выход подключение например direct x / vst плагинов, для любимого ревера/eq, генераторов шума и т.д. — для реальной ламповости? :)
  • VBI
  • +1
avatar
Сможешь- приделывай:)
А если серьезно, то ящитаю, что такие примочки- дело системы. Она в состоянии перехватить аудиопоток от любого приложения и обработать его как вздумается. В программе это делать мало того что бессмысленно, так еще и вредно.
avatar
Хорошо :) обсуждали вот «ламповость реала и кристальность эмулей», вот и возникло в процессе мыслЯ %)
avatar
Статья про ayumi от автора. sovietov.com/txt/ayumi_resampler/
  • lvd
  • +1
avatar
На хайпе так же от автора: hype.retroscene.org/blog/168.html
avatar
Лично я ужо оценил:)
avatar
Vitamin, у меня быстрый вопрос не по теме. Заметил тулзу, накоженную по заветам гоблина. Насколько реально добавить в неё команду чтобы команда start работала 1/50 секунды? Или, подразумевая бесконечную скороть современного пц, команду задержки на 1/50, чтобы поток в текстовом файле играл полноценный трек? Ну или переформулирую вопрос. Насколько сложно и, соответственно, реалистично добавить к этой программе возможность проигрывания какого-то текстового эквивалента psg?

Вопрос откуда возьмётся текстовый аналог psg предлагаю не обсуждать :)
avatar
Да не сложно. Вопрос- зачем это?

P.S. В силу своей говнистости, я ожидал, что с проблемой синхронизации столкнется сам заказчик, клятвенно утверждавшего, что ничего больше не понадобится:) Но он до сих пор, похоже, ниасилил даже то что есть.
avatar
Как я вижу сейчас — удобный способ нарисовать psg руками и послушать (звуки в мини-интрах часто примерно так и делаются, но с намного меньшим удобством — ассемблер + какой-то проигрыватель -> эмулятор). В перспективе — возможность работы с текстовым представлением psg. Всё остальное вроде удобно и так.
avatar
Сразу дополнение: если делать с командой задержки, видимо было бы неплохо иметь эту команду с аргументом — задержкой в, допустим, тысячных секунды. Это добавит возможностей не усложняя особенно программу.
avatar
Не, лучше ориентироваться на фреймы, ибо в противном случае скорость воспроизведения будет зависеть от скорости работы компа.
avatar
Будет время- забацаю. Пока занимаюсь разгребанием накопившегося говнокода:)
avatar
Спасибо! но всё же подумай о механизме для того, чтобы задавать скорость фреймов в таком случае.
avatar
Чем стандартные 50Гц не устраивают?
avatar
В мини-интро часто бывает экономнее делать пониженную скорость в кадрах в секунду — в этом случае выходит меньше возни с текстовым файлов.
В другой работе — есть некоторые наработки прогрывания псг быстрее чем 50 раз в секунду.
В твоём туле это небольшое отклонение от нормы, кот. закроет оба случая.
avatar
Как же я люблю внятные и аргументированные ТЗ:)
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.