typecast-ruby 0.1.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5de7b5e77a18c1e4e6834db6fedd467b922eb4a6124b01ad67b3521a7b5cebf7
4
- data.tar.gz: 95ddc9f242aebcb75372c93bcebde790e36aa4a84a096c981b5a039055dfcd2b
3
+ metadata.gz: f3d9076c37ff2cfbc76912eb4ac30f63edfbcd7ab0dc40ad403a63f7532a29c7
4
+ data.tar.gz: 81459d58895a8d94fb11b8dfed40b69b3ad7c8b7cab2c6d3df4d651d797c0cd0
5
5
  SHA512:
6
- metadata.gz: 6ade9e4df77283c37b69efc1fc8d417233d31b63b7f256c4badeccdd106f524014f01c526f199b3e187ceb6b8338cd2b0068814a67618930d8ebf37d4ce4ce6e
7
- data.tar.gz: f8f83592269f2d30ee810e257a8713dd34ffadc034933b51b089aa9bc0b1fbff7de9a8b0b9fda8947f914570e76b3f2948eaca65d38d53f49a1db8877ec7aa65
6
+ metadata.gz: 7819e4ed4127aa8277840cebe895a27ccf58e34e07be121b51ff48f236bf85b353b4d234a564a99ef9bac13fcae6e2c2fc1b9f3ec2208d6c166fad5ac754058c
7
+ data.tar.gz: de36d1e5f96ca440863658cd2eba53bff83f3452815c04d2d4b842dc4a00b81528c1bebc3c951b297fb73ebd6c60e6118d9c12755b39c1bab080a541e90fc389
@@ -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(:text_to_speech))
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(
@@ -91,6 +101,18 @@ module Typecast
91
101
  voices.first
92
102
  end
93
103
 
104
+ # Recommends voices from a text description.
105
+ #
106
+ # Results only include voice_id, voice_name, and score. Use get_voice_v2 or
107
+ # get_voices_v2 to fetch detailed voice metadata for returned voice IDs.
108
+ def recommend_voices(query, count: 5)
109
+ raise ArgumentError, "count must be between 1 and 10" unless (1..10).cover?(count)
110
+
111
+ JSON.parse(request_json(:get, "/v1/voices/recommendations", nil, { query: query, count: count }).body).map do |item|
112
+ Models::RecommendedVoice.from_h(item)
113
+ end
114
+ end
115
+
94
116
  def clone_voice(audio:, filename:, name:, model:)
95
117
  validate_clone_inputs(audio, name)
96
118
  response = request_multipart("/v1/voices/clone", audio: audio, filename: filename, fields: { name: name, model: model })
@@ -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: match[0].to_f)
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(text_to_speech)
30
- @text_to_speech = text_to_speech
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
- output_format = @defaults.dig(:output, :audio_format) || Models::AUDIO_WAV
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
- wav_spec = nil
87
- output_samples = []
88
- plan.each do |part|
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: Models::AUDIO_WAV)
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
@@ -179,6 +179,27 @@ module Typecast
179
179
  end
180
180
  end
181
181
 
182
+ class RecommendedVoice
183
+ attr_reader :voice_id, :voice_name, :score
184
+
185
+ # Recommendation results only include the matched voice ID, voice name,
186
+ # and similarity score. Use get_voice_v2 or get_voices_v2 to fetch
187
+ # detailed voice metadata for a returned voice_id.
188
+ def self.from_h(hash)
189
+ new(
190
+ voice_id: hash.fetch("voice_id", ""),
191
+ voice_name: hash.fetch("voice_name", ""),
192
+ score: hash.fetch("score", 0).to_f
193
+ )
194
+ end
195
+
196
+ def initialize(voice_id:, voice_name:, score:)
197
+ @voice_id = voice_id
198
+ @voice_name = voice_name
199
+ @score = score
200
+ end
201
+ end
202
+
182
203
  class VoicesV2Filter
183
204
  attr_reader :model, :gender, :age, :use_cases
184
205
 
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.5
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-06-23 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest