Wyślij zapytanie Dołącz do Sii

Obecnie słuchawki bezprzewodowe są bardziej popularne niż jeszcze kilka lat temu ze względu na to, że technologia w nich używana oraz długość działania na jednym ładowaniu zostały znacznie poprawione. Jednym z podstawowych elementów technologii w nich zastosowanych jest aktywna redukcja hałasu (ANC, ang. Active Noise Cancellation).

ANC to technologia, która zmniejsza dźwięki otoczenia poprzez generowanie dźwięków o przeciwnej fazie, co skutecznie je niweluje. System ANC wykorzystuje mikrofony do rejestrowania hałasu otoczenia, a następnie przetwarza je, aby stworzyć anty-hałas odtwarzany przez głośniki. Tę technologię stosuje się powszechnie w słuchawkach, samochodach i innych urządzeniach, zapewniając użytkownikom cichsze i bardziej komfortowe warunki do słuchania ulubionej muzyki.

ANC najlepiej działa na dźwięki o niskiej częstotliwości, które są wolno-zmienne, jest za to mniej skuteczna wobec nieprzewidywalnych, szybko-zmiennych hałasów.

Schemat blokowy nowoczesnych słuchawek bezprzewodowych
Ryc. 1 Schemat blokowy nowoczesnych słuchawek bezprzewodowych

W tym artykule przybliżę wam podstawy tworzenia aplikacji audio, omawiając mikrokontrolery dedykowane do tego typu zastosowań oraz metody optymalizacji kodu, zwłaszcza algorytmów DSP. Pokażę, jak krok po kroku zbudować swój pierwszy system audio oraz na co warto zwrócić uwagę podczas jego projektowania. Na zakończenie przedstawię i omówię osiągnięte wyniki.

Jaki powinien być fundament systemu audio?

Rozwój oprogramowania audio dla przenośnych systemów embedded zasilanych z baterii niesie ze sobą dodatkowe wyzwania związane z optymalizacją wydajności. Systemy embedded, stosowane m.in.: w słuchawkach, cechują się ograniczonymi zasobami sprzętowymi, co wymaga tworzenia oprogramowania o minimalnym zużyciu procesora i pamięci, przy jednoczesnym utrzymaniu wysokiej jakości dźwięku. Kluczowe znaczenie ma również zarządzanie latencją, ponieważ każde opóźnienie jest niepożądane, zwłaszcza w aplikacjach czasu rzeczywistego.

Aby sprostać tym wyzwaniom, możemy wykorzystać m.in.: następujące mikrokontrolery.

  1. Procesor DSP – pierwszą opcją jest wybór dedykowanego procesora DSP. Przykładem może być linia SHARC od firmy Analog Devices. Procesory te są taktowane zegarem do 400 MHz oraz oferują FPU obsługujący liczby zmiennoprzecinkowe w formacie 32 oraz 40 bitowym. Dodatkowo wspiera on operacje SIMD i posiada 5 Mb pamięci RAM L1 on-chip oraz 8 Mb RAM L2 ­– dzięki temu będziemy mieć szybki dostęp do danych naszych algorytmów, co bezpośrednio przełoży się na wydajność systemu.
  2. Mikrokontroler posiadający SIMD i FPU – drugą opcją, która jest tańsza i prostsza, jest konwencjonalny mikrokontroler, który wspiera operacje SIMD oraz posiada FPU. Może być to ESP32-S3 lub mikrokontroler wyposażony w rdzeń ARM-Cortex-M4 lub wyższy. Dzięki temu możemy użyć tylko jednego kontrolera i „przyspieszyć” nasze algorytmy, używając operacji wektorowych.
  3. Mikrokontroler z zewnętrznym modułem DSP ­– trzecią opcją jest wykorzystanie mikrokontrolera opartego np. na rdzeniu ARM z dodatkowym koprocesorem DSP. W tym przypadku moduł DSP będzie pre-programowany do wykonywania konkretnych algorytmów (np. filtrowania), a sam mikrokontroler będzie odpowiedzialny tylko za obsługę stworzonej aplikacji.

Zbudujmy swój pierwszy pipeline audio

W celu stworzenia podstawowej aplikacji audio użyję popularnego mikrokontrolera ESP32-S3. Posiada on bardzo dobry SDK, który pozwala na szybkie prototypowanie aplikacji. Dodatkowo jest szeroko dostępnym i tanim mikrokontrolerem z dużym wsparciem społeczności. Dzięki temu możemy skupić się głównie nad rozwiązaniami, które można zastosować w aplikacji.

Kolejnym krokiem w tworzeniu naszego systemu jest wybór rodzaju aplikacji, którą chcemy zaimplementować na początek. Możemy wyróżnić kilka typów przetwarzania audio:

  1. Generowanie sygnału – system otrzymuje dane z zewnętrznego źródła i przekształca je na dźwięk. Przykładem jest klawiatura MIDI, która przekształca naciśnięcia klawiszy w odpowiednie dźwięki.
  2. Analizowanie sygnału – system przyjmuje sygnał audio i analizuje go, np. za pomocą FFT. Przykładem może być analizator widma sygnału.
  3. Przetwarzanie sygnału – system pobiera sygnał audio, przetwarza go za pomocą algorytmów DSP, a następnie przesyła przetworzony sygnał na zewnątrz aplikacji, np. do głośników. Przykładem może być system ANC lub efekty gitarowe.

W tym artykule będziemy budować aplikację, która będzie przetwarzać sygnał audio. Jak w takim razie nasz system będzie wiedział, co przetwarzać? Możemy użyć przetwornika ADC, cyfrowego mikrofonu, modułu SAI, karty dźwiękowej USB… – odpowiedzi na to pytanie jest wiele.

Który z wymienionych będzie najlepszy? Użyję ulubionej odpowiedzi każdego inżyniera – to zależy 🙂 Zależy Ci na niskiej latencji sygnału, a Twój algorytm nie wymaga przetwarzania blokowego? Wybierz dobry przetwornik ADC i przetwarzaj sygnał próbka po próbce. Możesz zaakceptować wyższą latencję sygnału lub Twoje algorytmy przetwarzają sygnał blokowo? W tym przypadku możesz wybrać zarówno przetwornik ADC jak i rozwiązania cyfrowe wykorzystujące I2S czy SAI.

Czym jest I2S?

I2S (ang. Inter-IC Sound) to standardowy protokół komunikacyjny zaprojektowany do cyfrowego przesyłania sygnałów audio między układami scalonymi, takimi jak mikrokontrolery, kodeki audio i cyfrowe przetworniki sygnałów (DSP).

Główne cechy I2S to:

  • Szeregowy przesył danych – I2S wykorzystuje szeregowe połączenie do przesyłania danych audio, co pozwala na uproszczenie układów i zmniejszenie liczby ścieżek. W tym protokole wykorzystujemy 4 sygnały:
    • SCK – sygnał zegarowy,
    • SDIN – dane wejściowe z urządzenia I2S,
    • SDOUT – dane wyjściowe, które chcemy odtworzyć,
    • LRCK – sygnał wyboru kanału L/R.
  • Synchronizacja – dane są przesyłane w synchronizacji z sygnałem zegarowym (SCK) oraz sygnałem wyboru kanału (WS), co umożliwia precyzyjne odwzorowanie dźwięku.
  • Format danych – protokół obsługuje różne formaty bitowe, często używając 16-bitowych, 24-bitowych lub 32-bitowych ramki danych.
  • Oddzielne linie danych – dane dla lewego i prawego kanału audio są przesyłane na osobnych liniach danych, co zapewnia niezależność i jakość dźwięku.
Przykładowe sygnały ramki audio w formacie I2S
Ryc. 2 Przykładowe sygnały ramki audio w formacie I2S

W naszym przykładzie wykorzystamy protokół I2S, aby do mikrokontrolera podłączyć cyfrowy mikrofon I2S SPH0645LM4H jako sygnał wejściowy oraz cyfrowy kodek audio I2S UDA1334A, który będzie dekodował nasz przetworzony sygnał na sygnał analogowy.

Jak efektywnie odczytywać sygnał wejściowy?

Skoro już wiemy, że będziemy używać protokołu I2S do nagrywania i odtwarzania sygnałów audio, to jak zrobić to najlepiej? Istnieją dwie najbardziej popularne metody.

Pierwszą z nich jest synchroniczna obsługa protokołu I2S. Jest ona bardzo podobna do obsługi protokołu I2C. Najpierw przygotowujemy dane, a następnie wywołujemy funkcję i2s_write lub i2s_read, aby skomunikować się z naszym modułem I2S. Funkcje te są blokujące, przez co zmuszamy nasz procesor do czekania, aż wszystkie dane zostaną przesłane lub odczytane. Rozwiązanie to jest najprostsze, jednak nieefektywne ze względu na nieoptymalne użycie procesora.

Drugą opcją jest wykorzystanie DMA do przesyłu naszych danych. Przygotowujemy wtedy blok pamięci, który będzie służył za pośrednika między naszym CPU a modułem I2S. Dzięki temu odciążamy procesor, który w czasie przesyłania danych między tym blokiem a I2S będzie mógł zająć się innymi rzeczami, np. zasłużonym odpoczynkiem w Idle’u 😉 – w końcu zrobił kawał dobrej roboty.

Wymagający algorytm

Lecz co w przypadku, gdy nasz algorytm będzie na tyle wymagający dla procesora, że nie da mu nawet nanosekundy na odpoczynek? W takim przypadku może dojść do sytuacji, w której procesor nadal będzie przetwarzał dane, a DMA będzie już ładował nowe dane do tego samego bloku pamięci – co poskutkuje problemami ze spójnością danych.

Aby temu zapobiec, musimy zastosować technikę zwaną podwójnym buforowaniem (ang. Double-buffering). Na przykład, aby ułatwić przetwarzanie bufora o długości N, wystarczy utworzyć dwa bufory o długości N. Jak pokazano na rysunku poniżej, CPU przetwarza bufor in1 i przechowuje wynik w buforze out1, podczas gdy silnik DMA przesyła dane z wyjścia out0. Na rysunku można zobaczyć, że gdy silnik DMA skończy pracę z lewą połową podwójnych buforów, rozpoczyna przesyłanie danych do wejścia in1 i wyjścia out1, podczas gdy rdzeń przetwarza dane z wejścia in0 i out0. Oczywiście można zastosować więcej niż 2 bufory, co da nam więcej czasu na przetworzenie danych przez algorytm bez utraty zebranych danych z urządzenia I2S.

Schemat podwójnego buforowania DMA do przetwarzania strumieniowego
Ryc. 3 Schemat podwójnego buforowania DMA do przetwarzania strumieniowego

Użycie DMA jest bardziej skomplikowane niż pierwsza metoda, wymaga znajomości platformy oraz dogłębnego debugowania, aby upewnić się, że wszystkie dane audio będą odpowiednio przetworzone. Jednak jest to opcja najczęściej wybierana, ze względu na odpowiednie rozdysponowanie czasu procesora oraz czas przetwarzania ramek audio.

W naszym przykładzie będziemy używać drugiej metody obsługi danych – czyli DMA oraz podwójnego buforowania do odczytu i zapisu danych audio.

Jak efektywnie przetworzyć nasz sygnał?

Skoro już zoptymalizowaliśmy nagrywanie i odtwarzanie sygnału audio, nadszedł czas na optymalizację przetwarzania sygnałów. Każda aplikacja audio będzie zawierała pewien rodzaj ciągu połączonych procesorów audio (ang. pipeline). Taki procesor możemy zaimplementować na dwa sposoby:

  1. Wykorzystując przetwarzanie szeregowe – gdzie każda instancja, która ma za zadanie przetworzyć sygnał jest połączona ze sobą w sposób szeregowy. W tym przypadku procesor audio o numerze N+1, nie może rozpocząć swojego przetwarzania, dopóki procesor poprzedzający (N) nie zakończy swojej pracy. Układ taki jest korzystny, gdy każdy z procesorów jest przystosowany do pracy na takiej samej ilości danych w bloku. Dodatkowo nie wykorzystuje on dużo pamięci RAM mikrokontrolera, ponieważ całość przetwarzania wykonywana będzie na tym samym tasku. Niestety układ taki ma też duży minus, którym jest latencja przetwarzania, która będzie sumą czasów przetwarzania poszczególnych procesorów w pipeline’ie.
  2. Wykorzystując przetwarzanie równoległe – gdzie każda instancja przetwarzająca audio będzie działała na osobnym tasku, a połączona będzie buforem kołowym z poprzednią i kolejną instancją w stworzonym pipeline audio. Układ taki będzie potrzebował o wiele więcej pamięci RAM mikrokontrolera, ale każda instancja może operować na różnych wielkościach bloku audio, a całkowita latencja takiego systemu będzie wynosiła tyle, ile czas przetwarzania najwolniejszej z instancji (w najlepszym przypadku, kiedy każda instancja przetwarzająca będzie działała na osobym rdzeniu mikrokontrolera).

Usprawnienia instancji przetwarzających

Oprócz optymalizacji sposobu przetwarzania sygnału audio możemy również usprawnić same instancje przetwarzające. Mogą nimi być:

  • Filtry cyfrowe,
    • FIR (ang. Finite Impulse Response),
    • IIR (ang. Infinite Impulse Response),
    • Biquad (IIR 2-go rzędu),
  • FFT,
  • Algorytmy AEC (ang. Acoustic Echo Cancellation),
  • Algorytmy NS (ang. Noise Suppression),
  • Sieci neuronowe.

Filtr FIR

W tym artykule skoncentrujemy się na filtrze FIR, który jest cyfrowym filtrem o skończonej odpowiedzi impulsowej, co oznacza, że jego reakcja na impuls wejściowy zanika po określonym czasie.

Do jego głównych cech należą:

  • Liniowa faza – FIR może być zaprojektowany tak, aby miał liniową charakterystykę fazową, co oznacza, że wszystkie częstotliwości sygnału są opóźnione o tę samą ilość czasu. Jest to istotne w aplikacjach audio, gdzie nieliniowość fazy może prowadzić do zniekształceń sygnału (np. w przypadku algorytmu ANC czy AEC).
  • Stabilność – filtry FIR są zawsze stabilne, ponieważ nie mają sprzężenia zwrotnego w swojej implementacji.
  • Projektowanie – są łatwe do projektowania przy użyciu metod takich jak metoda okien czasowych, odpowiedzi impulsowej czy metody częstotliwościowej.

Filtr FIR oblicza każdą próbkę wyjściową jako sumę ważoną określonej liczby próbek wejściowych. Matematycznie wyrażając, jeśli mamy filtr FIR rzędu N, jego wyjściowa próbka y[n] może być wyrażona jako:

3 - Rozwój oprogramowania audio dla systemów embedded

gdzie:

  • x[n] to próbki wejściowe,
  • y[n] to próbki wyjściowe,
  • bk  to współczynniki filtru,
  • N to rząd filtru FIR.

Powyższe równanie użyte do obliczenia pojedynczej próbki w języku C++ może wyglądać następująco:

inline T ProcessSample(T sample) {
    T   acc{ 0 };
    int coeffsPos{ 0 };
    mDelayLine[mDelayPos] = sample;
    ++mDelayPos;
    if (mDelayPos >= mN) { mDelayPos = 0; }

    for (int n = mDelayPos; n < mN; n++) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
    }
    for (int n = 0; n < mDelayPos; n++) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
    }

    return acc;
}

W tym przypadku używamy tablicy mDelayLine jako bufora kołowego do którego zapisujemy próbki audio, a następnie przemnażamy z naszymi współczynnikami filtra.

Możemy przyspieszyć wykonywanie tej funkcji, używając instrukcji SIMD (ang. Single Instruction Multiple Data), aby przetworzyć wektor danych używając jednej instrukcji.

Poniżej implementacja filtra FIR z użyciem loop-unrolling, aby zaprezentować sposób przetwarzania.

inline T ProcessSample(T sample) {
    T   acc{ 0 };
    int coeffsPos{ 0 };

    mDelayLine[mDelayPos++] = sample;
    if (mDelayPos >= mN) { mDelayPos = 0; }
    int n;

    for (n = mDelayPos; n < mN; n += 4) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 1];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 2];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 3];
    }
    for (n = 0; n < mDelayPos; n += 4) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 1];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 2];
        acc += mCoeffs[coeffsPos++] * mDelayLine[n + 3];
    }
    for (; n < mDelayPos; n++) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
    }

    return acc;
}

Wykorzystując założenia loop-unrolling’u, możemy zaimplementować filtr FIR z użyciem instrukcji SSE/AVX dla procesorów x86.

inline T ProcessSample(T sample) {
    int    coeffsPos{ 0 };
    int    n;
    __m128 sum = _mm_setzero_ps(); // SSE - ustawienie sumy na zero

    mDelayLine[mDelayPos++] = sample;
    if (mDelayPos >= mN) { mDelayPos = 0; }

    for (n = mDelayPos; n < mN; n += 4) {
        // Załaduj 4 próbki wejściowe (zakładając wyrównane dane)
        __m128 x = _mm_load_ps(&mDelayLine[n]);
        // Załaduj 4 wyrównane współczynniki
        __m128 c = _mm_load_ps(&mCoeffs[coeffsPos]);
        // Mnożenie wejść przez współczynniki
        __m128 mul = _mm_mul_ps(x, c);
        // Sumowanie wyników
        sum = _mm_add_ps(sum, mul);

        coeffsPos += 4;
    }
    for (n = 0; n < mDelayPos; n += 4) {
       __m128 x = _mm_load_ps(&mDelayLine[n]);
       __m128 c = _mm_load_ps(&mCoeffs[coeffsPos]);
       __m128 mul = _mm_mul_ps(x, c);
       sum = _mm_add_ps(sum, mul);

        coeffsPos += 4;
    }
    for (; n < mDelayPos; n++) {
        acc += mCoeffs[coeffsPos++] * mDelayLine[n];
    }

    // Dodawanie horyzontalne, aby uzyskać pojedynczą sumę
    sum = _mm_hadd_ps(sum, sum);
    sum = _mm_hadd_ps(sum, sum);

    // Zapisz wynik do zmiennej
    T result;
    _mm_store_ss(&result, sum);

    return result;
}

Generowanie współczynnika

Jak w takim razie wygenerować współczynniki do takiego filtru? Możemy do tego wykorzystać metodę okien czasowych. Polega na wykorzystaniu funkcji okienkowych (np. okna Hamming’a, Hanning’a) do przycięcia nieskończonej odpowiedzi impulsowej idealnego filtru. Idealne filtry cyfrowe mają nieskończoną odpowiedź impulsową, co jest niepraktyczne do realizacji w rzeczywistych systemach. Na przykład idealny filtr dolnoprzepustowy ma funkcję sinc jako swoją odpowiedź impulsową:

4 - Rozwój oprogramowania audio dla systemów embedded

Aby uzyskać skończoną odpowiedź impulsową, przycinamy idealną odpowiedź impulsową do długości M. Jednakże bez dodatkowej modyfikacji, takie przycięcie wprowadza oscylacje w odpowiedzi częstotliwościowej (tzw. efekty Gibbsa). Aby zminimalizować efekty Gibbsa, stosujemy funkcję okna czasowego w(n), która wygładza przejścia na końcach przyciętej odpowiedzi impulsowej.

5 - Rozwój oprogramowania audio dla systemów embedded

Poniżej znajduje się rysunek ukazujący cztery najczęściej stosowane okna czasowe oraz ich transformaty Fouriera.

Cztery powszechnie stosowane funkcje okien czasowych (w(n), u góry) i odpowiadające im kwadratowe transformaty Fouriera (|W(k)|2, u dołu)
Ryc. 4 Cztery powszechnie stosowane funkcje okien czasowych (w(n), u góry) i odpowiadające im kwadratowe transformaty Fouriera (|W(k)|2, u dołu)

Aby dodatkowo zoptymalizować proces generowania współczynników takiego filtru, możemy użyć specyfikatora constexpr z języka C++, który obliczy i wygeneruje nam współczynniki podczas kompilacji programu, a same współczynniki będą umieszczone w tablicy, która bezpośrednio trafi do części kodu naszego programu.

struct HanningWindow {
    template<std::size_t N>
    static constexpr std::array<float, N> Get() {
        std::array<float, N> tHanningWindow{};
        uint16_t             i = 0;
        for (auto &el : tHanningWindow) {
            el = std::sin((kPi * i) / (N - 1)) * std::sin((kPi * i) / (N - 1));
            ++i;
        }
        return tHanningWindow;
    }
};

typedef struct {
    double f0;
    double fs;
} kEqParams;


template<class WindowType>
struct LPF {
    template<std::size_t N, typename T = float>
    constexpr std::array<T, N> GetCoeffs(const kEqParams &params) {
        const auto            fc     = params.f0 / params.fs;
        const auto            window = WindowType::template Get<N>();
        std::array<double, N> coeffs{};
        std::array<T, N>      retCoeffs{};

        for (int i = 0; i < N; i++) {
            coeffs[i] = dsp::math::sinc(2.0 * fc * (i - (N - 1) / 2.0));
        }

        int i = 0;
        for (auto &coeff : coeffs) { coeff *= window[i++]; }

        // Normalize to get unity gain
        auto maxAbsMultiplier = 0.0;
        for (auto &coeff : coeffs) { maxAbsMultiplier += coeff; }

        for (auto &coeff : coeffs) { coeff /= maxAbsMultiplier; }

        for (i = 0; i < N; ++i) { retCoeffs[i] = static_cast<T>(coeffs[i]); }

        return retCoeffs;
    }
};

Ograniczmy naszą rozdzielczość przetwarzania

Typowo do obliczeń cyfrowego przetwarzania sygnałów (DSP) używamy liczb zmiennoprzecinkowych typu float i double. Jeżeli chcielibyśmy dodatkowo zoptymalizować obliczenia, bo np. nie potrzebujemy aż tak dużej rozdzielczości obliczeń, możemy użyć liczb stałoprzecinkowych. Liczba stałoprzecinkowa (ang. fixed-point number) to metoda reprezentowania liczb ułamkowych (niecałkowitych) poprzez przechowywanie stałej liczby cyfr ich części ułamkowej.

Liczby stałoprzecinkowe są często używane w systemach wbudowanych i cyfrowym przetwarzaniu sygnałów (DSP) z powodu swojej efektywności obliczeniowej i prostoty implementacji. W systemach tych operacje na liczbach stałoprzecinkowych mogą być wykonane szybciej i z mniejszym zapotrzebowaniem na zasoby procesora niż operacje na liczbach zmiennoprzecinkowych.

Przykładowo, jeśli mamy 8-bitową liczbę stałoprzecinkową z przecinkiem dziesiętnym umieszczonym po czterech bitach, to liczba ta reprezentuje format Q4.4, gdzie:

  • 4 bity są przed przecinkiem (część całkowita),
  • 4 bity są po przecinku (część ułamkowa).

Dla liczby 8-bitowej (0101 0110) w formacie Q4.4:

  • Część całkowita:  0101 (czyli 5 w formacie dziesiętnym),
  • Część ułamkowa: 0110 (czyli 6/16 = 0.375 w formacie dziesiętnym).

Cała liczba wynosi natomiast

5 + 0.375 = 5.375.

Skalowanie jest kluczowym konceptem w liczbach stałoprzecinkowych. Polega na pomnożeniu wartości rzeczywistej przez określony czynnik skalujący (moc dwóch) w celu reprezentacji liczby w formacie całkowitoliczbowym.

Przykładowo, jeśli chcemy reprezentować liczbę 5.375 w formacie Q4.4:

  • Pomnóż 5.375 przez 24=16 (czynnik skalujący dla formatu Q4.4),
  • Otrzymujemy 5.375*16=86,
  • 86 w postaci binarnej to 0101 0110.

Zalety i wady liczb stałoprzecinkowych

Zalety:

  • Wydajność – operacje na liczbach stałoprzecinkowych są zazwyczaj szybsze i wymagają mniej zasobów niż operacje na liczbach zmiennoprzecinkowych.
  • Prostota – mniej skomplikowane jednostki arytmetyczne są wymagane do przetwarzania liczb stałoprzecinkowych.
  • Deterministyczność – liczby stałoprzecinkowe mają deterministyczne zachowanie pod względem czasu wykonania operacji.

Wady:

  • Ograniczony zakres i precyzja – ze względu na stałą pozycję przecinka, liczby stałoprzecinkowe mają ograniczony zakres reprezentacji i precyzję w porównaniu do liczb zmiennoprzecinkowych.
  • Potencjalne przepełnienie – operacje mogą łatwo prowadzić do przepełnienia, jeśli wyniki przekraczają zakres reprezentowanych wartości.
  • Złożoność skalowania – programista musi starannie zarządzać skalowaniem, aby uniknąć utraty precyzji i przepełnienia.

Implementując bibliotekę stałoprzecinkową, która wspiera operacje constexpr języka C++ oraz posiada odpowiednie przeciążenia operatorów matematycznych, możemy generować dane (np. współczynniki filtra FIR), zmieniając tylko typ danych.

constexpr auto                                kFilterSize   = 256u;
constexpr auto                                kSamplingFreq = 44.1_kHz;

static constexpr __attribute__((aligned(16))) std::array<float, kFilterSize> kFloatFilterCoeffs{
    dsp::fir::LPF<dsp::HammingWindow>{}.GetCoeffs<kFilterSize, float>(
        dsp::fir::kEqParams{ .f0 = 4000, .fs = kSamplingFreq })
};

static constexpr __attribute__((aligned(16))) std::array<QFormat<Q15>, kFilterSize> kQ15FilterCoeffs{
    dsp::fir::LPF<dsp::HammingWindow>{}.GetCoeffs<kFilterSize, QFormat<Q15>>(
        dsp::fir::kEqParams{ .f0 = 4000, .fs = kSamplingFreq })
};

Jak to się sprawdza w rzeczywistości?

Tak jak w poprzedniej części tego artykułu, tak samo w tej użyjemy mikrokontrolera ESP32-S3 z instrukcjami SIMD. Na początku sprawdźmy, jakie wyniki osiągnie ten mikrokontroler przy podstawowych operacjach matematycznych w zależności od typu danych.

Wyniki testu sprawdzającego szybkość operacji arytmetycznych mikrokontrolera ESP32-S3
Tab. 1 Wyniki testu sprawdzającego szybkość operacji arytmetycznych mikrokontrolera ESP32-S3

Jak widać w przedstawionej tabeli, operacje dodawania oraz mnożenia dla typu integer oraz float są porównywalne, natomiast typ float wypada o wiele lepiej przy operacji mnożenia i akumulacji (która jest wykorzystywana w filtrze FIR – co przełoży się na dalsze wyniki ;)). Najgorzej wypada typ double, ze względu na brak bezpośredniego wsparcia w FPU mikroprocesora. Najgorszym typem operacji jest dzielenie, które w każdym przypadku zabierało najwięcej czasu procesora.

Rozwiązaniem na pozbycie się dzielenia, a tym samym przyspieszenie kodu, jest mnożenie przez odwrotność. Zamiast dzielić każdą liczbę w tablicy, lepiej jest jednorazowo obliczyć odwrotność dzielnika i przemnożyć każdą liczbę.

std::array<float, N> data;
    for(auto &el : data)
    {
        data /= 4;
    }

    // Calculate 1/divider and multiply data
    std::array<float, N> data;
    const auto oneOverDivider = 1 / 4;
    for(auto &el : data)
    {
        data *= oneOverDivider;
    }

Porównanie wydajności

Przejdźmy teraz do porównania wydajności opisywanego w poprzedniej części filtra FIR. Sprawdźmy, ile czasu będzie potrzebował nasz mikrokontroler, aby przefiltrować sygnał audio – porównajmy czas przetwarzania w zależności od rozmiaru filtra, rozmiaru ramki danych audio oraz typu przetwarzanych danych.

Porównanie czasów przetwarzania filtra FIR operującego na danych typu float w zależności od rozmiaru filtra oraz rozmiaru bloku audio
Tab. 2 Porównanie czasów przetwarzania filtra FIR operującego na danych typu float w zależności od rozmiaru filtra oraz rozmiaru bloku audio
Porównanie czasów przetwarzania filtra FIR operującego na danych typu Q15 w zależności od rozmiaru filtra oraz rozmiaru bloku audio
Tab. 3 Porównanie czasów przetwarzania filtra FIR operującego na danych typu Q15 w zależności od rozmiaru filtra oraz rozmiaru bloku audio

Obrabiając dane z tabeli powyżej, możemy uzyskać wykres pokazujący w jakim stopniu operacje SIMD przyspieszyły nasz algorytm dla danego rozmiaru filtra oraz bloku audio.

Procentowy wykres akceleracji filtru FIR wykorzystując operacje SIMD. Porównanie względem rozmiaru filtru dla danych typu float
Ryc. 5 Procentowy wykres akceleracji filtru FIR wykorzystując operacje SIMD. Porównanie względem rozmiaru filtru dla danych typu float

Drugi wykres przedstawia wyniki uzyskane, gdy filtr FIR korzystał z danych typu Q15:

Procentowy wykres akceleracji filtru FIR wykorzystując operacje SIMD. Porównanie względem rozmiaru filtru dla danych typu Q15
Ryc. 6 Procentowy wykres akceleracji filtru FIR wykorzystując operacje SIMD. Porównanie względem rozmiaru filtru dla danych typu Q15

Jak widać na wykresie, w przypadku danych typu float nie opłaca się używać operacji SIMD, gdy rozmiar filtru jest mały lub gdy ramka audio ma mały rozmiar. W przypadku danych Q15, akceleracja jest o wiele niższa niż w przypadku danych typu float. Czemu tak jest? Pamiętacie jakie wyniki uzyskaliśmy podczas stress testu operacji matematycznych? Wyszło nam, że operacja MAC (ang. Multiply and Accumulate) została wykonywana najszybciej dla danych typu float. Właśnie to ma przełożenie na uzyskane przez nas wyniki.

Pora na podsumowanie

Artykuł z pewnością nie wyczerpuje wszystkich tematów związanych z cyfrowym przetwarzaniem sygnałów. Moim celem było pokazanie, jak w prosty sposób z wykorzystaniem łatwo dostępnych elementów możemy rozpocząć swoja przygodę z audio.

Dowiedzieliśmy się, jaki mikrokontroler należy wybrać, jeżeli chcemy stworzyć aplikację audio, jak działa protokół I2S, który jest szeroko stosowany w przetwarzaniu sygnałów i jak wykorzystać DMA do efektywnego przetwarzania danych.

Omówiliśmy też wykorzystanie operacji SIMD do zrównolegnienia naszych obliczeń matematycznych oraz dowiedzieliśmy się, jak wykorzystać typ stałoprzecinkowy. Uzyskane wyniki jednoznacznie pokazały, że wykorzystanie operacji SIMD jest kluczowe w aplikacjach DSP.

Kod przedstawiony w artykule oraz użyty do pomiarów czasu przetwarzania algorytmów jest dostępny na GitHubie.

5/5 ( głosy: 4)
Ocena:
5/5 ( głosy: 4)
Autor
Avatar
Michał Berdzik

Doświadczony embedded software developer z 5-letnim stażem, specjalizujący się w nowoczesnym C++. Na co dzień pracuje nad rozwiązaniami dla branży elektroniki użytkowej, w tym nad projektami z zakresu Smart Home. Jego pasją jest muzyka oraz technologia audio, zwłaszcza DSP (Digital Signal Processing). Po godzinach rozwija aplikację z dziedziny Spatial Audio, łącząc w niej swoje zainteresowania oraz umiejętności

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?