Dans l'article précédent de cette série, on a mis en place le contrat ainsi que son adaptation pour la librairie de andresayac. Le tout soigneusement testé. On a aussi créé un contrôleur de test pour utiliser la possibilité de streaming. Mais ce qui serait vraiment plus attrayant serait d'offrir une route de streaming qui fonctionne automatiquement. C'est ce que nous allons réaliser à présent. On va aussi en profiter pour ajouter un fichier de configuration qui va compléter utilement notre package.
Pour vous simplifier la vie, vous pouvez télécharger les fichiers du package tel qu'il est à la fin de cet article.
La configuration
On a presque toujours besoin d'une configuration pour un package pour fixer certaines valeurs importantes, procéder à des réglages selon l'utilisation, ajouter des éléments pour sécuriser... On va donc créer une configuration à présent :

Pour le moment, on va se contenter de définir une voix par défaut et anticiper le cache pour les fichiers synthétisés qui va nous servir dans le contrôleur de streaming, ainsi que les middlewares à prévoir pour protéger la route de streaming :
<?php
return [
// Define default voice
'default_voice' => env('EDGE_TTS_DEFAULT_VOICE', 'fr-FR-DeniseNeural'),
// Define middleware
'middleware' => [
// 'web' is often necessary for 'auth' or 'throttle' based on session to work.
'web',
// Requires a user connection. Change to 'auth:sanctum' if it's for an API.
//'auth',
// Limit requests to 60 per minute to prevent abuse.
'throttle:60,1',
],
// Define cache parameters
'cache' => [
// Enable/Disable cache.
'enabled' => env('EDGE_TTS_CACHE_ENABLED', true),
// Laravel disk to use for MP3 files.
// 'local' stores in storage/app. You can use 'public' if you want them to be accessible directly.
'disk' => env('EDGE_TTS_CACHE_DISK', 'local'),
// Cache duration in minutes (or null for unlimited duration).
'lifetime' => env('EDGE_TTS_CACHE_LIFETIME', null),
],
// Enable Call Logging
'enable_call_logging' => env('EDGE_TTS_LOG_CALLS', false),
];
Pour que cette configuration soit prise en compte, on va compléter notre service provider du package :
public function boot(): void
{
// Publish the configuration file
$this->publishes([
__DIR__.'/../config/edge-tts.php' => config_path('edge-tts.php'),
], 'edge-tts-config');
// Load the configuration file (in case it is not published)
$this->mergeConfigFrom(
__DIR__.'/../config/edge-tts.php', 'edge-tts'
);
}
De cette façon, l'utilisateur pourra publier notre configuration facilement :
php artisan vendor:publish --tag=edge-tts-config
S'il ne le fait pas, on prend quand même la précaution, avec le merge, d'intégrer nos valeurs par défaut dans le projet.
On ajoutera d'autres éléments par la suite dans cette configuration.
La validation
Valider les voix
Le service propose de très nombreuses voix. Comme on va mettre en œuvre une route et son contrôleur associé pour le streaming, on devra s'assurer de valider correctement les données reçues. C'est assez facile pour le texte, le volume... Par contre, c'est plus délicat pour la voix. On va donc créer un règle personnalisée :

Cette validation va impliquer de faire une requête pour récupérer toutes les voix disponibles pour vérifier que cette qu'on reçoit en fait partie. Mais à chaque fois qu'on va recevoir une requête de streaming cette validation va aller chercher toutes ces voix, ce qui peut alourdir le processus. Je suppose qu'il y a une certaine stabilité au niveau de ces voix disponibles et que ça ne doit pas changer bien souvent. Le plus simple est d'utiliser un cache, avec un délai raisonnable, par exemple une heure. Voici le code qui en résulte :
<?php
namespace Happycoder\LaravelEdgeTts\Rules;
use Closure;
use Exception;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Cache;
class VoiceExists implements ValidationRule
{
/**
* Check if the voice exists in the available voices.
*/
protected function voiceExists(string $voice): bool
{
$availableVoices = Cache::remember('edge_tts_available_voices', 3600, function () {
return app(TtsSynthesizer::class)->getVoices();
});
try {
return is_array($availableVoices) && in_array($voice, array_column($availableVoices, 'ShortName'), true);
} catch (Exception $e) {
return false;
}
}
/**
* Validate the attribute.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || !$this->voiceExists($value)) {
$fail('The selected voice is not available.');
}
}
}
Valider le SSML
Pour le streaming, on peut envoyer du simple texte, mais aussi quelque chose de plus structuré en SSML. Dans le contrôleur de test qu'on a créé précédemment, vous pouvez remplacer le petit texte qu'on avait prévu par des données SSML :
public function index(TtsSynthesizer $synthesizer)
{
$text = '<speak version="1.0"
xmlns="http://www.w3.org/2001/10/synthesis"
xmlns:mstts="https://www.w3.org/2001/mstts"
xml:lang="es-CO">
<voice name="es-CO-GonzaloNeural">
<mstts:express-as style="narration-professional">
<prosody rate="+5%" pitch="+10Hz" volume="+0%">
Hola, este es un ejemplo de <emphasis>SSML</emphasis>.
<break time="400ms" />
El número es <say-as interpret-as="cardinal">2025</say-as>.
La palabra se pronuncia
<phoneme alphabet="ipa" ph="ˈxola">hola</phoneme>.
</prosody>
</mstts:express-as>
</voice>
</speak>';
Vous aurez cette fois une jolie voix espagnole et vous voyez qu'on peut avoir plein de réglages possibles.
On peut se demander s'il faut prévoir une validation pour ce genre de données, il me semble que oui, du moins un minimum de précaution pour ne pas laisser tout passer. On va surtout vérifier la cohérence du format XML avec DOMDocument.
On va donc ajouter une validation et on va la prévoir dans un fichier helpers :

Voilà le code :
<?php
if (!function_exists('is_valid_ssml')) {
/**
* Validates the basic XML structure for SSML using DOMDocument.
* This function is a safeguard against obvious XML errors.
*/
function is_valid_ssml(string $content): bool
{
// Ensure the content is clean before parsing
$content = trim($content);
// Check if the content is empty after trimming
if (empty($content)) {
return false;
}
// 1. Check for nested <speak> tags
$speakTagCount = preg_match_all('/<[sS][pP][eE][aA][kK]\b[^>]*>/', $content, $matches);
$closeSpeakTagCount = substr_count(strtolower($content), '</speak>');
// If there's more than one opening <speak> tag or mismatched tags, it's invalid
if ($speakTagCount !== 1 || $closeSpeakTagCount !== 1) {
return false;
}
// 2. Create a temporary copy of the content with normalized speak tags
$normalizedContent = preg_replace_callback(
'/<([\/]?)([sS][pP][eE][aA][kK])([^>]*)>/',
function($matches) {
return '<' . $matches[1] . 'speak' . $matches[3] . '>';
},
$content
);
// 3. Attempt to load the normalized content as XML via DOMDocument
$doc = new DOMDocument();
// Save current error handling state
$originalErrorState = libxml_use_internal_errors(true);
try {
// Try to load the XML with LIBXML_NOERROR | LIBXML_NOWARNING to suppress warnings
$loaded = @$doc->loadXML($normalizedContent, LIBXML_NOERROR | LIBXML_NOWARNING);
// If loading failed (malformed XML)
if (!$loaded) {
return false;
}
// Ensure we have a document element
if (!$doc->documentElement) {
return false;
}
// Check if the root element is 'speak'
return $doc->documentElement->nodeName === 'speak';
} finally {
// Always restore original error handling state and clear errors
libxml_clear_errors();
libxml_use_internal_errors($originalErrorState);
}
}
}
Je ne rentre pas dans les détails de tout ça...
Pour que ce fichier soit chargé par l'autoload, on doit le préciser dans notre composer.json :
"autoload": {
"psr-4": {
"Happycoder\\LaravelEdgeTts\\": "src/"
},
"files": [
"src/helpers.php"
]
},
Il est plus prudent de faire un composer update dans le package après ça.
Et pour être complet, on ajoute quelques tests pour cet helper :

<?php
namespace Happycoder\LaravelEdgeTts\Tests\Unit;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
class HelpersTest extends TestCase
{
#[Test]
public function it_validates_ssml_correctly()
{
// Test for valid SSML
$validCases = [
'basic' => '<speak>Hello</speak>',
'with_attributes' => '<speak version="1.0" xml:lang="en-US">Hello</speak>',
'with_self_closing_tag' => '<speak><break time="1s"/>Hello</speak>',
'with_multiple_elements' => '<speak><p>Paragraph 1</p><p>Paragraph 2</p></speak>',
'with_comments' => '<!-- Comment --><speak>Hello</speak>',
'uppercase_tags' => '<SPEAK>Hello</SPEAK>',
'mixed_case_tags' => '<Speak>Hello</sPeaK>',
];
foreach ($validCases as $case => $ssml) {
$this->assertTrue(
is_valid_ssml($ssml),
"Valid test case failed: {$case}"
);
}
// Test for invalid SSML
$invalidCases = [
'missing_closing_tag' => '<speak>Hello',
'wrong_root' => '<p>Hello</p>',
'empty_string' => '',
'whitespace' => ' ',
'invalid_xml' => '<speak>Hello<invalid>',
'nested_speak' => '<speak><speak>Hello</speak></speak>',
'invalid_self_closing' => '<speak><break>Hello</speak>',
'only_comment' => '<!-- Comment -->',
];
foreach ($invalidCases as $case => $ssml) {
$this->assertFalse(
is_valid_ssml($ssml),
"Invalid test case failed: {$case}"
);
}
}
#[Test]
public function it_handles_edge_cases()
{
// Test with a very long string
$longString = str_repeat('a', 10000);
$this->assertTrue(is_valid_ssml("<speak>{$longString}</speak>"));
// Test with special characters
$this->assertTrue(is_valid_ssml('<speak>éàèùçâêîôûäëïöüÿ</speak>'));
// Test with escaped XML characters
$this->assertTrue(is_valid_ssml('<speak>< > & ' "</speak>'));
// Test with spaces in tags
$this->assertTrue(is_valid_ssml('<speak >Hello</speak>'));
$this->assertTrue(is_valid_ssml('<speak
>Hello</speak>'));
}
#[Test]
public function it_validates_with_namespace()
{
$ssml = '<?xml version="1.0"?>' . "\n" .
'<speak xmlns="http://www.w3.org/2001/10/synthesis" ' .
'xmlns:mstts="https://www.w3.org/2001/mstts" ' .
'version="1.0" xml:lang="en-US">' .
'Hello World' .
'</speak>';
$this->assertTrue(is_valid_ssml($ssml));
}
#[Test]
public function it_handles_libxml_errors_gracefully()
{
// Save the current error reporting state
$originalErrorReporting = error_reporting();
error_reporting(E_ALL & ~E_WARNING); // Temporarily disable warnings
// Test with a string that would normally cause a libxml error
$invalidXml = str_repeat('<speak>', 10000);
$this->assertFalse(@is_valid_ssml($invalidXml));
// Verify that the function does not generate a PHP error
$this->assertTrue(true);
// Restore the original error reporting state
error_reporting($originalErrorReporting);
}
}
On vérifie que ça fonctionne :

On est maintenant bien équipés pour la validation !
Le contrôleur
Créez un contrôleur :

Il n'aura qu'une fonction (__invoke), il va récupérer la requête entrante et on lui injectera notre synthétiseur. Il faudra qu'il :
- valide les données (on profitera de la règle qu'on a créée pour les voix et de l'helper pour le SSML)
- gérer un cache de fichiers pour ne pas recréer une synthèse équivalente (on a déjà préparé la configuration)
- synthétiser le texte transmis avec tous les réglages désirés
- générer le stream
Voilà le code :
<?php
namespace Happycoder\LaravelEdgeTts\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Support\Facades\Storage;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Illuminate\Support\Facades\Log;
use Happycoder\LaravelEdgeTts\Rules\VoiceExists;
class AudioStreamController extends Controller
{
/**
* Streams TTS audio directly to the browser.
*/
public function __invoke(Request $request, TtsSynthesizer $tts): StreamedResponse
{
// INPUT VALIDATION
$validatedData = $request->validate([
'text' => 'required|string|max:5000',
'voice' => ['nullable', 'string', 'max:100', new VoiceExists],
'rate' => ['nullable', 'string', 'regex:/^[-+]?\d{1,3}%$/'],
'volume' => ['nullable', 'string', 'regex:/^[-+]?\d{1,3}%$/'],
'pitch' => ['nullable', 'string', 'regex:/^[-+]?\d{1,3}Hz$/'],
]);
// CLEANED PARAMETERS
$defaultVoice = config('edge-tts.default_voice');
$voice = $validatedData['voice'] ?? $defaultVoice;
$rate = $validatedData['rate'] ?? '0%';
$volume = $validatedData['volume'] ?? '0%';
$pitch = $validatedData['pitch'] ?? '0Hz';
$text = trim($validatedData['text']);
$options = [];
// SSML CHECK AND VALIDATION
$isSsml = str_starts_with($text, '<speak');
if ($isSsml) {
// DIRECT CALL to the helper function
if (!is_valid_ssml($text)) {
$errorMessage = "SSML syntax error: The XML content is not well-formed or the <speak> tag is missing/incorrect.";
Log::error($errorMessage);
return response()->stream(function() use ($errorMessage) {
echo $errorMessage;
}, 400, [
'Content-Type' => 'text/plain',
]);
}
} else {
$options = [
'rate' => $rate,
'volume' => $volume,
'pitch' => $pitch,
];
}
$diskName = config('edge-tts.cache.disk', 'local');
$cacheEnabled = config('edge-tts.cache.enabled', false);
$storage = Storage::disk($diskName);
// UNIQUE HASH GENERATION
$cacheKey = md5(json_encode([
'text' => $text,
'voice' => $voice,
'options' => $options
]));
$filePath = "tts/{$cacheKey}.mp3";
// CACHE CHECK
if ($cacheEnabled && $storage->exists($filePath)) {
$stream = $storage->readStream($filePath);
$fileSize = $storage->size($filePath);
if ($stream === false) {
$errorMessage = "Cache file access error: Failed to open stream for " . $filePath;
Log::error($errorMessage);
return new StreamedResponse(function () use ($errorMessage) {
echo $errorMessage;
}, 500, [
'Content-Type' => 'text/plain',
]);
}
return new StreamedResponse(function() use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => 'inline; filename="tts_cached.mp3"',
'Content-Length' => $fileSize,
]);
}
// SYNTHESIS AND SAVING (If not found in cache)
$buffer = '';
$callback = function ($chunk) use (&$buffer) {
$buffer .= $chunk; // Accumulate the stream in a buffer
echo $chunk; // Send the stream to the client
flush();
};
try {
// Synthesize and execute the callback
$tts->synthesizeStream($text, $voice, $options, $callback);
// After sending to the client, save the content in the cache if enabled
if ($cacheEnabled) {
$storage->put($filePath, $buffer);
// If a lifetime is defined, it could be handled here (e.g., via an Artisan command)
// The simple file system does not automatically expire, but it is recorded.
}
} catch (\Exception $e) {
// Handle the error (e.g., failed connection)
// An appropriate HTTP error response should be returned
Log::error("Edge TTS Streaming Error: " . $e->getMessage());
// It should return an appropriate HTTP error response
return response()->stream(function() use ($e) { echo "Speech synthesis error: " . $e->getMessage(); }, 503);
}
$headers = [
'Content-Type' => 'audio/mpeg',
'Content-Disposition' => 'inline; filename="tts_live.mp3"',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
];
// We cannot easily add the content length in a StreamedResponse that has already sent data,
// but the return is technically the TTS service response.
return new StreamedResponse(function () {}, 200, $headers);
}
}
J'ai prévu pas mal de commentaires si vous voulez l'analyser.
On va ajouter les tests :

<?php
namespace Happycoder\LaravelEdgeTts\Tests\Feature;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Happycoder\LaravelEdgeTts\Http\Controllers\AudioStreamController;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Illuminate\Support\Facades\Cache;
class AudioStreamControllerTest extends TestCase
{
private AudioStreamController $controller;
private TtsSynthesizer $tts;
protected function setUp(): void
{
parent::setUp();
Cache::forget('edge_tts_available_voices');
// Mock for TtsSynthesizer
$this->tts = $this->createMock(TtsSynthesizer::class);
$this->tts->method('getVoices')
->willReturn([
['ShortName' => 'en-US-AriaNeural'],
['ShortName' => 'fr-FR-DeniseNeural']
]);
$this->app->instance(TtsSynthesizer::class, $this->tts);
$this->controller = new AudioStreamController();
}
#[Test]
public function it_validates_required_text_parameter()
{
$request = Request::create('/tts', 'GET', [
// Missing 'text'
'voice' => 'en-US-AriaNeural'
]);
$this->expectException(ValidationException::class);
$this->controller->__invoke($request, $this->tts);
}
#[Test]
public function it_uses_default_voice_when_none_provided()
{
config(['edge-tts.default_voice' => 'fr-FR-DeniseNeural']);
$request = Request::create('/tts', 'GET', [
'text' => 'Bonjour le monde'
]);
$this->tts->method('getVoices')
->willReturn([['ShortName' => 'fr-FR-DeniseNeural']]);
$response = $this->controller->__invoke($request, $this->tts);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('audio/mpeg', $response->headers->get('Content-Type'));
}
#[Test]
public function it_rejects_invalid_voice()
{
$request = Request::create('/tts', 'GET', [
'text' => 'Hello',
'voice' => 'invalid-voice'
]);
$this->tts->method('getVoices')
->willReturn([['ShortName' => 'en-US-AriaNeural']]);
$this->expectException(ValidationException::class);
$this->controller->__invoke($request, $this->tts);
}
}
Et on lance :

Tout se passe bien !
Il ne nous manque plus que la route :

<?php
use Illuminate\Support\Facades\Route;
use Happycoder\LaravelEdgeTts\Http\Controllers\AudioStreamController;
// Retrieves the list of middleware defined in the ‘edge-tts.middleware’ configuration file.
// If the config key does not exist, an empty array is used (no middleware).
$middlewares = config('edge-tts.middleware', []);
// The middleware is applied using the middleware() function with the retrieved array.
Route::middleware($middlewares)->group(function () {
// Audio streaming route (protected by configuration)
Route::get('edge-tts/stream', AudioStreamController::class)->name('edge-tts.stream');
});
On injecte les middlewares prévus dans la configuration (utilisateur authentifié, throttle...) pour ne pas laisser cette route ouverte au quatre vents et utilisée abusivement.
Il faut informer le projet que cette route existe, ça se passe dans notre service provider :
public function boot(): void
{
...
// Load the ESSENTIAL routes (STREAMING) ALWAYS
// The 'api' routes are loaded with a prefix and optional middlewares (according to Laravel convention)
// Here, we load them simply
$this->loadRoutesFrom(__DIR__.'/../routes/stream.php');
}
Cette déclaration doit être placée après celle concernant la configuration, du moins le merge, sinon les middlewares prévus dans la configuration ne seront pas pris en compte.
Utilisation de la route de streaming
Après tout ce travail, on va pouvoir utiliser enfin cette route ! On commence par quelque chose de très simple :
.../edge-tts/stream?text=bonjour
Vous devez entendre "bonjour" avec la voix française par défaut (fr-FR-DeniseNeural), ainsi que les réglages de volume, rate et pitch.
Vous pouvez effectuer tous les réglages que vous voulez :
http://edgetts.oo/edge-tts/stream?rate=-50%&text=bonjour&volume=-60%
À présent, la diction est plus lente et le volume plus bas.
Vous pouvez aussi changer la voix :
http://edgetts.oo/edge-tts/stream?text=bonjour&voice=en-US-JennyNeural
Enfin, vous pouvez entrer du SSML (bien encodé pour l'URL) :
http://edgetts.oo/edge-tts/stream?text=%3Cspeak%20version%3D%221.0%22%0A%20%20%20%20%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2001%2F10%2Fsynthesis%22%0A%20%20%20%20%20%20%20xmlns%3Amstts%3D%22https%3A%2F%2Fwww.w3.org%2F2001%2Fmstts%22%0A%20%20%20%20%20%20%20xml%3Alang%3D%22es-CO%22%3E%0A%20%20%3Cvoice%20name%3D%22es-CO-GonzaloNeural%22%3E%0A%20%20%20%20%3Cmstts%3Aexpress-as%20style%3D%22narration-professional%22%3E%0A%20%20%20%20%20%20%3Cprosody%20rate%3D%22%2B5%25%22%20pitch%3D%22%2B10Hz%22%20volume%3D%22%2B0%25%22%3E%0A%20%20%20%20%20%20%20%20Hola%2C%20este%20es%20un%20ejemplo%20de%20%3Cemphasis%3ESSML%3C%2Femphasis%3E.%0A%20%20%20%20%20%20%20%20%3Cbreak%20time%3D%22400ms%22%20%2F%3E%0A%20%20%20%20%20%20%20%20El%20n%C3%BAmero%20es%20%3Csay-as%20interpret-as%3D%22cardinal%22%3E2025%3C%2Fsay-as%3E.%0A%20%20%20%20%20%20%20%20La%20palabra%20se%20pronuncia%0A%20%20%20%20%20%20%20%20%3Cphoneme%20alphabet%3D%22ipa%22%20ph%3D%22%CB%88xola%22%3Ehola%3C%2Fphoneme%3E.%0A%20%20%20%20%20%20%3C%2Fprosody%3E%0A%20%20%20%20%3C%2Fmstts%3Aexpress-as%3E%0A%20%20%3C%2Fvoice%3E%0A%3C%2Fspeak%3E
Vous avez un espagnol qui parle.
Vous pouvez aussi vérifier les validations :
SSML syntax error: The XML content is not well-formed or the <speak> tag is missing/incorrect.
Speech synthesis error: Invalid volume format. Expected format: '-100% to 100%'.
Les fichiers générés se trouvent là, en cache, dans le projet support :

Évidemment, ils vont s'accumuler, et il faudra s'en occuper !
Conclusion
Notre package a bien évolué ! Nous avons à présent une route de streaming fonctionnelle qui accepte du texte simple avec réglages et de SSML. La validation est en place. On peut protéger avec des middlewares.
Par bestmomo
Aucun commentaire