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,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
|