Saltar al contenido →

Python: visualizar señal de audio con PyAudio

Hola, en esta entrada vamos a probar una librería de Python llamada PyAudio. Se trata de un binding en Python de la conocida librería de audio I/O multi-plataforma y open source PortAudio.

El objetivo es capturar una señal de audio a través del micrófono de nuestro ordenador, para luego visualizar su representación temporal y también su representación frecuencial. Para ello utilizaremos otra librería de representación gráfica en Python llamada PyQtGraph. He elegido esta librería porque proporciona un tiempo de procesado aceptable para poder hacer una representación gráfica en tiempo real de la señal de audio. En unas pruebas preliminares que hice con la librería estándar de Python para gráficos Matplotlib, no conseguí unos resultados aceptables en cuanto a la visualización de la señal, ya que no es una librería optimizada para este tipo de representaciones en tiempo real.

Finalmente para darle un poco más de contenido al proyecto y probar otros conceptos, utilizaremos una configuración  cliente-servidor, donde un proceso cliente se encargará de capturar la señal de audio, enviará la información a través de la red TCP/IP mediante sockets, y finalmente un componente servidor recibirá esta información y la visualizará por pantalla. Es un buen ejemplo para entender como funciona la comunicación por sockets e implementaremos un «protocolo» muy senzillo a nivel de aplicación para el establecimiento de la comunicación entre el cliente y el servidor.

Todo el código fuente lo podréis encontrar aquí.

Dicho esto, veamos como implementamos nuestro proyecto. A continuación os muestro un pequeño esquema de lo que vamos a implementar:

Programa cliente

Como hemos dicho antes, nuestro programa cliente se encargará de captar la señal de audio a través del micro de nuestro ordenador. Para ello utilizaremos la librería PyAudio. Crearemos una clase AudioRecorder donde implementaremos toda la funcionalidad requerida por el proceso cliente, desde captar la señal de audio, hasta el envío al proceso servidor.

Lo primero que hacemos es importar la librería de PyAudio

Luego definimos nuestra clase AudioRecorder con una serie de variables de la clase que utilizaremos para configurar la llamada a la librería PyAudio. En el método de inicialización de la clase (__init__), instanciamos un objeto de la clase PyAudio y lo asignamos a una variable del objeto.

El siguiente paso es definir el método openStream para definir la manera en que PyAudio debe captar la señal de audio del micro y cómo debe tratar las muestras de audio capturado. En nuestro ejemplo, hemos optado por la definición de una función de callback para que sea llamada cada vez que la librería captura un paquete de datos de audio del tamaño que le hemos especificado en el parámetro frames_per_buffer. Otros parámetros que hemos indicado en la llamada a openStream son el tamaño de la variable que almacena cada muestra (en nuestro caso hemos indicado un entero de 16 bits pyaudio.paInt16), los canales de audio a capturar y la frecuencia de muestreo (esto es, el número de muestras por segundo). Para más detalles podeis echar un vistazo en la página web de PyAudio.

Una vez hecho esto, definimos la función callbackFunc que llamará la librería PyAudio cada vez que tenga un paquete de datos de audio a punto para ser procesados. En nuestro caso imprimirá por pantalla un mensaje indicando que ha sido invocada la función de callback, y enviará por red estos datos al servidor. A continuación veremos cómo se hace este envío.

Para el resto del código importamos los módulos que necesitaremos.

En el método __init__ añadimos la inicialización de un socket.

self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Con este socket podremos comunicar nuestro proceso cliente con el proceso servidor a través de una comunicación TCP/IP. Para más información os podeis dirigir a la documentación oficial de socket para Python.

A continuación definimos un método connect con el que indicaremos a nuestro proceso cliente que queremos establecer una connexión TCP/IP con el servidor:

self.sock.connect((self.host, self.port))

El siguiente paso será implementar un pequeño protocolo de comunicación para enviar la información de los parámetros de adquisición de audio al servidor. Para ello definiremos uns secuencia de handshake muy simple, y definiremos una serie de métodos para gestionar la recepción y envío de la información.

Para el intercambio de esta información inicial entre cliente y servidor, cada vez que enviemos información a través del socket, primero enviaremos el tamaño de la información que queremos enviar, y luego procederemos a enviar la información propiamente dicha. En el lado del que «escucha», lo primero que recibimos es el tamaño de la información que recibiremos, y luego recibimos la información. Con esto, cuando recibamos información, determinaremos su tamaño y lo compararemos con el valor del tamaño que el que «escribe» nos ha indicado en el paquete de información anterior.

Este es el comportamiento que se puede ver implementado en el método handshake y en los métodos auxiliares myreceive y readlong

El método handshake se encarga de establecer la comunicación inicial enviando una estructura json con los parámeteros de adquisición de audio. Para ello crea la estructura, calcula su longitud y la envía por el socket. Una vez enviada la longitud de la información, se envía la información en sí, estos es el json con los parámetros de adquisición de audio. Una vez enviada esta información, para finalizar este proceso de handshake, esperamos a recibir respuesta. Para ello primero recibimos la información del tamaño del paquete de datos que nos enviaran posteriormente. Una vez recibido el tamaño de lo que nos van a enviar, recibimos el paquete de datos en si. En caso de que el mensaje que nos envíe el servidor sea un ‘ACK‘ daremos la comunicación por establecida, y en caso contrario finalizaremos la ejecución del programa cliente.

Los métodos myreceive y readlong, se encargan de recibir datos a través del socket. En el caso de readlong, el objetivo es recibir el tamaño del paquete de datos que recibiremos posteriormente. Para ello, calculamos el tamaño en bytes de un unsigned long (4 bytes) y pedimos al socket recibir 4 bytes, desempaquetamos el contenido binario que recibimos del socket y obtenemos el tamaño en formato de número entero. Con myreceive sería un caso más general para leer del socket un paquete de un tamaño variable que pasamos por parámetro al método.

Finalmente hemos definido un método initStream con el que iniciamos un bucle infinito para capturar audio del micro:

myRecorder = AudioRecorder() 
myRecorder.connect() 
myRecorder.handshake() 
if myRecorder.handshaked: 
   myRecorder.openStream() 
   myRecorder.initStream() 

Programa servidor

Vamos a ver como implementamos el componente servidor de nuestro proyecto. Como hemos dicho anteriormente, este programa se encargará de recibir vía socket, los paquetes de información del muestreo de la señal de audio. A medida que vaya recibiendo la información de la señal de audio, el programa servidor visualizará en tiempo real la evolución temporal y frecuencial de la señal (esta última por medio del cálculo de la FFT).

Vamos a ver qué pinta tiene el código del programa servidor:

En primer lugar importamos los módulos de Python que vamos a necesitar. Vamos a comentar dos de ellos. El primero es PyQtGraph. Se trata de una librería de representación gráfica de datos que es muy rápida y por ello es muy recomendable para representar datos que cambian muy rápido en tiempo real.

El segundo módulo es NumPy. Se trata de un paquete para procesado optimizado de arrays de datos de N dimensiones.

Las primeras líneas de código se encargan de preparar el entorno gráfico PyQt, definiendo el layout y color de la ventana donde visualizaremos las gráficas de nuestra señal de audio.

Vamos a encapsular todo nuestro código en una clase que llamaremos AudioServer. En el  método __init__() de nuestra clase, vamos a inicializar nuestro socket de conexión e indicaremos el puerto por el que estaremos escuchando. Posteriormente configuraremos la ventana de gráficos, colocando una etiqueta en la parte superior para visualizar la velocidad de actualización de la gráfica, y definiendo dos subáreas en la ventana donde iran nuestra gráficas: representación temporal de la señal y representación frecuencial de la señal.

Con el método listen pondremos a nuestro servidor en modo «escucha» a la espera de recibir una conexión entrante, en nuestro caso, de nuestro programa cliente.

También definiremos los métodos myreceivey readlong que ya vimos anteriormente en la parte cliente.

Con el método checkParam, verificaremos que el cliente nos ha enviado al inicio de la comunicación todos los parámetros de la captura de audio que necesitamos para hacer la representación gráfica. En caso de que falte alguno se producirá un error.

De manera análoga a como hicimos en la parte cliente, aquí también implementamos un método handshake para establecer al comunicación inicical e intercambiar los parámetros de audio.

En el método initPlot simplemente ajustamos las escalas, etiquetas y títulos de las dos gráficas que iremos visualizando.

Finalmente, en el método receive, es donde implementamos toda la lógica para recibir los datos de la señal de audio y hacer su representación gráfica. Este método se ejecutará periódicamente mediante un temporizador, leerá datos del socket y actualizará los gráficos con nuevos datos. Para recibir datos del socket, cada vez que se ejectute el método receive, se leerán 2*N bytes (recordemos que hemos codificado las muestras con 2 bytes), y se desempaquetarán con el módulo struct por medio de:

count = len(data)/2
format = "%dh"%(count)
shorts = struct.unpack(format,data)

La tupla resultante la convertiremos a un array NumPy mediante:

npshorts = np.asarray(shorts)

A partir de aquí generaremos los gráficos mediante el método plot. Para el caso de la representación frecuencial, tiraremos del módulo FFT de NumPy:

freq = np.fft.rfft(npshorts)

Para finalizar instanciamos nuestra clase con el puerto TCP/IP por el que queremos establecer la conexión, ponemos en escucha nuestro componente servidor con el método listen, realizamos el proceso de handshake con nuestro cliente  y en caso de que todo vaya bien, inicializamos los gráficos y activamos el timer para que se ejecute el método receive periódicamente (este último punto es necesario para que los gráficos de Qt se refresquen , es decir se ejecute el loop propio de Qt y pueda redibujar la ventana) :

myServer = AudioServer(50007, layout, w)
myServer.listen()
myServer.handshake()
if myServer.handshaked:
    myServer.initPlot()
    timer = QtCore.QTimer()
    timer.timeout.connect(myServer.receive)   
    timer.start(0)
else:
    print("No ha estat possible el handshake")
    exit(0)

Resultado final

Con esto ya podemos ejecutar nuestro cliente y servidor. Abrimos dos ventanas con Terminal (en mi caso estoy trabajando con Ubuntu). En la primera ejecutamos el programa servidor:

python3 ./AudioServer.py

I en la segunda ejecutamos el programa cliente:

python3 ./AudioClient.py

Nos aparecerá una ventana como la siguiente, donde se representa en tiempo real la señal de audio y su FFT:

A continuación os dejo un vídeo para que veáis cómo funciona:

Publicado en Audio

Comentarios

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *