Adventi naptár

Boci boci tarka

Most egy újabb trilógia következik, ezúttal a hangokról.

A mai feladat: állítsunk elő hangot! Rajzolja ki a program egy zongora billentyűit. Az egy oktávos billentyű gombjait a számítógép billentyűzetéről lehessen megnyomni. A billentyűzet qwerty sora a fehér, a 23567 sora pedig a fekete billentyűknek feleljenek meg.

1. A hangok természete

Eddig hangról nem volt szó még itt az adventi naptárban sem. Ez nem azt jelenti, hogy nagyon bonyolult dologról lenne szó – legalábbis az alapjai nem bonyolultak sem fizikailag, sem programozási szempontból. Az SDL-ben ez is nagyon egyszerűen meg van oldva: ha jelezzük egy-két függvényhívással, hogy hangot szeretnénk megszólaltatni, kapunk egy puffert, egy tömböt, ahova a hangot meghatározó adatokat kell írni. Minden alkalommal, amikor az adatok elfogynak, az SDL meghívja az általunk megadott függvényt, amelynek az a feladata, hogy az újabb adag adatot előállítsa, azzal feltöltse a puffert – és ennyi, már szól is a hang. A kérdés csak az, hogy mivel kell ezt a tömböt feltölteni, hogy ne csak zajt halljunk.

A hang fizikailag valamilyen közeg, egy tárgy vagy a levegő rezgése. Levegő esetén a légnyomás növekedéséről és csökkenéséről, azaz a levegő sűrűsödéséről és ritkulásáról van szó, amelyet a fülünk érzékel. A légnyomásváltozások mértéke adja a hang erősségét, az időbeli változásuk sebessége pedig a hang magasságát.

Egy hang felvételekor ezeket a sűrűsödéseket-ritkulásokat kell rögzíteni elektromos úton egy mikrofonnal. Lejátszásakor fordított irányban történik ugyanez, egy hangszóró mozgatja meg ugyanolyan ütemben a levegőt, ahogyan az eredetileg is történt. Itt egy létező hang rögzítéséről van szó, ahogyan egy fényképezőgép is egy létező képet rögzít. Megfelelő feszültség-idő függvény előállításával azonban szintetizálni is tudunk hangot (ahogyan a „semmiből” képet is állíthatunk elő a programunkban).

A matematikailag legegyszerűbb periodikus függvény a szinusz. A fentiek alapján egy szinuszos légnyomásváltozás amplitúdója határozza meg a hallott hang erősségét, frekvenciája pedig a magasságát. A zenei A hangnak a 440 Hz-es rezgés felel meg. Ez így hangzik:

A gyorsabb rezgéseket magasabb hangnak, a lassabb rezgéseket pedig alacsonyabb hangnak halljuk. A 440×2 = 880 Hz-es hangra azt mondjuk, hogy egy oktávval magasabban van, mint a 440 Hz-es:

A következő ilyen hangköz azonban nem a 440×3 = 1320 Hz:

Hanem a 440×4 = 440×2×2 = 1760 Hz-es:

Az összes emberi érzékszervhez hasonlóan a fül hangmagasság-érzékelése is logaritmikus: mindig az előző hanghoz képest kétszeres frekvenciájút hallunk ugyanakkora (itt: oktáv) hangköznek. Ez talán annyira nem meglepő, hiszen az ábrán láthatóan az 1760 Hz viszonyul úgy a 880-hoz, mint a 880 a 440-hez: hogy egy egész periódus fér be abba az időbe a nagyobbiknál, mint amekkorába a kisebbiknél csak egy fél.

Az egy oktávos hangközt a zenében a legtöbbször tizenkét félhangra bontjuk. Az egyes hangközök között a fentiek alapján frekvenciában nem az összeadás, hanem a szorzás teremti meg a kapcsolatot. Ez egy mértani sorozat: a szomszédos, eggyel magasabb félhangra mindig egy szorzással tudunk tovább jutni: a szorzó tényező pedig 122 kell legyen, mivel ennek a tizenkettedik hatványa a kettő, és tizenként félhang az oktáv. Ezeket a félhangokat jelöljük a zenében a C, C#, D, D#, E, F, F#, G, G#, A, A#, H betűkkel (ahol az A a 440 Hz-es). A hangszerek jelentős részén ezek meg is találhatóak. Például a zongora minden billentyűjének egy ilyen hang felel meg, a gitár minden bundjánál lefogva egy húrt úgy rövidíthető meg annak a hossza, hogy a megfelelő rezgésszámot produkálja. Más hangszereknél nincsen ilyen segítség, egy hegedűnél a zenésznek kell tudnia, hogy hol fogja le a húrt. A harsonánál is hasonló a helyzet: a zenész „lövi be” fülre, hogy milyen hosszúra kell nyújtania a csövet a kihúzásával, hogy a megfelelő frekvenciájú rezgés keletkezzen benne. Ez jó hallás és gyakorlás kérdése.

Tegyük fel, hogy két forrásból hallunk hangot. Ilyenkor a két forrás által keltett hangnyomás egyszerűen összeadódik. Ha az egyik hang az A (piros, 440 Hz), a másik hang az A# (zöld 440×122 ≈ 440×1,059 Hz), egy ilyen függvény (kék) adódik:

Látható, hogy a kis frekvenciakülönbség miatt a két hang néha erősíti egymást (amikor mindkettő ép növekszik, azaz fázisban vannak), máskor meg gyengítik egymást (amikor egyik nő, másik csökken, ellentétes fázisban vannak). Az erősítés-gyengítésnek is van egy periodicitása, ami matematikailag is kifejezhető a sin x + sin y-ból:

                      x+y     x-y
sin x + sin y = 2 sin --- cos ---
                       2       2 

Ebben megjelenik a két frekvencia különbsége szorzóként, amely a fenti példában 0,059×440 = 25,96 Hz. Ezt a jelenséget lebegésnek hívják. Ezt egy vibrálásnak érzékeljük, ami nem túl kellemes a fül számára. A két hang disszonáns, nem szól jól együtt. Ha kellően nagy a frekvenciák különbsége, akkor nem hallunk ilyesmit.

Adódik itt azonban egy kis probléma. A 122 szám irracionális. Mozduljunk el egy alaphangtól hétszer fél hanggal, azaz egy kvinttel. Vegyük tehát a szorzó hetedik hatványát: (122)7≈1,49831, ami majdnem 1,5, de nem pontosan annyi. Két ilyen hang összege is, alig hallhatóan ugyan, de lebeg. Viszont ha az 1,49831-es szorzó helyett inkább 1,5-et használunk, azaz egy tiszta hangközt, nem történik ilyen. Hasonló a helyzet a nagy tercnél, négy félhang távolságnál: a szorzó (122)4≈1,2599, ami helyett meg szebb hangzású a pontosan 1,25, azaz 5/4-del helyettesített tiszta hangköz.

A tiszta és az egyenletes hangközök közötti különbség hallatszik kicsit. Alább két szinuszokból szintetizált akkord (több hang együtt), amelyben a C, E, és G betűkkel jelölt hangok szólalnak meg egyszerre. Az egyenletesen temperált, azaz 122 hatványait használó hangoknál az akkord kicsit lebeg, míg a tiszta hangközöknél (4/3 és 3/2) nem. Ha nem hallatszik, próbáld fejhallgatóval!

C, E, G – egyenletes hangközökkel

C, E, G – tiszta hangközökkel

Ebből adódik a hangszerek hangolásának az alapproblémája. Ha meg szeretnénk szüntetni a lebegést, akkor a (122)n hatványsor minden számát egy hozzá közel álló racionális számmal kell helyettesíteni, pl. az n=7-ediket 3/2-del (1,49831≈3/2=1,5), vagy az n=5-ödiket 4/3-dal (1,3348≈4/3=1,3333). Ha így teszünk, akkor viszont nem lesz az igaz például az, hogy 12 kvint (12×7) 7 oktávot (7×12) ad, mert 1,512, a 12 kvint frekvenciaszorzója 129,74634, míg a 7 oktávé csak 27=128.

Az évszázadok alatt sok mindennel próbálkoztak ennek a problémának a megoldására. Manapság leginkább maradunk az irracionális arányoknál, azaz a 122 hatványainál. Ez az egyenletesen temperált hangolás. Ebben minden félhang között pontosan ugyanakkora különbség van, szemben azzal, mintha a fenti módon, kis egész számok hányadosával, racionális számokkal helyettesítenénk ezeket. Az egyenletes közök előnyösek: ezek teszik lehetővé azt, hogy egy zenedarabot transzponáljunk, azaz valahány hanggal magasabban játsszunk el, anélkül, hogy a jellege nagyon megváltozna.

Ennyit a frekvenciákról és a hangközökről. Ez csak azért kellett, hogy érthetőek legyenek a programba beírt számok, 1/12-edik hatványok. A programban ugyanis a billentyűkhöz egy frekvenciát rendelünk, amilyen rezgésszámú szinuszt meg kell szólaltatni az adott billentyű nyomva tartásakor.

typedef struct Hang {
    SDL_Keycode sym;         /* billentyuje */
    double frek;        /* a frekvenciaja */
    double hangero;     /* a hangero, 0->1 és 1->0 valtoztatva, hogy ne pattogjon */
    bool szol;          /* epp szol-e */
} Hang;

Hang hangok[] = {
    { SDLK_q, 440.0 * pow(2, -9 * 1.0/12.0) },   /* C */
    { SDLK_2, 440.0 * pow(2, -8 * 1.0/12.0) },   /* C# */
    { SDLK_w, 440.0 * pow(2, -7 * 1.0/12.0) },   /* D */
    { SDLK_3, 440.0 * pow(2, -6 * 1.0/12.0) },   /* D# */
    { SDLK_e, 440.0 * pow(2, -5 * 1.0/12.0) },   /* E */
    { SDLK_r, 440.0 * pow(2, -4 * 1.0/12.0) },   /* F */
    { SDLK_5, 440.0 * pow(2, -3 * 1.0/12.0) },   /* F# */
    { SDLK_t, 440.0 * pow(2, -2 * 1.0/12.0) },   /* G */
    { SDLK_6, 440.0 * pow(2, -1 * 1.0/12.0) },   /* G# */
    { SDLK_y, 440.0 * pow(2,  0 * 1.0/12.0) },   /* A */
    { SDLK_7, 440.0 * pow(2,  1 * 1.0/12.0) },   /* A# */
    { SDLK_u, 440.0 * pow(2,  2 * 1.0/12.0) },   /* H */
    { SDLK_i, 440.0 * pow(2,  3 * 1.0/12.0) },   /* C' */
    { SDLK_UNKNOWN }    /* tomb veget jeloli */
};

2. A program működése

A számítógépen nem tudunk időben folytonos jeleket feldolgozni. Mindig van egy legkisebb idő; illetve mindig van egy legkisebb jelváltozás is. Ez nincs másképp a hangoknál sem. Egy hang felvételekor nem a folytonos függvényt tároljuk, hanem egy másodpercben több ezer mintát veszünk belőle, és nem valós, hanem egész számokat tárolunk.

Ha túl alacsony a másodpercenkénti mintavételek száma (vagyis túl sok az elhanyagolás az x tengely mentén), akkor a visszaállított hang tompa lesz. Ha pedig a mintákat pontatlanul tároljuk, akkor zajos, sercegős hangot kapunk. A CD minőség másodpercenként 44100 darab mintát jelent, a DVD minőség ennél még egy kicsit többet, 48000-et. A programban 44100 Hz-es mintavételezésű hangot fogunk előállítani, 16 bites értékekkel. Csatornánk csak egy lesz: a hang nem sztereó, nincs külön hang a bal és a jobb fül számára, hanem csak monó.

Az SDL-ben a SDL_OpenAudio() függvénnyel lehet elindítani a hanglejátszást. Ennek egy SDL_AudioSpec struktúrában kell megadni a keltett hang paramétereit:

void hang_init(void *userdata) {
    audiospec.freq = 44100;                /* 44100Hz - CD minoseg, 48000 - dvd minoseg */
    audiospec.format = AUDIO_S16SYS;       /* 16-bit elojeles; a rendszer bajtsorrendjevel */
    audiospec.channels = 1;                /* mono */
    audiospec.samples = audiospec.freq/50; /* puffer merete - 1/50 sec */
    audiospec.callback = hang_callback;    /* sdl hivja, ha ujabb adag hang kell neki */
    audiospec.userdata = userdata;         /* egy pointert atad mindig a callbacknek */
    if (SDL_OpenAudio(&audiospec, NULL) < 0) {
        fprintf(stderr, "Hiba a hanggal: %s\n", SDL_GetError());
        exit(1);
    }
}

Meg kell adni egy pufferméretet is, ez a programban 1/50 másodpercnyi hang. Ez azt jelenti, hogy ekkora adatmennyiséget vár az SDL. Ha elfogy lejátszás közben az adat, akkor a megadott függvényt meghívja (ez a hang_callback() függvény). Ennek a dolga, hogy a következő pufferméretnyi hanganyagot előállítsa.

A hang kiszámításának menete nagyon egyszerű. Először is, tudni kell, hogy mennyi az idő. A t változó tárolja a programban az időt. Ezt minden minta kiszámolása után meg kell növelni dt-vel, amit pedig abból lehet kiszámolni, hogy mekkora részét teszi ki egy másodpercnek egy hangminta:

double dt = 1.0/audiospec.freq;       /* ennyi masodperc hangmintankent */
t += dt;
A hang_callback() függvény kiszámolja a hangok adott pillanatbeli értékét a szokásos sin ωt képlet alapján, ahol ω a hang körfrekvenciája (ω = 2πf, mert a szinusz függvény 2π alatt tesz meg egy periódust):
s_ez = sin(hangok[i].frek * 2 * 3.14159265 * t);

Az egyes hangokhoz tartozó mintát a hangerejükkel megszorozva kell hozzáadni az összesített értékhez. Ezáltal a többféle hang összemixelődik több gomb nyomva tartása esetén:

s += s_ez * hangero * hangok[i].hangero;

Egy dologra kell még figyelni: a hangok ki- és bekapcsolásuk pillanatakor nem szabad azonnal megváltoztatni a hangerejüket nulláról maximálisra, mert különben zavaró recsegést, pattanást fogunk hallgatni (click). Ezért a gomb megnyomásakor a program nem hirtelen, 40 mintányi idő alatt növeli maximálisra a hangerőt (1/0.025 lépésben), gomb elengedésekor pedig a hangerő ugyanígy csökken:

double const hangositas = 0.025;

if (hangok[i].szol) {
    if (hangok[i].hangero < 1)
        hangok[i].hangero += hangositas;
} else {
    if (hangok[i].hangero < hangositas)
        hangok[i].hangero = 0;
    if (hangok[i].hangero > 0)
        hangok[i].hangero -= hangositas;
}

3. A program

A forráskód letölthető innen: advent21-bociboci.c. Szükséges hozzá az SDL használata, amelyhez az útmutató az Érdekességek menüpont alatt megtalálható.

Az y helyén a program működik a z gombbal is, zenei A hangot keltve, hogy magyar billentyűzeten is jó legyen. Teszt bemenet: qeqett qeqett iuztrz trewqq. Adventi hangulathoz pedig: wuztw ww wuzte :D