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.
@@ -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