Outils pour utilisateurs

Outils du site


numeriser_jean_de_la_fontaine

Numériser Jean de La Fontaine

Contexte

Dans le cadre du Bestiaire d'Intelligences Artificielles, nous avons décidé de ressusciter ce fameux Jean de La Fontaine.

C'est Serge qui a fait le plus gros du travail en défrichant la technologie aitextgen, qui utilise Python et la GPT2 afin de proposer une intelligence artificielle capable de produire du texte à partir d'un corpus de base, en imitant le style et le vocable de l'auteur original.

Dans notre cas, c'est donc l'auteur des fables qui a été choisi pour ce cruel exercice.

Je suis parti assez rapidement sur le projet, sans prendre trop le temps de me renseigner sur les technologies manipulées. Vous pardonnerez donc les lacunes explicatives des étapes qui suivent.

Installaton d'aitextgen

J'ai pour ce faire utilisé les instructions de Serge, présentes sur la page Bestiaire IA Fables de la Fontaine.

Pour des raisons pratiques, on commence par mettre-à-jour pip et installer le module d'environnement virtuel de Python :

# Installation et Mise à jour du module pip
sudo apt install python3-pip
python3 -m pip install --upgrade pip
# Installation de venv
sudo apt install python3-venv

pip permet d'installer de manière simple les différentes librairies Python. Quand à l'environnement virtuel, il permet de faire cohabiter plusieurs projets Python en parallèle dont les librairies seraient contradictoires (par exemple si deux versions différentes d'une même librairie sont nécessaires à deux projets). Plus d'info sur ces sujets, en anglais, ici.

Après cela, une fois dans le bon dossier ( par exemple /home/machin/projets ), nous clonons le répertoire de Serge depuis git, ce qui nous facilite toute l'installation. Attention, il y a 2Go à télécharger, c'est un peu long :

git clone https://github.com/sergeLabo/La_Fontaine
# Allez dans le dossier des sources
cd ./La_Fontaine
python3 -m venv mon_env
source mon_env/bin/activate
python3 -m pip install -r requirements.txt

Vous pouvez voir qu'après avoir récupéré le dossier, nous mettons en place l'environnement virtuel. De fait, à partir de maintenant, nous n'utiliserons plus la commande Python de base, mais nous appellerons le binaire de l'environnement virtuel :

./mon_env/bin/python3 mon_fichier.py

Entraîner l'intelligence artificielle à partir d'un corpus

Vous pourrez trouver le corpus qui a servi de base d'entraînement pour ce projet : fables.txt. J'ai essayé à partir d'un autre corpus : l'Éthique de Spinoza. Le résultat était aussi chiant que l'original.

Il vous faudra donc avoir un fichier texte qui contient le modèle textuel à calquer. Attention, plus celui-ci est grand, mieux cela fonctionne. Et quand je dis grand, j'entends bien démesurément grand… Les modèles des Fables, ou de l'Éthique, qui fait pourtant 150 pages, sont beaucoup trop petit pour entraîner de manière correcte le modèle. C'est comme ça.

Il sera souvent nécessaire de retoucher le fichier texte avant l'entraînement. Dans le cas de l'Éthique, les numéros de pages étaient encore présent dans le fichier texte, je les ai donc supprimés. Il y avait également des césures à la plupart des sauts de lignes… Je n'ai pas eu le courage de trouver l'algorithme pour les corriger. Il n'apparaissaient pas dans les textes générés mais je pense qu'ils ont limité l'apprentissage. De la même manière, il faut supprimer les notes de bas de pages, les légendes des dessins, etc.

Une fois votre corpus enregistré en fichier texte dans le dossier, voici le script python qui permet de ré-entraîner le modèle :

text_training.py
from aitextgen.TokenDataset import TokenDataset
from aitextgen.tokenizers import train_tokenizer
from aitextgen.utils import GPT2ConfigCPU
from aitextgen import aitextgen
 
# The name of the downloaded text for training
file_name = "fables.txt"
 
# Train a custom BPE Tokenizer on the downloaded text
# This will save one file: `aitextgen.tokenizer.json`, which contains the
# information needed to rebuild the tokenizer.
train_tokenizer(file_name)
tokenizer_file = "aitextgen.tokenizer.json"
 
# GPT2ConfigCPU is a mini variant of GPT-2 optimized for CPU-training
# e.g. the # of input tokens here is 64 vs. 1024 for base GPT-2.
config = GPT2ConfigCPU()
 
# Instantiate aitextgen using the created tokenizer and config
ai = aitextgen(tokenizer_file=tokenizer_file, config=config)
 
# You can build datasets for training by creating TokenDatasets,
# which automatically processes the dataset with the appropriate size.
data = TokenDataset(file_name, tokenizer_file=tokenizer_file, block_size=64)
 
# Train the model! It will save pytorch_model.bin periodically and after completion to the `trained_model` folder.
ai.train(data, batch_size=8, num_steps=50000, generate_every=5000, save_every=5000)

Il faut spécifier le fichier texte du corpus à la ligne 7 .

D'autres configurations sont possibles, ici c'est la version qui utilise le CPU. Sur ma machine, équipée d'un Core I5 9th Gen, les entraînements pour les Fables et l'Éthique ont pris un peu plus d'une heure.

Générer du texte à partir d'un modèle

Voici maintenant le fichier utilisé pour générer du texte à partir du modèle. Je n'ai pas encore regardé comment modifier les paramètres, mais cela ne doit pas être trop difficile :

textgen.py
from aitextgen import aitextgen
 
ai = aitextgen(model_folder="trained_model",
                tokenizer_file="aitextgen.tokenizer.json")
 
ai.generate(1, prompt="")

Pour l'utiliser, je n'affiche pas le texte dans le terminal, mais le redirige vers un fichier nommé text.txt :

./mon_env/bin/python3 textgen.py > text.txt

Un exemple de texte généré :

La Grenouille  - La Fontaine

		L'Aigle et le Singe aperçut
De son ami-là : votre sens y prompt ;
De quoi cette Science eut un fardeau pendu.

C'est du grand portnawak, c'est parfait.

Vocalisation

Maintenant que le plus important est fait, à savoir, d'une certaine manière, avoir mis en place le contexte cérébral de ce petit Jean, il faut commencer à l'incarner.

La première étape est de pouvoir le faire dicter ses nouvelles fables, en utilisant un logiciel de synthèse sonore, en anglais Text To Speech, souvent abrévié TTS.

La ressource principale que j'ai trouvée, sur ce sujet et concernant les logiciels libres, est la documentation Ubuntu. Cependant, Manu a également trouvé un logiciel pertinent développé par une fondation bien connue : Mozilla TTS.

De manière succincte, voici les résultats auxquels j'ai abouti pour ces logiciels. Je n'ai pas essayé festival.

Premièrement, espeak. C'est presque un artefact des anciens temps, tant la voix est robotisée. On n'y comprends pas grand chose et c'est très moche :

espeak -v fr -f text.txt

Concernant svoxpico, la voix est meilleure qu'espeak, ce qui n'est pas bien difficile au final. Elle hoquette quelque peu mais cela lui confère un certain charme. Par contre elle est uniquement féminine en langue française :

pico2wave -l fr-FR -w text.wav < text.txt && play text.wav

Avec Mozilla TTS, les résultats sont plus probants, même s'il y a quelques cafouillis par-ci par-là. Mais là encore, seules des voix féminines sont disponibles :

tts --text "$(cat text.txt)" --model_name tts_models/fr/mai/tacotron2-DDC --vocoder_name vocoder_models/universal/libri-tts/fullband-melgan && play tts_output.wav

De fait, la solution que j'ai finalement retenue se sert d'espeak ! Mais en conjonction avec un autre logiciel, qui s'appelle mbrola. Je l'ai installé, ainsi que l'ensemble des voix françaises, via cette commande :

sudo apt-get install mbrola mbrola-fr*

Cette fois-ci, nous utilisons espeak pour générer un fichier de phonèmes, puis nous utilisons les vocoders de mbrola pour les lire :

espeak -q -s 100 -v mb/mb-fr1 -f text.txt --pho --phonout=text.pho && mbrola /usr/share/mbrola/fr6/fr6 text.pho text.wav && play text.wav

Étrangement, cela ne marche pas avec l'argument -v fr d'espeak, il faut utiliser -v mb/mb-fr1 à la place.

Pour changer de vocoder, il faut spécifier le chemin de celui-ci dans la commande mbrola. Dans le cas précédent, c'est le chemin /usr/share/mbrola/fr6/fr6 qui le spécifie.

Cette fois-ci, nous avons une voix masculine d'assez bonne qualité, ce qui me satisfait.

Quoi ma gueule ?

Il faut maintenant associer la voix à un visage. Pour ce faire, un petit tour sur le net et un copier-coller d'un tableau représentatif de l'écrivain.

Mais ce n'est pas tout, j'aimerai bien pouvoir l'animer un tantinet. Mon idée était de singer l'intelligence artificielle et la culture française en transformant Jidéhéléf en pantin. À l'aide de GIMP, j'ai donc séparé le tableau original en plusieurs parties faciles à animer, à la manière d'un système de calques :

LaFontaine.exe

Il est maintenant temps de regrouper ensemble les pensées, la voix et la frimousse de Jean !

J'avais en premier pensé utiliser le moteur de jeu vidéo libre Godot pour ce faire, car il intègre des outils d'animation simple à utiliser. Cependant, j'ai finalement opté pour SuperCollider pour faire cela, notamment car c'est le logiciel le plus simple que je connaisse pour implémenter le déplacement de la mâchoire de Jean en fonction des conneries qu'il raconte.

Voici le code du projet :

(

var win = Window.new(
	"La Fontaine",
	Rect( 1000, 500, 200, 200 ) );

var filePath = thisProcess.nowExecutingPath.dirname ++ "/text.wav";

var lock = false; // Empêche de regénérer une fable tant que la précédente n'est pas finie
var lockDelay = 1; // Délai supplémentaire de restriction

var stringCmd; // Variable qui va stocker l'appel Bash à la génération du texte et du fichier son

var view = UserView();

var imgJean = Image.new(
	thisProcess.nowExecutingPath.dirname ++ "/JEAN.png" );
var imgEmpty = Image.new(
	thisProcess.nowExecutingPath.dirname ++ "/emptyeyes.png" );
var imgChin = Image.new(
	thisProcess.nowExecutingPath.dirname ++ "/lafontaine_chin.png" );
var imgPupille = Image.new(
	thisProcess.nowExecutingPath.dirname ++ "/pupille.png" );

var textSpeed = 120; // Vitesse d'élocution
var voiceTag = "fr3"; // Vocoder utilisé

var jawOffset = 0; // Décalage de la mâchoire

var eg = Point( 390, 512 ); // Position de l'oeil gauche
var ed = Point( 385, 415 ); // Position de l'oeil droit

var egtarget = Point( 390, 512 ); // Position de référence l'oeil gauche
var edtarget = Point( 385, 415 ); // Position de référence l'oeil droit

if( ~masterIn == nil, { // Bus master
	~masterIn = Bus.audio( s, 1 ) } );

if( ~ampBus == nil, { // Bus de valeur d'amplitude
	~ampBus = Bus.control( s, 1 ) } );


SynthDef( \master, {

	var sound = In.ar( ~masterIn, 1 );
	var amp = Amplitude.kr( sound );
	var panSound = Pan2.ar( sound );

	Out.kr( ~ampBus, amp );
	Out.ar( 0, panSound );

} ).play;


// Construction de la commande Bash
voiceTag = voiceTag ++ "/" ++ voiceTag;

stringCmd = "cd ~/CNFS/bestiaireia/La_Fontaine/;";
stringCmd = stringCmd ++ "./mon_env/bin/python3 textgen.py > text.txt;";
stringCmd = stringCmd ++ "espeak -q -s " ++ textSpeed.asString ++ " -v mb/mb-fr1 -f text.txt --pho --phonout=text.pho;";
stringCmd = stringCmd ++ "mbrola /usr/share/mbrola/" ++ voiceTag ++ " text.pho text.wav";

// Fonction déclenchée à l'input
win.view.keyDownAction = { arg view, char, modifiers, unicode,keycode;

	if( lock == false, {
		if( unicode == 32, {

			stringCmd.systemCmd;
			lock = true;

			b = Buffer.read(
				s,
				filePath,
				action: { { lock = false; }.defer( b.numFrames * ( 1.0 / b.sampleRate ) ) }
			);

			SynthDef( "playbuf",{
				Out.ar( ~masterIn,
						PlayBuf.ar(
							numChannels: 1,
							bufnum: b.bufnum,
							rate: BufRateScale.kr( b.bufnum ) * 1,
							trigger: 1,
							startPos: 0,
							loop: 0,
							doneAction: Done.freeSelf ),
						0.0
				)
			} ).play;
		} );
	} );
};

win.view.layout_( HLayout() );

win.view.layout.add( view );

// Fonction d'affichage graphique :
view.drawFunc = {
	Pen.drawImage( Point( 1920 - 1284 / 2 + 350, 1080 - 1000 / 2 + ( 1000 - 437 - 175 ) ), imgEmpty, operation: 'sourceOver', opacity:1);



	Pen.drawImage( Point( 1920 - 1284 / 2 + eg.x, 1080 - 1000 / 2 + eg.y ), imgPupille, operation: 'sourceOver', opacity:1); // Oeil droit

	Pen.drawImage( Point( 1920 - 1284 / 2 + ed.x, 1080 - 1000 / 2 + ed.y ), imgPupille, operation: 'sourceOver', opacity:1); // Oeil gauche

	Pen.drawImage( Point( 1920 - 1284 / 2, 1080 - 1000 / 2 ), imgJean, operation: 'sourceOver', opacity:1);
	Pen.drawImage( Point( 1920 / 2 - 132 + jawOffset, 1080 / 2 - 83 ), imgChin, operation: 'sourceOver', opacity:1);
};


// Routine qui associe l'amplitude sonore à la position de la mâchoire :
Routine( {

	var rand;
	var randN = 10;

	loop {

		~ampBus.get( { | val | jawOffset = val.linlin( 0.0, 1.0, 0, 100 ); } );
		jawOffset = jawOffset.asInteger;

		if( lock == true, {
			rand = randN.rand;
			if( rand == 0, { eg.x = eg.x + 1 } );
			if( rand == 1, { eg.x = eg.x - 1 } );
			if( eg.x < 385, { eg.x = 385 } );
			if( eg.x > 410, { eg.x = 410 } );

			rand = randN.rand;
			if( rand == 0, { eg.y = eg.y + 1 } );
			if( rand == 1, { eg.y = eg.y - 1 } );
			if( eg.y < 488, { eg.y = 488 } );
			if( eg.y > 524, { eg.y = 524 } );

			rand = randN.rand;
			if( rand == 0, { ed.x = ed.x + 1 } );
			if( rand == 1, { ed.x = ed.x - 1 } );
			if( ed.x < 380, { ed.x = 380 } );
			if( ed.x > 395, { ed.x = 395 } );

			rand = randN.rand;
			if( rand == 0, { ed.y = ed.y + 1 } );
			if( rand == 1, { ed.y = ed.y - 1 } );
			if( ed.y < 400, { ed.y = 400 } );
			if( ed.y > 430, { ed.y = 430 } );
		} );

		if( lock == false, {
			if( eg.x < egtarget.x, { eg.x = eg.x + 1 } );
			if( eg.x > egtarget.x, { eg.x = eg.x - 1 } );
			if( eg.y < egtarget.y, { eg.y = eg.y + 1 } );
			if( eg.y > egtarget.y, { eg.y = eg.y - 1 } );

			if( ed.x < edtarget.x, { ed.x = ed.x + 1 } );
			if( ed.x > edtarget.x, { ed.x = ed.x - 1 } );
			if( ed.y < edtarget.y, { ed.y = ed.y + 1 } );
			if( ed.y > edtarget.y, { ed.y = ed.y - 1 } );
		} );

		{ view.refresh }.defer;

		(1/30).wait
	};
} ).play;

win.background = Color.black; // Set main widow background as black
win.fullScreen;

win.front;

// À la fermeture, libération de la mémoire :
CmdPeriod.doOnce( {
	Window.closeAll;
	s.freeAll;
	imgChin.free;
	imgJean.free;
	imgEmpty.free;
	imgPupille.free;
} );

)

Sans rentrer trop dans le détail, quelques points sont intéressants à aborder sur l'algorithme.

Corrélation de l'audio et de l'affichage graphique

L'idée principale était d'utiliser SuperCollider afin de relier le volume sonore ( l'amplitude ) du fichier son au déplacement de la mâchoire. Pour mettre cela en place, j'ai d'abord décidé d'utiliser espeak et mbrola de telle manière qu'ils produisent non pas un son direct, mais un enregistrement audio.

Au lancement du programme, un synthé maître est créé, qui utilise un Ugen IN afin de pouvoir bénéficier d'un son d'entrée créé a posteriori. Lors de la génération d'une fable, le fichier son résultant est chargé dans SC, puis envoyé dans l'entrée du synthé maître. Important : le synthé créé lors de la génération est supprimé lors de la fin de la lecture grâce au paramètre doneAction: Done.freeSelf du PlayBuf. Le cas échéant, les UGens créés pour jouer le sample resteraient présent en mémoire et risqueraient de rapidement saturer l'ordinateur.

Dans le synthé maître, avant de le passer en stéréo et de l'envoyer vers la carte son, le son est en premier lieu analysé grâce à l'UGen Amplitude, qui redirige son résultat d'analyse dans un Bus créé au préalable.

C'est une Routine qui, à intervalle régulier, récupère la valeur d'amplitude au sein du Bus, modifie la valeur de référence de la position de la mâchoire puis lance un appel de rafraîchissement de l'interface graphique. On utilise {}.defer car la mise-à-jour graphique ne peut s'effectuer dans le contexte audio.

La méthode d'accès à une valeur présente dans un UGen, c'est-à-dire une information qui passe du serveur à sclang, est discutée dans ce post sur le forum de SC, et approfondie dans ce tutoriel.

Commandes BASH depuis SC et synchronicité

Sous Linux, il est très simple d'envoyer des commandes BASH dans le terminal depuis SC. Pour ce faire, on utilise une méthode sur une chaîne de caractères qui contient la commande à exécuter. Pour exécuter plusieurs commandes, on les sépare par des points virgules :

"ls".unixCmd
"cd Dossier/;ls".systemCmd

Dans SuperCollider, on distingue deux types de fonctions : les commandes synchrones, et les commandes asynchrones. En règle générale, une commande synchrone bloque l'éxecution du code dans sclang le temps que le serveur se mette à jour. Cela permet par exemple d'éviter, après avoir ajouté une SynthDef, de l'appeler avant qu'il soit effectivement initialisé.

Dans notre cadre, la génération de la fable, et dans une moindre mesure la génération du fichier audio, prend un peu de temps à l'ordinateur. Nous utilisons donc .systemCmd, qui est la fonction synchrone d'accès au terminal.

Le cas échéant, en utilisant .unixCmd, le fichier audio est chargé par SuperCollider avant que la nouvelle fable soit générée, c'est donc la fable précédente qui est récitée…

Ici, un problème posé est que la suspension d'activité dans sclang via l'appel à la fonction synchrone .systemCmd arrête également la Routine responsable de la mise-à-jour graphique, ce qui bloque l'animation. J'ai résolu ce problème en n'animant pas la partie d'attente au cours de laquelle aucune fable n'est récitée. Le blocage de la Routine est donc invisible à l'utilisateur, même s'il existe.

Dans l'absolu, il aurait fallu plutôt utiliser .unixCmd, et mettre en place un retour depuis le terminal afin de lancer l'enregistrement audio après la génération du nouveau fichier. Tant qu'on y est, il est à noter que lorsque l'image est immobile, la Routine contiue de tourner, ce qui n'est pas optimal mais pas catastrophique par ailleurs.

La Suite ?

À ce stade, le prototype est fonctionnel mais quelques améliorations sont possibles :

  • Refaire l'algorithme de Routine pour qu'il ne reste activé que le temps de l'animation.
  • Mettre l'image de Jean de La Fontaine en plein écran, mais cela dépend de la résolution du moniteur que nous utiliserons pour l'installation.
  • Ajouter un retour visuel de la fable générée, parce que la voix n'est pas toujours claire, et pour les publics malentendants.
  • Réaliser le dispositif physique de l'installation, avec un joli cadre en bois, et tout le toutim.
  • Trouver un moyen de générer des fables plus longues.
numeriser_jean_de_la_fontaine.txt · Dernière modification : 2022/03/07 16:10 de Simon Deplat