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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

README.mdmodified+22-1
Changes to README.md
@@ -12,7 +12,7 @@
1212
1313This is a Text-To-Speech package for Laravel. Its primary intention is to use a shared API to easily convert text to speech.
1414
15Currently, 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.
15Supported 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.
1616
1717## 💡 Usage
1818
@@ -59,6 +59,27 @@ $output = TextToSpeech::speechMarks(['sentence', 'word', 'viseme', 'ssml'])
5959 ->convert('This is a test'); // This will return an array.
6060```
6161
62### 60db text-to-speech
63
64Set `TTS_DRIVER=sixtydb`, `SIXTYDB_API_KEY`, and `SIXTYDB_VOICE_ID` to an available
65workspace voice ID. Optionally set `SIXTYDB_MODEL_ID`. Publish the package config
66again or add `services.sixtydb` from `config/config.php` to your existing config.
67
68```php
69TextToSpeech::engine('sixtydb')->saveTo('speech.wav')->convert('Hello world', [
70 'speed' => 1.0,
71]);
72```
73
74The driver uses the existing source, disk and filename APIs and saves mono
75PCM16 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
7760db target language. Input must be plain UTF-8 text, 1–5000 characters; SSML and
78other output formats are rejected. JSON, NDJSON and binary audio responses are
79supported, with bounded response reads and HTTP errors raised before storage.
80The 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
6283## 🐙 Installation
6384
6485You can install the package via composer:
config/config.phpmodified+7-1
Changes to config/config.php
@@ -16,7 +16,7 @@ return [
1616
1717 /**
1818 * 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.
2020 */
2121 'driver' => env('TTS_DRIVER', 'polly'),
2222
@@ -67,6 +67,12 @@ return [
6767 ],
6868
6969 '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 ],
7076 'polly' => [
7177 /**
7278 * Voice ID to use for the synthesis.
src/Converters/SixtyDBConverter.phpadded+192-0
Changes to src/Converters/SixtyDBConverter.php
@@ -0,0 +1,192 @@
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 return $this->path;
112 } catch (\JsonException $error) {
113 throw new RuntimeException('60db returned invalid JSON.', 0, $error);
114 } finally {
115 $body->close();
116 }
117 }
118
119 protected function decode($record, int $depth = 0): string
120 {
121 if ($depth > 4 || ! is_array($record) || ($record['success'] ?? true) === false || ! empty($record['error']) || ($record['type'] ?? '') === 'error') {
122 throw new RuntimeException('60db returned an invalid synthesis response.');
123 }
124 foreach (['encoding', 'audio_encoding', 'output_format'] as $field) {
125 if (isset($record[$field]) && (! is_string($record[$field]) || ! in_array(strtolower($record[$field]), ['linear16', 'pcm', 'pcm16', 'wav'], true))) {
126 throw new RuntimeException('60db returned incompatible audio encoding.');
127 }
128 }
129 foreach (['sample_rate' => 24000, 'sample_rate_hertz' => 24000, 'channels' => 1, 'bit_depth' => 16] as $field => $expected) {
130 if (isset($record[$field]) && $record[$field] !== $expected) {
131 throw new RuntimeException('60db returned incompatible audio metadata.');
132 }
133 }
134 foreach (['audio_config', 'result', 'backendResponse'] as $field) {
135 if (isset($record[$field])) {
136 $audio = $this->decode($record[$field], $depth + 1);
137 if ($field !== 'audio_config') {
138 return $audio;
139 }
140 }
141 }
142 $value = $record['audioContent'] ?? ($record['audio_base64'] ?? null);
143 if ($value === null) {
144 return '';
145 }
146 $audio = is_string($value) ? base64_decode($value, true) : false;
147 if ($audio === false) {
148 throw new RuntimeException('60db returned invalid base64 audio.');
149 }
150 $nested = substr($audio, 0, 1) === '{' ? json_decode($audio, true, 32) : null;
151 return is_array($nested) ? $this->decode($nested, $depth + 1) : $this->pcm($audio);
152 }
153
154 protected function pcm(string $audio): string
155 {
156 if (substr($audio, 0, 4) !== 'RIFF') {
157 if (in_array(substr($audio, 0, 4), ['OggS', 'fLaC'], true) || substr($audio, 0, 3) === 'ID3') {
158 throw new RuntimeException('60db returned compressed audio instead of PCM16.');
159 }
160 return $audio;
161 }
162 if (strlen($audio) < 12 || substr($audio, 8, 4) !== 'WAVE' || unpack('Vsize', substr($audio, 4, 4))['size'] !== strlen($audio) - 8) {
163 throw new RuntimeException('60db returned an invalid WAV.');
164 }
165 $validFormat = false;
166 $pcm = '';
167 for ($offset = 12; $offset + 8 <= strlen($audio);) {
168 $id = substr($audio, $offset, 4);
169 $size = unpack('Vsize', substr($audio, $offset + 4, 4))['size'];
170 $offset += 8;
171 if ($size > strlen($audio) - $offset) {
172 throw new RuntimeException('60db returned truncated WAV audio.');
173 }
174 if ($id === 'fmt ' && $size >= 16) {
175 $format = unpack('vcodec/vchannels/Vrate/Vbytes/vblock/vbits', substr($audio, $offset, 16));
176 $validFormat = $format === ['codec' => 1, 'channels' => 1, 'rate' => 24000, 'bytes' => 48000, 'block' => 2, 'bits' => 16];
177 } elseif ($id === 'data') {
178 $pcm .= substr($audio, $offset, $size);
179 }
180 $offset += $size + $size % 2;
181 }
182 if ($offset !== strlen($audio) || ! $validFormat) {
183 throw new RuntimeException('60db WAV must be mono PCM16 at 24000 Hz.');
184 }
185 return $pcm;
186 }
187
188 protected function getExtension()
189 {
190 return 'wav';
191 }
192}
src/TextToSpeechManager.phpmodified+7-0
Changes to src/TextToSpeechManager.php
@@ -6,6 +6,8 @@ use Aws\Credentials\Credentials;
66use Aws\Polly\PollyClient;
77use Cion\TextToSpeech\Converters\NullConverter;
88use Cion\TextToSpeech\Converters\PollyConverter;
9use Cion\TextToSpeech\Converters\SixtyDBConverter;
10use GuzzleHttp\Client;
911use Exception;
1012use Illuminate\Support\Manager;
1113
@@ -69,6 +71,11 @@ class TextToSpeechManager extends Manager
6971 return new Credentials($credentials['key'], $credentials['secret'], $credentials['token']);
7072 }
7173
74 public function createSixtydbDriver()
75 {
76 return new SixtyDBConverter(new Client(), $this->config['tts.services.sixtydb']);
77 }
78
7279 /**
7380 * Create a Null Converter instance.
7481 *
tests/SixtyDBConverterTest.phpadded+155-0
Changes to tests/SixtyDBConverterTest.php
@@ -0,0 +1,155 @@
1<?php
2
3namespace Cion\TextToSpeech\Tests;
4
5use Cion\TextToSpeech\Contracts\Source;
6use Cion\TextToSpeech\Converters\SixtyDBConverter;
7use Cion\TextToSpeech\Sources\TextSource;
8use Cion\TextToSpeech\TextToSpeechManager;
9use GuzzleHttp\Client;
10use GuzzleHttp\Handler\MockHandler;
11use GuzzleHttp\HandlerStack;
12use GuzzleHttp\Middleware;
13use GuzzleHttp\Psr7\Response;
14use Illuminate\Config\Repository;
15use Illuminate\Container\Container;
16use Illuminate\Filesystem\FilesystemManager;
17use Illuminate\Support\Facades\Facade;
18use PHPUnit\Framework\TestCase;
19
20class 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 return (new SixtyDBConverter(new Client(['handler' => $stack]), ['api_key' => 'test', 'voice_id' => 'workspace-voice']))->saveTo('speech.wav');
62 }
63
64 public function testItStoresPcmAsWavAndUsesWorkspaceVoice(): void
65 {
66 $converter = $this->converter(json_encode(['audioContent' => base64_encode("\x01\x00\x02\x00")]));
67 $this->assertSame('speech.wav', $converter->language('en-US')->convert('Hello', ['speed' => 1.5, 'model' => 'chosen-model']));
68 $wav = file_get_contents($this->root.'/speech.wav');
69 $this->assertSame('RIFF', substr($wav, 0, 4));
70 $this->assertSame("\x01\x00\x02\x00", substr($wav, 44));
71 $request = $this->history[0]['request'];
72 $this->assertSame('https://api.60db.ai/tts-synthesize', (string) $request->getUri());
73 $this->assertSame('Bearer test', $request->getHeaderLine('Authorization'));
74 $payload = json_decode((string) $request->getBody(), true);
75 $this->assertSame('workspace-voice', $payload['voice_id']);
76 $this->assertSame('en', $payload['target_language']);
77 $this->assertSame(24000, $payload['audio_config']['sample_rate_hertz']);
78 $this->assertSame('chosen-model', $payload['model_id']);
79 $this->assertFalse($this->history[0]['options']['allow_redirects']);
80 }
81
82 public function testItHandlesNdjsonAndDoubleEncodedEnvelopes(): void
83 {
84 $inner = json_encode(['audioContent' => base64_encode("\x01\x00")]);
85 $body = json_encode(['audioContent' => base64_encode($inner)])."\n".json_encode(['audio_base64' => base64_encode("\x02\x00")]);
86 $this->converter($body, 'application/x-ndjson')->convert('Hello');
87 $this->assertSame("\x01\x00\x02\x00", substr(file_get_contents($this->root.'/speech.wav'), 44));
88 }
89
90 public function testItAcceptsValidWavAndPcmBeginningWithBrace(): void
91 {
92 $pcm = "{\x00\x02\x00";
93 $wav = 'RIFF'.pack('V', 40).'WAVEfmt '.pack('VvvVVvv', 16, 1, 1, 24000, 48000, 2, 16).'data'.pack('V', 4).$pcm;
94 $this->converter($wav, 'audio/wav')->convert('Hello');
95 $this->assertSame($wav, file_get_contents($this->root.'/speech.wav'));
96 $this->converter(json_encode(['audioContent' => base64_encode($pcm)]))->convert('Hello');
97 $this->assertSame($wav, file_get_contents($this->root.'/speech.wav'));
98 }
99
100 public function testItRejectsErrorAndInvalidAudioWithoutSaving(): void
101 {
102 foreach (['{"success":false}', '{"audioContent":"!"}', '{"sample_rate":16000,"audioContent":"AAA="}', '{"audioContent":"AA=="}', '{}', 'not-json'] as $body) {
103 try {
104 $this->converter($body)->convert('Hello');
105 $this->fail('Invalid synthesis response accepted');
106 } catch (\RuntimeException $error) {
107 $this->assertFileDoesNotExist($this->root.'/speech.wav');
108 }
109 }
110 $this->expectException(\RuntimeException::class);
111 $this->converter('denied', 'application/json', 401)->convert('Hello');
112 }
113
114 public function testItRejectsUnsupportedInputBeforeSending(): void
115 {
116 foreach ([['', []], ['Hello', ['speed' => 3]], ['Hello', ['format' => 'mp3']], ['Hello', ['model' => []]], [str_repeat('a', 5001), []]] as $case) {
117 try {
118 $this->converter('{}')->convert($case[0], $case[1]);
119 $this->fail('Invalid input accepted');
120 } catch (\InvalidArgumentException $error) {
121 $this->assertSame([], $this->history);
122 }
123 }
124 $this->expectException(\InvalidArgumentException::class);
125 $this->converter('{}')->ssml()->convert('<speak>Hello</speak>');
126 }
127
128 public function testItRejectsCompressedTruncatedAndOversizedBodies(): void
129 {
130 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) {
131 try {
132 $this->converter($case[0], $case[1])->convert('Hello');
133 $this->fail('Invalid binary audio accepted');
134 } catch (\RuntimeException $error) {
135 $this->assertFileDoesNotExist($this->root.'/speech.wav');
136 }
137 }
138 }
139
140 public function testStorageFailureDoesNotReturnASuccessPath(): void
141 {
142 $this->container->instance('filesystem', new class {
143 public function disk($name) { return $this; }
144 public function put($path, $bytes) { return false; }
145 });
146 Facade::clearResolvedInstances();
147 $this->expectException(\RuntimeException::class);
148 $this->converter('{"audioContent":"AAA="}')->convert('Hello');
149 }
150
151 public function testManagerResolvesTheNewDriver(): void
152 {
153 $this->assertInstanceOf(SixtyDBConverter::class, (new TextToSpeechManager($this->container))->engine('sixtydb'));
154 }
155}