Adventi naptár

Czirkos Zoltán · 2021.08.24.

A zenegép

A tegnapi írásban továbbfejlesztettük a hanggenerátort: az írás végére összeállt program nem csak egy szinuszos jelet állít elő, hanem felharmonikusokat is használ, és állítható benne a hang időbeli lefutása is. Így már el tudjuk orgonálni vagy klarinétozni a boci boci tarkát.

Írjunk most egy olyan programot, amely egy zenét játszik le – mégpedig magától. De természetesen a felhasználó által megadott adatok alapján.

Keringett egy időben a neten egy program, aminek a neve Tone Matrix volt. Ebben egy 16×16-os mátrix volt kirajzolva, amelyen a vízszintes irány volt az idő, a függőleges pedig a hangmagasság. A gép adott időközönként egy új oszlopra lépett, és az ott bejelölt hangokat szólaltatta meg. Ez fog történni a mi programunkban is – miközben persze a már szokásos módon be lehet majd állítani a hangok tulajdonságait.

1. A hangsor és a hangok

Első kérdés, hogy milyen hangokból építsük fel a zenét. Ha a tizenkét zenei hangot csak össze-vissza használjuk, abból szinte bizonyosan egy dallamtalan, disszonáns valami keletkezik. Egyik feltétel tehát, hogy jól válasszunk a tizenkét hang közül. Például ha a zongora összes billentyűje közül csak a fehéreket használjuk, azaz ezeket a hangokat:

C D E F G A H

Akkor az ún. C dúr hangsort kapjuk meg. A hangok megválasztása a zene hangulatára is hatással van (nem véletlenül hangfestő szó a hangulat!). A C dúrnak vidám hangulata van, ami már-már indulószerű is tud lenni. Ebben a hangsorban a hangok közötti távolság, félhangokban mérve: 2, 2, 1, 2, 2, 2, 1 – ami azon is látszik, hogy hol van a zongorán fekete billentyű, hol nincs.

Ezzel szemben, ha tizenkettőből máshogy választjuk ki a használt hét hangunkat, például így:

C D D# F G G# A#

Azaz a távolságok 2, 1, 2, 2, 1, 2, 2, mást kapunk. Ezeket a hangokat használva érzelmesebb, sokszor szomorkásabb dallamokat írhatunk. Ez a C moll hangsor (bár nem pont így szokták jelölni a zenészek). A hangulatokat akkor érezzük a legjobban, ha egyszerre szólaltatjuk meg a hangsorok kiemelt, három legfontosabb hangját: C, E, G a dúr esetén, és C, D#, G a moll esetén:

C, E, G

C, D#, G

A dúr és a moll hangsorban (skálában) még mindig vannak olyan egymás melletti hangok, amelyek között csak egy fél hang (kis szekund) a távolság. Ha ezeket együtt szólaltatjuk meg, kellemetlen lebegést fogunk hallani. Hagyjunk tehát ki néhány ilyet, és maradjunk ezeknél a hangoknál:

C D F G A

Ez egy pentaton (öt hangból álló) skála, amelyben a hangok közötti távolság: 2, 2, 3, 2, és 3 félhang. Miért jó ez? Mert bármit választunk ezek közül, sosem lesz disszonáns. Nem véletlenül a legegyszerűbb dalok ezt használják, és a zenében tanítani is ezt szokták először. Ezek lesznek a programban.

2. A szintetizátor

A szintetizátor a tegnapitól alig különbözik. Kicsit egyszerűbb lett: nem tud fázismodulációt, és az ADSR burkológörbének is csak a felfutás (attack) és elengedés (release) fázisa van meg. Erre az egyszerűsítésre azért volt szükség, mert a másik két fázisnak nem nagyon lett volna itt értelme: a felhasználó ebben a programban a hangok hosszát nem tudja megadni, csak a megszólalásuk időpontjait specifikálja a mátrixszal. Az állapotgép, amely a hangerőt kezeli, is ennek megfelelő: az indító jelre a felfutás fázisban feltekeri a hangerőt maximumra, és átvált elengedésbe, ahol meg visszamegy nullára. Ha esetleg mindez több ideig tartana, mielőtt az új indító jel jön, akkor annak hatására szintén felfutás fázisba ugrik:

A hangszínen egy okos trükkel nagyon sokat lehet változtatni. Ha a generált szinusz hullámunkat egy nemlineáris függvénnyel etetjük meg, akkor az eredeti szinusz jelentősen eltorzul. Az alábbi rajzon a hiperbolikus tangens, és az ezzel a függvénnyel torzított szinusz látható:

Az így keletkezett függvények nem írhatók le egyetlen szinusszal, hanem csak sok szinusz összegeként (lásd a tegnapi írást), tehát a nemlineáris torzítás felharmonikusok garmadáját hozza be. Valahogy így működnek a gitártorzítók is: a gitár hangját keresztülviszik egy olyan áramköri elemen, amelynél nem lineáris a feszültség és az áram közötti összefüggés.

Az újdonság még a tegnapi programhoz képest, hogy ennek a hangja sztereó. Minden megszólalt hanghoz véletlenszerűen sorsol egy hangerőt a bal vagy a jobb oldalt preferálva. Sőt kis visszhangosítás is van benne. A visszhanghoz egyszerűen eltárolja a régebbi hangot egy tömbben, és az aktuális mintához hozzákeveri (persze gyengítve). A szintetizálást végző függvényben sb a bal oldali, sj a jobb oldali minta:

/* visszhang hozzaadasa */
sb = sb + visszhang[2*visszhanghol] * 0.1;
sj = sj + visszhang[2*visszhanghol+1] * 0.1;
visszhang[2 * visszhanghol] = sj;   /* forditva! */
visszhang[2 * visszhanghol+1] = sb;

Egy zenei hang szintetizálásának menetét az alábbi ábra foglalja össze. Itt látszik, hol, milyen sorrendben és mi történik onnantól kezdve, hogy a szinuszok megszületnek.

3. A program működése

A program felhasználói felülete ugyanarra az eszközkészletre (widget.c) épül, mint a tegnapi. Kicsit tovább kellett fejleszteni, új widgetekre volt szükség: egy olyan gombra, amellyel a hangot ki-be lehet kapcsolni, és villanni is tud; továbbá egy szövegbeviteli mezőre, hogy meg lehessen kérdezni a felhasználótól a betöltendő fájl nevét.

A szövegbeviteli mező nem egy igazi widget, amely reagál az egérkattintásokra, hanem inkább csak egy függvény: az input_text()-et meghívva a képernyő adott helyén megjelenik a mező, és egészen addig semmilyen más felhasználói bemenetre nem reagál a program, amíg a bevitelt záró entert meg nem kapja. A függvény belseje tulajdonképpen az SDL-es írás input_text() függvényének egy átdolgozott változata, saját eseményvezérelt hurokkal.

Meg kellett oldani, hogy a szöveg bevitele után eltűnjön a szövegbeviteli mező. Ezért lett a widget.c modulnak egy minden_widget_ujrarajzol() függvénye. Ez trükkös, mert ez a függvény tulajdonképpen nem rajzol újra semmit, hanem csak az események (kattintás, billentyű, egérmozgás stb.) várakozási sorába betesz egy MINDENT_UJRARAJZOL típusú eseményt, amit aztán az esemenyvezerelt_main() előbb-utóbb fel fog dolgozni:

enum { MINDENT_UJRARAJZOL = SDL_USEREVENT + 1 };

/* olyan esemenyt tesz be a sorba, amelynek hatasara minden widget ujra lesz rajzolva */
void minden_widget_ujrarajzol(void) {
    SDL_Event ev = { MINDENT_UJRARAJZOL };
    SDL_PushEvent(&ev);
}

A minden_widget_ujrarajzol() maga nem is látja az egyes widgetek adatait (a pointereket, amelyek arra mutatnak), így meg se tudná hívni a rajzoló függvényeket, az esemenyvezerelt_main() viszont biztosan látja azokat, így oda delegálható ez a feladat. Ehhez a fenti konstanssal egy saját típusú eseményt adunk meg. Az SDL-ben az esemény típusa egy egyszerű egész szám, és a dokumentáció szerint az SDL_USEREVENT feletti számok szabadon használhatóak.

Hasonlóan működik ez akkor is, amikor egy fájlból betöltődik a zenedarab. A szintetizátor tulajdonságainak (időállandók, felharmonikusok stb.) átállítása után újra kell rajzolni a csúszkákat. Itt a nem túl szép (de legalább egyszerű) megoldás szerepel a programban: ez is meghívja a minden_widget_ujrarajzol() függvényt. A csúszkák kódja egyébként úgy lett módosítva, hogy a csúszka maga szándékosan nem is tárolja el az értékét (0 és 1 közötti valós szám), hanem inkább egy pointert tárol, hogy hol van az a double szám, amellyel össze van kötve. Így ha a felhasználó kattint rá, akkor bele tudja írni a megváltozott értéket, de ha a programból változik a szám, akkor is látja az új értéket az újrarajzoláskor:

Widget *uj_csuszka(int x, int y, int szeles, int magas, double *valtoztatott);

Ez tipikus probléma egyébként a felhasználói felületeknél. A program lényegét, belsejét (jelen esetben a szintetizátort és a zenegépet) mindig igyekszünk úgy megírni, hogy az legyen minél jobban elválasztva a felhasználói felületet adó kódtól. Ez azonban sokszor nagyon nehéz, mert a felhasználónak a legváltozatosabb helyeken próbálunk meg hozzáférést biztosítani a program belsejéhez, hiszen épp az az egésznek a lényege, hogy adatokkal (inputtal) láthassa el a programot, és láthassa a kimenetét (output).

Előkerült ez megoldandó problémaként magánál a mátrixnál is. A zenegép számára a mátrix egy 16×16-os, logikai (igaz/hamis) értékekből álló tároló, amelynek minden eleme azt mutatja, az adott időben meg kell-e szólaljon az adott magasságú hang. Választhatjuk azt a megoldást, hogy a zenegép ezt a mátrixot tárolja:

typedef struct ZeneGep {
    Szinti *sz;
    double tempo;
    int fazis;
    int hang[FazisMax][16];       /* hang[fazis][magassag] */
} ZeneGep;

Ekkor azonban gondban leszünk a zene „léptetésekor”. Bár a szintetizátornak jelezni tudjuk, hogy mely hangoknak kell megszólalnia, a felhasználói felület felé már nem látunk ezeken az adatokon keresztül: nem tudjuk animálni a gombokat, hogy azok egy felvillanással mutassák a hang megszólalását, mivel nem látjuk a gombokat jelképező változókat. A programban szereplő, megint csak egyszerű, de nem túl szép megoldás tehát a következő: tároljuk a zenegépet jelképező struktúrában a gombok pointereit (azaz a felhasználói felület elemeit :(), mert akkor mindent meg tudunk oldani:

typedef struct ZeneGep {
    Szinti *sz;
    double tempo;
    int fazis;
    Widget *gomb[16][16];       /* gomb[fazis][magassag] */
} ZeneGep;

Lehetne erre szebb és általánosabb megoldást is találni, de az bonyolultabb lenne ennél.

A zene léptetése függvény így elég egyszerűvé válik. A zenegép fázisa egy 0 és 15 közötti szám, amely az aktuális ütemet tárolja. A léptetésnél az előző ütem gombjainak villanását ki kell kapcsolni, utána pedig a következőknél bekapcsolni az átszínezést, és persze elindítani a hangot is:

/* ez a fuggveny "lepteti" a zenet, es allitja be az uj lejatszando
 * hangokat. az esemenyvezerelt mainbol fog meghivodni, az idozito
 * fuggveny altal betett sdl_userevent hatasara. */
void zene_leptet(SDL_Event *event, void *zgv) {
    ZeneGep *zg = (ZeneGep *) zgv;

    /* elozo fazis */
    for (int y = 0; y < 16; ++y) {
        zg->gomb[zg->fazis][y]->adat.villanogomb.villan = 0;
        widget_ujrarajzol(zg->gomb[zg->fazis][y]);
    }
    /* uj fazis (leptetes) es villantas */
    zg->fazis = (zg->fazis + 1) % 16;
    for (int y = 0; y < 16; ++y) {
        zg->gomb[zg->fazis][y]->adat.villanogomb.villan = 1;
        widget_ujrarajzol(zg->gomb[zg->fazis][y]);
        if (zg->gomb[zg->fazis][y]->adat.villanogomb.allapot)
            zg->sz->hangok[y].indit = 1;
    }
}

Ezt a függvényt kell meghívni adott időközönként egy időzítőből. Egy SDL-es időzítőben viszont nem szabad kirajzolás függvényeket hívni (mert külön szálban fut, de erről majd Szoftlab 3-on lesz szó), ezért ott a szokásos módon csak egy eseményt szúrunk be a várakozási sorba:

enum { ZENET_LEPTET = SDL_USEREVENT + 2 };

Uint32 idozit(Uint32 ms, void* zgv) {
    ZeneGep *zg = (ZeneGep *) zgv;
    SDL_Event ev = { ZENET_LEPTET };
    SDL_PushEvent(&ev);
    return 600 - zg->tempo*500;   /* ujabb varakozas (ms) */
}

Ezáltal persze megint az esemenyvezerelt_main()-ben találjuk magunkat, hiszen végül minden esemény az ottani eseményvezérelt hurokban köt ki. Most megint összefonódik a felhasználói felület és a zenegép alkalmazásunk logikája: a felhasználói felületet kezelő kódban kellene valami olyat elvégezni, ami a zenegéphez tartozik. Ilyennel már találkoztunk, a tegnapi programban a billentyűk lenyomásakor kellett olyan feladatot elvégezni, ami nem tartozott a felhasználói felülethez szorosan, hanem inkább a szintetizátor alkalmazáshoz. Mivel látjuk, hogy ez a feladat gyakran előkerül, adjunk erre most egy általánosabb megoldást. Ez a következő. A felhasználói felület működését biztosító esemenyvezerelt_main() függvény számára be tudunk regisztrálni eseményeket, és hozzájuk tartozó függvényeket, az alábbi függvény hívásával:

void callback_regisztral(SDL_EventType eventtype,
                         void (*callback_fv)(SDL_Event *, void *),
                         void *callback_fv_param);

Ennek jelezzük, hogy ZENET_LEPTET típusú esemény keletkezésekor meg kell hívni a zenet_leptet() függvényt, és átadni neki a zenegépet:

callback_regisztral(ZENET_LEPTET, zene_leptet, &zg);

Az események kezelésekor pedig az esemenyvezerelt_main() a saját dolgainak elvégzése mellett megnézi azt is, regisztráltunk-e be hívandó függvényeket az egyes eseményekhez (billentyű megnyomás, egér mozdulat stb.) Aztán ha igen, meghívja:

if (felhasznaloi_callback[i].callback_fv != NULL
    && felhasznaloi_callback[i].eventtype == ev.type)
    felhasznaloi_callback[i].callback_fv(&ev, felhasznaloi_callback[i].callback_fv_param);

4. A forráskód

A forráskód pedig: advent23-infoc_zenegep.zip. Linuxosoknak van benne egy Makefile. Akik Code::Blocksolnak, be kell tenniük egy SDL projektbe (Project / Add files), az alap SDL projekt main.c-je helyett. A zip tartalmazza a forráskódot, és egy példa fájlt.