eleven_rb 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/CHANGELOG.md +33 -0
- data/LICENSE +21 -0
- data/README.md +311 -0
- data/lib/eleven_rb/callbacks.rb +47 -0
- data/lib/eleven_rb/client.rb +110 -0
- data/lib/eleven_rb/collections/base.rb +92 -0
- data/lib/eleven_rb/collections/library_voice_collection.rb +91 -0
- data/lib/eleven_rb/collections/voice_collection.rb +78 -0
- data/lib/eleven_rb/configuration.rb +87 -0
- data/lib/eleven_rb/errors.rb +58 -0
- data/lib/eleven_rb/http/client.rb +277 -0
- data/lib/eleven_rb/instrumentation.rb +35 -0
- data/lib/eleven_rb/objects/audio.rb +118 -0
- data/lib/eleven_rb/objects/base.rb +86 -0
- data/lib/eleven_rb/objects/cost_info.rb +72 -0
- data/lib/eleven_rb/objects/library_voice.rb +66 -0
- data/lib/eleven_rb/objects/model.rb +56 -0
- data/lib/eleven_rb/objects/subscription.rb +91 -0
- data/lib/eleven_rb/objects/user_info.rb +24 -0
- data/lib/eleven_rb/objects/voice.rb +86 -0
- data/lib/eleven_rb/objects/voice_settings.rb +41 -0
- data/lib/eleven_rb/resources/base.rb +84 -0
- data/lib/eleven_rb/resources/models.rb +65 -0
- data/lib/eleven_rb/resources/text_to_speech.rb +164 -0
- data/lib/eleven_rb/resources/user.rb +66 -0
- data/lib/eleven_rb/resources/voice_library.rb +160 -0
- data/lib/eleven_rb/resources/voices.rb +138 -0
- data/lib/eleven_rb/tts_adapter.rb +151 -0
- data/lib/eleven_rb/version.rb +5 -0
- data/lib/eleven_rb/voice_slot_manager.rb +184 -0
- data/lib/eleven_rb.rb +113 -0
- metadata +193 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElevenRb
|
|
4
|
+
module Collections
|
|
5
|
+
# Collection of Voice objects
|
|
6
|
+
class VoiceCollection < Base
|
|
7
|
+
# Find voice by ID
|
|
8
|
+
#
|
|
9
|
+
# @param voice_id [String]
|
|
10
|
+
# @return [Objects::Voice, nil]
|
|
11
|
+
def find_by_id(voice_id)
|
|
12
|
+
items.find { |v| v.voice_id == voice_id }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Find voice by name (case-insensitive)
|
|
16
|
+
#
|
|
17
|
+
# @param name [String]
|
|
18
|
+
# @return [Objects::Voice, nil]
|
|
19
|
+
def find_by_name(name)
|
|
20
|
+
items.find { |v| v.name&.downcase == name.downcase }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Filter voices by gender
|
|
24
|
+
#
|
|
25
|
+
# @param gender [String]
|
|
26
|
+
# @return [Array<Objects::Voice>]
|
|
27
|
+
def by_gender(gender)
|
|
28
|
+
items.select { |v| v.gender&.downcase == gender.downcase }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Filter voices by language
|
|
32
|
+
#
|
|
33
|
+
# @param language [String]
|
|
34
|
+
# @return [Array<Objects::Voice>]
|
|
35
|
+
def by_language(language)
|
|
36
|
+
items.select { |v| v.language&.downcase == language.downcase }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Filter voices by accent
|
|
40
|
+
#
|
|
41
|
+
# @param accent [String]
|
|
42
|
+
# @return [Array<Objects::Voice>]
|
|
43
|
+
def by_accent(accent)
|
|
44
|
+
items.select { |v| v.accent&.downcase&.include?(accent.downcase) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Filter voices by category
|
|
48
|
+
#
|
|
49
|
+
# @param category [String]
|
|
50
|
+
# @return [Array<Objects::Voice>]
|
|
51
|
+
def by_category(category)
|
|
52
|
+
items.select { |v| v.category&.downcase == category.downcase }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get all voice IDs
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
def voice_ids
|
|
59
|
+
items.map(&:voice_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if voice ID exists in collection
|
|
63
|
+
#
|
|
64
|
+
# @param voice_id [String]
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def include_voice?(voice_id)
|
|
67
|
+
voice_ids.include?(voice_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_items(response)
|
|
73
|
+
voices = response['voices'] || response || []
|
|
74
|
+
voices.map { |v| Objects::Voice.from_response(v) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElevenRb
|
|
4
|
+
# Configuration for the ElevenRb client
|
|
5
|
+
#
|
|
6
|
+
# @example Basic configuration
|
|
7
|
+
# config = ElevenRb::Configuration.new(api_key: "your-api-key")
|
|
8
|
+
#
|
|
9
|
+
# @example Full configuration with callbacks
|
|
10
|
+
# config = ElevenRb::Configuration.new(
|
|
11
|
+
# api_key: "your-api-key",
|
|
12
|
+
# timeout: 60,
|
|
13
|
+
# on_error: ->(error:, **) { Sentry.capture_exception(error) }
|
|
14
|
+
# )
|
|
15
|
+
class Configuration
|
|
16
|
+
include Callbacks
|
|
17
|
+
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
base_url: 'https://api.elevenlabs.io/v1',
|
|
20
|
+
timeout: 120,
|
|
21
|
+
open_timeout: 10,
|
|
22
|
+
max_retries: 3,
|
|
23
|
+
retry_delay: 1.0,
|
|
24
|
+
retry_statuses: [429, 500, 502, 503, 504].freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
attr_accessor :api_key, :base_url, :timeout, :open_timeout,
|
|
28
|
+
:max_retries, :retry_delay, :retry_statuses,
|
|
29
|
+
:logger
|
|
30
|
+
|
|
31
|
+
# Initialize a new configuration
|
|
32
|
+
#
|
|
33
|
+
# @param options [Hash] configuration options
|
|
34
|
+
# @option options [String] :api_key ElevenLabs API key (required)
|
|
35
|
+
# @option options [String] :base_url API base URL (default: https://api.elevenlabs.io/v1)
|
|
36
|
+
# @option options [Integer] :timeout Request timeout in seconds (default: 120)
|
|
37
|
+
# @option options [Integer] :open_timeout Connection timeout in seconds (default: 10)
|
|
38
|
+
# @option options [Integer] :max_retries Maximum retry attempts (default: 3)
|
|
39
|
+
# @option options [Float] :retry_delay Base delay between retries in seconds (default: 1.0)
|
|
40
|
+
# @option options [Array<Integer>] :retry_statuses HTTP status codes to retry (default: [429, 500, 502, 503, 504])
|
|
41
|
+
# @option options [Logger] :logger Logger instance for debug output
|
|
42
|
+
# @option options [Proc] :on_request Callback before each request
|
|
43
|
+
# @option options [Proc] :on_response Callback after successful response
|
|
44
|
+
# @option options [Proc] :on_error Callback when an error occurs
|
|
45
|
+
# @option options [Proc] :on_audio_generated Callback after TTS generation
|
|
46
|
+
# @option options [Proc] :on_retry Callback before retry attempt
|
|
47
|
+
# @option options [Proc] :on_rate_limit Callback when rate limited
|
|
48
|
+
# @option options [Proc] :on_voice_added Callback when voice added
|
|
49
|
+
# @option options [Proc] :on_voice_deleted Callback when voice deleted
|
|
50
|
+
def initialize(**options)
|
|
51
|
+
# Set defaults
|
|
52
|
+
DEFAULTS.each { |k, v| send("#{k}=", v) }
|
|
53
|
+
|
|
54
|
+
# Override with provided options (including callbacks)
|
|
55
|
+
options.each do |key, value|
|
|
56
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate the configuration
|
|
61
|
+
#
|
|
62
|
+
# @raise [Errors::ConfigurationError] if configuration is invalid
|
|
63
|
+
# @return [true]
|
|
64
|
+
def validate!
|
|
65
|
+
if api_key.nil? || api_key.to_s.empty?
|
|
66
|
+
raise Errors::ConfigurationError,
|
|
67
|
+
'API key is required. Set via api_key option or ELEVENLABS_API_KEY environment variable.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Return a hash representation (with API key redacted)
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash]
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
api_key: api_key ? '[REDACTED]' : nil,
|
|
79
|
+
base_url: base_url,
|
|
80
|
+
timeout: timeout,
|
|
81
|
+
open_timeout: open_timeout,
|
|
82
|
+
max_retries: max_retries,
|
|
83
|
+
retry_delay: retry_delay
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElevenRb
|
|
4
|
+
module Errors
|
|
5
|
+
# Base error class for all ElevenRb errors
|
|
6
|
+
class Base < StandardError
|
|
7
|
+
attr_reader :http_status, :response_body, :error_code
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, http_status: nil, response_body: nil, error_code: nil)
|
|
10
|
+
@http_status = http_status
|
|
11
|
+
@response_body = response_body
|
|
12
|
+
@error_code = error_code
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Configuration/setup errors
|
|
18
|
+
class ConfigurationError < Base; end
|
|
19
|
+
|
|
20
|
+
# HTTP 400 - Bad request / validation errors
|
|
21
|
+
class ValidationError < Base; end
|
|
22
|
+
|
|
23
|
+
# HTTP 401 - Unauthorized
|
|
24
|
+
class AuthenticationError < Base; end
|
|
25
|
+
|
|
26
|
+
# HTTP 403 - Forbidden
|
|
27
|
+
class ForbiddenError < Base; end
|
|
28
|
+
|
|
29
|
+
# HTTP 404 - Not found
|
|
30
|
+
class NotFoundError < Base; end
|
|
31
|
+
|
|
32
|
+
# HTTP 422 - Unprocessable entity
|
|
33
|
+
class UnprocessableError < Base; end
|
|
34
|
+
|
|
35
|
+
# HTTP 429 - Rate limited
|
|
36
|
+
class RateLimitError < Base
|
|
37
|
+
attr_reader :retry_after
|
|
38
|
+
|
|
39
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
40
|
+
@retry_after = retry_after
|
|
41
|
+
super(message, **kwargs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# HTTP 5xx - Server errors
|
|
46
|
+
class ServerError < Base; end
|
|
47
|
+
|
|
48
|
+
# Generic API error (fallback)
|
|
49
|
+
class APIError < Base; end
|
|
50
|
+
|
|
51
|
+
# Voice slot specific errors
|
|
52
|
+
class VoiceSlotLimitError < Base; end
|
|
53
|
+
|
|
54
|
+
# Network/connection errors
|
|
55
|
+
class ConnectionError < Base; end
|
|
56
|
+
class TimeoutError < ConnectionError; end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module ElevenRb
|
|
7
|
+
module HTTP
|
|
8
|
+
# HTTP client for making requests to the ElevenLabs API
|
|
9
|
+
class Client
|
|
10
|
+
include HTTParty
|
|
11
|
+
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# Initialize the HTTP client
|
|
15
|
+
#
|
|
16
|
+
# @param config [Configuration] the configuration object
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Make a GET request
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] the API path
|
|
24
|
+
# @param params [Hash] query parameters
|
|
25
|
+
# @return [Hash, Array] parsed JSON response
|
|
26
|
+
def get(path, params = {})
|
|
27
|
+
request(:get, path, params: params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Make a POST request
|
|
31
|
+
#
|
|
32
|
+
# @param path [String] the API path
|
|
33
|
+
# @param body [Hash] request body
|
|
34
|
+
# @param response_type [Symbol] :json or :binary
|
|
35
|
+
# @return [Hash, Array, String] parsed response
|
|
36
|
+
def post(path, body = {}, response_type: :json)
|
|
37
|
+
request(:post, path, body: body, response_type: response_type)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Make a DELETE request
|
|
41
|
+
#
|
|
42
|
+
# @param path [String] the API path
|
|
43
|
+
# @return [Hash] parsed JSON response
|
|
44
|
+
def delete(path)
|
|
45
|
+
request(:delete, path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Make a multipart POST request (for file uploads)
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] the API path
|
|
51
|
+
# @param params [Hash] form parameters including files
|
|
52
|
+
# @return [Hash] parsed JSON response
|
|
53
|
+
def post_multipart(path, params)
|
|
54
|
+
request(:post, path, body: params, multipart: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Make a streaming POST request
|
|
58
|
+
#
|
|
59
|
+
# @param path [String] the API path
|
|
60
|
+
# @param body [Hash] request body
|
|
61
|
+
# @yield [String] yields each chunk of data
|
|
62
|
+
# @return [void]
|
|
63
|
+
def post_stream(path, body = {}, &block)
|
|
64
|
+
request(:post, path, body: body, stream: true, &block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def request(method, path, body: nil, params: nil, response_type: :json, multipart: false, stream: false,
|
|
70
|
+
attempt: 1, &block)
|
|
71
|
+
url = "#{config.base_url}#{path}"
|
|
72
|
+
start_time = Time.now
|
|
73
|
+
|
|
74
|
+
# Trigger before request callback
|
|
75
|
+
config.trigger(:on_request, method: method, path: path, body: sanitize_body_for_logging(body))
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
response = execute_request(method, url, body, params, multipart, stream, &block)
|
|
79
|
+
duration = ((Time.now - start_time) * 1000).round(2)
|
|
80
|
+
|
|
81
|
+
# For streaming, we've already processed the data
|
|
82
|
+
return nil if stream
|
|
83
|
+
|
|
84
|
+
# Trigger after response callback
|
|
85
|
+
config.trigger(:on_response, method: method, path: path, response: response, duration: duration)
|
|
86
|
+
|
|
87
|
+
# Return binary data directly
|
|
88
|
+
return response.body if response_type == :binary && response.success?
|
|
89
|
+
|
|
90
|
+
handle_response(response)
|
|
91
|
+
rescue Errors::RateLimitError => e
|
|
92
|
+
config.trigger(:on_rate_limit, retry_after: e.retry_after, error: e)
|
|
93
|
+
handle_retry(e, method, path, body, params, response_type, multipart, stream, attempt, &block)
|
|
94
|
+
rescue Errors::ServerError => e
|
|
95
|
+
handle_retry(e, method, path, body, params, response_type, multipart, stream, attempt, &block)
|
|
96
|
+
rescue Errors::Base => e
|
|
97
|
+
config.trigger(:on_error, error: e, method: method, path: path,
|
|
98
|
+
context: { body: sanitize_body_for_logging(body) })
|
|
99
|
+
raise
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
wrapped_error = wrap_error(e)
|
|
102
|
+
config.trigger(:on_error, error: wrapped_error, method: method, path: path,
|
|
103
|
+
context: { body: sanitize_body_for_logging(body) })
|
|
104
|
+
raise wrapped_error
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def execute_request(method, url, body, params, multipart, stream, &block)
|
|
109
|
+
options = build_options(body, params, multipart, stream, &block)
|
|
110
|
+
|
|
111
|
+
case method
|
|
112
|
+
when :get
|
|
113
|
+
self.class.get(url, options)
|
|
114
|
+
when :post
|
|
115
|
+
self.class.post(url, options)
|
|
116
|
+
when :delete
|
|
117
|
+
self.class.delete(url, options)
|
|
118
|
+
else
|
|
119
|
+
raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_options(body, params, multipart, stream, &block)
|
|
124
|
+
options = {
|
|
125
|
+
headers: headers(multipart),
|
|
126
|
+
timeout: config.timeout,
|
|
127
|
+
open_timeout: config.open_timeout
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
options[:query] = params if params && !params.empty?
|
|
131
|
+
|
|
132
|
+
if body && !body.empty?
|
|
133
|
+
options[:body] = if multipart
|
|
134
|
+
build_multipart_body(body)
|
|
135
|
+
else
|
|
136
|
+
body.to_json
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if stream && block_given?
|
|
141
|
+
options[:stream_body] = true
|
|
142
|
+
options[:on_data] = block
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
options
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def headers(multipart = false)
|
|
149
|
+
h = {
|
|
150
|
+
'xi-api-key' => config.api_key,
|
|
151
|
+
'Accept' => 'application/json'
|
|
152
|
+
}
|
|
153
|
+
h['Content-Type'] = 'application/json' unless multipart
|
|
154
|
+
h
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_multipart_body(params)
|
|
158
|
+
body = {}
|
|
159
|
+
|
|
160
|
+
params.each do |key, value|
|
|
161
|
+
case key.to_sym
|
|
162
|
+
when :files
|
|
163
|
+
# Handle file array
|
|
164
|
+
Array(value).each_with_index do |file, index|
|
|
165
|
+
body["files[#{index}]"] = file
|
|
166
|
+
end
|
|
167
|
+
else
|
|
168
|
+
body[key.to_s] = value
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
body
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle_response(response)
|
|
176
|
+
return parse_json(response.body) if response.success?
|
|
177
|
+
|
|
178
|
+
handle_error_response(response)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def handle_error_response(response)
|
|
182
|
+
status = response.code
|
|
183
|
+
body = parse_error_body(response.body)
|
|
184
|
+
message = extract_error_message(body)
|
|
185
|
+
|
|
186
|
+
error_class = case status
|
|
187
|
+
when 400 then Errors::ValidationError
|
|
188
|
+
when 401 then Errors::AuthenticationError
|
|
189
|
+
when 403 then Errors::ForbiddenError
|
|
190
|
+
when 404 then Errors::NotFoundError
|
|
191
|
+
when 422 then Errors::UnprocessableError
|
|
192
|
+
when 429 then Errors::RateLimitError
|
|
193
|
+
when 500..599 then Errors::ServerError
|
|
194
|
+
else Errors::APIError
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
error_kwargs = {
|
|
198
|
+
http_status: status,
|
|
199
|
+
response_body: body,
|
|
200
|
+
error_code: body['error_code']
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if error_class == Errors::RateLimitError
|
|
204
|
+
retry_after = response.headers['retry-after']&.to_i
|
|
205
|
+
error_kwargs[:retry_after] = retry_after
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
raise error_class.new(message, **error_kwargs)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def handle_retry(error, method, path, body, params, response_type, multipart, stream, attempt, &block)
|
|
212
|
+
raise error if attempt > config.max_retries || !config.retry_statuses.include?(error.http_status)
|
|
213
|
+
|
|
214
|
+
delay = if error.is_a?(Errors::RateLimitError) && error.retry_after
|
|
215
|
+
error.retry_after
|
|
216
|
+
else
|
|
217
|
+
config.retry_delay * attempt
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
config.trigger(:on_retry, error: error, attempt: attempt, max_attempts: config.max_retries, delay: delay)
|
|
221
|
+
|
|
222
|
+
sleep(delay)
|
|
223
|
+
|
|
224
|
+
request(method, path,
|
|
225
|
+
body: body, params: params, response_type: response_type, multipart: multipart, stream: stream, attempt: attempt + 1, &block)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def wrap_error(error)
|
|
229
|
+
case error
|
|
230
|
+
when Timeout::Error, Net::OpenTimeout
|
|
231
|
+
Errors::TimeoutError.new(error.message)
|
|
232
|
+
when SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET
|
|
233
|
+
Errors::ConnectionError.new(error.message)
|
|
234
|
+
else
|
|
235
|
+
Errors::APIError.new(error.message)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def parse_json(body)
|
|
240
|
+
return {} if body.nil? || body.empty?
|
|
241
|
+
|
|
242
|
+
JSON.parse(body)
|
|
243
|
+
rescue JSON::ParserError
|
|
244
|
+
{}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def parse_error_body(body)
|
|
248
|
+
parse_json(body)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def extract_error_message(body)
|
|
252
|
+
detail = body['detail']
|
|
253
|
+
case detail
|
|
254
|
+
when Hash
|
|
255
|
+
detail['message'] || detail['status'] || detail.to_s
|
|
256
|
+
when String
|
|
257
|
+
detail
|
|
258
|
+
else
|
|
259
|
+
body['message'] || body['error'] || 'Unknown error'
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def sanitize_body_for_logging(body)
|
|
264
|
+
return nil if body.nil?
|
|
265
|
+
|
|
266
|
+
# Remove any sensitive data or large binary content
|
|
267
|
+
if body.is_a?(Hash)
|
|
268
|
+
body.transform_values do |v|
|
|
269
|
+
v.is_a?(File) ? "[FILE: #{v.path}]" : v
|
|
270
|
+
end
|
|
271
|
+
else
|
|
272
|
+
body
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElevenRb
|
|
4
|
+
# Provides ActiveSupport::Notifications integration for Rails apps
|
|
5
|
+
#
|
|
6
|
+
# @example Subscribing to events
|
|
7
|
+
# ActiveSupport::Notifications.subscribe(/eleven_rb/) do |name, start, finish, id, payload|
|
|
8
|
+
# duration = (finish - start) * 1000
|
|
9
|
+
# Rails.logger.info("[ElevenRb] #{name} completed in #{duration.round(2)}ms")
|
|
10
|
+
# end
|
|
11
|
+
module Instrumentation
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Instrument a block with ActiveSupport::Notifications if available
|
|
15
|
+
#
|
|
16
|
+
# @param name [String] the event name (will be suffixed with .eleven_rb)
|
|
17
|
+
# @param payload [Hash] additional data to include in the notification
|
|
18
|
+
# @yield the block to instrument
|
|
19
|
+
# @return [Object] the return value of the block
|
|
20
|
+
def instrument(name, payload = {}, &block)
|
|
21
|
+
if defined?(ActiveSupport::Notifications)
|
|
22
|
+
ActiveSupport::Notifications.instrument("#{name}.eleven_rb", payload, &block)
|
|
23
|
+
elsif block_given?
|
|
24
|
+
yield
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if ActiveSupport::Notifications is available
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def available?
|
|
32
|
+
defined?(ActiveSupport::Notifications)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module ElevenRb
|
|
6
|
+
module Objects
|
|
7
|
+
# Represents generated audio data
|
|
8
|
+
class Audio
|
|
9
|
+
attr_reader :data, :format, :voice_id, :text, :model_id
|
|
10
|
+
|
|
11
|
+
# Initialize audio object
|
|
12
|
+
#
|
|
13
|
+
# @param data [String] binary audio data
|
|
14
|
+
# @param format [String] audio format (e.g., "mp3_44100_128")
|
|
15
|
+
# @param voice_id [String] the voice ID used
|
|
16
|
+
# @param text [String] the text that was converted
|
|
17
|
+
# @param model_id [String, nil] the model ID used
|
|
18
|
+
def initialize(data:, format:, voice_id:, text:, model_id: nil)
|
|
19
|
+
@data = data
|
|
20
|
+
@format = format
|
|
21
|
+
@voice_id = voice_id
|
|
22
|
+
@text = text
|
|
23
|
+
@model_id = model_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Save audio to a file
|
|
27
|
+
#
|
|
28
|
+
# @param path [String] file path to save to
|
|
29
|
+
# @return [String] the path that was written to
|
|
30
|
+
def save_to_file(path)
|
|
31
|
+
File.binwrite(path, data)
|
|
32
|
+
path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get the size in bytes
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
def bytes
|
|
39
|
+
data.bytesize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get the size in kilobytes
|
|
43
|
+
#
|
|
44
|
+
# @return [Float]
|
|
45
|
+
def kilobytes
|
|
46
|
+
bytes / 1024.0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convert to IO object for streaming/uploading
|
|
50
|
+
#
|
|
51
|
+
# @return [StringIO]
|
|
52
|
+
def to_io
|
|
53
|
+
StringIO.new(data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get the content type based on format
|
|
57
|
+
#
|
|
58
|
+
# @return [String]
|
|
59
|
+
def content_type
|
|
60
|
+
case format
|
|
61
|
+
when /mp3/
|
|
62
|
+
'audio/mpeg'
|
|
63
|
+
when /pcm/
|
|
64
|
+
'audio/pcm'
|
|
65
|
+
when /ogg/
|
|
66
|
+
'audio/ogg'
|
|
67
|
+
when /wav/
|
|
68
|
+
'audio/wav'
|
|
69
|
+
when /flac/
|
|
70
|
+
'audio/flac'
|
|
71
|
+
else
|
|
72
|
+
'application/octet-stream'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get file extension based on format
|
|
77
|
+
#
|
|
78
|
+
# @return [String]
|
|
79
|
+
def extension
|
|
80
|
+
case format
|
|
81
|
+
when /mp3/
|
|
82
|
+
'mp3'
|
|
83
|
+
when /pcm/
|
|
84
|
+
'pcm'
|
|
85
|
+
when /ogg/
|
|
86
|
+
'ogg'
|
|
87
|
+
when /wav/
|
|
88
|
+
'wav'
|
|
89
|
+
when /flac/
|
|
90
|
+
'flac'
|
|
91
|
+
else
|
|
92
|
+
'bin'
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get character count of source text
|
|
97
|
+
#
|
|
98
|
+
# @return [Integer]
|
|
99
|
+
def character_count
|
|
100
|
+
text.length
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Check if data is present
|
|
104
|
+
#
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def present?
|
|
107
|
+
data && !data.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Inspect the object
|
|
111
|
+
#
|
|
112
|
+
# @return [String]
|
|
113
|
+
def inspect
|
|
114
|
+
"#<#{self.class.name} format=#{format.inspect} bytes=#{bytes} voice_id=#{voice_id.inspect}>"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|