Mere om strømme

Dette afsnit dækker en del yderligere emneområder som hører sammen med strømme.

Strømtyper

Der er forskellige krav for hvordan et modul kan håndtere strømningen. For at illustrere dette, betragt følgende eksempel:

  • Skalering af et signal med en faktor to.

  • Udfører frekvenskonvertering af samplinger.

  • Dekomprimering af et runlength-kodet signal.

  • Læs MIDI-begivenheder fra /dev/midi00 og indsæt dem i en strøm.

Det første tilfælde er det enkleste: når modulet modtager 200 inddatasamplinger producerer det 200 uddatasamplinger. Det producerer kun uddata når det får inddata.

Det andet tilfælde producerer forskellige antal uddatasamplinger når det får 200 inddatasamplinger. Det afhænger af hvilken konvertering som udføres, men antallet er kendt i forvejen.

Det tredje tilfælde er endnu værre. Fra begyndelsen kan man ikke engang gætte hvor meget data som laves af 200 inddata byte (formodentlig meget mere end 200 byte, men...).

Det sidste tilfælde er et modul som aktiveres af sig selv, og sommetider laver data.

I aRts-0.3.4, håndteredes kun strømme af den første type, og de fleste ting virkede godt. Dette er formodentlig hvad du mest behøver når du skriver moduler som behandler lyd. Problemerne med de andre, mere komplekse slags strømme er, at de er svære at programmere, og at man for det meste ikke behøver funktionerne. Dette er grunden til at vi gør dette med to forskellige slags strømtyper: synkrone og asynkrone.

Synkrone strømme har følgende egenskaber:

  • Moduler skal kunne beregne data af en hvilken som helst længde, givet tilstrækkelig meget inddata.

  • Alle strømme har samme samplingsrate.

  • Funktionen calculateBlock() kaldes når tilstrækkeligt med data er tilgængelig, og modulet kan stole på at pegerne angiver data.

  • Der er ingen allokering eller afallokering der skal gøres.

Asynkrone strømme, på den anden side, opfører sig sådan her:

  • Moduler kan producere data ind imellem, eller med varierende samplingsfrekvens, eller kun hvis de får inddata fra en fil. De skal ikke følge reglen “skal kunne håndtere forespørgsler af en hvilken som helst størrelse”.

  • Asynkrone strømme for et modul kan have helt forskellige samplingsrater.

  • Udgående strømme: der er særlige funktioner til at allokere pakker, til at sende pakker - og en valgfri mekanisme til at spørge efter data som fortæller når mere data skal laves.

  • Indkommende strømme: et kald sendes når en ny pakke modtages. Man skal fortælle når man er færdig med at behandle al data i den pakke, og dette må ikke ske med det samme (man kan fortælle om det når som helst senere, og hvis alle har behandlet en pakke, bliver den frigjort/genbrugt).

Når strømme deklareres, bruges nøgleordet “async” til at angive at strømmen skal være asynkron. Så antag for eksempel at du vil konvertere en asynkron strøm af byte til en synkron strøm af samplinger. Grænsefladen ville så kunne se sådan her ud:

interface ByteStreamToAudio : SynthModule {
    async in byte stream inddata;   // den asynkrone inddatasampling

    out audio stream left,right;   // de synkrone uddatasamplinger
};

Brug af asynkrone strømme

Antag at du har bestemt dig for at skrive et modul som laver asynkron lyd. Dens grænseflade kunne se sådan her ud:

interface SomeModule : SynthModule
{
    async out byte stream outdata;
};

Hvordan sender man data? Den første metode kaldes “trykleverance”. Med asynkrone strømme sender man data som pakker. Det betyder at individuelle pakker sendes som i eksemplet ovenfor. Den virkelige proces er: allokér en pakke, fyld den, send den.

Her følger det i form af kode. Først allokerer vi en pakke:

DataPacket<mcopbyte> *packet = outdata.allocPacket(100);

Så fylder vi den:

// typekonvertering så fgets får en (char *) peger
char *data = (char *)packet->contents;

// som du ser, kan du krympe pakkestørrelsen efter allokeringen
// hvis du vil
if(fgets(data,100,stdin))
    packet->size = strlen(data);
else
    packet->size = 0;

Nu sender vi den:

packet->send();

Dette er meget enkelt, men hvis vi vil sende pakker nøjagtigt så hurtigt som modtageren kan håndtere dem, behøves en anden måde, metoden med “trækleverance”. Man beder om at sende pakker så hurtigt som modtageren er klar til at behandle dem. Man begynder med en vis mængde pakker som sendes. Mens modtageren behandler pakke efter pakke, begynder man at fylde dem igen med friske data, og sende dem igen.

Du starter det ved at kalde setPull. For eksempel:

outdata.setPull(8, 1024);

Dette betyder at du vil sende pakke via uddata. Du vil begynde med at sende 8 pakker på én gang, og når modtageren behandler nogle af dem, vil du fylde dem op igen.

Derefter behøver du at implementere en metode som fylder pakken, som kunne se sådan her ud:

void request_outdata(DataPacket<mcopbyte> *packet)
{
    packet->size = 1024;  // skal ikke være mere end 1024
    for(int i = 0;i < 1024; i++)
        packet->contents[i] = (mcopbyte)'A';
    packet->send();
}

Det er alt. Når du ikke har flere data, kan du begynde at sende pakker med størrelsen nul, som stopper trækleverancerne.

Bemærk at det er væsentligt at give metoden nøjagtigt navnet request_strømnavn.

Vi beskrev netop at sende data. At modtage data er meget enklere. Antag at du har et enkelt filter, ToLower, som helt enkelt konverterer alle bogstaver til små:

interface ToLower {
    async in byte stream inddata;
    async out byte stream uddata;
};

Dette er virkeligt enkelt at implementere. Her er hele implementationen:

class ToLower_impl : public ToLower_skel {
public:
    void process_inddata(DataPacket<mcopbyte> *inpacket)
    {
        DataPacket<mcopbyte> *outpacket = ouddata.allocPacket(inpacket->size);

        // lav om til små bogstaver
        char *instring = (char *)inpacket->contents;
        char *outstring = (char *)outpacket->contents;

        for(int i=0;i<inpacket->size;i++)
            outstring[i] = tolower(instring[i]);

        inpacket->processed();
        outpacket->send();
    }
};

REGISTER_IMPLEMENTATION(ToLower_impl);

Igen er det væsentligt at give metoden navnet process_strømnavn.

Som du kan se, så får du et kald til en funktion for hver pakke som ankommer (process_indata i vort tilfælde). Du skal kalde metoden processed() for en pakke for at angive at du har behandlet den.

Her er et implementeringstip: Hvis det tager lang tid at behandle data (dvs. hvis du skal vente på udskrift til lydkortet eller noget sådant), så kald ikke processed med det samme, men opbevar hele datapakken og kald kun processed når du virkelig har behandlet pakken. På denne måde, har afsenderne en chance for at vide hvor lang tid det virkelig tager at udføre arbejdet.

Eftersom synkronisering ikke er så behagelig med asynkrone strømme, skal man bruge synkrone strømme så ofte som muligt, og kun asynkrone hvis det er nødvendigt.

Standardstrømme

Antag at du har to objekter, for eksempel en AudioProducer og en AudioConsumer. AudioProducer har en uddatastrøm og AudioConsumer har en inddatastrøm. Hver gang du vil forbinde dem, bruger du disse to strømme. Den første brug af defaulting er at lade dig oprette forbindelsen uden at angive portene i dette tilfælde.

Antag nu at de to objekter ovenfor kan håndtere stereo, og begge har en “venstre” og “højre” port. Du vil stadigvæk skulle kunne koble dem sammen lige så let som tidligere. Men hvordan kan forbindelsesystemet vide hvilken udgang som skal kobles til hvilken indgang? Det har ingen måde at koble strømmene rigtigt sammen. Defaulting bruges så til at angive flere strømme med en vis rækkefølge. På den måde, hvis du forbinder et objekt med to standard uddatastrømme til et andet med to standard inddatastrømme, behøver du ikke angive portene, og forbindelserne gøres rigtigt.

Dette er naturligvis ikke begrænset til stereo. Hvilket som helst antal strømme kan gøres standard hvis det behøves, og forbindelsesfunktionen kontrollerer at antallet af standarder for to objekter passer sammen (med de angivne retninger) hvis du ikke angiver portene som skal bruges.

Syntaksen er den følgende: I IDL kan du bruge nøgleordet default i strømdeklarationen, eller på en enkelt linje. For eksempel:

interface TwoToOneMixer {
    default in audio stream input1, input2;
    out audio stream output;
};

I dette eksempel kommer objektet til at forvente at dets to inddataporte skal forbindes som standard. Rækkefølgen er den som angives på linjen, så et objekt som dette:

interface DualNoiseGenerator {
    out audio stream bzzt, couic;
    default couic, bzzt;
};

laver automatisk en forbindelse fra “couic” til “input1”, og “bzzt” til “input2” Bemærk at eftersom der kun er én udgang for mikseren, kommer den til at være standard i dette tilfælde (se nedenfor). Syntaksen som bruges i støjgeneratoren er nyttig til for at angive en anden rækkefølge end i deklarationen, eller til kun at vælge nogle få porte som standard. Retningen på portene på denne linje slås op af mcopidl, så angiv dem ikke. Du kan til og med blande ind- og udporte på en sådan linje, kun rækkefølgen spiller en rolle.

Der er nogle regler som følges når arv bruges:

  • Hvis en standardliste angives i IDL så skal den bruges. En forælders port kan også indgå i listen, hvad enten de var standard forælderen eller ej.

  • Ellers arves forældrenes standarder. Rækkefølgen er forælder1 forvalg1, forælder1 forvalg2..., forælder2 forvalg1... Hvis der er en fælles forfader som bruger to forældregrene, laves en sammenfletning som ligner “virtual public” ved standardens første plads i listen.

  • Hvis der stadigvæk ikke er nogen standard og en eneste strøm i en vis retning, så bruges den som standard for den retning.