ReviewOS

also looking at this

meemalabs/laravel-text-to-speech

feat: add 60db text-to-speech driver

#46
Open uditgoenka wants to merge feat/60db-tts into main
5 files +403 -9
src/Converters/SixtyDBConverter.phpadded+196-0
Changes to src/Converters/SixtyDBConverter.php
@@ -0,0 +1,196 @@
1<?php
2
3namespace Cion\TextToSpeech\Converters;
4
5use Cion\TextToSpeech\Contracts\Converter;
6use Cion\TextToSpeech\Traits\HasLanguage;
7use Cion\TextToSpeech\Traits\Sourceable;
8use Cion\TextToSpeech\Traits\SSMLable;
9use Cion\TextToSpeech\Traits\Storable;
10use GuzzleHttp\ClientInterface;
11use Illuminate\Support\Facades\Storage;
12use InvalidArgumentException;
13use RuntimeException;
14
15class 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}