Trabajo Práctico 1
Fecha de entrega: Domingo 2 de agosto
Objetivo del TP
Objetivo
El objetivo del presente trabajo práctico es la implementación de un sintetizador de audio capaz de convertir archivos MIDI en archivos WAVE.
El trabajo consiste en la resolución del problema mediante el diseño y uso de TDAs y en la reutilización de las rutinas desarrolladas a lo largo del curso en trabajos anteriores.
Nota
Se reitera: Este trabajo es de diseño y aplicación de TDAs. Si no entendiste bien el concepto de TDAs o la metodología de desarrollo estudiá eso primero.
Tanto un tipo como un trabajo práctico primero se diseña y luego se implementa y la instancia de diseño no es optativa sino necesaria.
Si tenés la idea de seguir adelante sin tener en claro los temas que se aplican o sin diseñar el trabajo práctico primero es bajo tu exclusiva responsabilidad.
Alcances
Mediante el presente TP se busca que el estudiante adquiera y aplique conocimientos sobre los siguientes temas:
Encapsulamiento en TDAs,
Concepto de TDA contenedor,
Punteros a funciones,
Archivos,
CLA,
Modularización,
Técnicas de abstracción,
además de los temas ya evaluados en trabajos anteriores.
Introducción
Si bien ya estuvimos construyendo el andamiaje necesario a lo largo del EJ1, EJ2, EJ3, EJ4 y EJ5 vamos a repasar e integrar todos los contenidos desarrollados hasta el momento.
El sonido
Sabemos que lo que sentimos como sonido se debe a la vibración de ondas en el aire que impactan en nuestros oidos.
En los sonidos podemos distinguir tres cosas:
- Intensidad:
Por la cual un sonido es más fuerte que otro.
- Tono:
Por el cual un sonido es más agudo que otro.
- Timbre:
Por el cual no todas las cosas del mismo tono suenan igual.
Construyamos de a pasos nuestro modelo con la adición de sus componentes.
Supongamos una onda pura que comienza en determinado momento y tiene una duración dada. Supongamos que comienza en 0, dura un cuarto de segundo, oscila a con un tono de 110 Hz y tiene una intensidad de 1.
Sería así:
Muy linda, pero no es algo que se pueda generar por medios físicos.
Una onda física no podría alcanzar su máxima potencia instantaneamente ni tampoco atenuarse instantaneamente. Si oyéramos una onda de esas características sentiríamos un chasquido al comienzo y al final de la misma.
Una onda producida por un elemento físico tendrá un incremento paulatino durante su período de ataque, un comportamiento particular durante su período de sostenido y luego se apagará en un tiempo dado por su decaimiento.
Para la misma onda anterior podemos agregar esto a nuestro modelo definiendo el intervalo \(0 < t < 0.05\) como el ataque, \(0.05 < t < 0.25\) como el sostenido y \(t > 0.25\) como el decaimiento:
Notar que la onda sigue durando 0,25 s, pero como no se apaga inmediantamente suena por más tiempo. Notar además que durante la etapa de sostenido la onda baja poco a poco su intensidad por disipación de energía.
Esto que representamos hasta ahora es una onda pura. Una onda pura aburrida como la del tono de un teléfono. En los sonidos producidos por elementos físicos aparecen un montón de otras frecuencias parásitas (generalmente armónicas de la frecuencia fundamental) que son las que constituyen el timbre característico de lo que esté sonando.
Si le agregáramos a nuestra onda los armónicos que ya utilizamos desde el EJ2 nos quedaría así:
En nuestro modelo sencillo el sonido que emita un instrumento estará compuesto por una intensidad, tono, timbre, inicio, duración y además estará modulada en amplitud por una función de ataque, otra de sostenido y otra de decaimiento.
Esto no es un modelo completo de síntesis, pero es el que vamos a desarrollar.
Nota
Si no entendiste qué es lo que representa alguna de las palabras en cursivas en el párrafo anterior releé el capítulo hasta fijarlo.
(Sí, voy a poner notas que señalen cosas que deberían ser obvias.)
La escala musical
La música se genera en base a sonidos, pero no en base a cualquier sonido sino que se construyen escalas que fijan qué tonos están permitidos. Las escalas se construyen dentro de una octava y se repiten en las diferentes octavas. Una octava es el intervalo que hay entre dos notas donde una duplica la frecuencia de la otra.
En el mundo occidental se utilizan las escalas dodecafónicas, que se construyen a partir de 12 particiones de la octava llamadas semitonos. Si bien hay varias formas de realizar esta partición, en la mayor parte de los instrumentos digitales se emplea la escala temperada que divide la octava en 12 partes iguales... ¿Iguales cómo?, no olvidarse de que las octavas se construyen duplicando frecuencias por lo que tenemos que mirar estas particiones en escala logarítmica. Entonces, si tuviéramos la frecuencia de una nota de frecuencia \(f\), el semitono siguiente estará a \(f\,\sqrt[12]2\) (notar que la raiz se convierte en un factor de un doceavo si se aplicaran logaritmos).
Con todo esto, dada una nota cualquiera podríamos construir el resto de la escala, pero antes nos falta una única cosa y es tener una frecuencia de referencia para derivar las demás en función de ella. Ahí recurrimos al comité ISO que fija el La de la octava 4 con una frecuencia de 440 Hz.
Entonces, si tenemos la frecuencia de una nota, por ejemplo el La de la octava 4 y quisiéramos la frecuencia del Si de la octava 6 podemos derivar una de la otra. El La de la octava 5 (A5) tendrá frecuencia 880 Hz, el A6 tendrá frecuencia 1760 Hz. Luego, el A#6 tendrá frecuencia 1864,7 Hz (\(1760\,\sqrt[12]2\)) y finalmente el B6 tendrá frecuencia 1975,5 Hz. Podemos hacer esta cuenta más compacta como \(440\,2^{2 + \frac2{12}}\) si acumulamos las operaciones que realizamos, donde el exponente es \(o + \frac{s}{12}\) con \(o\) el número de octavas con respecto a la referencia y \(s\) la cantidad de semitonos (notar que podría omitirse la octava dado que por cada octava se tienen 12 semitonos, usar lo que se considere más práctico).
El formato MIDI
¿Qué se puede decir del formato MIDI que no se haya dicho ya en el EJ4 y el EJ5?
Bueno, un par de cosas:
Los instrumentos se ejecutan por canal, es decir, a cada número de canal se le asocia un instrumento y este instrumento será el mismo para ese canal para todo el archivo.
Las notas se codifican con un evento para el momento de inicio y un evento para el momento de finalización. Entonces el inicio de la nota será en el momento del evento de inicio mientras que la duración de la nota será el tiempo transcurrido entre el inicio y la finalización.
Siempre habrá pares inicio-finalizado para cada nota en una pista.
El inicio de la nota siempre se marcará con un evento nota encendida donde la velocidad será distinta de cero. El final de una nota se marcará o bien con un evento nota apagada o bien con un evento nota encendida con la velocidad puesta en cero (esto se suele representar así porque es mucho más económico que una pista tenga únicamente eventos de nota encendida al no tener que repetir la información del evento).
La intensidad de la nota estará dada por la velocidad del evento nota encendida (asumiendo que el mismo sea distinto de cero).
Los tiempos en MIDI se representan en unidades de pulsos. El archivo MIDI define la cantidad de pulsos por negra o por segundo, y en música se suelen especificar la cantidad de negras por minuto.
El significado del parámetro de pulsos por negra del encabezado es intrincado de procesar y en el caso de estar especificado en pulsos por negra luego hay un metaevento que define las negras por minuto de forma variable a medida que transcurre el tiempo... como no queremos recaer en eso ignoraremos esta información del MIDI y utilizaremos una cantidad prefijada de pulsos a segundo y con eso convertiremos pulsos en tiempos.
El formato WAVE
El formato WAVE forma parte de la especificación RIFF de Microsoft. El mismo permite almacenar muestras de audio sin compresión en modo raw.
Un archivo WAVE es un archivo binario little-endian compuesto por un encabezado y luego por dos chunks donde el primero contiene la información del archivo y el segundo contiene las muestras de audio.
Para este trabajo se utilizará la variante PCM que es sin compresión, con 16 bits de resolución y formato mono.
Un archivo WAVE de estas características será así (varios valores están hardcodeados para forzar los 16 bits, mono, etc.):
Campo |
Significado |
Tipo |
Valores |
---|---|---|---|
ChunkId |
Identifica un archivo RIFF |
|
Vale siempre |
ChunkSize |
Tamaño del archivo |
|
\(36 + 2n\) |
Format |
Identifica un WAVE |
|
Vale siempre |
SubChunk1ID |
Encabezado chunk1 |
|
Vale siempre |
SubChunk1Size |
Tamaño chunk1 |
|
Vale siempre 16 |
AudioFormat |
Formato de audio |
|
Vale 1 |
NumChannels |
Número de canales |
|
Vale 1 |
SampleRate |
Tasa de muestreo |
|
8000, 44100, etc. |
ByteRate |
Tasa de bytes |
|
2 x SampleRate |
BlockAlign |
Alineación de bloque |
|
Vale 2 |
BitsPerSample |
Bits por muestra |
|
Vale 16 |
SubChunk2ID |
Encabezdo chunk2 |
|
Vale siempre |
SubChunk2Size |
Tamaño chunk2 |
|
\(2n\) |
Data |
Muestras |
|
Secuencia de \(n\) muestras |
(Tenés esto mismo con diagramitas, dibujitos lindos y ejemplos en el enlace de la sección de referencias.)
Notar que las muestras son de tipo int16_t
signados. El signo se almacena en
formato complemento a dos pero esto no es relevante: Se puede asumir que es el
mismo formato en el que cualquier computadora almacena los números signados.
Como las muestras son números enteros de 16 bits las muestras podrán valer
cualquier valor entre -32768 y 32767. Ahora bien, desde el EJ2 que venimos
almacenando nuestras muestras como números flotantes y esto está hecho así de
forma intencional: En una variable flotante se pueden almacenar un número de
cualquier módulo y uno a priori no sabe cuál va a ser el valor máximo de las
muestras. Pero una vez que uno ya tiene muestreado todo el audio sobre
flotantes es sencillo buscar el valor de la muestra máxima y normalizar a todas
ellas para que estén en el rango de un int16_t
.
Trabajo
Aplicación
Se debe implementar una aplicación sintetizador
que pueda ser ejecutada de la
siguiente manera:
$ ./sintetizador -s <sintetizador.txt> -i <entrada.mid> -o <salida.wav> [-c <canal>] [-f <frecuencia>] [-r <pulsosporsegundo>]
Donde los parámetros representan
Parámetro |
Condición |
Significado |
Valor por omisión |
---|---|---|---|
|
Obligatorio |
Archivo de sintetizador |
-- |
|
Obligatorio |
Archivo MIDI de entrada |
-- |
|
Obligatorio |
Archivo WAVE de salida |
-- |
|
Optativo |
Número de canal del MIDI |
0 |
|
Optativo |
Frecuencia de muestreo |
44100 |
|
Optativo |
Pulsos por segundo |
(Después les especificamos uno ;)) |
Nota
Que un parámetro sea optativo quiere decir que el usuario puede ingresarlo o no. En caso de no ingresarlo debe tomarse el valor por omisión.
Nota
Se recomienda pensar una solución genérica al problema del procesamiento de argumentos donde no importe el orden en el que los mismos son ingresados. Cada parámetro puede ingresarse una única vez, pero en el caso de que alguno se ingrese múltiples veces se puede tomar uno solo de sus valores.
Al ser ejecutado el programa debe leer el archivo MIDI de entrada y muestrear los eventos del canal indicado en el archivo WAVE de salida. Para convertir los pulsos de los eventos del MIDI a segundos se utilizará el parámetro de pulsos por segundo. Las muestras se generarán a la frecuencia indicada. Más adelante se describe el formato del archivo de sintetizador.
Sintetizador
Para realizar la síntesis de una pista, la misma se realizará nota por nota, donde cada nota tendrá sus parámetros de inicio, duración, frecuencia e intensidad.
Por otro lado el sintetizador tendrá los parámetros de las frecuencias e intensidades, y la configuración de ataque, sostenido y decaimiento.
Copypasteando del EJ2 sabemos que una nota se sintetizará como
donde \(a\) es la intensidad y \(f\) es la frecuencia de la misma. Además sabemos que esta nota sonará entre un tiempo \(t_0\) y un tiempo \(t_0 + d\) donde \(d\) es la duración de la nota. Ahora bien, ya sabemos que una nota no se apagará inmediatamente en este tiempo si no que habrá un tiempo de decaimiento \(t_d\) por lo que el tiempo de finalización real será \(t_0 + d + t_d\).
Hasta este lugar, ajustando los parámetros adecuadamente, estamos ante la síntesis implementada para el EJ2 y el EJ3. Ahora introduciremos el detalle final que son las funciones de ataque, sostenido y decamiento.
La idea es multiplicar la \(f(t)\) de cada una de las notas individualmente por una de estas 3 funciones dependiendo del valor de \(t\).
Recordemos que definíamos un ataque que se dará entre el tiempo \(t_0\) y el tiempo \(t_0 + t_a\), luego un sostenido que se dará entre el tiempo \(t_0 + t_a\) y el tiempo \(t_0 + d\), y finalmente un decaimiento que será entre el tiempo \(t_0 + d\) y el tiempo \(t_0 + d + t_d\).
Siendo \(f_a(t), f_s(t)\) y \(f_d(t)\) las funciones de ataque, sostenido y decaimiento respectivamente la envolvente por la que se multiplicará la onda \(f(t)\) será:
Luego veremos cuáles son las distintas funciones posibles, pero se observará que las mismas constituyen una función continua.
Recapitulando, cada nota será finalmente:
es decir, simplemente multiplicar la onda generada por tramo_crear_muestreo
por la función \(m(t)\). Luego, cada nota puede sumarse a las ya generadas
con tramo_extender
.
Funciones de modulación
Las funciones disponibles para modular el ataque (A), sostenido (S) y decaimiento (D) son las siguientes:
Algunas observaciones:
El parámetro \(t_0\) es el parámetro \(t_a\) en las funciones de ataque y el parámetro \(t_d\) en las funciones de decaimiento.
Notar que todas las funciones de ataque van de 0 a 1 en \(t_a\), todas las de decaimiento de 1 a 0 en \(t_d\) y que todas las de sostenido empiezan en 1... por lo que, como dijimos \(m(t)\) es continua.
Si bien las funciones tienen un número variable de argumentos entre 0 y 3 es viable implementar funciones del estilo:
float modulacion_constante(double t, float params[3]) { return 1; }
donde todas compartan la misma firma. Además cabe destacar que
params[0]
será elt_a
y elt_d
en las funciones de ataque y decaimiento.
Nota
Si leíste esta sección y no pensaste en punteros a funciones y en diccionarios andá a repasar esos temas y volvé.
Archivo de sintetizador
El archivo del sintetizador será un archivo de texto plano como el siguiente:
8
1 0.577501
2 0.577501
3 0.063525
4 0.127050
5 0.103950
6 0.011550
7 0.011550
8 0.011550
TRI 0.05 0.03 1.3
CONSTANT
INVLINEAR .02
donde el 8 indica que hay 8 armónicos, luego sigue una línea por cada armónico con su multiplicador y su intensidad, y finalmente tres líneas cada una de ellas con el nombre de la función de modulación y sus parámetros, la primera para el ataque, la segunda para el sostenido y la última para el decaimiento.
Se incluyeron las funciones TRI
, CONSTANT
y LINEAR
en el ejemplo para
mostrar diferente número de parámetros. La envolvente presentada en la
introducción tiene parámetros:
LINEAR 0.05
INVLINEAR 5
INVLINEAR .02
(Con los armónicos con los que venimos robando desde el EJ2.)
Nota
La idea de este formato de archivo es que sea sencillo de procesar, de a una línea por vez, simplemente extrayendo valores numéricos separados por espacios. Eso sabemos hacerlo desde el parcialito 1.
Nota
Puede asumirse que la cantidad máxima de armónicos a procesar es un número fijado en tiempo de compilación.
Diseño
Lo que ya está presentado es el enunciado completo y toda la información sobre qué hay que resolver ya está cubierta. En esta sección se intentarán proponer un par de lineamientos para ayudar a orientar el diseño de parte del alumno.
Nota
Si es tu idea que esta sección te ahorre diseñar el trabajo te equivocaste.
Si no hacés previamente el trabajo de entender cuál es el poblema que tenés que resolver e intentás abordar dicho diseño todo lo que podamos decir en esta sección va a sonarte como si estuviera escrito en Klingon.
Iterá esta sección con tu desarrollo para clarificar hacia dónde vas.
Esta es una listita de cosas sin un orden particular:
Te recomendamos empezar por un TDA sencillo como lo es la nota.
La modularización y abstracción es siempre un balance, tenés que modularizar el trabajo y tenés que hacer TDAs, pero no todo tiene que ser un TDA, algunas cosas pueden ser una función o un grupo de funciones sueltas.
Te recomendamos mantener sencillas las partes de lectura de MIDI, escritura del WAVE y procesamiento de argumentos. Estas partes pueden tener que interactuar con otros TDAs, pero pueden ser funciones sueltas y sencillas.
No esperamos que el procesamiento de un archivo binario sea elegante, la lectura o escritura de un formato binario es una sucesión de casos particulares. Tenés el
main()
del EJ5 como base para la lectura de MIDI.Fijate que no hace falta tener vectores de tramos en ningún momento, la interfaz de las funciones del EJ3 se diseñaron para poder hacer:
tramo_t *tramo; para cada nota en el MIDI: Sintetizo la nota en cuestión tramo_extender(tramo, nota_sintetizada)
esto va a funcionar eficientemente, se encarga de ajustar el tamaño del tramo solo, etc. (Ojo: La primitiva de tramo que ya está no modula el ataque, sostenido y decaimiento.)
Volviendo al tramo del EJ3, no te olvides de que a esa altura del cuatrimestre no sabíamos TDAs. Si vas a reutilizarlo (guiño guiño: reutilizalo), completalo de tal manera de que sea uno.
Los TDAs son cajitas cerradas. Tienen un único propósito. Conocen un único problema. Saben sólo hacer eso. No conocen cuál es el contexto del problema que van a resolver. Vamos a considerar mal tanto un TDA donde Alan asuma cosas que son responsabilidad de Bárbara como un uso de un TDA donde Bárbara se meta en los detalles de la implementación de Alan.
Diseñá cuál es el objetivo de cada TDA y su invariante. Documentalo.
Tené en claro qué TDA usa a qué TDA, tené en claro qué está a más alto nivel que otra cosa. La jerarquía tiene que ser explícita.
Sólo las primitivas pueden acceder a la representación interna del TDA.
Las primitivas tienen que ser genéricas (por ejemplo si tuvieras un TDA Color que tenga una primitiva que usa imágenes tenés algo mal, la Imagen usa al Color, el Color no conoce en qué contexto existe y no puede depender de un TDA que está a más alto nivel que él).
Antes de programar cualquier función preguntate de quién es la responsabilidad de conocer eso y delegale al TDA o la función correspondiente...
Te deberías hacer preguntas de este estilo:
Tengo un TDA Nota, tengo un TDA Sintetizador. El TDA Nota recuerda nombres de notas Do, Re, Mí y sabe lo que es una nota, mientras que el TDA Sintetizador sabe sintetizar frecuencias. ¿En cuál de los dos TDAs va a estar la primitiva que convierta A4 en 440 Hz?, ¿en el que sabe qué son las notas o en el que sólo conoce frecuencias?, ¿quién entre Alan Notas y Alan Sintetizador debería estudiar el problema de la traducción de una nota?, ¿es esto algo que Bárbara Sintetizador tiene la responsabilidad de resolver o es algo que puede delegarle a Alan Notas?
para cada una de las primitivas que diseñes.
Nota
Además de hacerte las preguntas, la idea sería que las respondas.
(Y preferentemente antes de responder pensala primero.)
Entendé las funciones que ya implementaste en los ejercicios anteriores. Muchas de ellas van a ser útiles. La mayor parte de ellas pueden usarse como ya vienen. Algunas te pueden servir de base para hacer cosas nuevas. No pierdas de vista la delegación y la jerarquía (por ejemplo, si te pareció buena idea que las funciones de
tramo_t
reciban un TDA Sintetizador como parámetro probablemente no estés entendiendo quién usa a quién).Testeá pequeñas piezas de código. Si pretendés generar tu primer WAVE en base a un archivo MIDI de 10.000 eventos probablemente no vaya funcionar nada. ¿Por qué no testeás tu WAVE con una onda pura? No te hace falta nada adicional para hacerlo y si anda ya sabés que tu código funciona bien (y si no anda ya sabés dónde está el error).
Queremos ver un
main()
elegante, que sea una sucesión de llamadas a funciones de alto nivel que nos muestren cómo es el flujo de la aplicación. La lógica de la función principal es de alto nivel.
Nota
Lo siguiente no cumple con la premisa anterior:
int main(int argc, char *argv[]) {
return main2(argc, argv);
}
Quedate tranquilo: El enunciado es largo porque intenta abarcar muchas aristas y poner la mayor cantidad de información a disposición para que puedas orientar tu diseño a algo que funcione. Si seguís pautas de diseño y desarrollo razonables el trabajo va a estar bien.
Si pensás tirarte a escribir código sin entender el problema porque creés que la inspiración divina te va a llegar vía el compilador, te recomendamos no perder el tiempo.
Entrega
Requisitos
Tenés que tener al menos:
Un TDA Nota.
Un TDA que permita almacenar las notas de un (canal de un) MIDI.
Un TDA Sintetizador.
Un TDA que permita almacenar las muestras (cof cof tramo).
La idea sería que el módulo de lectura del archivo MIDI devuelva el contenedor con las Notas, y que ese contenedor se le pase al Sintetizador que será capaz de generar las muestras correspondientes. Esas muestras se le pasarán al módulo de escritura para generar el archivo WAVE.
Nota
El enunciado de este trabajo es una base de lo que tenés que implementar. Si implementás lo que se pide (y lo hacés bien) tenés un 10.
Por fuera de eso: Si querés agregarle cosas al enunciado que no se piden y que tenés ganas de hacer (ejemplos: que el sintetizador soporte más de un instrumento, el programa más de un canal, que se lean los tiempos del MIDI, etc.) podés hacerlo. Ahora bien implementá primero lo pedido, no te distraigas, no te empantanes en algo que se va de los alcances del trabajo.
No pierdas de vista de que la prioridad es terminar este trabajo para aprobar la materia.
Entregables
Deberá entregarse:
El código fuente del trabajo debidamente documentado.
El archivo
Makefile
para compilar el proyecto.
Nota
Nota obvia número 53
El programa debe:
estar programado en C,
estar completo,
compilar...
sin errores...
sin warnings,
correr...
sin romperse...
sin fugar memoria...
resolviendo el problema pedido...
esto es generando un WAVE válido a un MIDI de entrada.
Trabajos que no cumplan con lo anterior no serán corregidos.
La entrega se realiza a través del sistema de entregas.
El ejercicio es grupal permitiéndose grupos de hasta 2 integrantes.
Si elegís hacer el trabajo en grupo te pedimos que nos avises o por mail o por Discord quién es tu compañero de grupo así lo cargamos en el sistema de entregas. (Si el grupo sufriera alteraciones avisanos también.)
Referencias
- Endianness:
- Lista de frecuencias de muestreo habituales:
https://en.wikipedia.org/wiki/Sampling_(signal_processing)#Sampling_rate
- Formato WAVE:
- Formato MIDI:
http://www.cs.cmu.edu/~music/cmsip/readings/Standard-MIDI-file-format-updated.pdf