typecast-ruby 0.1.6 → 0.1.8

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: f65c6725ca10d449b9ba688231a8ad96a7042197efe9fdba188b84a55fa1d232
4
- data.tar.gz: 9995e18d6b6364c9e9906216d643759ab0570b0867228cb49951a0e7259ccb2e
3
+ metadata.gz: 6f13e3b3f4318a0a02d9e6f4a0ae42aedbd260a422dbb8cdd1cac83954b00c4c
4
+ data.tar.gz: 47db73ac6a78270f0bf13c3c04569fe4ff471211b3cd579cc33b85e15c468816
5
5
  SHA512:
6
- metadata.gz: e724bac05758d13d390cb1865eb522c57b55cdd6ae976d778d28643b06db35f390e3151040a4f611138977a13d6f07448ec35f39cb86f13ce7376915b2820c50
7
- data.tar.gz: 75001ca132eed41352314a431fc34d184f5d3f8c931f3cbce6b5809ca9f90214da108c6f97c7bf48d048744a325ee15783c2bc9d75faef053a74c3fcf93c0dc2
6
+ metadata.gz: ab36553b84e5ec6ee14c7988d2970945e170252f142a6d6e5d8853b2caf171ea87d5c93aa7124cab0b54d9bdcfb6ce3887dd2d9f4ea5e57ff65d882cb45985f8
7
+ data.tar.gz: 193f865fdfd38e7dbe2d5faba6c1288bea4f0665516ea559e15c1079b962726a57214d67589059528699b7f23069fffab1527750e788436423b3d1b51310d37b
@@ -16,12 +16,13 @@ module Typecast
16
16
 
17
17
  attr_reader :api_key, :base_url
18
18
 
19
- def initialize(api_key: ENV["TYPECAST_API_KEY"], base_url: ENV["TYPECAST_API_HOST"] || DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 30)
19
+ def initialize(api_key: ENV["TYPECAST_API_KEY"], base_url: ENV["TYPECAST_API_HOST"] || DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 30, source: nil, generated_by: nil)
20
20
  @api_key = api_key.to_s.strip
21
21
  @base_url = normalize_base_url(base_url)
22
22
  validate_api_key!
23
23
  @open_timeout = open_timeout
24
24
  @read_timeout = read_timeout
25
+ @attribution = attribution_suffix(source, generated_by)
25
26
  end
26
27
 
27
28
  def text_to_speech(request)
@@ -29,14 +30,24 @@ module Typecast
29
30
  Models::TTSResponse.new(
30
31
  audio_data: response.body,
31
32
  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
33
+ format: response["Content-Type"].to_s.downcase.include?("mp3") || response["Content-Type"].to_s.downcase.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
33
34
  )
34
35
  end
35
36
 
36
37
  def compose_speech
37
- SpeechComposer.new(method(:text_to_speech))
38
+ SpeechComposer.new(method(:compose_text_to_speech))
38
39
  end
39
40
 
41
+ def compose_text_to_speech(segments)
42
+ response = request_json(:post, "/v1/text-to-speech/compose", segments: segments)
43
+ Models::TTSResponse.new(
44
+ audio_data: response.body,
45
+ duration: response["X-Audio-Duration"].to_f,
46
+ format: response["Content-Type"].to_s.downcase.include?("mp3") || response["Content-Type"].to_s.downcase.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
47
+ )
48
+ end
49
+ private :compose_text_to_speech
50
+
40
51
  # Browse available API voices at https://typecast.ai/developers/api/voices.
41
52
  def generate_to_file(path, text:, voice_id:, model: Models::TTS_MODEL_V30, language: nil, prompt: nil, output: nil, seed: nil)
42
53
  request = Models::TTSRequest.new(
@@ -180,7 +191,16 @@ module Typecast
180
191
  base = default_base_url? ? "default" : "custom"
181
192
  timeout = @read_timeout == 30 ? "default" : "#{@read_timeout}s"
182
193
  "typecast-ruby/#{VERSION} Ruby/#{RUBY_VERSION} net-http " \
183
- "(base=#{base}; timeout=#{timeout}; os=#{os_name}; arch=#{arch_name}; sdk_env=ruby; platform=server)"
194
+ "(base=#{base}; timeout=#{timeout}; os=#{os_name}; arch=#{arch_name}; sdk_env=ruby; platform=server)#{@attribution}"
195
+ end
196
+
197
+ def attribution_suffix(source, generated_by)
198
+ return "" if source.nil? && generated_by.nil?
199
+ unless %w[llms skill].include?(source) && generated_by&.match?(/\A[a-z0-9][a-z0-9._-]{0,31}\z/)
200
+ raise ArgumentError, "source (llms or skill) and generated_by must be valid and provided together"
201
+ end
202
+
203
+ " typecast-integration/1 (source=#{source}; generated_by=#{generated_by})"
184
204
  end
185
205
 
186
206
  def os_name
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Typecast
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.8"
3
3
  end
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typecast-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neosapience
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  - !ruby/object:Gem::Dependency
14
27
  name: minitest
15
28
  requirement: !ruby/object:Gem::Requirement
@@ -60,7 +73,6 @@ homepage: https://github.com/neosapience/typecast-sdk/tree/main/typecast-ruby
60
73
  licenses:
61
74
  - MIT
62
75
  metadata: {}
63
- post_install_message:
64
76
  rdoc_options: []
65
77
  require_paths:
66
78
  - lib
@@ -75,8 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
87
  - !ruby/object:Gem::Version
76
88
  version: '0'
77
89
  requirements: []
78
- rubygems_version: 3.0.3.1
79
- signing_key:
90
+ rubygems_version: 4.0.16
80
91
  specification_version: 4
81
92
  summary: Official Ruby SDK for the Typecast Text-to-Speech API
82
93
  test_files: []