Semaine d'informatique - Un peu de son synthétisé en OCaml

Bon, un article un peu différent de mes habitudes: pas d’info théorique en vue, juste un petit projet que j’ai réalisé pour le fun hier: un mini-synthé en OCaml.

Donc, ici mon objectif va être de vous présenter le code, et d’expliquer son fonctionnement. Le code sera suffisament complet pour que vous puissiez simplement le copier dans un fichier .ml et l’exécuter.

Tout d’abord, voyons la structure du code: on va avoir un module Signal, qui représente un… bah, un signal sonore. Ce module est en fait un foncteur puisqu’il va être initialisé avec une fréquence d’échantillonnage spécifique, ce qui permet de ne pas avoir à la rappeler dans chaque fonction en faisant attention à ne jamais la changer (elle sera utile à plusieurs endroits et doit être constante).

module type Sampler = sig
  val rate : int
end

module Signal (S : Sampler) = struct
	(* Laissé vide, on va le remplir au fur et à mesure. *)

La première question est celle de comment représenter les signaux. Il y a plein de manières, que ce soit des listes ou autre chose. Ici, on choisit de représenter un signal par une fonction int -> float, qui prend en entrée le moment d’échantillonnage et nous donne la sortie. Cette représentation a des problèmes quand on essaye de synthétiser efficacement des sons complexes, mais pour des sons basiques elle est très simple d’usage dans un langage fonctionnel comme OCaml et c’est donc celle que j’ai choisie ici.

	type t = int -> float

Ensuite, définissons des signaux très simples (constants), et des moyens de les combiner. Pour les signaux constants, le signal est le même partout et correspond donc à une fonction constante. Pour simplifier la définition de signaux, on ajoute simplement la notation !n pour le signal constant de valeur n. Pour les moyens de les combiner, commençons par simplement permettre les 4 opérations de base (+, -, *, /), qui opèrent point par point. Pour un exemple d’usage, * et / permettent de changer le volume d’un signal, et + et - permettent de jouer plusieurs signaux simultanés.

	let ( ! ) v = fun _ -> v

	let ( + ) v v' = fun i -> v i +. v' i
	let ( - ) v v' = fun i -> v i -. v' i
	let ( * ) v v' = fun i -> v i *. v' i
	let ( / ) v v' = fun i -> v i /. v' i

Tout celà est pratique pour définir des signaux, mais pour que ces signaux soient audibles, il faut qu’ils varient. Ainsi, on définit 4 signaux périodiques classiques: la sinusoide, le carré, le triangle et la dent de scie.

Notons que, pour avoir la fraction de seconde correspondant à l’instant que l’on évalue, on a besoin de la fréquence d’échantillonnage: on doit diviser l’indice par cette fréquence pour obtenir l’instant en secondes.

	let sine f = fun i -> Float.sin (float_of_int i /. float_of_int S.rate *. 2. *. Float.pi *. f)
	(* Un simple sinus. On multiplie par 2πf pour avoir la bonne fréquence.
	 * Notons que ceci ne marche que pour un sinus à fréquence constante:
	 * pour un sinus dont la fréquence est elle même un signal qui varie,
	 * il faut faire une intégrale, ce qui est très peu efficace avec la
	 * représentation des signaux que l'on a choisi. *)

	let square f = fun i -> if sine f i > 0. then 1. else -1.
	(* On prend le sinus, et on dit que tout valeur au dessus de 0 est 1,
	 * et les autres -1. On pourrait l'implémenter plus efficacement en
	 * faisant un modulo sur l'instant, mais on privilégie ici
	 * l'implémentation simple au possible. *)

	let triangle f = fun i -> 2. /. Float.pi *. Float.asin (Float.sin (float_of_int i /. float_of_int S.rate *. 2. *. Float.pi *. f))
	(* Même principe que le sinus, mais avec une autre formule. *)

	let saw f = fun i -> (fst @@ Float.modf @@ float_of_int i /. float_of_int S.rate *. f) *. 2. -. 1.
	(* Ici, notre première formule sans trigonométrie: pour le triangle, on
	 * fait simplement la partie décimale du temps (multiplié bien sûr par
	 * la fréquence). *)

Enfin, c’est bien beau de les définir, mais il faut pouvoir écouter ces signaux ! Pour celà, notre module a besoin d’une dernière fonction, pour extraire une liste d’échantillons d’un signal, que l’on pourra ensuite envoyer directement dans notre carte son. Cette fonction prend bien sûr un signal, une durée pour savoir combien d’échantillons retourner, et une phase. La phase est importante car, sans elle, on prendrait toujours le signal du début. Or, si on a un signal que l’on veut jouer particulièrement longtemps, il peut être utile de le récupérer en plusieurs fois. Mais, si on recommence du début à chaque fois, on n’aura jamais la fin. Et, même dans le cas d’un signal périodique (comme tout ceux supportés actuellement par notre système), si on change d’endroit au milieu on va avoir un clac désagréable dans le son.

	let sample ~phase ~duration s =
		let duration = int_of_float (duration *. float_of_int S.rate)
		(* On calcule le nombre d'échantillons qu'on va retourner *)
		and phase = int_of_float (phase *. float_of_int S.rate) in
		(* ... et l'indice du premier. *)
		Array.init duration (fun i -> s (Int.add phase i))
		(* Ensuite, il suffit de retourner le tableau des <durée>
		 * valeurs du signal commençant par la valeur à l'indice
		 * <phase>. *)
end

Maintenant, aspect plus technique, définissons quelques signaux et utilisons les pour faire une mélodie, et surtout appelons les bibliothèques nécessaires pour faire jouer cette mélodie par notre ordinateur.

let rate = 96000
module Signal = Signal (struct let rate = rate end)
(* Ici, on obtient le module de gestion des signaux, à un rythme de 96000
 * échantillons par seconde. *)

let note i n = Signal.(sine (220. *. Float.pow 2. (float_of_int i /. 12.)) / !(float_of_int n))
let note' i n = Signal.(saw (220. *. Float.pow 2. (float_of_int i /. 12.)) / !(float_of_int n))
(* Ici, on définit deux fonctions utilitaires pour jouer des notes, une pour un
 * sinus et une pour une dent de scie.
 * On utilise une fréquence de 220 * 2^(i / 12), ce qui permet d'obtenir la
 * i-ième note après le la. En effet, dans le système le plus utilisé dans la
 * musique moderne habituelle, les fréquences des notes sont séparées par un
 * facteur de la douzième racine de 2 (pour que les octaves soient séparées
 * d'un facteur deux exactement): c'est ce qu'on appelle la gamme tempérée. *)
(* On divise le résultat par une constante pour pouvoir jouer plusieurs notes
 * ensembles sans saturer le son. *)

let chord i = Signal.(List.fold_left ( + ) !0. @@ List.map (fun i -> note i 20) i)
(* Ici, on dit tout simplement qu'un accord est un ensemble de notes additionées. *)

Enfin, passons à l’usage du son de l’ordinateur.

let () =
	(* Ici, on initialise la bibliothèque sonore, ainsi qu'un flux de son. *)
	Portaudio.init ();
	let stream = Portaudio.(open_default_stream ~format:format_float32 0 1 rate 256) in
	Portaudio.start_stream stream;

	(* Là, on définit une fonction qui va, quand on lui donne un signal et
	 * une durée, jouer ce signal pendant cette durée.
	 * Elle a une variable mutable "phase", qui lui permet de continuer le
	 * signal là ou elle a arrêté le précédent: si le signal précédent et
	 * le suivant ont des composantes en commun, celà évite de les
	 * interrompre et limite donc les artefacts. On ajoute simplement la
	 * durée de chaque note à la phase. *)
	let phase = ref 0.0 in
	List.iter (fun (signal, duration) ->
		let s = Signal.sample ~phase:!phase ~duration signal in
		phase := !phase +. duration;
		Portaudio.write_stream stream [|s|] 1 (Array.length s))
	(* Enfin, ici, le concat_map permet de simplifier un peu l'écriture
	 * d'accords joués avec des notes : pour chaque groupe signal c +
	 * signaux s + durée, on retourne une liste de signaux tous de ladite
	 * durée, où on ajoute le signal c à chacun des signaux s à tour de
	 * rôles: c est l'accord joué par dessus la mélodie s.
	 * Si l'on veut faire des notes de durées différentes, il faut faire un
	 * nouveau groupe, d'où l'intérêt de la phase pour préserver les
	 * accords. *)
	@@ List.concat_map
		Signal.(fun (s, n, d) -> List.map (fun n -> (s + note' n 20, d)) n)
		[
		  (chord [12; 17; 20], [8; 5; 0; 5; 8; 5; 0; 5], 0.18);
		  (chord [12; 15; 20], [8; 3; 0; 3; 8; 3; 0; 3], 0.18);
		  (chord [12; 16; 19], [7; 4; 0; 4; 7; 4; 0; 4], 0.18);
		  (chord [12; 16; 19], [7; 4; 0; 4], 0.18);
		  (chord [12; 16; 19], [7; 12], 0.18 *. 2.);
		];
	(* Et une fois fini on arrête bien sûr la bibliothèque sonore. *)
	Portaudio.terminate ()

Voilà, un petit résumé d’un petit projet d’une soirée. Si vous essayez ce code (avec la bibliothèque ocaml-portaudio), dites-moi si vous reconnaisser la petite mélodie que j’ai glissé à la fin comme exemple.

À bientôt,
Amélia Coutard-Sander.