Kurs FPGA Lattice (16). Generowanie dźwięków

Kurs FPGA Lattice (16). Generowanie dźwięków

W tym oraz kolejnym odcinku kursu sporządzimy odtwarzacz dzwonków podobny do tego, jaki był dostępny w telefonach Nokia 3310. Nauczymy się odtwarzać różne dźwięki, a w kolejnej części poznamy bloki pamięci EBR i wykorzystamy je do odtwarzania melodii.

Czy fotografia otwierająca przywołuje w Tobie wspomnienia? Dla mnie ten projekt okazał się bardzo nostalgiczny – 20 lat temu, kiedy byłem dzieciakiem w gimnazjum, przez całe godziny wklepywałem różne kody w kompozytorze dzwonków na Nokii 3310 (oprócz grania w Snake’a oczywiście!). W raczkującym wówczas Internecie można było znaleźć strony, na których znajdowały się tysiące najróżniejszych melodii. Niektóre z tych witryn istnieją do dzisiaj, a kompozycje ówczesnych wirtuozów piszczących melodyjek posłużą nam, by nauczyć się czegoś nowego na temat FPGA.

Projekt podzielony będzie na dwie duże części. Pierwszą będzie moduł odpowiedzialny za odtwarzanie pojedynczych nut. Każdą z nich można scharakteryzować za pomocą dwóch wartości liczbowych – częstotliwości dźwięku oraz czasu trwania. Moduł odtwarzający nuty będzie sygnalizował, kiedy jest zajęty, a kiedy gotowy do odtworzenia kolejnej nuty.

Rysunek 1. Schemat podłączenia głośnika

W drugiej części opracujemy moduł nadrzędny, wyposażony w przyciski Start i Stop, a także pamięć, w której będą zapisane nuty melodii. Po wciśnięciu Start moduł nadrzędny będzie odczytywał kolejne nuty z pamięci i przekazywał je do modułu odtwarzającego pojedyncze dźwięki tak długo, aż melodia się skończy lub zostanie wciśnięty przycisk Stop. W ten sposób poznamy metodę, jak zgrać ze sobą dwa moduły, gdzie praca jednego jest uzależniona od funkcjonowania drugiego. Ponadto, co najważniejsze, poznamy różne sposoby implementacji pamięci w układach FPGA MachXO2. Dodatkowo na multipleksowanym wyświetlaczu LED, który poznaliśmy w 9 odcinku kursu, wyświetlać będziemy długość trwania nuty oraz jej czas półokresu na potrzeby debugowania układu.

W roli głośnika wykorzystamy prosty i niedrogi brzęczyk. Na płytce User Interface Board, współpracującej z MachXO2 Mega, zastosowano układ, którego schemat widzimy na rysunku 2. Pin Buzzer został połączony z linią 28 układu FPGA. Stan wysoki sygnału Buzzer powoduje otwarcie się tranzystora T90, w związku z czym przez niego (a więc i przez cewkę głośnika) płynie prąd. Otwarcie i zamknięcie tranzystora powoduje „pyknięcie” głośnika. Kiedy „pyknięcia” zdarzają się z odpowiednio dużą częstotliwością, nasze uszy odbierają je jako dźwięk – strasznie piszczący i niezbyt przyjemny, ale jakość dźwięku nie jest priorytetem w kursie FPGA.

Rysunek 2. Przebiegi sygnałów uzyskane podczas symulacji

Moduł SoundGenerator

Zacznijmy od przeanalizowania kodu z listingu 1.

Listing 1. Plik sound_generator.v

// Plik sound_generator.v
`default_nettype none
module SoundGenerator #(
parameter CLOCK_HZ = 10_000_000
)(
input wire Clock, // 1
input wire Reset,

input wire Start_i, // 2
input wire Finish_i, // 3
input wire [15:0] Duration_ms_i, // 4
input wire [15:0] HalfPeriod_us_i, // 5

output wire SoundWave_o, // 6
output wire Busy_o, // 7
output wire Done_o // 8
);

// Generator impulsów co 1 milisekundę
wire TickMilli; // 9
StrobeGenerator #(
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(1000)
) StrobeGeneratorMilli(
.Clock(Clock),
.Reset(Reset),
.Enable_i(Busy_o || Start_i), // 10
.Strobe_o(TickMilli)
);

// Generator impulsów co 1 mikrosekundę
wire TickMicro; // 11
StrobeGenerator #(
.CLOCK_HZ(CLOCK_HZ),
.PERIOD_US(1)
) StrobeGeneratorMicro(
.Clock(Clock),
.Reset(Reset),
.Enable_i(Busy_o || Start_i), // 12
.Strobe_o(TickMicro)
);

// Timer kontrolujący długość dźwięku
reg [15:0] DurationTimer; // 13
always @(posedge Clock, negedge Reset) begin
if(!Reset)
DurationTimer <= 0;
else if(Start_i) // 14
DurationTimer <= Duration_ms_i;
else if(Busy_o && TickMilli) // 15
DurationTimer <= DurationTimer – 1’b1;
else if(Finish_i) // 16
DurationTimer <= 0;
end

// Sygnał zajętości
assign Busy_o = |DurationTimer; // 17

// Wykrywacz zbocza sygnału zajętości
reg BusyPrevious; // 18
always @(posedge Clock, negedge Reset) begin
if(!Reset)
BusyPrevious <= 0;
else
BusyPrevious <= Busy_o; // 19
end

assign Done_o = !Busy_o && BusyPrevious; // 20

// Generowanie sygnału o zadanej częstotliwości
reg Signal; // 21
reg [15:0] HalfPeriodCopy; // 22
reg [15:0] HalfPeriodTimer; // 23
always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Signal <= 0;
HalfPeriodCopy <= 0;
HalfPeriodTimer <= 0;
end else begin

// Początek pracy
if(Start_i) begin // 24
HalfPeriodTimer <= HalfPeriod_us_i; // 25
HalfPeriodCopy <= HalfPeriod_us_i;
DebugMessage(); // 26

// W trakcie pracy
end else if(HalfPeriodCopy != 16’d0) begin // 27
if(TickMicro && Busy_o) begin
if(HalfPeriodTimer == 16’d0) begin // 28
Signal <= ~Signal;
HalfPeriodTimer <= HalfPeriodCopy;
end else begin // 29
HalfPeriodTimer <= HalfPeriodTimer – 1’b1;
end
end
// Brak generowania dźwięku
end else begin
Signal <= 1’b0;
end
end
end

assign SoundWave_o = Busy_o ? Signal : 1’b0; // 30

// Wyświetlanie informacji debugowych
task DebugMessage(); // 31
begin: message // 32
integer Frequency; // 33

if(HalfPeriod_us_i == 16’d0) // 34
Frequency = 0;
else
Frequency = 1_000_000 / ((HalfPeriod_us_i + 1) * 2);

$display(“%t %d %d %d”, // 35
$realtime,
Duration_ms_i,
HalfPeriod_us_i,
Frequency
);
end
endtask // 36

endmodule
`default_nettype wire

Zastanówmy się, jak w ogóle zabrać się za odtwarzacz pojedynczej nuty. Zacznijmy od określenia portów wejścia i wyjścia modułu. To, co najbardziej oczywiste, to wyjście sygnału dźwiękowego. Zrealizujemy je najprościej, jak to jest możliwe – czyli będzie ono generować sygnał prostokątny o stałym wypełnieniu 50% i zmiennej częstotliwości. Wyjście sygnału dźwiękowego nazwane będzie SoundWave_o (linia 6).

Moduł ma współpracować z modułem nadrzędnym, podającym informacje o nutach do odtworzenia. Musi zatem informować o tym, że jest gotowy do rozpoczęcia pracy lub że pozostaje jeszcze zajęty. Sygnał Busy_o (linia 7) będzie mówił nam o tym, czy moduł aktualnie wykonuje jakąś operację. Stan wysoki oznaczać będzie, że dźwięk właśnie jest w trakcie odtwarzania i wtedy moduł ignoruje wszystkie swoje wejścia (z wyjątkiem Finish_i). Stan niski będzie informować o bezczynności modułu. Przyda się także wyjście Done_o (linia 8), które działać będzie w nieco inny sposób. Dopiero w momencie zakończenia pracy stan tego wyjścia ustawi się w stan wysoki na jeden cykl zegarowy, a przez cały pozostały czas to wyjście będzie w stanie niskim.

Moduł oczywiście wyposażymy w standardowe wejścia Clock i Reset (linia 1), tak jak prawie wszystkie inne moduły, które dotychczas poznaliśmy. Oprócz tego będziemy mieć dwa wejścia Start_i (linia 2) oraz Finish_i (linia 3), które służyć będą do rozpoczynania odtwarzania dźwięku i przerywania odtwarzania w dowolnym momencie. Aby rozpocząć lub zatrzymać pracę modułu, należy jeden z tych sygnałów ustawić w stan wysoki na czas jednego taktu sygnału zegarowego.

Pozostaje już tylko ustalić, w jaki sposób będziemy informować moduł o częstotliwości i długości dźwięku. Do programowania długości dźwięku utworzymy 16-bitowe wejście Duration_ms_i (linia 4), na którym należy podać żądaną długość dźwięku w milisekundach. W ten sposób będziemy ustawiać czas w zakresie od 1 do 65 535 milisekund. Może to trochę za szeroki przedział, ale zakres 8-bitowy byłby za mały. Dałoby się to zagadnienie zoptymalizować, ale moją intencją było zachowanie prostoty kodu.

Z ustawieniem częstotliwości dźwięku jest pewien problem. Musielibyśmy w jakiś sposób przeliczyć częstotliwość na okres sygnału, obliczając odwrotność częstotliwości. W procesorze nie byłoby żadnego problemu, jednak w FPGA obliczenia matematyczne stają się kłopotliwe. To, co w FPGA można robić łatwo, to liczenie taktów zegarowych. Zatem może lepiej będzie obejść problem dookoła i zamiast podawać częstotliwość sygnału dźwiękowego, podawać jego okres? Otóż to! Okres to przecież pewien czas, który jest równy jakiejś liczbie taktów zegarowych. A jeszcze lepiej będzie podawać półokres, bo czas, kiedy sygnał SoundWave_o jest w stanie niskim i wysokim, jest taki sam (sygnał ma wszak współczynnik wypełnienia równy 50%). Zatem sygnał SoundWave_o będzie odwracany po każdym półokresie sygnału dźwiękowego.

Częstotliwość dźwięków słyszanych przez człowieka to zakres od około 16 Hz do 20000 Hz, choć najbardziej istotne dźwięki dla nas mają częstotliwość poniżej 4000 Hz. Okres dźwięku o częstotliwości 20000 Hz to 50 µs, a półokres to 25 µs. Natomiast w przypadku 16 Hz okres to 62500 µs, a półokres wynosi 31250 µs. Zatem wygodnie będzie, jeżeli wejście HalfPeriod_us_i (linia 5) będzie wejściem 16-bitowym, gdzie półokres będzie określony w mikrosekundach. Zero będzie tutaj wartością specjalną – oznaczać będzie pauzę, czyli przerwę w generowaniu dźwięku. Pauza, podobnie jak wszystkie nuty, ma także określony czas trwania.

Następnie musimy zrealizować dwie operacje jednocześnie – odmierzanie długości nuty i generowanie sygnału o zadanej częstotliwości. Skoro długość dana jest w milisekundach, a półokres w mikrosekundach, to potrzebujemy wzorca tych jednostek czasu. W liniach 9 i 11 tworzymy zmienne TickMilli oraz TickMicro. Służą one do rozprowadzania sygnałów strobe, generowanych przez StrobeGeneratorMilli oraz StrobeGeneratorMicro, które utworzone zostały kilka linijek niżej. W ten sposób uzyskujemy sygnały, które na jeden takt zegara przyjmują stan wysoki i dziać się to będzie co jedną milisekundę oraz jedną mikrosekundę.

Wykorzystujemy dwie instancje modułu StrobeGenerator, który znamy z poprzednich odcinków kursu, ale wprowadzimy w nim małe zmiany. W liniach 10 oraz 12 pojawiło się nowe wejście Enable_i, którego nie było w poprzednich odcinkach. Stan wysoki na tym wejściu oznacza zezwolenie na pracę, natomiast stan niski zatrzymuje generowanie strobów i zeruje liczniki w taki sposób, aby po ponownym uruchomieniu pierwszy sygnał strobe pojawił się dokładnie po milisekundzie lub mikrosekundzie. Oba moduły StrobeGenerator będą pracować, kiedy w stan wysoki jest ustawiony sygnał Start_i (ten jest ustawiany na jeden takt zegara) lub Busy_o (który jest w stanie wysokim tak długo, jak długo moduł odtwarza dźwięk).

Przeskoczmy teraz do listingu 2, gdzie zaprezentowano zmodyfikowany StrobeGenerator. Omawialiśmy go szczegółowo w 9. odcinku kursu, więc podam tylko zmiany, jakie zostały wprowadzone. W linii 1 pojawiło się wejście Enable_i. Stan tego wejścia jest sprawdzany w linii 2. Jeżeli jest on w stanie wysokim, to moduł działa (przypominam, że licznik Counter liczy w dół od wartości początkowej do zera), natomiast jeżeli jest w stanie niskim, to Counter zostaje załadowany wartością początkową, określoną poprzez parametr DELAY.

Listing 2. Zmodyfikowany kod modułu StrobeGenerator

// Plik strobe_generator.v
`default_nettype none
module StrobeGenerator #(
parameter CLOCK_HZ = 10_000_000,
parameter PERIOD_US = 100
)(
input wire Clock,
input wire Reset,
input wire Enable_i, // 1
output reg Strobe_o
);

localparam DELAY = (CLOCK_HZ / 1_000_000) * PERIOD_US – 1;
localparam WIDTH = $clog2(DELAY + 1);

initial begin
if(DELAY <= 0)
$fatal(0, “Wrong DELAY value: %d”, DELAY);
end

reg [WIDTH-1:0] Counter;

always @(posedge Clock, negedge Reset) begin
if(!Reset) begin
Counter <= DELAY;
Strobe_o <= 1’b0;
end else if(Enable_i) begin // 2
if(!Counter) begin
Counter <= DELAY;
Strobe_o <= 1’b1;
end else begin
Counter <= Counter – 1’b1;
Strobe_o <= 1’b0;
end
end else begin // 3
Counter <= DELAY;
end
end

endmodule
`default_nettype wire

Wracamy do listingu 1. Zobaczmy, jak kontrolowany jest czas odtwarzania dźwięku.

W linii 13 tworzymy 16-bitowy DurationTimer, który odpowiedzialny będzie za odmierzanie długości nuty. W bloku always, który tworzymy poniżej, możliwe są cztery działania, w zależności od tego, który warunek zostanie spełniony. Pierwszy, jak zwykle, to asynchroniczne zerowanie licznika, jeżeli sygnał Reset jest w stanie niskim.

Następnie, jeżeli sygnał Start_i jest w stanie wysokim, przepisujemy stan wejścia Duration_ms_i do licznika DurationTimer (linia 14). Dalej, jeżeli jednocześnie sygnały Busy_o oraz TickMilli są w stanie wysokim, to stan licznika zmniejsza się o jeden (linia 15). W taki sposób będziemy dekrementować licznik milisekund, zaczynając od wartości początkowej do zera. Alternatywnie, licznik może zostać wyzerowany, jeżeli wejście Finish_i zostanie ustawione w stan wysoki (linia 16) i wtedy moduł przestanie odtwarzać dźwięk.

Sygnał Busy_o, który już parę razy pojawił się w tekście, zależy od licznika DurationTimer (linia 17). Jeżeli jego wartość jest większa od zera, stan Busy_o jest wysoki. Można to sprawdzić na dwa sposoby. Pierwszy to zastosowanie operatora redukcji OR, którego symbol to |. Wystąpienie jedynki na dowolnym bicie zmiennej oznacza, że ta zmienna jest większa od zera (dotyczy zmiennej całkowitej bez znaku!). Drugi to operator nierówności DurationTimer != 16’d0. Co ciekawe, te sposoby nieznacznie różnią się pod względem zapotrzebowania na zasoby w FPGA.

Aby uzyskać sygnał Done_o, który ustawiany jest na jeden takt zegara po tym, jak moduł skończy odtwarzać dźwięk, musimy utworzyć prosty detektor zbocza. W tym celu tworzymy 1-bitową zmienną BusyPrevious (linia 18.). Następnie, w bloku always poniżej, w każdym cyklu zegarowym przepisujemy do tej zmiennej aktualny stan Busy_o (w każdym takcie zegara stan tego sygnału może się zmienić, więc przepisujemy jego stan, jaki panował przed wystąpieniem zbocza zegara, a nie po!).

Finalnie, sygnał Done_o uzyskujemy w linii 20 – wykorzystując instrukcję assign sprawdzamy, czy aktualny stan Busy_o jest niski i jednocześnie czy BusyPrevious jest wysoki. W takiej sytuacji Done_o zostanie ustawione w stan wysoki na jeden okres sygnału zegarowego.

Zobaczmy teraz, w jaki sposób generowany jest sygnał o zadanej częstotliwości.

Najpierw musimy utworzyć kilka zmiennych. Służyć będą do przechowywania jakichś danych, więc zostaną zadeklarowane jako typ reg. W linii 21 tworzymy 1-bitową zmienną Signal (jest to pojedynczy przerzutnik D), która ma generować sygnał o żądanej częstotliwości, doprowadzony do głośnika poprzez wyjście SoundWave_o.

W liniach 22 i 23 tworzymy 16-bitowe rejestry HalfPeriodTimer oraz HalfPeriodCopy. Jak nietrudno się domyślić, HalfPeriodTimer będzie funkcjonował jako licznik liczący w dół od wartości początkowej do zera, a HalfPeriodCopy będzie przechowywał wartość początkową. W momencie wyzerowania licznika HalfPeriodTimer zostanie zanegowany stan przerzutnika Signal.

Przejdźmy do linii 24. W momencie, kiedy stan wejścia Start_i jest wysoki (a powinien być taki tylko przez jeden takt zegara), kopiujemy dane z wejścia HalfPeriod_us_i do rejestrów HalfPeriodTimer oraz HalfPeriodCopy. Można by zapytać – dlaczego potrzebna jest kopia? Czy nie prościej byłoby ładować timer danymi prosto z wejścia modułu? Owszem, byłoby prościej, ale to wymuszałoby na module nadrzędnym, aby nie zmieniał danych na wejściu przez cały czas odtwarzania dźwięku. Takie wymaganie może być problematyczne do realizacji i może prowadzić do trudno wykrywalnych błędów. Ponadto, w FPGA często przygotowujemy nowe dane dla modułu, w czasie kiedy on jeszcze wykonuje poprzednio zadaną operację. W tym przypadku byłoby to odczytanie z pamięci kolejnej nuty w czasie, kiedy poprzednia jeszcze jest odtwarzana. Z tego powodu właśnie musimy skopiować „polecenie” w momencie, kiedy sygnał Start_i jest w stanie wysokim. Przypominam, że wejście Start_i informuje moduł, że na wejściach znajdują się jakieś ważne dane i przy najbliższym zboczu zegara moduł ma te dane odczytać i coś z nimi zrobić. Kiedy Start_i jest w stanie niskim, to dane na wejściach Duration_ms_i oraz HalfPeriod_us_i mogą się dowolnie zmieniać bez żadnego wpływu na pracę modułu.

W linii 26. znajduje się coś nowego. Mamy tam instrukcję DebugMessage(), która wygląda niczym funkcja. Jednak to nie jest funkcja, lecz task. Stwierdziłem, że mamy tutaj doskonałą okazję na zademonstrowanie nowych, nieomawianych dotychczas funkcjonalności języka Verilog. Taski i funkcje to temat dość obszerny, lecz dzisiaj wykorzystamy prosty task do „drukowania” na konsoli informacji debugowych.

Task rozpoczynamy instrukcją task i kończymy poprzez endtask. Przypomina to trochę module oraz endmodule, tym bardziej że task może mieć wejścia i wyjścia tak samo jak moduł. Jednak w konstrukcji tasków jest pewne dziwadło – jeżeli wewnątrz tasku znajduje się więcej niż jedna instrukcja, to trzeba cały task objąć blokiem begin-end. A żeby było jeszcze dziwniej, jeżeli wewnątrz tego bloku chcemy utworzyć jakąś zmienną, blok begin-end musi zostać jakoś nazwany (obojętnie jak). Przykład takiego rozwiązania widzimy w linii 32.

Celem naszego tasku jest wyświetlenie danych wejściowych, odbieranych przez moduł, kiedy Start_i jest w stanie wysokim. Dodatkowo chcemy, żeby task przeliczył półokres na częstotliwość, co będzie dla nas bardziej zrozumiałe. W tym celu tworzymy zmienną Frequency typu integer (uwaga na przyzwyczajenia z C – w Verilogu zmienna całkowita to integer, a nie int). W linii 34 przeliczamy półokres na częstotliwość, a linijkę niżej wyświetlamy wszystkie te parametry na konsoli za pomocą instrukcji $display.

Przejdźmy do linii 27. W tym miejscu działanie algorytmu zmienia się w zależności od tego, czy mamy odtwarzać jakiś dźwięk, czy ma wystąpić pauza. Pauzę od nuty odróżniamy po tym, że czas półokresu pauzy to zero. W związku z tym sprawdzamy, czy kopia półokresu jest różna od zera.

Jeżeli kopia półokresu jest różna od zera, to wtedy czekamy, aż pojawi się stan wysoki na TickMicro, kiedy jednocześnie moduł pracuje (czyli Busy_o jest w stanie wysokim) i wtedy zmniejszamy HalfPeriodTimer o jeden (linia 29), a jeżeli HalfPeriodTimer już jest równy zero, to wtedy ładujemy go ponownie kopią półokresu HalfPeriodCopy i odwracamy stan przerzutnika Signal (linia 28).

Jeżeli natomiast kopia półokresu jest równa zero (czyli mamy pauzę), to w takim przypadku do Signal wpisujemy na stałe zero. Dlaczego nie możemy po prostu zostawić tej zmiennej w takim stanie, w jakim była wcześniej, nawet jeżeli to byłby stan wysoki? Przecież ustawienie stanu wysokiego na stałe nie będzie powodowało żadnego dźwięku. Owszem, jest to prawda, ale rzućmy okiem jeszcze raz na schemat z rysunku 2. Stan wysoki spowoduje otwarcie tranzystora i przepływ prądu przez cewkę głośnika. Jest to sytuacja niepożądana i dlatego ustawiamy ten sygnał w stan niski, aby zamknąć tranzystor, kiedy żaden dźwięk nie jest odtwarzany.

Z tego samego powodu wyjście SoundWave_o jest sterowane za pomocą operatora warunkowego (linia 30). Jeżeli moduł pracuje, czyli Busy_o jest w stanie wysokim, to SoundWave_o połączone jest przerzutnikiem Signal, a jeżeli nie, to na wyjściu jest na stałe stan niski.

Symulacja

Zanim utworzymy moduł top i wgramy bitstream do FPGA, najpierw przetestujmy nasz moduł w symulatorze, aby mieć pewność, że działa tak, jak tego oczekujemy. Na listingu 3 pokazano kod testbencha, który można uruchomić w symulatorze Icarus Verilog (został on omówiony w 12. odcinku kursu) za pomocą skryptu z listingu 4. Po wykonaniu symulacji powinniśmy zobaczyć na konsoli takie komunikaty, jakie są na listingu 5.

Listing 3. Kod pliku sound_generator_tb.v

// Plik sound_generator_tb.v

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

parameter CLOCK_HZ = 2_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 testowe
reg Reset = 1’b0;
reg Start = 1’b0;
reg Finish = 1’b0;
reg [15:0] Duration_ms = 16’d0;
reg [15:0] HalfPeriod_us = 16’d0;
wire Done;
wire Busy;
wire SoundWave;

// Instancja testowanego modułu
SoundGenerator #(
.CLOCK_HZ(CLOCK_HZ)
) DUT(
.Clock(Clock),
.Reset(Reset),
.Start_i(Start),
.Finish_i(Finish),
.Duration_ms_i(Duration_ms),
.HalfPeriod_us_i(HalfPeriod_us),
.SoundWave_o(SoundWave),
.Busy_o(Busy),
.Done_o(Done)
);

// Eksport zmiennych do pliku VCD
initial begin
$dumpfile(“sound_generator.vcd”);
$dumpvars(0, SoundGenerator_tb);
end

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

#1 Reset <= 1’b1;

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

// 1ms, 50kHz
Duration_ms <= 16’d1; // 2
HalfPeriod_us <= 16’d9; // 3
Start <= 1’b1; // 4
@(posedge Clock); // 5
Duration_ms <= 16’dX; // 6
HalfPeriod_us <= 16’dX; // 7
Start <= 1’b0; // 8
@(posedge Done); // 9

// 2ms, pauza
Duration_ms <= 16’d2;
HalfPeriod_us <= 16’d0;
Start <= 1’b1;
@(posedge Clock);
Duration_ms <= 16’dX;
HalfPeriod_us <= 16’dX;
Start <= 1’b0;
@(posedge Done);

// 3ms, 10kHz
Duration_ms <= 16’d3;
HalfPeriod_us <= 16’d49;
Start <= 1’b1;
@(posedge Clock);
Duration_ms <= 16’dX;
HalfPeriod_us <= 16’dX;
Start <= 1’b0;
@(posedge Done);

// 10ms, 100Hz
Duration_ms <= 16’d10;
HalfPeriod_us <= 16’d499;
Start <= 1’b1;
@(posedge Clock);
Duration_ms <= 16’dX;
HalfPeriod_us <= 16’dX;
Start <= 1’b0;
repeat(50000) @(posedge Clock); // 10

// Zakończenie pracy
Finish <= 1’b1; // 11
@(posedge Clock);
Finish <= 1’b0;
@(posedge Clock);
repeat(10) @(posedge Clock);

#1 $display(“====== END ======”);
#1 $finish;
end

endmodule
`default_nettype wire
Listing 4. Kod skryptu sound_generator.bat

@echo off
iverilog -o sound_generator.o sound_generator.v sound_generator_tb.v strobe_generator.v
vvp sound_generator.o
del sound_generator.o
Listing 5. Log z konsoli po wykonaniu symulacji

VCD info: dumpfile sound_generator.vcd opened for output.
===== START =====
Time Durat HaPer Freq
5.500us 1 9 50000
1006.000us 2 0 0
3006.000us 3 49 10000
6009.000us 10 499 1000
====== END ======
sound_generator_tb.v:115: $finish called at 12509502 (1ns)
Listing. 6. Kod pliku top.v

// Plik top.v

`default_nettype none
module top(
input wire Reset, // Przycisk K0
input wire Play_i, // Przycisk enkodera E41
input wire Stop_i, // Przycisk enkodera E42
output wire SoundWave_o, // Głośniczek
output wire Busy_o // Dioda LED D0
);

// 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()
);

// Instancja generatora dźwięków
SoundGenerator #(
.CLOCK_HZ(CLOCK_HZ)
) SoundGenerator_inst(
.Clock(Clock),
.Reset(Reset),
.Start_i(!Play_i), // 1
.Finish_i(!Stop_i), // 2
.Duration_ms_i(16’d1000), // 3
.HalfPeriod_us_i(16’d100), // 4
.SoundWave_o(SoundWave_o), // 5
.Busy_o(Busy_o), // 6
.Done_o()
);

endmodule
`default_nettype wire

Konstrukcja modułu testbench jest bardzo typowa i podobna do tego, co widzieliśmy już w poprzednich odcinkach kursu. Na początku tworzymy generator sygnału zegarowego o częstotliwości określonej parametrem CLOCK_HZ (równej 2 MHz), aby łatwiej można było oglądać przebiegi sygnałów w przeglądarce. Następnie tworzymy kilka zmiennych oraz instancję testowanego modułu. Nie ma tu nic nowego, czego nie widzielibyśmy w poprzednich odcinkach, więc przejdźmy od razu do omawiania sekwencji testowej.

Podczas symulacji będziemy modyfikować zaledwie trzy zmienne – za pomocą 16-bitowych zmiennych Duration_ms oraz HalfPeriod_us będziemy konfigurować parametry dźwięku, a 1-bitową zmienną Start będziemy uruchamiać moduł. W ten sposób uzyskamy przebiegi takie, jak pokazano na rysunku 3.

Rysunek 3. Zbliżenie na uruchomienie pierwszej nuty

W linii 1 czekamy 10 cykli zegarowych. Ma to na celu „narysowanie” odstępu na wykresie, aby trochę zwiększyć jego czytelność. Następnie, odtworzymy dźwięk o częstotliwości 50 kHz i trwający 1 milisekundę. W tym celu w linii 2 do zmiennej Duration_ms wpisujemy 1.

Dźwięk o częstotliwości 50 kHz ma okres 20 µs, zatem jego półokres to 10 µs. Moduł wymaga podania półokresu pomniejszonego o 1 i dlatego w linii 3 do zmiennej HalfPeriod_us wpisujemy wartość 9. Ostatnią czynnością jest ustawienie wejścia Start_i w stan wysoki (linia 4), aby poinformować moduł SoundGenerator, że na swoich wejściach ma istotne dane i powinien rozpocząć pracę.

Pozostaje tylko poczekać na rosnące zbocze sygnału zegarowego, które spowoduje, że moduł odczyta dane i rozpocznie funkcjonowanie (linia 5).

Sygnał Start_i powinien być w stanie wysokim tylko przez jeden cykl zegarowy. Dlatego w linii 8 zerujemy zmienną Start. Zmienne przechowujące czas i półokres są nieistotne, kiedy Start_i jest w stanie niskim. Moglibyśmy pozostawić je bez zmian, jednak aby podkreślić ich nieistotność, w liniach 6 i 7 wpisujemy do nich stan X – czyli niezdefiniowany.

Zobacz teraz zrzut ekranu z symulatora, pokazany na rysunku 4. Jest to zbliżenie na pierwsze chwile symulacji, gdzie dokładnie widać skutki działania instrukcji, opisanych w powyższych akapitach. Sygnał Start specjalnie zaznaczono kolorem żółtym, a stany nieistotne X sygnalizuje kolor czerwony.

Rysunek 4. Zbliżenie na koniec pierwszej nuty i początek pauzy

Moduł zaczyna pracować, co można stwierdzić po zmianie sygnału Busy_o z 0 na 1. Ponadto widzimy, że licznik HalfPeriodTimer zaczął cyklicznie odliczać od 9 do 0, a na wyjściu Sound_o pojawił się sygnał o żądanej częstotliwości.

Czekamy na koniec pracy. Moduł zasygnalizuje to, ustawiając wyjście Done_o w stan wysoki na jeden takt zegarowy. Instrukcja czekania na zbocze rosnące tego sygnału znajduje się w linii 9. Moment zakończenia pierwszej nuty oraz załadowania kolejnej (pauzy o długości 2 ms i półokresie 0) pokazano na rysunku 5.

Rysunek 5. Przerwanie odtwarzania dźwięku sygnałem Finish

W ten sposób testujemy kilka dźwięków. Po zakończeniu odtwarzania dźwięku 50 kHz o długości 1 ms następuje pauza o długości 2 ms, dalej 10 kHz przez 3 ms, a później 100 Hz przez 10 ms. Jednak ostatni dźwięk przerwiemy przed upływem zadanego czasu. Z tego powodu w linii 10 nie oczekujemy na sygnał Done, tylko czekamy 50000 taktów zegarowych. Tę liczbę dobrałem doświadczalnie, aby wykres na rysunku 3 wyglądał optymalnie.

Przerwanie odtwarzania następuje na skutek ustawienia wejścia Finish_i w stan wysoki (linia 11). Czekamy na kolejne zbocze zegara i ustawiamy to wejście z powrotem w stan niski. Tę sytuację obrazuje rysunek 6.

Rysunek 6. Konfiguracja pinów w Spreadsheet

Przetestowaliśmy moduł generujący dźwięki – czas najwyższy, aby sporządzić moduł top i przetestować nasz projekt na żywo w FPGA.

Moduł Top

Moduł top będzie bardzo prosty. Jego kod pokazano na listingu 6. Zacznijmy od omówienia wejść i wyjść:

  • Reset – sygnał resetujący, podłączony do przycisku K0 na płytce MachXO2 Mega, aktywny w stanie niskim.
  • Play_i – sygnał rozpoczynający odtwarzanie dźwięku, podłączony do przycisku w enkoderze E41 na płytce User Interface Board, aktywny w stanie niskim.
  • Stop_i – sygnał przerywający odtwarzanie dźwięku, podłączony do przycisku w enkoderze E41 na płytce User Interface Board, aktywny w stanie niskim.
  • SoundWave_o – wyjście sygnału dźwiękowego do głośnika.
  • Busy_o – sygnał informujący o pracy modułu, podłączony do diody LED D0 na płytce MachXO2 Mega.

Kod modułu top jest bardzo prosty i składa się zaledwie z dwóch składników. Pierwszy to instancja generatora sygnału zegarowego, pracującego z częstotliwością 14 MHz. Ten kod widzieliśmy już wielokrotnie w poprzednich odcinkach, więc nie będziemy go omawiać kolejny raz.

Następnie mamy instancję modułu SoundGenerator.

Łączymy ze sobą wejścia Play_i z Start_i (linia 1) oraz Stop_i z Finish_i (linia 2). Celowo użyłem innych słów, by odróżnić wejścia modułu top oraz SoundGenerator. Zwróć uwagę, że sygnały Play_i oraz Stop_i są zanegowane za pomocą operatora !. Normalnie te sygnały są w stanie wysokim na skutek rezystorów podciągających obecnych na płytce, a naciśnięcie przycisków powoduje zwarcie linii sygnałowych do masy.

W linii 3 i 4 ustawiamy „na sztywno” czas trwania dźwięku i jego półokres. Polecam poeksperymentować z różnymi wartościami.

Pozostaje już tylko wyprowadzić sygnał dźwiękowy SoundWave_o (linia 5), a wyjście Busy_o będzie służyło do zaświecania diody LED, kiedy dźwięk jest odtwarzany (linia 6).

Po przeprowadzeniu syntezy, otwórz narzędzie Spreadsheet i skonfiguruj piny tak, jak pokazano na rysunku 6. Zwróć uwagę, że przyciski mają włączoną dużą histerezę – w kolumnie HYSTERESIS jest ustawiona wartość LARGE. Przyciski enkoderów na płytce User Interface Board mają filtry RC w celu odszumiania drgań styków mechanicznych. Sygnał doprowadzony do pinów FPGA jest wolnozmienny, a dodanie histerezy pozwala odczytywać stan przycisku bez usuwania tych drgań innymi metodami.

Czegoś jednak brakuje… Czy sygnały przycisków są zsynchronizowane z zegarem? Nie są. Zatem możliwe jest, że wystąpi tu problem metastabilności. Poruszaliśmy ten problem w odcinku 11, wtedy także omówiliśmy, jak go rozwiązać. Jednak tym razem przymkniemy na to oko, aby kod był prostszy.
Wygeneruj bitstream i wgraj go do FPGA. Po naciśnięciu przycisku E41 usłyszysz dźwięk o stałej częstotliwości i długości jednej sekundy. Przycisk E42 przerywa odtwarzanie dźwięku przed upływem sekundy.

W następnym odcinku utworzymy pamięć, w której będą zapisane nuty różnych melodii. Opracujemy moduł, który będzie odczytywać kolejne nuty z pamięci i przekazywać je do modułu odtwarzającego poszczególne dźwięki.

Dominik Bieczyński
leonow32@gmail.com

Zobacz więcej:
1. Repozytorium modułów wykorzystywanych w kursie https://github.com/leonow32/verilog-fpga

Artykuł ukazał się w
Elektronika Praktyczna
luty 2024
DO POBRANIA
Materiały dodatkowe
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik listopad 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio listopad - grudzień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje listopad - grudzień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna listopad 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich grudzień 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów