Precedente Principale Sommario Informazioni Redazione Browser Successivo

Rubrica Programmazione Coordinamento di Francesco Sileno

Piccoli Mozart

Ispirato da un colloquio telematico con un lettore, oggi vedremo prima come far emettere allo speaker del vostro scatolotto delle note, e come voi ben saprete mettendo insieme delle note possiamo fare delle semplici melodie; quindi esamineremo un metodo primitivo per riprodurre suoni campionati.

di Francesco Sileno

NOTE
Tutti i sorgenti sono nel file ASM_SRC.ZIP - 12Kb, compresso con PkZip 2.04g, che è presente insieme al pacchetto di BETA o in alternativa nell'archivio on-line.
Sembra che Netscape sia particolarmente allergico ai sorgenti assembler (o forse io sono particolamente incapace in HTML) e ogni tanto duplichi righelli in zone casuali. Potrebbe accadere anche in questo articolo.

All'inizio era il BEEP...

Anzitutto domandatevi: come si fa a far emettere un BEEP allo speaker del PC? Forse qualcuno di voi sa che il carattere ASCII 0x07 (BELL, non a caso), combinato con una particolare funzione BIOS, può aiutarci.
Ma cosa fa questa funzione BIOS?
Tutto ha origine nel PIT, l'integrato che fa le veci di Mastro Hora nel nostro amato ammasso di circuiti.

Il PIT è famoso poichè detiene il controllo dell'IRQ0, collegato all'INT8, il timer di sistema. Ma non fa solo quello: il PIT ha a disposizione un altro paio di contatori, uno dei quali progettato proprio per operare in congiunzione con lo speaker.
Eh si, ha dei contatori: ogni frequenza, anche quella del clock, viene generata decrementando il valore di un contatore. E quando quest'ultimo raggiunge lo 0, invia un impulso a qualsiasi cosa abbia attaccata. Poichè il decremento avviene a velocità fissa, variando il valore di partenza, varia la frequenza con cui viene emesso l'impulso in uscita.
Vi accludo un estratto dall' HelpPC 2.1 di David Jurgens (lo trovate su internet, è shareware, e vale la pena prenderlo), illustrante l'anatomia e il modo d'uso del PIT.

Ma ci serve ancora qualcosa: per accendere e spegnere lo speaker, e per collegarlo al timer 2 del PIT, dobbiamo agire altrove. Precisamente, dobbiamo andare a scomodare il controller della tastiera, in quanto i 2 bit che ci interessano sono stati mappati nella porta 0x60.
Dalla Ralf Brown Interrupt List:

Bitfields for KB controller port B (system control port) [output]:
Bit(s)  Description     (Table P041)
 7      pulse to 1 for IRQ1 reset (PC,XT)
 6-4    reserved
 3      I/O channel parity check disable
 2      RAM parity check disable
 1      speaker data enable                     <--
 0      timer 2 gate to speaker enable          <--
SeeAlso: PORT 0061h-W,#P042
Attivando i bit 0 e 1 accendiamo lo speaker, e facciamo in modo che il suo ingresso venga collegato all'uscita del timer 2 del PIT.

CURIOSITA': Tenendo fede al principio per cui più le cose sono incasinate più ci si diverte, si è deciso di piazzare quei bit di controllo in avanzo in una porta qualsiasi avente bit disponibili. Il controller della tastiera ne aveva un mucchio, poichè gli hanno appioppato sia il controllo dello speaker, sia il controllo del gate A20. Questo perchè nei vecchi XT queste porte erano utilizzate da un altro integrato (il PPI), e perchè nei nuovi AT non sapevano più dove mettere il controllo per il gate A20, un importantissima opzione che permette anche ai P5 di lavorare in modo reale come un buon, vecchio, glorioso 8086.

Bene dunque, lo speaker si può accendere e collegare al timer 2. Ma in che modo esce fuori il suono? Beh, vedete (nell'estratto menzionato in precedenza) che il PIT ha varie modalità di conteggio? Vedete quella denominata MODE 3 - SQUARE WAVE RATE GENERATOR? E' proprio quella che fa al caso nostro... cambiando la frequenza dell'onda, si ottengono diverse tonalità di un segnale ad onda quadra. Un piccolo sintetizzatore, che ha sicuramente molto da invidiare ad una DX7.

In che modo la frequenza dell'onda è legata al contatore del PIT?
Anzitutto, il PIT viaggia ad una frequenza fissa di 1193181Hz, che è la stessa che otteniamo ponendo un contdown uguale ad 1.
Per avere un altra frequenza:

       1193181Hz
    -------------- = valore_contatore
    Onda_Quadra_Hz
Possiamo quindi spaziare da circa 18Hz (quando il contatore vale 0, che in realtà viene interpretato come 0x10000) a circa 1Mhz (quando il contatore vale 1), più che sufficenti per un orecchio umano, e anche per uno disumano.

Siamo a posto! Per un primo esempio, nell'archivio incluso nell'articolo, esaminate NOTE.ASM.

Qualche precisazione:

  • le frequenze per le note sono approssimate, le ho ricavate da un libro sulla Sound Blaster. Non garantisco che quello che qui chiamo DO sia effettivamente un DO, ma se non altro il rapporto tra un tono e l'altro (semitoni compresi) è valido, a orecchio.
  • l'implementazione delle ottave nella MACRO Play_Note è banale, in quanto l'aumento di un ottava significa il raddoppio della frequenza.
  • la NOTA_NULL, frequenza 0FFFFh = 65535Hz, è inaudibile, e la possiamo usare per le pause. Forse il vostro cane avrà qualcosa da ridire.
  • per fare le pause ho usato l'INT 015h, funzione 086h, che raggiunge la precisione di un microsecondo. Non che ci serva tale precisione, ma fa scena, no?
Quello che ci interessa maggiormente è:
Program_PIT MACRO Freq
    mov     bx,Freq
    mov     dx,012h
    mov     ax,034DDh
    div     bx                  [1]
    push    ax
    mov     al,0B6h
    out     PIT_CONTROL,al      [2]
    pop     ax
    out     PIT_CHAN_2,al
    xchg    ah,al
    out     PIT_CHAN_2,al       [3]
ENDM
[1] si esegue la divisione 1193181/Freq, per sapere il valore del contatore. Eventualmente si può precalcolare questo valore, se, come nel nostro caso, si usano solo determinate frequenze.
C'era un motivo per cui IO non lo avevo precalcolato, ma ora non ricordo...
[2] si programma il timer 2, scrittura di una word, mode 3, 16 bit counter. 0B6h = 10110110b
[3] si immette il valore del contatore. Prima il byte meno significativo, poi quello più significativo, visto che abbiamo a disposizione una sola porta da 8 bit per un valore a 16 bit.

Musica, maestro!

Ora possiamo sbizzarirci a comporre quello che vogliamo... prima però cerchiamo di semplificarci il lavoro.
Anzitutto, una struttura che ci permetta di rappresentare una nota, intesa come frequenza, ottava e durata.
Quindi una serie di durate 'standard', ovvero semicrome, crome, etc.
NOTA_QUARTO     EQU 200000
NOTA_SEDICESIMO EQU NOTA_QUARTO / 4
NOTA_OTTAVO     EQU NOTA_QUARTO / 2
NOTA_META       EQU NOTA_QUARTO * 2
NOTA_INTERO     EQU NOTA_QUARTO * 4

Nota    STRUC
    N_Valore    dw  ?
    N_Ottava    db  ?
    N_Durata    dd  ?
Nota    ENDS
200000 corrisponde a 0.2 secondi, se cambiate questo automaticamente cambiare tutti le altre durate, alla successiva compilazione. Ora possiamo definire una sequenza di note in questo modo:
For_Elisa       Nota <NOTA_MI, 3, NOTA_OTTAVO>
                Nota <NOTA_REd,3, NOTA_OTTAVO>
                Nota <NOTA_MI, 3, NOTA_OTTAVO>
                Nota <NOTA_REd,3, NOTA_OTTAVO>
                Nota <NOTA_MI, 3, NOTA_OTTAVO>
                    ...
For_Elisa_notes    dw  ( $-For_Elisa ) / SIZE Nota
L'ultima riga fa uso di un paio di direttive dell'assemblatore: $ indica l'offset nel segmento corrente alla posizione del carattere $, come dire
Pippo   dw offset Pippo
mentre SIZE da la dimensione in byte del dato specificato. $ - For_Elisa si traduce in offset_attuale-offset_inizio, e con la divisione sappiamo quante note ci sono nella sequenza.

Per questo esempio, fate riferimento a: ELISA.ASM, SPEAKER.MAC, SPEAKER.PRC.

Non mi sembra che ci sia nulla di complicato, se non che ovviamente non è per niente ottimizzato, e che sono ricorso a registri 32bit (EAX) per risparmiarmi un po' di intrallazzi. Si può arrivare anche ad un semplice effetto di polifonia: spezzettando ogni nota in intervalli di piccolissima durata, e suonando alternativamente i pezzettini di ogni voce. Con intervalli veramente microscpici, dovrebbe uscire fuori un effetto simile a quello che si poteva ascoltare sullo Spectrum, come anche sul gioco Elite per PC:

                VOCE 1                      VOCE 2
    ...
    Nota <NOTA_LA   ,3  ,NOTA_VELOCE>
                                Nota <NOTA_FA   ,1  ,NOTA_VELOCE>
    Nota <NOTA_LA   ,3  ,NOTA_VELOCE>
                                Nota <NOTA_FA   ,1  ,NOTA_VELOCE>
    Nota <NOTA_LA   ,3  ,NOTA_VELOCE>
                                Nota <NOTA_FA   ,1  ,NOTA_VELOCE>
    Nota <NOTA_LA   ,3  ,NOTA_VELOCE>
                                Nota <NOTA_FA   ,1  ,NOTA_VELOCE>
    Nota <NOTA_LA   ,3  ,NOTA_VELOCE>
                                Nota <NOTA_FA   ,1  ,NOTA_VELOCE>
    ...

... poi venne il TADAAA

Sfruttando quello che abbiamo appena appreso su altoparlanti e cronometri, possiamo idearci un metodo per fare riprodurre allo speaker dei suoni campionati, pur non avendo un DAC. In realtà il metodo l'ha inventato qualcun'altro tanto tempo fa, io mi limito a riproporvelo.
Non faccio come certa gente che passa per sue vecchie idee di altri.
Mentre un DAC a 8bit di una scheda sonora può darci 256 livelli di segnale, il nostro speaker può averne solo 2 (acceso/spento), e allora cosa ti ha pensato il geniaccio? Ha pensato di stabilire un rapporto di proporzionalità, per cui maggiore è il valore che ha il singolo campione, maggiore è il tempo che l'altoparlante rimane acceso.

Esaminiamo il mode 0 del PIT: dopo aver eseguito il contdown, viene generato un IRQ. In realtà, solo il timer 0 ha collegato un IRQ, gli altri non hanno nessun IRQ, ma sono collegati direttamente ad altri dispositivi. E come abbiamo visto, il timer 2 ha l'uscita collegata allo speaker.
Durante il conto l'uscita è bassa, finito il conto, e finchè non si immette un nuovo valore, rimane alta: in corrispondenza avremo che lo speaker si troverà spento durante il conteggio, acceso alla fine e fino al prossimo. Sono piuttsto convinto che questa sequenza di acceso/spento con durata variabile sia riconducibile ad una modulazione di frequenza, e un campionamento altro non è che un caso particolare della modulazione di frequenza (se ricordo bene, sennò tanto funziona lo stesso). Tenendo presente che il valore che immettiamo nel contatore fa da divisore alla frequenza base del PIT, ne consegue che per aumentare il tempo in cui lo speaker rimane acceso, dobbiamo incrementare il valore del divisore.
Forse rileggendo con calma gli ultimi due periodi, riuscirete ad intuire quello che volevo dirvi, ricordate che DURANTE il conteggio lo speaker è spento, e fino al prossimo rimane acceso, e che F=1/T.
In ogni caso ne viene una tabella di conversione sul modello:

Valore Campione Contatore Tempo Accensione Spk.
25501Tot_Time - 800ns [1]
.........
19220Tot_Time - 16us
.........
12840Tot_Time - 33us
.........
06460Tot_Time - 50us
.........
00080[2]Tot_Time - 66us

[1]Tot_Time dipende dalla frequenza di riproduzione dei campioni. Per 11025Hz, Tot_Time = 90us, secondo la formula F=1/T.
[2]Questo valore, ovvero il massimo che deve assumere il contatore, dovrebe essere determinato su calcoli in base alla frequenza di riproduzione e alla frequenza base del PIT.
In pratica però una tabella precalcolata trovata in giro, quella che uso in questi esempi, si comporta sempre meglio di una qualsiasi altra calcolata appositamente.
Devo ammettere di non essere riuscito a capire il perchè, visto che sembra tarata per lavorare intorno ai 15Khz, comunque la formula per calcolare l'intera tabella sarebbe

                         (255-valore_campione) * pit_base_freq
            contatore = --------------------------------------- + 1
                                    play_freq * 256
    
O io in realtà non ho capito nulla di questo meccanismo, o c'è un piccolo dettaglio che mi sfugge. Se per caso qualcuno capisce cosa, può scrivermi.

Va bene, facciamo qualcosa di concreto. Supponiamo di avere file campionati a 11025Hz, 8bit, mono, massimo 64k, in formato raw. Ovvero senza header, semplicemente un campione appresso all'altro.
Supponiamo di aver caricato in memoria il nostro file, e di averlo disponibile ad ES:0000

    cli
    Set_Speaker_On
    mov     cx,file_size
byte_loop:
    mov     al,es:[si]
    converti campione secondo tabella
    out     PIT_CHAN_2,al
    push    cx
    mov     cx,150h
wait_loop:
    loop    wait_loop
    pop     cx
    inc     si
    loop    byte_loop
    Set_Speaker_Off
    sti
  • il limite di 64Kb è per il problema della segmentazione in modalità reale. O meglio, è per il problema della pigriza che non mi vuol far fare troppi intrallazzi.
  • con CLI disabilitiamo gli interrupt, per evitare interferenze. Volendo esagerare, si potrebbe mascherare direttamente il PIC.
    Usando il solo CLI, leviamo di mezzo il fruscio a 18Khz del clock, se mascheriamo il PIC facciamo in modo che neanche accessi a disco o tasti premuti o qualsiasi altra cosa possa rallentarci il play.
  • la conversione si può fare usando l'istruzione XLAT. Dato l'offset della tabella di conversione, XLAT legge il valore alla posizione specificata in AL e lo sostituisce in AL stesso. Come dire che I = ARRAY[I].
  • il WAIT_LOOP non è per niente preciso, poichè dipende enormemente dalla CPU. Dovrete cambiare il valore di CX finchè non ne troverete uno adatto che vi farà sentire il file alla giusta velocità. E' questo ciclo che determina la frequenza di riproduzione, per ora.
I file da esaminare sono: PLAY.ASM, SPEAKER2.MAC, SPEAKER2.PRC, SOUND.RAW.

Ci sono un po' di curiosità in quest' ultimo esempio:

Linea di comando
il nome del file da riprodurre viene passato tramite linea di comando. Il dos la memorizza nel PSP, una struttura che crea per ogni eseguibile in esecuzione. All'offset 0x80 del PSP vi è la dimensione della linea di comando, memorizzata dall'offset 0x81, e per un massimo di 128 caratteri. Siccome vengono memorizzati anche gli spazi, si deve provvedere ad eliminarli. Ora che ci penso... anche i TAB vengono memorizzati... comunque è tutt'altro che elegante il metodo che ho usato qui, cercerò di rimediare nel prossimo...
Allocazione di memoria
il dos alloca tutta la memoria compresa nei primi 640Kb al programma avviato. Quindi, se noi vogliamo allocarne per conto nostro, dobbiamo ridurre quella che si è presa l'eseguibile e farci spazio. Qui si magheggia con l'MCB (Memory Control Block), che è un ulteriore struttura che il dos usa per ogni area di memoria allocata, e che ne contiene la dimensione in paragrafi. Magari una delle prossime volte vedremo la questione in dettaglio.
I/O su file
ho usato le funzioni dos per la manipolazione di file tramite handler. Una qualsiasi guida potrà illuminarvi, roba facile.

La prossima volta vedremo come leggere direttamente i file .WAV, ma, cosa più importante, useremo il timer di sistema per avere una frequenza di riproduzione esatta e indipendente dalla macchina.

rimandatamente,
Cthulhu

Segue estratto dall'HelpPC. Torna all'articolo
HelpPC 2.10           Quick Reference Utility     Copyright 1991 David Jurgens

              8253/8254 PIT - Programmable Interval Timer
 
        Port 40h, 8253 Counter 0 Time of Day Clock (normally mode 3)
        Port 41h, 8253 Counter 1 RAM Refresh Counter (normally mode 2)
        Port 42h, 8253 Counter 2 Cassette and Speaker Functions
        Port 43h, 8253 Mode Control Register, data format:
 
        |7|6|5|4|3|2|1|0|  Mode Control Register
         | | | | | | | +---- 0=16 binary counter, 1=4 decade BCD counter
         | | | | +-+-+----- counter mode bits
         | | +-+---------- read/write/latch format bits
         +-+------------- counter select bits (also 8254 read back command)
 
        Bits
         76 Counter Select Bits
         00  select counter 0
         01  select counter 1
         10  select counter 2
         11  read back command (8254 only, illegal on 8253, see below)
 
        Bits
         54  Read/Write/Latch Format Bits
         00  latch present counter value
         01  read/write of MSB only
         10  read/write of LSB only
         11  read/write LSB, followed by write of MSB
 
        Bits
        321  Counter Mode Bits
        000  mode 0, interrupt on terminal count;  countdown, interrupt,
             then wait for a new mode or count; loading a new count in the
             middle of a count stops the countdown
        001  mode 1, programmable one-shot; countdown with optional
             restart; reloading the counter will not affect the countdown
             until after the following trigger
        010  mode 2, rate generator; generate one pulse after 'count' CLK
             cycles; output remains high until after the new countdown has
             begun; reloading the count mid-period does not take affect
             until after the period
        011  mode 3, square wave rate generator; generate one pulse after
             'count' CLK cycles; output remains high until 1/2 of the next
             countdown; it does this by decrementing by 2 until zero, at
             which time it lowers the output signal, reloads the counter
             and counts down again until interrupting at 0; reloading the
             count mid-period does not take affect until after the period
        100  mode 4, software triggered strobe; countdown with output high
             until counter zero;  at zero output goes low for one CLK
             period;  countdown is triggered by loading counter;  reloading
             counter takes effect on next CLK pulse
        101  mode 5, hardware triggered strobe; countdown after triggering
             with output high until counter zero; at zero output goes low
             for one CLK period
 
        Read Back Command Format  (8254 only)
 
        |7|6|5|4|3|2|1|0| Read Back Command (written to Mode Control Reg)
         | | | | | | | +--- must be zero
         | | | | | | +---- select counter 0
         | | | | | +----- select counter 1
         | | | | +------ select counter 2
         | | | +------- 0 = latch status of selected counters
         | | +-------- 0 = latch count of selected counters
         +-+--------- 11 = read back command
 
        Read Back Command Status (8254 only, read from counter register)
 
        |7|6|5|4|3|2|1|0|  Read Back Command Status
         | | | | | | | +--- 0=16 binary counter, 1=4 decade BCD counter
         | | | | +-+-+---- counter mode bits (see Mode Control Reg above)
         | | +-+--------- read/write/latch format (see Mode Control Reg)
         | +------------ 1=null count (no count set), 0=count available
         +------------- state of OUT pin (1=high, 0=low)
 
 
        - the 8253 is used on the PC & XT, while the 8254 is used on the AT+
        - all counters are decrementing and fully independent
        - the PIT is tied to 3 clock lines all generating 1.19318 MHz.
        - the value of 1.19318MHz is derived from (4.77/4 MHz) and has it's
          roots based on NTSC frequencies
        - counters are 16 bit quantities which are decremented and then
          tested against zero.  Valid range is (0-65535).  To get a value
          of 65536 clocks you must specify 0 as the default count since
          65536 is a 17 bit value.
        - reading by latching the count doesn't disturb the countdown but
          reading the port directly does; except when using the 8254 Read
          Back Command
        - counter 0 is the time of day interrupt and is generated
          approximately 18.2 times per sec.  The value 18.2 is derived from
          the frequency 1.10318/65536 (the normal default count).
        - counter 1 is normally set to 18 (dec.) and signals the 8237 to do
          a RAM refresh approximately every 15ęs
        - counter 2 is normally used to generate tones from the speaker
          but can be used as a regular counter when used in conjunction
          with the 8255
        - newly loaded counters don't take effect until after a an output
          pulse or input CLK cycle depending on the mode
        - the 8253 has a max input clock rate of 2.6MHz, the 8254 has max
          input clock rate of 10MHz
 
        Programming considerations:
 
          1.  load Mode Control Register
          2.  let bus settle (jmp $+2)
          3.  write counter value
          4.  if counter 0 is modified, an INT 8 handler must be written to
              call the original INT 8 handler every 18.2 seconds.  When it
              does call the original INT 8 handler it must NOT send and EOI
              to the 8259 for the timer interrupt, since the original INT 8
              handler will send the EOI also.
 
        Example code:
 
        countdown  equ  8000h ; approx 36 interrupts per second
 
           cli
           mov  al,00110110b  ; bit 7,6 = (00) timer counter 0
                              ; bit 5,4 = (11) write LSB then MSB
                              ; bit 3-1 = (011) generate square wave
                              ; bit 0 = (0) binary counter
           out  43h,al        ; prep PIT, counter 0, square wave&init count
           jmp  $+2
           mov  cx,countdown  ; default is 0x0000 (65536) (18.2 per sec)
                              ; interrupts when counter decrements to 0
           mov  al,cl         ; send LSB of timer count
           out  40h,al
           jmp  $+2
           mov  al,ch         ; send MSB of timer count
           out  40h,al
           jmp  $+2
           sti


Copyright © 1996 Beta. Tutti i diritti riservati.
Precedente Principale Sommario Informazioni Redazione Browser Successivo