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 ] ( 2214 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 14047 )
Las redes neuronales son un tópico que se ha vuelto a poner de moda gracias al concepto del "deep learning" y a la irrupción de las redes neuronales convolucionales, que han revolucionado, sobre todo, las técnicas de reconocimiento de imágenes. No vamos a llegar tan lejos: implementaremos un sencillo perceptrón multicapa (MLP) en un Arduino que permita a este aprender y predecir el comportamiento de un usuario que acciona unos botones. No es un juego especialmente entretenido pero sirve como prueba de concepto de la implementación de redes neuronales artificiales sobre sistemas embebidos.
Perceptrón multicapa (MLP)
En un post anterior se explicaron los fundamentos teóricos de los perceptrones multicapa. Tenemos un conjunto de neuronas dispuestas en cascada, las de un extremo son las neuronas de entrada y las del otro extremo son las neuronas de salida.
Cada capa de neuronas está conectada con la capa inmediatamente anterior mediante un conjunto de pesos sinápticos que determinan el nivel de influencia de cada neurona de la capa (i-1)-ésima con cada neurona de la capa i-ésima. Los valores de entrada pasan por la matriz de pesos sinápticos que une la capa de entrada con la primera capa intermedia y determinan los valores de las neuronas de la primera capa oculta, a continuación este primera capa oculta propaga sus valores a través de otra matriz de pesos que une dicha capa con la siguiente y así sucesivamente hasta llegar a la capa de salida. La salida del perceptrón multicapa serán las salidas de las neuronas de la última capa.
La salida de cada neurona oculta y de salida viene determinada por:
$$suma^{o}_{pk} = \sum_{j=1}^{L}w_{kj}^{o}y_{pj} + \theta_{k}^{o}$$
$$y_{pk}=f_{k}^{o}(suma^{o}_{pk})$$
Siendo:
$w_{kj}^{o}$ el peso sináptico de la neurona $j$ de la capa anterior sobre la neurona $k$ de la capa actual.
$f$ la función de activación (en este caso la sigmoide).
$$f(x) = {{1} \over {1+e^{-x}}}$$
Cada neurona puede representarse de forma gráfica de la siguiente manera:
Por ejemplo, un perceptrón multicapa con 6 entradas, 2 salidas y una capa ocultas con 4 neuronas tendrá la siguiente configuración:
A0 = {a01, a02, a03, a04, a05, a06} <-- capa de entrada
W0,1 = { <-- pesos sinápticos entre la entrada y la capa oculta
w11 w12 w13 w14 w15 w16
w21 w22 w23 w24 w25 w26
w31 w32 w33 w34 w35 w36
w41 w42 w43 w44 w45 w46
}
A1 = {a11 a12 a13 a14} <-- capa oculta
X1,2 = { <-- pesos sinápticos entre la capa oculta y la salida
x11 x12 x13 x14
x21 x22 x23 x24
}
A2 = {a21 a22} <-- capa de salida
Recomiendo leer el post anterior donde se explica de forma más pormenorizada tanto el perceptrón multicapa como el algoritmo de aprendizaje "backpropagation", el más utilizado y el usado en esta prueba de concepto.
Juego de predicción
Se plantea un sencillo juego de predicción en el que el Arduino deberá aprenderse los movimientos del usuario que interactúa con él. Se disponen 4 pulsadores y 4 leds (a cada led le corresponde un botón y viceversa). Se incluyen además dos pulsadores adicionales etiquetados como "ENTRENAR" y "EVALUAR" y dos leds adicionales etiquetados como "ENTRENAMIENTO TERMINADO" y "EVALUANDO". Se implementa una red neuronal de tipo MLP con 8 entradas, una capa oculta de 8 neuronas y una capa de salida de 4 neuronas. 4 de las neuronas de entrada recogen las entradas de los 4 pulsadores (1 = pulsado, 0 = no pulsado) y las 4 neuronas de entrada restantes reciben el valor de los pulsadores del instante anterior (es una forma de dar memoria a la red), las cuatro neuronas de salida están conectadas a los 4 leds de salida (encendido = salida de la neurona mayor que 0.5, apagado = en caso contrario).
En modo evaluación el Arduino cada vez que detecta la pulsación de un botón aplica los valores correspondientes a las 8 neuronas de entrada, evalúa el MLP y emite el valor de las 4 neuronas de salida a los leds. En modo entrenamiento la red se va entrenando a sí misma observando las pulsaciones del usuario. La mecánica del "juego" es la siguiente:
1. Nada más arrancar el Arduino, inicia en modo EVALUACIÓN e ilumina el led "EVALUANDO". En este modo cada vez que se pulsa uno de los 4 pulsadores de entrada se evalúa la red neuronal y se emite la salida correspondiente. Como inicialmente los pesos sinápticos son aleatorios, los leds actuarán de forma aleatoria en función de la entrada.
2. Cuando queramos entrenar la red neuronal, pulsamos "ENTRENAR". Esto hace que el led "EVALUANDO" se apague para indicar que estamos en modo aprendizaje y la red entra en modo de aprendizaje. El usuario empieza a accionar los pulsadores en el orden que quiera, se asume una secuencia de cuatro pulsaciones de tal manera que cada cuatro pulsaciones, la red es entrenada para que sea capaz de aprenderse la secuencia. Cada vez que terminamos de introducir una secuencia (cuatro pulsaciones), la red es entrenada con la secuencia introducida y se ilumina el led "ENTRENAMIENTO TERMINADO", si seguimos repitiendo la secuencia afianzaremos el aprendizaje de la red neuronal.
3. Una vez que el led "ENTRENAMIENTO TERMINADO" se haya iluminado al menos una vez podemos volver al modo de evaluación accionando el pulsador "EVALUAR". Al entrar de nuevo en el modo de evaluación se iluminará el led "EVALUANDO". Tras el entrenamiento la red tendrá los pesos sinápticos modificados por el aprendizaje de tal manera que intentará predecir qué pulsador accionará el jugador en cada momento.
Veamos un ejemplo:
1. Encendemos y esperamos a que se encienda el led "EVALUANDO". Comprobamos que al accionar cualquiera de los 4 pulsadores los leds de salida se encienden y se apagan sin criterio, de forma aleatoria debido a los pesos sinápticos aleatorios.
2. Pulsamos "ENTRENAR" y esperamos a que se apague el led "EVALUANDO". Ahora estamos en modo aprendizaje (o entrenamiento).
3. Nos inventamos una secuencia, por ejemplo {1, 4, 2, 3} y vamos accionando los pulsadores de entrada en ese orden. Cuando pulsemos el último número de la secuencia, el 3, se aplicará el algoritmo de aprendizaje para entrenar la red neuronal y una vez termine, se encenderá el led "ENTRENAMIENTO TERMINADO".
4. Llegamos a este punto podemos seguir entrenando la red con la misma secuencia (volvemos a introducir la secuencia en orden {1, 4, 2, 3}, al pulsar el 1 se apagará el led "ENTRENAMIENTO TERMINADO" y volverá a encenderse cuando pulsemos el 3 para indicar que se ha realizado otro entrenamiento) o podemos pasar al modo de evaluación.
5. Para pasar al modo de evaluación pulsamos "EVALUAR". Esto hará que el led "EVALUANDO" se encienda y ahora podremos probar la red entrenada.
6. En modo evaluación lo que hace la red es tratar de adivinar qué pulsador se accionará en el siguiente movimiento. Por ejemplo en nuestro caso, para la secuencia {1, 4, 2, 3}, ocurrirá lo siguiente:
pulsamos 1 --> se enciende el 4
pulsamos 4 --> se enciende el 2
pulsamos 2 --> se enciende el 3
pulsamos 3 --> se enciende el 1
Como se puede comprobar, la red neuronal se ha aprendido nuestros movimientos correctamente.
Implementación
A continuación puede verse el diagrama de clases utilizado en la implementación del juego de predicción en C++:
Se trata de una estructura de código muy sencilla. La clase PredictorGame mantiene la máquina de estados principal del juego, lee los pulsadores, controla las luces e incluye también la red neuronal (ExampleMLP). La clase MLP es una clase abstracta que permite definir mediante la implementación de varios de sus métodos virtuales puros la topología de un perceptrón multicapa cualquiera (entradas, capas ocultas y neuronas de salida). La clase ExampleMLP es una especialización de MLP con la topología descrita: 8 entradas, 1 capa intermedia oculta con 8 neuronas y una capa de salida de 4 neuronas.
#include <time.h> #include <math.h> #include <stdlib.h> #include "MultilayerPerceptron.H" using namespace avelino; using namespace std; float MultilayerPerceptron::getNetValue(uint8_t numNeuronsPrevLayer, uint8_t currentLayer, uint8_t n) { float acc = 0; for (uint8_t p = 0; p < numNeuronsPrevLayer; p++) { float x = this->getNeuronValue(currentLayer - 1, p); float w = this->getInputWeight(currentLayer, p, n); acc = acc + (x * w); } return acc; } void MultilayerPerceptron::evaluate() { uint8_t numLayers = this->getNumHiddenLayers() + 1; for (uint8_t l = 1; l <= numLayers; l++) { uint8_t numNeurons = this->getNumNeurons(l); uint8_t numNeuronsPrevLayer = this->getNumNeurons(l - 1); for (uint8_t n = 0; n < numNeurons; n++) { float acc = this->getNetValue(numNeuronsPrevLayer, l, n); float y = 1.0 / (1.0 + exp(-acc)); this->setNeuronValue(l, n, y); } } } void MultilayerPerceptron::setTrainRate(float r) { this->trainRate = r; } float MultilayerPerceptron::getTrainRate() { return this->trainRate; } float MultilayerPerceptron::getEstimatedError(uint8_t layer, uint8_t n) { uint8_t numLayers = this->getNumHiddenLayers() + 1; float ret = 0; if (layer == numLayers) { float out = this->getNeuronValue(layer, n); ret = (this->getDesiredOutput(n) - out); } else { uint8_t numNeuronsNextLayer = this->getNumNeurons(layer + 1); for (uint8_t k = 0; k < numNeuronsNextLayer; k++) { float e = this->getNeuronErrorValue(layer + 1, k); float w = this->getInputWeight(layer + 1, n, k); ret += (e * w); } } return ret; } void MultilayerPerceptron::backpropagate(uint8_t layer, float *totalError) { if (totalError != NULL) *totalError = 0; uint8_t numNeurons = this->getNumNeurons(layer); for (uint8_t n = 0; n < numNeurons; n++) { float out = this->getNeuronValue(layer, n); float aux = out * (1 - out); float error = aux * this->getEstimatedError(layer, n); this->setNeuronErrorValue(layer, n, error); if (totalError != NULL) *totalError += (error * error); } uint8_t numNeuronsPrevLayer = this->getNumNeurons(layer - 1); for (uint8_t n = 0; n < numNeurons; n++) { float e = this->getNeuronErrorValue(layer, n); for (uint8_t k = 0; k < numNeuronsPrevLayer; k++) { float y = this->getNeuronValue(layer - 1, k); float w = this->getInputWeight(layer, k, n); w = w + (this->trainRate * e * y); this->setInputWeight(layer, k, n, w); } } } void MultilayerPerceptron::train(uint8_t times, float &totalError) { while (times > 0) { uint8_t outputLayer = this->getNumHiddenLayers() + 1; for (uint8_t l = outputLayer; l >= 1; l--) { float *e = (l == outputLayer) ? &totalError : NULL; this->backpropagate(l, e); } this->commitInputWeights(); times--; } } void MultilayerPerceptron::initWithRandomWeights() { srand(time(NULL)); uint8_t n = this->getNumHiddenLayers() + 1; for (uint8_t l = 1; l <= n; l++) { uint8_t prevLayerNumNeurons = this->getNumNeurons(l - 1); uint8_t currentLayerNumNeurons = this->getNumNeurons(l); for (uint8_t from = 0; from < prevLayerNumNeurons; from++) { for (uint8_t to = 0; to < currentLayerNumNeurons; to++) { float v = ((2.0f * rand()) / RAND_MAX) - 1.0f; this->setInputWeight(l, from, to, v); } } } this->commitInputWeights(); } void MultilayerPerceptron::setMaxError(float v) { this->maxError = v; } float MultilayerPerceptron::getMaxError() { return this->maxError; }
El código fuente está organizado de tal manera que el que es independiente de la plataforma (clases MultilayerPerceptron, ExampleMultilayerPerceptron, KeyReader, Leds y PredictorGame) se encuentra en la carpeta raiz. El código dependiente de cada plataforma se encuentra en la carpeta correspondiente a dicha plataforma: dentro de la carpeta "linux" están las clases "LinuxKeyReader" y "LinuxLeds" así como el fichero "main.cc" para Linux y dentro de la carpeta "arduino" están las clases "RealKeyReader" y "RealLeds" así como el fichero "main.cc" para Arduino. En este caso concreto, las clases "RealKeyReader" y "RealLeds" están especializadas para el patillaje del Arduino Leonardo pero son fácilmente adaptables a otros modelos de Arduino.
Para compilar la versión "linux" basta con entrar en la carpeta "linux" y hacer "make" mientras que para compilar la versión "arduino" hay que ir a la carpeta "arduino", editar el fichero "Makefile", poner los valores adecuados para la carpeta donde está instalado el IDE de Arduino ("ARDUINO_FOLDER") y el puerto serie donde se conecta el Arduino ("SERIAL") y a continuación hacer "make" para generar el fichero "main.hex" y "make install" para tostarlo en el Arduino.
A nivel eléctrico se trata de un circuito sumamente sencillo: 6 pulsadores, con su pequeña red antirrebote cada uno, y 6 leds.
Todo el código está disponible en la sección soft.
[ añadir comentario ] ( 2044 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 5319 )
Incluir una entrada analógica en una FPGA pasa, normalmente, por agregar al menos un integrado que haga de ADC. Sin embargo, existen alternativas al ADC tradicional que, aprovechando las características de una FPGA, nos permiten implementar un conversor analógico-digital utilizando muy pocos componentes externos.
Registro de aproximaciones sucesivas (SAR)
Esta técnica utiliza un comparador, una resistencia y un condensador como únicos componentes externos y consume dos pines E/S de la FPGA. Se consigue una resolución en bits arbitraria que sólo dependerá de los valores de R, de C y de la frecuencia de reloj de la FPGA.
El principio de funcionamiento es muy sencillo: inicialmente el condensador se encuentra descargado y la salida de realimentación (el pin conectado a R) se pone a 1. Esto hace que el condensador empiece a cargarse. En el instante en que el voltaje en el condensador llegue hasta la mitad de Vcc (1.65v en el caso de una FPGA alimentada a 3.3v), la FPGA lee el valor de la entrada digital (el otro pin). Esta entrada está conectada a la salida de un comparador que emitirá 3.3v si el voltaje en la entrada V+ es superior al voltaje en la entrada V- y 0v en caso contrario.
Si la FPGA lee un 0 en esa entrada significará que el voltaje que estamos midiendo en la entrada V+ se encuentra por debajo de Vcc/2 por lo que ya podemos determinar que el bit más significativo del valor convertido es un 0. Si la FGPA lee un 1 en esa entrada significará que el voltaje que estamos midiendo en la entrada V+ se encuentra por encima de Vcc/2 por lo que ya podemos determinar que el bit más significativo del valor convertido es en este caso un 1.
Si la FPGA ha leido un 0 en la entrada de comparación (V+ está por debajo del voltaje que hay en ese instante en los extremos del condensador, Vcc/2), significará que para determinar el siguiente bit hay que bajar el voltaje de la entrada V- (descargar el condensador), por lo que se emite un 0 por el pin conectado a R. Esto provoca que el condensador empiece a descargarse.
Si la FPGA leyó un 1 en la primera comparación (V+ está por encima del voltaje que hay en ese instante en los extremos del condensador, Vcc/2), significará que para determinar el siguiente bit hay que seguir cargando el condensador (la FPGA debe seguir emitiendo un 1 por la pata que conecta a R).
Independientemente de si la FPGA emite un 0 (para descargar el condensador) o un 1 (para seguir cargándolo), espera la mitad de tiempo que la primera vez antes de volver a leer la entrada conectada al comparador. En esta segunda lectura vuelve a hacer lo mismo: Si vale 0 significa que el voltaje del condensador está por encima del voltaje que estamos midiendo (V+) mientras que si vale 1 significa que el voltaje del condensador está por debajo del voltaje que estamos midiendo. En el primer caso el siguiente bit valdrá 0 y se emite un 0 por el pin conectado a R para descargar del condensador. En el segundo caso el siguiente bit valdra 1 y se emitirá un 1 por el pin conectado a R para cargar el condensador. Como puede observar el bit que se emite como valor es el mismo que el que alimenta a la salida conectada a R.
En cada iteración del proceso se espera la mitad de tiempo que la iteración anterior, se empieza siempre por el bit más significativo y se pueden realizar tantas iteraciones como se deseen: La cantidad de bits de resolución de la conversión vendrá determinada por la cantidad de comparaciones que hagamos.
Implementación: el circuito
En este caso se ha implementado una pequeña prueba de concepto: un conversor de 4 bits de resolución usando un amplificador operacional barato LM358N (por AliExpress se pueden adquirir 10 unidades por menos de 2¤ en el momento que escribo estas líneas) para usarlo como comparador, un condensador de 1uF y una resistencia de 10K.
Como FPGA he utilizado una Cyclone II de Altera que pillé hace poco. No es tan potente como la Spartan-3E de Xilinx que he usado en otros montajes pero es compacta, barata y más que suficiente para el ADC.
Implementación: el software
Como se vió en la descripción varios párrafos más arriba, la FPGA debe leer la entrada de comparación en unos instantes determinados: inicialmente debe esperar hasta que el condensador llegue a Vcc/2 para realizar la primera medida, la segunda medida la realizará en la mitad de tiempo que la primera, la tercera en la mitad de tiempo que la segunda y así sucesivamente.
En nuestro caso, asumiendo un condensador de 1uF (C = 0.000001), una resistencia de 10K (R = 10000), la salida de la FPGA que está conectada a R, a la que etiquetaremos como Vi con un valor de 3.3v y la entrada inversora del comparador, a la que llamaremos Vo con un valor de 1.65v (3.3 / 2), tenemos que:
$$t=-R \times C \times log\left({{V_o - V_i} \over {V_o(0) - V_i}}\right)$$
$$t = -10000 \times 0.000001 \times log\left({{1.65 - 3.3} \over {0 - 3.3}}\right) = 0.006931471805599453 \; s$$
Es el tiempo que tarda Vo en valer 1.65v (la mitad de Vcc = 3.3v) partiendo de 0v (condensador totalmente descargado). Esta ecuación es la solución analítica a la ecuación diferencial de la carga de un circuito RC que vimos en este post anterior.
Si asumimos una frecuencia de reloj de 50MHz tenemos que hacen falta:
$$50000000 * 0.006931471805599453 = 346574 \; ciclos$$
Para esperar desde que el condensador está totalmente descargado hasta que Vo = 1.65v. Como se va a hacer una conversión de 4 bits los puntos de comparación se deberán hacer en los siguientes instantes:
1ª comparación (bit 3): 346574 ciclos de reloj ↓ 346574 / 2 ↓ 2ª comparación (bit 2): 173286 ciclos de reloj (instante 519860) ↓ 173286 / 2 ↓ 3ª comparación (bit 1): 86643 ciclos de reloj (instante 606503) ↓ 86643 / 2 ↓ 4ª comparación (bit 0): 43321 ciclos de reloj (instante 649824)
Tras hacer la 4ª comparación es necesario realizar una descarga completa del condensador para iniciar la siguiente conversión. Si asumimos el peor de los casos, que el condensador esté totalmente cargado (Vo(0) = 3.3v), tenemos que para llegar a Vo = 0.01v con Vi = 0 se necesitan:
$$-10000 \times 0.000001 \times log\left({{0.01 - 0} \over {3.3 - 0}}\right) = 0.057990926544605255 \; segundos$$
Que equivalen a:
$$50000000 \times 0.057990926544605255 = 2899546 \; ciclos$$
Desde un punto de vista teórico el condensador nunca se descarga del todo (siempre tiene carga residual). Si se pusiese 0 en lugar 0.01 en la ecuación anterior el tiempo tendería a infinito, por lo que hay que poner una cantidad muy baja que no sea cero. De forma global se ve que cada conversión requiere:
649824 ciclos para la conversión en sí + 2899546 ciclos para descargar el condensador del todo antes de iniciar una nueva conversión = 3549370 ciclos
Para contar 3549370 ciclos (desde 0 hasta 3549369) hacen falta como mínimo 22 bits ($2^{21} = 2097152$, se queda corto, y $2^{22} = 4194304$). Por tanto el "motor" de nuestro conversor analógico-digital de 4 bits será un contador de 22 bits que emitirá las siguientes señales de control:
0 --> forzar biestable de comparación = 1 346574 --> cargar el biestable con el 1º valor de comparación 346575 --> empujar valor biestable en registro de desplazamiento (bit 3) 519860 --> cargar el biestable con el 2º valor de comparación 519861 --> empujar valor biestable en registro de desplazamiento (bit 2) 606503 --> cargar el biestable con el 3º valor de comparación 606504 --> empujar valor biestable en registro de desplazamiento (bit 1) 649824 --> cargar el biestable con el 4º valor de comparación 649825 --> empujar valor biestable en registro de desplazamiento (bit 0) forzar biestable de comparación = 1 649826 --> cargar registro de salida desde registro de desplazamiento
El proceso de descarga se realizará entre el ciclo 649825 y el 4194303 ($2^{22} - 1$). No se va a reiniciar el contador en el ciclo 3549370 para evitar sobrecargar con más puertas lógicas el diseño, sino que se va a dejar que el contador se desborde de forma natural en el ciclo 4194304 ($2^{22}$). Se pierde una centésima de segundo en resolución temporal pero para el caso que nos ocupa no es relevante. Aprovechar el mismo registro contador de tiempo como máquina de estados simplifica enormemente el diseño.
La implementación en VHDL partiendo de este diagrama es muy sencilla:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity ADC is port ( Clock : in std_logic; Reset : in std_logic; CompIn : in std_logic; ChargeOut : out std_logic; DataOut : out std_logic_vector(3 downto 0) ); end entity; architecture A of ADC is signal DComp : std_logic; signal QComp : std_logic; signal CompSel : std_logic_vector(1 downto 0); signal DCounter : std_logic_vector(21 downto 0); signal QCounter : std_logic_vector(21 downto 0); signal DShiftReg : std_logic_vector(3 downto 0); signal QShiftReg : std_logic_vector(3 downto 0); signal ShiftRegEnable : std_logic; signal DOutReg : std_logic_vector(3 downto 0); signal QOutReg : std_logic_vector(3 downto 0); signal OutRegEnable : std_logic; constant Conversion1Cycle : integer := 346574; constant Conversion2Cycle : integer := 519860; constant Conversion3Cycle : integer := 606503; constant Conversion4Cycle : integer := 649824; begin -- comparator d flip-flop process (Clock) begin if (Clock'event and (Clock = '1')) then QComp <= DComp; end if; end process; DComp <= '0' when ((CompSel = "00") or (Reset = '1')) else '1' when (CompSel = "01") else CompIn when (CompSel = "10") else QComp; ChargeOut <= QComp; -- shift register process (Clock) begin if (Clock'event and (Clock = '1')) then QShiftReg <= DShiftReg; end if; end process; DShiftReg <= (QShiftReg(2 downto 0) & QComp) when (ShiftRegEnable = '1') else QShiftReg; -- output register process (Clock) begin if (Clock'event and (Clock = '1')) then QOutReg <= DOutReg; end if; end process; DOutReg <= QShiftReg when (OutRegEnable = '1') else QOutReg; DataOut <= QOutReg; -- 22 bit counter & fsm process (Clock) begin if (Clock'event and (Clock = '1')) then QCounter <= DCounter; end if; end process; DCounter <= std_logic_vector(to_unsigned(0, 22)) when (Reset = '1') else std_logic_vector(to_unsigned(to_integer(unsigned(QCounter)) + 1, 22)); CompSel <= "00" when (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 1)) else "01" when (to_integer(unsigned(QCounter)) = 0) else "10" when ((to_integer(unsigned(QCounter)) = Conversion1Cycle) or (to_integer(unsigned(QCounter)) = Conversion2Cycle) or (to_integer(unsigned(QCounter)) = Conversion3Cycle) or (to_integer(unsigned(QCounter)) = Conversion4Cycle)) else "11"; ShiftRegEnable <= '1' when ((to_integer(unsigned(QCounter)) = (Conversion1Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion2Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion3Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 1))) else '0'; OutRegEnable <= '1' when (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 2)) else '0'; end architecture;
Conclusión
Usar un registro de aproximaciones sucesivas (SAR) es la forma más sencilla y barata de implementar un ADC, aunque tiene sus inconvenientes:
- Las conversiones son lentas. Aunque pongamos condensadores y resistencias pequeños, es complicado aumentar la frecuencia de muestreo por encima de unos pocos KHz.
- La pendiente de carga de un condensador en un circuito típico RC no es lineal por lo que la conversión resultante tampoco será lineal. Este escollo puede superarse con un circuito externo más elaborado que garantice una corriente de carga constante en el condensador y, por tanto, una pendiente de carga constante en el mismo.
También tiene sus ventajas :-):
- Usa relativamente pocos recursos de la FPGA.
Muy pocos componentes externos. El comparador podría implementarse incluso utilizando las entradas diferenciales que todas las FPGAs tienen (LVDS, mini-LVDS, etc.).
- La resolución en bits es arbitraria y sólo depende de la implementación interna en la FPGA.
A continuación puede verse un vídeo con el conversor implementado al que se le han conectado cuatro leds:
Todo el código puede descargarse de la sección soft.
[ añadir comentario ] ( 1534 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 5055 )
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 ] ( 41903 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 4571 )
Transmitir una señal modulada en FM dentro de la banda de frecuencias de la FM comercial utilizando FPGAs es un tópico ampliamente cubierto en decenas de webs y vídeos online pero que en pocos casos es desgranado y explicado de forma entendible y rigurosa. A lo largo de este post se desarrollará tanto la base teórica como una prueba de concepto de un transmisor FM basado en FPGA.
Los fundamentos
La modulación FM consiste en hacer variar la frecuencia de la señal que estamos mandando a la antena en función de la amplitud de la señal moduladora (sonido, por ejemplo). El rango de frecuencias reservado para emisoras comerciales de audio abarca desde los 87.5 MHz hasta los 108 MHz (es el rango que recoge cualquier receptor FM analógico) con una profundidad de modulación de +- 75 KHz. Esto significa que si queremos emitir en la frecuencia de 100 MHz debemos hacer variar la frecuencia entre 99.925 MHz y 100.075 MHz en función de la amplitud de la señal moduladora.
(imagen extraida de Wikipedia, con licencia Creative Commons Attribution - Share Alike 2.0, autor Gvf)
Generar la señal en el dominio digital
Intentar hacer un transmisor FM utilizando una FPGA nos hace chocar, a priori, con un primer impedimento: Poder generar una señal que varíe de frecuencia de una forma tan ligera. Hay que tener en cuenta que con una anchura de modulación de 150 KHz (75 KHz + 75 KHz) y asumiendo una señal sonora muestreada a 16 bits tenemos que la variación de la señal de salida de la antena debe de ser en pasos de:
$${150000 \over {2^{16}}} = {150000 \over 65536} = 2.2888 Hz$$
Esos son muy pocos hercios. En el dominio digital lo habitual para modificar la frecuencia de una señal es multiplicarla por una constante (mediante un PLL) o dividirla entre una constante (mediante un divisor de frecuencias). Los PLLs son circuitos híbridos (entre analógicos y digitales), no son todo lo rápido que nos gustaría (lo normal es que un PLL necesite varios ciclos hasta estabilizarse en la frecuencia deseada), normalmente trabajan con factores constantes (no variables como en nuestro caso) y no están siempre disponibles en las FPGAs para uso directo del usuario. Los divisores de frecuencia son más fáciles de hacer (no dejan de ser baterías de biestables) pero, como su propio nombre indica, sólo son capaces de dividir la frecuencia entre un valor entero.
Acumuladores de fase
Existe, sin embargo, una forma bastante ingeniosa para la síntesis directa de señales digitales sin necesidad del uso de PLLs ni de divisores de frecuencia: los acumuladores de fase. Un acumulador de fase es una de las partes que integran un oscilador digital basado en tabla de ondas. Imaginemos que queremos generar una onda senoidal mediante una tabla de ondas.
Asumiremos, por simplificar, que la tabla de la onda (realmente sería una ROM o una RAM) tiene un tamaño de 256 muestras ($2^8$). Por otro lado tenemos un registro (al que llamaremos acumulador de fase) de 16 bits y lo que hacemos es indexar la tabla de ondas utilizando los 8 bits más significativos del acumulador de fase. Las entradas D de los biestables de este registro se conectan a la salida un sumador que suma las salidas Q (el valor actual de registro) más un valor de entrada de 16 bits. En cada flanco de subida del reloj del sistema el registro (el acumulador de fase) se carga con el valor de las entradas D procedentes del sumador. En cada tick de reloj el acumulador va acumulando el valor de la entrada de 16 bits.
Si en la entrada de 16 bits ponemos un 0, el registro se quedará "quieto", no cambiará su valor y, por tanto la salida de datos de la ROM indexada por dicho registro sacará un valor constante (frecuencia de 0 Hz). Si en la entrada de 16 bits ponemos un 1, en registro se irá incrementando de 1 en 1, sin embargo, como lo que indexa a la tabla de valores son los 8 bits más significativos, este índice sólo avanzará cada 256 pulsos de reloj del sistema. Esto significa que con una frecuencia de reloj de 32 KHz, nuestro oscilador generaría una señal con una frecuencia de:
$${32000 \over 256} = 125 Hz$$
Yéndonos al otro extremo, si en la entrada de 16 bits metemos un valor de $2^{15} = 32768$, el acumulador de fase, al ser de 16 bits, se desbordará cada dos ciclos de reloj: el acumulador de fase generará la secuencia de valores 0, 32768, 0, 32768, 0, 32768, etc. que, a su vez, indexarán los valores mínimo y máximo de la onda senoidal que tenemos en la tabla. Esto significa que la salida del oscilador ha alcanzado su frecuencia máxima (su frecuencia de Nyquist, de 16 KHz).
Como se puede comprobar, los incrementos en los pasos son lineales por lo que podemos establecer una especie de regla de tres para el cálculo de la frecuencia de salida de nuestro oscilador imaginario:
$$I_{acumulador fase} = {f_{deseada} \over 32000} \times 2^{16}$$
Vemos que si frecuencia_deseada = 0, entonces incremento_acumulador_fase = 0. Si frecuencia_deseada = 16000 (la frecuencia Nyquist, para 32 KHz), entonces:
$$I_{acumulador fase} = {16000 \over 32000} \times 2^{16} = {1 \over 2} \times 2^{16} = 2^{15} = 32768$$
Para 440 Hz (la nota LA de la cuarta octava del piano) tenemos que:
$$I_{acumulador fase} = {440 \over 32000} \times 2^{16} = 901.12$$
En estos casos, cuando el resultado es fraccionario, hay dos opciones: O quedarse con la parte entera (redondeando o truncando, incremento_acumulador_fase = 901), o, si no queremos perder precisión, incrementar el número de bits para reducir el error. Por ejemplo, utilizando un acumulador de fase de 59 bits conseguiríamos un valor de incremento entero (sin parte fraccionaria):
$$I_{acumulador fase} = {440 \over 32000} \times 2^{59} = 7926335344172073$$
De forma general se puede plantear la ecuación de la siguiente manera:
$$I_{acumulador fase} = {f_{deseada} \over f_{muestreo}} \times 2^{bits}$$
Nótese que la cantidad de bits que indexa la tabla de ondas no es relevante. Lo único importante es que la tabla de ondas está indexada por la parte alta del registro acumulador de fase. Como se puede apreciar, con este método es posible generar frecuencias arbitrarias con pasos relativamente pequeños y sólo limitados por el número de bits que utilicemos en el acumulador de fase.
El transmisor
Imaginemos que la frecuencia de muestreo es ahora de 320 MHz y que queremos generar una frecuencia de 100 MHz. Utilizando un acumulador de fase de 32 bits (la cantidad de bits elegida es arbitraria) tendríamos que:
$$I_{acumulador fase} = {100000000 \over 320000000} \times 2^{32} = 1342177280$$
Como se comentó antes, la cantidad de bits de resolución a la hora de indexar la tabla de ondas no es relevante a efectos de frecuencia (aunque sí a efectos de distorsión armónica y de relación señal/ruido). En nuestro caso, como no disponemos de un DAC sino que vamos a generar una señal cuadrada directamente, en teoría lo que necesitamos es una tabla con una onda cuadrada de tal forma que cuando esté en el máximo emita un 1 y cuando esté en el mínimo emita un 0.
Si asumimos que en la tabla de ondas vamos a meter una onda cuadrada con un ciclo de trabajo del 50% (perfectamente cuadrada), esto significará que la mitad de la tabla de ondas va a estar al valor mínimo (0 por ejemplo) y la otra mitad al valor máximo (255 por ejemplo, si es una ROM de 8 bits sin signo). Si al final vamos a traducir la salida de la tabla de ondas como un 0 si está en el valor mínimo y como un 1 si está en el valor máximo, es obvio que la salida de la tabla de ondas coincidirá con el valor del bit más significativo del acumulador de fase.
Y he aquí la "magia" del invento: Usando un único registro con un sumador podemos hacer un oscilador de onda cuadrada para el que podemos controlar la frecuencia de forma precisa entre 0 Hz y la mitad de la frecuencia de reloj. La precisión a la hora de ajustar la frecuencia nos la dará la cantidad de bits que usemos.
El jittering
Cualquier incremento en el acumulador de fase que no sea potencia de dos va a generar un efecto jitter en la señal de salida haciendo que ésta muchas veces diste de ser una señal cuadrada perfecta. Esto, como es obvio, provocará que la cantidad de armónicos que se generen se dispare. Vamos a verlo con un ejemplo.
Imaginemos el caso anterior: 320 MHz de frecuencia de reloj, un acumulador de fase de 32 bits y un incremento para el acumulador de fase igual a 1342177280. Aplicando este acumulador de fase obtenemos la siguiente salida (correspondiente al bit más significativo):
1 1 0 1 1 0 1 1 0 0 1 0 0 1 0 0 ... la secuencia se repite indefinidamente
Como se puede observar esta secuencia de bits dista mucho de parecerse a una señal cuadrada con ciclo de trabajo del 50%, en concreto genera tres pulsos anchos más juntos y luego dos pulsos estrechos más separados. Sin embargo si hacemos el análisis de Fourier de esta secuencia, tratándola como si fuese una señal, calculándole la transformada de Fourier usando un software numérico como Octave:
octave> abs(fft([1 1 0 1 1 0 1 1 0 0 1 0 0 1 0 0])) ans = 8.00000 1.79995 0.00000 1.01959 0.00000 5.12583 0.00000 1.20269 0.00000 1.20269 0.00000 5.12583 0.00000 1.01959 0.00000 1.79995
Vemos que la transformada de Fourier resultante es efectivamente simétrica (quitando la posición 0, que es la componente de continua), al tratarse de una señal real, y que, descartando la componente de continua (el índice 0 del vector), hay un máximo en el índice 5 del vector. Por las propiedades de la transformada de Fourier en este caso la posición 8 (el centro del vector y el centro de simetría) se corresponde con la frecuencia Nyquist que, al ser la frecuencia de reloj de 320 MHz, sería de 160 MHz (la mitad de la frecuencia del reloj). El máximo situado en la posición 5 del vector se corresponderá, siguiendo una regla de tres, con la frecuencia de:
$${{160000000 \times 5} \over 8} = 100000000 = 100 MHz$$
En efecto, la frecuencia fundamental (el máximo en la transformada de Fourier) de la señal de salida es de 100 MHz, que era nuestro objetivo, aunque como se puede ver en el análisis de Fourier, también se emitirán armónicos de 20 MHz (el valor 1.79995 se corresponde con la frecuencia ${{160 \times 1} \over 8}$), de 60 MHz (${{160 \times 3} \over 8}$) y de 140 MHz (${{160 \times 7} \over 8}$), aunque de menor amplitud.
Implementación y prueba de concepto
Como se va a emitir el sonido por radio FM y aún no disponemos de un ADC que permita la lectura de una fuente externa de audio, se generará la señal de audio dentro de la propia FPGA.
El sistema consta de dos entradas conectadas a sendos botones y que permiten seleccionar la frecuencia del tono a transmitir: cuando no se pulsa ningún botón no se modula (se genera la portadora sin modular), cuando se pulsa el botón “440” la portadora se modula con una señal cuadrada de 440 Hz (nota LA en la cuarta octava del piano), mientras que si se pulsa el botón “880” la portadora se modula con una señal cuadrada de 880 Hz (nota LA en la quinta octava del piano).
La única salida del sistema es la salida de la antena, que va a un trozo de cable que se coloca al aire. No es necesario nada más si vamos a colocar el receptor a pocos metros de la FPGA. En el caso de que queramos conectar la salida a una antena real y que queramos más potencia habría que colocar circuitos acondicionadores y/o amplificadores a la salida y, sobretodo, filtros: hay que recordar que la señal de salida es una onda cuadrada repleta de armónicos.
En reposo, las entradas “440” y “880” valen 0, por lo que el MUX inferior selecciona la entrada del incremento correspondiente a los 90 MHz (portadora sin modular). Cuando se pulsa sólo el botón “440” el MUX inferior deja pasar la señal moduladora (ya convertida en secuencias de incrementos en lugar de en 0 y 1) a la entrada del oscilador para modularlo, y cuando se pulsa el botón “880” ocurre lo mismo con el MUX inferior y, además, al cambiar la entrada de selección del MUX superior, cambia el incremento del oscilador que genera la señal moduladora para que genere 880 Hz en lugar de 440 Hz.
El módulo combinacional CONV convierte la entrada de 1 bit (0 o 1) proveniente del oscilador de 440 u 880 Hz, en una salida de 32 bits que es el incremento de fase de correspondiente a cada nivel de la señal moduladora:
0 --> 1341058799
1 --> 1343295761
En este caso se ha realizado una implementación sobre una FPGA Spartan-3E de Xilinx con un reloj externo de 32 MHz (papilio one). Las Spartan-3E disponen de varios DCM (Digital Clock Managers) que permiten subir la frecuencia de reloj mediante multiplicadores. En este caso, con un reloj a 32 MHz la máxima frecuencia que se puede alcanzar es de 288 MHz, por lo que ajustamos los cálculos a dicha frecuencia y asumiendo que vamos a transmitir en la banda de 90 MHz.
$$I_{acumulador fase} = {90000000 \over 288000000} \times 2^{32} = 1342177280$$
Como la señal moduladora (los tonos de 440 y 880 Hz) van a ser también ondas cuadradas, sólo hay que calcular los incrementos para el 0 y el 1 de la señal moduladora (en este caso no hay valores intermedios). El 0 de la señal moduladora lo asociaremos a 89,925 MHz y el 1 de la señal moduladora lo asociaremos a 90,075 MHz (recordemos que la profundidad de modulación en la FM comercial es de 75 KHz).
$$I_{89.925} = {89925000 \over 288000000} \times 2^{32} \approx 1341058799$$
$$I_{90.075} = {90075000 \over 288000000} \times 2^{32} \approx 1343295761$$
A continuación puede verse el código fuente:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity FMTransmitter is port ( Clk : in std_logic; Reset : in std_logic; Button440 : in std_logic; Button880 : in std_logic; AntOut : out std_logic ); end entity; architecture Architecture1 of FMTransmitter is component Oscillator is generic ( NBits : integer := 32 ); port ( IncrementIn : in std_logic_vector(31 downto 0); Clk : in std_logic; Reset : in std_logic; DataOut : out std_logic ); end component; component Mux2Inputs is generic ( NBits : integer := 32 ); port ( Sel : in std_logic; DataIn0 : in std_logic_vector((NBits - 1) downto 0); DataIn1 : in std_logic_vector((NBits - 1) downto 0); DataOut : out std_logic_vector((NBits - 1) downto 0) ); end component; signal Mux1Out : std_logic_vector(31 downto 0); signal Mux1Sel : std_logic; signal ConvOut : std_logic_vector(31 downto 0); signal ConvIn : std_logic; signal Mux2Out : std_logic_vector(31 downto 0); begin RadioOsc : Oscillator generic map ( NBits => 32 ) port map ( Clk => Clk, Reset => Reset, IncrementIn => Mux1Out, DataOut => AntOut ); Mux1Sel <= Button440 or Button880; Mux1 : Mux2Inputs generic map ( NBits => 32 ) port map ( Sel => Mux1Sel, DataIn0 => std_logic_vector(to_unsigned(1342177280, 32)), -- center freq = 90.0 MHz DataIn1 => ConvOut, DataOut => Mux1Out ); -- center freq - 75 KHz when 0 -- center freq + 75 KHz when 1 ConvOut <= std_logic_vector(to_unsigned(1341058799, 32)) when (ConvIn = '0') else std_logic_vector(to_unsigned(1343295761, 32)); AudioOsc : Oscillator generic map ( NBits => 32 ) port map ( Clk => Clk, Reset => Reset, IncrementIn => Mux2Out, DataOut => ConvIn ); Mux2 : Mux2Inputs generic map ( NBits => 32 ) port map ( Sel => Button880, DataIn0 => std_logic_vector(to_unsigned(6562, 32)), -- 440 Hz DataIn1 => std_logic_vector(to_unsigned(13124, 32)), -- 880 Hz DataOut => Mux2Out ); end architecture;
Tanto el oscilador de salida (el de alta frecuencia) como el oscilador de audio se han implementado usando el mismo componente “Oscillator.vhd”.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; use ieee.std_logic_signed.all; entity Oscillator is generic ( NBits : integer := 32 ); port ( IncrementIn : in std_logic_vector(31 downto 0); Clk : in std_logic; Reset : in std_logic; DataOut : out std_logic ); end entity; architecture Architecture1 of Oscillator is component Reg is generic ( NBits : integer := 32 ); port ( Enable : in std_logic; Clk : in std_logic; DataIn : in std_logic_vector((NBits - 1) downto 0); DataOut : out std_logic_vector((NBits - 1) downto 0) ); end component; signal MuxOut : std_logic_vector((NBits - 1) downto 0); signal RegOut : std_logic_vector((NBits - 1) downto 0); begin PhaseAcc : Reg generic map ( NBits => NBits ) port map ( Enable => '1', Clk => Clk, DataIn => MuxOut, DataOut => RegOut ); MuxOut <= (others => '0') when (Reset = '1') else (RegOut + IncrementIn); DataOut <= RegOut(NBits - 1); end architecture;
El tipo de sumador
En otros proyectos FPGA anteriormente abordados en este blog, cada vez que hacía falta un sumador se tiraba de un sumador estándar implementado mediante lógica combinatoria (“Adder.vhd”). Hasta ahora se ha hecho así por razones pedagógicas. En este caso, sin embargo, al estar el reloj a una frecuencia extremadamente alta para la FGPA ha sido necesario el uso del operador “+”. Este operador garantiza “la mejor implementación” de la suma para la plataforma y esto se traduce, en el caso del Spartan-3E y en el caso de la mayoría de las FPGAs existentes, en que se va a hacer uso de sumadores que ya se encuentran integrados (“hardwired”) en el sustrato de todas las FPGA (todos los fabricantes los incluyen, de mayor o menor cantidad de bits).
¿Qué ventajas tienen estos sumadores con respecto al sumador que hemos estado usando hasta ahora? La principal diferencia es que en nuestro “Adder.vhd” el acarreo es en cascada, mientras que los sumadores implementados a fuego en las FPGAs están basados siempre en circuitos CLA (Carry Look Ahead), que permiten precalcular los acarreos de cada bit sin necesidad de que estén calculados los bits anteriores. Aún siendo circuitos combinacionales tanto los unos como los otros, el tiempo de propagación del resultado en el caso de sumadores con CLA es mucho menor que en el caso de sumadores con acarreo en cascada (como el “Adder.vhd” que hemos usado hasta ahora en los proyectos).
En nuestro caso concreto se da además la circunstancia que, con un reloj a 288 MHz, el sumador con acarreo en cascada (el “Adder.vhd” de siempre) da problemas de timing o, lo que es lo mismo, “no le da tiempo de sumar tan rápido” y no queda otra opción que tirar del operador “+” (cosa que, por otro lado, es lo recomendable ya que se garantiza siempre la mejor implementación).
Vídeo
A continuación un pequeño fragmento de vídeo donde se puede ver y escuchar el invento en funcionamiento. La calidad del audio es bastante baja: usé mi radio-despertador como receptor, en el receptor del móvil se oye mucho mejor… pero no hubiese podido grabarlo :-)
Espero que haya resultado interesante. Todo el código fuente se puede descargar de la sección soft.
[ añadir comentario ] ( 2463 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 4202 )