Sample - w pogoni za utraconymi cyklami

Data

W piątym wydaniu magazynu Hot Style pisałem o wszystkich znanych mi metodach odtwarzania sampli na naszym C64. Czasy ich wynalezienia i zastosowania na tej platformie są tak odległe, jak pierwsza połowa lat osiemdziesiątych XX wieku i tak nowe, jak pierwsza dekada XXI wieku. Jednak metoda przekonwertowania logicznego stanu pamięci komputera na uniesienie membrany głośnika to nie wszystko, co można i trzeba w tej materii napisać. Dlatego dzisiaj zabierzemy się za procedury kopiowania danych, które są motorem każdego odtwarzacza sampli i zastanowimy, dlaczego owe techniki są tak kosztowne na maszynach 8-bitowych.

Wydajność czy funkcjonalność nawet najbardziej zaawansowanej procedury odtwarzającej może zostać zdegradowana przez słabą procedurę dostarczającą kolejne próbki do naszego przetwornika C/A. Ponieważ najogólniej mówiąc jest to pętla kopiująca dużą ilość danych w pamięci RAM z jednego miejsca w drugie, posłużymy się pewnym literaturowym przykładem, który będzie tutaj naszym sufitem. W dodatku nr 7 na końcu książki „Mikroprocesor 6502 i jego rodzina” autorstwa Henryka Kruszyńskiego i Krzysztofa Kulpy mamy test porównawczy prędkości pięciu najbardziej popularnych procesorów 8-bit tamtej ery. Procesor 6502, który jest zgodny programowo z 6510 w naszym C64, błyszczy na tle konkurencji osiągając prędkość transferu 55 KB/s przy takcie 1 MHz. Owa procedura testowa jest dosyć podobna do tej, jakiej moglibyśmy użyć do odtwarzania sampli, więc użyjemy jej jako punktu odniesienia. Wprawdzie procesor naszego komputera nie jest wcale taktowany zegarem 1 MHz (1,02 MHz dla NTSC i 0,98 dla PAL), jednakże są to różnice pomijalne i choć będą wpływać na prędkość odtwarzanych sampli, nie będzie to słyszalne. Owe 55 KB/s oznacza 55 KHz przy 8-bitowych samplach, więc spróbujmy osiągnąć to teraz przy użyciu Covoxa w porcie użytkownika jako najprostszego programowo przetwornika C/A. Aby poniższą procedurę inicjalizować, musimy wpisać SEI, by przerwania IRQ nie psuły nam synchronizacji, następnie wyłączyć ekran poprzez #$00 w $D011 oraz wpisać #$FF do $DD03, aby ustawić USER PORT jako wyjście. Jeśli Wasz Covox ma swoje wyjście dodatkowo puszczone przez AUDIO IN w układzie SID, trzeba jeszcze wpisać #$0F do $D418.

loop LDA sample,X ;4 cykle STA $DD01 ;4 cykle INX ;2 cykle BNE loop ;2/3 cykle INC loop+2 ;6 cykli BNE loop ;2/3 cykle

No dobrze, więc mamy tutaj pętlę, która odtwarza 256 próbek, następnie zwiększa stronę pamięci dla kolejnych 256 próbek i tak do końca RAM-u. Mamy więc dwa scenariusze prędkości: w najlepszym odtworzenie jednej próbki zajmuje 13 cykli, w najgorszym 21 cykli. Oczywiście w przypadku zwykłego kopiowania danych nie miałoby to większego znaczenia. Jednakże my odtwarzamy dźwięk cyfrowy, a w tym przypadku równe interwały pomiędzy kolejnymi próbkami są tutaj dla jakości kluczowe. Pozostaje nam więc nieco zmodyfikować procedurę tak, aby czas wykonywania krótkiej pętli zbilansował się z długą.

loop loop1 NOP ;2 cykle NOP ;2 cykle NOP ;2 cykle NOP ;2 cykle LDA sample,X ;4 cykle STA $DD01 ;4 cykle INX ;2 cykle BNE loop ;2/3 cykle INC loop+2 ;6 cykli BNE loop1 ;2/3 cykle

No, znacznie lepiej. Teraz nasza procedura niezależnie od miejsca wykonuje się w 21 cykli, co daje nam jakieś 46 KHz (0,98525/21 = 46,9). Należy tutaj dodać, że jest jeszcze inna szkoła konstruowania pętli odtwarzających sample na C64 wykorzystująca adresowanie pośrednie indeksowe i komórki na stronie zerowej w roli starszego i młodszego bajtu adresu sampla. Wracając jednak do naszej procedury, spożytkowaliśmy 100% mocy procesora, straciliśmy możliwość pokazania czegoś na ekranie i na domiar złego potrzebujemy dodatkowego sprzętu. Nie nadaje się więc do użycia w grze, czy demie. Wymaga modyfikacji.

Wyłączyliśmy przerwania IRQ, jak i ekran, by VIC-II nie rozsynchronizował nam naszej procedury. Kiedy tylko go włączymy wpisując #$1B do $D011, od następnej ramki układ graficzny zacznie odbierać procesorowi dostęp do magistrali co 8 linii rastra na 42 cykle, by pobrać z pamięci definicje wyświetlanych znaków. W C64 ekran ma 312 linii (połowa rozdzielczości pionowej PAL), a każda podzielona jest na 63 cykle zegarowe o szerokości jednego znaku tekstowego każdy. Szkoda, choć C64 nie jest tutaj jedyny. W przypadku serii Commodore 264 (C16, C116 i Plus/4) jest podobnie, tyle że tam TED zatrzymuje CPU w dwóch pierwszych liniach znakowych. Odszczepieńcem w tej rodzince jest VIC-20, którego układ graficzny z powodu niskiej rozdzielczości ekranu jest w stanie pobrać definicję znaku i go wyświetlić jednocześnie. Fenomen nazywany badlines występuje dlatego, że pamięć RAM jest współdzielona przez układ graficzny i procesor. Jest to jedna i ta sama niezbyt szybka pamięć połączona z niezbyt szybkimi układami, więc kompromisy zajść musiały, jeżeli chciało się mieć komputer za $200. Ogólnie wykradanie cykli procesorowi głównemu przez układy specjalizowane jest również dobrze znane na szesnastobitowcach. Jednakże w przypadku tych maszyn takt procesora jest na tyle duży, że nie stanowi to większego problemu. Zresztą po chwilowym (jak na horyzont czasowy rozwoju komputeryzacji) zachłyśnięciu się dedykowaną pamięcią dla układów dźwiękowych i graficznych, historia zatoczyła koło i znów rynek zdominowały systemy z pamięcią współdzieloną.

Tak więc oczywistym staje się, że musimy albo pozbyć się badlines albo zsynchronizować się z układem graficznym tak, aby nie wpadać na nie. Pierwsze możemy bez problemu uczynić na Commodore DTV, gdyż jest to współczesna maszyna taktowana zegarem systemowym 32 MHz i bad linie są tam jedynie emulowane dla zachowania zgodności. Możemy też w pewnym stopniu uczynić to na C64 za pomocą wariacji procedury FLD. Niestety aspektów konstrukcyjnych nie przeskoczymy i stracimy ekran, więc żaden z niej pożytek. Pozostańmy zatem przy synchronizacji. Dzięki rejestrowi $D012, którego odczyt informuje nas o wartości młodszego bajtu numeru linii rastra, którą VIC-II właśnie kreśli, będziemy mogli rozpocząć odtwarzanie sampli w liniach nieparzystych i o ile nasza procedura odtwarzająca się w nich zmieści, nigdy nie trafimy na bad linię – pięknie! Jednakże... takie podejście niesie za sobą pewne konsekwencje. Teraz nasza procedura będzie „grać” na liniach długich, których jest w ramce 156. Tak więc 312 linii/2 * 50 Hz = 7800 Hz. OK. Nie jest źle, w końcu nawet na Amidze najczęściej spotykana częstotliwość próbkowania sampli w modułach wynosiła 8363 Hz. Ponieważ jak napisałem wyżej odtworzenie jednej próbki naszą testową procedurą kosztuje nas 21 cykli, tak więc kusi, by pojechać po bandzie i podwoić częstotliwość odtwarzania... Ale nie odlatujmy. Należy wspomnieć, że taka procedura uruchomiona na C64 NTSC będzie odtwarzała sample z częstotliwością 7860 Hz (264 linie/2 * 60 Hz) – mówiłem, że różnicy nie będzie słychać. Teraz zanim przejdziemy do praktyki, pozostała jeszcze jedna teoretyczna kwestia. Zsynchronizowanie się z VIC-II na początku ramki, by od odpowiedniego momentu uruchomić nasz player, nie sprawi nam większego problemu, ale nadal będziemy musieli wpleść w naszą pętlę instrukcje opóźniające odtwarzanie, aby przeskoczyć bad linię... 100% CPU, jak nic. Nie idźmy tą drogą. Zamiast tego niech CIA zrobi to za nas! Zaprogramujemy jeden z liczników CIA tak, aby pracował z częstotliwością zegara systemowego, zerował się z częstotliwością 7800 Hz i wywoływał przerwanie wprost do naszej procedury odtwarzającej. Tym sposobem otrzymamy równe interwały odtwarzanych próbek, ponieważ taktowanie CIA jest niezależne od tandemu VIC-CPU, a jednocześnie mechanizm przerwań uwolni pozostałe moce przerobowe procesora do wykonywania innych fajnych rzeczy. Zbyt piękne, aby mogło być prawdziwe? Tylko trzeba teraz to napisać.

SEI ;bez IRQ LDA #$35 ;wyłącz KERNAL i BASIC STA $01 LDA #$FF ;USER PORT jako wyjście STA $DD03 LDA #<nmi ;wektor przerwań NMI będzie wskazywał na player STA $FFFA LDA #>nmi STA $FFFB LDA #$7D ;zegar A będzie odliczał 126 cykli ;bo 0.98525 MHz/7,8 KHz=126 STA $DD04 LDA #$00 STA $DD05 LDX #$81 STX $DD0D ;zegar A jako źródło przerwań NMI raster CMP $D012 ;czekaj na zerową linię rastra BNE raster STX $DD0E ;zegar A start w trybie ciągłym z przeładowaniem JMP * nmi BIT $DD0D ;4 cykle smp LDA sample ;4 cykle STA $DD01 ;4 cykle INC smp+1 ;6 cykli BNE endnmi ;2/3 cykle INC smp+2 ;6 cykli endnmi RTI ;6 cykli

Powyższy program najlepiej uruchomić po zresetowaniu komputera, ponieważ chciałem, aby procedura była możliwie krótka i inicjalizacja rejestrów CIA jest potraktowana po macoszemu. Jak widać wyłączamy system operacyjny i interpreter. Przerwania IRQ są również wyłączone, ale możemy je spokojnie zagospodarować w przyszłości. Ponieważ pozbyliśmy się narzutu systemu operacyjnego i interpretera, zmieniamy wektor przerwań bezpośrednio i system nie wyręczy nas już w zapamiętaniu wartości rejestrów procesora podczas przerwania. Należy o tym pamiętać, ponieważ akurat w tym przykładzie we fragmencie playera wykorzystujemy tylko akumulator, a poza przerwaniem procesor kołuje w pętli. Nie zawsze jednak tak będzie i jeśli będziemy chcieli coś ciekawego zrobić poza przerwaniem, musimy zrezygnować z jednego rejestru lub po prostu w przerwaniu wrzucać i ściągać jego zawartość ze stosu, albo jeszcze szybciej – z jakiejś komórki na stronie zerowej. Gdy CIA odliczy z $7D do $00 i wywoła przerwanie, jego obsługa potrwa kilka cykli, ponieważ licznik programu (PC) i rejestr stanu słowa muszą być zapamiętane. Dalej zaczyna się wyścig z rastrem, w którym CPU zawsze przegrywa. Kiedy porównamy zawartość rejestru $D012 z #$00 i wynik będzie pozytywny, zegar A zacznie odliczanie dopiero po 10 cyklach zegarowych od momentu w którym przyłapaliśmy VIC-II na kreśleniu linii 0, ale to nie wszystko. Sprawdzamy tylko młodszy bajt rejestru rastra, jest jeszcze starszy bit w rejestrze $D011. Innymi słowy wartość linii rastra o numerze 0 w naszym programie może wystąpić w dwóch miejscach na ekranie. Ponadto nie mamy wcale pewności, że nasza pętla sprawdzająca nie rozpocznie się akurat, gdy VIC-II właśnie kończył kreślenie linii 0. W naszym przypadku nie zepsuje nam to dnia, ale warto o tym pamiętać przy pisaniu skrajnie wymagających czasowo programów. Procedurę playera zaczynamy od BIT $DD0D, który informuje CIA, że przerwanie zostało odebrane. Może być zamiast tego LDA $DD0D i też będzie OK. Reszta procedury wygląda bardzo podobnie do naszej pierwszej kopiującej i tak jak ona, nie jest synchroniczna. Tak więc nasz kod w najgorszym przypadku pochłonie 32 cykle CPU. Mamy z grubsza rozwiązaną kwestię wydajności i ekranu. Możemy w miarę swobodnie wykonywać różne ciekawe rzeczy poza przerwaniem. W miarę swobodnie pod warunkiem, że nie włączymy sprajtów. Powyższa procedura zakłada, iż są wyłączone. Należy pamiętać, że każdy sprajt kradnie procesorowi dodatkowo cykle w każdej linii, w której jest wyświetlany. Implikacje są bardzo istotne, ale nie będziemy się tutaj nad nimi rozwodzić. Natomiast pozbędziemy się naszego sprzętowego balastu i z Covoxa przejdziemy na SID-a.

nmi LDA $DD00 ;4 cykle smp LDA sample ;4 cykle STA $D402 ;4 cykle LDA #$49 ;2 cykle STA $D404 ;4 cykle LDA #$41 ;2 cykle STA $D404 ;4 cykle INC smp+1 ;6 cykli BNE endnmi ;2/3 cykle INC smp+2 ;6 cykli endnmi RTI ;6 cykli nmi LDA $DD0D ;4 cykle LDA #$11 ;2 cykle STA $D404 ;4 cykle LDA #$09 ;2 cykle STA $D404 ;4 cykle smp LDA sample ;4 cykle STA $D401 ;4 cykle LDA #$01 ;2 cykle STA $D404 ;4 cykle INC smp+1 ;6 cykli BNE endnmi ;2/3 cykle INC smp+2 ;6 cykli endnmi RTI ;6 cykli

Po stronie lewej mamy metodę software’owego PWM zaproponowaną przez Levente’a. Zaletę tej metody widać na pierwszy rzut oka – niski apetyt na czas CPU. Jej minusem jest dźwięk nośnej obecny w torze audio, którą jest przebieg prostokątny generowany z częstotliwością wywoływania owej procedury. Po prawej mamy wynalazek SounDemoNa, który tak, jak poprzednia metoda, również używa bitu testowego SID-a, ale w tym wypadku nie do generowania przebiegu prostokątnego szybciej niż uczyniłby to sam SID, ale do kontrolowanego dodawania wartości sampla do wartości generowanej fali. Jej zdecydowaną zaletą jest wyśmienita jakość odtwarzanego dźwięku, lecz jest o jeden wpis do rejestru SID-a cięższa dla procesora. Każda z tych metod wymaga nieco innej inicjalizacji SID-a przed uruchomieniem, więc poniższy kod trzeba będzie dodać do naszej procedury inicjalizującej zegary CIA:

LDA #$00 STA $D400 STA $D403 LDA #$40 STA $D401 LDA #$f0 STA $d406 LDA #$0f STA $d418 LDA #$00 STA $D400 LDA #$f0 STA $d406 LDA #$0f STA $d418

Po złożeniu tego w całość i uruchomieniu na pewno dostrzeżecie, że jakość pozostawia wiele do życzenia. To dlatego, że nie wspomniałem jeszcze o innej bardzo istotnej rzeczy. Napisałem wyżej, że procedura nie jest zbalansowana i nie jest jak diabli. Dopilnowaliśmy tylko, aby nasz kod wykonywał się w nieparzystych liniach rastrowych, by ominąć bad linie. Niestety mamy tutaj do czynienia z samplami, gdzie przesunięcie w czasie pomiędzy kolejnymi próbkami nawet o jeden cykl zegarowy wprowadza zakłócenia. Niesymetrycznym ogonkiem naszej procedury zajmiemy się na końcu, ponieważ ważniejszym problemem jest jej niesymetryczność na początku. Skoro CIA wywołuje przerwanie cyklicznie wedle wskazań zegara, to dlaczego w ogóle ona występuje? No cóż... Gdybyśmy mieli do czynienia ze współczesnym 8-bitowym procesorem, jak na przykład AVR, gdzie prawie wszystkie mnemoniki wykonują się w ciągu jednego cyklu zegarowego, nie byłoby problemu. Jednakże na 6510 rozkazy mogą zajmować procesorowi od 2 do 7 cykli, a CPU może wykonać skok w pamięci jedynie po zakończeniu wykonywania rozkazu. Tak więc oprócz wspomnianego przeze mnie wyżej stałego opóźnienia przy przyjmowaniu przerwania (zapis wartości rejestrów na stosie) dochodzi jeszcze bliżej nieprzewidywalne opóźnienie wynoszące od 0 do 7 cykli zegarowych w zależności od tego, co CPU wykonywał, nim go od tego przerwaniem oderwaliśmy. Dla sampli takie opóźnienie to zbyt wiele, a im metoda odtwarzania lepsza, tym bardziej cierpi na braku synchronizacji. Powyższe cierpią na przesunięcie o 3 cykle, co można sprawdzić dodając gdzieś INC $D020, DEC $D020. Dlaczego akurat 3? Ponieważ poza przerwaniem wykonujemy JMP *, który tak się akurat składa, że zajmuje 3 cykle. Potrzebujemy stabilnego rastra i ponieważ o metodach jego osiągania można by napisać spokojnie drugi artykuł tej wielkości, to posłużymy się gotowcem, w którym wykorzystamy ten sam zegar, który wywołuje nasze przerwanie. Kod, który będzie musiał być wykonywany przy każdym przerwaniu, wygląda następująco:

LDA $DD04 ;załaduj wartość licznika do A EOR #$07 ;zamień malejące wartości licznika... AND #$07 ;...na 0, 1, 2, 3, 4, 5, 6, 7 STA *+4 ;zapisz, jako argument (offset skoku) instrukcji BPL BPL * ;skocz, jeśli na tym etapie A różne od 0 CMP #$C9 ;A=0 -> 8 cykli CMP #$C9 ;A=1 -> 7 cykli BIT $EA24 ;A=2 -> 6 cykli ;A=3 -> 5 cykli ;A=4 -> 4 cykle ;A=5 -> 3 cykle ;A=6 -> 2 cykle ;A=7 -> 0 cykli

OK. Myślę, że z tak obszernym komentarzem nic już więcej dodawać nie trzeba co do tego, jak działa. Może poza tym, że gdy instrukcja BPL skoku nie wykonuje, zajmuje o 1 cykl mniej, więc pierwszy scenariusz jest o 1 cykl krótszy.

Dobrze, zajmijmy się teraz drugim końcem naszej procedury odtwarzającej. W naszym treningowym przypadku odtwarzamy zawartość całej pamięci RAM w pętli. Różnica między jednym, a drugim końcem wynosi 5 cykli, ponieważ w drugim rozgałęzieniu nie wykonujemy INC na starszym bajcie adresu, ale zyskaliśmy 1 cykl z niewykonania skoku BNE. BIT $EA i NOP powinny załatwić sprawę, choć ja zrównoważę to innym skokiem. Całość może wyglądać następująco:

nmi STA $02 ;zapamiętaj A LDA $DD04 EOR #$07 AND #$07 STA *+4 BPL * CMP #$C9 CMP #$C9 BIT $EA24 smp LDA sample STA $D402 LDA #$49 STA $D404 LDA #$41 STA $D404 INC smp+1 BNE end INC smp+2 endnmi LDA $DD0D LDA $02 ;przywróć A RTI end CLV BVC endnmi nmi STA $02 ;zapamiętaj A LDA $DD04 EOR #$07 AND #$07 STA *+4 BPL * CMP #$C9 CMP #$C9 BIT $EA24 LDA #$13 STA $D404 LSR $D404 smp LDA sample STA $D401 LDA #$01 STA $D404 INC smp+1 BNE end INC smp+2 endnmi LDA $DD0D LDA $02 ;przywróć A RTI end CLV BVC endnmi

Przyjrzyjmy się instrukcji odbierającej przerwanie (LDA $DD0D) oraz je kończącej (RTI). Razem zajmują 10 cykli. Pierwsza nie wymaga, aby wynik odczytu został gdziekolwiek zapamiętany – nie jest nam do niczego potrzebny. Wystarczy sam akt dostępu do konkretnego adresu w RAM w trybie odczytu. Natomiast RTI zdejmuje ze stosu licznik programu oraz rejestr stanu słowa. Jeśli w naszej procedurze inicjalizacji playera zapiszemy w $DDOC wartość #$40, która jest kodem instrukcji RTI i wykonamy JMP $DD0C, procesor wykona ów mnemonik i poprosi o dostęp do kolejnego pod $DD0D. Dzięki tej sztuczce zyskamy 1 cykl – niedużo, ale zawsze coś. Jednakże nawet z jej zastosowaniem procedura Levente’a zajmie 74 cykle, a SounDemoNa aż 80 cykli z obsługą NMI licząc. Będzie to odpowiednio 62% i 67% CPU przy włączonym ekranie i wyłączonych sprajtach. W drugim listingu straszy jeszcze ten LSR $D404. Zyskujemy dzięki niemu dwa bajty, jeśli nie wykorzystujemy w tym czasie trzeciego kanału SID-a. Zajętość procesora jest więc znacząca w porównaniu do wersji na Covoxa, której ciężar w tym wypadku wyniósłby jakieś 52%.

Jak widać nawet cyklożerność zaawansowanych metod odtwarzania jest niczym w porównaniu ze stabilizacją i synchronizacją całości z rastrem. Wszak C64 nie został zaprojektowany z myślą o odtwarzaniu sampli, a i tak to, co udało się na tej platformie osiągnąć bez rozszerzenia sprzętu w porównaniu do konkurencji, jest po prostu imponujące.

/Data