Dans le précédent article de cette série, on a mis en place l'infrastructure de base de notre package. Tout est bien organisé en dossiers hiérarchisés. On a créé un service provider pour que Laravel reconnaisse et charge notre package. À présent, on va commencer à coder en se posant encore une fois les bonnes questions et surtout, en trouvant les meilleures stratégies.
Pour rappel, on va utiliser les fonctionnalités de ce package. Il permet d'utiliser Microsoft Edge TTS sans passer par les API. On peut donc transformer facilement et à bon compte du texte écrit en sa version vocale, en choisissant la voix parmi un très grand nombre de possibilités. On peut sauvegarder le résultat sous forme de fichier audio, mais aussi créer un stream en temps réel. On pourrait évidemment utiliser directement ce package dans Laravel, mais, avec notre package, on va créer quelque chose de mieux intégrer à Laravel et de plus simple et agréable à utiliser.
Pour vous simplifier la vie, vous pouvez télécharger les fichiers du package tel qu'il est à la fin de cet article.
Les dépendances et les tests
Quand on crée un package, on doit se demander de quelles dépendances on va avoir besoin. La première qui vienne à l'idée est bien sûr le package qui nous a inspiré et qui va nous fournir les fonctions fondamentales. On commence donc par le charger dans le package (il faut se positionner dans le dossier du package) :
composer require afaya/edge-tts
D'autre part, on va faire du travail soigné et accompagner notre code de tests. Alors, on a aussi besoin de PHPUnit avec Orchestra (uniquement en développement) :
composer require orchestra/testbench:"~10" --dev
Pour les tests, on a besoin d'une architecture de dossiers et de certains fichiers de base :

Le fichier clé des tests est TestCase.php :
<?php
namespace Happycoder\LaravelEdgeTts\Tests;
use Happycoder\LaravelEdgeTts\EdgeTtsLaravelServiceProvider;
class TestCase extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [
EdgeTtsLaravelServiceProvider::class,
];
}
protected function getEnvironmentSetUp($app): void
{
// Base configuration
$app['config']->set('filesystems.default', 'local');
$app['config']->set('filesystems.disks.local', [
'driver' => 'local',
'root' => storage_path('app'),
]);
// Ensure storage directory exists
if (!file_exists(storage_path('app'))) {
mkdir(storage_path('app'), 0755, true);
}
}
}
Le fichier ExempleTest.php sert juste à vérifier que tout fonctionne :
<?php
namespace Happycoder\LaravelEdgeTts\Tests\Feature;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
class ExampleTest extends TestCase
{
#[Test]
public function a_basic_test_example()
{
$this->assertTrue(true);
}
}
La configuration de PHPUnit se situe dans le fichier phpunit.xml :
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
<php>
<env name="APP_KEY" value="base64:2fl+Ktvkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10="/>
</php>
</phpunit>
Pour que ces classes soient prises en compte, il faut prévoir l'autoload dans le composer.json du package :
"autoload-dev": {
"psr-4": {
"Happycoder\\LaravelEdgeTts\\Tests\\": "tests/"
}
},
Quand vous ajoutez des classes dans votre package, pensez à toujours faire un composer dump !
Lancez les tests pour voir si tout se passe bien :
E:\laragon\www\edgetts\packages\happycoder\laravel-edge-tts
php vendor/bin/phpunit
PHPUnit 12.3.15 by Sebastian Bergmann and contributors.Runtime: PHP 8.3.24
Configuration: E:\laragon\www\edgetts\packages\happycoder\laravel-edge-tts\phpunit.xml. 1 / 1 (100%)
Time: 00:02.089, Memory: 30.00 MB
OK (1 test, 1 assertion)
Nos tests fonctionneront bien !
Le contrat (contract)
Un contrat dans Laravel est une interface PHP qui définit un ensemble de méthodes qu'une classe doit implémenter. En d'autres termes, il s'agit d'un contrat de fonctionnement qui spécifie ce qu'une classe doit faire, sans dicter la manière de le faire. Ils sont au cœur de l'architecture de Laravel et sont principalement utilisés pour promouvoir un code moins couplé, plus flexible et plus facile à maintenir et à tester.
Pour notre package, on va prévoir un contrat (contract), de telle sorte qu'il sera simple, en cas de besoin, de modifier la classe sous-jacente. On dit qu'on change l'implémentation. c'est une bonne pratique à privilégier même si ça donne un peu plus de travail et de code à générer. D'autre part ça facilite grandement les tests.
On ajoute le contrat ici :

Avec ce code :
<?php
namespace Happycoder\LaravelEdgeTts\Contracts;
interface TtsSynthesizer
{
/**
* Synthesize text to speech and execute a callback function for streaming the audio chunks.
*
* @param string $text The text to synthesize.
* @param string $voice The voice name (e.g., 'fr-FR-DeniseNeural').
* @param array $options Modulation options (rate, volume, pitch).
* @param callable $callback Function to be called with each audio chunk.
* @return void
*/
public function synthesizeStream(string $text, string $voice, array $options, callable $callback): void;
/**
* Synthesize text to speech and return the complete audio data (MP3) as a string.
* This is useful for saving files or caching.
*
* @param string $text The text to synthesize.
* @param string $voice The voice name.
* @param array $options Modulation options (rate, volume, pitch).
* @return string The raw MP3 audio data.
*/
public function synthesize(string $text, string $voice, array $options = []): string;
/**
* Retrieves the list of available voices from the service.
*
* @return array
*/
public function getVoices(): array;
/**
* Get the synthesized audio data as a Base64 encoded string.
*
* @param string $text The text to synthesize.
* @param string $voice The voice name.
* @param array $options Modulation options.
* @return string The audio data encoded in Base64.
*/
public function toBase64(string $text, string $voice, array $options = []): string;
/**
* Save the synthesized audio to a file.
*
* @param string $text The text to synthesize.
* @param string $voice The voice name.
* @param string $fileName The path/name of the file to save (without extension).
* @param array $options Modulation options.
* @return string The full path to the saved file.
*/
public function toFile(string $text, string $voice, string $fileName, array $options = []): string;
/**
* Get the raw synthesized audio data (MP3 stream) as a string.
* Alias for the synthesize() method in this context.
*
* @param string $text The text to synthesize.
* @param string $voice The voice name.
* @param array $options Modulation options.
* @return string The raw MP3 audio data.
*/
public function toRaw(string $text, string $voice, array $options = []): string;
}
On définit ainsi cinq fonctions. Il agit comme un accord qui garantit que toute classe l'implémentant sera capable d'effectuer les opérations de base et avancées du Text-to-Speech (TTS).
Capacités définies :
-
Synthèse Principale (synthetize et toRaw) : ces méthodes garantissent la capacité de générer et de retourner les données audio brutes (MP3) complètes à partir d'un texte, d'une voix et d'options de modulation (vitesse, volume, hauteur). La méthode toRaw sert d'alias pour maintenir une nomenclature cohérente dans le package.
-
Streaming (synthetizeStream) : c'est une fonctionnalité essentielle qui permet la synthèse des données par morceaux (chunks). En acceptant une fonction de callback, l'implémentation doit permettre de traiter l'audio au fur et à mesure de sa réception, ce qui est crucial pour les applications nécessitant une faible latence ou pour les longs textes.
-
Formats de Sortie (toBase64 et toFile) : le contrat inclut des méthodes d'utilité pour le consommateur du service.
-
toBase64 permet d'encoder l'audio en Base64, idéal pour l'intégration directe dans les balises HTML/JavaScript
-
toFile est indispensable pour la mise en cache des résultats ou le stockage sur disque.
-
-
Métadonnées (getVoices) : La méthode getVoices garantit que le service peut fournir la liste des voix disponibles. C'est nécessaire pour que les utilisateurs puissent configurer dynamiquement le synthétiseur.
De cette façon, on pourrait très bien utiliser la synthèse vocale fournie par Google sans révolutionner le code.
L'adaptateur (adapter)
Dans Laravel, un adapter (adaptateur) fait référence au patron de conception structurel adaptateur (Adapter Pattern). Son objectif principal est de permettre à deux interfaces ou classes incompatibles de travailler ensemble en servant de « traducteur » ou d'enveloppe (wrapper) entre elles. Dans le contexte de Laravel, l'adaptateur est essentiel pour garantir une interface de programmation (API) cohérente pour l'utilisateur final, même si les services sous-jacents sont très différents.
On a défini ci-dessus un contrat, on veut le respecter en utilisant le package de andresayac que nous avons installé. On va donc coder un adaptateur qui va respecter notre contrat. En gros, on respecte l'appellation des fonctions du contrat et on va chercher leurs équivalents dans la librairie concernée. Ça ne sera pas difficile parce que ce sont pratiquement les mêmes.
On place notre adaptateur ici :

Pour le code, on va avoir toutes les fonctions prévues dans le contrat avec une adaptation pour récupérer ce qu'il faut dans le package de base :
<?php
namespace Happycoder\LaravelEdgeTts\Services;
use Afaya\EdgeTTS\Service\EdgeTTS;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
class EdgeTtsAdapter implements TtsSynthesizer
{
protected EdgeTTS $edgeTts;
protected string $audioData = '';
public function __construct(EdgeTTS $edgeTts)
{
$this->edgeTts = $edgeTts;
}
/**
* @inheritDoc
*/
public function synthesizeStream(string $text, string $voice, array $options, callable $callback): void
{
$this->edgeTts->synthesizeStream($text, $voice, $options, $callback);
}
/**
* @inheritDoc
*/
public function synthesize(string $text, string $voice, array $options = []): string
{
$this->audioData = '';
$this->edgeTts->synthesizeStream($text, $voice, $options, function($chunk) {
$this->audioData .= $chunk;
});
return $this->audioData;
}
/**
* @inheritDoc
*/
public function getVoices(): array
{
return $this->edgeTts->getVoices();
}
/**
* @inheritDoc
*/
public function toBase64(string $text, string $voice, array $options = []): string
{
$audioData = $this->synthesize($text, $voice, $options);
return base64_encode($audioData);
}
/**
* @inheritDoc
*/
public function toFile(string $text, string $voice, string $fileName, array $options = []): string
{
$audioData = $this->synthesize($text, $voice, $options);
$dir = dirname($fileName);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($fileName, $audioData);
return $fileName;
}
/**
* @inheritDoc
*/
public function toRaw(string $text, string $voice, array $options = []): string
{
return $this->synthesize($text, $voice, $options);
}
}
Test de l'adaptateur
Il est temps maintenant de vérifier que notre adaptateur fonctionne correctement. On ajoute ce test :

<?php
namespace Tests\Unit\Services;
use Afaya\EdgeTTS\Service\EdgeTTS;
use Happycoder\LaravelEdgeTts\Services\EdgeTtsAdapter;
use PHPUnit\Framework\MockObject\MockObject;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
class EdgeTtsAdapterTest extends TestCase
{
private EdgeTTS|MockObject $edgeTtsMock;
private EdgeTtsAdapter $adapter;
private string $audioData = 'simulated_audio_data';
protected function setUp(): void
{
parent::setUp();
// Mock for EdgeTTS
$this->edgeTtsMock = $this->createMock(EdgeTTS::class);
$this->adapter = new EdgeTtsAdapter($this->edgeTtsMock);
}
private function setupSynthesizeStreamMock(): void
{
$this->edgeTtsMock->expects($this->once())
->method('synthesizeStream')
->willReturnCallback(function ($text, $voice, $options, $callback) {
// Simulate sending audio data in chunks
$chunks = str_split($this->audioData, 5);
foreach ($chunks as $chunk) {
$callback($chunk);
}
});
}
#[Test]
public function it_can_synthesize_text_to_speech()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = ['rate' => '1.0'];
$this->setupSynthesizeStreamMock();
$result = $this->adapter->synthesize($text, $voice, $options);
$this->assertEquals($this->audioData, $result);
}
#[Test]
public function it_can_convert_text_to_base64()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = [];
$expectedResult = base64_encode($this->audioData);
$this->setupSynthesizeStreamMock();
$result = $this->adapter->toBase64($text, $voice, $options);
$this->assertEquals($expectedResult, $result);
}
#[Test]
public function it_can_save_speech_to_file()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$fileName = 'test.mp3';
$options = [];
$this->setupSynthesizeStreamMock();
// Use a temporary file for the test
$tempFile = sys_get_temp_dir() . '/' . uniqid('test_') . '.mp3';
$result = $this->adapter->toFile($text, $voice, $tempFile, $options);
$this->assertEquals($tempFile, $result);
$this->assertFileExists($tempFile);
$this->assertEquals($this->audioData, file_get_contents($tempFile));
// Clean up
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
#[Test]
public function it_can_convert_text_to_raw_audio()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = [];
$this->setupSynthesizeStreamMock();
$result = $this->adapter->toRaw($text, $voice, $options);
$this->assertEquals($this->audioData, $result);
}
#[Test]
public function it_can_get_available_voices()
{
$expectedVoices = [
['Name' => 'en-US-AriaNeural', 'Gender' => 'Female'],
['Name' => 'fr-FR-DeniseNeural', 'Gender' => 'Female'],
];
$this->edgeTtsMock->expects($this->once())
->method('getVoices')
->willReturn($expectedVoices);
$voices = $this->adapter->getVoices();
$this->assertEquals($expectedVoices, $voices);
}
#[Test]
public function it_can_synthesize_stream()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = [];
$receivedChunks = [];
$callback = function($chunk) use (&$receivedChunks) {
$receivedChunks[] = $chunk;
};
$this->edgeTtsMock->expects($this->once())
->method('synthesizeStream')
->with(
$text,
$voice,
$options,
$this->callback(fn($arg) => is_callable($arg))
)
->willReturnCallback(function ($text, $voice, $options, $callback) {
$chunks = str_split($this->audioData, 5);
foreach ($chunks as $chunk) {
$callback($chunk);
}
});
$this->adapter->synthesizeStream($text, $voice, $options, $callback);
// Vérifie que le callback a bien été appelé avec les données
$this->assertNotEmpty($receivedChunks);
$this->assertEquals($this->audioData, implode('', $receivedChunks));
}
}
On teste ainsi :
- it_can_synthesize_text_to_speech : vérifie que la méthode synthesize renvoie correctement les données audio complètes en agrégeant les fragments audio reçus via le flux
- it_can_convert_text_to_base64 s'assure que la méthode toBase64 encode correctement les données audio générées en Base64
- it_can_save_speech_to_file teste la méthode toFile. Elle vérifie que les données audio sont bien écrites dans un fichier spécifié et que leur contenu correspond aux données audio simulées. Il inclut également le nettoyage du fichier temporaire après le test
- it_can_convert_text_to_raw_audio : confirme que la méthode toRaw renvoie les données audio brutes (similaires à synthesize)
- it_can_get_available_voices : vérifie que la méthode getVoices transmet correctement l'appel au service EdgeTTS sous-jacent et récupère la liste des voix disponibles
- it_can_synthesize_stream teste la méthode synthesizeStream. Il s'assure que le callback fourni est appelé pour chaque fragment de données audio et que l'ensemble des fragments reçus reconstitue le message audio complet
Tous ces tests reposent sur le mocking de la dépendance EdgeTTS :
- L'objet EdgeTTS est remplacé par un objet Mock ($this->edgeTtsMock)
- La méthode synthesizeStream du mock est configurée pour simuler l'envoi de données audio ($this->audioData) en petits morceaux via la fonction de rappel fournie. C'est la clé pour tester le comportement de streaming
- La méthode getVoices est simulée pour renvoyer une liste de voix prédéfinies
Cela permet de tester l'adaptateur EdgeTtsAdapter de manière isolée, sans dépendre du service externe Edge TTS.
Voyons si ça fonctionne :

Un peu de concret
À présent que notre adaptateur fonctionne et que nous avons un beau contrat, voyons déjà comment utiliser ça dans notre projet. Mais il faut d'abord l'informer concernant ces classes et lui donner le moyen de les utiliser efficacement. On va donc compléter notre service provider du package :
...
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Happycoder\LaravelEdgeTts\Services\EdgeTtsAdapter;
use Afaya\EdgeTTS\Service\EdgeTTS;
class EdgeTtsLaravelServiceProvider extends ServiceProvider
{
/**
* Register the services of the package in the IOC container.
*/
public function register(): void
{
// 1. Register the concrete library (the internal dependency of the adapter)
$this->app->singleton(EdgeTTS::class, function ($app) {
return new EdgeTTS();
});
// 2. Register the adapter: Bind the Contract to the Implementation
$this->app->singleton(TtsSynthesizer::class, function ($app) {
$edgeTts = $app->make(EdgeTTS::class);
return new EdgeTtsAdapter($edgeTts);
});
}
...
Ainsi Laravel sait comment instancier ces classes, avec le singleton, on crée une classe unique, et enfin, on pourra facilement utiliser l'injection de dépendance.
Pour illustrer cela, créons un contrôleur dans le projet :

<?php
namespace App\Http\Controllers;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class TestController extends Controller
{
public function index(TtsSynthesizer $synthesizer)
{
$text = 'Un petit texte pour essayer';
$voice = 'fr-FR-DeniseNeural';
// 1. Définir la fonction de streaming
$callback = function () use ($text, $voice, $synthesizer) {
// La méthode synthesizeStream appelle la fonction de rappel
// pour chaque bloc de données audio.
$synthesizer->synthesizeStream(
$text,
$voice,
[], // Options (rate, volume, pitch)
function($chunk) {
// Envoi immédiat du bloc au navigateur
echo $chunk;
// Assure que le tampon est vidé immédiatement
flush();
}
);
};
// 2. Définir les en-têtes pour un flux audio MP3
// Le EdgeTTS renvoie généralement du MP3, mais vérifiez la configuration si besoin.
$headers = [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => 'inline; filename="audio.mp3"',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
];
// 3. Renvoyer une StreamedResponse
// C'est la clé : cela permet d'envoyer la réponse par morceaux
return new StreamedResponse($callback, 200, $headers);
}
}
Remarquez qu'on injecte le contrat (interface).
On ajoute une route :
use App\Http\Controllers\TestController;
use Illuminate\Support\Facades\Route;
...
Route::get('/test', [TestController::class, 'index']);
Si tout se passe bien, vous devez avoir la version sonore :

Conclusion
Nous avons bien avancé dans la création du package. Dans les prochaines étapes, on ajoutera une façade, une route pour simplifier le streaming, une directive Blade, et quelques autres fonctionnalités bien pratiques.
Par bestmomo
Aucun commentaire