also looking at this
meemalabs/laravel-text-to-speech
feat: add 60db text-to-speech driver
#46
5 files
+403
-9
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | |
| 13 | 13 | This is a Text-To-Speech package for Laravel. Its primary intention is to use a shared API to easily convert text to speech. |
| 14 | 14 | |
| 15 | Currently, the only supported driver is Amazon Polly. We are exploring the option to soon support Google WaveNet. Feel free to start a discussion on any possible driver. | |
| 15 | Supported drivers are Amazon Polly and 60db. We are exploring the option to soon support Google WaveNet. Feel free to start a discussion on any possible driver. | |
| 16 | 16 | |
| 17 | 17 | ## 💡 Usage |
| 18 | 18 | |
| @@ -59,6 +59,27 @@ $output = TextToSpeech::speechMarks(['sentence', 'word', 'viseme', 'ssml']) | ||
| 59 | 59 | ->convert('This is a test'); // This will return an array. |
| 60 | 60 | ``` |
| 61 | 61 | |
| 62 | ### 60db text-to-speech | |
| 63 | ||
| 64 | Set `TTS_DRIVER=sixtydb`, `SIXTYDB_API_KEY`, and `SIXTYDB_VOICE_ID` to an available | |
| 65 | workspace voice ID. Optionally set `SIXTYDB_MODEL_ID`. Publish the package config | |
| 66 | again or add `services.sixtydb` from `config/config.php` to your existing config. | |
| 67 | ||
| 68 | ```php | |
| 69 | TextToSpeech::engine('sixtydb')->saveTo('speech.wav')->convert('Hello world', [ | |
| 70 | 'speed' => 1.0, | |
| 71 | ]); | |
| 72 | ``` | |
| 73 | ||
| 74 | The driver uses the existing source, disk and filename APIs and saves mono | |
| 75 | PCM16 WAV at 24 kHz. Use `.wav` output paths. `voice`, `speed` (0.5–2.0), and | |
| 76 | `model` can be overridden per conversion; `language('en-US')` sends `en` as the | |
| 77 | 60db target language. Input must be plain UTF-8 text, 1–5000 characters; SSML and | |
| 78 | other output formats are rejected. JSON, NDJSON and binary audio responses are | |
| 79 | supported, with bounded response reads and HTTP errors raised before storage. | |
| 80 | The driver requires a 60db API key and uploads text to the | |
| 81 | [60db synthesis API](https://docs.60db.ai/api-reference/tts/text-to-speech). | |
| 82 | ||
| 62 | 83 | ## 🐙 Installation |
| 63 | 84 | |
| 64 | 85 | You can install the package via composer: |
| @@ -16,7 +16,7 @@ return [ | ||
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | 18 | * The driver to be used for converting text-to-speech |
| 19 | * You can choose polly, or null as of now. | |
| 19 | * You can choose polly, sixtydb, or null. | |
| 20 | 20 | */ |
| 21 | 21 | 'driver' => env('TTS_DRIVER', 'polly'), |
| 22 | 22 | |
| @@ -61,12 +61,18 @@ return [ | ||
| 61 | 61 | * @see \Cion\TextToSpeech\Contracts\Source |
| 62 | 62 | */ |
| 63 | 63 | 'sources' => [ |
| 64 | 'text' => \Cion\TextToSpeech\Sources\TextSource::class, | |
| 65 | 'path' => \Cion\TextToSpeech\Sources\PathSource::class, | |
| 64 | 'text' => \Cion\TextToSpeech\Sources\TextSource::class, | |
| 65 | 'path' => \Cion\TextToSpeech\Sources\PathSource::class, | |
| 66 | 66 | 'website' => \Cion\TextToSpeech\Sources\WebsiteSource::class, |
| 67 | 67 | ], |
| 68 | 68 | |
| 69 | 69 | 'services' => [ |
| 70 | 'sixtydb' => [ | |
| 71 | 'api_key' => env('SIXTYDB_API_KEY', ''), | |
| 72 | 'voice_id' => env('SIXTYDB_VOICE_ID', ''), | |
| 73 | 'model_id' => env('SIXTYDB_MODEL_ID'), | |
| 74 | 'speed' => 1.0, | |
| 75 | ], | |
| 70 | 76 | 'polly' => [ |
| 71 | 77 | /** |
| 72 | 78 | * Voice ID to use for the synthesis. |
formatting only · 12 hidden lines
| @@ -94,9 +100,9 @@ return [ formatting only | ||
| 94 | 100 | * IAM Credentials from AWS. |
| 95 | 101 | */ |
| 96 | 102 | 'credentials' => [ |
| 97 | 'key' => env('AWS_ACCESS_KEY_ID', ''), | |
| 98 | 'secret' => env('AWS_SECRET_ACCESS_KEY', ''), | |
| 99 | 'token' => env('AWS_SESSION_TOKEN', ''), | |
| 103 | 'key' => env('AWS_ACCESS_KEY_ID', ''), | |
| 104 | 'secret' => env('AWS_SECRET_ACCESS_KEY', ''), | |
| 105 | 'token' => env('AWS_SESSION_TOKEN', ''), | |
| 100 | 106 | ], |
| 101 | 107 | |
| 102 | 108 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), |
| @@ -0,0 +1,196 @@ | ||
| 1 | <?php | |
| 2 | ||
| 3 | namespace Cion\TextToSpeech\Converters; | |
| 4 | ||
| 5 | use Cion\TextToSpeech\Contracts\Converter; | |
| 6 | use Cion\TextToSpeech\Traits\HasLanguage; | |
| 7 | use Cion\TextToSpeech\Traits\Sourceable; | |
| 8 | use Cion\TextToSpeech\Traits\SSMLable; | |
| 9 | use Cion\TextToSpeech\Traits\Storable; | |
| 10 | use GuzzleHttp\ClientInterface; | |
| 11 | use Illuminate\Support\Facades\Storage; | |
| 12 | use InvalidArgumentException; | |
| 13 | use RuntimeException; | |
| 14 | ||
| 15 | class SixtyDBConverter implements Converter | |
| 16 | { | |
| 17 | use Storable, Sourceable, HasLanguage, SSMLable; | |
| 18 | ||
| 19 | protected $client; | |
| 20 | protected $config; | |
| 21 | ||
| 22 | public function __construct(ClientInterface $client, array $config) | |
| 23 | { | |
| 24 | $this->client = $client; | |
| 25 | $this->config = $config; | |
| 26 | } | |
| 27 | ||
| 28 | public function convert(string $data, ?array $options = null) | |
| 29 | { | |
| 30 | $options = $options ?? []; | |
| 31 | if ($this->path && pathinfo($this->path, PATHINFO_EXTENSION) && strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) !== 'wav') { | |
| 32 | throw new InvalidArgumentException('60db output paths must use the .wav extension.'); | |
| 33 | } | |
| 34 | $text = $this->getTextFromSource($data); | |
| 35 | $length = preg_match_all('/./us', $text); | |
| 36 | $voice = $options['voice'] ?? ($this->config['voice_id'] ?? ''); | |
| 37 | $key = $this->config['api_key'] ?? ''; | |
| 38 | $speed = $options['speed'] ?? ($this->config['speed'] ?? 1); | |
| 39 | if (! is_string($key) || trim($key) === '' || ! is_string($voice) || trim($voice) === '') { | |
| 40 | throw new InvalidArgumentException('60db requires an API key and workspace voice ID.'); | |
| 41 | } | |
| 42 | if ($length === false || $length < 1 || $length > 5000 || trim($text) === '') { | |
| 43 | throw new InvalidArgumentException('60db requires 1 to 5000 UTF-8 characters.'); | |
| 44 | } | |
| 45 | if (! is_numeric($speed) || ! is_finite((float) $speed) || $speed < 0.5 || $speed > 2) { | |
| 46 | throw new InvalidArgumentException('60db speed must be between 0.5 and 2.'); | |
| 47 | } | |
| 48 | if (($options['format'] ?? 'wav') !== 'wav' || ($this->textType ?? config('tts.text_type', 'text')) !== 'text') { | |
| 49 | throw new InvalidArgumentException('60db supports plain text and WAV output in this driver.'); | |
| 50 | } | |
| 51 | $payload = [ | |
| 52 | 'text' => $text, 'voice_id' => $voice, 'speed' => (float) $speed, | |
| 53 | 'audio_config' => ['audio_encoding' => 'LINEAR16', 'sample_rate_hertz' => 24000], | |
| 54 | 'output_format' => 'wav', 'timestamp_type' => 'NONE', | |
| 55 | ]; | |
| 56 | $model = $options['model'] ?? ($this->config['model_id'] ?? null); | |
| 57 | if ($model !== null && (! is_string($model) || trim($model) === '')) { | |
| 58 | throw new InvalidArgumentException('60db model must be a nonempty string.'); | |
| 59 | } | |
| 60 | if ($model) { | |
| 61 | $payload['model_id'] = $model; | |
| 62 | } | |
| 63 | if ($this->language !== null) { | |
| 64 | $payload['target_language'] = explode('-', $this->language)[0]; | |
| 65 | } | |
| 66 | $response = $this->client->request('POST', 'https://api.60db.ai/tts-synthesize', [ | |
| 67 | 'headers' => ['Authorization' => 'Bearer '.$key], 'json' => $payload, | |
| 68 | 'allow_redirects' => false, 'http_errors' => false, 'stream' => true, | |
| 69 | 'connect_timeout' => 10, 'read_timeout' => 30, 'timeout' => 60, | |
| 70 | ]); | |
| 71 | $body = $response->getBody(); | |
| 72 | try { | |
| 73 | if ($response->getStatusCode() !== 200) { | |
| 74 | throw new RuntimeException('60db synthesis failed (HTTP '.$response->getStatusCode().').'); | |
| 75 | } | |
| 76 | $bytes = ''; | |
| 77 | while (! $body->eof()) { | |
| 78 | $chunk = $body->read(65536); | |
| 79 | if ($chunk === '' && ! $body->eof()) { | |
| 80 | throw new RuntimeException('60db response stream stopped making progress.'); | |
| 81 | } | |
| 82 | $bytes .= $chunk; | |
| 83 | if (strlen($bytes) > 32 * 1024 * 1024) { | |
| 84 | throw new RuntimeException('60db response exceeds 32 MiB.'); | |
| 85 | } | |
| 86 | } | |
| 87 | $kind = strtolower(trim(explode(';', $response->getHeaderLine('Content-Type'))[0])); | |
| 88 | if ($kind === 'application/json') { | |
| 89 | $pcm = $this->decode(json_decode($bytes, true, 32, JSON_THROW_ON_ERROR)); | |
| 90 | } elseif (in_array($kind, ['application/x-ndjson', 'application/ndjson', 'text/plain'], true)) { | |
| 91 | $pcm = ''; | |
| 92 | foreach (explode("\n", $bytes) as $line) { | |
| 93 | if (trim($line) !== '') { | |
| 94 | $pcm .= $this->decode(json_decode($line, true, 32, JSON_THROW_ON_ERROR)); | |
| 95 | } | |
| 96 | } | |
| 97 | } elseif (in_array($kind, ['audio/wav', 'audio/x-wav', 'audio/pcm', 'application/octet-stream'], true)) { | |
| 98 | $pcm = $this->pcm($bytes); | |
| 99 | } else { | |
| 100 | throw new RuntimeException('60db returned an unsupported content type.'); | |
| 101 | } | |
| 102 | if ($pcm === '' || strlen($pcm) % 2 !== 0) { | |
| 103 | throw new RuntimeException('60db returned empty or incomplete PCM16 audio.'); | |
| 104 | } | |
| 105 | $wav = 'RIFF'.pack('V', 36 + strlen($pcm)).'WAVEfmt '.pack('VvvVVvv', 16, 1, 1, 24000, 48000, 2, 16) | |
| 106 | .'data'.pack('V', strlen($pcm)).$pcm; | |
| 107 | $this->ensurePathIsNotNull($text, $wav); | |
| 108 | if (! Storage::disk($this->disk ?: config('tts.disk'))->put($this->path, $wav)) { | |
| 109 | throw new RuntimeException('Could not store 60db audio.'); | |
| 110 | } | |
| 111 | ||
| 112 | return $this->path; | |
| 113 | } catch (\JsonException $error) { | |
| 114 | throw new RuntimeException('60db returned invalid JSON.', 0, $error); | |
| 115 | } finally { | |
| 116 | $body->close(); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | protected function decode($record, int $depth = 0): string | |
| 121 | { | |
| 122 | if ($depth > 4 || ! is_array($record) || ($record['success'] ?? true) === false || ! empty($record['error']) || ($record['type'] ?? '') === 'error') { | |
| 123 | throw new RuntimeException('60db returned an invalid synthesis response.'); | |
| 124 | } | |
| 125 | foreach (['encoding', 'audio_encoding', 'output_format'] as $field) { | |
| 126 | if (isset($record[$field]) && (! is_string($record[$field]) || ! in_array(strtolower($record[$field]), ['linear16', 'pcm', 'pcm16', 'wav'], true))) { | |
| 127 | throw new RuntimeException('60db returned incompatible audio encoding.'); | |
| 128 | } | |
| 129 | } | |
| 130 | foreach (['sample_rate' => 24000, 'sample_rate_hertz' => 24000, 'channels' => 1, 'bit_depth' => 16] as $field => $expected) { | |
| 131 | if (isset($record[$field]) && $record[$field] !== $expected) { | |
| 132 | throw new RuntimeException('60db returned incompatible audio metadata.'); | |
| 133 | } | |
| 134 | } | |
| 135 | foreach (['audio_config', 'result', 'backendResponse'] as $field) { | |
| 136 | if (isset($record[$field])) { | |
| 137 | $audio = $this->decode($record[$field], $depth + 1); | |
| 138 | if ($field !== 'audio_config') { | |
| 139 | return $audio; | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | $value = $record['audioContent'] ?? ($record['audio_base64'] ?? null); | |
| 144 | if ($value === null) { | |
| 145 | return ''; | |
| 146 | } | |
| 147 | $audio = is_string($value) ? base64_decode($value, true) : false; | |
| 148 | if ($audio === false) { | |
| 149 | throw new RuntimeException('60db returned invalid base64 audio.'); | |
| 150 | } | |
| 151 | $nested = substr($audio, 0, 1) === '{' ? json_decode($audio, true, 32) : null; | |
| 152 | ||
| 153 | return is_array($nested) ? $this->decode($nested, $depth + 1) : $this->pcm($audio); | |
| 154 | } | |
| 155 | ||
| 156 | protected function pcm(string $audio): string | |
| 157 | { | |
| 158 | if (substr($audio, 0, 4) !== 'RIFF') { | |
| 159 | if (in_array(substr($audio, 0, 4), ['OggS', 'fLaC'], true) || substr($audio, 0, 3) === 'ID3') { | |
| 160 | throw new RuntimeException('60db returned compressed audio instead of PCM16.'); | |
| 161 | } | |
| 162 | ||
| 163 | return $audio; | |
| 164 | } | |
| 165 | if (strlen($audio) < 12 || substr($audio, 8, 4) !== 'WAVE' || unpack('Vsize', substr($audio, 4, 4))['size'] !== strlen($audio) - 8) { | |
| 166 | throw new RuntimeException('60db returned an invalid WAV.'); | |
| 167 | } | |
| 168 | $validFormat = false; | |
| 169 | $pcm = ''; | |
| 170 | for ($offset = 12; $offset + 8 <= strlen($audio);) { | |
| 171 | $id = substr($audio, $offset, 4); | |
| 172 | $size = unpack('Vsize', substr($audio, $offset + 4, 4))['size']; | |
| 173 | $offset += 8; | |
| 174 | if ($size > strlen($audio) - $offset) { | |
| 175 | throw new RuntimeException('60db returned truncated WAV audio.'); | |
| 176 | } | |
| 177 | if ($id === 'fmt ' && $size >= 16) { | |
| 178 | $format = unpack('vcodec/vchannels/Vrate/Vbytes/vblock/vbits', substr($audio, $offset, 16)); | |
| 179 | $validFormat = $format === ['codec' => 1, 'channels' => 1, 'rate' => 24000, 'bytes' => 48000, 'block' => 2, 'bits' => 16]; | |
| 180 | } elseif ($id === 'data') { | |
| 181 | $pcm .= substr($audio, $offset, $size); | |
| 182 | } | |
| 183 | $offset += $size + $size % 2; | |
| 184 | } | |
| 185 | if ($offset !== strlen($audio) || ! $validFormat) { | |
| 186 | throw new RuntimeException('60db WAV must be mono PCM16 at 24000 Hz.'); | |
| 187 | } | |
| 188 | ||
| 189 | return $pcm; | |
| 190 | } | |
| 191 | ||
| 192 | protected function getExtension() | |
| 193 | { | |
| 194 | return 'wav'; | |
| 195 | } | |
| 196 | } | |
| @@ -6,7 +6,9 @@ use Aws\Credentials\Credentials; | ||
| 6 | 6 | use Aws\Polly\PollyClient; |
| 7 | 7 | use Cion\TextToSpeech\Converters\NullConverter; |
| 8 | 8 | use Cion\TextToSpeech\Converters\PollyConverter; |
| 9 | use Cion\TextToSpeech\Converters\SixtyDBConverter; | |
| 9 | 10 | use Exception; |
| 11 | use GuzzleHttp\Client; | |
| 10 | 12 | use Illuminate\Support\Manager; |
| 11 | 13 | |
| 12 | 14 | class TextToSpeechManager extends Manager |
formatting only · 10 hidden lines
| @@ -52,8 +54,8 @@ class TextToSpeechManager extends Manager formatting only | ||
| 52 | 54 | protected function setPollyClient(array $config, Credentials $credentials) |
| 53 | 55 | { |
| 54 | 56 | return new PollyClient([ |
| 55 | 'version' => $config['version'], | |
| 56 | 'region' => $config['region'], | |
| 57 | 'version' => $config['version'], | |
| 58 | 'region' => $config['region'], | |
| 57 | 59 | 'credentials' => $credentials, |
| 58 | 60 | ]); |
| 59 | 61 | } |
| @@ -69,6 +71,11 @@ class TextToSpeechManager extends Manager | ||
| 69 | 71 | return new Credentials($credentials['key'], $credentials['secret'], $credentials['token']); |
| 70 | 72 | } |
| 71 | 73 | |
| 74 | public function createSixtydbDriver() | |
| 75 | { | |
| 76 | return new SixtyDBConverter(new Client(), $this->config['tts.services.sixtydb']); | |
| 77 | } | |
| 78 | ||
| 72 | 79 | /** |
| 73 | 80 | * Create a Null Converter instance. |
| 74 | 81 | * |
| @@ -0,0 +1,164 @@ | ||
| 1 | <?php | |
| 2 | ||
| 3 | namespace Cion\TextToSpeech\Tests; | |
| 4 | ||
| 5 | use Cion\TextToSpeech\Contracts\Source; | |
| 6 | use Cion\TextToSpeech\Converters\SixtyDBConverter; | |
| 7 | use Cion\TextToSpeech\Sources\TextSource; | |
| 8 | use Cion\TextToSpeech\TextToSpeechManager; | |
| 9 | use GuzzleHttp\Client; | |
| 10 | use GuzzleHttp\Handler\MockHandler; | |
| 11 | use GuzzleHttp\HandlerStack; | |
| 12 | use GuzzleHttp\Middleware; | |
| 13 | use GuzzleHttp\Psr7\Response; | |
| 14 | use Illuminate\Config\Repository; | |
| 15 | use Illuminate\Container\Container; | |
| 16 | use Illuminate\Filesystem\FilesystemManager; | |
| 17 | use Illuminate\Support\Facades\Facade; | |
| 18 | use PHPUnit\Framework\TestCase; | |
| 19 | ||
| 20 | class SixtyDBConverterTest extends TestCase | |
| 21 | { | |
| 22 | private $root; | |
| 23 | private $previousContainer; | |
| 24 | private $previousFacade; | |
| 25 | private $container; | |
| 26 | private $history = []; | |
| 27 | ||
| 28 | protected function setUp(): void | |
| 29 | { | |
| 30 | $this->root = sys_get_temp_dir().'/sixtydb-'.bin2hex(random_bytes(8)); | |
| 31 | mkdir($this->root); | |
| 32 | $this->previousContainer = Container::getInstance(); | |
| 33 | $this->previousFacade = Facade::getFacadeApplication(); | |
| 34 | $this->container = new Container(); | |
| 35 | Container::setInstance($this->container); | |
| 36 | $this->container->instance('config', new Repository([ | |
| 37 | 'tts' => ['disk' => 'local', 'text_type' => 'text', 'driver' => 'sixtydb', 'services' => ['sixtydb' => ['api_key' => 'test', 'voice_id' => 'workspace-voice']]], | |
| 38 | 'filesystems' => ['default' => 'local', 'disks' => ['local' => ['driver' => 'local', 'root' => $this->root]]], | |
| 39 | ])); | |
| 40 | $this->container->instance('filesystem', new FilesystemManager($this->container)); | |
| 41 | $this->container->bind(Source::class, TextSource::class); | |
| 42 | Facade::clearResolvedInstances(); | |
| 43 | Facade::setFacadeApplication($this->container); | |
| 44 | } | |
| 45 | ||
| 46 | protected function tearDown(): void | |
| 47 | { | |
| 48 | foreach (glob($this->root.'/*') as $file) { | |
| 49 | unlink($file); | |
| 50 | } | |
| 51 | rmdir($this->root); | |
| 52 | Facade::clearResolvedInstances(); | |
| 53 | Facade::setFacadeApplication($this->previousFacade); | |
| 54 | Container::setInstance($this->previousContainer); | |
| 55 | } | |
| 56 | ||
| 57 | private function converter($body, $type = 'application/json', $status = 200) | |
| 58 | { | |
| 59 | $stack = HandlerStack::create(new MockHandler([new Response($status, ['Content-Type' => $type], $body)])); | |
| 60 | $stack->push(Middleware::history($this->history)); | |
| 61 | ||
| 62 | return (new SixtyDBConverter(new Client(['handler' => $stack]), ['api_key' => 'test', 'voice_id' => 'workspace-voice']))->saveTo('speech.wav'); | |
| 63 | } | |
| 64 | ||
| 65 | public function testItStoresPcmAsWavAndUsesWorkspaceVoice(): void | |
| 66 | { | |
| 67 | $converter = $this->converter(json_encode(['audioContent' => base64_encode("\x01\x00\x02\x00")])); | |
| 68 | $this->assertSame('speech.wav', $converter->language('en-US')->convert('Hello', ['speed' => 1.5, 'model' => 'chosen-model'])); | |
| 69 | $wav = file_get_contents($this->root.'/speech.wav'); | |
| 70 | $this->assertSame('RIFF', substr($wav, 0, 4)); | |
| 71 | $this->assertSame("\x01\x00\x02\x00", substr($wav, 44)); | |
| 72 | $request = $this->history[0]['request']; | |
| 73 | $this->assertSame('https://api.60db.ai/tts-synthesize', (string) $request->getUri()); | |
| 74 | $this->assertSame('Bearer test', $request->getHeaderLine('Authorization')); | |
| 75 | $payload = json_decode((string) $request->getBody(), true); | |
| 76 | $this->assertSame('workspace-voice', $payload['voice_id']); | |
| 77 | $this->assertSame('en', $payload['target_language']); | |
| 78 | $this->assertSame(24000, $payload['audio_config']['sample_rate_hertz']); | |
| 79 | $this->assertSame('chosen-model', $payload['model_id']); | |
| 80 | $this->assertFalse($this->history[0]['options']['allow_redirects']); | |
| 81 | } | |
| 82 | ||
| 83 | public function testItHandlesNdjsonAndDoubleEncodedEnvelopes(): void | |
| 84 | { | |
| 85 | $inner = json_encode(['audioContent' => base64_encode("\x01\x00")]); | |
| 86 | $body = json_encode(['audioContent' => base64_encode($inner)])."\n".json_encode(['audio_base64' => base64_encode("\x02\x00")]); | |
| 87 | $this->converter($body, 'application/x-ndjson')->convert('Hello'); | |
| 88 | $this->assertSame("\x01\x00\x02\x00", substr(file_get_contents($this->root.'/speech.wav'), 44)); | |
| 89 | } | |
| 90 | ||
| 91 | public function testItAcceptsValidWavAndPcmBeginningWithBrace(): void | |
| 92 | { | |
| 93 | $pcm = "{\x00\x02\x00"; | |
| 94 | $wav = 'RIFF'.pack('V', 40).'WAVEfmt '.pack('VvvVVvv', 16, 1, 1, 24000, 48000, 2, 16).'data'.pack('V', 4).$pcm; | |
| 95 | $this->converter($wav, 'audio/wav')->convert('Hello'); | |
| 96 | $this->assertSame($wav, file_get_contents($this->root.'/speech.wav')); | |
| 97 | $this->converter(json_encode(['audioContent' => base64_encode($pcm)]))->convert('Hello'); | |
| 98 | $this->assertSame($wav, file_get_contents($this->root.'/speech.wav')); | |
| 99 | } | |
| 100 | ||
| 101 | public function testItRejectsErrorAndInvalidAudioWithoutSaving(): void | |
| 102 | { | |
| 103 | foreach (['{"success":false}', '{"audioContent":"!"}', '{"sample_rate":16000,"audioContent":"AAA="}', '{"audioContent":"AA=="}', '{}', 'not-json'] as $body) { | |
| 104 | try { | |
| 105 | $this->converter($body)->convert('Hello'); | |
| 106 | $this->fail('Invalid synthesis response accepted'); | |
| 107 | } catch (\RuntimeException $error) { | |
| 108 | $this->assertFileDoesNotExist($this->root.'/speech.wav'); | |
| 109 | } | |
| 110 | } | |
| 111 | $this->expectException(\RuntimeException::class); | |
| 112 | $this->converter('denied', 'application/json', 401)->convert('Hello'); | |
| 113 | } | |
| 114 | ||
| 115 | public function testItRejectsUnsupportedInputBeforeSending(): void | |
| 116 | { | |
| 117 | foreach ([['', []], ['Hello', ['speed' => 3]], ['Hello', ['format' => 'mp3']], ['Hello', ['model' => []]], [str_repeat('a', 5001), []]] as $case) { | |
| 118 | try { | |
| 119 | $this->converter('{}')->convert($case[0], $case[1]); | |
| 120 | $this->fail('Invalid input accepted'); | |
| 121 | } catch (\InvalidArgumentException $error) { | |
| 122 | $this->assertSame([], $this->history); | |
| 123 | } | |
| 124 | } | |
| 125 | $this->expectException(\InvalidArgumentException::class); | |
| 126 | $this->converter('{}')->ssml()->convert('<speak>Hello</speak>'); | |
| 127 | } | |
| 128 | ||
| 129 | public function testItRejectsCompressedTruncatedAndOversizedBodies(): void | |
| 130 | { | |
| 131 | foreach ([['ID3compressed', 'audio/pcm'], ['RIFF'.pack('V', 99).'WAVE', 'audio/wav'], ['unexpected', 'text/html'], [str_repeat("\x00", 32 * 1024 * 1024 + 2), 'audio/pcm']] as $case) { | |
| 132 | try { | |
| 133 | $this->converter($case[0], $case[1])->convert('Hello'); | |
| 134 | $this->fail('Invalid binary audio accepted'); | |
| 135 | } catch (\RuntimeException $error) { | |
| 136 | $this->assertFileDoesNotExist($this->root.'/speech.wav'); | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | public function testStorageFailureDoesNotReturnASuccessPath(): void | |
| 142 | { | |
| 143 | $this->container->instance('filesystem', new class | |
| 144 | { | |
| 145 | public function disk($name) | |
| 146 | { | |
| 147 | return $this; | |
| 148 | } | |
| 149 | ||
| 150 | public function put($path, $bytes) | |
| 151 | { | |
| 152 | return false; | |
| 153 | } | |
| 154 | }); | |
| 155 | Facade::clearResolvedInstances(); | |
| 156 | $this->expectException(\RuntimeException::class); | |
| 157 | $this->converter('{"audioContent":"AAA="}')->convert('Hello'); | |
| 158 | } | |
| 159 | ||
| 160 | public function testManagerResolvesTheNewDriver(): void | |
| 161 | { | |
| 162 | $this->assertInstanceOf(SixtyDBConverter::class, (new TextToSpeechManager($this->container))->engine('sixtydb')); | |
| 163 | } | |
| 164 | } | |