InfoC adventi naptár
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. Az elkészített programból a zenék kimenthetők lesznek fájlba, és mindenki feltöltheti a szerzeményt ide a portálra!
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.
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 gy 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 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; int y; /* elozo fazis */ for (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 (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ás.
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
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.cpp-je helyett. A zip tartalmazza a forráskódot, és egy példa fájlt.
Letölthető zenék
A letöltött fájlokat a Code::Blocks-ból indítás esetén a projekt mappájába kell tenni. A programban hibakezelés nem sok, úgyhogy nem fog szólni (a zene sem), ha nem találja a fájlt.