Из приложения к журналу Info Guide #10, Рязань, 05.2007 ---------------------------------------------------------------- GriV О звуке (в особенности на бипере) Предисловие Тут я немного постараюсь обобщить всё то, что мы знаем о Спектруме в качестве звукового комбайна. Возможно, здесь я чего-то упущу (я почти ничего не знаю о приставках Covox, GS и т.д.), однако они стоят далеко не у каждого пользователя, потому отсутствие их обзора с этой точки зрения является целесообразным. Впрочем, кто захочет, тот добавит. ---------------------------------------------------------------- О захвате звука Вообще говоря, Спектрум обладает слабыми возможностями по захвату звука. Это 1-битный компаратор на магнитофонном входе, причём качество схемы включения компаратора зачастую оставляет желать лучшего. Тем не менее, существует много утилит и программ, предназначенных для захвата и последующего воспроизведения сигнала с этого компаратора. Cамые простые и самые известные программы для прослушивания этого порта (по-английски он называется "УХО" - "EAR") - это копировщики и собственно подпрограммы для загрузки с магнитофона. Конечно, в определённой степени звук, который можно услышать при загрузке с кассеты, можно назвать музыкальным, но вообще говоря, это не музыка. Кроме того, есть программы-осциллографы (показывают приближенное частотное разложение сигнала, поступающего с "УХА"). 1.1. Линейное кодирование Те, кто давно занимается Спектрумом, помнят, может быть, пакет 20 super routines. В этом пакете в том числе были программы для оцифровки и вопроизведения полноценного звукового ряда, хотя их звучание оставляет желать лучшего. Вот приближённо их исходный текст (детали здесь не так важны): Программа-оцифровщик (привожу только тело - интерфейс там, в принципе, везде одинаковый): LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD D,8 LOOP1 IN A,(#FE) ; читаем порт AND A ; сбрасываем CY BIT 6,A ; выделяем бит JR Z,LOOP2 SCF ; устанавливаем CY по необходимости LOOP2 LD B, ; !цикл задержки LOOP3 NOP DJNZ LOOP3 RRC E ; пишем флаг переноса DEC D ; 8 бит делаем JR NZ,LOOP1 LD (HL),E ; пишем в память INC HL LD A,H ; все 64K заполняем OR L JR NZ,LOOP0 RET Т.е. по сути она будет писать (отображать) состояние "уха" в память, чтобы потом его воспроизвести: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD D,8 LD E,(HL) LOOP1 RRC E ; читаем бит LD A,0 ; приводим в соответствие биту байт JR Z,LOOP2 LD A,255 LOOP2 LD B, ; !цикл задержки LOOP3 NOP DJNZ LOOP3 OUT (#FE),A DEC D ; 8 бит делаем JR NZ,LOOP1 INC HL LD A,H ; все 64K проходим OR L JR NZ,LOOP0 RET Т.е. почти та же программа, но уже на вывод данных. Этот алгоритм чрезвычайно моден (был, во всяком случае), и многие изобретатели велосипедов (и я в том числе) делали его модификации. В основном всё, что делалось - это вводились опции для работы с расширенной памятью (128К и выше). Оптимизация же работы программы редко проводилась. На самом деле, даже с первого взгляда видно, что внесение задержки (метка Loop2 и Loop3) не оправдано, так как, вообще говоря, с одним битом и так всё пишется не очень-то и качественно, а за счёт задержки звук страдает просто капитально. Т.о., естественная мысль, приходящая в голову,- убрать задержку. LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD D,8 LOOP1 IN A,(#FE) ; читаем порт AND A ; сбрасываем CY BIT 6,A ; выделяем бит JR Z,LOOP2 SCF ; устанавливаем CY по необходимости LOOP2 RRC E ; пишем флаг переноса DEC D ; 8 бит делаем JR NZ,LOOP1 LD (HL),E ; пишем в память INC HL LD A,H ; все 64K заполняем OR L JR NZ,LOOP0 RET и LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD D,8 LD E,(HL) LOOP1 RRC E ; читаем бит LD A,0 ; приводим в соответствие биту байт JR Z,LOOP2 LD A,255 LOOP2 OUT (#FE),A DEC D ; 8 бит делаем JR NZ,LOOP1 INC HL LD A,H ; все 64K проходим OR L JR NZ,LOOP0 RET Далее, поклонники раздела "Этюды" журнала ZX-Review заметят, что есть моменты в теле программы, которые могут быть написаны гораздо быстрей и красивей. Например, конструкция SBC A,A - которая заполняет флагом переноса аккумулятор. Тогда проигрыватель будет: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD D,8 LD E,(HL) LOOP1 RRC E ; читаем бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A DEC D ; 8 бит делаем JR NZ,LOOP1 INC HL LD A,H ; все 64K проходим OR L JR NZ,LOOP0 RET Далее, можно развернуть цикл LD D,8 ... DEC D : JR NZ,LOOP1 (как в оцифровщике, так и в проигрывателе). LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP1 IN A,(#FE) ; читаем порт AND A ; сбрасываем CY BIT 6,A ; выделяем бит JR Z,LOOP2 SCF ; устанавливаем CY по необходимости RRC E ; пишем флаг переноса ; LOOP2 IN A,(#FE) AND A BIT 6,A JR Z,LOOP3 SCF RRC E ; ... ; это тело работает 8 раз ; LOOP8 IN A,(#FE) AND A BIT 6,A JR Z,LOOP9 SCF RRC E ; собственно восьмой раз ; ; LOOP9 LD (HL),E ; пишем в память INC HL LD A,H ; все 64K заполняем OR L JR NZ,LOOP1 RET и проигрыватель: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD E,(HL) RRC E ; читаем бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A ; RRC E SBC A,A OUT (#FE),A ; ... ; тоже 8 раз ; RRC E SBC A,A OUT (#FE),A ; тоже восьмой раз ; ; INC HL LD A,H ; все 64K проходим OR L JR NZ,LOOP0 RET Однако теперь, если сделать и запустить эту пару, то можно услышать, что записанный звук проигрывается в каком-то шустром темпе. И вот почему: RRC E 8 тактов SBC A,A 4 такта OUT (#FE),A 11 тактов ----------- Итого: 23 такта на цикл воспроизведения IN A,(#FE) 11 тактов AND A 4 такта BIT 6,A 8 тактов JR Z,LOOP2 12 тактов 7 тактов SCF 4 такта RRC E 8 тактов ----------- Итого: 41 и 40 тактов на цикл записи Т.е. при написании пары оцифровщик/проигрыватель обязательно надо их синхронизировать по времени выполнения операций. Естественно, лучше разгонять, чем тормозить, поэтому хочется подумать: а как же ускорить цикл записи? Во-первых, надо избавиться от ветвления, которое кроме прочего вводит зависимость от введённых данных (единичка или нолик). Как это сделать? Что необходимо получить: значение бита порта Как необходимо получить: безразлично Куда его надо поместить: в флаг переноса Ну, можно здесь всякое придумывать, но реально единственная конструкция, которая помещается в 23 такта, будет следующая: IN A,(#FE) 11 тактов ADD A,#40 7 тактов RRC E 8 тактов ----------- Итого: 26 тактов на цикл записи Это работает, если старший бит 254 порта всегда установлен в единицу. (Если он всегда в нуле, то вместо ADD надо использовать SUB.) Когда прибавляем к значению порта 64, то если в шестом бите нолик, то в результате получится CY=0, если же в шестом бите единичка, то будет установлен CY=1, что, в принципе, и надо. А откуда же 23 такта? Дело в том, что можно сделать так: вместо ADD A,#40 записать ADD A,D, где в D перед этим записать #40. Тогда конструкция будет такая: IN A,(#FE) 11 тактов ADD A,D 4 тактов RRC E 8 тактов ----------- Итого: 23 такта на цикл записи Т.о. оцифровщик: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LD D,#40 LOOP0 IN A,(#FE) ; читаем порт ADD A,D ; делаем (6,#FE)->CY RRC E ; сохраняем бит ; IN A,(#FE) ADD A,D RRC E ; ... ; это тело работает 8 раз ; IN A,(#FE) ADD A,D RRC E ; собственно восьмой раз ; ; LD (HL),E ; пишем в память INC HL LD A,H ; все 64K заполняем OR L JR NZ,LOOP0 RET и проигрыватель: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD E,(HL) RRC E ; читаем бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A ; RRC E SBC A,A OUT (#FE),A ; ... ; тоже 8 раз ; RRC E SBC A,A OUT (#FE),A ; тоже восьмой раз ; ; INC HL LD A,H ; все 64K проходим OR L JR NZ,LOOP0 RET После этого можно заметить следующее: сам цикл фиксации и воспроизведения длится 23 такта, однако конструкция INC HL 6 такта LD A,H 4 такта OR L 4 такта JR NZ,LOOP0 12 такта ----------- Итого: 26 тактов на байт а с учётом процедуры записи эта цифра вырастает до 26+7=33 такта. Эта величина уже сравнима с длительностью двух циклов, что, конечно, является недостатком. Можно заменить JR на JP - это сократит выполнение служебной конструкции до 31 такта, однако это сокращение не радикально. Можно использовать такую конструкцию: LD (HL),E 7 INC L 4 JP NZ,LOOP0 10 ----------- Итого: 21 такт на байт вместо 33 однако такая конструкция усложняется тем, что потом надо учитывать и старшую половинку HL - добавлять: INC H 4 JP NZ,LOOP0 10 ----------- Итого: 16 такт на 256 байт вместо 0 Однако просчёт старшей половинки будет осуществляться раз в 256 байтов по 8 циклов каждый, итого (23*8 + 21)*256 = 52480 тактов, т.е. почти прерывание, в то время как просчёт младшей половинки осуществляется каждый цикл. Т.о. финальный (самый быстрый) линейный оцифровщик будет выглядеть так: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LD D,#40 LOOP0 IN A,(#FE) ; читаем порт ADD A,D ; делаем (6,#FE)->CY RRC E ; сохраняем бит ; IN A,(#FE) ADD A,D RRC E ; ... ; это тело работает 8 раз ; IN A,(#FE) ADD A,D RRC E ; собственно восьмой раз ; ; LD (HL),E ; пишем в память INC L ; проверка окончания памяти JP NZ,LOOP0 INC H JP NZ,LOOP0 RET и простейший линейный проигрыватель: LD HL,BEGIN_ADDR ; адрес начала, куда пишутся данные LOOP0 LD E,(HL) RRC E ; читаем бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A ; RRC E SBC A,A OUT (#FE),A ; ... ; тоже 8 раз ; RRC E SBC A,A OUT (#FE),A ; тоже восьмой раз ; ; INC L ; проверка окончания памяти JP NZ,LOOP0 INC H JP NZ,LOOP0 RET Такой алгоритм подразумевает частоту дискретизации порядка 150 кГц. 1.2. Кодирование со сжатием Другой алгоритм - подразумевающий сжатие - использует в основе своей априорную информацию о характере сигнала. За редким исключением, если рассмотреть сигнал, полученный на основе линейного оцифровшика, то можно заметить, что единичка, как правило, соседствует с несколькими другими единичками, а нолики - с несколькими другими ноликами. Естественно, встаёт вопрос о сжатии/компрессии этого звукового сигнала. Первой программой, которая учитывала этот момент, была Speak Easy. Вот её дизассемблер: LD HL,BEGIN_ADR ; адрес начала записи JR LOOP1 ; обход записи D LOOP0 LD (HL),D ; запись данных INC HL ; проверка на окончание области памяти ;(6+4+4+5 тактов) LD A,H OR L RET Z LOOP1 LD D,0 ; начальный счётчик на "1" LOOP2 IN A,(#FE) ; опрос порта BIT 6,A ; проверка бита на соответствие JR Z,LOOP3 ; если не соответстует, то прыжок - ;условие Z INC D ; иначе увеличение счётчика JR LOOP2 LOOP3 LD (HL),D ; запись данных INC HL ; проверка на окончание области памяти LD A,H OR L RET Z LD D,0 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; далее аналогично BIT 6,A JR NZ,LOOP0 ; только проверка уже на "1"-условие NZ INC D JR LOOP4 Я точно не уверен, но, по-моему, в оригинальном алгоритме в том числе были циклы задержки: LD B,XX DJNZ $ Алгоритм вывода таких данных очень прост: LD HL,BEGIN_ADR ; начальный адрес LOOP1 LD B,(HL) LOOP2 LD A,0 OUT (#FE),A ... ; здесь находятся синхронизирующие ;команды, уравнивающие время работы ; цифровщика и проигрывателя DJNZ LOOP2 INC HL ; проверка окончания LD A,L OR H RET Z LD B,(HL) LOOP3 LD A,255 OUT (#FE),A ... ; здесь находятся синхронизируюшие ;команды, уравнивающие время работы DJNZ LOOP3 INC HL ; проверка окончания LD A,L OR H JR NZ,LOOP1 RET Как можно увидеть, оригинальный алгоритм не свободен от недостатков, которые были у оригинального предыдущего алгоритма. Во-первых, задержки DJNZ в оцифровщике и такты простоя в проигрывателе. Во-вторых, те же "INC HL : LD A,H : OR L" и те же сложные ветвящиеся конструкции. Т.о., даже на первый взгляд очевидно, что и эти циклы можно упростить и ускорить при сохранении той же функциональности. "Классическая" замена INC HL на INC L: LD HL,BEGIN_ADR ; адрес начала записи JR LOOP1 ; обход записи D LOOP0 LD (HL),D ; запись данных INC L ; проверка на окончание области памяти ;(4+7 тактов на проверку вместо 19) JR Z,LOOP5 ; прыжок если L=0 LOOP1 LD D,0 ; начальный счётчик на "1" LOOP2 IN A,(#FE) ; опрос порта BIT 6,A ; проверка бита на соответствие JR Z,LOOP3 ; если не соответстует, то прыжок - ;условие Z INC D ; иначе увеличение счётчика JR LOOP2 LOOP3 LD (HL),D ; запись данных INC L ; смотрите ниже, в пояснениях LD D,0 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; далее аналогично BIT 6,A JR NZ,LOOP0 ; только проверка уже на "1"-условие NZ INC D JR LOOP4 LOOP5 INC H ; проверяем старший регистр пары JR NZ,LOOP1 RET Кроме того, было сделано следующее допущение: счёт начинается с ЧЁТНОГО адреса, т.е. проверка на 0 проводится только раз в два цикла записи в память. Во втором же цикле (когда число, хранящееся в HL, становится нечётным) предполагается, что происходит просто увеличение L, и старший регистр пары остаётся неизменным. Только на циклах записи выигрывается (19+19) - (11+4) = 23 такта, что для конечной модификации предыдущего алгоритма составляет целый такт работы! Далее, "долгую" команду BIT можно заменить на более быструю "ADD A,", что даст прирост на каждом цикле опроса в 4 такта (описание этого приёма было уже приведено выше). Команды удержания циклов JR LOOP4 и JR LOOP2 можно заменить на их более быстрые аналоги JP LOOP4 и JP LOOP2, которые, правда, имеют больший размер (3 против 2 байт). С учётом этой косметики программное тело будет следующим: LD HL,BEGIN_ADR ; адрес начала записи LD E,64 ; заранее заносим константу в E JR LOOP1 ; обход записи D LOOP0 LD (HL),D ; запись данных INC L ; проверка на окончание области памяти ;(14 тактов на проверку вместо 19) JR Z,LOOP5 ; прыжок если L=0, эта команда в случае ;простоя длится 7 тактов LOOP1 LD D,0 ; начальный счётчик на "1" LOOP2 IN A,(#FE) ; опрос порта ADD A,E ; проверка бита на соответствие JR C,LOOP3 ; если не соответстует, то прыжок - ;условие C INC D ; иначе увеличение счётчика JP LOOP2 LOOP3 LD (HL),D ; запись данных INC L LD D,0 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; далее аналогично ADD A,E JR NC,LOOP0 ; только проверка уже на "1"-условие NC INC D JP LOOP4 LOOP5 INC H ; проверяем старший регистр пары JR NZ,LOOP1 ; эта команда выполняется чрезвычайно ;редко, потому её ; размер важней скорости RET Рабочий цикл опроса "УХА" теперь длится: LOOP4 IN A,(#FE) 11 ADD A,E 4 JR NC,LOOP0 7 - условие несрабатывания INC D 4 JP LOOP4 10 ---- Итого 36 тактов Опять же, скорость рабочего цикла опроса этого алгоритма становится сравнимой со скоростью рабочего цикла опроса конечной модификации предыдущего алгоритма. Однако в этот раз процесс идёт наряду с простейшим сжатием! При тестировании этого алгоритма у меня возникла следующая проблема: звук при воспроизведении иногда заметно ускорялся, причём каждый раз это было в достаточно определённые моменты (подразумевается, что такты работы оцифровщика и проигрывателя выравнены). В поисках причин я начал копать код - и вот что обнаружил. В результате анормального ускорения оцифровщика, которое я осуществил, реально возникает ситуация, когда один и тот же уровень сигнала держится на "УХЕ" более 256 рабочих циклов опроса порта: имеется команда INC D JP LOOP4 которая не проверяет выход за предел в 256 значений. Возникает такая сложность: - Хранить для этой информации два байта? Но тогда потери памяти при быстрых сменах сигнала EAR будут колоссальны - Использовать сигнальные биты - какой тип сжат? - усложнение программы, добавление ветвлений, без которых никак не обойтись, плюс потеря (сигнального) бита на каждом записанном байте - Можно ничего не хранить - пусть алгоритм как-нибудь сам справляется :) Для проверки последнего предположения были разработаны два алгоритма одинаковой длительности (в тактах) рабочего цикла опроса и записи в память. Один учитывал такой переход следующим образом: LOOP3 LD D,0 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; опрос "УХА" ADD A,E JR NC,LOOP0 ; переход в случае смены сигнала INC D ; увеличение JP NZ,LOOP4 LD (HL),D INC L ; т.е. в оцифровщике проскакивается ;следующая часть INC L ; программы - опрос противоположного ;состояния "УХА" JP NZ,LOOP3 ; также пропускается само значение ;следующего байта INC H JP NZ,LOOP3 RET и, соответственно, в проигрывателе: LD B,(HL) ; загрузка счётчика LOOP1 OUT (C),D ; В D уже записано #FF,в E записано #00 NOP DJNZ LOOP1 LD A,(HL) AND A ; если здесь 0, то переход на часть, JR Z,LOOP2 ;которая отрабатывает "длинный" сигнал, ;разбросанный в нескольких байтах ; это ветвление загромождает программу, ;замедляя её на не связанных ; с опросом "УХА" программных циклах А во второй программе просто по окончании регистра D (при опросе) осуществлялся переход на следующую часть п/п - на п/п опроса потивоположного состояния "УХА": LD HL,BEGIN_ADR LD E,#40 JR LOOP1 LOOP0 LD (HL),D INC L JR Z,LOOP5 ; эта команда в случае простоя длится ;всего 7 тактов LOOP1 LD D,0 ; начальный счётчик на "0" LOOP2 IN A,(#FE) ; опрос "УХА" ADD A,E JR C,LOOP4 ; переход в случае смены сигнала INC D ; увеличение JP NZ,LOOP2 LD (HL),D ; просто запись значения, как будто ;пришёл переход INC L ; состояния "УХА" LD D,0 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; опрос "УХА" ADD A,E JR NC,LOOP0 ; переход в случае смены сигнала INC D ; увеличение JP NZ,LOOP4 JP LOOP0 LOOP5 INC H JR NZ,LOOP1 RET И в результате работы этих программ существенного отличия на слух звукового сигнала, который был записан и вопроизведён обоими алгоритмами, не было замечено. Как-то внятно это объяснить я не могу, хотя и попытаюсь. Скорей всего, дело в том, что хотя циклы записи данных в память были ускорены максимально, их длительность, сравнимая с длительностью рабочего цикла опроса, приводит к тому, что сигнал "УХА" успевает сменяться на противоположное состояние. Также возможно, что из-за большой длительности сигнала одного состояния наиболее вероятно, что изменение сигнала "УХА" на противоположное происходит именно к тому времени, когда регистр закончил счёт (дошёл до 255+1). Причиной смены сигнала может быть элементарно помеха. Кроме того, за счёт частоты рабочего цикла опроса (а значит, и вывода), равной 3,5 МГц / 36 тактов = 97 КГц, случай неверного перехода из-за переполнения регистра просто невозможно воспринять на слух. Откуда был сделан вывод, что не имеет смысла загромождать лишними проверками проигрыватель и оцифровщик и лучше выбрать второй алгоритм для оценки переполнения (которое всё же происходит достаточно редко), к тому же, таким образом сокращается длина (в тактах) неосновных циклов, почему было принято решение "забить" на эту ;))))). Теперь, глядя на полученный перл, кажется, что улучшить его некуда. На самом деле, если заменить увеличение на уменьшение, то получится следующий алгоритм: LD HL,BEGIN_ADR LD E,#40 JR LOOP1 LOOP0 LD (HL),D INC L JR Z,LOOP5 ; эта команда в случае простоя длится ;всего 7 тактов LOOP1 LD D,255 ; начальный счётчик на "0" LOOP2 IN A,(#FE) ; опрос "УХА" ADD A,E JR C,LOOP4 ; переход в случае смены сигнала DEC D ; увеличение JP NZ,LOOP2 LD (HL),D ; просто запись значения, как будто ;пришёл переход INC L ; состояния "УХА" LD D,255 ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; опрос "УХА" ADD A,E JR NC,LOOP0 ; переход в случае смены сигнала DEC D ; увеличение JP NZ,LOOP4 JP LOOP0 LOOP5 INC H JR NZ,LOOP1 RET Что это даёт? Это даёт возможность команды DEC D JP NZ,LOOPX заменить на более короткий и лаконичный DJNZ LOOPX с заменой счётчика на регистр B, конечно :) Т.о., теперь оцифровщик будет выглядеть так: LD HL,BEGIN_ADR LD E,#40 LD D,#FF LOOP1 LD B,D ; начальный счётчик на "0" - 4 такта ;вместо имеющихся в ; этом месте ранее 7 тактов от LD B,255 LOOP2 IN A,(#FE) ; опрос "УХА" ADD A,E JR C,LOOP3 ; переход в случае смены сигнала DJNZ LOOP2 ; счётчик в B LOOP3 LD (HL),B ; просто запись значения, как будто ;пришёл переход INC L ; состояния "УХА" LD B,D ; начальный счётчик на "0" LOOP4 IN A,(#FE) ; опрос "УХА" ADD A,E JR NC,LOOP0 ; переход в случае смены сигнала DJNZ LOOP4 ; счётчик в B LOOP0 LD (HL),B ; эту часть теперь оптимальней ;расположить здесь INC L ; т.к. неосновные циклы сокращаются JP NZ,LOOP1 ; здесь в любом случае присутсвовал ;JP плюс затем отрабатывался ; ещё и JR в ложном условии - 7 тактов INC H JR NZ,LOOP1 RET Теперь рабочий цикл опроса длится: LOOP4 IN A,(#FE) 11 ADD A,E 4 JR NC,LOOP0 7 DJNZ LOOP4 13 ---- итого 35 тактов Кроме уменьшения просто длины рабочего цикла опроса "УХА" укоротились неосновные циклы записи/чтения. А вот проигрыватель для такого оцифровщика: LD HL,BEGIN_ADR LD C,#FE ; порт для команды OUT (С) а также ;значение для вывода в ; команде OUT (C),C LD D,0 ; предустановленное значение для вывода ;в порт LOOP1 LD B,(HL) ; начальный счётчик на "0" LOOP2 OUT (C),D ; вывод 0 DEC B ; счётчик в B - убрали команду DJNZ,так ;как она не подходит JR Z,LOOP3 ; по тактовой длительности JR NZ,LOOP2 ; весь цикл построен таким образом, ; чтобы имитировать работу оцифровщика LOOP3 NOP ; это компенсация для уравнения ;длительности неосновных циклов ; проигрывателя и оцифровщика INC L ; увеличение LD B,(HL) ; начальный счётчик на "1" LOOP4 OUT (C),C ; вывод 254 (#FE) DEC B ; аналогично имитируется ход работы JR Z,LOOP0 ; оцифровщика JR NZ,LOOP4 ; LOOP0 LD (HL),B ; проверка на окончание INC L JP NZ,LOOP1 INC H JR NZ,LOOP1 RET Для такой пары проигрыватель-оцифровщик частота рабочего цикла опроса "УХА" будет 3,5 МГц / 35 = 100 кГц - это, конечно, меньше, чем для конечной модификации предыдущего алгоритма, однако здесь у нас сжатие... А можно ли ЕЩЁ быстрей? Можно, только сложно. Теперь ускорения такими методами оптимизации просто невозможно добиться. Придётся из рабочего цикла (там всего-то 4 команды... хе-хе...) выкидывать какую-то команду. Первый претендент на уход - DJNZ, он длится 13 тактов - почти треть цикла. Теперь тело основного рабочего цикла будет таким: LOOP2 IN A,(#FE) ; опрос "УХА" ADD A,E JR C,LOOP3 ; переход в случае смены сигнала DEC B IN A,(#FE) ; опрос "УХА" ADD A,E JR C,LOOP3 ; переход в случае смены сигнала DEC B ..... И так 256 раз. Длина (в тактах) у такой конструкции будет: IN A,(#FE) 11 ADD A,E 4 JR C,LOOP3 7 DEC B 4 --- итого 26 тактов - на 9 тактов короче - существенный (на 26%) прирост. Однако длина (в байтах) такой конструкции будет IN A,(#FE) 2 ADD A,E 1 JR C,LOOP3 2 DEC B 1 --- итого 6 байт на один цикл 6*256 = 1,5k на один только цикл опроса на наличие "0". А надо ещё опрашивать на наличие "1" на "УХЕ". Кроме того, есть ещё (кроме оцифровщика) распаковщик, всё вместе будет занимать 6 кбайт + копейки, почти весь экран Спектрума! Кстати, в связи с этим первые версии компилировались именно туда! :D Команда JR не способна охватить все 256 циклов (прыгнуть в конец цикла), поэтому её надо будет заменить на JP, что увеличит размер как в тактах, так и в байтах: IN A,(#FE) 11 ADD A,E 4 JP С,LOOP3 10 DEC B 4 --- итого 29 тактов - на 6 тактов короче - прирост на 18%. Частота при таком рабочем цикле опроса "УХА" будет 120 кГц. IN A,(#FE) 2 ADD A,E 1 JP C,LOOP3 3 DEC B 1 --- итого 7 байт на один цикл И на весь комплект (оцифровщик + проигрыватель) около 7*256*4=7k. Что можно здесь сделать? Программа хотя и стала более оптимальной по скорости, не является оптимальной по размеру. Надо задейстовать самый быстрый инструмент, который есть у Z80 - стек. Каким образом? Мы можем использовать такую хитрость - команду перехода через стек - RET, её при заданных значениях, лежащих на вершине стека, можно использовать как JP (SP). Но сама команда RET неинтересна, гораздо интересней её братья - RET Z, например, или RET NC. Тогда, если учесть что на стеке находятся нужные значения, рабочий цикл опроса будет иметь вид: IN A,(#FE) 11 ADD A,E 4 RET C 5 - условие ложно DEC B 4 --- итого 24 такта - ещё на 5 тактов короче - суммарный прирост на 32%! Размер: IN A,(#FE) 2 ADD A,E 1 RET C 1 DEC B 1 --- итого 5 байт на один цикл что в сумме будет 5*256*4=5k - уже гораздо лучше. Строка RET C в циклах опроса "1" будет меняться на RET NC в циклах опроса "0" в "УХЕ". Сама процедура подготовки стека будет состоять в следующем: LD SP,STACK_ADDR ; адрес стека LD B,128 ; количество повторов LD HL,LOOP1 ; адрес, который заносится на стек LD DE,LOOP2 ; другой адрес LOOP0 PUSH HL PUSH DE DJNZ LOOP0 ... Почему именно 256 (128 повторов по 2 значения) значений переходов? Да просто именно раз в 256 циклов у нас срабатывает переход: INC L JP Z,LOOP4 .... LOOP4 LD SP,STACK_ADDR-512 ; 256 значений по 2 байта - ;смещение INC H ; т.о., на стеке снова "оказались" ;нужные значения JP NZ,LOOP1 ; переходов, и можно заново ;запускать циклы ... EXIT Далее восстанавливается стек и осуществляется выход. И в сумме это будет 5,5k, плюс данные на стек, плюс управляющий код. Теперь, развернув циклы, кажется, что невозможно ещё что-либо улучшить. И сократить какую-то команду никак нельзя. Сократить нельзя, а вот всё-таки улучшить можно. Есть такая команда, она т.н. полудокуметированная: IN F,(C). Где-то эту команду называют INF. Я изучил эту команду, и оказалось, что при вводе с 254 порта (#FE) эта команда меняет флаги так, что нужный бит "УХА" оказывается во флаге чётности/переполнения. Тогда, учитывая это нюанс, можно заново переписать цикл так: IN F,(C) 12 RET PO 5 - условие ложно DEC B 4 --- итого 21 такт - ещё на 3 такта короче - суммарный прирост на 40%!!! Размер: IN F,(C) 2 RET PO 1 DEC B 1 --- итого 4 байта на один цикл Суммарно всё будет занимать 4*256*4 = 4k + стек 0,5k = 4,5k суммарный объём - это уже можно считать оптимумом. Итоговая максимальная частота рабочего цикла будет: 3,5 МГц / 21 = 166 кГц! Это даже быстрей, чем конечная реализация предыдущего алгоритма, который подразумевает отсутствие сжатия. Рабочее тело будет представлено так: DEC A ; провоцируем возникновение флага, ;сигнализирующего окончание вывода ; текущего значения OUT (C),0 ; или OUT (C),C - вывод "1" или "0" на SPK RET Z ; такого вида конструкция делает абсолютно ;одинаковым воспроизведение и ; запись - в тактовом выражении ..... ; и так 256 раз Команда OUT (C),0 на некоторых моделях Z80 выводит не 0, а 255. Стоит отметить, что здесь указана максимальная частота, потому что переключения между циклами (команда RET, запись в память, установка нового значения в счётчик и т.д.) при частом переключении сигнала "УХА" могут значительно снизить среднюю частоту опроса "УХА" и, как следствие, снизить частоту воспроизведения. 1.3 К этим двум финальным программам хочется добавить ещё один перл: программу, которая бы напрямую выводила всё, что она слышит через "УХО". С её помощью можно сразу услышать, как будет записан тот или иной звук через "УХО" оцифровщиками (своего рода режим DEMO). В чистом виде я такие программы не встречал, поэтому всё же я приведу тот алгоритм демонстрации, который я сам написал: LD D,#40 ; предвнесённое значение LOOP1 IN A,(#FE) ; ввод с порта ADD A,D ; бит "УХА" переходит в флаг CY SBC A,A ; заполняем этим флагом аккумулятор OUT (#FE),A ; выводим его за угол ;))) JP LOOP1 Эта конструкция намного медленней оцифровщиков (т.к. имеются в одном рабочем цикле как ввод с порта, так и вывод в порт + идёт расчёт значений). LOOP1 IN A,(#FE) 11 ADD A,D 4 SBC A,A 4 OUT (#FE),A 11 JP LOOP1 10 --- итого 40 тактов Если сделать такую косметику: LD HL,LOOP1 LD D,#40 ; предвнесённое значение LOOP1 IN A,(#FE) ; ввод с порта ADD A,D ; бит "УХА" переходит в флаг CY SBC A,A ; заполняем этим флагом аккумулятор OUT (#FE),A ; выводим сие JP (HL) ; переходим на LOOP1 то здесь рабочий цикл сократится до 34 тактов. Если попытаться данную системы развернуть в память, то выигрыш 4 тактов мало чего даст. Тогда можно сделать так: Если создать таблицу по адресу XX00-XXFF таким образом, чтобы ячейкы с младшим байтом адреса, равным %?1??????, содержали FF, а ячейки %?0?????? содержали 00, тогда алгоритм изменится так: LD HL,XX00 ; предполагается что таблица по этому ;адресу уже подготовлена LD C,254 ; порт ввода/вывода ... LOOP1 IN L,(C) ; младший байт HL с нужным битом ;указывает на то, что надо выводить OUTD ; выводим ячейку, на которую указывает ;HL IN L,(C) OUTD .... ; и т.д. Такая конструкция будет работать: IN L,(C) 12 OUTD 16 --- итого 28 тактов Это предел (всего две команды), который не может быть превзойдён. Если расплодить такие команды (их сумма всего 4 байта), скажем, 256 раз, то единственный переход на старт (на LOOP1 из конца всей программы) не будет значительно влиять на среднюю длительность основного рабочего цикла. 1.4 Я считаю, что эти три конечных алгортима поставили жирную точку в записи/воспроизведении звука (имеются ввиду именно оба взаимосвязанных процесса) на ZX стандартными средствами, и каких-либо программных улучшений здесь быть не может. ---------------------------------------------------------------- 2. О вопроизведении звука В классическом Спектруме не было никаких "извращений" в виде музыкальных приставок "Covox", "GS" и т.д. Обычно это был самый обыкновенный ZX-Spectrum 48К, который из всех средств производства звука имел тока BEEPER и SPK на выходе магнитофона. Хотя именно в то время было создано (в штуках) по крайней мере не меньше половины программ, тот звук, который они показывали, не казался бледным - конечно, достаточно проблематичным был вывод одновременно графики и звука, однако реализовать звук выстрела или там ещё какой-нибудь звуковой эффект мог даже человек, не очень сильный в программировании, - опять же, подняв пакет 20 суперпроцедур (не уверен в своей памяти, но, по-моему, там был набор стандартных звуков - типа выстрел, удар и т.д.). Всё изменилось с появлением звуковых приставок (тот же музыкальный сопроцессор AY/YM был одной из приставок в конечном счёте). Однако возможности Спектрума по записи звука весьма ограничены (в любом из смыслов), самый лучший эффект по соотношению эффективность*простота_реализации/стоимость имел имеет и будет иметь, пожалуй, тот самый "EAR" на стандартном разъёме спекка. Такой взгляд обоснован прежде всего тем, что за него не надо платить (он обязательно присутствует в стандартной конфигурации), а кроме того, спекк не имеет ни процессорных, ни аппаратных средств более-менее серьёзно обслуживать поток от Аналогово-Цифрового Преобразователя (АЦП или ADC), потому вся эта часть будет целиком посвящена обработке имеющемуся уже готовому оцифрованному материалу, который тем или иным образом представляется на спекке. Естественно, я не отрицаю возможность хранения (и обработки в том числе) полноценных звуковых материалов на спекке (моно, стерео, квадро, 5:1, 7:1, 16 бит глубина канала, 24, 32 и т.д., 11, 22, 44, 96, 192 и выше кГц частота дискретизации), однако с учётом того, какие носители имеются на спекке, мне кажется практически неприемлемым (для iS-DOS, кажется, был драйвер для HDD, что давало возможность создавать разделы до 16 метров в разделе за раз, но этого всё равно мало) использовать для этого Spectrum. И единственный способ хранения полноценных звуковых рядов мне (с этой точки зрения) видится в использовании для этого средств и возможностей других компьютеров. Т.о. задача воспроизвдения звука на спекке переходит в несколько иную плоскость - создание прежде всего спецификаций на хранение звукового ряда на Спектруме, что отвечает следующим требованиям: - Сравнительно малый размер сжатых данных (конечные данные) - Лёгкое^ (не требующее больших процессорных затрат) декодирование на ходу и вывод - Исходя из первого и второго следует, что звуковой ряд в спектрумовском представлении должны занимать мало места (и грузиться разом в память) и легко декодироваться - что само по себе является противоречивым требованием; вот, собственно, надо найти компромисс, чтобы удовлетворялись два предыдущих условия ^Насчёт больших процессорных затрат: я подразумеваю классический Спектрум с Z80A процессором, работающим на частоте 3,5МГц, имеющим в одном прерывании 69 тыс. тактов (не 71 тыс., но это не принципиально). Соответственно, простой расчёт - для 44 кГц оцифровки на всё, про всё даётся ни много, ни мало - около 80 тактов, из которых одна только команда вывода в порт занимает в лучшем случае 11 тактов. Кроме того, наиболее (с моей точки зрения) важно составить программный комплекс, который бы собственно осуществлял кодирование из какого-либо распространённого формата другой платформы в указанный разработанный формат спекка. Ну, собственно, я думаю, всё здесь ясно, в качестве наиболее распространённой платформы я выбрал (как мне кажется, достаточно обоснованно) ПК на основе x86 совместимого процессора, в качестве исходного формата я выбрал звуковой формат WAV (в нём данные просто хранятся как есть - в виде простых дискретных замеров звукового сигнала). Вся задача воспроизведения звука на спекке состоит: 1) В разработке формата хранения звукового ряда; 2) Выбор готового звукового ряда в WAVE формате; 3) Преобразование в speccy-формат; 4) Запись на носитель Спектрума; 5) Собственно воспроизведение. Вот список устройств, на которые можно выводить звук на Спектруме: 1. На Speaker/Beeper 2. На AY 3. На GS 4. На Covox 5. На DMA USC 6. На что-нибудь другое С практической, опять же, точки зрения все варианты, кроме 1 и 2, отбрасываются, и остаются, соответственно, муз. сопроцессор и бипер Спектрума, которые можно назвать наиболее часто встречаемыми компонентами ZX Spectrum. Возможности вывода звука для Speaker/Beeper: - Глубина дискретизации = 1 бит - Максимальная частота дискретизации - ограничена только аппаратной реализацией (НЧ фильтром, сформированным ёмкостями и сопротивляениями) - практически можно считать неограниченной Возможности выводы цифрового звука на AY/YM: - Глубина дискретизации = 4 бита - Максимальная частота дискретизации - неизвестна, теоретически около 1,7 МГц 2.1. Воспроизведение звука на Speaker Придётся (исходя из указанных специфик звукового оборудования) закрывать глаза на все существующие форматы и самостоятельно разрабатывать свои (т.е. с самого начала отметаются такие бредовые идеи, как пытаться воспроизвести форматы а-ля mp3, требующие сложных вычислений). Самое простое - взять уже имеющиеся депакеры (см. первую часть) и под них писать программы-кодировщики. Однако те, кто сразу попробует это сделать, столкнутся с такими проблемами: Speaker имеет всего один бит глубины, в то время как самый чахлый WAV имеет глубину в 8 бит. Как осуществлять подобного рода переход - 8 бит в 1? 2.1.1. Error Diffusion При конвертации из звукового ряда берётся не только значение уровня, но и его смещение в блоке (звуковой ряд бьётся на блоки одинакового размера). Теперь из таблицы конвертации согласно взятым значениям уровня и смещению находится соответствующее значение конечного элемента - оно и записывается. Такая хитрая схема даёт следующее. Пусть например, мы кодируем одну величину уровня = 10h. У нас есть следующая подтаблица: Смещение 0 1 2 3 4 5 6 7 8 9 A B C D E F --------------------------------------------------------- Значение 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Т.е. среднее значение конечного элемента при конвертации будет = 1/16. Для FFh будет иметь место такая подтаблица: Смещение 0 1 2 3 4 5 6 7 8 9 A B C D E F --------------------------------------------------------- Значение 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 Т.е. среднее значение конечного элемента при конвертации будет = 1. Для 80h: Смещение 0 1 2 3 4 5 6 7 8 9 A B C D E F --------------------------------------------------------- Значение 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 Т.о. среднее значение конечного элемента при конвертации будет = 1/2. Ну, я думаю, мысль-то понятная, что в конечном итоге получается всё тот же 1 бит из 8 бит, но только хитрым образом заполненный из указанных таблиц. Достоинством метода является относительно высокая степень сжатия - 8:1. У этого метода так же есть существенный недостаток - в результате такой обработки получается файл, который, вообще говоря, плохо сжимается. Это прежде всего является следствием псевдослучайной картины распределений из подтаблиц. Кроме того, невозможно регулировать качество конечного материала, это связано именно с жёсткостью коэффициента сжатия, таким образом, может сложиться картина, когда из двух звуковых самплов один будет получаться просто идеально, а второй будет настолько плох, что очень сложно будет узнать оригинал. Суммарное качество полученного сигнала остаётся не очень высоким (вы можете это проверить сами). Вот исходный текст программы-кодировщика на Pascal (для DOS): (нажмите "0" ) А вот тело программы декодировщика-проигрывателя на Спектруме: OUT_MA LD HL,#C000 OUT_1 LD B,7 ; 7 циклов для записи в порт, на восьмой ;особый план - ; т.к. 8-й цикл используется для ;перемещения указателя памяти OUT_2 RLC (HL) ; сдвигаем нужный бит в флаг переноса SBC A,A ; заполняем флагом переноса аккумулятор OUT (C),A ; выводим в порт OUT (C),A OUT (C),A OUT (C),A DJNZ OUT_2 ; цикл RLC (HL) ; "хитрый" 8-й цикл - его длина должна ;быть равна ; длине обычного цикла, а потому он ;формируется отдельно SBC A,A INC L OUT (C),A OUT (C),A OUT (C),A NOP NOP JP NZ,OUT_1 ; "любимая" конструкция INC H JR NZ,OUT_1 Т.е. по сути эта часть просто проигрывает то, что имеется в текущей странице. Как можно заметить, я использовал таблицу с размерностью указателя всего в 4 бита - на самом деле использовался не весь восьмибитный поток аудиоданных, а только его часть. Теоретически какой-то прирост качества при использовании более глубоких таблиц должен быть, практически он навряд ли будет ощутим на слух. Как и в случае любого негибкого метода кодирования, качество воспроизводимых данных оставляет желать лучшего, что подвигает на новые изобретения. 2.1.2. Гибкое (динамическое) управление Для любознательных сделаю отсылку к ZX-Review'93 - поищите там статью про звук, там есть некоторые достаточно интересные приёмы. Так вот, ещё в те времена, начитавшись сего материала и загоревшись идеей гибкого управления диффузором колонки как таковым, я стал писать свои программы. Сама идея гибкого управления диффузором состоит вот в чём. Дело в том, что диффузор, как и любой материальный объект, имеет какую-то массу. Как известно из курса физики, масса на самом деле определяет меру инертности тела (меру его восприятия сторонных воздействий). Поэтому при подаче, скажем, на выход бипера значения 0, при том, что он находился в еденичном состоянии, диффузор далеко не сразу займет положение, соответствующее нулю. На самом деле есть такая большая наука, которая изучает такого рода воздействия, называется она Теория Автоматического Управления. Так вот, согласно этой дисциплине, такой переход задающего поведение системы (а системой у нас является диффузор динамика колонки) сигнала называется ступенчатым воздействием. А само положение диффузора, будучи зарисовано в виде графика, отнесённого ко времени, называется переходной характеристикой. Из общих положений известно, что сам переходной процесс занимает время, увеличивающееся с увеличением инерционности системы (массе диффузора в данном случае). Кроме того, исходя из чистой теории, можно сказать, что на самом деле идеальную позицию нуля или единицы диффузор займёт через время, равное бесконечности ((((-; поэтому вводят так называемый пятипроцентный барьер. Т.е. когда диффузор при движении к максимуму займёт 95% положение от того, которое он должен занять, считается временем срабатывания. И наоборот - когда при движении к нулю займёт 5% положение от максимума - так же будет считаться окончанием переходного процесса. Если же воспользоваться этим и быстро выводить то единицы, то нолики, то чисто теоретически можно организовать такую ситуацию, когда диффузор не будет занимать крайнее единичное и/или крайнее нулевое положение (даже с учётом 5% барьера). Что это даёт? Это даёт возможность гибко управлять выводимым звуком, так как появляются псевдоустойчивые значения в виде 1/2, 1/4, 3/4 и т.д. Но такие значения будут формироваться только за счёт манипулирования (и быстрого манипулирования) данными, которые выводятся в порт бипера. Т.е. если записать ряд команд: LD DE,#FF OUT (C),D OUT (C),E OUT (C),D OUT (C),E ... OUT (C),D OUT (C),E То получится организовать на выходе уровень^ (MAX-MIN)*(1/2)+MIN, где MAX соответствует максимальному отклонению диффузора, а MIN - минимальному. ^На самом деле слово "уровень" для характеристики того сигнала, что получится на выходе, не самое адекватное. Скорей это будет синусоида, которая имеет малую амплитуду колебания, частоту 3500000/12 = 290 кГц и находится около указанного значения (MAX-MIN)*(1/2)+MIN. Так как амплитуда мала, а частота высока, сигнал, скорее всего, не будет слышимым. Комбинируя частоту команд OUT (C),D и OUT (C),E, чисто теоретически можно осуществить сколь угодно точное управление положением диффузора. Но практически имеем частоту таких команд 290 кГц (11 тактов минимум, реально 12 тактов на команду), что даёт повод поразмышлять о качестве управления состоянием объекта. Максимальная частота, номинально ограничивающая скорость передвижения диффузора, будет 44 кГц (средняя цифра, на каких-то системах эта цифра больше, на каких-то меньше), таким образом, в течение приблизительно 80 тактов вывод в порт бипера приводит к изменению ощущаемого уровня сигнала. Из этих 80 тактов 11-12 тактов (для out (#fe),a и out (c),a) уходят на обслуживание порта. Т.е. в течение 68 тактов после вывода данных в порт диффузор поднимается/опускается. Хочется ещё раз подчеркнуть, что это чисто теоретические выкладки, у кого-то на практике могут получиться другие значения - больше или меньше (в указанном печатном журнале эта цифра достигала тысяч тактов - там для получения нужного уровня использовалась команда DJNZ). Таким образом, эта теория дала возможность сделать следующие предположения: - Диффузор в ходе работы программы, учитывающей ограниченность скорости его перемещения, находится не в состоянии MAX и не в состоянии MIN, а где-то между - При подаче 0 в порт бипера диффузор начинает отклоняться в сторону MIN - При подаче 1 в порт бипера диффузор начинает отклоняться в сторону MAX - Если долго ждать (несколько тысяч тактов), то после всех перерегулирований диффузор таки займёт одно из положений - MAX или MIN (в зависимости от последнего выведенного значения) - Скорость перемещения диффузора принимается одинаковой (постоянной) на всём его пути движения (для быстрых выводов значений в порт так оно и есть, на самом деле это достаточно грубое приближение, но для нас это приемлемо) - Влияние цепи задержки (состоящей из электрической ёмкости) считается пренебрежимо малым Пример: Пусть диффузор находится в нулевом состоянии. Пусть имеет место следующая программа: Ld a,#FF Out (#FE),a nop nop nop Вопрос: Насколько отклонится диффузор? Решение: Примем, что переход MIN->MAX (или MAX->MIN) длится 80 тактов. Тогда получаем: Out (#FE),a ; 11 тактов nop ; 3*4 такта nop nop итого 11+12=23 такта. т.о. диффузор отклонится на (MAX-MIN)*(23/80)+MIN. Пусть MAX=1, а MIN=0. Тогда итоговое отклонение будет составлять 23/80=0,2875. Хочется подчеркнуть общую мысль о невозможности получения такого статического уровня - этот уровень возможно получить только для случая ДИНАМИЧЕСКОГО управления диффузором. Несколько иначе интерпретировав полученные выкладки, можно отметить, что выведенные в порт бипера 0 или 1 определяют положительную или отрицательную скорость движения диффузора динамика колонки. Таким образом, из общей теории следуют значительно более важные для разработки сведения: - Выведенное число определяет знак скорости движения диффузора - 0 - "вниз", 1 - "вверх" - Имеется ограничения в виде макисмального и минимального положений диффузора - Вблизи максимального и минимального значений положения закон изменения положения теряет линейный характер, он преобретает сложный характер, описываемый несколькими производными от скорости, поэтому долгое "зависание" одного значения (в тактах процессора - это Первая Величина) может привести к нарушению логики формирования динамического уровня - Если представить зону линейного перемещения из минимального положения в максимальное, замерить (в тактах процессора Z80) время перемещения диффузора, то это будет Вторая Величина (она значительно меньше Первой Величины) - Длительность одного цикла вывода будет определять уровень подъёма - Третья Величина Вооружившись тремя основными Величинами, можно уже напрямую заниматься формированием звука. Так же, как в первой части, имеется 2 основных вида алгоритмов: - Сохранения состояний в битовой карте (1 бит соответствует 1 циклу вывода) - Сохранение повторов битовых состояний 2.1.2.1. Линейное кодирование 2.1.2.1.1. Линейное кодирование с использованием битового смещения Первый алгоритм идёт "ногу в ногу" с тем, что было в самом начале: LOOP0 LD B,(HL) ; загрузка битовой карты RRC B ; сдвигаем вправо - начало тела цикла - оно ;повторяется восемь раз для всех восьми бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A ;собственно вывод данных - конец тела цикла ....... OUT (#FE),A ; окончание последнего восьмого цикла INC L JR NZ,LOOP0 ; классический ускоренный обсчёт по ;перепонению HL INC H JR NZ,LOOP0 RET Таким образом, на один такой полный цикл, состоящий из восьми битовых циклов, будет приходиться 7 + (8+4+11)*8 + 4 + 12 = 207 тактов. Т.о. на один цикл в среднем приходится 26 тактов. 2.1.2.1.2. Линейное кодирование с использованием табличного метода для распаковки Наиболее длинными в приведённом в 2.1.2.1.1 алгоритме являются, как ни странно, команды обсчёта данных - смещения регистра и заполнения полученным битом флага переноса аккумулятора - это даёт 12 тактов поверх самой команды вывода (естественно, что выкинуть команды вывода в порт никак нельзя), да и ещё команды загрузки регистров и проверки текущей ячейки на выход за границу. Имеется ли возможность как нибудь избавиться от этого досадного недостатка? В теле цикла всего три команды - RRC B ; сдвигаем вправо - начало тела цикла - оно ;повторяется восемь раз для всех восьми бит SBC A,A ; заполняем флагом переноса аккумулятор OUT (#FE),A ;собственно вывод данных - конец тела цикла и, как ни крути, получается что выбрасывать нечего - 1 команда вывода в порт, которая в любом случае будет, и две достаточно быстрые команды обработки данных. Очень долго я считал представленный выше алгоритм самым быстрым из всех возможных. Чтобы хоть чуточку ускорить работу, необходимо поменять сам подход к выводу звука и формирования значений, которые будут выводиться. Помочь в этом может только табличный метод. Представим такую головокружительную конструкцию: для каждого байта (по его значению, полученному из сжатого звукового ряда) производится переход в заданную точку обработки. Заданная точка обработки содержит команды: OUT (C),X ; здесь X - пока ещё неопределённый регистр, в ;результате клонирования OUT (C),X ; этой п/п происходит замена кода команды либо ;на OUT (C),0, либо на OUT (C),X ; OUT (C),C - для обеих команд не требуется ;дополнительных регистров со OUT (C),X ; значениями выводимых значений в порт - первая просто ;выводит 0, вторая 254 - OUT (C),X ; число, которое, в общем-то, можно считать ;противоположным 0. Этими двумя OUT (C),X ; командами обеспечивается управление диффузором. OUT (C),X ; OUT (C),X ; JP LOOP0 ; LOOP0 есть подпрограмма управления - она читает байт ;и делает очередную отсылку к следующей п/п вывода Например, для значения считанного байта 0 = %00000000 это будет: OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 JP LOOP0 Например, для значения считанного байта 220 = %110111100 это будет: OUT (C),C ; 1 OUT (C),C ; 1 OUT (C),0 ; 0 OUT (C),C ; 1 OUT (C),C ; 1 OUT (C),C ; 1 OUT (C),0 ; 0 OUT (C),0 ; 0 JP LOOP0 Таким образом, остаётся написать только управляющую программу, которая бы рассылала программный счётчик (имеется в виду регистр) на нужные точки. Т.е. теперь происходит не вычисление того значения, которое надо выводить, а вычисление адреса следующего исполняемого куска. После продолжительных терзаний моей головы, я смог придумать только следующее: LOOP0 INC E ; в DE указатель на текущий байт, происходит ;его увеличение ; после очередного чтения ячейки, на которую ;указывает пара DE ; сразу же проводится и проверка на ноль RET Z ; здесь в случае возврата происходит переход ;на п/п, которая ; дообслужит увеличение DE, этот хитрый ;приём заменяет значительно более ; длинный (в тактах) INC L JR NZ... и ;позволяет учесть старший байт START LD A,(DE) ; считали байт LD H,B ; в регистре H содержится старший байт ;выравненной на 256 байт таблицы, его ; надо восстановить после следующего ;изменения, в цикле оно должно здесь ; иметь строго определённое значение; ;таблица содержит в себе LD L,A ; адреса переходов по готовым уже ;сгенерированным п/п выводов в порт LD A,(HL) ; однако это не классическая таблица с ;двухбайтовыми адресами - в целях INC H ; ускорения работы эта таблица имеет в ;первых 256 байтах только младшие адреса LD H,(HL) ; переходов, а в следующих 256 байтах - ;старшие байты адресов переходов LD L,A ; осуществляем переход по собранному ;значению адреса п/п JP (HL) Я тут подумал, что всё-таки методика неклассическая, и лучше бы её расписать (я про адреса переходов, которые получаются в процессе работы из хитро сформированной таблицы). Пусть у нас есть значение #ABCD (старший байт #AB, младший #CD). Пусть у нас это пятнадцатое (+#0F) значение в таблице. Тогда, чтобы получить этот адрес, надо сделать следующее: - Сформировать адрес начала таблицы, сделать команду типа LD HL,#XX00 (т.к. далее всё равно младший байт будет модифицироваться, то можно было бы сделать просто LD H,#XX) - В младший полубайт загрузить смещение - в нашем случае это +#0F - пятнадцатый элемент - LD L,#0F - загрузить старший байт, например в D - LD D,(HL); в итоге в D будет #AB - теперь чтобы переместить указатель на младший байт необходимо просто увеличить HL на 256, или просто увеличить на 1 H - INC H - загрузить младший байт в E - LD E,(HL); в итоге в E будет #CD Основное достоинство такой конструкции - быстрая адресация элементов, так как смещение не надо умножать на длину хранимого слова (сравните с классическим подходом, когда, например, имеется двухбайтовое слово и надо достать нужное значение - сразу же надо подключать небыструю 16-битную арифметику типа LD H,0 LD L,A ADD HL,HL LD DE,#XX00 ADD HL,DE и теперь ещё и надо получить значения, которые там хранятся.) Подсчитаем тактовую длительность: inc e 4 4 ret z 5 9 ld a,(de) 7 16 ld h,b 4 20 ld l,a 4 24 ld a,(hl) 7 31 inc h 4 35 ld h,(hl) 7 42 ld l,a 4 46 jp (hl) 4 50 Поделённое 50/8 = 6,25 такта дополнительно в среднем на один цикл вывода - очевидно, что это намного меньше почти 14 тактов для предыдущего варианта - который без табличного запуска. (Справедливости ради отмечу, что "балластная" команда Out скрадывает это увеличение - 18,25 и 28 тактов для нового и старого вариантов соответственно.) Неспроста эта управляющая часть состоит в основном из самых быстрых и коротких однобайтовых команд процессора Z80 - с самого начала была задумка разместить управляющий код параллельно коду вывода данных в порт - чтобы пауза между двумя соседними командами вывода варьировалась как можно меньше. Понятно, что полностью уравнять тактовые промежутки между соседними командами вывода невозможно, однако постараться его как можно больше "сгладить" можно - именно используя такие короткие и быстрые команды, они будут наиболее компактно ложиться в код вывода данных в порт. Кроме того, размещение управляющей части в коде вывода экономит один прыжок - JP LOOP0, так как сразу после выполнения JP (HL) будет переход и на управляющую часть, и на код вывода. Примечание: Представленная п/п (или как там её ещё можно назвать - часть программы?) прыжков по таблице чрезвычайно универсальна. Универсальность заключается в том, что кроме 1-байтовых, 2-байтовых и т.д., можно использовать очень большие (даже 5-байтовые) величины, и всего-то надо будет сделать очередной INC H с последующим чтением LD ,(HL). Размер же таблицы элементов при такой организации может достигать 512 штук (выше там уже быстрее и удобнее использовать другую организацию таблицы - классическую) - однако не обязательно быть кратным 256 или 512, просто будет оставаться немного свободного места. Предустановки регистров: C=254 ; для вывода в порты B=Hi_Byte ;старший байт адреса таблицы с адресами прыжков по п/п HL=table_addr_to_jump ; используется для промежуточного ;вычисления адреса прыжка и ; для самого прыжка out (c),x ; 0й цикл ld a,(de) out (c),x ; 1й цикл ld h,b inc e out (c),x ; 2й цикл ret z ; ВНИМАНИЕ! выход из цикла при проверке на обнуление ;младшего регистра DE осуществляется ; из центра полного цикла, потому возврат (точнее, Call) ;после переключения старшего ; регистра DE надо осуществлять именно сюда! out (c),x ; 3й цикл ld l,a out (c),x ; 4й цикл ld a,(hl) out (c),x ; 5й цикл inc h out (c),x ; 6й цикл ld h,(hl) out (c),x ; 7й цикл ld l,a jp (hl) Итого получается 146 тактов на всю такую конструкцию - что даёт почти 18 тактов (в среднем) на 1 цикл вывода. В общем, получился достаточно быстрый алгоритм, который, правда, имеет недостаток - огромный размер исполняемого кода, который перед запуском надо генерировать (но генерировать не каждый раз, а только после загрузки программы в память). Размер одного полного цикла - 26 байт. На 256 циклов - 26*256 = 6656 байт. Добавляем сюда 512 байт на таблицу переходов, а так же около того же на саму программу инициализации. Т.о., получается, около 7,5 кбайт в памяти занимает вся эта система. Плюс стек, который содержит значения переходов для команды Ret Z (в цикле используется только увеличение Inc E, а надо ещё раз на 256 циклов увеличивать D; кроме того, надо ещё и учитывать переходы по страницам, итого 64 значения в стеке, которые занимают 128 байт). Мне кажется, что это достойная плата за такую скорость. Программа-кодировщик для этого метода: (нажмите "3" ) Эта программа в конце своей работы выдаёт кроме собственно файла, который должен быть на спекке проигран (файл NEW.SND), файл (NEW_E.RAW) в виде 16 бит моно беззнаковый, как он должен в идеале проиграться на Спектруме, если модель приближения к реальному звуку 100% совпадает с процессами, проходящими в Спектруме. Кроме этого, программа выводит параметры сжатия. Основным параметром сжатия я считаю уровень ошибок - показывает отличие сигнала полученного от сигнала исходного в точках замера исходного сигнала^ - именно он и выносится в отчёте программы на экран. Кроме этого параметра выводится такой параметр, как ошибка аппроксимации. Этот параметр показывает, насколько сильно отличается та ломаная линия, которой, собственно, описывается сигнал, от интерполированного дискретного сигнала на входе кодера. Ну и, конечно же, размер собственно того, что получилось. ^Примечание: точки замера исходного сигнала отличаются от точек замера сжатого сигнала. В среднем одна точка исходного сигнала приходится на 80 тактов Z80, в то время как сигнал сжатый имеет замеры со средней частотой в 18 тактов (см. выше), что и приводит к двухуровневой системе оценки ошибки сжатия. Встречаются файлы, для которых оба типа ошибок идут относительно вровень. Встречаются файлы, для который одна из ошибок имеет превалирующее значение. В таких файлах качество при переводе страдает значительно. Однако всё же ошибка первого уровня имеет, видимо, большее значение, чем ошибка второго уровня. Управлять ошибками можно просто: есть принимаемая величина смещения диффузора на 1 такт - Shift_per_tact, если удачно подобрать это значение, то будет достаточно высокое качество. Однако как его подбирать - никто не знает, тут появляется задача многомерной оптимизации с функциональной зависимостью от исходных значений, в общем виде я не представляю, как её решать, потому здесь есть резервы для развития - модифицировать указанную программу для того, чтобы подбиралось оптимальное значение. Я сам подбирал этот параметро вручную ;). И ещё, этот алогритм, так же, как 2.1.1 Error Diffusion, имеет недостаток - у него фиксированный уровень сжатия, хотя, в отличие от 2.1.1, можно в значительной мере управлять качеством конечного (=сжатого) звукового ряда подбором указанного параметра (=shift_per_tact). Отсутствие управления по уровню сжатия делает возможным существование таких данных, которые никак точно не могут быть описаны указанным алгоритмом (при любом значении исходных параметров будет высокая конечная ошибка). 2.1.2.1.3. Линейное кодирование без вычислений в процессе проигрывания Глядя на полученный в 2.1.2.1.2 код, можно представить его в виде: ; Основная часть OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 OUT (C),0 ; Управляющая часть inc e ret z ld a,(de) ld h,b ld l,a ld a,(hl) inc h ld h,(hl) ld l,a jp (hl) Да, получилось быстро, но ведь второй кусок занимается вычислением, и, как всегда, вопрос - а можно ли каким-либо образом убрать эти вычисления, переложив их на "братьев по разуму" - на другие платформы? Можно, но это уже получается совсем плохо (не в смысле качества, а в смысле затрат памяти под таблицы). Адрес приходится вычислять потому, что он не приходит в прямом виде. А если его в таком виде представить? Тогда будет тело декодировщика: ; Основная часть OUT (C),X ; естественно, предустановлено значение C=254 OUT (C),X OUT (C),X OUT (C),X OUT (C),X OUT (C),X OUT (C),X OUT (C),X ; Управляющая часть RET ; на стеке, который фактически является сформированным ;куском медиа-данных, лежат уже не ; значения, которые позволяют вычислить адрес перехода, а ;прямой адрес перехода. Такая своеобразная "находка" уже использовалась мной не раз, но это случай особенный. Прежде чем вводить "в строй" новую методику декодирования, я, как всегда, принялся считать. Прежде всего - считать время, в течение которого будет воспроизводиться (в некотором идеализированном случае) полученный кусок медии, загруженный в 256k. Пусть стек адресует 2^16 (размер слова стека 16 бит) вариантов подпрограмм вывода, тогда каждый возврат со стека позволит выводить 16 значений (каждый бит - вывод 1 или 0 в порт) и будет завершаться RET'ом. Итого получается, что один полный цикл будет длиться: 12 тактов*16 команд + 10 тактов на RET = 202 такта. Затраты памяти при этом очевидны - 2 байта на слово стека. Всего имеется 256*1024 байт, итого 256/2*1024 циклов воспроизведения. 256k*1024байта / 2байта_на_слово_стека*202такта_на_один_цикл = около 7,5 секунд. Т.е. даже монстры с 256k дадут настолько быстрое воспроизведение, что это не позволит сколь-либо значимый фрагмент записать и воспроизвести. Самое главное - величина моих допущений при рассчёте времени воспроизведения. - Памяти будет менее 256k, потому что есть управляющая программа, которая переключает банки памяти, кроме того, каждый переход по RET вызывает процедуру воспроизведения, которая в свою очередь занимает какую-то область памяти - Очевидно, что таких процедур должно быть 65536 (т.е. 2^16), а так как основной памяти всего 65536 (без учёта страниц расширений, которые должны быть свободны от процедур и заняты только под стек), и каждая процедура должна занимать отдельное место в памяти и вызываться по собственному адресу, получается, что каждой процедуре вывода достанется по 1 байту памяти, что сразу же съедается управляющей частью - RET'ом - В итоге приходится считать, что количество адресуемых подпрограмм вывода будет менее 2^16, а значит, КПД расходования памяти становится отличным от 100% - менее 100%, так как не все комбинации смогут релизоваться. - В минимуме подпрограмма вывода будет иметь по крайней мере тело OUT (C),X : RET, т.е. иметь размер в 3 байта, значит, максимально возможное количество п/п будет 65536/3 = 21845. - Количество доступных подпрограмм вывода звука сокращается в связи с тем, что верхняя область памяти уже занята стековыми значениями переходов по подпрограммам. Кроме того, я, как программист с сложившимися устоями, отрицаю использование теневого ОЗУ - адресов #0000-#3FFF. Т.о. всего доступной для подпрограмм памяти будет 32 кбайта. А самих подпрограмм получается не более 10922 штук. - На самом деле размер подпрограмм будет более 3 байт на подпрограмму, а значит, указанное количество сокращается ещё сильней. И придётся искать компромисс между количеством подпрограмм и их длиной - чем длинней подпрограмма, тем больше данных она выводит, но тем более памяти она отнимает под своё тело, а значит, других подпрограмм будет меньше. Пусть одна подпрограмма выводит X значений. Тогда её длина будет X*2 + 1 (2 байта на команду вывода в порт и 1 байт на RET). Для X = 10 количество вариантов подпрограмм будет (очевидно) 2^10 (1024). Длина одной подпрограммы будет 10*2 + 1 = 21 байт. Т.о. весь блок подпрограмм вывода будет 1024*21 = 21k памяти. Это близко к пределу, так как для X = 9 будет 2^9 = 512 подпрограмм и 9*2+1 = 19 байт на подпрограмму, что даст 512*19 = 9728 байт на все подпрограммы (имеющиеся две страницы основной памяти используются недостаточно эффективно). Для 11 это будет 2^11 = 2048 подпрограмм и 2*11 + 1 = 23 байта на одну подпрограмму. Итого - 47104 - теоретически осуществимло лишь для случаев, если используется теневое ОЗУ. Кроме того, расчёты показывают, что так как для подпрограмм вывода используется дополнительная страница, то страниц памяти для медиаданных остаётся меньше, в итоге выигрыш, получаемый от повышения КПД использования памяти, отведённой под стек, нивелируется. Я привел этот достаточно сумбурный список в таком виде именно так потому, что именно так я двигался в своих размышлениях. Теперь можно посчитать сколько на самом деле будет длиться одна загруженная мелодия: - памяти 256k-32k (две банки под подпрограммы) = 224k = 224*1024 байт - КПД использовния памяти, отведённой под медиаданные, составляет 10/16 = 62,5% (естественно, что все предыдущие варианты кодирования на 100% использовали память) - Количество подпрограмм - 1024, одна подпрограмма занимает памяти 21 байт - Скорость одной подпрограммы будет 12тактов*10команд_вывода_в_порт+10 тактов_на_RET = 130 тактов. Сравните с предыдущий подпрограммой линейного кодирования - для 8 выводов тратилось 146 тактов, здесь же на 10 выводов используется меньшее количество тактов, т.е. можно сказать, что скорость воспроизведения повысилась сильно - Всего доступно 224*1024/2 количество слов стека (не учитывается 1 переход в конце банки ОЗУ на переключатель банок памяти) - Всего проигрывание будет длиться 224*1024/2*130 = 14909440 тактов, что составляет около 4,5 секунд )))-: Тем не менее, несмотря на столь удручающий результат, я написал кодер и декодер (естественно, декодер был не простой - он вначале генерировал те самые 1024 подпрограммы для вывода данных в порт). Кодирующая программа (я её не привожу в силу непринципиальности отличий от предыдущего варианта программы для линейного кодирования из 2.1.2.1.2) была достаточно простая - от предыдущей она отличалась тем, что собирала не 8 бит, а 10, и потом по этим битам (так как размер каждой процедуры постоянен и равен 21 байту) по формуле #4000 + N*21 вычисляла адрес перехода и записывала это слово в конечный файл (рабочий код начинается прямо с #4000). Меня сильно смущал низкий КПД использования памяти - хотелось, чтобы память использовалась несколько рациональней, хотя бы чуть-чуть. Увеличение количества подпрограмм вело к закономерному результату - увеличивалась требуемая для них память, значит, надо было как-то выкручиваться. Прежде всего, надо определиться, какого рода оптимизация может быть проведена. Может быть, имеет смысл на ходу распаковывать данные, получаемые со стека? Тогда используемые со стека данные будут иметь 100% КПД использования памяти. Однако внедрение любой арифметики приведёт в усложнению проигрывателя, удлинению его тела и в конечном итоге снижению количества рабочих подпрограмм, что выльется в своего рода деградацию в вариант 2.1.2.1.1. В общем, я отказался от этого метода. Значит, оптимизацию надо искать в самих подпрограммах. Просмотрев тактовую длительность команд Z80 (и их размер, конечно), да и принимая во внимание тот факт, что у меня машина с задержкой М1 (все нечётнотактовые команды выполняются на 1 такт дольше), я нашёл такую замечательную команду, как Add hl,hl. Эта команда занимает 1 байт и длится 12 тактов для моей машины. Что это даёт? Дело в том, что далеко не всегда необходимо менять состояние порта, т.е. возможно, что моя автоматическая программа сгенерирует код, в котором будут стоять подряд несколько команд Out (c),0 или подряд Out (c),d. В этом случае необходимо ставить только первую команду Out (c),x, а затем писать Add hl,hl столько раз, сколько длится вывод одинаковых команд. Результат, я думаю, понятный - для подпрограмм, где есть повторные выводы, будет сокращаться их длина (так как используется 1 такт Add hl,hl вместо 2 байт Out (c),x). Однако я не торопился писать код и решил ещё обдумать... Для некоторых случаев, например, четыре подряд повтора, можно вместо четырёх Add hl,hl написать более оптимальный код, состоящий из трёх байт. Я долго этим занимался и в конце концов написал программу, которая анализировала бы данные при генерации и в зависимости от длины повторяющейся комбинации генерировала очень комактный код для "забивки" тактами промежутка, когда не выводятся никакие данные (в этом помогали такие длинные в тактах и короткие в байтах команды как Ex (sp),hl). Конечно, эффективность такого подхода тем больше, чем больше длина комбинации повторяющихся выводов и чем больше количество используемых бит стека, и количество их в общем-то не очень велико. Решающая проверка дала совсем не тот результат, который ожидался - да, размер рабочего тела уменьшился, для 2^10 количества подпрограмм они стали занимать чуть больше одной страницы памяти, а для 2^11 - чуть больше двух страничек памяти (для 2^12 суммарный размер подпрограмм перекрывал доступное количество ОЗУ). Т.е., хоть движение произошло (и движение неплохое), тем не менее, какого-то количественного прироста КПД получить не удалось. Единственным плюсом стало, пожалуй, то, что теперь можно было не устраивать на экране помойку, а рабочее тело генерировать в области, начиная, например, с адреса #6000. Дальнейшие раздумья и анализ кода декодера привели к следующим мыслям: огромное количество подпрограмм только начинаются по-разному, а заканчиваются одинаково. Например, вывод значений %11 1011 1010, %10 1011 1010, %01 1011 1010 и %00 1011 1010 - очевидно, что последние 8 выводимых значений буду одинаковые. Возможно ли как-нибудь оптимизировать такой вывод с учётом знания того, что конечные части одинаковые? Единственная методика оптимизации в этом случае - это использование команд переходов. Но сразу возникает проблема - команда перехода имеет свою длину в байтах и тактах. Если, например, длина в байтах неважна, потому что один переход длиной 3 байта может закрыть сразу 10-20 байт подпрограммы, то длина в тактах очень важна. Каждый вывод данных или каждый пропуск вывода (для случаев, когда идёт вывод повторных значений) должен длиться 12 тактов. Переход же на нужный элемент непредсказуем - поэтому предустановленные переходы по Jp (hl), Jp (ix) и Jp (iy) невозможны. Кроме того, как это понятно, не всегда возможно без разрыва основного тела выполнить переход в заданное место, например в череде Out (c),d Out (c),0 Out (c),d Out (c),0 Out (c),d Out (c),0 Out (c),d Out (c),0 Out (c),d Out (c),0 ret некуда вставить команду перехода или придётся жертвовать точностью (что крайне нежелательно) и вставлять между очередными Out'ами нужную команду перехода. В общем, вопросов появилось много, и я стал искать решение. Прежде всего, из команд Jp и Jr я выбрал команду Jr - она имеет длительность 12 тактов, что в имеющихся условиях просто идеально соответствует требованиям, кроме того, эта команда имеет меньшую длительность в байтах - 2 байта вместо 3 у Jp. Во-вторых, очевидно, что вставить команду перехода можно только вместо повтора. Т.о. если внутри тела проигрывателя есть хоть один повторный вывод, то очевидно, что он может быть сокращён при помощи перехода на похожую подпрограмму (даже в случае, если этот переход будет находиться в конце подпрограммы, то получится сэкономить на Ret 1 байт - переход будет осуществляться на ближайщий Ret). Очевидно, что чем ранее стоит повтор, тем выше эффективность такого перехода, минимальный дивиденд, который может принести такой переход (как было указано выше), - 1 байт, максимум - длина подпрограммы (которая сейчас ещё не определена) минус 4 байта (2 байта на Out и 2 байта на Jr). Т.о. минимальный размер получившейся подпрограммы вывода доходит до 4 байт!!! Можно предположить, что в связи с тем, что подпрограммы друг на друга очень похожи, то суммарный размер каждой будет очень близок к этому минимуму. Теперь осталось определиться, каким образом будет проводиться оптимизация. Чем больше кусок будет оптимизироваться (автоматически это сделать, видимо, не получится, слишком уж сложный получается для этого генератор, поэтому оптимизация будет проводиться вручную), тем меньше эффективность его оптимизации по сравнению с предыдущим, меньшим куском, и тем больше работы надо будет делать. С этой точки зрения я выбрал 5-битную оптимизацию - т.е. решил оптимизировать только старшие 5 бит вывода, а остальные будут генерироваться автоматически - как в предыдущем варианте. 5 бит - это 32 варианта вывода (для 6 бит это было бы 64 варианта, а для 7 - аж 128, я думаю, ясно, почему именно 5 бит). Т.о. в результате работы выйдет код оптимального вывода 5 бит - имеющего малый размер - к которому надо будет ещё приписать "хвостик" для вывода оставшихся бит. Для определённости будем называть оптимизированный код для вывода тех самых пяти бит оптимизированной подпрограммой. В оптимизированной подпрограмме будет 32 подпрограммы вывода. Схема оптимизации была составлена следующая: имеются две ключевые комбинации: %01010 с меткой в коде B_10_? (рабочее тело: Out (c),0 Out (c),d Out (c),0 Out (c),d Out (c),0) и %10101 с меткой B_21_? (рабочее тело: Out (c),d Out (c),0 Out (c),d Out (c),0 Out (c),d) Эти две комбинации являются ключевыми, потому что все остальные комбинации будут завершаться ими (или переходить на следующие за ними "хвостики"). Также придётся каждую из этих ключевых комбинаций завершать своим хвостиком, так как нет возможности внутри них организовать переход - вставить команду Jr. В общем, тут получается трудноформализуемый словами метод - своего рода дерево переходов с Out'ами перед/после переходов - потому я приведу шаблон оптимизированной подпрограммы. ; Здесь таблица адресов процедур в порядке возрастания, на самом ;деле эти процедуры ; разбросаны согласно логике кратчайшего перехода B_TAB DEFW B_00_1,B_01_1,B_02_1,B_03_1 DEFW B_04_1,B_05_1,B_06_1,B_07_1 DEFW B_08_1,B_09_1,B_10_1,B_11_1 DEFW B_12_1,B_13_1,B_14_1,B_15_1 DEFW B_16_1,B_17_1,B_18_1,B_19_1 DEFW B_20_1,B_21_1,B_22_1,B_23_1 DEFW B_24_1,B_25_1,B_26_1,B_27_1 DEFW B_28_1,B_29_1,B_30_1,B_31_1 B_00_1 OUT (C),(HL) JR B_16_3 B_16_1 OUT (C),C OUT (C),(HL) B_16_3 JR B_08_4 B_24_1 OUT (C),C JR B_08_3 B_08_1 OUT (C),(HL) OUT (C),C B_08_3 OUT (C),(HL) B_08_4 JR B_20_5 B_12_1 OUT (C),(HL) OUT (C),C JR B_20_4 B_04_1 OUT (C),(HL) JR B_20_3 B_20_1 OUT (C),C OUT (C),(HL) B_20_3 OUT (C),C B_20_4 OUT (C),(HL) B_20_5 JR B_10_6 B_28_1 OUT (C),C JR B_20_3 ; 46 байт тела B_10_1 OUT (C),(HL) B_10_2 OUT (C),C B_10_3 OUT (C),(HL) B_10_4 OUT (C),C B_10_5 OUT (C),(HL) B_10_6 DEFW 0,0,...,0 ; OUT'ы, тот самый хвостик, который ;добавляется автоматически DEFB #C9 ; RET B_02_1 OUT (C),(HL) JR B_10_3 B_26_1 OUT (C),C JR B_10_3 B_18_1 OUT (C),C OUT (C),(HL) JR B_10_4 B_14_1 OUT (C),(HL) OUT (C),C B_14_3 JR B_10_4 B_30_1 OUT (C),C JR B_14_3 B_22_1 OUT (C),C OUT (C),(HL) B_22_3 OUT (C),C JR B_10_5 B_06_1 OUT (C),(HL) JR B_22_3 ; ещё 46 байт тела, размер увеличивается на длину "хвостика" - ; количество команд OUT по два байта каждая + 1 байт RET B_31_1 OUT (C),C JR B_15_3 B_15_1 OUT (C),(HL) OUT (C),C B_15_3 JR B_23_4 B_07_1 OUT (C),(HL) JR B_23_3 B_23_1 OUT (C),C OUT (C),(HL) B_23_3 OUT (C),C B_23_4 JR B_11_5 B_19_1 OUT (C),C OUT (C),(HL) JR B_11_4 B_27_1 OUT (C),C JR B_11_3 B_11_1 OUT (C),(HL) OUT (C),C B_11_3 OUT (C),(HL) B_11_4 OUT (C),C B_11_5 JR B_10_6 B_03_1 OUT (C),(HL) JR B_11_3 ; ещё 46 байт тела B_21_1 OUT (C),C OUT (C),(HL) B_21_3 OUT (C),C B_21_4 OUT (C),(HL) B_21_5 OUT (C),C B_21_6 DEFW 0,0,...,0 ; OUT'ы, тот самый хвостик, который ;добавляется автоматически DEFB #C9 ; RET B_05_1 OUT (C),(HL) JR B_21_3 B_29_1 OUT (C),C JR B_21_3 B_17_1 OUT (C),C OUT (C),(HL) B_17_3 JR B_21_4 B_13_1 OUT (C),(HL) OUT (C),C JR B_21_4 B_01_1 OUT (C),(HL) JR B_17_3 B_09_1 OUT (C),(HL) OUT (C),C B_09_3 OUT (C),(HL) JR B_21_5 B_25_1 OUT (C),C JR B_09_3 B_END ; последние 46 байт тела подпрограммы вывода, размер ; увеличивается на длину "хвостика" - количество команд OUT по ; два байта каждая + 1 байт RET ^Примечание: команда OUT (C),(HL) соответствет OUT (C),0 - просто я пользуюсь GENS'ом, а он "не знает" последней команды Названия меток логичны. Метка B_XX_Y в коде соответствует: XX соответствует коду вывода - для XX=10 (метка B_10_1 например) это %01010, Y - это точка входа, Y=2 соответствует второй точке входа (соответственно второй паре байт). Итого на 32 варианта будет (исключая место, отведённое под "хвостики") 184 байт памяти. Тогда размер одной подпрограммы вывода будет: 184/32 = 5,75 байт в среднем!!! Методика дала отличный результат. Теперь можно посчитать, как эффективно может расходоваться стековая память. Включая хвостики, на 1 оптимизированную подпрограмму будет уходить 184+ +(Nколичество_выводов_в_хвостике*2байта_на_OUT+1_байт_на_RET)* *2хвостика_в_теле. Тогда для N=10 будет (так как старшие 5 бит уже учтены в оптимизированном коде, всего вариантов 2^10 = 1024) количество таких оптимизированных подпрограмм (выводящих 5 бит сразу) 2^5 = 32 варианта. Размер одной оптимизированной подпрограммы будет 184+(5*2+1)*2 = 206 байт. 32*206 = 6592 байта. Итого на каждую отдельную подпрограмму вывода (коих в оптимизированной подпрограмме, напоминаю, 32) будет: 6592/1024 = 6,4375 байта (напоминаю про предположение в самом начале разработки оптимизированной подпрограммы - что минимальная и средняя величина отдельной подпрограммы будут близки). Очевидно, что можно увеличить количество задействованных бит стека. (Кстати, опять обращаю внимание - 6592 байта и около 17 кбайт для предыдущей методики для в общем-то одного типа вывода, т.е. оптимизация получилась очень хорошая - почти в 3 раза) Возьмём 11 бит стека (2^11 = 2048 вариантов). Тогда N будет 11-5 = 6. Размер одной оптимизированной подпрограммы 184+(6*2+1)*2 = 210 байт. Количество вариантов 2^6 = 64. Тогда размер декодера будет: 210*64 = 13440 байт. На каждую отдельную подпрограмму вывода будет 13440/2048 = 6,5625 (т.е. при увеличении количества учитываемых бит стекового адреса на 1 средняя длина подпрограммы вывода выросла на 0,125 байта - на 1 бит :D). И опять меньше даже страницы! Можно ещё увеличить количество подпрограмм: пусть это будет 12 (2^12 = 4096 вариантов) бит со стека. Тогда N = 12-5 = 7 бит. Количество вариантов - 2^7 = 128. Размер оптимизированной подпрограммы 184+(7*2+1)*2 = 214 байт. Размер декодера будет: 214*128 = 27392 - это почти две страницы, почти предел. На каждую отдельную подпрограмму вывода будет 27932/4096 = 6,82 байта на одну подпрограмму вывода (теперь прирост более значительный). На всякий случай посчитаю для схемы, когда используется 13 бит стекового значения. N = 13-5 = 8 бит. Количество вариантов = 2^8 = 256. Размер оптимизированной подпрограммы: 184+(8*2+1)*2 = 218. Размер декодера: 256*218 = 55808 байт, что неприемлемо даже в случае использования теневого ОЗУ. На каждую отдельную подпрограмму вывода будет 55808/8192 = 6,8125 байта на одну подпрограмму. По последнему результату хочется отметить следующее. Теоретически, более глубокой оптимизацией (выбрав размер оптимизации не 5 бит, а, скажем, 7) можно достичь того, чтобы тело декодера для 13 бит умещалось в основную память, но это для случая использования теневого ОЗУ и кропотливого труда по созданию оптимизированной подпрограммы или создания автоматического генератора оптимизированной подпрограммы. Т.е. использование 13 бит стека является теоретическим максимумом, который осуществим в указанных условиях. Если даже предположить, что можно использовать 14 бит, тогда исходя из битового остатка (из 16 бит используются 14 бит, остальные 2 бита будут отводиться на размер подпрограммы вывода), который равен 2, размер одной подпрограммы будет составлять 4 байта, что не допустимо даже теоретически, так как есть несворачиваемые в 4 байта комбинации (постоянные неповторяющиеся выводы). Можно посчитать, сколько на самом деле будет длиться одна загруженная мелодия: - памяти 256k-32k (две банки под подпрограммы) = 224k = 224*1024 байт - КПД использовния памяти, отведённой под медиаданные, составляет 12/16 = 75% (по сравнению с предыдущим 10-битовым вариантом КПД значительно вырос) - Количество подпрограмм - 4096, одна подпрограмма занимает памяти в среднем 6,82 байта - Скорость одной подпрограммы будет 12тактов*12команд_вывода_в_порт+10 тактов_на_RET = 154 такта. - Всего доступно 224*1024/2 количество слов стека (не учитывается 1 переход в конце банки ОЗУ на переключатель банок памяти) - Всего проигрывание будет длиться 224*1024/2*154 = 14909440 тактов, что составляет около 5 секунд. Тем не менее, получилось отыграть целых полсекунды. Тело кодера для этого типа декодера: (нажмите "4" ) Для этого кодера необходим файл "exadr12.dat", в котором содержатся адреса подпрограмм вывода по возрастанию (от %0000 0000 0000 до %1111 1111 1111) - их адреса, в общем-то, в самом начале не определены и зависят от того, как глубоко проведена оптимизация. на 7 бит (итого 12 бит) 1264 байт на сжатие Add hl,hl 1896 приблизительно с переходами на 8 бит 3112 байт на сжатие Add hl,hl 4668 приблизительно с переходами 184 байта 1 оптимизированная процедура 184*128 = 23552 184*256 = 47104 5,875*64 = 376 376*128 = 48128 2.1.2.2. Кодирование со сжатием В этом случае, как всегда, используется количество повторов "1" или "0". В типовом случае - хранение в одном байте количества повторов - и чередование их последовательно - в чётном байте количество "0", в нечётном - количество "1". Тогда существенно алгоритм воспроизведения можно не менять совсем - можно его взять из 1.2 - и просто разработать алгоритм сжатия, который бы учитывал задержки для циклов "0" и "1", причём этот алгоритм должен обязательно учитывать неравномерность "0" и "1" - в конце последнего идёт обсчёт на выход HL за граничное значение: LOOP0 OUT (C),0 ; начало вывода "нуля" ... ; здесь идут команды, формирующие цикл и переход ;на вывод "1" LOOP1 OUT (C),C ; начало вывода "единицы" ... ; просчёт цикла INC L JP NZ,LOOP0 ; здесь как раз возникает асимметричность INC H ... В результате имеем превышение на 14 тактов (в указанном примере) цикла вывода "1" над циклом вывода "0", что выливается в то, что (а колоночка не ждёт ведь!) диффузор будет отклоняться во время этого обсчёта. Т.о. программа формирования сжатой последовательности будет оперировать следующими значениями: - Shift_per_tact - смещение диффузора в заданном направлении за 1 такт работы процессора - Base_0_length - базовая длина цикла вывода "0" - чисто служебная часть, на расчёт и т.п., в тактах - Base_1_length - базовая длина цилка вывода "1", в тактах - Cycle_length - длина одного цикла при неизменном выведенном значении в порт^, в тактах ^Примечание: ниже будет видно, что эта величина вовсе не соответствует числу 11 или 12 Отличие Base_0_length от Base_1_length заключается в смещении циклов вывода "1" от циклов вывода "0" в тактах друг относительно друга (см. предыдущий абзац). Ну вот, из этих величин можно получить вот что: Если есть, скажем, M выводов "0" и N выводов "1". (N и M - это величины, которые будут получены в результате сжатия). Тогда суммарная длительность в тактах всей процедуры вывода этих смежных значений займёт: Для всего вывода "0": Base_0_length+M*Cycle_length Для всего вывода "1": Base_1_length+N*Cycle_length Исходя из этих данных, можно получить точное значение "горки" огибающей, которая получится в результате сжатия. Величина Shift_per_tact даёт возможность, помножив число тактов, на неё узнать, насколько принятых единиц сместился диффузор в заданную сторону. Т.е. для вывода "0" диффузор убежит вниз на Shift_per_tact*(Base_0_length+M*Cycle_length). Для вывода "1" диффузор убежит вверх на Shift_per_tact*(Base_1_length+N*Cycle_length). Для указанного выше алгоритма вывода (взятого из 1.2) значения этих величин следующие: - Base_0_length - 17 тактов (LD A,(DE) : INC E : RET с выполненным условием минус виртуальные 5 тактов, затрачиваемых на виртуальную команду RET с невыполненным условием) - Base_1_length - аналогично 17 тактов (стековые манипуляции происходят очень редко, ими можно пренебречь - Cycle_length - 21 такт (DEC A : OUT (C),A : RET с невыполненным уловием) Примечание: Shift_per_tact имеет произвольное значение, выбранное из целей оптимальности соответствия огибающей сжатого сигнала с огибающей иходного сигнала, аналогично выбору этой величины в 2.1.2.1. Вполне закономерный вопрос - а зачем повторять каждый раз всю ту же процедуру OUT (C),X, если она уже была один раз сделана (для повторяющихся значений)? Ведь в 1.2 такой каркас проигрывателя был выбран для того, чтобы была жёсткая синхронизация проигрывателя и оцифровщика. Долой лишние OUT'ы! :D Вот отсюда и вытекает значение Cycle_length меньшее, чем 12 тактов. Сам процесс вывода нужного значения в порт перекочёвывает из Cycle_length в Base_?_length. Ну, собственно, алгоритм: LOOP0 LD B,(HL); взяли значение повторов для "0" INC L ; сместили указатель памяти OUT (C),0 ; вывели нулевое значение в порт ; эти три команды у нас создают длительность ;Base_0_length = 23 такта DJNZ $ ; это тело цикла задержки после вывода, ;формируем "спад" импульса ; таким образом Cycle_length = 13 ; последний цикл на DJNZ на 5 тактов короче, ;эти пять тактов выливаются в ; сокращение длительности Base_0_length на ;5 тактов LD B,(HL); взяли значение повторов для "1" INC L ; сместили указатель памяти JR Z,FINISH ; переход если значение стало равным 0 OUT (C),C ; вывели единичное значение в порт ; эти четыре команды плюс следующая JP LOOP0 ;у нас создают ; длительность Base_1_length = 40 тактов DJNZ $ ; это тело цикла задержки после вывода, ;формируем "подъём" импульса ; таким образом, Cycle_length = 13 ; последний цикл на DJNZ на 5 тактов короче, ;эти пять тактов выливаются в ; сокращение длительности Base_1_length на 5 ;тактов JP LOOP0 ; эта команда тоже попадает в Base_1_length FINISH INC H JP NZ,LOOP0 ; проверка на выход за предел страницы ну ;дальше стандартное ; переключение банков памяти и т.д. Опять же, в целях ускорения/оптимизации программа была переделана следующим образом: START JP LOOP0 ; немножко перестроена программа LOOP1 OUT (C),C ; вывели единичное значение в порт ; эта команда плюс оставшиеся ниже (после ;LOOP0) создают длительность ; Base_1_length = 33 такта DJNZ $ ; это тело цикла задержки после вывода, ;формируем "подъём" импульса ; таким образом Cycle_length = 13 ; последний цикл на DJNZ на 5 тактов короче, ;эти пять тактов выливаются в ; сокращение длительности Base_1_length на 5 ;тактов ; здесь пропадает команда JP LOOP0, хотя ;теперь JR работает обычно в условии ; истинности, и только иногда в ложном условии LOOP0 LD B,(HL); взяли значение повторов для "0" INC L ; сместили указатель памяти OUT (C),0 ; вывели нулевое значение в порт ; эти три команды у нас создают длительность ;Base_0_length = 23 такта DJNZ $ ; это тело цикла задержки после вывода, ;формируем "спад" импульса ; таким образом, Cycle_length = 13 ; последний цикл на DJNZ на 5 тактов короче, ;эти пять тактов выливаются в ; сокращение длительности Base_0_length на 5 ;тактов LD B,(HL); взяли значение повторов для "1" INC L ; сместили указатель памяти JP NZ,LOOP1 ; переход если значение отлично от нуля, ;стандартный цикл ; можно заметить что теперь здесь вместо ;JR стоит JP, последний на 2 ; такта короче - 10 вместо 12 для ;выполнения при истинном условии JR FINISH INC H JP NZ,LOOP0 ; проверка на выход за предел страницы, ну ;дальше стандартное ; переключение банков памяти и т.д. Итого: - Base_0_length - 23 - Base_1_length - 33 - Cycle_length - 13 Т.е. ускорение налицо. Однако есть и недостаток такого подхода: при частых переключениях каждое из них будет занимать 23+13 = 36 и 33+13 = 46 тактов, в то время как в предыдущем варианте это будет всегда (в обоих случаях) 17+21 = 38 тактов. Можно заметить следующее (недостаток? достоинство?): минимальная длина (в тактах) одной инструкции 4 такта, в то время как текущая длина цикла Cycle_length = 13. Можно поступить следующим образом: имеется цепочка (256 штук) команд NOP (вот они - 4 такта), которые идут сразу после OUT. Но кроме этого имеется ещё и команда JP (JR), которая передаёт управление на нужный NOP - т.е. таким образом, чтобы вышла пауза из нужного количества NOP'ов. LOOP1 NOP ; здесь 256 таких команд, на один из них ;происходит переход ... NOP NOP ; сразу после задержки происходит переход на ;вывод "0" LOOP0 EX DE,HL ; команда компенсации JP (DE) - надо ;восстановить значение HL LD A,(HL) ; прочитали значение задержки для выдачи "0" INC L ; сместили указатель ... ; здесь рассчёт, в DE помещается значение ;перехода EX DE,HL OUT (C),0 JP (HL) ; имитация JP (DE) LOOP2 NOP ; здесь так же 256 NOP'ов, на один из них ;происходит ... ; переход предыдущей командой NOP ; NOP ; LOOP3 EX DE,HL LD A,(HL) ; прочитали значение задержки для выдачи "1" INC L ; сместили указатель JR Z,FINISH ; здесь переход на проверку выхода ... ; здесь рассчёт, в DE помещается значение ;перехода EX DE,HL OUT (C),C JP (HL) ; имитация JP (DE) В результате имеем неопределённый алгоритм вычисления DE (явно не менее 10 тактов), откуда Base_0_length и Base_1_length получаются не маленькими, и очень короткое тело - Cycle_length всего 4 такта. Вот как может выглядеть алгоритм вычисления DE: LD HL,LOOP2 ; или LOOP1 если переход с 1 на 0 LD E,A ; D предустановлено в 0, т.е. D=0 ADD HL,DE ; вот получили адрес перехода Недостатком такого достаточно очевидного алгоритма является его длительность - целых 25 тактов. Кроме того, он ещё и портит значение HL, которое надо сохранять перед этим. В общем, пришлось отказываться (опять ведь!) от типовых решений и искать свои. Я опять пропускаю все этапы поиска решения и привожу готовый результат. Вот основные моменты: 1) Все значения повторов даются в инвертированном виде - т.е. вместо 1 будет 255, вместо 2 будет 254 и т.д. 2) начало каждой таблицы NOP'ов выравнено по адресу в 256 3) в HL адрес таблицы NOP'ов для перехода после "1" к "0", в IX - адрес таблицы NOP'ов для перехода после "0" к "1" 4) Т.о. для перехода от "1" к "0" с заданной задержкой просто пишется LD L,A : JP (HL) 5) Указатель памяти теперь не HL, а DE Ну вот, собственно, всё, теперь сама программа: LD BC,254 ; B=0, C=254 LD H,XX ; задали старшие байты указанных регистровых LD IXH,YY ;пар JP LOOP1 ; переход на процесс воспроизведения ORG #XX00 ; начало таблицы NOP 1->0 LOOP0 NOP ; те самые 256 NOP'ов ; адрес первого NOP'а #XX00 ... NOP ; его адрес #XXFE NOP ; его адрес #XXFF LOOP1 OUT (C),B ; вывели "0" в порт LD A,(DE) ; считали величину задержки для "0" INC E ; изменили указатель LD IXL,A ; загрузка младшего регистра, т.о. ;формирование адреса перехода JP (IX) ; переход на NOP'ы ORG #YY00 ; LOOP2 NOP ; опять 256 NOP'ов, адрес первого YY00 ... NOP ; #YYFE NOP ; #YYFF LOOP3 OUT (C),C ; вывели "1" в порт LD A,(DE) ; чтение очередной задержки INC E ; коррекция указателя RET Z ; проверка на ноль и выход в главное тело, где ;увеличение D и проверка ; банков памяти и т.д. LD L,A ; загрузка младшего регистра, т.о. ;формирование адреса перехода JP (HL) ; переход на NOP'ы Итак, OUT (C),B 12 LD A,(DE) 7 INC E 4 LD IXL,A 8 JP (IX) 8 ---------------- итого 38 тактов для Base_0_length OUT (C),C 12 LD A,(DE) 7 INC E 4 RET Z 5 LD L,A 4 JP (HL) 4 ---------------- итого 36 тактов для Base_1_length - Base_0_length - 38 - Base_1_length - 36 - Cycle_length - 4 В этом алгоритме можно маленько сжульничать. Дело в том, что у нас фактически всегда есть хотя бы 1 (т.е. в негативе оно будет выглядеть как 255) повтор, так вот, зачем нам лишний NOP в этом случае? Ведь можно эту задержку - 1 NOP - переместить на тело основного цикла. Т.е. убрать 1 NOP из таблицы и сдвинуть управляющую часть. Задержка в 4 такта для последнего NOP'а всё равно остаётся, она просто будет брать уже не с таблицы NOP'ов, а с управляющей части (с Base_0_length и Base_1_length). Теперь тот же алгоритм, но с жульничанием, выглядит так: LD BC,254 ; B=0, C=254 LD H,XX ; задали старшие байты указанных регистровых LD IXH,YY ;пар JP LOOP1 ; переход на процесс воспроизведения ORG #XX00 ; начало таблицы NOP 1->0 LOOP0 NOP ; те самые 256 NOP'ов ; адрес первого NOP'а #XX00 ... NOP ; его адрес #XXFD NOP ; его адрес #XXFE LOOP1 OUT (C),B ; вывели "0" в порт, команда эта начинается в ;#XXFF, часть её - 4 такта - ; то самое жульничание LD A,(DE) ; считали величину задержки для "0" INC E ; изменили указатель LD IXL,A ; загрузка младшего регистра, т.о. ;формирование адреса перехода JP (IX) ; переход на NOP'ы ORG #YY00 ; начало таблицы 0->1 LOOP2 NOP ; опять 256 NOP'ов, адрес первого YY00 ... NOP ; #YYFD NOP ; #YYFE LOOP3 OUT (C),C ; вывели "1" в порт, команда начинается в ;#YYFF - аналогично жульничание LD A,(DE) ; чтение очередной задержки INC E ; коррекция указателя RET Z ; проверка на ноль и выход в главное тело, где ; увеличение D и проверка банков памяти и т.д. LD L,A ; загрузка младшего регистра, т.о. ;формирование адреса перехода JP (HL) ; переход на NOP'ы - Base_0_length - 34 - Base_1_length - 32 - Cycle_length - 4 Теперь влияние быстрых переходов таково: 34+4=38 и 32+4=36 тактов на единичное вхождение, что всё-таки меньше, чем в взятом из 1.2 алгоритме, однако кроме этого более новый алгоритм значительно короче и значительно быстрее по значению Cycle_length. Значительно меньшая величина Cycle Length приводит к большему соответствию огибающих исходного сигнала и сжатого и затем воспроизведенного. Достоинством такого метода воспроизведения (и его же недостатком) является сильная зависимость от степени приближения огибающей - коэффициент сжатия у такого алгоритма зависит от многих параметров - т.е. даже для одного и того же звукового ряда можно задать параметры, которые приведут к разным результатам - либо в высокому сжатию, либо к высокой точности, но соответственно к низкому качеству звука. Это свойство даёт возможность варьировать качество выводимого звука в зависимости от той задачи, какая решается в контексте Вашего приложения. Т.е. если сжимать, например, речь, то она сжимается очень хорошо, там даже 1:20 коэффициент сжатия будет приемлем, речь будет звучать отчётливо, а насчёт места, я думаю, понятно, что очень компактно (если такое слово вообще можно применять к мультимедиа данным) оно будет храниться в оперативной памяти Спектрума, оставляя место для кода, например, демы и других мультимедиа данных. Алгоритм имеет и недостаток, причём очень существенный: всего возможно хранить 256 состояний задержки, умножаем 4 такта на 256 = 1024 такта на полный разворот значения плюс 36/38 тактов на базовый цикл, т.о. чисто теоретический предел проигрываемого контента для одной 16 КБ страницы будет 5 секунд ;) Итого на 256 кб память 5*16 = 90 секунд. Реально же обработанный звук, чтобы это были не одни полоски (смена "1" на "0" и "0" на "1"), будет длиться секунд 15-20 (для 256 кб памяти). Я считаю, что это существенное ограничение, однако никто не мешает использовать менее быстрый (предыдущий) алгоритм, у которого Cycle_length = 13 ;). И, несмотря на все приведённые аргументы, найдутся люди, которые спросят: "А можно ли ЕЩЁ быстрее?". Можно-то можно, только здесь даже такие монстры, как Scorpion с их 256 кб, будут просто отдыхать. Вот какой подход: в результате работы кодера получается не что иное, как адреса переходов. В то же время сам проигрыватель состоит из: 1) Пачки NOP'ов (256 штук и более, здесь возможность управлять более гибкая^) 2) из стоящих за этими NOP'ами OUT (C),C RET или OUT (C),0 RET ^Примечание: на стеке фактически хранятся адреса переходов, которые ограничены только памятью Спектрума (фактически 32 кб, 16 кб уходит на сам стек, если есть теневое ОЗУ, то это будет уже не 32, а 48 кб), т.е. можно сделать набор п/п, которые будут таким образом последовательно вызываться. Однако здесь для меня абсолютно непонятно, какие это могут быть п/п,- если хорошо этот момент продумать, то можно составить очень производительную систему. Например, очень часто попадаются комбинации, когда вначале идёт возрастание сигнала, потом его падение, т.е. надо выводить вначале 1, потом 0. Т.е. составив типовую п/п типа OUT (C),C OUT (C),0 можно существенно сократить издержки, выпустив соответсвенно на выходы только адрес этой п/п. Т.е. стек устанавливается на считанные данные, после чего каждый RET будет приводить программу на нужный NOP. Однако такая система будет сразу же в 2 раза более прожорливой (2 байта вместо одного), что сократит и без того малое время звучания звукового "сжатого" ряда. Но для такого алгоритма зато будет: - Base_0_length - 18^ - Base_1_length - 18 - Cycle_length - 4 ^Примечание: 18 тактов на самом деле получены с жульничанием, так же учитывается что по крайней мере 1 цикл задержки происходит, поэтому суммарное время единичного цикла будет 18+4 = 22 такта, как раз длительность RET и OUT. ---------------------------------------------------------------- lincod.pas {(C) GriV There ZX-Spectrum sound compressor route} {We have in source WAV-file - for 16 bit per channel and mono only. We make dinamic modulation for sound playing routine, it may increase quality of ZX-Speaker playing music. } uses crt,dos; { decoder's body: OUT (C),C LD A,(DE) - 7+12 = 19 (20) OUT (C),C LD L,A LD H,B - 12+4+4 = 20 OUT (C),C LD A,(HL) - 12+7 = 19 (20) OUT (C),C INC H - 12+4 = 16 OUT (C),C LD H,(HL) - 12+7 = 19 (20) OUT (C),C INC E LD L,A - 12+4+4 = 20 OUT (C),C RET Z - 12+5 = 17 (18) OUT (C),C JP (HL) - 12+4 = 16 } const Real_freq= 3500000; {CPU freq} { Real_freq= 3494400; {CPU freq} Shift_per_cycle= 10; {Up and down by cycle} buf_max= 8192; var f: file; {Text variable for file working} f2: file; {for byte-file} f1: file; WAV_freq,res_PACK,cur_byte_WAV,size_max: longint; in_buf_2,in_buf_1,internal_pos,internal_pos_max: integer; a: char; wav_fill: array [0..1] of longInt; inter_copy,err,err1,last_pos,posit,devider: double; percent,repeats: byte; buffer0,buffer2: array [1..buf_max] of char; buffer1: array [1..buf_max] of word; cyc_len: array [0..7] of shortint; shifter_in: shortint; byte_cmp:byte; DirInfo: SearchRec; end_: boolean; {------------------------ Here the procedures -------------------} procedure write_buf_2(buf: byte); begin if in_buf_2>buf_max then begin blockwrite(f2,buffer2,buf_max); in_buf_2:=1; end; buffer2[in_buf_2]:= chr(buf); inc(in_buf_2); end; procedure write_buf_1(buf: word); begin if in_buf_1>buf_max then begin blockwrite(f1,buffer1,buf_max); in_buf_1:=1; end; buffer1[in_buf_1]:= buf; inc(in_buf_1); end; {------------------ There are functions ------------------} Function to_int_from_char(a: integer): word; var { char_to_int: array [0..1] of char;} p: ^word; begin p:=@buffer0[a]; inc(cur_byte_WAV,2); to_int_from_char:= p^; end; function Inter: real; {retiurns interpolated value} var s: byte; l:word; low_to_word: array [0..1] of word; p: ^longint; begin if posit>=devider then begin if cur_byte_WAV>=size_MAX then end_:= true else begin err:= err+100*{sqr}(abs(WAV_fill[1]-last_pos)/(WAV_fill[1]+1)); p:= @low_to_word; p^:= round(last_pos); write_buf_1(low_to_word[0]); write(f1,l);} Wav_fill[0]:= Wav_fill[1]; if internal_pos>internal_pos_max then begin {cur_byte_WAV:= cur_byte_WAV+internal_pos-1;} internal_pos:= 3; if cur_byte_WAVpercent then begin writeln('Current ',s,'% working.'); percent:=s; end; end; end else begin Inter:= Wav_fill[0]+posit*(Wav_fill[1]-Wav_fill[0])/devider; {Linear interpolation|approximation} end; end; {---------------------- Now is main body --------------------} begin { DEFM "RIFF" DEFDW LEN_FILE+LEN_HEADER ; DWORD DEFM "WAVEfmt " DEFB 16,0,0,0,1,0,2,0 DEFDW FREQ_RATE DEFB 10H,B1H,2,0,4,0,10H,0 DEFM "data" DEFDW LENFILE } { cyc_len[0]:=19; cyc_len[1]:=20; cyc_len[2]:=19; cyc_len[3]:=16; cyc_len[4]:=19; cyc_len[5]:=20; cyc_len[6]:=17; cyc_len[7]:=20;} {for pentagon} cyc_len[0]:=20; cyc_len[1]:=20; cyc_len[2]:=20; cyc_len[3]:=16; cyc_len[4]:=20; cyc_len[5]:=20; cyc_len[6]:=18; cyc_len[7]:=16; {for M1 machines} {summary 146|150 tacts} findfirst(paramstr(1),anyfile,DirInfo); {Load source file} size_max:= Dirinfo.size; assign(f,DirInfo.Name); {Make pointer for file} reset(f,1); {open file} blockread(f,buffer0,28); {read first 26 bytes} cur_byte_WAV:= 29; Wav_freq:= to_int_from_char(25)+65536*to_int_from_char(27); {calculate frequency of input file} res_PACK:= 0; percent:= 0; devider:= Real_freq/Wav_freq; posit:= 0; in_buf_2:= 1; in_buf_1:= 1; internal_pos:= 1; blockread(f,buffer0,buf_max,internal_pos_max); clrscr; writeln(wav_freq); Wav_fill[0]:=to_int_from_char(internal_pos) xor 32768; {16bit traslation} Wav_fill[1]:=to_int_from_char(internal_pos+2) xor 32768; inc(internal_pos,4); {makes 2 interpolated values} assign(f2,'New_e.snd'); rewrite(f2,1); {ready file for playing} assign(f1,'New_e.raw'); rewrite(f1,2); {file for checking} rewrite(f1);} last_pos:= 32768; err1:= 0; {base errors} err:= 0; byte_cmp:=0; repeat inter_copy:=inter; byte_cmp:=byte_cmp*2; if last_pos<=inter_copy then inc(byte_cmp); posit:=posit+cyc_len[shifter_in]; err1:= err1+100*{sqr}abs(inter_copy-last_pos)/(inter_copy+1); if odd(byte_cmp) then last_pos:= last_pos + shift_per_cycle*cyc_len[shifter_in] else last_pos:= last_pos - shift_per_cycle*cyc_len[shifter_in]; inc(shifter_in); if shifter_in>=8 then begin write_buf_2(byte_cmp); byte_cmp:=0; shifter_in:=0; end; until end_; write_buf_2(byte_cmp); if in_buf_2>1 then blockwrite(f2,buffer2,in_buf_2-1); writeln('Error is ',(err/size_MAX):8:3); writeln('Approx. error is ',(err1/(size_MAX*devider)):8:3); writeln('Result file size is ',res_PACK); close(f2); close(f1); close(f); readkey; end. ---------------------------------------------------------------- lincod2.pas {There ZX-Spectrum sound compressor route} {We have in source WAV-file - 16 bit per channel and mono only in any freq. } uses crt,dos; const Real_freq= 3494400; {CPU freq} buf_max= 8192; bits=12; var f: file; {Text variable for file working} f2: file; {for byte-file} f1: file; cnt,WAV_freq,res_PACK,cur_byte_WAV,size_max: longint; in_buf_2,in_buf_1,internal_pos,internal_pos_max: integer; a: char; wav_fill: array [0..1] of longInt; adr_4_exa: array [0..4095] of word; cycle_sh,shift_ud,shift_med,Step_base,pos_min,inter_copy, err_up,err_dn,err_md,sh_up,sh_dn,epsi,err, last_pos,posit,devider,old_err: double; percent,repeats: byte; buffer0: array [1..buf_max] of char; buffer1,buffer2: array [1..buf_max] of word; cyc_len: array [0..11] of shortint; shifter_in: shortint; byte_cmp:word; k:byte; DirInfo: SearchRec; file2,end_: boolean; {------------------------ Here the procedures -------------------} procedure write_buf_2(buf: word); begin if in_buf_2>buf_max then begin if file2 then blockwrite(f2,buffer2,buf_max); in_buf_2:=1; inc(res_pack,buf_max); end; buffer2[in_buf_2]:= buf; inc(in_buf_2); end; procedure write_buf_1(buf: word); begin if in_buf_1>buf_max then begin if file2 then blockwrite(f1,buffer1,buf_max); in_buf_1:=1; end; buffer1[in_buf_1]:= buf; inc(in_buf_1); end; {------------------ There are functions ------------------} Function to_int_from_char(a: integer): word; var { char_to_int: array [0..1] of char;} p: ^word; begin p:=@buffer0[a]; inc(cur_byte_WAV,2); to_int_from_char:= p^; end; function Inter: real; {retiurns interpolated value} var s: byte; l:integer; low_to_word: array [0..1] of word; p: ^longint; begin if posit>=devider then {Need to read other value} repeat if cur_byte_WAV>=size_MAX then end_:= true {End of File} else begin err:= err+100*sqrt(sqr((inter_copy-last_pos-old_err)/(inter_copy+1)) +sqr((inter_copy-last_pos)/(inter_copy+1))); old_err:=(inter_copy-last_pos); p:= @low_to_word; p^:= round(last_pos); write_buf_1(low_to_word[0]); Wav_fill[0]:= Wav_fill[1]; {Move back value forward} if internal_pos>internal_pos_max then {need read block from disk} begin internal_pos:= 3; {1 value already has been read} if cur_byte_WAVpercent) and (file2) then begin writeln('Current ',s,'% working.'); percent:=s; end; end; until (posit=bits then begin byte_cmp:= adr_4_exa[byte_cmp]; write_buf_2(byte_cmp); byte_cmp:=0; shifter_in:=0; end; until end_; write_buf_2(byte_cmp); if in_buf_2>1 then blockwrite(f2,buffer2,in_buf_2-1); err:= err/size_MAX; shift_med:=step_base; writeln('Error is ',err:8:3); writeln('Result file size is ',res_PACK*2); writeln('Recommended shift is ',shift_med:8:3); close(f2); close(f1); close(f); end; {---------------------- Now is main body --------------------} begin { DEFM "RIFF" DEFDW LEN_FILE+LEN_HEADER ; DWORD DEFM "WAVEfmt " DEFB 16,0,0,0,1,0,2,0 DEFDW FREQ_RATE DEFB 10H,B1H,2,0,4,0,10H,0 DEFM "data" DEFDW LENFILE } clrscr; assign(f,'exadr12.dat'); {Make pointer for file} reset(f,2); {open file} blockread(f,adr_4_exa,4096); {Make buffer for traslation } close(f); cyc_len[0]:=12; cyc_len[1]:=12; cyc_len[2]:=12; cyc_len[3]:=12; cyc_len[4]:=12; cyc_len[5]:=12; cyc_len[6]:=12; cyc_len[7]:=12; cyc_len[8]:=12; cyc_len[9]:=12; cyc_len[10]:=12; cyc_len[11]:=22; {for M1 machines} file2:= false; shift_ud:= 0; sh_dn:=50; err_up:=100; epsi:=0.001; err_md:=sh_dn/5; if true then repeat for k:=0 to 10 do begin step_base:= sh_dn + (k-5)*err_md; file_2_zx; if err_up>err then begin err_up:= err; sh_up:=step_base; end; end; sh_dn:= sh_up; err_md:= err_md/5; until err_md<0.01; shift_ud:= 0; step_base:= sh_dn; file2:= true; file_2_zx; readkey; end. ---------------------------------------------------------------- erdiff.pas {(C) GriV There ZX-Spectrum sound compressor route^} {We have in source WAV-file - for 44KHz and 16 bit per channel Using Error difusion method } uses crt,dos; const Err_:array[0..15,0..15] of byte = ((1,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0), {Its error difusion} (1,0,0,0, 1,0,0,0, 0,0,0,0, 0,0,0,0), {constants for} (1,0,0,0, 1,0,0,0, 1,0,0,0, 0,0,0,0), {conversion} (1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0), (1,0,1,0, 1,0,0,0, 1,0,0,0, 1,0,0,0), (1,0,1,0, 1,0,0,0, 1,0,1,0, 1,0,0,0), (1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,0,0), (1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0), (1,1,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0), (1,1,1,0, 1,1,0,0, 1,1,1,0, 1,0,1,0), (1,1,1,0, 1,1,1,0, 1,1,1,0, 1,0,1,0), (1,1,1,0, 1,1,1,0, 1,1,1,0, 1,1,1,0), (1,1,1,1, 1,1,1,0, 1,1,1,0, 1,1,1,0), (1,1,1,1, 1,1,1,0, 1,1,1,1, 1,1,1,0), (1,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,0), (1,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,1)); var f: file; {Text variable for file working} f2: file of byte; {for byte-file} cur_byte_WAV,size_max: longint; last_pos,car,car1: word; a: char; wav_fill: array [0..1] of longInt; buffer: array [1..2048] of char; DirInfo: SearchRec; elem: byte; {---------------------- Now is main body --------------------} begin { DEFM "RIFF" DEFDW LEN_FILE+LEN_HEADER ; DWORD DEFM "WAVEfmt " DEFB 16,0,0,0,1,0,2,0 DEFDW FREQ_RATE DEFB 10H,B1H,2,0,4,0,10H,0 DEFM "data" DEFDW LENFILE } findfirst(paramstr(1),anyfile,DirInfo); {Load %1 as source file} size_max:= Dirinfo.size; assign(f,DirInfo.Name); {Make pointer for file} reset(f,1); {open file} blockread(f,buffer,26); cur_byte_WAV:= 27; {Pass header} clrscr; {Clear window} assign(f2,'New.snd'); rewrite(f2); repeat last_pos:=0; blockread(f,buffer,16,car); elem:=0; repeat Car1:=round(int((ord(buffer[last_pos*2+2]) xor 128)/16)); {As sign word, using only} inc(last_pos); {elder 4 bits} elem:=elem*2+err_[car1,last_pos]; {Changing element by shift and value} until last_pos*2>=car; write(f2,elem); {write full byte} inc(cur_byte_WAV,car); until cur_byte_WAV>=Size_Max; {until end of file} close(f2); {close files} close(f); readkey; {wait anykey} end.