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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ module Resources
5
+ # Text-to-speech resource
6
+ #
7
+ # @example Generate audio
8
+ # audio = client.tts.generate("Hello world", voice_id: "voice_id")
9
+ # audio.save_to_file("output.mp3")
10
+ #
11
+ # @example Stream audio
12
+ # client.tts.stream("Hello world", voice_id: "voice_id") do |chunk|
13
+ # io.write(chunk)
14
+ # end
15
+ class TextToSpeech < Base
16
+ DEFAULT_MODEL = 'eleven_multilingual_v2'
17
+ MAX_TEXT_LENGTH = 5000
18
+
19
+ OUTPUT_FORMATS = %w[
20
+ mp3_44100_128
21
+ mp3_44100_192
22
+ pcm_16000
23
+ pcm_22050
24
+ pcm_24000
25
+ pcm_44100
26
+ ulaw_8000
27
+ ].freeze
28
+
29
+ # Generate audio from text
30
+ #
31
+ # @param text [String] the text to convert
32
+ # @param voice_id [String] the voice ID to use
33
+ # @param model_id [String] the model to use (default: eleven_multilingual_v2)
34
+ # @param voice_settings [Hash] voice settings overrides
35
+ # @param output_format [String] audio output format
36
+ # @return [Objects::Audio]
37
+ def generate(text, voice_id:, model_id: DEFAULT_MODEL, voice_settings: {}, output_format: 'mp3_44100_128')
38
+ validate_text!(text)
39
+ validate_presence!(voice_id, 'voice_id')
40
+
41
+ settings = Objects::VoiceSettings::DEFAULTS.merge(voice_settings)
42
+
43
+ body = {
44
+ text: text,
45
+ model_id: model_id,
46
+ voice_settings: settings
47
+ }
48
+
49
+ path = "/text-to-speech/#{voice_id}?output_format=#{output_format}"
50
+ response = post_binary(path, body)
51
+
52
+ audio = Objects::Audio.new(
53
+ data: response,
54
+ format: output_format,
55
+ voice_id: voice_id,
56
+ text: text,
57
+ model_id: model_id
58
+ )
59
+
60
+ # Trigger cost tracking callback
61
+ cost_info = Objects::CostInfo.new(text: text, voice_id: voice_id, model_id: model_id)
62
+ http_client.config.trigger(
63
+ :on_audio_generated,
64
+ audio: audio,
65
+ voice_id: voice_id,
66
+ text: text,
67
+ cost_info: cost_info.to_h
68
+ )
69
+
70
+ audio
71
+ end
72
+
73
+ # Stream audio from text
74
+ #
75
+ # @param text [String] the text to convert
76
+ # @param voice_id [String] the voice ID to use
77
+ # @param model_id [String] the model to use
78
+ # @param voice_settings [Hash] voice settings overrides
79
+ # @param output_format [String] audio output format
80
+ # @yield [String] each chunk of audio data
81
+ # @return [void]
82
+ def stream(text, voice_id:, model_id: DEFAULT_MODEL, voice_settings: {}, output_format: 'mp3_44100_128', &block)
83
+ validate_text!(text)
84
+ validate_presence!(voice_id, 'voice_id')
85
+ raise ArgumentError, 'Block required for streaming' unless block_given?
86
+
87
+ settings = Objects::VoiceSettings::DEFAULTS.merge(voice_settings)
88
+
89
+ body = {
90
+ text: text,
91
+ model_id: model_id,
92
+ voice_settings: settings
93
+ }
94
+
95
+ path = "/text-to-speech/#{voice_id}/stream?output_format=#{output_format}"
96
+ post_stream(path, body, &block)
97
+
98
+ # Trigger cost tracking callback after streaming completes
99
+ cost_info = Objects::CostInfo.new(text: text, voice_id: voice_id, model_id: model_id)
100
+ http_client.config.trigger(
101
+ :on_audio_generated,
102
+ audio: nil, # No audio object for streaming
103
+ voice_id: voice_id,
104
+ text: text,
105
+ cost_info: cost_info.to_h
106
+ )
107
+ end
108
+
109
+ # Generate audio with timestamps
110
+ #
111
+ # @param text [String] the text to convert
112
+ # @param voice_id [String] the voice ID to use
113
+ # @param model_id [String] the model to use
114
+ # @param voice_settings [Hash] voice settings overrides
115
+ # @param output_format [String] audio output format
116
+ # @return [Hash] contains :audio and :alignment data
117
+ def generate_with_timestamps(text, voice_id:, model_id: DEFAULT_MODEL, voice_settings: {},
118
+ output_format: 'mp3_44100_128')
119
+ validate_text!(text)
120
+ validate_presence!(voice_id, 'voice_id')
121
+
122
+ settings = Objects::VoiceSettings::DEFAULTS.merge(voice_settings)
123
+
124
+ body = {
125
+ text: text,
126
+ model_id: model_id,
127
+ voice_settings: settings
128
+ }
129
+
130
+ path = "/text-to-speech/#{voice_id}/with-timestamps?output_format=#{output_format}"
131
+ response = post(path, body)
132
+
133
+ # Decode base64 audio
134
+ audio_data = Base64.decode64(response['audio_base64']) if response['audio_base64']
135
+
136
+ audio = if audio_data
137
+ Objects::Audio.new(
138
+ data: audio_data,
139
+ format: output_format,
140
+ voice_id: voice_id,
141
+ text: text,
142
+ model_id: model_id
143
+ )
144
+ end
145
+
146
+ {
147
+ audio: audio,
148
+ alignment: response['alignment']
149
+ }
150
+ end
151
+
152
+ private
153
+
154
+ def validate_text!(text)
155
+ validate_presence!(text, 'text')
156
+
157
+ return unless text.length > MAX_TEXT_LENGTH
158
+
159
+ raise Errors::ValidationError,
160
+ "text exceeds maximum length of #{MAX_TEXT_LENGTH} characters (got #{text.length})"
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ module Resources
5
+ # User/account resource
6
+ #
7
+ # @example Get subscription info
8
+ # sub = client.user.subscription
9
+ # puts sub.characters_remaining
10
+ #
11
+ # @example Get user info
12
+ # info = client.user.info
13
+ class User < Base
14
+ # Get subscription information
15
+ #
16
+ # @return [Objects::Subscription]
17
+ def subscription
18
+ response = get('/user/subscription')
19
+ Objects::Subscription.from_response(response)
20
+ end
21
+
22
+ # Get user account information
23
+ #
24
+ # @return [Objects::UserInfo]
25
+ def info
26
+ response = get('/user')
27
+ Objects::UserInfo.from_response(response)
28
+ end
29
+
30
+ # Get subscription with current voice count
31
+ # This makes an additional API call to count voices
32
+ #
33
+ # @return [Objects::Subscription]
34
+ def subscription_with_voice_count(voices_count)
35
+ sub = subscription
36
+ sub.voice_slots_used = voices_count
37
+ sub
38
+ end
39
+
40
+ # Check if user can add more voices
41
+ #
42
+ # @param current_voice_count [Integer]
43
+ # @return [Boolean]
44
+ def can_add_voice?(current_voice_count)
45
+ sub = subscription
46
+ return true unless sub.voice_limit
47
+
48
+ current_voice_count < sub.voice_limit
49
+ end
50
+
51
+ # Get character usage summary
52
+ #
53
+ # @return [Hash]
54
+ def character_usage
55
+ sub = subscription
56
+ {
57
+ used: sub.character_count,
58
+ limit: sub.character_limit,
59
+ remaining: sub.characters_remaining,
60
+ percentage: sub.characters_used_percentage,
61
+ resets_at: sub.next_reset_at
62
+ }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ module Resources
5
+ # Voice library resource for accessing shared voices
6
+ #
7
+ # @example Search for Spanish voices
8
+ # voices = client.voice_library.search(language: "Spanish", gender: "female")
9
+ #
10
+ # @example Add a voice from the library
11
+ # voice = client.voice_library.add(
12
+ # public_user_id: "abc123",
13
+ # voice_id: "xyz789",
14
+ # name: "My Spanish Voice"
15
+ # )
16
+ class VoiceLibrary < Base
17
+ CATEGORIES = %w[professional famous high_quality].freeze
18
+ MAX_PAGE_SIZE = 100
19
+
20
+ # Search the shared voice library
21
+ #
22
+ # @param page_size [Integer] results per page (max 100)
23
+ # @param category [String] voice category filter
24
+ # @param gender [String] gender filter
25
+ # @param age [String] age filter
26
+ # @param accent [String] accent filter
27
+ # @param language [String] language filter
28
+ # @param locale [String] locale filter
29
+ # @param search [String] search term
30
+ # @param use_cases [Array<String>] use case filters
31
+ # @param featured [Boolean] only featured voices
32
+ # @param reader_app_enabled [Boolean] only reader app enabled
33
+ # @param owner_id [String] filter by owner
34
+ # @param sort [String] sort order
35
+ # @param page [String] pagination cursor
36
+ # @return [Collections::LibraryVoiceCollection]
37
+ def search(
38
+ page_size: 30,
39
+ category: nil,
40
+ gender: nil,
41
+ age: nil,
42
+ accent: nil,
43
+ language: nil,
44
+ locale: nil,
45
+ search: nil,
46
+ use_cases: nil,
47
+ featured: nil,
48
+ reader_app_enabled: nil,
49
+ owner_id: nil,
50
+ sort: nil,
51
+ page: nil
52
+ )
53
+ params = {
54
+ page_size: [page_size.to_i, MAX_PAGE_SIZE].min,
55
+ category: category,
56
+ gender: gender,
57
+ age: age,
58
+ accent: accent,
59
+ language: language,
60
+ locale: locale,
61
+ search: search,
62
+ use_cases: use_cases&.join(','),
63
+ featured: featured,
64
+ reader_app_enabled: reader_app_enabled,
65
+ owner_id: owner_id,
66
+ sort: sort,
67
+ cursor: page
68
+ }.compact
69
+
70
+ response = get('/shared-voices', params)
71
+ Collections::LibraryVoiceCollection.from_response(response)
72
+ end
73
+
74
+ # Add a shared voice to your account
75
+ #
76
+ # @param public_user_id [String] the public user ID of the voice owner
77
+ # @param voice_id [String] the voice ID
78
+ # @param name [String] the name to give the voice in your account
79
+ # @return [Objects::Voice]
80
+ def add(public_user_id:, voice_id:, name:)
81
+ validate_presence!(public_user_id, 'public_user_id')
82
+ validate_presence!(voice_id, 'voice_id')
83
+ validate_presence!(name, 'name')
84
+
85
+ response = post("/voices/add/#{public_user_id}/#{voice_id}", { new_name: name })
86
+
87
+ # Trigger callback
88
+ http_client.config.trigger(:on_voice_added, voice_id: response['voice_id'], name: name)
89
+
90
+ # Return a Voice object with the info we have
91
+ Objects::Voice.from_response({
92
+ 'voice_id' => response['voice_id'],
93
+ 'name' => name
94
+ })
95
+ end
96
+
97
+ # Search for voices by keyword
98
+ #
99
+ # @param query [String] search query
100
+ # @param options [Hash] additional search options
101
+ # @return [Collections::LibraryVoiceCollection]
102
+ def find(query, **options)
103
+ search(search: query, **options)
104
+ end
105
+
106
+ # Get all Spanish voices
107
+ #
108
+ # @param options [Hash] additional search options
109
+ # @return [Collections::LibraryVoiceCollection]
110
+ def spanish(**options)
111
+ search(language: 'Spanish', **options)
112
+ end
113
+
114
+ # Get all professional voices
115
+ #
116
+ # @param options [Hash] additional search options
117
+ # @return [Collections::LibraryVoiceCollection]
118
+ def professional(**options)
119
+ search(category: 'professional', **options)
120
+ end
121
+
122
+ # Iterate through all pages of results
123
+ #
124
+ # @param options [Hash] search options
125
+ # @yield [Collections::LibraryVoiceCollection] each page of results
126
+ # @return [Enumerator] if no block given
127
+ def each_page(**options)
128
+ return enum_for(:each_page, **options) unless block_given?
129
+
130
+ cursor = nil
131
+ loop do
132
+ collection = search(**options, page: cursor)
133
+ yield collection
134
+
135
+ break unless collection.has_more?
136
+
137
+ cursor = collection.next_cursor
138
+ end
139
+ end
140
+
141
+ # Get all voices matching criteria (auto-paginates)
142
+ #
143
+ # @param max_pages [Integer] maximum pages to fetch (safety limit)
144
+ # @param options [Hash] search options
145
+ # @return [Array<Objects::LibraryVoice>]
146
+ def all(max_pages: 10, **options)
147
+ voices = []
148
+ pages = 0
149
+
150
+ each_page(**options) do |collection|
151
+ voices.concat(collection.to_a)
152
+ pages += 1
153
+ break if pages >= max_pages
154
+ end
155
+
156
+ voices
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ module Resources
5
+ # Voice management resource
6
+ #
7
+ # @example List all voices
8
+ # client.voices.list
9
+ #
10
+ # @example Get a specific voice
11
+ # client.voices.get("voice_id")
12
+ #
13
+ # @example Delete a voice
14
+ # client.voices.delete("voice_id")
15
+ class Voices < Base
16
+ # List all voices in your account
17
+ #
18
+ # @return [Collections::VoiceCollection]
19
+ def list
20
+ response = get('/voices')
21
+ Collections::VoiceCollection.from_response(response)
22
+ end
23
+
24
+ # Get details for a specific voice
25
+ #
26
+ # @param voice_id [String] the voice ID
27
+ # @return [Objects::Voice]
28
+ def find(voice_id)
29
+ validate_presence!(voice_id, 'voice_id')
30
+ response = get("/voices/#{voice_id}")
31
+ Objects::Voice.from_response(response)
32
+ end
33
+
34
+ # Delete a voice from your account
35
+ #
36
+ # @param voice_id [String] the voice ID
37
+ # @return [Boolean] true if successful
38
+ def destroy(voice_id)
39
+ validate_presence!(voice_id, 'voice_id')
40
+ response = delete("/voices/#{voice_id}")
41
+
42
+ # Trigger callback
43
+ http_client.config.trigger(:on_voice_deleted, voice_id: voice_id)
44
+
45
+ response['status'] == 'ok'
46
+ end
47
+
48
+ # Create a new voice from audio samples (Instant Voice Cloning)
49
+ #
50
+ # @param name [String] voice name
51
+ # @param samples [Array<File>] audio sample files
52
+ # @param description [String, nil] voice description
53
+ # @param labels [Hash] voice labels (e.g., { "accent" => "British" })
54
+ # @return [Objects::Voice]
55
+ def create(name:, samples:, description: nil, labels: {})
56
+ validate_presence!(name, 'name')
57
+ validate_samples!(samples)
58
+
59
+ params = {
60
+ name: name,
61
+ files: samples
62
+ }
63
+ params[:description] = description if description
64
+ params[:labels] = labels.to_json unless labels.empty?
65
+
66
+ response = post_multipart('/voices/add', params)
67
+
68
+ # Trigger callback
69
+ http_client.config.trigger(:on_voice_added, voice_id: response['voice_id'], name: name)
70
+
71
+ Objects::Voice.from_response(response)
72
+ end
73
+
74
+ # Update an existing voice
75
+ #
76
+ # @param voice_id [String] the voice ID
77
+ # @param name [String, nil] new name
78
+ # @param description [String, nil] new description
79
+ # @param samples [Array<File>, nil] additional audio samples
80
+ # @param labels [Hash, nil] new labels
81
+ # @return [Objects::Voice]
82
+ def update(voice_id, name: nil, description: nil, samples: nil, labels: nil)
83
+ validate_presence!(voice_id, 'voice_id')
84
+
85
+ params = {}
86
+ params[:name] = name if name
87
+ params[:description] = description if description
88
+ params[:labels] = labels.to_json if labels
89
+ params[:files] = samples if samples
90
+
91
+ response = if samples
92
+ post_multipart("/voices/#{voice_id}/edit", params)
93
+ else
94
+ post("/voices/#{voice_id}/edit", params)
95
+ end
96
+
97
+ Objects::Voice.from_response(response)
98
+ end
99
+
100
+ # Get default voice settings
101
+ #
102
+ # @return [Objects::VoiceSettings]
103
+ def default_settings
104
+ response = get('/voices/settings/default')
105
+ Objects::VoiceSettings.from_response(response)
106
+ end
107
+
108
+ # Get settings for a specific voice
109
+ #
110
+ # @param voice_id [String] the voice ID
111
+ # @return [Objects::VoiceSettings]
112
+ def settings(voice_id)
113
+ validate_presence!(voice_id, 'voice_id')
114
+ response = get("/voices/#{voice_id}/settings")
115
+ Objects::VoiceSettings.from_response(response)
116
+ end
117
+
118
+ # Update settings for a voice
119
+ #
120
+ # @param voice_id [String] the voice ID
121
+ # @param settings [Hash] the settings to update
122
+ # @return [Boolean]
123
+ def update_settings(voice_id, settings)
124
+ validate_presence!(voice_id, 'voice_id')
125
+ response = post("/voices/#{voice_id}/settings/edit", settings)
126
+ response['status'] == 'ok'
127
+ end
128
+
129
+ private
130
+
131
+ def validate_samples!(samples)
132
+ return if samples.is_a?(Array) && samples.any?
133
+
134
+ raise Errors::ValidationError, 'samples must be an array of files'
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ # Adapter for potential future voiceagent wrapper gem
5
+ #
6
+ # Implements a standard interface that other TTS provider gems could follow,
7
+ # allowing a wrapper gem to provide a unified API across providers.
8
+ #
9
+ # @example Using the adapter directly
10
+ # adapter = ElevenRb::TTSAdapter.new
11
+ # audio = adapter.generate("Hello", voice_id: "abc123")
12
+ #
13
+ # @example With a hypothetical wrapper gem
14
+ # agent = VoiceAgent.new
15
+ # agent.register(:elevenlabs, ElevenRb::TTSAdapter.new)
16
+ # agent.generate("Hello", provider: :elevenlabs, voice_id: "abc123")
17
+ class TTSAdapter
18
+ attr_reader :client
19
+
20
+ # Initialize the adapter
21
+ #
22
+ # @param client [Client, nil] optional client (creates one if not provided)
23
+ def initialize(client = nil)
24
+ @client = client || Client.new
25
+ end
26
+
27
+ # Provider identifier
28
+ #
29
+ # @return [Symbol]
30
+ def provider_name
31
+ :elevenlabs
32
+ end
33
+
34
+ # List available voices
35
+ #
36
+ # @return [Array<Hash>] normalized voice data
37
+ def list_voices
38
+ @client.voices.list.map do |voice|
39
+ {
40
+ provider: :elevenlabs,
41
+ voice_id: voice.voice_id,
42
+ name: voice.name,
43
+ gender: voice.gender,
44
+ language: voice.language,
45
+ accent: voice.accent,
46
+ category: voice.category,
47
+ preview_url: voice.preview_url,
48
+ metadata: voice.to_h
49
+ }
50
+ end
51
+ end
52
+
53
+ # Generate audio from text
54
+ #
55
+ # @param text [String] the text to convert
56
+ # @param voice_id [String] the voice ID
57
+ # @param options [Hash] additional options
58
+ # @return [Objects::Audio]
59
+ def generate(text, voice_id:, **options)
60
+ @client.tts.generate(text, voice_id: voice_id, **options)
61
+ end
62
+
63
+ # Stream audio from text
64
+ #
65
+ # @param text [String] the text to convert
66
+ # @param voice_id [String] the voice ID
67
+ # @param options [Hash] additional options
68
+ # @yield [String] each chunk of audio
69
+ def stream(text, voice_id:, **options, &block)
70
+ @client.tts.stream(text, voice_id: voice_id, **options, &block)
71
+ end
72
+
73
+ # Check if streaming is supported
74
+ #
75
+ # @return [Boolean]
76
+ def supports_streaming?
77
+ true
78
+ end
79
+
80
+ # Get available models
81
+ #
82
+ # @return [Array<Hash>] normalized model data
83
+ def list_models
84
+ @client.models.list.map do |model|
85
+ {
86
+ provider: :elevenlabs,
87
+ model_id: model.model_id,
88
+ name: model.name,
89
+ multilingual: model.multilingual?,
90
+ languages: model.supported_language_codes,
91
+ metadata: model.to_h
92
+ }
93
+ end
94
+ end
95
+
96
+ # Get subscription/quota info
97
+ #
98
+ # @return [Hash] normalized quota data
99
+ def quota
100
+ sub = @client.user.subscription
101
+ {
102
+ provider: :elevenlabs,
103
+ tier: sub.tier,
104
+ characters_used: sub.character_count,
105
+ characters_limit: sub.character_limit,
106
+ characters_remaining: sub.characters_remaining,
107
+ resets_at: sub.next_reset_at
108
+ }
109
+ end
110
+
111
+ # Search voice library
112
+ #
113
+ # @param options [Hash] search options
114
+ # @return [Array<Hash>] normalized voice data
115
+ def search_voices(**options)
116
+ @client.voice_library.search(**options).map do |voice|
117
+ {
118
+ provider: :elevenlabs,
119
+ voice_id: voice.voice_id,
120
+ public_owner_id: voice.public_owner_id,
121
+ name: voice.name,
122
+ gender: voice.gender,
123
+ language: voice.language,
124
+ accent: voice.accent,
125
+ metadata: voice.to_h
126
+ }
127
+ end
128
+ end
129
+
130
+ # Ensure a library voice is available (for voice slot management)
131
+ #
132
+ # @param public_user_id [String]
133
+ # @param voice_id [String]
134
+ # @param name [String]
135
+ # @return [Hash] normalized voice data
136
+ def ensure_voice_available(public_user_id:, voice_id:, name:)
137
+ voice = @client.voice_slots.ensure_available(
138
+ public_user_id: public_user_id,
139
+ voice_id: voice_id,
140
+ name: name
141
+ )
142
+
143
+ {
144
+ provider: :elevenlabs,
145
+ voice_id: voice.voice_id,
146
+ name: voice.name,
147
+ metadata: voice.to_h
148
+ }
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElevenRb
4
+ VERSION = '0.1.0'
5
+ end