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
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+12-6
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
@@ -61,12 +61,18 @@ return [
6161 * @see \Cion\TextToSpeech\Contracts\Source
6262 */
6363 '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,
6666 'website' => \Cion\TextToSpeech\Sources\WebsiteSource::class,
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.
formatting only · 12 hidden lines
Changes to config/config.php
@@ -94,9 +100,9 @@ return [ formatting only
94100 * IAM Credentials from AWS.
95101 */
96102 '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', ''),
100106 ],
101107
102108 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
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}
src/TextToSpeechManager.phpmodified+9-2
Changes to src/TextToSpeechManager.php
@@ -6,7 +6,9 @@ use Aws\Credentials\Credentials;
66use Aws\Polly\PollyClient;
77use Cion\TextToSpeech\Converters\NullConverter;
88use Cion\TextToSpeech\Converters\PollyConverter;
9use Cion\TextToSpeech\Converters\SixtyDBConverter;
910use Exception;
11use GuzzleHttp\Client;
1012use Illuminate\Support\Manager;
1113
1214class TextToSpeechManager extends Manager
formatting only · 10 hidden lines
Changes to src/TextToSpeechManager.php
@@ -52,8 +54,8 @@ class TextToSpeechManager extends Manager formatting only
5254 protected function setPollyClient(array $config, Credentials $credentials)
5355 {
5456 return new PollyClient([
55 'version' => $config['version'],
56 'region' => $config['region'],
57 'version' => $config['version'],
58 'region' => $config['region'],
5759 'credentials' => $credentials,
5860 ]);
5961 }
Changes to src/TextToSpeechManager.php
@@ -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+164-0
Changes to tests/SixtyDBConverterTest.php
@@ -0,0 +1,164 @@
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
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}