Kurs FPGA Lattice (17). Odtwarzacz melodii

Kurs FPGA Lattice (17). Odtwarzacz melodii

W poprzednim odcinku stworzyliśmy moduł, który odtwarza dźwięk o żądanej długości i częstotliwości. Teraz rozbudujemy ten projekt, dodając pamięć ROM z melodyjkami oraz moduł, który będzie odczytywał nuty z pamięci i przekazywał je do modułu generującego dźwięki.

Projekt, którym zajmiemy się w tym odcinku kursu, będzie trochę bardziej skomplikowany niż konstrukcje stworzone przez nas wcześniej. Z tego powodu zaczniemy od zapoznania się ze schematem projektu, zaprezentowanym na rysunku 1 oraz hierarchią modułów, którą widać na rysunku 2.

Rysunek 1. Schemat modułu top
Rysunek 2. Hierarchia modułów w projekcie

Utwórz nowy projekt w Lattice Diamond i dodaj do niego pliki widoczne na rysunku 3. Wszystkie moduły, z wyjątkiem top i MelodyPlayer, omawialiśmy już w poprzednich odcinkach kursu. Każdy z nich możesz pobrać z repozytorium na GitHubie, a także pobrać gotowy projekt dla programu Diamond. Linki do źródeł znajdziesz w ramce.

Rysunek 3. Drzewko projektu z widoczną listą plików źródłowych

Moduł top

Przeanalizujmy plik top.v, którego kod prezentuje listing 1.

// Plik top.v

`default_nettype none
module top(
input wire Reset, // Pin 17
input wire Play_i, // Pin 66
input wire Stop_i, // Pin 69
output wire SoundWave_o, // Pin 28
output wire [7:0] Segments_o,
output wire [7:0] Cathodes_o
);

// Generator sygnału zegarowego
parameter CLOCK_HZ = 14_000_000;
wire Clock;
OSCH #(
.NOM_FREQ("14.00")
) OSCH_inst(
.STDBY(1’b0),
.OSC(Clock),
.SEDSTDBY()
);

// Wykrywanie zbocza opadającego na przycisku enkodera E41
// W stanie spoczynkowym jest stan 1
wire Play; // 1
Encoder EncoderPlay( // 2
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(1’b1), // 3
.AsyncB_i(1’b1), // 4
.AsyncS_i(Play_i), // 5
.Increment_o(),
.Decrement_o(),
.ButtonPress_o(Play), // 6
.ButtonRelease_o(),
.ButtonState_o()
);

// Wykrywanie zbocza opadającego na przycisku enkodera E42
wire Stop;
Encoder EncoderStop(
.Clock(Clock),
.Reset(Reset),
.AsyncA_i(1’b1),
.AsyncB_i(1’b1),
.AsyncS_i(Stop_i),
.Increment_o(),
.Decrement_o(),
.ButtonPress_o(Stop),
.ButtonRelease_o(),
.ButtonState_o()
);

// Zmienne do pokazywania dźwięków na wyświetlaczu
wire [15:0] Duration_ms; // 7
wire [15:0] HalfPeriod_us;

// Instancja odtwarzacza melodii
MelodyPlayer #( // 8
.CLOCK_HZ(CLOCK_HZ)
) MelodyPlayer_inst(
.Clock(Clock),
.Reset(Reset),
.Play_i(Play), // 9
.Stop_i(Stop), // 10
.SoundWave_o(SoundWave_o), // 11
.Duration_o(Duration_ms), // 12
.HalfPeriod_o(HalfPeriod_us) // 13
);

// Instancja sterownika wyświetlacza
DisplayMultiplex #(
.CLOCK_HZ(CLOCK_HZ),
.SWITCH_PERIOD_US(1000),
.DIGITS(8)
) DisplayMultiplex_inst(
.Clock(Clock),
.Reset(Reset),
.Data_i({Duration_ms, HalfPeriod_us}), // 14
.DecimalPoints_i(8’b00010000), // 15
.Cathodes_o(Cathodes_o),
.Segments_o(Segments_o),
.SwitchCathode_o()
);

endmodule

`default_nettype wire

Listing 1. Kod pliku top.v

Na liście portów mamy następujące wejścia i wyjścia:

  • Reset – standardowo, jak w każdym poprzednim projekcie, reset asynchroniczny jest wyzwalany przyciskiem K0 na płytce MachXO2 Mega.
  • Play_i oraz Stop_i – są to wejścia podłączone do przycisków, w enkoderach E41 (górny) i E42 (dolny). Te przyciski będą służyć do uruchomienia odtwarzania melodii oraz do jej zatrzymania.
  • SoundWave_o – wyjście podłączone do tranzystora sterującego głośniczkiem.
  • Segments_o[7:0] – 8-bitowe wyjście sterujące segmentami wyświetlacza multipleksowanego.
  • Cathodes_o[7:0] – 8-bitowe wyjście sterujące katodami wyświetlacza multipleksowanego.

W pierwszej kolejności budujemy generator sygnału zegarowego, tworząc instancję modułu OSCH – dokładnie tak samo, jak w poprzednich odcinkach kursu.

Następnie musimy zająć się wykrywaniem wciśnięcia gałek enkoderów. Na płytce User Interface Board zastosowano już odpowiednie rezystory pull-up oraz filtry RC do odszumiania drgań styków, więc nie potrzebujemy żadnego dodatkowego kodu w tym celu. Wystarczą nam dwie instancje modułów Encoder – po jednej na każdy przycisk. Omówimy tylko jedną z nich, ponieważ obie działają w identyczny sposób.

W linii 2 tworzymy instancję modułu enkodera, który obsługiwać będzie enkoder E41 – przycisk E41 używany jest z kolei do uruchamiania odtwarzacza. Interesuje nas tylko wykrywanie wciśnięcia gałki enkodera, więc do jego wejścia AsyncS_i doprowadzamy sygnał Play_i. Moduł enkodera zapewnia synchronizację sygnałów wejściowych z domeną zegarową, a także wykrywanie zmian badanego sygnału. W tym przypadku używać będziemy tylko wyjścia ButtonPress_o, informującego o wykryciu wciśnięcia przycisku (linia 6). Łączymy je z sygnałem Play typu wire, który został utworzony w linii 1.

Nie interesuje nas wykrywanie obrotu gałki, zatem do wejść AsyncA_i oraz AsyncB_i doprowadzamy na stałe stan wysoki (linie 3 i 4), a wszystkie pozostałe wyjścia modułu enkodera pozostawiamy niepołączone.

Dalej, w linii 7 i kolejnej tworzymy dwie 16-bitowe zmienne wire Duration_ms oraz HalfPeriod_us. Posłużą one do przekazywania informacji o aktualnie odtwarzanym dźwięku z modułu MelodyPlayer (linie 11 i 12) do modułu sterującego wyświetlaczem DisplayMultiplex (linia 14).

Przechodzimy wreszcie do instancji odtwarzacza melodyjek (linia 8). W liniach 9 i 10 mamy wejścia uruchamiające i przerywające odtwarzanie melodii – są to sygnały wychodzące ze sterowników enkoderów. W linii 11 znajduje się wyjście sygnału dźwiękowego, które wyprowadzone jest na pin układu FPGA. Linie 12 i 13 to wyjścia informujące o długości oraz półokresie aktualnie odtwarzanego dźwięku, które służą tylko do celów informacyjnych. Można te wyjścia pozostawić niepodłączone.

Na końcu mamy instancję sterownika wyświetlacza LED, który już wielokrotnie stosowaliśmy w poprzednich odcinkach kursu.

Zwróć uwagę, że wejście Data_i modułu wyświetlacza jest 32-bitowe, ponieważ wyświetlacz ma osiem cyfr wyświetlających 4-bitową szesnastkową cyfrę od 0 do 9 i od A do F. Z tego powodu dwie 16-bitowe zmienne Duration_ms oraz HalfPeriod_us sklejamy ze sobą za pomocą operatora konkatenacji {}. W ten sposób cztery cyfry wyświetlacza z prawej strony będą pokazywać półokres odtwarzanego dźwięku, a pozostałe cztery cyfry z lewej będą pokazywały jego długość (linia 14). W celu zwiększenia czytelności ustawimy wyświetlanie kropki pomiędzy tymi dwiema liczbami (linia 15).

Moduł MelodyPlayer

Moduł MelodyPlayer zawiera w sobie dwa moduły podrzędne – pamięć ROM, omawianą w 15 odcinku kursu (w niej zapisana jest melodia) oraz moduł SoundGenerator, opracowany przez nas w 16 odcinku. Kod tego modułu pokazano na listingu 2.

// Plik melody_player.v

`default_nettype none
module MelodyPlayer #(
parameter CLOCK_HZ = 10_000_000
)(
input wire Clock,
input wire Reset,
input wire Play_i,
input wire Stop_i,
output wire SoundWave_o,

output wire [15:0] Duration_o,
output wire [15:0] HalfPeriod_o
);

// Pamięć melodii
reg [11:0] Address; // 1
wire [ 7:0] Data; // 2
ROM #(
.ADDRESS_WIDTH(12), // 3
.DATA_WIDTH(8), // 4
.MEMORY_FILE("rom.mem") // 5
) ROM_inst(
.Clock(Clock),
.Reset(Reset),
.ReadEnable_i(1’b1), // 6
.Address_i(Address), // 7
.Data_o(Data) // 8
);

// Zmienne
reg [15:0] Duration_ms; // 9
reg [15:0] HalfPeriod_us; // 10
reg Request; // 11
wire SoundGeneratorDone; // 12

// Rejestr stanów i możliwe stany
reg [2:0] State; // 13
localparam IDLE = 3’d0;
localparam DUMMY = 3’d1;
localparam READ_DURATION_H = 3’d2;
localparam READ_DURATION_L = 3’d3;
localparam READ_HPERIOD_H = 3’d4;
localparam READ_HPERIOD_L = 3’d5;
localparam PLAYING = 3’d6;

// Maszyna stanów, która odczytuje kolejne nuty z pamięci
// i przekazuje do generatora dźwięków
always @(posedge Clock, negedge Reset) begin // 14
if(!Reset) begin // 15
State <= IDLE;
Request <= 0;
Duration_ms <= 0;
HalfPeriod_us <= 0;
Address <= 0;
end else begin
case(State) // 16
IDLE: begin
if(Play_i) begin
Address <= 0;
State <= DUMMY;
end
end

DUMMY: begin // 17
Address <= Address + 1’b1;
State <= READ_DURATION_H;
end

READ_DURATION_H: begin // 18
Duration_ms[15:8] <= Data;
Address <= Address + 1’b1;
State <= READ_DURATION_L;
end

READ_DURATION_L: begin // 19
Duration_ms[7:0] <= Data;
Address <= Address + 1’b1;
State <= READ_HPERIOD_H;
end

READ_HPERIOD_H: begin // 20
HalfPeriod_us[15:8] <= Data;
Address <= Address + 1’b1;
State <= READ_HPERIOD_L;
end

READ_HPERIOD_L: begin // 21
HalfPeriod_us[7:0] <= Data;
Request <= 1’b1;
State <= PLAYING;
end

PLAYING: begin // 22
Request <= 1’b0;
if(Duration_ms == 16’d0) begin // 23
State <= IDLE;
end else if(Stop_i) begin // 24
State <= IDLE;
Duration_ms <= 16’d0;
HalfPeriod_us <= 16’d0;
end else if(SoundGeneratorDone) begin // 25
State <= READ_DURATION_H;
Address <= Address + 1’b1;
end
end
endcase
end
end

// Instancja generatora dźwięków
SoundGenerator #( // 26
.CLOCK_HZ(CLOCK_HZ)
) SoundGenerator_inst(
.Clock(Clock),
.Reset(Reset),
.Start_i(Request), // 27
.Finish_i(Stop_i),
.Duration_ms_i(Duration_ms), // 28
.HalfPeriod_us_i(HalfPeriod_us), // 29
.SoundWave_o(SoundWave_o),
.Busy_o(),
.Done_o(SoundGeneratorDone) // 30
);

// Tylko na potrzeby debugowania
// aby pokazać czas trwania i półokres nuty na wyświetlaczu
assign Duration_o = Duration_ms;
assign HalfPeriod_o = HalfPeriod_us;

endmodule
`default_nettype wire

Listing 2. Kod pliku melody_player.v

Praca niniejszego modułu jest bardzo prosta. Rozpoczyna ją wystąpienie stanu wysokiego na wejściu Play_i. Stan wysoki powinien trwać jeden cykl sygnału zegarowego, a następnie wejście to powinno przejść w stan niski (kolejne próby uruchomienia pracującego już modułu będą ignorowane). Moduł odczytuje informacje o dźwięku, który ma być odtworzony. Kopiuje te dane z pamięci ROM do zmiennych roboczych, po czym uruchamia odtwarzanie dźwięku w module SoundGenerator. Następnie czeka, aż odtwarzanie nuty zostanie zakończone i wtedy odczytuje kolejny dźwięk z pamięci. Odbywa się to tak długo, aż moduł odczyta nutę o zerowej długości lub aż pojawi się stan wysoki na wejściu Stop_i.

Każda nuta definiowana jest dwiema 16-bitowymi liczbami. Są to: czas trwania w milisekundach (Duration_ms) oraz półokres sygnału dźwiękowego, podany w mikrosekundach (HalfPeriod_us). Zatem na każdą nutę przypadają cztery bajty. Jeżeli półokres jest równy zeru, mamy pauzę, której długość określona została w Duration_ms. Natomiast jeżeli Duration_ms jest równe zero, to znaczy, że nie ma już więcej nut do odtworzenia i dotarliśmy do końca melodii.

Portów wejścia i wyjścia nie będziemy opisywać, ponieważ nie ma tutaj nic, czego nie omówiliśmy już wcześniej.

Kod modułu zaczynamy od instancji pamięci ROM, w której przechowywane będą melodie. W liniach 1 i 2 tworzymy dwie kluczowe zmienne do obsługi pamięci, tzn. Addess typu reg oraz Data typu wire. Szerokość zmiennej Data to osiem bitów, czyli jeden bajt, natomiast zmienna Address jest 12-bitowa. Oznacza to, że możemy zaadresować 212, czyli 4096 bajtów. Każda nuta zajmuje 4 bajty, więc sumarycznie będziemy mogli zapisać 1024 nuty.

Następnie, w liniach 3 i 4 konfigurujemy instancję modułu za pomocą parametrów ADDRESS_WIDTH oraz DATA_WIDTH, w których trzeba podać liczbę bitów zmiennych Addess oraz Data. Dalej podajemy nazwę pliku z zawartością pamięci (linia 5). W kolejnej części kursu zobaczymy, jak ustworzyć taki plik.

Teraz podłączamy wejścia i wyjścia. W linii 6 wejście ReadEnable ustawiamy na stałe w stan wysoki. Jest to funkcjonalność wbudowana w blok EBR polegająca na tym, że pamięć ignoruje zegar i wszelkie zmiany na wejściu adresowym, jeżeli wejście ReadEnable jest w stanie niskim. W linii 7 łączymy wejście adresowe ze zmienną Address, sterowaną przez maszynę stanów, którą omówimy za chwilę. W linii 8 łączymy wyjście danych ze zmienną Data, które odczytywane jest przez logikę tej samej maszyny stanów.

Tworzymy dwie 16-bitowe zmienne Duration_ms (linia 9) oraz HalfPeriod_us (linia 10) typu reg. Te zmienne będziemy zapisywać w maszynie stanów, którą za chwilę omówimy, danymi pobranymi z pamięci ROM. Będziemy odczytywać – bajt po bajcie – dane z pamięci ROM i kopiować je do tych zmiennych.

Celem zmiennej Request (linia 11) jest uruchomienie modułu, w chwili gdy zakończone zostanie odczytywanie z pamięci kolejnej nuty i będzie ona gotowa do odczytu w zmiennych Duration_ms oraz HalfPeriod_us. Zmienna ta będzie ustawiana w stan wysoki na jeden cykl sygnału zegarowego.

Pozostaje nam już tylko utworzenie zmiennej SoundGeneratorDone, typu wire (linia 12). Służy ona do przekazywania sygnału zakończenia odtwarzania nuty z modułu SoundGenerator (linia 30) do maszyny stanów, która uruchamia odtwarzanie kolejnej nuty (linia 25).

Przejdźmy do maszyny stanów. Jej rejestr tworzymy w linii 13 i następnie definiujemy wszystkie możliwe stany. Cała logika maszyny stanów zawarta jest w jednym synchronicznym bloku always (linia 14). Maszyna używa kilka zmiennych, które już wcześniej omówiliśmy, a w momencie resetu układu inicjalizujemy je w linii 15 i kilku kolejnych.

Maszyna stanów opisana została w postaci instrukcji warunkowej case, wykonującej różne zadania w zależności aktualnej wartości od zmiennej State (linia 16).

Stan IDLE to stan bezczynności, kiedy nie jest odtwarzana żadna melodia. W tej sytuacji sprawdzamy jedynie, czy sygnał Play_i jest w stanie wysokim i jeżeli tak, to zerujemy licznik adresu, aby odtwarzać melodię od początku i jednocześnie zmieniamy stan na DUMMY.

W kolejnym cyklu zegara wchodzimy do stanu DUMMY, w którym jedynie zwiększamy licznik adresu o 1 i przełączamy stan na READ_DURATION_H (linia 17). Można zadać pytanie, dlaczego zwiększyliśmy adres, a nie odczytaliśmy danych z adresu zerowego? Otóż bloki pamięci EBR, w przeciwieństwie do tradycyjnych pamięci, działają synchronicznie z sygnałem zegarowym. W jednym takcie zegarowym moduł sterujący pamięcią ustawia żądany adres. W kolejnym takcie – pamięć udostępnia dane z tego adresu na swoim wyjściu Data_o. Dopiero w następnym takcie można te dane odczytać.

Stany READ_DURATION_H, READ_DURATION_L oraz READ_HPERIOD_H (linie 18-20) wyglądają bardzo podobnie. Odczytujemy kolejny bajt danych i kopiujemy go do odpowiednich połówek 16-bitowych zmiennych Duration_ms oraz HalfPeriod_us. Każdy z tych stanów trwa tylko jeden takt zegarowy.

Dochodzimy wreszcie do stanu READ_HPERIOD_L (linia 21). W tym momencie kopiujemy ostatni z czterech odczytanych bajtów do HalfPeriod_us. Dalej ustawiamy zmienną Request w stan wysoki, aby poinformować moduł SoundGenerator, że przy kolejnym takcie zegara ma odczytać dane na swoich wejściach Duration_ms_i oraz HalfPeriod_us_i, a następnie ma rozpocząć odtwarzanie dźwięku. Moduł SoundGenerator odczytuje zmienną Request poprzez wejście Start_i (linia 27). Następnie stan maszyny jest zmieniany na PLAYING.

W linii 22 obsługujemy stan PLAYING. Niezależnie od wszystkich innych rzeczy, zerujemy zmienną Request, ponieważ moduł SoundGenerator rozpoczyna w tym momencie odtwarzanie dźwięku, a ta zmienna powinna mieć wartość 1 tylko przez jeden takt sygnału zegarowego. Jednocześnie, w tym samym takcie zegarowym, możliwe są do wykonania trzy operacje:

  • Jeżeli odczytaliśmy z pamięci nutę o zerowej długości, tzn. zmienna Duration_ms jest równa zero (linia 23), to znaczy, że dotarliśmy do końca melodii. W związku z tym stan maszyny jest ustawiany na IDLE, w którym czekać będzie ona na kolejne wciśnięcie przycisku start.
  • Jeżeli warunek 1 nie został spełniony, to następnie sprawdzamy, czy został wciśnięty przycisk stop (linia 24). W takiej sytuacji zmieniamy stan maszyny na IDLE oraz zerujemy zmienne tymczasowe.
  • Jeżeli warunki 1 i 2 nie zostały spełnione, sprawdzamy, czy został ustawiony SoundGeneratorDone (linia 25) przez moduł SoundGenerator. Sygnał ten jest ustawiany na wyjściu tego modułu w linii 30. Informuje on, że odtwarzanie nuty zostało zakończone. Wtedy zmieniamy stan maszyny na READ_DURATION_H, aby odczytać kolejną nutę oraz inkrementujemy licznik adresu.

Jeżeli żaden z tych warunków nie został spełniony – to maszyna stanów nie robi nic. Trwa odtwarzanie dźwięku. W tym czasie czekamy tak długo, aż któryś z trzech opisanych warunków stanie się prawdziwy.

Pozostaje już tylko utworzyć instancję modułu SoundGenerator w linii 26. Moduł ten omawialiśmy szczegółowo w poprzednim odcinku kursu.

Testbench modułu MelodyPlayer

Zanim wgramy bitstream do FPGA, przetestujmy, jak działa nasz nowy odtwarzacz melodyjek. W testbenchu utworzymy instancję modułu MelodyPlayer i podamy mu plik, w którym ma się znajdować testowa melodyjka, składająca się tylko z kilku prostych dźwięków. Przeanalizujmy kod testbencha, który pokazano na listingu 3.

// Plik melody_player_tb.v

`timescale 1ns/1ns
`default_nettype none
module MelodyPlayer_tb();

parameter CLOCK_HZ = 10_000_000;
parameter HALF_PERIOD_NS = 1_000_000_000/(2 * CLOCK_HZ);

// Generator sygnału zegarowego
reg Clock = 1’b1;
always begin
#HALF_PERIOD_NS;
Clock = !Clock;
end

// Zmienne
reg Reset = 1’b0;
reg Play = 1’b0;
reg Stop = 1’b0;
wire SoundWave;

// Instancja testowanego modułu
MelodyPlayer #(
.CLOCK_HZ(CLOCK_HZ)
) DUT(
.Clock(Clock),
.Reset(Reset),
.Play_i(Play),
.Stop_i(Stop),
.SoundWave_o(SoundWave)
);

// Zapisywanie zmiennych w trakcie symulacji
initial begin
$dumpfile("melody_player.vcd");
$dumpvars(0, MelodyPlayer_tb);
end

// Sekwencja testowa
initial begin
$timeformat(-6, 3, "us", 12);
$display("===== START =====");
$display(" Time Durat HaPer Freq"); // 1

#1 Reset <= 1’b1;

repeat(9) @(posedge Clock); // 2

@(posedge Clock) // 3
Play <= 1’b1;
@(posedge Clock)
Play <= 1’b0;

repeat(10) @(posedge Clock); // 4

wait(DUT.State == DUT.IDLE); // 5
repeat(10) @(posedge Clock); // 6

$display("====== END ======");
$finish;
end

endmodule
`default_nettype wire

Listing 3. Kod pliku melody_player_tb.v

Testbench zaczyna się tak samo, jak każdy inny omawiany w tym kursie, więc przeskoczymy od razu do sekwencji testowej. W gruncie rzeczy jest ona bardzo prosta. W linii 1 printujemy nagłówek tabeli, w której pojawiać się będą informacje o aktualnie odtwarzanym dźwięku. Następnie odczekujemy kilka taktów zegarowych tylko po to, by wykres w GTKWave ładniej wyglądał (linia 2) – dzięki temu uzyskamy odstęp między krawędzią okna wykresu a interesującymi zmianami sygnałów.

W linii 3 i kolejnych oczekujemy na zbocze rosnące sygnału zegarowego. Następnie ustawiamy zmienną Play w stan wysoki. Jest ona połączona z wejściem Play_i testowanego modułu, a stan wysoki powoduje rozpoczęcie odtwarzania melodii. Później znów czekamy na zbocze rosnące sygnału zegarowego, po czym przywracamy tę zmienną w stan niski. Rozpoczyna się odtwarzanie dźwięku. Moduł MelodyPlayer zaczyna odczytywać z pamięci pierwszą nutę i przekazuje ją do modułu SoundGenerator.

Maszyna stanów modułu MelodyPlayer pracuje i kopiuje kolejne nuty do generatora dźwięków tak długo, aż się skończą, tzn. zostanie odczytany dźwięk o zerowym półokresie i zerowej częstotliwości. Wtedy maszyna stanów przejdzie ponownie w stan IDLE. W linii 5 umieściliśmy instrukcję wait, która zawiesza wykonywanie instrukcji sekwencji testowej aż do wystąpienia opisanej sytuacji. Taka metoda testowania jest wygodna – daje nam możliwość sprawdzenia dowolnej melodii, niezależnie od tego, jak długo trwa, ile dźwięków jest zapisanych w pamięci, czy też jaka jest częstotliwość sygnału zegarowego w symulacji (nie musimy bawić się w odliczanie cykli zegarowych).

Dalej pozostaje już tylko poczekać kilka taktów zegarowych, aby w przeglądarce GTKWave widoczny stał się odstęp między końcem odtwarzania dźwięku a końcem symulacji (linia 6). Następnie kończymy symulację za pomocą instrukcji $finish.

Pamięć melodii musimy wypełnić jakąś melodią. Na potrzeby testbencha utworzymy plik rom.mem, którego zawartość zaprezentowano na listingu 4. Zawiera on tylko trzy krótkie dźwięki, aby symulacja wykonywała się w miarę szybko. Oczywiście moglibyśmy zasymulować jakąś rzeczywistą melodię, ale wtedy symulator pracowałby bardzo długo, a plik z wynikami symulacji miałby wiele gigabajtów danych.

// Plik rom.mem
00 20 07 77 // 64c1
00 20 03 BB // 64c2
00 40 01 DD // 32c3
00 00 00 00 // koniec

Listing 4. Kod pliku rom.mem

Jak należy stworzyć plik z zawartością pamięci, zobaczymy w dalszej części artykułu.

Pozostaje już tylko uruchomić symulator skryptem, którego kod znajduje się na listingu 5.

@echo off
iverilog -o melody_player.o melody_player.v melody_player_tb.v sound_generator.v strobe_generator.v rom.v
vvp melody_player.o
del melody_player.o

Listing 5. Kod pliku melody_player.bat

Po przeprowadzeniu symulacji powinniśmy otrzymać na konsoli komunikaty pokazane na listingu 6. Warning w pierwszej linii jest zjawiskiem prawidłowym i wynika z tego, że pamięć zainicjalizowaliśmy plikiem zawierającym zaledwie 16 bajtów. Pamięć ma 4096 bajtów, więc reszta komórek pozostaje niezainicjalizowana (przy próbie odczytania niezainicjalizowanych komórek pamięci dostaniemy wartość nieokreśloną X).

WARNING: ../rom/rom.v:17: $readmemh(rom.mem): Not enough words in the file for the requested range [0:4095].
VCD info: dumpfile melody_player.vcd opened for output.
===== START =====
Time Durat HaPer Freq
1.700us 32 1911 261
32002.300us 32 955 523
64002.900us 64 477 1046
128003.500us 0 0 0
====== END ======
melody_player_tb.v:60: $finish called at 128004500 (1ns)

Listing 6. Wynik symulacji na konsoli

W następnej tabeli widzimy czas symulacji, w którym rozpoczęło się odtwarzanie poszczególnych dźwięków. Kolumna Durat to skrót od Duration, czyli czas trwania dźwięku w milisekundach. HaPer to półokres, a Freq to oczywiście częstotliwość w hercach.

Zobaczmy screenshoty z symulacji. Pierwszy z nich, ukazujący całą symulację od początku do końca, pokazano na rysunku 4. Sygnał wyjściowy, doprowadzony do głośniczka, w celu poprawy czytelności zaznaczono kolorem pomarańczowym. Widać wyraźnie, że podczas symulacji zostały odtworzone trzy dźwięki o różnych częstotliwościach, przy czym pierwszy i drugi dźwięk były tak samo krótkie, a trzeci był dwukrotnie dłuższy.

Rysunek 4. Przebiegi uzyskane podczas symulacji

Zwróć uwagę na DurationTimer oraz HalfPeriodTimer. Te dwa liczniki celowo zostały przedstawione w formie analogowej. Widzimy wyraźnie, że na początku odtwarzania dźwięku licznik DurationTimer jest ładowany wartością początkową i odlicza w dół, aż osiągnie wartość zerową, co kończy odtwarzanie dźwięku. Natomiast HalfPeriodTimer podczas odtwarzania nuty wielokrotnie liczy od wartości początkowej do zera. Kiedy osiągnie zero, wówczas odwracany jest stan wyjścia SoundWave.

Przybliżmy widok na początek pracy, co prezentuje rysunek 5. Wszystko zaczyna się od stanu wysokiego na wejściu Play_i, co trwa przez jeden takt zegarowy. Dla zwiększenia czytelności zaznaczono ten sygnał kolorem żółtym. Skutkuje to uruchomieniem maszyny stanów. Zwróć uwagę, że inkrementuje się licznik adresu pamięci. Pamięć podaje różne dane, które są przepisywane do zmiennych Duration_ms i HalfPeriod_us. Finalnie maszyna stanów przechodzi w stan PLAYING i ustawia wejście Start_i modułu SoundGenerator w stan wysoki na jeden cykl zegara. To sprawia, że w następnym cyklu zegarowym liczniki odpowiedzialne za liczenie długości i półokresu dźwięku zaczynają pracować. Sygnał Busy_o przechodzi w stan wysoki, ponieważ zaczyna się właściwa praca modułu odtwarzającego dźwięki.

Rysunek 5. Zbliżenie na odczytywanie pierwszej nuty z pamięci

Przyjrzyjmy się, jak działa odczytywanie kolejnej nuty po zakończeniu odtwarzania aktualnego dźwięku (rysunek 6). Kiedy w module SoundGenerator licznik DurationTimer jest równy zero i jednocześnie TickMilli jest w stanie wysokim, to znaczy, że upłynął czas odtwarzania dźwięku. Z tego powodu wyjście Busy_o generatora dźwięków przechodzi w stan niski do czasu, aż zostanie on ponownie uruchomiony z nowymi danymi. Ponadto jego wyjście Done_o zostaje ustawione w stan wysoki na jeden takt zegarowy. To powoduje ponowne uruchomienie maszyny stanów modułu MelodyPlayer, przez co zostają odczytane kolejne bajty i załadowane do liczników. Kiedy liczniki są już załadowane nowymi danymi, ustawiamy wejście Start_i modułu SoundGenerator w stan wysoki na jeden takt zegarowy i rozpoczyna się odtwarzanie kolejnej nuty.

Rysunek 6. Zbliżenie na moment odczytywania kolejnej nuty

Plik z melodyjkami

Mamy już gotowy moduł odtwarzający dźwięk, odczytujący nuty z pamięci, mamy także moduł pamięci ROM, lecz trzeba jeszcze zapełnić tę pamięć jakimiś melodyjkami. Aby ułatwić sobie zadanie przekształcania nut na częstotliwość dźwięku i długość w milisekundach, stwierdziłem, że dobrze będzie zaadaptować format zapisu stosowany w starych telefonach Nokia. Każdy mógł wówczas skomponować sobie dowolną melodię, wpisując nuty z klawiatury w postaci liter i cyfr, a na szczęście, w internecie wciąż dostępna jest cała masa stron z mnóstwem melodii gotowych do użytku.

Najpierw jednak zobaczmy, jak zapisywane są poszczególne nuty. Notacja zapisu jest bardzo prosta i składa się z dwóch części. Pierwsza to czas trwania dźwięku. Wszystkie możliwe czasy zebrano w tabeli 1.

Druga część to częstotliwość dźwięku dla wszystkich nut i ich półtonów z trzech oktaw. Aby nie wchodzić zbytnio w zawiłości muzyczne, częstotliwości wszystkich obsługiwanych dźwięków pokazano w tabeli 2. Łącząc dowolne dwie części z tych tabel, możemy uzyskać dowolne nuty, a poszczególne nuty w melodii powinny być oddzielone spacją. W ten sposób melodię mamy zapisaną za pomocą znaków ASCII. Wystarczy tylko opracować jakiś skrypt, który przekonwertuje zapisy nut na bitstream zrozumiały dla naszego modułu.

Zobaczmy kod zaprezentowany na listingu 7. Jest to skrypt w Pythonie, który konwertuje melodyjki zapisane w notacji z telefonów Nokia na plik pamięci rom.mem, inicjalizujący pamięć ROM. Ponieważ jest to kurs języka Verilog, a nie Python, nie będziemy szczegółowo analizować tego kodu (zresztą jest on bardzo trywialny, nie ma żadnej obsługi błędów ani możliwości konfiguracji).

melody = ""

# Debug
#melody += "64c1 64c2 32c3 "

# Axel F
melody += "4g2 8.#a2 16g2 16- 16g2 8c3 8g2 8f2 4g2 8.d3 16g2 16- 16g2 8#d3 8d3 8#a2 8g2 8d3 8g3 16g2 16f2 16- 16f2 8d2 8a2 2g2 2- "

# Star wars
melody += "4a1 4a1 4a1 4f1 16c2 4a1 4f1 16c2 2a1 4e2 4e2 4e2 4f2 16c2 4#g1 4f1 16c2 2a1 4a2 4a1 16a1 4a2 4#g2 16g2 16#f2 16f2 4#f2 8#a1 4#d2 4d2 16#c2 16c2 16b1 4c2 8f1 4#g1 4f1 16#g1 4c2 4a1 16c2 2e2 2- "

# Harry Potter
melody += "8b1 8.e2 16g2 8#f2 4e2 8b2 4.a2 4.#f2 8.e2 16g2 8#f2 4d2 8f2 2b1 8- 8b1 8.e2 16g2 8#f2 4e2 8b2 4d3 8#c3 4c3 8#g2 8.c3 16b2 8#a2 4#f2 8g2 2e2 8- 8g2 4b2 8g2 4b2 8g2 4c3 8b2 4#a2 8#f2 8.g2 16b2 8#a2 4#a1 2- "

# Pink Panther
melody += "8#g1 2a1 8b1 2c2 8#g1 8a1 8b1 8c2 8f2 8e2 8a1 8c2 8e2 2#d2 16d2 16c2 16a1 8g1 1a1 8#g1 2a1 8b1 2c2 8#g1 8a1 8b1 8c2 8f2 8e2 8c2 8e2 8a2 1#g2 8#g1 2a1 8b1 2c2 16#g1 8a1 8b1 8c2 8f2 8e2 8a1 8c2 8e2 2#d2 8d2 16c2 16a1 2- "

# Star Wars 2
melody += "8#c1 8#c1 16#c1 2#f1 2#c2 8b1 16#a1 8#g1 2#f2 4#c2 8b1 16#a1 8#g1 2#f2 4#c2 8b1 16#a1 8b1 2#g1 8#c1 8#c1 16#c1 2#f1 2#c2 8b1 16#a1 8#g1 2#f2 4#c2 8b1 16#a1 8#g1 2#f2 4#c2 8b1 16#a1 8b1 2#g1 4#c1 16#c1 2#d1 8#c2 8b1 8#a1 8#g1 8#f1 16#f1 8#g1 16#a1 4#g1 2- "

# Final countdown
melody += "4- 8- 16b2 16a2 4b2 4e2 4- 8- 16c3 16b2 8c3 8b2 4a2 4- 8- 16c3 16b2 4c3 4e2 4- 8- 16a2 16g2 8a2 8g2 8#f2 8a2 4g2 8- 16#f2 16g2 4a2 8- 16g2 16a2 8b2 8a2 8g2 8#f2 4e2 4c3 2b2 4- 16b2 16c3 16b2 16a2 1b2 2- "

# Phanthom of the Opera
melody += "4e1 4a1 4e1 4g1 8f1 2f1 4d1 4g1 8d1 1e1 4e1 4a1 4e1 4g1 8f1 2f1 4d1 4g1 8d1 1e1 4e1 4a1 4c2 4e2 8d2 2d2 4d2 4g2 8d2 1e2 4e2 1a2 8g2 8f2 8e2 8d2 8c2 8b1 8a1 1#g1 4f1 4f1 8e1 1e1 2- "

duration_dict = {
"64.": b’\x00\x30’,
"64": b’\x00\x20’,
"32.": b’\x00\x60’,
"32": b’\x00\x40’,
"16.": b’\x00\xBB’,
"16": b’\x00\x7D’,
"8.": b’\x01\x77’,
"8": b’\x00\xFA’,
"4.": b’\x02\xEE’,
"4": b’\x01\xF4’,
"2.": b’\x05\xDC’,
"2": b’\x03\xE8’,
"1.": b’\x0B\xB8’,
"1": b’\x07\xD0’,
}

frequency_dict = {
"#c1": b’\x07\x0B’,
"c1": b’\x07\x77’,
"#d1": b’\x06\x47’,
"d1": b’\x06\xA6’,
"e1": b’\x05\xEC’,
"#f1": b’\x05\x47’,
"f1": b’\x05\x97’,
"#g1": b’\x04\xB3’,
"g1": b’\x04\xFB’,
"#a1": b’\x04\x30’,
"a1": b’\x04\x70’,
"b1": b’\x03\xF4’,
"#c2": b’\x03\x85’,
"c2": b’\x03\xBB’,
"#d2": b’\x03\x23’,
"d2": b’\x03\x53’,
"e2": b’\x02\xF6’,
"#f2": b’\x02\xA3’,
"f2": b’\x02\xCB’,
"#g2": b’\x02\x59’,
"g2": b’\x02\x7D’,
"#a2": b’\x02\x18’,
"a2": b’\x02\x38’,
"b2": b’\x01\xFA’,
"#c3": b’\x01\xC2’,
"c3": b’\x01\xDD’,
"#d3": b’\x01\x91’,
"d3": b’\x01\xA9’,
"e3": b’\x01\x7B’,
"#f3": b’\x01\x51’,
"f3": b’\x01\x65’,
"#g3": b’\x01\x2C’,
"g3": b’\x01\x3E’,
"#a3": b’\x01\x0C’,
"a3": b’\x01\x1C’,
"b3": b’\x00\xFD’,
"-": b’\x00\x00’,
}

melody = melody.strip()
notes = melody.split(" ")
counter = 0

with open("rom.mem", "w") as file:

for note in notes:

print(f"{counter}\t{note:6s}\t", end="")
counter += 1

half_period_hex = None;
duration_hex = None;

for frequency in frequency_dict:
if frequency in note:
note_without_frequency = note.replace(frequency, "")
half_period_hex = frequency_dict[frequency]
break;

for duration in duration_dict:
if duration in note_without_frequency:
duration_hex = duration_dict[duration]
break;

print(f"{duration_hex[0]:02X}{duration_hex[1]:02X}
{half_period_hex[0]:02X}{half_period_hex[1]:02X}")

file.write(f"{duration_hex[0]:02X} ")
file.write(f"{duration_hex[1]:02X} ")
file.write(f"{half_period_hex[0]:02X} ")
file.write(f"{half_period_hex[1]:02X} ")
file.write(f"// {note:6s}\n")

file.write("00 00 00 00 // end\n")

Listing 7. Kod pliku converter.py

Melodyjki dodajemy na początku pliku do zmiennej melody. Umieściłem tam kilka przykładowych melodii, które będą rozpoznawane przez większość Czytelników. Możemy dodać ich dowolną liczbę, byle tylko sumaryczna liczba wszystkich nut nie przekroczyła 1023. W tym miejscu można dodać melodie, których mnóstwo znajdą Czytelnicy w internecie. Warto tylko zwrócić uwagę, że po każdej nucie – łącznie z ostatnią – trzeba umieścić spację.

Następnie mamy dwa słowniki dla każdej możliwej opcji długości i częstotliwości dźwięku. W tych słownikach etykietom tekstowym przyporządkowano odpowiadający im bitstream. Dalej znajduje się pętla iterująca po wszystkich nutach po kolei, która wyszukuje fragmenty tekstu w słownikach i dopasowuje do nich odpowiednie bajty. Wszystko to jest później zapisywane w pliku rom.mem.

Aby uruchomić ten skrypt, otwieramy konsolę i wpisujemy polecenie python converter.py.

Mamy już wszystko, czego potrzebujemy. Syntezujemy, konfigurujemy piny IO w taki sposób, jak uwidoczniono na rysunku 7 – i wgrywamy bitstream do FPGA.

Rysunek 7. Konfiguracja pinów w Spreadsheet

Po naciśnięciu górnego enkodera usłyszymy melodię. Naciśnięcie drugiego enkodera powoduje przerwanie odtwarzania.

W następnym odcinku prześledzimy, jak przesyłać dane z FPGA do innego układu poprzez popularny interfejs UART.

Zobacz więcej:
Repozytorium modułów wykorzystywanych w kursie: https://github.com/leonow32/verilog-fpga
MachXO2 Family Datasheet: https://www.latticesemi.com/view_document?document_id=38834
Memory Usage Guide for MachXO2 Devices: https://www.latticesemi.com/view_document?document_id=39082

Dominik Bieczyński
leonow32@gmail.com

Artykuł ukazał się w
Elektronika Praktyczna
marzec 2024
DO POBRANIA
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik czerwiec 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio lipiec - sierpień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje maj 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna czerwiec 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich lipiec 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów