typecast-ruby 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/typecast/client.rb +12 -2
- data/lib/typecast/composer.rb +18 -98
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3d9076c37ff2cfbc76912eb4ac30f63edfbcd7ab0dc40ad403a63f7532a29c7
|
|
4
|
+
data.tar.gz: 81459d58895a8d94fb11b8dfed40b69b3ad7c8b7cab2c6d3df4d651d797c0cd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7819e4ed4127aa8277840cebe895a27ccf58e34e07be121b51ff48f236bf85b353b4d234a564a99ef9bac13fcae6e2c2fc1b9f3ec2208d6c166fad5ac754058c
|
|
7
|
+
data.tar.gz: de36d1e5f96ca440863658cd2eba53bff83f3452815c04d2d4b842dc4a00b81528c1bebc3c951b297fb73ebd6c60e6118d9c12755b39c1bab080a541e90fc389
|
data/lib/typecast/client.rb
CHANGED
|
@@ -29,14 +29,24 @@ module Typecast
|
|
|
29
29
|
Models::TTSResponse.new(
|
|
30
30
|
audio_data: response.body,
|
|
31
31
|
duration: response["X-Audio-Duration"].to_f,
|
|
32
|
-
format: response["Content-Type"].to_s.include?("mp3") || response["Content-Type"].to_s.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
|
|
32
|
+
format: response["Content-Type"].to_s.downcase.include?("mp3") || response["Content-Type"].to_s.downcase.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
|
|
33
33
|
)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def compose_speech
|
|
37
|
-
SpeechComposer.new(method(:
|
|
37
|
+
SpeechComposer.new(method(:compose_text_to_speech))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def compose_text_to_speech(segments)
|
|
41
|
+
response = request_json(:post, "/v1/text-to-speech/compose", segments: segments)
|
|
42
|
+
Models::TTSResponse.new(
|
|
43
|
+
audio_data: response.body,
|
|
44
|
+
duration: response["X-Audio-Duration"].to_f,
|
|
45
|
+
format: response["Content-Type"].to_s.downcase.include?("mp3") || response["Content-Type"].to_s.downcase.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
private :compose_text_to_speech
|
|
49
|
+
|
|
40
50
|
# Browse available API voices at https://typecast.ai/developers/api/voices.
|
|
41
51
|
def generate_to_file(path, text:, voice_id:, model: Models::TTS_MODEL_V30, language: nil, prompt: nil, output: nil, seed: nil)
|
|
42
52
|
request = Models::TTSRequest.new(
|
data/lib/typecast/composer.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "stringio"
|
|
2
|
-
|
|
3
1
|
require "typecast/models"
|
|
4
2
|
|
|
5
3
|
module Typecast
|
|
@@ -13,10 +11,12 @@ module Typecast
|
|
|
13
11
|
last_index = 0
|
|
14
12
|
text.to_s.scan(PAUSE_TOKEN) do |match|
|
|
15
13
|
match_data = Regexp.last_match
|
|
14
|
+
seconds = match[0].to_f
|
|
15
|
+
next unless seconds.finite? && seconds.positive?
|
|
16
16
|
if match_data.begin(0) > last_index
|
|
17
17
|
parts << TextPart.new(kind: "text", text: text[last_index...match_data.begin(0)])
|
|
18
18
|
end
|
|
19
|
-
parts << PausePart.new(kind: "pause", seconds:
|
|
19
|
+
parts << PausePart.new(kind: "pause", seconds: seconds)
|
|
20
20
|
last_index = match_data.end(0)
|
|
21
21
|
end
|
|
22
22
|
if last_index < text.length
|
|
@@ -26,8 +26,8 @@ module Typecast
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
class SpeechComposer
|
|
29
|
-
def initialize(
|
|
30
|
-
@
|
|
29
|
+
def initialize(compose)
|
|
30
|
+
@compose = compose
|
|
31
31
|
@defaults = {}
|
|
32
32
|
@parts = []
|
|
33
33
|
end
|
|
@@ -78,39 +78,22 @@ module Typecast
|
|
|
78
78
|
raise ArgumentError, "at least one speech segment is required"
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
formats = plan.each_with_object([]) do |part, values|
|
|
82
|
+
format = part.is_a?(Hash) && part[:kind] == "speech" ? part.dig(:settings, :output, :audio_format) : nil
|
|
83
|
+
values << format if format
|
|
84
|
+
end.uniq
|
|
85
|
+
raise ArgumentError, "composed speech segments must use one audio format" if formats.length > 1
|
|
86
|
+
|
|
87
|
+
output_format = formats.first || Models::AUDIO_WAV
|
|
82
88
|
unless [Models::AUDIO_WAV, Models::AUDIO_MP3].include?(output_format)
|
|
83
89
|
raise ArgumentError, "unsupported composed speech output format: #{output_format}"
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if part.is_a?(PausePart)
|
|
90
|
-
raise ArgumentError, "pause cannot be the first composed part" if wav_spec.nil?
|
|
91
|
-
|
|
92
|
-
output_samples.concat(Array.new(seconds_to_samples(part.seconds, wav_spec[:sample_rate]), 0))
|
|
93
|
-
next
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
response = @text_to_speech.call(request_from_settings(part[:text], part[:settings]))
|
|
97
|
-
wav = parse_wav(response.audio_data)
|
|
98
|
-
if wav_spec && wav[:spec] != wav_spec
|
|
99
|
-
raise ArgumentError, "all composed WAV segments must use the same PCM format"
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
wav_spec = wav[:spec]
|
|
103
|
-
output_samples.concat(trim_silence(wav[:samples]))
|
|
92
|
+
segments = plan.map do |part|
|
|
93
|
+
part.is_a?(PausePart) ? { type: "pause", duration_seconds: part.seconds } :
|
|
94
|
+
{ type: "tts", **request_from_settings(part[:text], part[:settings], output_format).to_h }
|
|
104
95
|
end
|
|
105
|
-
|
|
106
|
-
wav_data = encode_wav(output_samples, wav_spec)
|
|
107
|
-
raise ArgumentError, "ffmpeg is required to encode composed speech as mp3" if output_format == Models::AUDIO_MP3
|
|
108
|
-
|
|
109
|
-
Models::TTSResponse.new(
|
|
110
|
-
audio_data: wav_data,
|
|
111
|
-
duration: output_samples.length.to_f / wav_spec[:sample_rate],
|
|
112
|
-
format: Models::AUDIO_WAV
|
|
113
|
-
)
|
|
96
|
+
@compose.call(segments)
|
|
114
97
|
end
|
|
115
98
|
|
|
116
99
|
private
|
|
@@ -169,8 +152,8 @@ module Typecast
|
|
|
169
152
|
output
|
|
170
153
|
end
|
|
171
154
|
|
|
172
|
-
def request_from_settings(text, settings)
|
|
173
|
-
output = merge_output(settings[:output], audio_format:
|
|
155
|
+
def request_from_settings(text, settings, output_format)
|
|
156
|
+
output = merge_output(settings[:output], audio_format: output_format)
|
|
174
157
|
Models::TTSRequest.new(
|
|
175
158
|
voice_id: settings[:voice_id],
|
|
176
159
|
text: text,
|
|
@@ -182,68 +165,5 @@ module Typecast
|
|
|
182
165
|
)
|
|
183
166
|
end
|
|
184
167
|
|
|
185
|
-
def parse_wav(data)
|
|
186
|
-
io = StringIO.new(data)
|
|
187
|
-
raise ArgumentError, "unsupported WAV data" unless io.read(4) == "RIFF"
|
|
188
|
-
|
|
189
|
-
io.read(4)
|
|
190
|
-
raise ArgumentError, "unsupported WAV data" unless io.read(4) == "WAVE"
|
|
191
|
-
|
|
192
|
-
spec = nil
|
|
193
|
-
samples = nil
|
|
194
|
-
until io.eof?
|
|
195
|
-
chunk_id = io.read(4)
|
|
196
|
-
break if chunk_id.nil? || chunk_id.bytesize < 4
|
|
197
|
-
|
|
198
|
-
chunk_size_bytes = io.read(4)
|
|
199
|
-
raise ArgumentError, "unsupported WAV data" if chunk_size_bytes.nil? || chunk_size_bytes.bytesize < 4
|
|
200
|
-
|
|
201
|
-
chunk_size = chunk_size_bytes.unpack1("V")
|
|
202
|
-
chunk_data = io.read(chunk_size)
|
|
203
|
-
io.read(1) if chunk_size.odd?
|
|
204
|
-
raise ArgumentError, "unsupported WAV data" if chunk_data.nil? || chunk_data.bytesize < chunk_size
|
|
205
|
-
|
|
206
|
-
case chunk_id
|
|
207
|
-
when "fmt "
|
|
208
|
-
audio_format, channels, sample_rate, _byte_rate, _block_align, bits_per_sample = chunk_data.unpack("vvVVvv")
|
|
209
|
-
if audio_format != 1 || channels != 1 || bits_per_sample != 16
|
|
210
|
-
raise ArgumentError, "only mono 16-bit PCM WAV is supported for composed speech"
|
|
211
|
-
end
|
|
212
|
-
spec = { sample_rate: sample_rate, channels: channels, bits_per_sample: bits_per_sample }
|
|
213
|
-
when "data"
|
|
214
|
-
samples = chunk_data.unpack("s<*")
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
raise ArgumentError, "unsupported WAV data" if spec.nil? || samples.nil?
|
|
219
|
-
|
|
220
|
-
{ spec: spec, samples: samples }
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def encode_wav(samples, spec)
|
|
224
|
-
payload = samples.pack("s<*")
|
|
225
|
-
[
|
|
226
|
-
"RIFF",
|
|
227
|
-
[36 + payload.bytesize].pack("V"),
|
|
228
|
-
"WAVE",
|
|
229
|
-
"fmt ",
|
|
230
|
-
[16, 1, spec[:channels], spec[:sample_rate], spec[:sample_rate] * spec[:channels] * 2, spec[:channels] * 2, spec[:bits_per_sample]].pack("VvvVVvv"),
|
|
231
|
-
"data",
|
|
232
|
-
[payload.bytesize].pack("V"),
|
|
233
|
-
payload
|
|
234
|
-
].join
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def trim_silence(samples)
|
|
238
|
-
start_index = 0
|
|
239
|
-
end_index = samples.length
|
|
240
|
-
start_index += 1 while start_index < end_index && samples[start_index].abs <= 0
|
|
241
|
-
end_index -= 1 while end_index > start_index && samples[end_index - 1].abs <= 0
|
|
242
|
-
samples[start_index...end_index] || []
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def seconds_to_samples(seconds, sample_rate)
|
|
246
|
-
(seconds * sample_rate).round
|
|
247
|
-
end
|
|
248
168
|
end
|
|
249
169
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typecast-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Neosapience
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|