Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: Grzegorz 'baziorek' Bazior
Inne artykuły

[C++] Odtwarzanie dźwięku za pomocą różnych bibliotek (SFML, QT, FFMPEG, MiniAudio)

[artykuł] W artykule przedstawiono sposoby odtwarzania dźwięku w C++ przy użyciu różnych bibliotek, takich jak SFML, Qt, FFMPEG i MiniAudio. Omówione zostały ich zalety, zastosowania oraz przykładowe implementacje, co pozwala na łatwe porównanie i wybór odpowiedniego rozwiązania do własnego projektu.

Wprowadzenie

Zapewne wielu z nas chciało kiedyś w łatwy sposób odpalić dźwięk z aplikacji w C++. Niestety na moment pisania artykułu nie ma w standardzie C++ do wersji C++23 włącznie, jak również w tzw. "poczekalni do standardu C++" oficjalnie zwaną biblioteką boost czegoś co by mogło odtworzyć plik muzyczny, czy nawet binarne dane w jakimkolwiek formacie.
Jedyne co mamy zarówno w C jak i C++ to znak ASCII, ale wtedy bazujemy na dźwięku systemowym, którego nie możemy zmienić:
C/C++
#include <iostream>

int main()
{
   
std::cout << "\a" << std::endl;
}

Wielu z nas chciałby mieć następujące możliwości w standardzie:
C/C++
#include <audio>

int main()
{
   
std::audio::play( "song.wav" );
}
Ewentualnie jeszcze możliwość, sterowania obiektem muzycznym - wstrzymywanie, wznawianie, zatrzymywanie, zmiana utworu, czy też pobieranie danych na temat tego utworu takich jak długość. Właśnie najprostrzym rozwiązaniem, które nie jest wspierane od prawie 20 lat jest biblioteka audiere, która w prawie tak prosty sposób umożliwiała odtwarzanie plików muzycznych.

O czym jest niniejszy artykuł

W artykule tym właśnie chce wpierw przeanalizować dostępne biblioteki do odtwarzania muzyki w C++ pod następującym względem (warunki są posortowane od najistotniejszych dla mnie):
  • biblioteka musi być utrzymywana: co najmniej 1000 commitów, oraz ostatni commit nie dawniej niż rok temu
  • multiplatformowa (najlepiej możliwa do odtworzenia bez aplikacji okienkowej), również do odtworzenia na RaspberryPI
  • ma to być biblioteka łatwa w użyciu dla odtwarzania muzyki, aby nie wymagała wiedzy muzycznej, skupiające się na największej prostocie
  • możliwe do użycia we wszelakich projektach bezpłatnie (również zamkniętych)
  • ma być ona w C++ (czyli nie w C, czy "C+"),
  • otwarty kod źródłowy biblioteki
  • wspierające natywnie format MP3 (patenty już wygasły w 2017 roku), albo przynajmniej współpracująca z kodekami do MP3
  • lekka w użyciu (aby nie było porzebne dodawanie do projektu olbrzymiej biblioteki dostarczającej bardzo wiele funkcjonalności, gdy tylko potrzebujemy odpalenie dźwięku)

Następnie przedstawiam przykładowe kody uruchamiające dźwięk przy użyciu trzech wybranych bibliotek, wraz z przedstawieniem wydruku, opisem "bardziej zawiłych" miejsc w kodzie, a także przedstawiam sposób zbudowania w sposób multiplatformowy (wg poradnika opisującego manager pakietów VCPKG). Wspominam też o licencjach zaprezentowanych narzędzi, ale zastrzegam, że nie jestem od nich specjalistą - te kwestie przed podjęciem decyzji biznesowych trzeba sobie sprawdzić samemu.

Niniejszy artykuł jest zainspirowany wystąpieniem konferencyjnym Audio in standard C++ - Timur Doumler [ACCU 2019], w którym to autor zaproponował dodanie do standardu libstdaudio. Niestety mimo iż należy on do komitetu standaryzacyjnego C++ to nie zostało to jeszcze dodane (opis propozycji w formie tekstowej). Wystąpienie to zawiera wiele istotnych w tym temacie kwestii. Timur Doumler jest znany z wielu wystąpień na konferencjach poświęconych C++ (w tym wystąpień na temat audio).

Twórca języka C++  Bjarne Stroustrup podczas Cpp.chat Episode #44 powiedział:
"C++ is there to deal with hardware at a low level, and to abstract away from it with zero overhead."
Tak się składa, że obsługa dźwięku jest blisko sprzętu, a standard C++ tego nie zauważa.

Mini teoria

Wg wspomnianego wystąpienia Timura gdy odtwarzany jest dźwięk to pod spodem odbywa się automatyczne wołanie pewnej funkcji (ang. callback), która to ma za zadanie ustawić pewne bufory danymi audio do odtwarzania. Wszystkie biblioteki obsługujące dźwięk robią to w taki właśnie sposób (jak nie wprost to pod spodem). Zarejestrowanie takiej funkcji to jedyny aspekt, którego się nie da zrobić w standardowym C++, całą resztę się da ... ale posiadając wiedzę w temacie przetwarzania audio. Ważnym aspektem jest, że ta funkcja zwrotna (callback) nie powinna wykonywać operacji o niedeterministycznym czasie trwania, czyli:
  • Wykonanie tej funkcji nie może zawieść
  • Nie można nic pominąć (człowiek to usłyszy)
  • Nie powinno się używać mutexów
  • Nie powinno się alokować pamięci
  • Nie powinno się robić operacji sieciowych czy dyskowych
  • Nie powinno się wołać API, które ma niedeterministyczny czas trwania (np. pod spodem korzysta z mutexów)
  • Korzystając z mechanizmu lock-free należy synchronizować dostęp (np. przez
    std::atomic
    ) do danych (dane do odtworzenia mogą przepełnić bufor cykliczny, podobnie mogą być zbyt wolno dostarczane)
W oparciu o tę teorię zapewne dostrzegasz, Drogi Czytelniku, potrzebę aby skorzystać z biblioteki, która ma teorię zaimplementowaną w praktyce.

Biblioteki do odtwarzania dźwięku - analiza

Timur Doumler bibliotekę libstdaudio zaimplementował jedynie na MacOS, przez co nie spełnia ona opisanych powyżej wymagań względem biblioteki, które wymieniłem wcześniej. Poza tym jego propozycja nie dostarcza mechanizmu do odtwarzania plików dźwiękowych w łatwy sposób dla osoby nie znającej od podszewki jak działa odtwarzanie dźwięku. W poszukiwaniach "najlepszej" biblioteki do odtwarzania dźwięków za punkt startowy posłużyła mi lista multiplatformowych bibliotek, które on wymienił:


Przeanalizowałem te biblioteki pod kontem moich kryteriów, wszystkie są multiplatformowe, ale:
Biblioteka C++ Open-source Licencja Wsparcie dla MP3 Lekkość i przeznaczenie
libSDL C API Tak Zlib Obsługa MP3 przez SDL_mixer lub inny dodatkowy kodek* Lekka, wielozadaniowa (grafika, dźwięk, input)
PortAudio C API Tak MIT Obsługa MP3 wymaga dodatkowego kodeka* Lekka, niskopoziomowa, ogólne audio
RtAudio C++ Tak MIT Obsługa MP3 wymaga dodatkowego kodeka* Lekka, niskopoziomowa, dla zaawansowanych
Libsoundio C API Tak MIT Obsługa MP3 wymaga dodatkowego kodeka* Niskopoziomowa, dla zaawansowanych
FMOD C++ Nie (proprietary, darmowa wersja dla niekomercyjnych) Proprietary Tak Wysokopoziomowa, idealna do gier
WWise C++ Nie Proprietary Tak Zaawansowane narzędzie dla profesjonalistów
Fabric C++ Nie Proprietary Tak Integracja z Unity, dedykowane do gier
JUCE C++ Tak AGPLv3/komercyjna Tak Wszechstronna, złożona, zaawansowana

Żadna z powyższych bibliotek nie spełniła wszystkich ww. kryteriów, stąd szukałem kolejnych multiplatformowych frameworków, oto one:
Biblioteka C++ Open-source Licencja Wsparcie dla MP3 Lekkość i przeznaczenie
SFML C++ Tak Zlib Wbudowana obsługa MP3 Wysokopoziomowy framework multimedialny
Qt Multimedia C++ Tak LGPL/komercyjna Obsługa MP3 przez backendy Qt (np. GStreamer lub native decoders) Wszechstronny framework GUI i multimedialny
MiniAudio C API Tak MIT Wbudowana obsługa MP3 Lekki i prosty framework audio
OpenAL-soft C API Tak LGPL Obsługa MP3 wymaga dodatkowego kodeka* Niskopoziomowe API do obsługi 3D audio
GStreamer C API, z bindingami dla C++ Tak LGPL (szczegóły) Obsługa MP3 przez pluginy GStreamer (gst-plugins-ugly) lub dodatkowe kodeki* Wszechstronne API dla audio i wideo
BASS Bindingi C i C++ Nie Proprietary Wbudowana obsługa MP3 Lekki framework do audio (darmowy dla niekomercyjnych)

Kodeki do obsługi MP3

Gdy wybrana przez nas biblioteka nie posiada wbudowanej obsługi MP3 (lub licencja na dany komponent w bibliotece nas nie satysfakcjonuje) to możemy skorzystać z kodeków np. dekodujących MP3. Wtedy dane binarne pliku muzycznego są zamieniane na odpowiednią tablicę zdekodowanych danych, która może być odtworzona przez naszą bibliotekę. Oto przykładowe kodeki do obsługi MP3:
  • SDL_mixer – Dedykowany kodek dla SDL (licencja Zlib). Obsługuje formaty: MP3, WAV, OGG/Vorbis, FLAC, MIDI (z wykorzystaniem zewnętrznego biblioteki FluidSynth lub Timidity++).
  • gst-plugins-ugly – Dedykowany plugin dla GStreamer (licencja LGPL). Obsługuje formaty: MP3, AAC, AMR, oraz inne przy wykorzystaniu dodatkowych wtyczek.
  • libmadLicencja GPL. Obsługiwane formaty: MP3 (MPEG-1, MPEG-2, MPEG-2.5 Audio Layer III).
  • mpg123Licencja LGPL. Obsługiwane formaty: MP3 (MPEG-1, MPEG-2, MPEG-2.5 Audio Layer III).
  • FFmpeg – Licencja LGPL/GPL w zależności od konfiguracji. Obsługuje on niezliczone formaty audio i video.
  • dr_libs – Lekka biblioteka do obsługi formatów MP3, FLAC i WAV (licencja Public Domain lub MIT do wyboru przez użytkownika). Biblioteka jest zainspirowana innym, aczkolwiek już dawno nieutrzymywanym kodekiem minimp3.
  • minimp3 – Minimalistyczny dekoder MP3, obsługuje dekodowanie w formacie MP3 do PCM (licencja CC0/Public Domain).
Z kodekami również należy uważać pod względem licencyjnym, gdyż jeśli użyjemy kodeka na licencji GPL to wymusza to zrobienie naszego kodu na tejże licencji.

SFML - najlepsza (wg moich kryteriów) biblioteka do odtwarzania dźwięku

SFML (na licencji ZLIB)  jest potężną biblioteką multimedialną, w której można tworzyć nawet gry, operować na multimediach, wykonywać obliczenia na kartach graficznych i inne.
SFML (wg dokumentacji) wspiera następujące formaty audio: WAV (PCM), OGG/Vorbis, FLAC i MP3 (tylko dekodowanie).

Kod odtwarzający pliki muzyczne w SFMLu

Poniżej znajduje się przykładowy kod odtwarzający muzykę:
C/C++
#include <iostream>
#include <thread>
#include <chrono>
#include <SFML/Audio.hpp>

int main() {
   
const std::string filePath = "audio.wav";
   
   
// Wczytaj plik audio
   
sf::Music music;
   
if( !music.openFromFile( filePath ) ) {
       
std::cerr << "Error: Could not load file " << filePath << std::endl;
       
return 1;
   
}
   
   
// Wyświetl informacje o pliku
   
std::cout << "Now playing: " << filePath << ", duration: " << music.getDuration().asSeconds() << " seconds" << std::endl;
   
   
// Rozpocznij odtwarzanie, ponizsza funkcja jest nieblokujaca
   
music.play();
   
   
while( sf::Music::Playing == music.getStatus() ) {
       
std::cout << "\rCurrent position: " << music.getPlayingOffset().asSeconds()
        <<
" / " << music.getDuration().asSeconds() << " seconds" << std::flush;
       
std::this_thread::sleep_for( std::chrono::milliseconds( 500 ) );
   
}
   
   
std::cout << "\nPlayback finished." << std::endl;
}
Przykładowy wydruk:
Now playing: audio.wav, duration: 4.34746 seconds
Current position: 3.98199 / 4.34746 seconds
Playback finished.
SFML ma bardzo dobrą dokumentacje, dużą społeczność i oficjalne tutoriale. Tak się składa, że na oficjalnej stronie znajduje się oficjalny tutorial jak odtwarzać dźwięk przy pomocy kodu używając SFMLa.
Poza tym na moje oko jest to najprostrzy kod do odtwarzania plików audio ze wszystkich innych bibliotek.
SFML zawiera również obiekty do odtwarzania dźwięku już wczytanego w formie danych binarnych, do tego celu służą
sf::Sound
 i
sf::SoundBuffer
. Można użyć tego mechanizmu odtwarzając dane z sieci, lub gdy mamy do czynienia z większym plikiem (którego wczytanie do pamięci byłoby zauważalne dla zużycia zasobów). Kolejnym zastosowaniem jest użycie zewnętrznego kodeka.

Kod odtwarzający pliki muzyczne w SFMLu przy użyciu zewnętrznego kodeka: FFmpeg

SFML nie odtwarza wszystkich formatów, stąd jeśli potrzebujemy odtworzyć taki plik konieczne jest użycie dodatkowego kodeka.
Zdecydowałem o użyciu kodeka FFmpeg, gdyż obsługuje on olbrzymią gamę formatów multimedialnych, nie tylko audio, ale też video.
Oto kod, który dekoduje wiele formatów multimedialnych (również video) przy pomocy FFmpeg, a następnie uruchamia muzykę za pomocą SFMLa:
C/C++
#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
#include <SFML/Audio.hpp>

extern "C" {
   
#include <libavformat/avformat.h>
    #include <libavcodec/avcodec.h>
    #include <libswresample/swresample.h>
    #include <libavutil/channel_layout.h>
    #include <libavutil/samplefmt.h>
    #include <libavutil/mem.h>
}

struct AudioFileData
{
   
int channels;
   
int sampleRate;
   
   
std::vector < sf::Int16 > pcmAudioBuffer;
   
   
bool successReading;
};

AudioFileData decodeAudioWithFFmpeg( const std::string & filePath );

void playSound( const AudioFileData & audioFileData );

int main( int argc, char * * argv ) {
   
if( argc < 2 ) {
       
std::cerr << "Usage: " << argv[ 0 ] << " <audio file>" << std::endl;
       
return 1;
   
}
   
   
const std::string filePath = argv[ 1 ];
   
   
AudioFileData audioData = decodeAudioWithFFmpeg( filePath );
   
if( audioData.pcmAudioBuffer.empty() )
       
 std::cerr << "Failed to decode or play audio from file " << filePath << std::endl;
   
else
       
 playSound( audioData );
   
}

AudioFileData decodeAudioWithFFmpeg( const std::string & filePath ) {
   
avformat_network_init();
   
   
AVFormatContext * formatContext = nullptr;
   
if( avformat_open_input( & formatContext, filePath.c_str(), nullptr, nullptr ) != 0 ) {
       
std::cerr << "Error: Could not open audio file with FFmpeg: " << filePath << std::endl;
       
return { };
   
}
   
   
if( avformat_find_stream_info( formatContext, nullptr ) < 0 ) {
       
std::cerr << "Error: Could not retrieve stream info." << std::endl;
       
avformat_close_input( & formatContext );
       
return { };
   
}
   
   
const AVCodec * codec = nullptr;
   
AVCodecContext * codecContext = nullptr;
   
int streamIndex = - 1;
   
   
for( unsigned int i = 0; i < formatContext->nb_streams; ++i ) {
       
if( formatContext->streams[ i ]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ) {
           
codec = avcodec_find_decoder( formatContext->streams[ i ]->codecpar->codec_id );
           
if( !codec ) {
               
std::cerr << "Error: Unsupported codec for audio stream." << std::endl;
               
avformat_close_input( & formatContext );
               
return { };
           
}
           
           
codecContext = avcodec_alloc_context3( codec );
           
avcodec_parameters_to_context( codecContext, formatContext->streams[ i ]->codecpar );
           
if( avcodec_open2( codecContext, codec, nullptr ) < 0 ) {
               
std::cerr << "Error: Could not open codec." << std::endl;
               
avcodec_free_context( & codecContext );
               
avformat_close_input( & formatContext );
               
return { };
           
}
           
streamIndex = i;
           
break;
       
}
    }
   
   
if( streamIndex == - 1 ) {
       
std::cerr << "Error: No audio stream found in file." << std::endl;
       
avcodec_free_context( & codecContext );
       
avformat_close_input( & formatContext );
       
return { };
   
}
   
   
SwrContext * swrContext = swr_alloc();
   
if( !swrContext ) {
       
std::cerr << "Error: Could not allocate SwrContext." << std::endl;
       
avcodec_free_context( & codecContext );
       
avformat_close_input( & formatContext );
       
return { };
   
}
   
   
swr_alloc_set_opts2( & swrContext,
   
& codecContext->ch_layout, AV_SAMPLE_FMT_S16, codecContext->sample_rate,
   
& codecContext->ch_layout, codecContext->sample_fmt, codecContext->sample_rate,
   
0, nullptr );
   
   
if( swr_init( swrContext ) < 0 ) {
       
std::cerr << "Error: Could not initialize SwrContext." << std::endl;
       
swr_free( & swrContext );
       
avcodec_free_context( & codecContext );
       
avformat_close_input( & formatContext );
       
return { };
   
}
   
   
AVPacket * packet = av_packet_alloc();
   
AVFrame * frame = av_frame_alloc();
   
   
AudioFileData audioFileData;
   
audioFileData.sampleRate = codecContext->sample_rate;
   
audioFileData.channels = codecContext->ch_layout.nb_channels;
   
   
while( av_read_frame( formatContext, packet ) >= 0 ) {
       
if( packet->stream_index == streamIndex ) {
           
if( avcodec_send_packet( codecContext, packet ) == 0 ) {
               
while( avcodec_receive_frame( codecContext, frame ) == 0 ) {
                   
int bufferSize = av_samples_get_buffer_size( nullptr, audioFileData.channels, frame->nb_samples, AV_SAMPLE_FMT_S16, 1 );
                   
std::vector < uint8_t > tempBuffer( bufferSize );
                   
                   
uint8_t * tempBufferPtr = tempBuffer.data();
                   
swr_convert( swrContext, & tempBufferPtr, frame->nb_samples, frame->data, frame->nb_samples );
                   
                   
audioFileData.pcmAudioBuffer.insert( end( audioFileData.pcmAudioBuffer ),
                   
( sf::Int16 * ) tempBuffer.data(),
                   
( sf::Int16 * ) tempBuffer.data() + bufferSize / sizeof( sf::Int16 ) );
               
}
            }
        }
       
av_packet_unref( packet );
   
}
   
   
av_frame_free( & frame );
   
av_packet_free( & packet );
   
swr_free( & swrContext );
   
avcodec_free_context( & codecContext );
   
avformat_close_input( & formatContext );
   
   
return audioFileData;
}

void playSound( const AudioFileData & audioFileData ) {
   
sf::SoundBuffer soundBuffer;
   
if( !soundBuffer.loadFromSamples( audioFileData.pcmAudioBuffer.data(), audioFileData.pcmAudioBuffer.size(), audioFileData.channels, audioFileData.sampleRate ) ) {
       
std::cerr << "Error: Could not load buffer for playback" << std::endl;
       
return;
   
}
   
   
sf::Sound sound;
   
sound.setBuffer( soundBuffer );
   
sound.play();
   
   
std::cout << "Playing file, duration: " << soundBuffer.getDuration().asSeconds() << " seconds" << std::endl;
   
while( sound.getStatus() == sf::Sound::Playing ) {
       
std::this_thread::sleep_for( std::chrono::milliseconds( 500 ) );
   
}
   
   
std::cout << "Playback finished." << std::endl;
}
Widzimy tutaj wręcz przepaść między prostotą SFMLa, a niskopoziomowym dekoderem.

Z tym kodem trzeba uważać o tyle, że FFmpeg może być skonfigurowany jako używający bibliotek GPL, wtedy nasz kod powinniśmy ustawić jako GPL. Aby tego uniknąć warto skonfigurować sobie FFmpeg aby nie używał GPLa.
Wpierw sprawdźmy, jaką mamy konfiguracje:
ffmpeg -version
 i czy tam jest
--enable-gpl
, jeśli tak to trzeba skonfigurować bez tej opcji, przykładowo w taki sposób
--disable-gpl --enable-nonfree
, ale jeśli chcemy być w pełni legalni to na oficjalnej stronie FFmpeg opisali konieczne kroki (checklistę) aby być zabezpieczonymi.

Kod odtwarzający pliki muzyczne w SFMLu przy użyciu C++owej nakładki na FFmpeg: AvCpp

FFmpeg jest potężnym narzędziem, jednakże jest w C, dlatego możemy się pokusić o użycie C++owej nakładki na FFmpeg (dwa rodzaje licencji: licencja BSD lub LGPL): AvCpp. Projekt co prawda ma jeszcze mniej niż 1000 commitów, niemniej jednak jak utworzyłem ISSUE na Githubie z prośbą o przykład na integracje z SFMLem to dość szybko autor mi taki przykład zrobił umieszczając jako odpowiedź do ISSUE. W poniższym kodzie jest ten przykład, oczyszczony przeze mnie, zapewne kiedyś pojawi się jako jeden z przykładów):
C/C++
#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
#include <filesystem>
#include <optional>

#include <SFML/Audio.hpp>

#include "avcpp/av.h"
#include "avcpp/format.h"
#include "avcpp/formatcontext.h"
#include "avcpp/codec.h"
#include "avcpp/codeccontext.h"
#include "avcpp/audioresampler.h"

struct AudioFileData
{
   
int channels;
   
int sampleRate;
   
   
std::vector < sf::Int16 > pcmAudioBuffer;
   
   
bool successReading;
};

AudioFileData decodeAudioWithAvcpp( const std::filesystem::path & filePath );
void playSound( const AudioFileData & audioFileData );

int main( int argc, char * * argv ) {
   
if( argc < 2 ) {
       
std::cerr << "Usage: " << argv[ 0 ] << " <audio file>" << std::endl;
       
return 1;
   
}
   
   
const std::string filePath = argv[ 1 ];
   
   
AudioFileData audioData = decodeAudioWithAvcpp( filePath );
   
   
if( audioData.pcmAudioBuffer.empty() )
       
 std::cerr << "Failed to decode or play audio from file " << filePath << std::endl;
   
else
       
 playSound( audioData );
   
}

AudioFileData decodeAudioWithAvcpp( const std::filesystem::path & filePath )
{
   
/// Reference to https://github.com/h4tr3d/avcpp/blob/master/example/api2-samples/api2-decode-audio.cpp
   
   
std::error_code ec;
   
std::optional < size_t > audioStream;
   
   
av::init();
   
av::setFFmpegLoggingLevel( AV_LOG_DEBUG );
   
   
av::FormatContext ictx;
   
av::Stream ast;
   
av::AudioDecoderContext adec;
   
   
ictx.openInput( filePath ); // exception on fail
   
ictx.findStreamInfo(); // process input and collect streams information
   
    /// Lookup first audio stream
   
for( auto i = 0u; i < ictx.streamsCount(); ++i ) {
       
auto st = ictx.stream( i );
       
if( st.isAudio() ) {
           
audioStream = i;
           
ast = st;
           
break;
       
}
    }
   
   
if( ast.isNull() ) {
       
std::cerr << "Audio stream not found\n";
       
exit( 1 );
   
}
   
   
if( ast.isValid() ) {
       
adec = av::AudioDecoderContext( ast );
       
adec.open( av::Codec() ); // throw on error
   
}
   
   
/// Resample: required to convert into proper SFML format. SFML supports only PCM_S16 samples
   
av::AudioResampler resampler( adec.channelLayout(), adec.sampleRate(), AV_SAMPLE_FMT_S16,
   
adec.channelLayout(), adec.sampleRate(), adec.sampleFormat() );
   
   
   
/// Output data
   
AudioFileData audioFileData;
   
audioFileData.sampleRate = adec.sampleRate();
   
audioFileData.channels = adec.channels();
   
   
/// Process input
   
while( true ) {
       
auto pkt = ictx.readPacket( ec );
       
// handle in proper way, like EGAIN
       
if( ec ) {
           
std::clog << "Packet reading error: " << ec << ", " << ec.message() << std::endl;
           
break;
       
}
       
       
// EOF
       
if( !pkt ) {
           
break;
       
}
       
       
// Skip non-audio packets
       
if( pkt.streamIndex() != * audioStream )
           
 continue;
       
       
std::clog << "Read packet: " << pkt.pts() << " / " << pkt.pts().seconds() << " / " << pkt.timeBase() << " / st: " << pkt.streamIndex() << std::endl;
       
       
auto samples = adec.decode( pkt );
       
       
std::clog << "  Samples [in]: " << samples.samplesCount()
        <<
", ch: " << samples.channelsCount()
        <<
", freq: " << samples.sampleRate()
        <<
", name: " << samples.channelsLayoutString()
        <<
", pts: " << samples.pts().seconds()
        <<
", ref=" << samples.isReferenced() << ":" << samples.refCount()
        <<
std::endl;
       
       
// skip empty frames if any
       
if( !samples )
           
 continue;
       
       
// Resample it
       
resampler.push( samples );
       
       
/// Pop resampler data: in common way, single input samples frame can produce multiple output. Or empty, if single output frame
        /// requires more input one.
        /// Note, we can only push data here and process it at once on the Flushing. But too much more memory is needed.
       
       
auto ouSamples = av::AudioSamples::null();
       
while(( ouSamples = resampler.pop( samples.samplesCount(), ec ) ) ) {
           
std::clog << "  Samples [ou]: " << ouSamples.samplesCount()
            <<
", ch: " << ouSamples.channelsCount()
            <<
", freq: " << ouSamples.sampleRate()
            <<
", name: " << ouSamples.channelsLayoutString()
            <<
", pts: " << ouSamples.pts().seconds()
            <<
", ref=" << ouSamples.isReferenced() << ":" << ouSamples.refCount()
            <<
", size=" << ouSamples.size()
            <<
std::endl;
           
           
// UB due to strict aliasing...
           
audioFileData.pcmAudioBuffer.insert( std::end( audioFileData.pcmAudioBuffer ),
           
( sf::Int16 * ) ouSamples.data(),
           
( sf::Int16 * ) ouSamples.data() + ouSamples.size() / sizeof( sf::Int16 ) );
       
}
    }
   
   
/// Flush resampler
   
{
       
while( resampler.delay() ) {
           
auto ouSamples = resampler.pop( 0, ec ); // request all remain data at once
           
           
if( ec ) {
               
std::clog << "Resampling status: " << ec << ", text: " << ec.message() << std::endl;
               
break;
           
} else if( !ouSamples ) {
               
break;
           
} else {
               
std::clog << "  Samples [ou]: " << ouSamples.samplesCount()
                <<
", ch: " << ouSamples.channelsCount()
                <<
", freq: " << ouSamples.sampleRate()
                <<
", name: " << ouSamples.channelsLayoutString()
                <<
", pts: " << ouSamples.pts().seconds()
                <<
", ref=" << ouSamples.isReferenced() << ":" << ouSamples.refCount()
                <<
", size=" << ouSamples.size()
                <<
std::endl;
               
               
// UB due to strict aliasing...
               
audioFileData.pcmAudioBuffer.insert( std::end( audioFileData.pcmAudioBuffer ),
               
( sf::Int16 * ) ouSamples.data(),
               
( sf::Int16 * ) ouSamples.data() + ouSamples.size() / sizeof( sf::Int16 ) );
           
}
        }
    }
   
   
return audioFileData;
}

void playSound( const AudioFileData & audioFileData ) {
   
sf::SoundBuffer soundBuffer;
   
if( !soundBuffer.loadFromSamples( audioFileData.pcmAudioBuffer.data(), audioFileData.pcmAudioBuffer.size(), audioFileData.channels, audioFileData.sampleRate ) ) {
       
std::cerr << "Error: Could not load buffer for playback" << std::endl;
       
return;
   
}
   
   
sf::Sound sound;
   
sound.setBuffer( soundBuffer );
   
sound.play();
   
   
std::cout << "Playing file, duration: " << soundBuffer.getDuration().asSeconds() << " seconds" << std::endl;
   
while( sound.getStatus() == sf::Sound::Playing ) {
       
std::this_thread::sleep_for( std::chrono::milliseconds( 500 ) );
   
}
   
   
std::cout << "Playback finished." << std::endl;
}
Kod co prawda nie jest krótszy niż analogiczny przykład w czystym FFmpeg, jednakże wersja dostarczona przez utrzymującego AvCpp wygląda czytelniej, ma lepszą obsługę błędów, oraz wiele cennych komentarzy ... zwłaszcza dla osób o mniejszym doświadczeniu w odtwarzaniu muzyki z poziomu C++.

Mimo iż AvCpp ma do wyboru licencje BSD lub LGPL to należy uważać przy instalacji przy użyciu VCPKG, gdyż tamta wersja zaciąga "wirus GPL", przez co korzystając z AvCpp również musimy dostarczyć nasz kod jako GPL. Utworzyłem issue dla VCPKG aby to naprawili, ale póki to nie nastąpi trzeba budować AvCpp na własną rękę.

Budowanie SFMLa

Sposób budowania SFMLa jest opisany na stronie domowej, jednakże ja jestem zwolennikiem zastosowania managera pakietów VCPKG wraz z CMakeLists.txt.
Przy pomocy tego managera pakietów zasadniczo wystarczy zawołać (na systemie linuxowym):
git clone https://github.com/Microsoft/vcpkg.git --depth=1
./vcpkg/bootstrap-vcpkg.sh -disableMetrics

# instalacja SFMLa
./vcpkg/vcpkg install 'sfml[audio]'

# gdybyśmy chcieli jeszcze FFmpeg to można np.
./vcpkg/vcpkg install 'ffmpeg[all]'
# tylko trzeba sprawdzić, czy nie zainstalowały się nam kodeki GPL
# dlatego lepiej pojedyncze kodeki instalować

# można wyszukać dostępne konfiguracje powyższych:
./vcpkg/vcpkg search sfml
./vcpkg/vcpkg search ffmpeg

# nastepnie musimy "doinstalować" zainstalowane pakiety przez:
./vcpkg/vcpkg integrate install
# zależnie od systemu operacyjnego pojawi się nam argument do podania do CMake'a lub nie
Po dokonaniu instalacji pojawią się nam polecenia do dodania do naszego pliku CMakeLists.txt, oto przykładowy plik:
cmake_minimum_required(VERSION 3.31)

project(AudioTerminal LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)


# binary1 (just SFML)
find_package(SFML COMPONENTS system window graphics audio CONFIG REQUIRED)

add_executable(SFML_AUDIO sfml1.cpp)
target_link_libraries(SFML_AUDIO PRIVATE sfml-system sfml-network sfml-graphics sfml-window sfml-audio)

# binary2 (SFML + FFmpeg)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
    libavformat
    libavcodec
    libswresample
    libavutil
)

add_executable(SFML_FFMPEG sfml_ffmpeg.cpp)
target_include_directories(SFML_FFMPEG PRIVATE ${PROJECT_SOURCE_DIR} PkgConfig::FFMPEG)
target_link_libraries(SFML_FFMPEG PRIVATE PkgConfig::FFMPEG sfml-audio sfml-system)

Odtwarzanie dźwięku przy użyciu Qt Multimedia

Biblioteka Qt wraz z modułem Qt Multimedia niemalże spełnia wszystkie przedstawione kryteria.
Zarzutem względem niej jest to, że Qt jest olbrzymią biblioteką do robienia programów (nawet zawiera konkurencyjne implementacje wielu aspektów względem biblioteki standardowej C++), zawierająca bardzo wiele funkcjonalności multimedialnych, a my potrzebujemy jedynie odtwarzać audio. Jest na szczęście możliwe uruchomienie audio bez interfejsu graficznego GUI (w interfejsie tekstowym). Olbrzymią zaletą tej biblioteki jest to, iż nie tylko wspiera format MP3, ale również można odtwarzać dźwięk z ... filmów.

Informacje licencyjne

Różne moduły biblioteki Qt są udostępniane na różnych licencjach, m.in. licencji GPL lub LGPL. Qt Multimedia jest udostępniana na licencji LGPL, co pozwala na używanie jej w projektach zamkniętych czy nawet komercyjnych, bez konieczności udostępniania kodu źródłowego aplikacji, pod warunkiem spełnienia wymogów licencji LGPL. Wymagania te to m.in.:
  • dynamiczne linkowanie bibliotek Qt (nie statyczne),
  • poinformowanie użytkownika o używaniu Qt na licencji LGPL,
  • zapewnienie użytkownikowi możliwości wymiany bibliotek Qt (np. zastąpienia ich innymi wersjami),
  • brak modyfikacji kodu źródłowego bibliotek Qt

Uwaga: W przypadku modyfikacji kodu Qt należy udostępnić zmodyfikowany kod źródłowy biblioteki, zgodnie z wymogami licencji LGPL.

Przykładowy kod odtwarzający dźwięki z plików multimedialnych (nie tylko audio) z poziomu Qt (wersja minimalistyczna)

Poniżej znajduje się przykładowy kod, następnie wydruk z uruchomienia programu, a w dalszej kolejności opisanie aspektów charakterystycznych dla Qt. Osoby mające doświadczenie z Qt szybko się połapią. A oto kod:
C/C++
#include <QCoreApplication>
#include <QMediaPlayer>
#include <QTimer>
#include <QDebug>
#include <QAudioOutput>


int main( int argc, char * argv[ ] ) {
   
QCoreApplication app( argc, argv ); // (1)
   
   
QMediaPlayer player; // (2)
   
   
QAudioOutput audioOutput; // (3)
   
player.setAudioOutput( & audioOutput );
   
   
player.setSource( QUrl::fromLocalFile( "song.mp3" ) );
   
   
QObject::connect( & player, & QMediaPlayer::playbackStateChanged,[ & app ]( QMediaPlayer::PlaybackState state ) { // (4)
       
if( QMediaPlayer::StoppedState == state ) {
           
app.quit();
       
}
    }
);
   
   
player.play(); // (5)
   
   
return app.exec(); // (6)
}
Wydruk (występujący u mnie dla wskazanego pliku):
[mp3float @ 0x567ef54c5480] Could not update timestamps for skipped samples.
[mp3float @ 0x567ef54c5480] Could not update timestamps for discarded samples.
Jak widać po zakończeniu programu mogą się pojawić komunikaty, które są produkowane przez backendy używane przez Qt (np. GStreamer, FFmpeg, Media Foundation), których nie da się wyłączyć z poziomu Qt w multiplatformowy sposób.

Opis (bardziej dla osób nie znających Qt i tego jak ono działa):
  • QCoreApplication app( argc, argv ); /* (1) */
     - Tworzy instancję klasy, która zarządza cyklem życia aplikacji w trybie tekstowym (bez GUI). Obiekt ten zarządza główną pętlą zdarzeń.
  • QMediaPlayer player; /* (2) */
    - Tworzymy obiekt klasy odpowiedzialnej za odtwarzanie multimediów (audio i wideo). Jest to centralny obiekt do kontrolowania odtwarzania plików multimedialnych, obsługuje m.in. operacje takie jak odtwarzanie, pauza, zatrzymanie oraz kontrolę głośności. Aby odtwarzać dźwięk należy przypisać obiekt
    QAudioOutput
    , aby odtwarzać jeszcze obraz musimy przypisać jeszcze outpu dla Video.
  • QAudioOutput audioOutput; /* (3) */
    - Tworzy obiekt klasy, który jest odpowiedzialny za przesyłanie danych audio do urządzenia wyjściowego (np. głośników). Obiekt ten trzeba następnie przypisać do odtwarzacza, bo bez tego dźwięk nie zostanie odtworzony. Oddzielenie
    QAudioOutput
     od
    QMediaPlayer
     pozwala na większą elastyczność, np. zmianę ustawień dźwięku niezależnie od odtwarzacza.
  • QObject::connect( ... ); /* (4) */
    - Qt działa w ten sposób, że następuje łączenie wysyłających sygnały obiektów z czymś co nasłuchuje na konkretne sygnały od konkretnych obiektów (w nomenklaturze Qt zwanych slotami). W tym wypadku ustawiamy nasłuchiwanie na "sygnale"
    playbackStateChanged
     obiektem playera, a slotem, będącym lambdą. Lambda ta sprawdza stan
    mediaPlayera
     i w razie zatrzymania odtwarzania następuje wyjście z całej aplikacji.

Bardziej rozbudowany kod odtwarzający dźwięki z plików multimedialnych (nie tylko audio) z poziomu Qt

Poniżej ciut bardziej rozbudowany programik, który nasluchuje na więcej sygnałów, oraz kończy się po pewnym czasie (nawet przed zakończeniem odtwarzania pliku dźwiękowego):
C/C++
#include <QCoreApplication>
#include <QMediaPlayer>
#include <QTimer>
#include <QDebug>
#include <QAudioOutput>


int main( int argc, char * argv[ ] ) {
   
QCoreApplication app( argc, argv );
   
   
QMediaPlayer player;
   
   
QAudioOutput audioOutput;
   
player.setAudioOutput( & audioOutput );
   
   
player.setSource( QUrl::fromLocalFile( "song.mp3" ) );
   
   
QObject::connect( & player, & QMediaPlayer::playbackStateChanged,[ & app ]( QMediaPlayer::PlaybackState state ) {
       
if( QMediaPlayer::StoppedState == state ) {
           
qDebug() << "Playback finished.";
           
app.quit();
       
}
    }
);
   
   
/// printing extra info, the code is optional:
   
QObject::connect( & player, & QMediaPlayer::durationChanged,[ & player ]() {
       
qDebug() << "Duration (ms, 1s=1000ms):" << player.duration();
   
} );
   
QObject::connect( & player, & QMediaPlayer::errorOccurred,[ & player ]() {
       
qCritical() << "Error occurred:" << player.error() << player.errorString();
   
} );
   
QObject::connect( & player, & QMediaPlayer::mediaStatusChanged,[ ]( QMediaPlayer::MediaStatus status ) {
       
qDebug() << "Media status changed:" << status;
   
} );
   
   
player.play(); /// this is mandatory, the method does not stop program here
   
    // stop after some time in ms, the code is optional, without the code it will stop after playing entire audio file
   
QTimer::singleShot( 2000, & app, & QCoreApplication::quit );
   
   
qDebug() << "Playback started, You should hear!";
   
return app.exec();
}

Przykładowy wydruk z drugiego programu:
Playback started, You should hear!
Duration (ms, 1s=1000ms): 2795
Media status changed: QMediaPlayer::LoadedMedia
Media status changed: QMediaPlayer::BufferingMedia
[mp3float @ 0x5929f94abd80] Could not update timestamps for skipped samples.
Media status changed: QMediaPlayer::BufferedMedia

Jak widzimy patrząc na kody programów w SFMLu i w QT, to tak właściwie jeśli chcemy odtwarzać z mniej popularnych formatów lub z plików video to kod w QT jest prostrzy niż kod w SFMLu w połączeniu z kodekiem FFmpeg. Oczywiście FFmpeg obsługuje więcej formatów niż QT.

Instalacja biblioteki Qt

Instalacja biblioteki Qt zależy od zastosowania. Zakładając zastosowanie ogólne (czyli również komercyjne) multiplatformowo można zainstalować na dwa sposoby:
1. Przez użycie managera pakietów VCPKG (można skorzystać z opisu) a następnie CMake'a.
2. Przez pobranie odpowiedniego instalatora ze strony Qt.io i zaznaczenie w instalatorze Qt Multimedia (domyślnie nie musi być zaznaczony).

Opiszę ten pierwszy krok. Możemy zainstalować VCPKG (pierwsze kroki analogiczne jak wyżej), a następnie zawołać:
git clone https://github.com/Microsoft/vcpkg.git --depth=1
./vcpkg/bootstrap-vcpkg.sh -disableMetrics

# instalacja Qt Multimedia
./vcpkg/vcpkg install 'qtmultimedia[gstreamer]' # instalujemy z backendem GStreamer, który ma licencje LGPL.

# jakbyśmy chcieli zobaczyć jeszcze inne dostępne opcje (m.in. jakie backendy mają dostępne poza GStreamer)
./vcpkg/vcpkg search qtmultimedia
W rezultacie instalacji pojawi się trochę poleceń do pliku CMakeLists.txt, ale w moim przypadku nie wszystkie były konieczne, działający CMakeLists.txt (dla obydwu przykładowych kodów z QT ten sam):
cmake_minimum_required(VERSION 3.31)

project(AudioTerminal LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)


# binary3 (Qt):
add_executable(QT_MULTIMEDIA_MINIMUM qt_multimedia_minimum.cpp)

find_package(Qt6Multimedia CONFIG REQUIRED)
target_link_libraries(QT_MULTIMEDIA_MINIMUM PRIVATE Qt6::Multimedia Qt6::MultimediaPrivate)

Biblioteka MiniAudio - lekkie rozwiązanie w C

Innym rozwiązaniem, które spełnia wszystkie ww. kryteria poza tym, że jest w C (a nie C++) jest biblioteka MiniAudio, która tak właściwie jest jednym plikiem nagłówkowym na kiladziesiąt tysięcy linii:
#include <miniaudio/miniaudio.h>
, przez co jest zdecydowanie lżejsza niż SFML czy QT. W przeciwieństwie do Qt biblioteka MiniAudio ma licencje MIT-0. Jest to biblioteka do odtwarzania dźwięku, odtwarza popularne formaty muzyczne (m.in. MP3, WAV), ale nie odtwarza bezpośrednio (czyli bez dodatkowych kodeków) muzyki z plików video. Jest to kod bardziej niskopoziomowy niż wcześniejsze przykłady, ale wciąż łatwy w użyciu przez osoby mniej obeznane z programowaniem audio.

Przykładowy kod odtwarzający dźwięk z poziomu biblioteki MiniAudio

Oto przykładowy kod odtwarzający dźwięk (z dużą liczbą komunikatów debugowych):
C/C++
#define MINIAUDIO_IMPLEMENTATION
#include <miniaudio/miniaudio.h>
#include <iostream>
#include <csignal>
#include <thread>
#include <atomic>
#include <chrono>

std::atomic < bool > isPlaying { true };
ma_engine engine;
ma_sound sound;

void signal_handler( int signal ) {
   
if( SIGINT == signal ) {
       
std::cout << "\nPlayback interrupted by signal." << std::endl;
       
isPlaying = false;
   
}
}

// Cleanup functions to be registered with std::atexit
void cleanup_engine() {
   
ma_engine_uninit( & engine );
   
std::cout << "Audio engine uninitialized." << std::endl;
}

void cleanup_sound() {
   
ma_sound_stop( & sound );
   
ma_sound_uninit( & sound );
   
std::cout << "Sound uninitialized." << std::endl;
}

int main() {
   
const char * filePath = "audio.mp3";
   
   
// Register the signal handler
   
std::signal( SIGINT, signal_handler );
   
   
// Initialize the engine
   
if( ma_engine_init( nullptr, & engine ) != MA_SUCCESS ) {
       
std::cerr << "Failed to initialize audio engine." << std::endl;
       
return 1;
   
}
   
std::atexit( cleanup_engine ); // Register cleanup for engine
   
    // Initialize the sound
   
if( ma_sound_init_from_file( & engine, filePath, 0, nullptr, nullptr, & sound ) != MA_SUCCESS ) {
       
std::cerr << "Failed to load sound: " << filePath << std::endl;
       
return 1;
   
}
   
std::atexit( cleanup_sound ); // Register cleanup for sound
   
    // Get sound length
   
float durationInSeconds = 0.0f;
   
if( ma_sound_get_length_in_seconds( & sound, & durationInSeconds ) != MA_SUCCESS ) {
       
std::cerr << "Failed to get sound length." << std::endl;
       
return 1;
   
}
   
std::cout << "File: " << filePath << std::endl;
   
std::cout << "Duration: " << durationInSeconds << " seconds" << std::endl;
   
   
// Start playing
   
if( ma_sound_start( & sound ) != MA_SUCCESS ) {
       
std::cerr << "Failed to start playback." << std::endl;
       
return 1;
   
}
   
   
std::cout << "Playing: " << filePath << std::endl;
   
   
while( isPlaying ) {
       
float currentTime = { };
       
if( ma_sound_get_cursor_in_seconds( & sound, ¤ tTime ) != MA_SUCCESS ) {
           
std::cerr << "\nFailed to get current playback position." << std::endl;
           
break;
       
}
       
std::cout << "\rCurrent position: " << currentTime << " / " << durationInSeconds << " seconds" << std::flush;
       
       
if( currentTime >= durationInSeconds ) {
           
std::cout << "\nPlayback finished." << std::endl;
           
break;
       
}
       
       
std::this_thread::sleep_for( std::chrono::milliseconds( 500 ) );
   
}
}
Wydruk:
File: audio.mp3
Duration: 2.82122 seconds
Playing: audio.mp3
Current position: 2.82122 / 2.82122 seconds
Playback finished.
Sound uninitialized.
Audio engine uninitialized.

Wiele bibliotek w języku C, gdy korzystamy z pewnych obiektów to potem musimy samodzielnie sprzątać zasoby, jest to zrobione przez
std::atexit
. Poza tym nie mając mechanizmu wyjątków musimy sprawdzać kody błędów zwracana przez funkcje.

Instalacja biblioteki MiniAudio

Biblioteka ta to w zasadzie plik nagłówkowy, co prawda na kilkadziesiąt tysięcy linii, ale jednak wystarczy go wrzucić obok projektu. Alternatywnie można też skorzystać z VCPKG, tak jak wcześniej opisałem, wołając analogiczną komendę:
./vcpkg/vcpkg search miniaudio

a komendy do dodania do pliku CMakeLists.txt zostają wyświetlone na standardowe wyjście.

Wyróżnienie - QtAVPlayer

QtAVPlayer  to wysokopoziomowy odtwarzacz korzystający z biblioteki Qt, oraz implementujący wiele funkcjonalności potężnego narzędzia ffmpeg, taka wybuchowa kombinacja umożliwia m.in.:
  • dekodowanie video/audio/napisów
  • obsługa filtrów FFmpeg, nawet wielu równocześnie
  • odtwarzanie z adresu URL
  • odtwarzanie z zasobów Qt (QRC)
  • odtwarzanie obrazu z kamery na bieżąco (klatka za klatką)
  • pobieranie klatek z video
  • pobieranie napisów z klatek
  • akceperacja sprzętowa HW
  • multiplatformowość: Linux, Windows, MacOS, Android

Przykłady demonstrujące użycie tych funkcjonalności znajdują się na stronie projektu. Tam znajdują się również informacje jak użyć bibliotekę.
Sam QtAVPlayer jest na licencji MIT, chociaż używane pod spodem Qt, oraz FFMPEG mają swoje licencje. Warto się upewnić, że nie używamy kodeków na licencji GPL, o ile nie chcemy naszego kodu "zarazić" tą licencją.
Liczba commitów tego projektu, na moment pisania artykułu to 770, czyli nie spełnia on pierwszego z ww. kryteriów, natomiast projekt jest wciąż na bieżąco aktualizowany, pozostaje mieć więc nadzieję, że biblioteka ta będzie dalej rozwijana.

Dodatek - otwartoźródłowe, wieloplatformowe narzędzie do odtwarzania multimediów z konsoli

Przeszukując biblioteki znalazłem również bibliotekę musikcore, która jest niestety (prawie) nieużywalna w sposób bezpośredni, gdyż jest zakorzeniona w narzędziu musikcube. Narzędzie to stanowi ciekawą alternatywę dla znanych nam narzędzi do odtwarzania muzyki, zawiera duży wachlarz funkcjonalności, a wszystko w terminalu. Jest z nim jednak jak z VIMem - trzeba się nauczyć pewnych kombinacji klawiszy i staje się najszybszym w użyciu.

Gwarancje i ich brak

Artykuł został napisany przez osobę, która nie jest specjalistą zarówno jeśli chodzi o użycie multimediów z poziomu programowania, jak również w kwestii prawa. Artykuł może zawierać błędy w kwestii prawa, dlatego przed wyborem któregoś z zaprezentowanych rozwiązań warto sprawdzić czy licencje zostały poprawnie zidentyfikowane, oraz czy producenci bibliotek i kodeków nie zmienili licencji.

Autorzy artykułu

Autor Zakres zmian Afiliacja
Bazior Grzegorz Utworzenie artykułu Pracownik AGH w Krakowie
Rok napisania: 2025.