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.
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.
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.
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);
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.