Nagrywamy po raz drugi
Mamy już przygotowane i przetestowane nowe bloki, więc możemy je teraz połączyć razem. Schemat naszego drugiego projektu został zaprezentowany na rysunku 17. Jest on bardzo podobny do pierwszej wersji z rysunku 4. Po prostu pojawiły się dwa dodatkowe moduły, przez które przepływają nasze dane.
Musimy jeszcze ustalić, ile bitów danych z wejścia ADC potrzebujemy. Jak pamiętamy z wykresu przedstawionego na rysunku 8, gdy przekazywaliśmy osiem najstarszych bitów pomiaru, użyteczny sygnał mieścił się, mniej więcej (z pewnym marginesem bezpieczeństwa), w zakresie 80…120. Dla przypomnienia zostało to pokazane na pierwszym słupku z rysunku 18.
Ponieważ przetwornik zwraca nam 12 bitów danych, oznacza to, że nasz sygnał znajduje się pomiędzy wartościami 1280…1920. Pokazuje to środkowy słupek. Potrafimy już usunąć składową stałą, która wynosi około 1600. Po odjęciu jej okaże się, że użyteczne dane mieszczą się w zakresie około –320…320. Oznacza to, że można bez problemu zmieścić go w 10-bitowej liczbie ze znakiem. Ponieważ, tak jak poprzednio, chcemy wysyłać po 8 bitów danych na jeden pomiar, możemy od razu na blok dc_r podać jedynie 10 najstarszych bitów, co będzie odpowiadało podzieleniu przez cztery.
18 parameter N_ADC = 10;
19 parameter N = 8;
26 StreamBus #(.N(N_ADC)) bus_adc(clk, rst);
27 StreamBus #(.N(N_ADC)) bus_dc(clk, rst);
28 StreamBus #(.N(N)) bus_uart(clk, rst);
55 assign bus_adc.data = adc_data_out[11-:N_ADC];
65 dc_r #(.N(N_ADC)) dc_remove (
66 .in(bus_adc),
67 .out(bus_dc));
68
69 saturation #(.N_IN(N_ADC), .N_OUT(N)) sat (
70 .in(bus_dc),
71 .out(bus_uart));
72
73 uart_tx #(
74 .F(F),
75 .BAUD(115200)
76 ) uart (
77 .bus(bus_uart),
78 .tx(tx));
Przejdźmy teraz do kodu, którego fragmenty znajdziemy na listingu 9. Najpierw definiujemy pomocniczo dwie stałe N_ADC i N, które będą określać szerokości interfejsów. Zostały one zdefiniowane w liniach 26…28. Do pierwszej magistrali bus_adc podłączamy wyjście z przetwornika ADC. Niżej znajdują się instancje modułów składowych.
Tym razem zmodyfikujemy sposób symulacji przetwornika ADC. Skorzystamy z możliwości dodania własnego pliku z listą kolejnych wartości napięcia. Najpierw jednak przygotujemy nowy sygnał testowy, który przyda nam się także w kolejnych projektach. Zamiast mowy wygenerujemy sygnał chirp. Po polsku czasami nazywa się go świergot albo ćwierkanie. Jest to sygnał sinusoidalny, ale z liniową zmianą częstości pomiędzy początkową wartością ω0, a końcową ω1. Oznacza to, że w momencie t chwilowa częstość jest równa:
Wyjściowy sygnał otrzymujemy po obliczeniu funkcji sinus dla aktualnej częstości:
Na rysunku 19 pokazany jest przykładowy przebieg częstości, zmieniającej się od 1 do 2 radianów na sekundę.
Do wygenerowania pliku dźwiękowego został wykorzystany program Audacity [5]. Jego główne okno pokazuje rysunek 20. Na pasku zadań wybieramy opcję Generuj. Z rozwiniętego menu wybieramy opcję Świergot.
Pojawi się nowe okno (rysunek 21), gdzie możemy skonfigurować parametry naszego sygnału. Jako częstotliwość początkową wybieramy 0, a końcową 5000 Hz. Amplitudę (zarówno początkową, jak i końcową) zmieniamy na 1. Klikamy OK. Teraz naciskając zielony przycisk Play, możemy odegrać sygnał. Uwaga, jest to dość głośny i nieprzyjemny odgłos.
W repozytorium znajduje się także plik mp3 11_dc_r\parse\chirp_0_5.mp3, zawierający stworzony przebieg.
Do zarejestrowania danych ponownie wykorzystujemy projekt 10_record/10_rec.qpf. Tym razem zamiast zapisać wynik z powrotem do pliku dźwiękowego, stworzymy plik tekstowy, w którym w każdej linii znajduje się numer próbki, a następnie liczba zmiennoprzecinkowa, odpowiadająca napięciu. Przykładową zawartość pokazuje listing 10.
01 39877 1.2761718034744263
02 39878 1.2761718034744263
03 39879 1.2761718034744263
04 39880 1.2890625
05 39881 1.2890625
06 39882 1.2890625
07 39883 1.2890625
08 39884 1.2890625
09 39885 1.3019530773162842
10 39886 1.3019530773162842
Do wygenerowania pliku tekstowego użyjemy kolejnego skryptu. Jego fragment znajdziemy na listingu 11. Najpierw odczytane z portu szeregowego wartości zmieniamy na wolty, mnożąc przez wartość napięcia odniesienia 3,3 V, a następnie dzieląc przez odpowiadającą jej wartość 256.
01 Vmax = 3.3
02 ADC_max = 256
03 v = np.float32(xn)*Vmax/ADC_max
04 plt.plot(v)
05
06 file="adc_sample.txt"
07 vp = v[20000:30000]
08 with open(file, ‘w’) as f:
09 for i in range(len(vp)):
10 for j in range(5):
11 f.write("{} {}\n".format(5*i+j, vp[i]))
Dalej w pętli tworzymy nasz plik do symulacji. Ponieważ przesyłamy tylko co piątą próbkę, musimy każdy pomiar zapisać w pliku pięciokrotnie. Aby symulacja nie trwała zbyt długo, wybieramy tylko fragment zebranych danych. Uzyskane wartości napięcia możemy także wyrysować w formie wykresu, który został pokazany na rysunku 22.
Teraz, generując blok ADC dla nowego projektu, możemy w zakładce Logic Simulation (symulacja logiki) wybrać opcję Enable (włączone), a następnie, przy konfiguracji kanału 1 (CH1), dodać ścieżkę do wygenerowanego przez nas pliku. Kiedy przyjrzymy się wygenerowanym modułom, zobaczymy, że pojawiła się tam odpowiednia konfiguracja, co zostało pokazane na listingu 12.
26 altera_modular_adc_control #(
39 .simfilename_ch0 (""),
40 .simfilename_ch1 ("C:/riscv/fpga-experiments/11_dc_r/parse/adc_sample.txt"),
41 .simfilename_ch2 (""),
Pozostaje jeszcze przygotowanie testbenchu. Jest on bardzo prosty, ponieważ tym razem generujemy jedynie sygnał zegarowy i reset. Dane będą odczytywane z pliku i dostarczane przez blok ADC. Tym razem, pomiędzy kolejnymi próbkami, będziemy mieli długą przerwę ze stanem nieustalonym. Dlatego, aby wyrysować czytelne wykresy, stworzymy dodatkowe sygnały, gdzie będziemy zatrzaskiwać jedynie poprawne dane.
Odpowiadający za to kod przedstawiony jest na listingu 13.
33 always_ff @(posedge clk) begin
34 if (dut.adc_valid_out)
35 adc_data_out <= dut.adc_data_out;
36 if (dut.bus_dc.valid)
37 data_dc <= dut.bus_dc.data;
38 if (dut.bus_uart.valid)
39 data_out <= dut.bus_uart.data;
40 end
Symulację uruchamiamy komendą:
do ./rec_sim.do
Ponieważ symulujemy czas kilku sekund, jej wykonanie może zająć nawet kilkanaście minut. Fragment uzyskanego wyniku pokazuje rysunek 24.
Na końcu otwórzmy projekt 11_dc_r/11_rec.qpf w środowisku Quartus i rozpocznijmy jego budowę. Kiedy się zakończy, możemy wgrać bitstream i zarejestrować kolejny fragment nagrania. Do jego konwersji na format dźwiękowy możemy wykorzystać skrypt 11_dc_r/parse/Parse.ipynb.
Od pokazanego na listingu 2 różni się traktowaniem odebranych danych jako liczby ze znakiem:
xn = np.int8(x)
Wykres z zarejestrowanymi danymi przedstawia rysunek 25. Widzimy, że gdy wartości przechodzą poza dopuszczalne wartości 127, albo –128, następuje nasycenie.
Podsumowanie
W tym odcinku rozpoczęliśmy eksperymenty z przetwarzaniem dźwięku w układzie FPGA. W następnej części zaimplementujemy transformatę Fouriera i będziemy prezentować aktualne spektrum odbieranego sygnału na diodach LED.
Rafał Kozik
rafkozik@gmail.com
[1] Intel MAX 10 Analog to Digital Converter User Guide, 2020-08-15, https://intel.ly/3iIQ6Q2
[2] anaconda.com, 2020-08-15, https://bit.ly/3iN89oi
[3] Film pokazujący, jak skonwertować nagranie https://bit.ly/3iMh5u4
[4] Lyons r.G., Wprowadzenie do cyfrowego przetwarzania sygnałów, Wydawnictwo Komunikacji i Łączności, Warszawa 2010
[5] Audacity, 2020-08-15, https://bit.ly/3h5vjWz