typecast-ruby 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +51 -0
- data/THIRD-PARTY-LICENSES.md +13 -0
- data/lib/typecast/client.rb +209 -0
- data/lib/typecast/errors.rb +53 -0
- data/lib/typecast/models.rb +215 -0
- data/lib/typecast/timestamps.rb +96 -0
- data/lib/typecast/version.rb +3 -0
- data/lib/typecast.rb +5 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 783ee042ba45a4a26659e61c50b2e28fb058f03ba0ad11e1573bf43a5abca44a
|
|
4
|
+
data.tar.gz: a15b17e6e707b5dc8bf4c524c6a3b50ceee8b0dcada0d34820359905f38042c9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 66ace71a958a4f2f05e321d9be6ff162f66e8fbf4c327bb300ff29f716a4b4e64d426cc9034fb830452a97689a2e28ad6143135d501c1e8f024d7d3ab6926c2a
|
|
7
|
+
data.tar.gz: 1145c85137b9fb6253b3db418c8f50ecd53afe244c98a8c6df3b867ed9dc94b24f49f34ee98707fd76f6dc8cbfa1c39ae84698ec31895dff619492731e2ae5f3
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Neosapience
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Typecast Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the Typecast Text-to-Speech API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install typecast-ruby
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For local development from this monorepo:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "typecast-ruby", path: "../typecast-ruby"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "typecast"
|
|
21
|
+
|
|
22
|
+
client = Typecast::Client.new(api_key: ENV["TYPECAST_API_KEY"])
|
|
23
|
+
response = client.text_to_speech(
|
|
24
|
+
Typecast::Models::TTSRequest.new(
|
|
25
|
+
voice_id: "tc_60e5426de8b95f1d3000d7b5",
|
|
26
|
+
text: "Hello from Typecast Ruby.",
|
|
27
|
+
model: Typecast::Models::TTS_MODEL_V30,
|
|
28
|
+
language: "eng",
|
|
29
|
+
output: Typecast::Models::Output.new(audio_format: "wav")
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
File.binwrite("hello.wav", response.audio_data)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- Text-to-speech synthesis
|
|
39
|
+
- Streaming synthesis
|
|
40
|
+
- Word and character timestamps with SRT/VTT helpers
|
|
41
|
+
- Voice listing and subscription APIs
|
|
42
|
+
- Instant voice cloning
|
|
43
|
+
- No runtime dependencies beyond the Ruby standard library
|
|
44
|
+
|
|
45
|
+
## Testing
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
make install
|
|
49
|
+
make test
|
|
50
|
+
TYPECAST_API_KEY=... make e2e
|
|
51
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Third-Party Licenses
|
|
2
|
+
|
|
3
|
+
The Ruby SDK has no runtime gem dependencies.
|
|
4
|
+
|
|
5
|
+
Development and test dependencies:
|
|
6
|
+
|
|
7
|
+
| Dependency | Usage | License |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| minitest | Unit tests | MIT |
|
|
10
|
+
| rake | Test and coverage tasks | MIT |
|
|
11
|
+
|
|
12
|
+
This summary is based on the dependencies declared in `Gemfile` and
|
|
13
|
+
`typecast-ruby.gemspec`.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require "typecast/errors"
|
|
7
|
+
require "typecast/models"
|
|
8
|
+
require "typecast/timestamps"
|
|
9
|
+
|
|
10
|
+
module Typecast
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.typecast.ai".freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :api_key, :base_url
|
|
15
|
+
|
|
16
|
+
def initialize(api_key: ENV["TYPECAST_API_KEY"], base_url: ENV["TYPECAST_API_HOST"] || DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 30)
|
|
17
|
+
@api_key = api_key.to_s
|
|
18
|
+
@base_url = normalize_base_url(base_url)
|
|
19
|
+
@open_timeout = open_timeout
|
|
20
|
+
@read_timeout = read_timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def text_to_speech(request)
|
|
24
|
+
response = request_json(:post, "/v1/text-to-speech", request.to_h)
|
|
25
|
+
Models::TTSResponse.new(
|
|
26
|
+
audio_data: response.body,
|
|
27
|
+
duration: response["X-Audio-Duration"].to_f,
|
|
28
|
+
format: response["Content-Type"].to_s.include?("mp3") || response["Content-Type"].to_s.include?("mpeg") ? Models::AUDIO_MP3 : Models::AUDIO_WAV
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def text_to_speech_stream(request)
|
|
33
|
+
response = request_json(:post, "/v1/text-to-speech/stream", request.to_h)
|
|
34
|
+
return enum_for(:text_to_speech_stream, request) unless block_given?
|
|
35
|
+
|
|
36
|
+
yield response.body
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def text_to_speech_with_timestamps(request, granularity: nil)
|
|
40
|
+
unless granularity.nil? || %w[word char].include?(granularity)
|
|
41
|
+
raise ArgumentError, "granularity must be 'word' or 'char'"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
path = "/v1/text-to-speech/with-timestamps"
|
|
45
|
+
query = granularity.nil? ? nil : { granularity: granularity }
|
|
46
|
+
response = request_json(:post, path, request.to_h, query)
|
|
47
|
+
Models::TTSWithTimestampsResponse.from_h(JSON.parse(response.body))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_my_subscription
|
|
51
|
+
Models::SubscriptionResponse.from_h(JSON.parse(request_json(:get, "/v1/users/me/subscription").body))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_voices_v2(filter = nil)
|
|
55
|
+
query = filter.respond_to?(:to_h) ? filter.to_h : filter
|
|
56
|
+
JSON.parse(request_json(:get, "/v2/voices", nil, query).body).map do |item|
|
|
57
|
+
Models::VoiceV2.from_h(item)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_voice_v2(voice_id)
|
|
62
|
+
voices = JSON.parse(request_json(:get, "/v2/voices/#{path_segment(voice_id)}").body).map do |item|
|
|
63
|
+
Models::VoiceV2.from_h(item)
|
|
64
|
+
end
|
|
65
|
+
raise NotFoundError, "Voice not found: #{voice_id}" if voices.empty?
|
|
66
|
+
|
|
67
|
+
voices.first
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def clone_voice(audio:, filename:, name:, model:)
|
|
71
|
+
validate_clone_inputs(audio, name)
|
|
72
|
+
response = request_multipart("/v1/voices/clone", audio: audio, filename: filename, fields: { name: name, model: model })
|
|
73
|
+
Models::CustomVoice.from_h(JSON.parse(response.body))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_voice(voice_id)
|
|
77
|
+
request_raw(:delete, "/v1/voices/#{path_segment(voice_id)}")
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def request_json(method, path, body = nil, query = nil)
|
|
84
|
+
headers = { "X-API-KEY" => api_key, "Content-Type" => "application/json" }
|
|
85
|
+
request_raw(method, path, body.nil? ? nil : JSON.generate(body), headers, query)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def request_raw(method, path, body = nil, headers = { "X-API-KEY" => api_key }, query = nil)
|
|
89
|
+
uri = build_uri(path, query)
|
|
90
|
+
request = request_for(method, uri)
|
|
91
|
+
headers.each { |key, value| request[key] = value }
|
|
92
|
+
request.body = body unless body.nil?
|
|
93
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
94
|
+
http.open_timeout = @open_timeout
|
|
95
|
+
http.read_timeout = @read_timeout
|
|
96
|
+
http.write_timeout = @read_timeout if http.respond_to?(:write_timeout=)
|
|
97
|
+
http.request(request)
|
|
98
|
+
end
|
|
99
|
+
handle_error(response)
|
|
100
|
+
response
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def request_multipart(path, audio:, filename:, fields:)
|
|
104
|
+
boundary = "typecast-ruby-#{SecureRandom.hex(8)}"
|
|
105
|
+
body = +""
|
|
106
|
+
fields.each do |name, value|
|
|
107
|
+
field_name = disposition_value(name)
|
|
108
|
+
field_value = multipart_body_value(value)
|
|
109
|
+
body << "--#{boundary}\r\n"
|
|
110
|
+
body << "Content-Disposition: form-data; name=\"#{field_name}\"\r\n\r\n"
|
|
111
|
+
body << "#{field_value}\r\n"
|
|
112
|
+
end
|
|
113
|
+
safe_filename = disposition_value(filename)
|
|
114
|
+
body << "--#{boundary}\r\n"
|
|
115
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{safe_filename}\"\r\n"
|
|
116
|
+
body << "Content-Type: application/octet-stream\r\n\r\n"
|
|
117
|
+
body << audio
|
|
118
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
119
|
+
|
|
120
|
+
request_raw(
|
|
121
|
+
:post,
|
|
122
|
+
path,
|
|
123
|
+
body,
|
|
124
|
+
{
|
|
125
|
+
"X-API-KEY" => api_key,
|
|
126
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_clone_inputs(audio, name)
|
|
132
|
+
raise ArgumentError, "audio must be 25MB or smaller" if audio.bytesize > Models::CLONING_MAX_FILE_SIZE
|
|
133
|
+
raise ArgumentError, "name must be 1-#{Models::CLONING_NAME_MAX_LENGTH} chars" if name.empty? || name.length > Models::CLONING_NAME_MAX_LENGTH
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_base_url(value)
|
|
137
|
+
raw = value.to_s
|
|
138
|
+
raw = "https://#{raw}" unless raw.match?(%r{\Ahttps?://}i)
|
|
139
|
+
uri = URI.parse(raw)
|
|
140
|
+
if uri.scheme != "https" && !local_uri?(uri)
|
|
141
|
+
raise ArgumentError, "base_url must use HTTPS"
|
|
142
|
+
end
|
|
143
|
+
uri.to_s
|
|
144
|
+
rescue URI::InvalidURIError
|
|
145
|
+
raise ArgumentError, "base_url must be a valid URL"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def local_uri?(uri)
|
|
149
|
+
uri.scheme == "http" && ["localhost", "127.0.0.1", "::1"].include?(uri.hostname)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def disposition_value(value)
|
|
153
|
+
string = value.to_s
|
|
154
|
+
raise ArgumentError, "multipart field values must not contain CR or LF" if string.match?(/[\r\n]/)
|
|
155
|
+
|
|
156
|
+
string.gsub(/["\\]/) { |character| "\\#{character}" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def multipart_body_value(value)
|
|
160
|
+
string = value.to_s
|
|
161
|
+
raise ArgumentError, "multipart field values must not contain CR or LF" if string.match?(/[\r\n]/)
|
|
162
|
+
|
|
163
|
+
string
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def path_segment(value)
|
|
167
|
+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_uri(path, query)
|
|
171
|
+
uri = URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", path.sub(%r{\A/}, ""))
|
|
172
|
+
uri.query = URI.encode_www_form(query) if query && !query.empty?
|
|
173
|
+
uri
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def request_for(method, uri)
|
|
177
|
+
case method
|
|
178
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
179
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
180
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
181
|
+
else raise ArgumentError, "unsupported method: #{method}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def handle_error(response)
|
|
186
|
+
return if response.code.to_i.between?(200, 299)
|
|
187
|
+
|
|
188
|
+
detail = extract_detail(response.body)
|
|
189
|
+
error = case response.code.to_i
|
|
190
|
+
when 400 then BadRequestError
|
|
191
|
+
when 401 then UnauthorizedError
|
|
192
|
+
when 402 then PaymentRequiredError
|
|
193
|
+
when 404 then NotFoundError
|
|
194
|
+
when 422 then UnprocessableEntityError
|
|
195
|
+
when 429 then RateLimitError
|
|
196
|
+
when 500 then InternalServerError
|
|
197
|
+
else ApiError
|
|
198
|
+
end
|
|
199
|
+
raise error.new(detail)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def extract_detail(body)
|
|
203
|
+
parsed = JSON.parse(body)
|
|
204
|
+
parsed["detail"] || parsed["error"] || parsed["message"] || body
|
|
205
|
+
rescue JSON::ParserError
|
|
206
|
+
body
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Typecast
|
|
2
|
+
class ApiError < StandardError
|
|
3
|
+
attr_reader :status_code, :detail
|
|
4
|
+
|
|
5
|
+
def initialize(message, status_code, detail = nil)
|
|
6
|
+
super(detail.nil? || detail.empty? ? message : "#{message}: #{detail}")
|
|
7
|
+
@status_code = status_code
|
|
8
|
+
@detail = detail
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class BadRequestError < ApiError
|
|
13
|
+
def initialize(detail = nil)
|
|
14
|
+
super("Bad request", 400, detail)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class UnauthorizedError < ApiError
|
|
19
|
+
def initialize(detail = nil)
|
|
20
|
+
super("Unauthorized", 401, detail)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class PaymentRequiredError < ApiError
|
|
25
|
+
def initialize(detail = nil)
|
|
26
|
+
super("Payment required", 402, detail)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class NotFoundError < ApiError
|
|
31
|
+
def initialize(detail = nil)
|
|
32
|
+
super("Not found", 404, detail)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class UnprocessableEntityError < ApiError
|
|
37
|
+
def initialize(detail = nil)
|
|
38
|
+
super("Unprocessable entity", 422, detail)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class RateLimitError < ApiError
|
|
43
|
+
def initialize(detail = nil)
|
|
44
|
+
super("Rate limit exceeded", 429, detail)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class InternalServerError < ApiError
|
|
49
|
+
def initialize(detail = nil)
|
|
50
|
+
super("Internal server error", 500, detail)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
module Typecast
|
|
2
|
+
module Models
|
|
3
|
+
TTS_MODEL_V21 = "ssfm-v21"
|
|
4
|
+
TTS_MODEL_V30 = "ssfm-v30"
|
|
5
|
+
AUDIO_WAV = "wav"
|
|
6
|
+
AUDIO_MP3 = "mp3"
|
|
7
|
+
CLONING_MAX_FILE_SIZE = 25 * 1024 * 1024
|
|
8
|
+
CLONING_NAME_MAX_LENGTH = 30
|
|
9
|
+
|
|
10
|
+
LANGUAGE_CODES = %w[
|
|
11
|
+
eng kor jpn spa deu fra ita pol nld rus ell tam tgl fin zho slk ara hrv
|
|
12
|
+
ukr ind dan swe msa ces por bul ron ben hin hun nan nor pan tha tur vie yue
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class Output
|
|
16
|
+
attr_reader :volume, :target_lufs, :audio_pitch, :audio_tempo, :audio_format
|
|
17
|
+
|
|
18
|
+
def initialize(volume: nil, target_lufs: nil, audio_pitch: nil, audio_tempo: nil, audio_format: nil)
|
|
19
|
+
@volume = volume
|
|
20
|
+
@target_lufs = target_lufs
|
|
21
|
+
@audio_pitch = audio_pitch
|
|
22
|
+
@audio_tempo = audio_tempo
|
|
23
|
+
@audio_format = audio_format
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
Models.compact(
|
|
28
|
+
volume: volume,
|
|
29
|
+
target_lufs: target_lufs,
|
|
30
|
+
audio_pitch: audio_pitch,
|
|
31
|
+
audio_tempo: audio_tempo,
|
|
32
|
+
audio_format: audio_format
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class OutputStream
|
|
38
|
+
attr_reader :audio_pitch, :audio_tempo, :audio_format
|
|
39
|
+
|
|
40
|
+
def initialize(audio_pitch: nil, audio_tempo: nil, audio_format: nil)
|
|
41
|
+
@audio_pitch = audio_pitch
|
|
42
|
+
@audio_tempo = audio_tempo
|
|
43
|
+
@audio_format = audio_format
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
Models.compact(audio_pitch: audio_pitch, audio_tempo: audio_tempo, audio_format: audio_format)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Prompt
|
|
52
|
+
attr_reader :emotion_preset, :emotion_intensity
|
|
53
|
+
|
|
54
|
+
def initialize(emotion_preset: nil, emotion_intensity: nil)
|
|
55
|
+
@emotion_preset = emotion_preset
|
|
56
|
+
@emotion_intensity = emotion_intensity
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
Models.compact(emotion_preset: emotion_preset, emotion_intensity: emotion_intensity)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class PresetPrompt
|
|
65
|
+
attr_reader :emotion_preset, :emotion_intensity
|
|
66
|
+
|
|
67
|
+
def initialize(emotion_preset: nil, emotion_intensity: nil)
|
|
68
|
+
@emotion_preset = emotion_preset
|
|
69
|
+
@emotion_intensity = emotion_intensity
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
Models.compact(emotion_type: "preset", emotion_preset: emotion_preset, emotion_intensity: emotion_intensity)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class SmartPrompt
|
|
78
|
+
attr_reader :previous_text, :next_text
|
|
79
|
+
|
|
80
|
+
def initialize(previous_text: nil, next_text: nil)
|
|
81
|
+
@previous_text = previous_text
|
|
82
|
+
@next_text = next_text
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
Models.compact(emotion_type: "smart", previous_text: previous_text, next_text: next_text)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class TTSRequest
|
|
91
|
+
attr_reader :voice_id, :text, :model, :language, :prompt, :output, :seed
|
|
92
|
+
|
|
93
|
+
def initialize(voice_id:, text:, model:, language: nil, prompt: nil, output: nil, seed: nil)
|
|
94
|
+
@voice_id = voice_id
|
|
95
|
+
@text = text
|
|
96
|
+
@model = model
|
|
97
|
+
@language = language
|
|
98
|
+
@prompt = prompt
|
|
99
|
+
@output = output
|
|
100
|
+
@seed = seed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_h
|
|
104
|
+
Models.compact(
|
|
105
|
+
voice_id: voice_id,
|
|
106
|
+
text: text,
|
|
107
|
+
model: model,
|
|
108
|
+
language: language,
|
|
109
|
+
prompt: Models.value_to_h(prompt),
|
|
110
|
+
output: Models.value_to_h(output),
|
|
111
|
+
seed: seed
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class TTSRequestStream < TTSRequest
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class TTSResponse
|
|
120
|
+
attr_reader :audio_data, :duration, :format
|
|
121
|
+
|
|
122
|
+
def initialize(audio_data:, duration:, format:)
|
|
123
|
+
@audio_data = audio_data
|
|
124
|
+
@duration = duration
|
|
125
|
+
@format = format
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class SubscriptionResponse
|
|
130
|
+
attr_reader :plan, :plan_credits, :used_credits, :concurrency_limit
|
|
131
|
+
|
|
132
|
+
def self.from_h(hash)
|
|
133
|
+
credits = hash.fetch("credits", {})
|
|
134
|
+
limits = hash.fetch("limits", {})
|
|
135
|
+
new(
|
|
136
|
+
plan: hash.fetch("plan", ""),
|
|
137
|
+
plan_credits: credits.fetch("plan_credits", 0),
|
|
138
|
+
used_credits: credits.fetch("used_credits", 0),
|
|
139
|
+
concurrency_limit: limits.fetch("concurrency_limit", 0)
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def initialize(plan:, plan_credits:, used_credits:, concurrency_limit:)
|
|
144
|
+
@plan = plan
|
|
145
|
+
@plan_credits = plan_credits
|
|
146
|
+
@used_credits = used_credits
|
|
147
|
+
@concurrency_limit = concurrency_limit
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class VoiceV2
|
|
152
|
+
attr_reader :voice_id, :voice_name, :models, :gender, :age, :use_cases
|
|
153
|
+
|
|
154
|
+
def self.from_h(hash)
|
|
155
|
+
new(
|
|
156
|
+
voice_id: hash.fetch("voice_id", ""),
|
|
157
|
+
voice_name: hash.fetch("voice_name", ""),
|
|
158
|
+
models: hash.fetch("models", []),
|
|
159
|
+
gender: hash["gender"],
|
|
160
|
+
age: hash["age"],
|
|
161
|
+
use_cases: hash.fetch("use_cases", [])
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def initialize(voice_id:, voice_name:, models:, gender: nil, age: nil, use_cases: [])
|
|
166
|
+
@voice_id = voice_id
|
|
167
|
+
@voice_name = voice_name
|
|
168
|
+
@models = models
|
|
169
|
+
@gender = gender
|
|
170
|
+
@age = age
|
|
171
|
+
@use_cases = use_cases
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class VoicesV2Filter
|
|
176
|
+
attr_reader :model, :gender, :age, :use_cases
|
|
177
|
+
|
|
178
|
+
def initialize(model: nil, gender: nil, age: nil, use_cases: nil)
|
|
179
|
+
@model = model
|
|
180
|
+
@gender = gender
|
|
181
|
+
@age = age
|
|
182
|
+
@use_cases = use_cases
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def to_h
|
|
186
|
+
Models.compact(model: model, gender: gender, age: age, use_cases: use_cases&.join(","))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
class CustomVoice
|
|
191
|
+
attr_reader :voice_id, :name, :model
|
|
192
|
+
|
|
193
|
+
def self.from_h(hash)
|
|
194
|
+
new(voice_id: hash.fetch("voice_id", ""), name: hash.fetch("name", ""), model: hash["model"])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def initialize(voice_id:, name:, model: nil)
|
|
198
|
+
@voice_id = voice_id
|
|
199
|
+
@name = name
|
|
200
|
+
@model = model
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.compact(hash)
|
|
205
|
+
hash.reject { |_key, value| value.nil? }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.value_to_h(value)
|
|
209
|
+
return nil if value.nil?
|
|
210
|
+
return value.to_h if value.respond_to?(:to_h)
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
|
|
3
|
+
module Typecast
|
|
4
|
+
module Models
|
|
5
|
+
class AlignmentSegmentWord
|
|
6
|
+
attr_reader :word, :start_time, :end_time
|
|
7
|
+
|
|
8
|
+
def self.from_h(hash)
|
|
9
|
+
new(word: hash.fetch("word", ""), start_time: hash.fetch("start_time", 0).to_f, end_time: hash.fetch("end_time", 0).to_f)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(word:, start_time:, end_time:)
|
|
13
|
+
@word = word
|
|
14
|
+
@start_time = start_time
|
|
15
|
+
@end_time = end_time
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class AlignmentSegmentCharacter
|
|
20
|
+
attr_reader :character, :start_time, :end_time
|
|
21
|
+
|
|
22
|
+
def self.from_h(hash)
|
|
23
|
+
new(character: hash.fetch("character", ""), start_time: hash.fetch("start_time", 0).to_f, end_time: hash.fetch("end_time", 0).to_f)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(character:, start_time:, end_time:)
|
|
27
|
+
@character = character
|
|
28
|
+
@start_time = start_time
|
|
29
|
+
@end_time = end_time
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class TTSWithTimestampsResponse
|
|
34
|
+
attr_reader :audio, :audio_format, :audio_duration, :words, :characters
|
|
35
|
+
|
|
36
|
+
def self.from_h(hash)
|
|
37
|
+
new(
|
|
38
|
+
audio: hash.fetch("audio", ""),
|
|
39
|
+
audio_format: hash.fetch("audio_format", AUDIO_WAV),
|
|
40
|
+
audio_duration: hash.fetch("audio_duration", 0).to_f,
|
|
41
|
+
words: hash.fetch("words", []).map { |item| AlignmentSegmentWord.from_h(item) },
|
|
42
|
+
characters: hash.fetch("characters", []).map { |item| AlignmentSegmentCharacter.from_h(item) }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(audio:, audio_format:, audio_duration:, words: [], characters: [])
|
|
47
|
+
@audio = audio
|
|
48
|
+
@audio_format = audio_format
|
|
49
|
+
@audio_duration = audio_duration
|
|
50
|
+
@words = words
|
|
51
|
+
@characters = characters
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def audio_bytes
|
|
55
|
+
Base64.decode64(audio)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def save_audio(path)
|
|
59
|
+
File.binwrite(path, audio_bytes)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_srt
|
|
63
|
+
caption_lines(webvtt: false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_vtt
|
|
67
|
+
"WEBVTT\n\n#{caption_lines(webvtt: true)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def caption_lines(webvtt:)
|
|
73
|
+
segments = words.empty? ? characters.map { |c| [c.character, c.start_time, c.end_time] } : words.map { |w| [w.word, w.start_time, w.end_time] }
|
|
74
|
+
raise ArgumentError, "No alignment segments are available" if segments.empty?
|
|
75
|
+
|
|
76
|
+
lines = []
|
|
77
|
+
segments.each_with_index do |segment, index|
|
|
78
|
+
lines << (index + 1).to_s unless webvtt
|
|
79
|
+
lines << "#{format_time(segment[1], webvtt)} --> #{format_time(segment[2], webvtt)}"
|
|
80
|
+
lines << segment[0]
|
|
81
|
+
lines << ""
|
|
82
|
+
end
|
|
83
|
+
lines.join("\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_time(seconds, webvtt)
|
|
87
|
+
millis = (seconds * 1000).round
|
|
88
|
+
hours = millis / 3_600_000
|
|
89
|
+
minutes = (millis % 3_600_000) / 60_000
|
|
90
|
+
secs = (millis % 60_000) / 1000
|
|
91
|
+
ms = millis % 1000
|
|
92
|
+
format("%02d:%02d:%02d%s%03d", hours, minutes, secs, webvtt ? "." : ",", ms)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/typecast.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: typecast-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Neosapience
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: A Ruby client for Typecast TTS, timestamps, voices, subscription, and
|
|
42
|
+
quick cloning APIs.
|
|
43
|
+
email:
|
|
44
|
+
- support@neosapience.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- THIRD-PARTY-LICENSES.md
|
|
52
|
+
- lib/typecast.rb
|
|
53
|
+
- lib/typecast/client.rb
|
|
54
|
+
- lib/typecast/errors.rb
|
|
55
|
+
- lib/typecast/models.rb
|
|
56
|
+
- lib/typecast/timestamps.rb
|
|
57
|
+
- lib/typecast/version.rb
|
|
58
|
+
homepage: https://github.com/neosapience/typecast-sdk/tree/main/typecast-ruby
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata: {}
|
|
62
|
+
post_install_message:
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '2.6'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.0.3.1
|
|
78
|
+
signing_key:
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Official Ruby SDK for the Typecast Text-to-Speech API
|
|
81
|
+
test_files: []
|