also looking at this
meemalabs/laravel-text-to-speech
feat: add 60db text-to-speech driver
#46
5 files
+403
-9
| @@ -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 | } | |