Nous arrivons à la dernière étape du développement de notre package. Il est maintenant fonctionnel avec une jolie route de streaming protégée par des middlewares et équipée d'une solide validation. Pour ce dernier article, on va s'occuper des fichiers audios générés pour le cache qui nécessite un peu de ménage. D'autre part, on va ajouter une façade pour ceux qui aiment les utiliser. Enfin, on ajoutera un composant Blade pour peaufiner notre proposition.
Pour vous simplifier la vie, vous pouvez télécharger les fichiers du package tel qu'il est à la fin de cet article.
Le ménage des fichiers audios
Comme je viens de l'évoquer ci-dessus, le fait d'avoir créé un cache des fichiers audios pour éviter de générer un fichier qui l'a déjà été a sa contrepartie : le dossier correspondant va inexorablement se remplir au fil de l'utilisation du package. La méthode qui me semble la plus adaptée pour répondre à cette situation est de créer une commande artisan dédiée. On ajoute un dossier et un fichier :

<?php
namespace Happycoder\LaravelEdgeTts\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class CachePruneCommand extends Command
{
/**
* The name and signature of the console command.
* @var string
*/
protected $signature = 'edge-tts:cache-prune
{--days=90 : The number of days after which cache files should be deleted.}';
/**
* The console command description.
* @var string
*/
protected $description = 'Prune old Edge TTS cache files from storage.';
/**
* Execute the console command.
*/
public function handle()
{
$diskName = config('edge-tts.cache.disk', 'local');
$storage = Storage::disk($diskName);
$days = (int) $this->option('days');
// Limit of days to delete files
$cutoffDate = Carbon::now()->subDays($days);
$deletedCount = 0;
$this->info("Pruning Edge TTS cache on disk '{$diskName}'...");
$this->comment("Deleting files older than {$days} days (cutoff date: {$cutoffDate->format('Y-m-d')}).");
// Get all files in the tts/ directory
$files = $storage->allFiles('tts');
if (empty($files)) {
$this->info("No cache files found in 'tts/'.");
return Command::SUCCESS;
}
$bar = $this->output->createProgressBar(count($files));
$bar->start();
foreach ($files as $filePath) {
// Only .mp3 files are concerned
if (!str_ends_with($filePath, '.mp3')) {
$bar->advance();
continue;
}
try {
// Get the last modified date of the file
$lastModified = Carbon::createFromTimestamp($storage->lastModified($filePath));
if ($lastModified->lt($cutoffDate)) {
$storage->delete($filePath);
$deletedCount++;
}
} catch (\Exception $e) {
$this->warn("Could not process file '{$filePath}': " . $e->getMessage());
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Cache pruning complete. Total files deleted: {$deletedCount}.");
return Command::SUCCESS;
}
}
Il faut la charger dans notre service provider :
<?php
...
use Happycoder\LaravelEdgeTts\Console\CachePruneCommand;
...
class EdgeTtsLaravelServiceProvider extends ServiceProvider
{
...
public function boot(): void
{
...
// Register the console command
if ($this->app->runningInConsole()) {
$this->commands([
CachePruneCommand::class,
]);
}
}
}
Le but est de disposer d'une commande pour supprimer les vieux fichiers :
php artisan edge-tts:cache-prune
Par défaut, la notion de "vieux" est de 90 jours, mais en option, on peut entrer une autre valeur :
// Delete files older than 30 days
php artisan edge-tts:cache-prune --days=10
Pour éviter d'oublier de le faire, on peut prévoir une tâche automatique dans route/console.php :
use Illuminate\Support\Facades\Schedule;
Schedule::command('edge-tts:cache-prune --days=60')->daily();
Pour être complet, on prévoit des tests :

<?php
namespace Happycoder\LaravelEdgeTts\Tests\Unit\Console;
use PHPUnit\Framework\Attributes\Test;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use Illuminate\Support\Facades\Storage;
class CachePruneCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Configure the test disk
config(['filesystems.disks.test-disk' => [
'driver' => 'local',
'root' => storage_path('framework/testing/disk1'),
]]);
}
protected function tearDown(): void
{
// Clean up
Storage::disk('test-disk')->deleteDirectory('tts');
parent::tearDown();
}
#[Test]
public function it_has_correct_signature_and_description()
{
$this->artisan('edge-tts:cache-prune --help')
->expectsOutputToContain('Prune old Edge TTS cache files')
->assertExitCode(0);
}
#[Test]
public function it_uses_custom_disk_from_config()
{
config(['edge-tts.cache.disk' => 'test-disk']);
// Create test file
Storage::disk('test-disk')->put('tts/test.txt', 'test');
$this->artisan('edge-tts:cache-prune')
->expectsOutput("Pruning Edge TTS cache on disk 'test-disk'...")
->assertExitCode(0);
}
#[Test]
public function it_deletes_old_files()
{
config(['edge-tts.cache.disk' => 'test-disk']);
$disk = Storage::disk('test-disk');
// Create old file
$disk->put('tts/old.mp3', 'old');
touch($disk->path('tts/old.mp3'), now()->subDays(100)->timestamp);
// Create new file
$disk->put('tts/new.mp3', 'new');
touch($disk->path('tts/new.mp3'), now()->subDays(10)->timestamp);
// Check file exists before delete
$this->assertTrue($disk->exists('tts/old.mp3'));
$this->assertTrue($disk->exists('tts/new.mp3'));
$this->artisan('edge-tts:cache-prune', ['--days' => 30])
->assertExitCode(0);
// Check only old file is deleted
$this->assertFalse($disk->exists('tts/old.mp3'), 'Old file should be deleted');
$this->assertTrue($disk->exists('tts/new.mp3'), 'New file should still exist');
}
#[Test]
public function it_handles_empty_directory()
{
config(['edge-tts.cache.disk' => 'test-disk']);
$this->artisan('edge-tts:cache-prune')
->expectsOutput("No cache files found in 'tts/'.")
->assertExitCode(0);
}
}

Notre opération nettoyage est terminée !
Une façade
Pour Laravel, les façades ont leurs utilisateurs enthousiastes et leurs détracteurs. On va en prévoir une pour ma première catégorie :

Le code en est très simple :
<?php
namespace Happycoder\LaravelEdgeTts\Facades;
use Illuminate\Support\Facades\Facade;
class EdgeTts extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
// Must correspond to the alias defined in register() of the ServiceProvider
return 'laravel-edge-tts';
}
}
Il suffit ensuite de l'enregistrer dans le service provider :
public function register(): void
{
...
// 3. Update the alias for the Facade
// The alias must now point to the Contract
$this->app->alias(TtsSynthesizer::class, 'laravel-edge-tts');
}
Maintenant la façade EdgeTts est disponible dans le projet. C'est une alternative à l'injection de dépendance.
Sur notre lancée, on ajoute les tests :

<?php
namespace Happycoder\LaravelEdgeTts\Tests\Feature;
use Happycoder\LaravelEdgeTts\Contracts\TtsSynthesizer;
use Happycoder\LaravelEdgeTts\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
class EdgeTtsFacadeTest extends TestCase
{
private $ttsMock;
protected function setUp(): void
{
parent::setUp();
// Mock for TtsSynthesizer
$this->ttsMock = Mockery::mock(TtsSynthesizer::class);
// Register the mock in the container
$this->app->instance(TtsSynthesizer::class, $this->ttsMock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_delegates_synthesize_to_service()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = ['rate' => '10%'];
$expected = 'audio_data';
$this->ttsMock->shouldReceive('synthesize')
->once()
->with($text, $voice, $options)
->andReturn($expected);
$result = $this->ttsMock->synthesize($text, $voice, $options);
$this->assertEquals($expected, $result);
}
#[Test]
public function it_delegates_synthesize_stream_to_service()
{
$text = 'Hello world';
$voice = 'en-US-AriaNeural';
$options = ['rate' => '10%'];
$callback = function() {};
$called = false;
$this->ttsMock->shouldReceive('synthesizeStream')
->once()
->with($text, $voice, $options, Mockery::on(function($arg) use (&$called) {
// Verify that the callback is a function
$called = is_callable($arg);
return true;
}));
$this->ttsMock->synthesizeStream($text, $voice, $options, $callback);
// Verify that the callback was properly passed to the service
$this->assertTrue($called, 'The callback was not properly passed to the service');
}
#[Test]
public function it_delegates_get_voices_to_service()
{
$expected = [['ShortName' => 'en-US-AriaNeural']];
$this->ttsMock->shouldReceive('getVoices')
->once()
->andReturn($expected);
$result = $this->ttsMock->getVoices();
$this->assertEquals($expected, $result);
}
#[Test]
public function it_delegates_to_base64_to_service()
{
$text = 'Hello';
$voice = 'en-US-AriaNeural';
$options = [];
$expected = 'base64_encoded_audio';
$this->ttsMock->shouldReceive('toBase64')
->once()
->with($text, $voice, $options)
->andReturn($expected);
$result = $this->ttsMock->toBase64($text, $voice, $options);
$this->assertEquals($expected, $result);
}
#[Test]
public function it_delegates_to_file_to_service()
{
$text = 'Hello';
$voice = 'en-US-AriaNeural';
$path = 'test.mp3';
$options = [];
$expected = '/path/to/file.mp3';
$this->ttsMock->shouldReceive('toFile')
->once()
->with($text, $voice, $path, $options)
->andReturn($expected);
$result = $this->ttsMock->toFile($text, $voice, $path, $options);
$this->assertEquals($expected, $result);
}
#[Test]
public function it_delegates_to_raw_to_service()
{
$text = 'Hello';
$voice = 'en-US-AriaNeural';
$options = [];
$expected = 'raw_audio_data';
$this->ttsMock->shouldReceive('toRaw')
->once()
->with($text, $voice, $options)
->andReturn($expected);
$result = $this->ttsMock->toRaw($text, $voice, $options);
$this->assertEquals($expected, $result);
}
}

Apparemment tout se passe bien !
Un composant Blade
Arrivés à ce stade, nous sommes déjà bien équipés. Mais ça serait plutôt sympa de pouvoir écrire ça dans une vue :
@edge_tts(['text' => 'Il fait beau aujourd'hui !'])
Et évidemment en pouvant jouer avec les options :
@edge_tts([
'text' => 'Bonjour le monde !',
'voice' => 'fr-FR-DeniseNeural',
'rate' => '+30%'
])
Comme on dispose d'une route de streaming, ça devient plutôt facile à réaliser. Dans le service provider ajouter ce code :
use Illuminate\Support\Facades\Blade;
...
public function boot(): void
{
...
// Register the @edge_tts directive
Blade::directive('edge_tts', function ($expression) {
// Expression must be an array (ex: ['text' => 'Hello'])
return "<?php echo '<audio controls autoplay><source src=\"' . route('edge-tts.stream', {$expression}) . '\" type=\"audio/mpeg\"></audio>'; ?>";
});
}
On génère ce genre de code dans la vue :
<audio controls autoplay><source src="http://edgetts.oo/edge-tts/stream?text=Bonjour%20le%20monde&voice=fr-FR-DeniseNeural" type="audio/mpeg"></audio>

Conclusion
J'espère que cette série consacrée au développement d'un package pour Laravel vous a plu. On a passé en revue les fondamentaux et on a vu comment s'organiser. Le package que j'ai publié sur github possède quelques éléments complémentaires. Essentiellement une vue de démonstration. J'ai aussi ajouté la possibilité de mémoriser en Log toute l'activité. Vous pouvez aller consulter le code correspondant.
Par bestmomo
Aucun commentaire