Programación embebida no bloqueante mediante máquinas de estado en C++. Caso de uso con el chip de sonido SN76489 
El uso de máquinas de estado en un recurso casi obligatorio a la hora de implementar código no bloqueante, para dotar a los sistemas de algún tipo de funcionalidad multi-tarea o simplemente como mecanismo de ahorro de energía. En este post se estudiará un caso de uso: la programación del chip de sonido SN76489, mediante esta técnica de desarrollo y utilizando parte del potencial del lenguaje C++ moderno para ello.

Descripción funcional

El objetivo es hacer un pequeño montaje basado en el ARM Cortex-M3 STM32F103C8T6 como MCU y en el chip de sonido SN76489 (3 canales de tono más un canal de ruido, utilizado en las antiguas consolas de Sega y en algunas placas de recreativas de los años 80 y 90).



Dicho montaje incluye dos pulsadores externos: uno para generar un sonido de disparo "láser" y otro para generar un sonido de metralleta (o parecido, dentro de las limitaciones del SN76489).

Sonido

El SN76489 es un chip de sonido extremadamente sencillo de programar: un bus de 8 bits con señales /WE y /CE (que en este caso se han cortocircuitado) y una señal READY de salida. Como se puede ver en el circuito anterior, el SN76489 se alimenta a 5 voltios, mientras que el STM32 se alimenta a 3.3 voltios, lo que, a priori puede resultar un problema de cara a la señal READY. Dicho problema, sin embargo, no lo es tal ya que, según la hoja de datos del fabricante, el pin READY del SN76489 es una salida en colector abierto, por lo que puede atacar perfectamente a una entrada del STM32 siempre y cuando la resistencia de pull-up correspondiente esté a 3.3 voltios.

Como se puede ver en su hoja de datos, para enviarle un byte, es necesario hacer los siguiente pasos:

1. Esperar a que la salida READY se ponga a 1 (colector abierto), lo que indica que el chip puede recibir un comando.

2. Colocar el byte que se quiere enviar en el bus de 8 bits.

3. Poner /CE y /WE a 0.

4. Esperar a que la salida READY se ponga a 0 (a masa), lo que significa que está procesando el comando

5. Esperar a que la salida READY se ponga de nuevo a 1 (colector abierto), lo que significa que el comando ha sido procesado y el chip está preparado para recibir más comandos.

Esta secuencia hay que realizarla por cada comando (byte) que se le quiera enviar al SN76489.

A la hora de diseñar la máquina de estados se ha utilizado el formalismo de la máquina de Mealy. En este formalismo, los estados están caracterizados sólo por sus etiquetas, mientras que las transiciones están caracterizadas por el par (entrada | salida), en este caso, "entrada" será la condición de entrada para que el proceso tome por ese arco y la "salida" serán las operaciones a realizar en ese salto:



"condición" puede ser vacía, lo que significará que siempre se toma ese arco, y "operaciones" también podrá ser vacía, lo que significará que no se ejecuta ninguna operación al tomar ese arco.



En la máquina de estados se han introducido estados adicionales al final que permiten introducir retardos entre en envío de un comando y el siguiente. Esta funcionalidad es muy útil para controlar los tiempos: tiempo de caida de los tonos de "láser", tiempo entre "balas" de la metralleta, etc. "tc" es la variable miembro "tickCounter" usada para esperas forzosas (el STM32 va más rápido que el SN76489) mientras que "dt" se corresponde con la variable miembro "delayTicks", usada para hacer las esperas entre el envío de un comando y el envío del siguiente.

Cada objeto SN76489 incluye una cola de comandos: las funciones miembro que hacen de API se encargan de preparar y meter en la cola (push) los comandos que será posteriormente enviados por la función miembro "tick" (la encargada de ejecutar la máquina de estados):

void SN76489::setToneGeneratorFrequency(uint8_t index, uint16_t frequency, int16_t sweepFrequencyIncrement, int16_t sweepTicksPerIncrement, int16_t sweepNumIncrements) {
    if (index > 2)        // 0, 1 or 2
        index = 2;
    frequency &= 0x03FF;  // 10 bit value
    this->txQueue.push({(uint8_t) (0x80 | (index << 5) | (frequency & 0x0F))});
    this->txQueue.push({(uint8_t) ((frequency >> 4) & 0x3F), sweepTicksPerIncrement});
    while (sweepNumIncrements > 0) {
        frequency += sweepFrequencyIncrement;
        sweepNumIncrements--;
        this->txQueue.push({(uint8_t) ((frequency >> 4) & 0x3F), sweepTicksPerIncrement});
    }
}


void SN76489::setToneGeneratorAttenuation(uint8_t index, uint8_t attenuation) {
    if (index > 2)        // 0, 1 or 2
        index = 2;
    this->txQueue.push({(uint8_t) (0x80 | (index << 5) | 0x10 | (attenuation & 0x0F))});
}


void SN76489::setNoiseGeneratorFrequency(NoiseGeneratorFrequency frequency, NoiseGeneratorFeedback feedback) {
    this->txQueue.push({(uint8_t) (0xE0 | ((static_cast<uint8_t>(feedback) & 0x01) << 2) | (static_cast<uint8_t>(feedback) & 0x03))});
}


void SN76489::setNoiseGeneratorAttenuation(uint8_t attenuation, int32_t delayTicks) {
    this->txQueue.push({(uint8_t) (0xF0 | (attenuation & 0x0F)), delayTicks});
}

Mientras que la función miembro "tick" deberá ser invocada desde el SYSTICK del microcontrolador (que se ejecuta varios cientos de veces por segundo). Dicha función miembro "tick" es la encargada de ejecutar la máquina de estados y de enviar los comandos al SN76489 de forma correcta.

Para separar la implementación de esta máquina de estados del hardware se define una clase abstracta "SN76489Interface", que deberá ser implementada por el "usuario" y que es la que en última instancia escribe en el bus de datos del SN76489, escribe el bit /WE y lee el bit READY del SN76489.

class SN76489Interface {
    public:
        virtual void sn76489SetByte(uint8_t v) = 0;
        virtual void sn76489SetWE(uint8_t v) = 0;
        virtual bool sn76489GetReady() = 0;
};

Cuando se invoca la función miembro "init" del objeto "SN76489", se le pasa un puntero a un objeto de tipo "SN76489Interface", para que el objeto "SN76489" pueda comunicarse con un hardware real a través de este interface.

Cada elemento de la cola de comandos es un par (comando, ticksRetardo), de tal manera que la máquina de estados (la función miembro "tick") envía el comando y espera una cantidad de ticks equivalente a "ticksRetardo" antes de enviar el siguiente comando de la cola (si lo hubiese). De esta manera es muy sencillo hacer los sonidos propuestos:

- Láser: Se configura el generador de tono 0 con una frecuencia alta, se espera un par de ticks, se envía una frecuencia más baja, se espera otro par de ticks, se envía otra fecuencia más baja, etc. Esta caida de frecuencia en el tiempo genera un efecto psicoacústico "percutido", como de disparo. Al final de la secuencia se pone el volumen el mínimo del generator tono 0.

- Metralleta: Se configura el generador de ruido y se manda 17 veces la secuencia: volumen máximo con espera de 20 ticks y silencio con espera de 30 ticks. Esta alternancia de ruido/silencio repetidas veces genera el sonido de "metralleta".

Nótese que los comandos descritos para cada sonido se empujan en la cola de comandos de forma simultánea uno detrás de otro, es la máquina de estados (función miembro "tick" de la clase "SN76489") la que se encarga de enviar los comandos y realizar la espera entre comando y comando a medida que va sacando comandos de la cola.

Teclado

De forma adicional, es necesario implementar un pequeño teclado de dos botones, de manera de un botón dispare el "láser" y el otro la "metralleta". Para este menester creamos también una clase Keyboard pero en este caso, como dicha clase sí va a estar vinculada directamente con un recurso hardware (esta es una decisión arbitraria), será una clase estática (con todas sus funciones miembro estáticas).

La función miembro "init" configurará dos pines GPIO y el controlador de interrupciones externas ETXI del STM32 para que genere una interrupción cada vez que se produzca un flanco de bajada en esos dos pines del microcontrolador. Como se puede comprobar en la declaración de la clase Keyboard, la función miembro estática "interrupt" se coloca en la sección ".exti" dentro del código objeto: esto hará que el linker script la coloque en el lugar adecuado para que se invoque al producirse una interrupción de GPIO.

#ifndef  __KEYBOARD_H__
#define  __KEYBOARD_H__


#include <stdint.h>


extern "C++" {
    namespace avelino {
        using namespace std;

        class KeyboardListener {
            public:
                virtual void buttonPressed(int8_t key) = 0;
        };

        class Keyboard {
            protected:
                static KeyboardListener *listener;
                static int8_t currentKey;
                static int32_t tickCounter;
                enum class Status : int8_t { IDLE, WAIT_AFTER_PRESSED };
                static Status status;
            public:
                static void init(KeyboardListener *listener);
                static void interrupt()  __attribute__ ((section(".exti")));
                static void tick();
        };
    }
}


#endif  // __KEYBOARD_H__

La función miembro "interrupt" hace muy poco: simplemente actualiza la variable miembro "key" y limpia los "pending bits" del microcontrolador para que la interrupción pueda volver a dispararse en el futuro. Uno puede pensar que con esto ya sería, de por sí, suficiente pero lo cierto es que no es así, ya que al tratarse de entradas desde el exterior, son propensas a experimentar rebotes y ruido, por lo que se hace necesario hacer "limpieza" de esa señal mediante una sencilla máquina de estados. Es la función miembro "tick" (invocada, como su homónima de SN76489, periódicamente desde el SYSTICK del microcontrolador) la encargada de ejecutar la máquina de estados que procesa los valores de esta variable "key".



Como la interrupción se dispara sólo ante flancos de bajada, lo único que hace la máquina de estados es simplemente esperar un tiempo prudencial (200 ejecuciones de ticks) antes de volver al estado de espera a que se detecte una nueva pulsación. De esta forma se eliminan las pulsaciones espúreas que saldrían su hubiese rebote mecánico.

La función miembro "buttonPressed" de la clase "ButtonListener" (que hereda de "KeyboardListener") es invocada cada vez que se detecta la pulsación de uno de los botones y el parámetro "key" indica qué botón se ha pulsado. Es aquí donde se realizan las llamadas a la API del objeto de clase "SN76489" para encolar los comandos que generan el sonido "láser" o encolar los comandos que generan el sonido de "metralleta", en función de la tecla pulsada.

void systick() __attribute__ ((section(".systick")));


void systick() {
    sn76489.tick();
    Keyboard::tick();
}


class ButtonListener : public KeyboardListener {
    public:
        virtual void buttonPressed(int8_t key);
};


void ButtonListener::buttonPressed(int8_t key) {
    if (key == 1) {   // láser
        sn76489.setToneGeneratorAttenuation(0, 0);   // full volume
        sn76489.setToneGeneratorFrequency(0, 477, 100, 5, 10);
        sn76489.setToneGeneratorAttenuation(0, 0x0F);   // min volume
    }
    else if (key == 2) {   // metralleta
        sn76489.setNoiseGeneratorFrequency(SN76489::NoiseGeneratorFrequency::N_512, SN76489::NoiseGeneratorFeedback::WHITE);
        for (int i = 0; i < 17; i++) {
            sn76489.setNoiseGeneratorAttenuation(0, 20);
            sn76489.setNoiseGeneratorAttenuation(0xFF, 30);
        }
    }
}

Como se puede apreciar, la función global "systick", que es invocada de forma periódica por el timer SYSTICK del microcontrolador, es la encargada a su vez de invocar las funciones miembro "tick" de "SN76489" y "Keyboard".

El hecho de que todo el software esté basado en interrupciones permite generar un código más elegante y más eficiente desde un punto de vista energético: la función main sólo tiene que inicializar los objetos y variables y dormirse a la espera de que se produzcan interrupciones (ya sea por GPIO como de SYSTICK):

int main() {
    // keyboard
    Keyboard::init(&buttonListener);
    // enable systick timer
    SYST_RVR = 0x0001FFFF;
    SYST_CVR = 0;
    SYST_CSR = 7;
    // sn76489
    sn76489GPIOInterface.init();
    sn76489.init(&sn76489GPIOInterface);
    sn76489.setToneGeneratorAttenuation(0, 0xFF); // min volume for tone generator 0
    sn76489.setToneGeneratorAttenuation(1, 0x0F); // min volume for tone generator 1
    sn76489.setToneGeneratorAttenuation(2, 0x0F); // min volume for tone generator 2
    sn76489.setNoiseGeneratorAttenuation(0x0F);   // min volume for noise generator
    while (true) {
        // WFI (wait for interrupt) instruction, enters low power mode
        asm volatile ("wfi");
    }
}

Nótese el bucle infinito con la instrucción que duerme la CPU "wfi" (Wait For Interrupt).

Características de C++ aprovechadas

- Plantillas estáticas en lugar de memoria dinámica: En sistemas embebidos hay que evitar el uso de memoria dinámica y es por ello que se ha creado la plantilla "StaticQueue" que define una cola circular estática cuyos parámetros de plantilla son el tipo base y la cantidad de elementos: Esta cola estática es la utilizada para almacenar los pares (comando, ticksRetardo) que utiliza la clase SN76489.

- Enumeradores fuertemente tipados ("enum class"): Los estados en las máquinas de estado no están definidos como constantes enteras sino como tipos "enum class" que es un tipo de enumerado fuertemente tipado introducido en C++11. El uso de este tipo de datos impide, por ejemplo, que se le asigne un entero aunque su tipo base sea un entero. Además, el código generado es igual de eficiente que si se utilizasen constantes enteras y se gana mucho en claridad en el código.

A continuación puede verse un vídeo del montaje en acción (con sonido):


Todo el código fuente está disponible en la sección soft.

[ añadir comentario ] ( 2266 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 2.9 / 979 )
Sintetizador monofónico basado en FPGA: Parser MIDI mejorado y filtro paso-bajo de segundo orden 
Partiendo del montaje realizado en el post anterior, se han realizado varias modificaciones y mejoras. El parser MIDI de esta segunda iteración genera ahora 3 señales de control, de 7 bits cada una, que se utilizan para controlar la frecuencia de corte, la resonancia y la ganancia de la entrada de un filtro paso bajo de segundo orden:

Este sería el diagrama de bloques de esta segunda iteración:




Parser MIDI mejorado


En la versión iniciar el parser MIDI no se tuvieron en cuenta algunas características "raras" que se dan el algunos teclados controladores y al mismo tiempo se asumía que un "note off" posterior a un "note on" siempre era de la misma tecla, lo cual es demasiado suponer, sobre todo cuando quien toca es un humano. Cuando un humano toca una secuencia de notas en un teclado (por ejemplo: La, Mi, Do) uno puede pensar que los mensaje que manda el teclado controlador son los siguientes:
noteOn(La), noteOff(La), noteOn(Mi), noteOff(Mi), noteOn(Do), noteOff(Do)

Sin embargo lo cierto es que a veces un humano pulsa la siguiente tecla al mismo tiempo o antes de soltar la anterior:
noteOn(La), noteOn(Mi), noteOff(La), noteOff(Mi), noteOn(Do), noteOff(Do)

Con la anterior versión del parser, que asumía que un noteOff se correspondía siempre con el noteOn inmediatamente anterior, lo que ocurría era que cuando al sinte le llegaba el noteOff(La) callaba la nota Mi disparada justo antes porque asumía que ese noteOff se correspondía con dicha nota Mi. En la nueva versión del parse este noteOff(Mi) es ignorado por la máquina de estados por lo que la respuesta del sintetizador es más natural.

Para mejorar el comportamiento y la funcionalidad del parser MIDI se ha optado por un diseño basado en máquinas de estado en serie y en paralelo en lugar de una única máquina de estados grande. El parser MIDI se ha divido en dos etapas (Stage1 y Stage2), la primera etapa genera señales "KeyOn" y "KeyOff" limpias por cables separados y además implementa en paralelo una máquina de estados aparte para procesar los mensajes de "Control Change". En la segunda etapa se implementa la lógica anteriormente descrita de ignorar los "Note Off" que no se corresponden con el mensaje "Note On" inmediatamente anterior.







De esta forma, aunque aparentemente se ha complicado el diseño, se han separado los problemas y es más sencillo introducir modificaciones y depurar errores en las máquinas de estado. Cada una por separado es más sencilla y fácil de trazar que una hipotética máquina de estados única para todo.

Además de la mejora en el procesado de los mensajes "Note On" y "Note Off", este parser ya reconoce mensajes de tipo "Control Change", en concreto para tres valores prefijados de controlador: 71, 74 y 16, que se asignarán en el sintetizador a la frecuencia de corte del filtro, la resonancia del filtro y la ganancia de entrada del filtro.


Filtro paso bajo de segundo orden


Se ha optado por la implementación estándar de un filtro de estado variable (state variable filter). Se trata de un filtro de segundo orden (dos polos) que genera simultáneamente 3 salidas:

- paso bajo (con pendiente de filtrado de 12 dB/octava)
- paso alto (con pendiente de filtrado de 12 dB/octava)
- paso banda (con pendiente de filtrado de 6 dB/octava)

No son grandes pendientes de filtrado pero siempre se pueden mejorar poniendo varios filtros en cascada. La implementación que se ha utilizado es la descrita en el libro "Musical Applications of Microprocessors" de Hal Chamberlin (dicha implementación ya fue usada sobre un microcontrolador en este post). El filtro de estado variable viene determinado por el siguiente sistema de ecuaciones en diferencias finitas:

$$pasoAlto[n] = entrada - ({r \times pasoBanda[n-1]}) - pasoBajo[n]$$
$$pasoBanda[n] = ({f \times pasoAlto[n]}) + pasoBanda[n - 1]$$
$$pasoBajo[n] = ({f \times pasoBanda[n - 1]}) + pasoBajo[n - 1]$$

Siendo:

$$f = 2\sin\left({\pi F_c \over F_s}\right)$$
$$r = {1 \over Q}$$

Siendo $F_c$ la frecuencia de corte del filtro, $F_s$ la frecuencia de muestreo y $Q$ la Q del filtro (la resonancia).

Si se reordenan las ecuaciones en diferencias:

$$pasoBajo[n] = ({f \times pasoBanda[n - 1]}) + pasoBajo[n - 1]$$
$$pasoAlto[n] = entrada - ({r \times pasoBanda[n - 1]}) - pasoBajo[n]$$
$$pasoBanda[n] = ({f \times pasoAlto[n]}) + pasoBanda[n - 1]$$

Podemos olvidarnos de los índices:
pasoBajo += f * pasoBanda
pasoAlto = entrada - (r * pasoBanda) - pasoBajo
pasoBanda += f * pasoAlto

Como se puede apreciar es preciso mantener en memoria (registro) al menos las variables pasoBajo y pasoBanda entre que se procesa una muestra y la siguiente (se trata de un filtro digital de segundo orden).

Para implementar dicho filtro sobre FPGA lo que necesitaremos serán básicamente los siguientes elementos:

- Al menos tres registros en los que almacenaremos los valores "pasoBajo", "pasoBanda" y "pasoAlto" (aunque realmente podríamos no gastar un registro para "pasoAlto", lo vamos a incluir para poder disponer de esa salida en el módulo).
- Una unidad de suma con multiplicación: Un módulo combinacional que realiza la operación: A = (B * C) + D (en muchos casos D = A, por lo que se puede ver como A += B * C)
- Una máquina de estados para controlar qué operandos y operaciones se hacen en cada momento.

Con estos elemento y teniendo en cuenta las ecuaciones anteriores, podemos hacer una propuesta de secuenciación de operaciones como sigue:

1. LP := (cutoff * BP) + LP
2. HP := (0 * x ) + IN
3. HP := (-reso * BP) + HP
4. HP := (-1 * LP) + HP
5. BP := (cutoff * HP) + BP

Cada paso requiere un único ciclo de reloj por lo que bastará con implementar una máquina de estados que, por cada muestra que llegue, pase por los 5 estados de forma secuencial para que los registros LP, BP y HP (LowPass, BandPass y HighPass) tengan los valores de salida del filtro que necesitamos. Nótese que será preciso utilizar aritmética de punto fijo y en nuestro caso se ha optado por un formato Q16.16 (16 bits de parte entera y 16 bits de parte fraccionaria).

A continuación puede verse como quedaría la implementación del filtro en VHDL:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity StateVariableFilter is
    port (
        Reset       : in std_logic;
        Clk         : in std_logic;
        EnableIn    : in std_logic;
        SampleIn    : in std_logic_vector(15 downto 0);
        CutOffIn    : in std_logic_vector(31 downto 0);    -- 0..1  fixed point Q16.16
        ResonanceIn : in std_logic_vector(31 downto 0);    -- 0..1  fixed point Q16.16
        SampleOut   : out std_logic_vector(15 downto 0);
        EnableOut   : out std_logic
    );
end entity;

architecture RTL of StateVariableFilter is
    signal LPDBus : std_logic_vector(31 downto 0);
    signal LPQBus : std_logic_vector(31 downto 0);
    signal HPDBus : std_logic_vector(31 downto 0);
    signal HPQBus : std_logic_vector(31 downto 0);
    signal BPDBus : std_logic_vector(31 downto 0);
    signal BPQBus : std_logic_vector(31 downto 0);
    signal MultOperandA : std_logic_vector(31 downto 0);
    signal MultOperandB : std_logic_vector(31 downto 0);
    signal MultResult64 : std_logic_vector(63 downto 0);
    signal MultResult : std_logic_vector(31 downto 0);
    signal AddOperandB : std_logic_vector(31 downto 0);
    signal AddResult : std_logic_vector(31 downto 0);
    signal NegResonance : std_logic_vector(31 downto 0);
    signal FSMDBus : std_logic_vector(2 downto 0);
    signal FSMQBus : std_logic_vector(2 downto 0);
begin
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            LPQBus <= LPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            HPQBus <= HPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            BPQBus <= BPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            FSMQBus <= FSMDBus;
        end if;
    end process;

    NegResonance <= std_logic_vector(to_signed(-to_integer(signed(ResonanceIn)), 32));
    MultOperandA <= CutOffIn when ((FSMQBus = "001") or (FSMQBus = "101")) else
                    NegResonance when (FSMQBus = "011") else
                    std_logic_vector(to_signed(-65536, 32)) when (FSMQBus = "100") else  -- -65536 es -1 en notación Q16.16
                    std_logic_vector(to_signed(0, 32));
    MultOperandB <= LPQBus when (FSMQBus = "100") else
                    BPQBus when ((FSMQBus = "001") or (FSMQBus = "011")) else
                    HPQBus;
    AddOperandB <= LPQBus when (FSMQBus = "001") else
                   BPQBus when (FSMQBus = "101") else
                   HPQBus when ((FSMQBus = "011") or (FSMQBus = "100")) else
                   std_logic_vector(to_signed(to_integer(signed(SampleIn)), 32));
    --MultResult64 <= std_logic_vector(to_signed(to_integer(signed(MultOperandA)) * to_integer(signed(MultOperandB)), 64));
    MultResult64 <= std_logic_vector(signed(MultOperandA) * signed(MultOperandB));
    MultResult <= MultResult64(47 downto 16);
    --AddResult <= std_logic_vector(to_signed(to_integer(signed(MultResult)) + to_integer(signed(AddOperandB)), 32));
    AddResult <= std_logic_vector(signed(MultResult) + signed(AddOperandB));
    LPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when (FSMQBus = "001") else
              LPQBus;
    HPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when ((FSMQBus = "011") or (FSMQBus = "100") or (FSMQBus = "010")) else
              HPQBus;
    BPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when (FSMQBus = "101") else
              BPQBus;

    -- fsm
    --    LP += cutoff * BP
    --    HP = in - (resonance * BP) - LP
    --    BP += cutoff * HP
    FSMDBus <= "000" when ((Reset = '1') or (FSMQBus = "110")) else       --       MultOperandA   MultOperandB   AddOperandB
               "001" when ((FSMQBus = "000") and (EnableIn = '1')) else   -- LP := cutoff       * BP           + LP
               "010" when (FSMQBus = "001") else                          -- HP := 0            * x            + IN
               "011" when (FSMQBus = "010") else                          -- HP := -reso        * BP           + HP
               "100" when (FSMQBus = "011") else                          -- HP := -1           * LP           + HP
               "101" when (FSMQBus = "100") else                          -- BP := cutoff       * HP           + BP
               "110" when (FSMQBus = "101") else
               "000";
    EnableOut <= '1' when (FSMQBus = "110") else
                 '0';
    SampleOut <= std_logic_vector(to_signed(-32768, 16)) when (to_integer(signed(LPQBus)) < -32768) else
                 std_logic_vector(to_signed(32767, 16)) when (to_integer(signed(LPQBus)) > 32767) else
                 LPQBus(15 downto 0);
end architecture;


La máquina de estados espera hasta que la entrada "EnableIn" se ponga a "1", dicho evento es la señal que indica al filtro que debe realizar una iteración (i.e. calcular la siguiente muestra a partir de la entrada "SampleIn").



Todo el código está disponible en la sección soft.

[ 1 comentario ] ( 1101 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 2510 )
Sintetizador monofónico basado en FPGA: parser MIDI, oscilador y DAC básicos 
Tradicionalmente, la síntesis y el procesado de sonido digital siempre se ha delegado a nivel hardware en el uso de DSPs. El uso de FPGAs para sustituir DSPs es una tendencia actual derivada del abaratamiento de las FPGAs y de la incursión de las mismas dentro del mundo de la electrónica amateur y DIY. Actualmente una FPGA media tiene suficiente potencia para llevar a cabo múltiples operaciones DSP a una velocidad incluso mayor. El problema con las FPGAs es la forma de programarlas, que requiere un pensamiento abstracto de tipo diferente al razonamiento algorítmico tradicional que se utiliza para programar CPUs y DSPs estándar. Este post se introducirá en el diseño y la implementación de un sintetizador monofónico muy simple sobre una FPGA.

La idea

La idea de esta primera versión es implementar un sintetizador monofónico con un único oscilador de diente de sierra, que sólo lea mensajes de tipo NoteOn y NoteOff y que reproduzca el sonido a través de un DAC I2S.



Como se puede apreciar se trata del típico circuito de entrada MIDI con optoacoplador más un PCM5102A como DAC I2S de alta calidad. Los mensajes MIDI de NoteOn se traducen en tonos que genera el oscilador.

El interfaz de salida I2S para el DAC externo

El protocolo I2S es un estándar definido para transportar sonido digital a muy cortas distancias (dentro de una misma placa, por ejemplo). Es estándar de facto en casi la totalidad de los conversores DAC y ADC de alta calidad del mercado de todos los fabricantes y se trata de un protocolo relativamente ligero y fácil de implementar.


(imagen © Texas Instruments Incorporated, extraida con permiso de la hoja de datos del PCM5102A)

Existe una variante del I2S denominada "Left Justified" que simplifica el uso del reloj LR, evitando el desfase de un bit entre el envío de cada palabra para el canal izquierdo y el canal derecho:


(imagen © Texas Instruments Incorporated, extraida con permiso de la hoja de datos del PCM5102A)

Y que es la variante I2S que se ha usado en este proyecto ya que es más fácil de implementar que el estándar original y actualmente todos los DACs del mercado la soportan. A continuación puede verse lo que sería el diagrama de bloques de la interfaz I2S-LJ dentro de la FPGA:



Las diferentes tablas de verdad de cada uno de los bloques combinacionales serían las siguientes:

EntradasSalidas
ClkOutDivider == 22ResetMUXcod
00+
010
1X0


EntradasSalidas
ClkOutDivider == 22ClkOutDivider == 10ResetMUXco
1010
0101
en otro casoClkOut


EntradasSalidas
ClkOutDivider == 22ResetMUXbc
X10
10+ mod 32
en otro casoBitCounter


EntradasSalidas
BitCounter < 16ResetMUXlrco
000
011
1X1


EntradasSalidas
ClkOutDivider == 22BitCounter == 31MUXdata
0Xdata
10<<
11muestra izq + der


Como se puede apreciar el mecanismo se basa en meter en un registro de desplazamiento de 32 bits las dos palabras de 16 bits de cada canal (izquierdo + derecho) e ir emitiendo bit a bit ese registro cambiando la polaridad de la señal LRCLK cada 16 bits para indicar canal izquierdo o canal derecho.

El oscilador

El oscilador se ha implementado como un sencillo acumulador de fase.



Como lo que se busca es un oscilador de diente de sierra, lo más sencillo es aprovechar el comportamiento natural de cualquier acumulador que, cuando se desborda "da la vuelta". Esto simplifica enormemente todo el diseño ya que, de forma natural, la señal resultante tiene forma de diente de sierra.


(imagen de dominio público extraida de Wikipedia)

Por cada nueva muestra que debe ser calculada, el acumulador es incrementado en una cantidad determinada, lo que provoca que su valor crezca de forma lineal (la rampa del diente de sierra). Al cabo de una cantidad suficiente de muestras, el acumulador se desbordará y "dará la vuelta" empezando de nuevo desde abajo (el "pico" del diente de sierra).

La cantidad que se use para ir incrementando el acumulador de fase determinará la frecuencia de la señal del oscilador:

$$DivisorFrecuenciaRelojI2S = {{32000000 Hz \over 44100 Hz} \over 32 bits}$$

$$inc = {{f \times 65536} \over {{32000000Hz \over DivisorFrecuenciaRelojI2S} \over 32 bits}} \times 65536$$

El incremento (inc) debe estar en formato Q16.16 (punto fijo de 16 bits de parte entera y 16 bits de parte fraccionaria), que es el formato usado por el acumulador de fase del oscilador.

Nótese que el oscilador no se incrementa en cada ciclo de reloj de la FPGA, sino cada vez que se requiere una nueva muestra por parte de la interfaz I2S-LJ para emitirla al DAC.

El parser MIDI

El módulo de procesamiento MIDI se encarga de implementar un receptor UART sencillo a 31250 baudios y una máquina de estados que vaya leyendos los datos MIDI de entrada y determinando en cada momento si hay que reproducir una nota en el oscilador (y con qué frecuencia) o no.

La UART se implementa de forma muy sencilla usando un registro de desplazamiento y un contador para medir el tiempo equivalente a 1.5 bits y a 1 bit.



Y usando la siguiente máquina de estados:



En una entrada anterior de este blog se abordó este proyecto de forma separada. Lo que se ha hecho en este caso ha sido simplificar aquel esquema para que cupiese todo dentro de un único fichero VHDL.

Una vez implementado el receptor UART, el parser MIDI se puede implementar mediante una sencilla máquina de estados que sólo detecte eventos NoteOn y NoteOff.



El parser MIDI en este caso no sólo determina qué nota debe ser reproducida, sino que usando una ROM interna, determina el valor de incremento que debe ser usado por el módulo oscilador para generar el tono correspondiente.

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity NotesRom is
    port (
        AddressIn : in std_logic_vector(6 downto 0);
        DataOut   : out std_logic_vector(31 downto 0)
    );
end entity;

architecture RTL of NotesRom is
    type RomType is array (0 to 127) of std_logic_vector(31 downto 0);
    constant Data : RomType := (
        x"00000000",   -- note 0
        x"000cdf51",   -- note 1
        x"000da345",   -- note 2
        x"000e72df",   -- note 3
        x"000f4ed1",   -- note 4
        x"001037d7",   -- note 5
        x"00112eb9",   -- note 6
        x"00123449",   -- note 7
        x"00134966",   -- note 8
        x"00146efe",   -- note 9
        x"0015a60b",   -- note 10
        x"0016ef97",   -- note 11
        . . .
        . . .
        . . .
        x"368d1251",   -- note 122
        x"39cb7a59",   -- note 123
        x"3d3b4348",   -- note 124
        x"40df5cc9",   -- note 125
        x"44bae33a",   -- note 126
        x"48d12253"    -- note 127
    );
begin
    DataOut <= Data(to_integer(unsigned(AddressIn)));
end architecture;

Para generar este conjunto de valores se hizo un pequeño programa en C++ que convirtió el valor de cada nota MIDI en el valor de incremento correspondiente para que el oscilador emita a esa frecuencia:

#include <iostream>
#include <iomanip>
#include <stdint.h>
#include <math.h>

using namespace std;

double getFreq(uint8_t midiNote) {
    const double A4_FREQ = 440;
    const int32_t A4_MIDI_NOTE = 69;
    return A4_FREQ * pow(2.0, ((double) (((int32_t) midiNote) - A4_MIDI_NOTE)) / 12.0);
}

uint32_t getInc(uint8_t midiNote) {
    const uint32_t CLK_FREQ = 32000000;
    const uint32_t SAMPLE_RATE = 44100;
    double freq = getFreq(midiNote);
    double div = (((double) CLK_FREQ) / SAMPLE_RATE) / 32;
    double inc = (freq * 65536) / ((CLK_FREQ / div) / 32);
    uint32_t ret = round(inc * 65536);
    return ret;
}

int main() {
    for (uint8_t n = 0; n < 128; n++)
        cout << "\t\tx\"" << hex << setw(8) << setfill('0') << getInc(n) << "\",   -- note " << dec << setw(0) << setfill(' ') << ((int) n) << endl;
    return 0;
}

Compilando este programa y ejecutándolo, genera en la salida estándar los valores de incremento de todas las 127 notas MIDI posibles:

g++ -c -o notes_rom_generator.o notes_rom_generator.cc
g++ -o notes_rom_generator notes_rom_generator.o
./notes_rom_generator


Todo junto

A la hora de ponerlo todo junto, basta con interconectar los tres bloques:





Implementación sobre cualquier FPGA

La implementación se ha desarrollado sobre una Spartan3E de Xilinx a 32 MHz pero el proyecto se puede meter en cualquier FPGA siempre y cuando se ajusten las ecuaciones y las constantes para tener en cuenta las diferentes frecuencias de reloj. En caso de que queramos meter el sintetizador en una FPGA que vaya a otra frecuencia de reloj habría que realizar los siguientes cambios:

1. Las constantes CLK_OUT_DIV y CLK_OUT_DIV_BITS de LJI2SOutput.vhd deben se recalculadas.

2. Las constantes TIME_COUNTER_BITS, TIME_COUNTER_1BIT y TIME_COUNTER_1_5BIT de UartRx.vhd deben ser recalculadas.

3. La constante CLK_FREQ dentro de notes_rom_generator.cc debe ser cambiada, hay que recompilar el programa y colocar la salida generada como los nuevos valores de NotesRom.vhd.

Todo el código fuente puede descargarse de la sección soft.

[ añadir comentario ] ( 2315 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 2517 )
Implementación de un MIDI shuffler sobre Arduino 
El efecto "shuffle" o "swing" es un efecto muy utilizado en producción musical para humanizar y meter mas "groove" a canciones reproducidas por un secuenciador. El efecto consiste básicamente en adelantar o atrasar el disparo de determinadas notas durante algunos milisegundos para dar sensación de "humanidad" a la cadencia de la música. A lo largo de este post se abordará la implementación en C++ sobre Arduino de un "shuffler" MIDI para secuencias 4/4.

La forma más estándar de "shuffle" en secuencias musicales de 4/4 es la que consiste en retrasar una cantidad de tiempo determinada (milisegundos) la segunda y la cuarta semicorchea después de cada negra:

*----.----.----.----*----.----.----.----*----.----.----.----*----.----.----.---- Compás 4/4 estándar
*------.--.------.--*------.--.------.--*------.--.------.--*------.--.------.-- Compás de 4/4 con "shuffle"

Los asteriscos determinan las negras (4 negras por cada compás de 4/4) y los puntos determinan las semicorcheas (4 semicorcheas por cada negra). El concepto es muy sencillo, aunque a la hora de implementarlo en MIDI hay que tener en cuenta algunos aspectos importantes.

Protocolo MIDI

El protocolo MIDI es un protocolo muy sencillo por el que se envían eventos e información musical. No es objetivo de este post el explicar el protocolo ni los mensajes MIDI (cualquier búsqueda sobre "midi protocol" en la red nos dará acceso a centenares de páginas donde lo explican muy bien) aunque sí nos centraremos en los mensajes que más nos interesan de cara a implementar nuestro shuffler.

Dentro de los mensajes MIDI hay unos especiales denominados de tiempo real que son transmitidos por los secuenciadores cuando están reproduciendo una secuencia MIDI pregrabada:

0xF8: "timing clock" se envía 24 veces por cada negra.
0xFA: "start" indica que se va a iniciar la reproducción de una secuencia. Este mensaje es seguido de forma inmediata por el primer 0xF8.
0xFB: "continue" indica que se reanuda la secuencia por donde se paró.
0xFC: "stop" indica que se para la secuencia.

Por tanto, si en nuestro secuenciador musical tenemos una canción con un tempo de 120 negras por minuto, al emitir dicha secuencia por un cable MIDI, de forma intercalada con los mensaje de activación y desactivación de las notas y demás, irán entremezclados mensajes 0xF8 a razón de 24 por cada negra, es decir:

$${{120 \times 24} \over 60} = 48\;mensajes/segundo$$

Nótese que la cantidad de mensajes 0xF8 enviados por unidad de tiempo no depende de la velocidad de transmisión MIDI, sino del tempo de la secuencia musical que se esté reproduciendo. Si cada vez que nos llegue un mensaje 0xF8 desde el secuenciador vamos contando de 0 a 23 dando la vuelta de nuevo a 0 cada vez que llegamos a 24 tenemos que los mensaje 0xF8 coinciden en el tiempo con las negras y semicorcheas de la forma que indica la siguiente tabla:

n                 s                 s                 s
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23


En esta tabla se puede ver que la negra (el beat) coincide con el contador de mensajes 0xF8 recibidos a 0 mientras que las tres semicorcheas siguientes coinciden con ese mismo contador a 6, a 12 y a 18. Ahora tenemos una base de tiempo sólida que podemos aprovechar para implementar nuestro efecto shuffle: Lo que hay que hacer es atrasar en el tiempo los mensajes de "note on" y "note off" que lleguen entre el instante 6 y el 12 y entre el instante 18 y 0 de la siguiente negra.

n                 s                 s                 s
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
+--------> atrasar +--------> atrasar


Dicho atraso no puede ser tal que nuestro shuffler emita notas fuera de orden por lo que el retraso en el tiempo debe ser proporcional (una nota que llegue entre los instantes 6 y el 7 será atrasada más que una que llegue entre los instantes 9 y 10 pero la primera nunca debe emitirse depués de la segunda, debemos garantizar el orden de llegada de los eventos "note on" y "note off").

Algoritmo propuesto

El MIDI shuffler se plantea como un filtro MIDI, un dispositivo con una entrada MIDI y una salida MIDI que se intercala entre el secuenciador y los sintetizadores. La salida MIDI del secuenciador irá conectada a la entrada MIDI del shuffler y la salida MIDI del shuffler irá conectada a la entrada MIDI de los secuenciadores. A continuación se plantea una propuesta de pseudocódigo para el MIDI shuffler:

iniciarShuffler
estado := ESPERAR_START_MIDI
fin iniciarShuffler

getInstanteAtrasado(t)
ret := (tamSemicorchea - tamReducido) + ((t + tamReducido) / tamSemicorchea)
devolver ret
fin getInstanteAtrasado

byteMIDIRecibido(byte)
enviar := SÍ
si (estado = ESPERAR_START_MIDI) entonces
si (byte = 0xFA) entonces
colaRetraso.borrar()
estado := ESPERAR_PRIMER_CLOCK_MIDI
fin sin
en otro caso, si (estado = ESPERAR_PRIMER_CLOCK_MIDI) entonces
si (byte = 0xF8) entonces
estado := ESPERAR_CLOCK_MIDI
contadorReloj := 6
indiceSemicorchea := 0
timer.iniciar()
fin si
en otro caso, si (estado = ESPERAR_CLOCK_MIDI) entonces
si (byte = 0xF8) entonces
contadorReloj := contadorReloj - 1
si (contadorReloj = 0) entonces
si ((indiceSemicorchea = 0) ó (indiceSemicorchea = 2)) entonces
tamSemicorchea = timer.getValor()
tamReducido = (temSemicorchea * (100 - PERCENT)) / 100
fin si
contadorReloj := 6
indiceSemicorchea := (indiceSemicorchea + 1) mod 4
timer.parar()
timer.iniciar()
fin si
en otro caso, si (esEventoNota(byte) y ((indiceSemicorchea = 1) ó (indiceSemicorchea = 3)) entonces
t := getInstanteAtrasado(timer.getValor())
colaRetraso.meter({byte, t})
enviar := NO
en otro caso, si (byte = 0xFC)
estado := ESPERAR_START_MIDI
fin si
fin si
si (enviar = SÍ) entonces
enviar(byte)
fin si
fin byteMIDIRecibido

principal
siempre hacer
si ((indiceSemicorchea = 1) ó (indiceSemicorchea = 3)) entonces
t := timer.getValor()
mientras (colaRetraso.hayAlgo()) hacer
d := colaRetraso.getCabeza()
si (d.t <= t) entonces
colaRetraso.sacar()
enviar(d.byte)
en otro caso
salir del bucle
fin si
fin mientras
fin si
fin siempre
fin principal

Lo que hace el algoritmo es aprovechar el intervalo entre el midi clock 0 y el 5 para calcular el tiempo en unidades de timer que dura una semicorchea. El objeto "timer" es un timer de bastante resolución que se arranca en el instante 0 y se para en el instante 6. En ese instante 6, una vez parado el timer, se anota la cuenta del mismo como tamSemicorchea (para indicar que es el tamaño en ticks de nuestro contador de lo que dura una semicorchea) y se calcula tamReducido a partir del porcentaje de "shuffle" que queramos (un shuffle del 0% da un tamReducido = tamSemicorchea, mientras que un shuffle del 100% da un tamReducido = 0).

instante  semicorchea   acción
0 0 Iniciar timer de alta resolución
1
2
3
4
5
6 1 Anotar cuenta del timer, pararlo
7 y volver a iniciarlo. Encolar cualquier
8 evento "note on" o "note off" que llegue
9 en este intervalo calculando su instante
10 de emisión con una regla de tres.
11
12 2 La misma que la semicorchea 0
13
14
15
16
17
18 3 La misma que la semicorchea 1
19
20
21
22
23

Entre los instantes 6 y el 11 lo que se hace es encolar los eventos de "note on" y "note off" que vayan llegando calculándoles en el momento que llegan, en qué instante del tick del timer deben ser transmitidos haciendo una regla de tres (en getInstanteAtrasado) y metiendo cada una de estas parejas de valores (byte e instante que debe ser transmitido) en la cola "colaRetraso".

Lo mismo se hace para los instantes de tiempo 12 al 17 y 18 al 23, respectivamente.

Ya tenemos los eventos atrasados metidos en una cola (para garantizar que el orden de emisión sea el mismo que el de recepción), ahora lo que hay que hacer es emitirlos en el instante que corresponda. y de esto se encarga el procedimiento principal en su bucle infinito. Este procedimiento principal ejecuta un bucle infinito que lo que hace es inspeccionar si hay algo que enviar en la cola "colaRetraso", si hay algo que debe ser enviado (su instante de envío es menor o igual al valor actual del timer) lo envía y lo quita de la cola. El procedimiente byteMIDIRecibido es invocado cada vez que llega un byte por el puerto MIDI.

El circuito

El MIDI shuffler, como se comentó antes, hace de filtro MIDI con una entrada y una salida. La cantidad de efecto shuffle se controla mediante un potenciómetro conectado a una de las entradas analógicas del Arduino.

Con el potenciómetro al mínimo se aplica un efecto shuffle del 0% (sin efecto shuffle) mientras que con el potenciómetro al máximo se aplica un efecto shuffle del 50% (valores superiores al 50% genera unos resultados muy extremos).

Implementación en C++

A pesar de que en el algoritmo propuesto el procedimiento byteMIDIRecibido se supone que es invocado de forma asíncrona por el sistema cada vez que llega un byte por el puerto MIDI, lo cierto es que es más sencillo si en la rutina de interrupción de la UART encolamos los bytes MIDI que van llegando por la entrada MIDI y luego los vamos sirviendo en el bucle principal antes de comprobar el estado de la colaRetraso, haciéndolo de esta forma evitamos colisiones y la necesidad de hacer que colaRetraso sea reentrante.

int32_t MIDIShuffler::getDelayedInstant(int32_t sourceInstant) {
    return ((this->sixteenthNoteLength - this->reducedLength) + ((sourceInstant * this->reducedLength) / this->sixteenthNoteLength));
}


void MIDIShuffler::byteReceived(uint8_t byte) {
    this->rxQueue.push(byte);
}


void MIDIShuffler::processRxByte(uint8_t byte) {
    bool send = true;
    uint8_t noChannelByte = byte & 0xF0;
    if (this->status == STATUS_WAIT_START_MIDI_CLOCK) {
        if (byte == 0xFA) {
            this->delayQueue.clear();
            this->rxQueue.clear();
            this->status = STATUS_WAIT_FIRST_MIDI_CLOCK;
        }
    }
    else if (this->status == STATUS_WAIT_FIRST_MIDI_CLOCK) {
        if (byte == 0xF8) {
            this->status = STATUS_WAIT_MIDI_CLOCK;
            this->clockCounter = CLOCK_PER_SIXTEENTH_NOTE;
            this->sixteenthNoteIndex = 0;
            this->timeCounter->start();
        }
    }
    else if (this->status == STATUS_WAIT_MIDI_CLOCK) {
        if (byte == 0xF8) {
            this->clockCounter--;
            if (this->clockCounter == 0) {
                if ((this->sixteenthNoteIndex == 0) || (this->sixteenthNoteIndex == 2)) {
                    this->sixteenthNoteLength = this->timeCounter->getValue();
                    this->reducedLength = (this->sixteenthNoteLength * (100 - this->percentProvider->getPercent())) / 100;
                }
                this->clockCounter = CLOCK_PER_SIXTEENTH_NOTE;
                this->sixteenthNoteIndex = (this->sixteenthNoteIndex + 1) & 3;    // ... % 4
                this->timeCounter->stop();
                this->timeCounter->start();
            }
        }
        else if ((noChannelByte < 0xA0) && ((this->sixteenthNoteIndex == 1) || (this->sixteenthNoteIndex == 3)) && !this->byPass) {
            DelayedMIDIByte d(this->getDelayedInstant(this->timeCounter->getValue()), byte);
            this->delayQueue.push(d);
            send = false;
        }
        else if (byte == 0xFC)
            this->status = STATUS_WAIT_START_MIDI_CLOCK;
    }
    if (send && (this->sender != NULL))
        this->sender->sendByte(byte);
}


void MIDIShuffler::init(MIDISender &sender, PercentProvider &percentProvider, TimeCounter &timeCounter) {
    MIDIFilter::init(sender);
    this->percentProvider = &percentProvider;
    this->delayQueue.clear();
    this->rxQueue.clear();
    this->status = STATUS_WAIT_START_MIDI_CLOCK;
    this->timeCounter = &timeCounter;
    this->byPass = false;
    this->sixteenthNoteIndex = 0;
}


void MIDIShuffler::run() {
    if (this->rxQueue.hasElements()) {
        uint8_t byte = this->rxQueue.getHead();
        this->processRxByte(byte);
        this->rxQueue.pop();
    }
    if (((this->sixteenthNoteIndex == 1) || (this->sixteenthNoteIndex == 3)) && this->delayQueue.hasElements()) {
        int32_t t = this->timeCounter->getValue();
        while (this->delayQueue.hasElements()) {
            DelayedMIDIByte d = this->delayQueue.getHead();
            if (d.t <= t) {
                this->delayQueue.pop();
                if (this->sender != NULL)
                    this->sender->sendByte(d.byte);
            }
            else
                break;
        }
    }
}

A continuación puede verse un vídeo con el MIDI shuffler en acción (obviamente, hay que poner el audio para que se oiga :-) )



Todo el código fuente puede descargarse de la sección soft.

[ añadir comentario ] ( 2033 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 13791 )
Cálculo de la curva de carga de un condensador en un circuito RC 
A lo largo de este post se desarrollará el cálculo de la curva de carga de un condensador en una red RC estándar: se trata de un post muy cortito pero intenso y espero que interesante. Se va a hacer una aproximación desde cero, planteando el problema inicial, las ecuaciones y obteniendo la solución tanto de forma analítica como numérica.

El circuito

Partimos de un circuito estándar RC. En el que R, C y $V_i$ son constantes. El objetivo es calcular la curva de la salida $V_o$ con respecto al tiempo:



La ecuación

Si asumimos que a través de la salida no se consume corriente, la corriente que circula por la resistencia:
$$I={V_i - V_o \over R}$$
Será igual a la corriente que circula por el condensador:
$$I=C{dV_o \over dt}$$
Igualando ambas expresiones obtenemos la ecuación básica de este circuito:
$${V_i - V_o \over R}=C{dV_o \over dt} \Rightarrow {V_i \over RC} - {V_o \over RC}={dV_o \over dt}$$

Solución analítica

Se trata de una ecuación diferencial lineal de primer orden:
$$y^\prime+p(x)y=q(x)$$
$$ y(x_0)=y_0 $$
Ya que si cambiamos el orden de los operandos:
$${dV_o \over dt}+{1 \over RC}V_o={V_i \over RC}$$
Se ve fácilmente que:
$$y=V_o$$
$$x=t$$
$$p(x)={1 \over RC}$$
$$q(x)={V_i \over RC}$$
Por tanto se puede calcular en valor de $V_o$ en función de $t$ usando la forma estándar de resolución de este tipo de ecuaciones diferenciales:
$$V_o=e^{-\int_{0}^{t}p(s)ds}\left[ V_o(0)+\int_{0}^{t}q(s)e^{\int_{0}^{s}p(v)dv}ds\right]$$
Siendo $V_o(0)$ el valor de $V_o$ en el instance inicial (0 si el condensador está totalmente descargado). Como tenemos que:
$$\int_{0}^{w}p(t)dt=\int_{0}^{w}{1 \over RC}dt={1 \over RC}\int_{0}^{w}dt={1 \over RC}\left[t\right]_{0}^{w}={w \over RC}$$
Entonces podemos reescribir la ecuación de $V_o$ como:
$$V_o=e^{-{t \over RC}}\left[V_o(0)+\int_{0}^{t}{V_i \over RC}e^{s \over RC}ds\right]$$
Calculando la integral de dentro tenemos que:
$$\int_{0}^{t}{V_i \over RC}e^{s \over RC}ds={V_i \over RC}\int_{0}^{t}e^{s \over RC}ds={V_i \over RC}\left[RCe^{s \over RC}\right]_{0}^{t}={V_i \over RC}\left(RCe^{t \over RC}-RCe^0\right)=V_i\left(e^{t \over RC}-1\right)$$
Sustituyendo ahora la integral por su resultado, la ecuación $V_o$ la podemos reescribir de la siguiente manera:
$$V_o=e^{-{t \over RC}}\left(V_o(0)+V_i\left(e^{t \over RC}-1\right)\right)=e^{-{t \over RC}}V_o(0)+e^{-{t \over RC}}V_i\left(e^{t \over RC}-1\right)$$
$$V_o=e^{-{t \over RC}}V_o(0)+V_i\left(e^{-{t \over RC}}e^{t \over RC}-e^{-{t \over RC}}\right)=e^{-{t \over RC}}V_o(0)+V_i\left(1-e^{-{t \over RC}}\right)$$
Con lo que nos sale:
$$V_o=e^{-{t \over RC}}V_o(0)+V_i\left(1-e^{-{t \over RC}}\right)$$
Nótese que si despejamos la $t$ se puede saber el tiempo que tarda el condensador en alcanzar un voltaje final $V_o$ partiendo de un voltaje de salida inicial $V_o(0)$ y con un voltaje de entrada de $V_i$:
$$t=-RC\log\left({V_o-V_i \over V_o(0)-V_i}\right)$$

Solución numérica

Para obtener una solución numérica lo que hacemos en este caso es despejar la derivada de la salida en la ecuación inicial:
$${dV_o \over dt}={V_i \over RC}-{V_o \over RC}={V_i-V_o \over RC}$$
Por lo tanto la salida $V_o$ en el instante $t=s$ será:
$$V_o=\int_{0}^{s}{V_i-V_o \over RC}dt$$
Esta integral definida podemos aproximarla mediante un método de integración numérica. En este caso, por simplicidad, optaremos por uno de los métodos de integración numérica más sencillos: el método de Euler. Según este método, si tenemos que:
$${dy \over dx}=f(x,y)$$
Entonces $y$ lo podemos aproximar mediante la siguiente ecuación de recurrencia:
$$y_n=y_{n-1}+h \times f(x_{n-1},y_{n-1})$$
Siendo $h$ un valor que tiende a cero. En el caso que nos ocupa:
$$y=V_o$$
$$x=t$$
$$f(t,V_o)={V_i-V_o \over RC}$$
Por lo tanto la ecuación de recurrencia del método de Euler quedaría como sigue:
$$V_{o_n}=V_{o_{n-1}}+h \times {V_i-V_{o_{n-1}} \over RC}$$
Las condiciones iniciales vendrán determinadas, al igual que la solución analítica por el valor de $V_o$ cuando $n=0$.

Representación de ambas soluciones

A continuación puede verse un código en lenguaje Octave (software libre de cálculo numérico) que pinta superpuestas las gráficas resultantes de ambas soluciones asumiento con condiciones iniciales nulas ($V_o=0$, i.e. condensador totalmente descargado, en el instante inicial).

global R = 10000;           % R = 10 K
global C = 0.000001;        % C = 1 uF
global Vi = 3.3;            % Vi = 3.3 voltios
global h = 0.001;
global t = [0:h:0.1];       % entre 0 y 0.1 segundos con pasos de h = 0.001 segundos

% solución analítica con condiciones iniciales nulas
Vo_analytic = Vi * (1 - exp(-t ./ (R * C)));


% solución numérica
function ret = f(Vo)
	global R;
	global C;
	global Vi;
	ret = (Vi - Vo) / (R * C);
endfunction

Vo_numeric = zeros(size(t));
n = 1;
for i = t
	if (n == 1)
		Vo_numeric(1) = 0;    % condiciones iniciales nulas
	else
		% integramos numéricamente mediante el método de Euler
		Vo_numeric(n) = Vo_numeric(n - 1) + h * f(Vo_numeric(n - 1));
	endif
	n = n + 1;
endfor

% visualizamos ambas soluciones (en rojo la solución analítica y en azul la solución numérica)
plot(t, Vo_analytic, "r", t, Vo_numeric, "b");

La solución analítica es la más exacta pero también es la más complicada de obtener ya que requiere del uso de ecuaciones diferenciales mientras que la solución numérica no es la más exacta pero es más sencilla de obtener.



En rojo puede verse la solución analítica y en azul la solución numérica. Hay que tener en cuenta que se ha utilizado el método de Euler de integración numérica que no es, ni de lejos, de los mejores métodos de integración. Con otros métodos de integración, como el Runge-Kutta de cuarto orden (RK4) se obtendrían resultados mucho mejores y con un error mucho menor con respecto de la solución analítica.

Espero que les haya resultado interesante.

[ 3 comentarios ] ( 41433 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 4301 )

<< <Anterior | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |