omniai-google 3.19.0 → 3.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4c5367190abf998fe07c957ea0bcb092660540d96e6a81bbe82b1f67e751895
4
- data.tar.gz: 1d37b3a65b76667d647f35091b432cfa4c94458d222a1d43120d509145045d94
3
+ metadata.gz: ce2434a17917341984b99049670ef65083443c2c04b8937d2b8d4af688a5b631
4
+ data.tar.gz: 6c22fa11d66efc7df6ca393bc7f167c5f682ac0398a934a6b25a8674920a6905
5
5
  SHA512:
6
- metadata.gz: 4227aa6e92bc19605188ba27e0d5b3b33672f4d66b1cbfdc14a0d17c47ad205f86e893d131f9986c830df21135d778a97e962f1575c67adf5d72c5cac2d6b3ae
7
- data.tar.gz: eb585c514a31d9fa8577968ff5e558a9b9da3762d7c316e4528dfecd7e60890f408adf5172d94a2b7981e3382d00c75177b3a2c6f092a371f1685037ec6dea06
6
+ metadata.gz: f935566b3cae177f1bd445f19e2a2b87ae32d9acc274d5a5a4cb470b867c36d3e2382d9ca945f5b8e5a00a617806a1c1d4399905b642c716b2c10e9bf6dfd568
7
+ data.tar.gz: b183524e4139f0a2470749fde7bb0fb837dc32cd0ca9f810af1559d44a2fbef4e62a3e5fe98e8b385de4f66559b55026a643a04371477f909ffd9bb1d45adaca
data/README.md CHANGED
@@ -344,6 +344,36 @@ client.transcribe("audio.mp3", model: OmniAI::Google::Transcribe::Model::CHIRP)
344
344
 
345
345
  [Google Speech-to-Text API Reference](https://cloud.google.com/speech-to-text/docs)
346
346
 
347
+ #### Gemini Transcribe
348
+
349
+ `gemini-3.5-transcribe` is a Gemini model served by Vertex `generateContent`, not Speech-to-Text, so it needs Vertex
350
+ credentials. Pass it as the model; the Chirp models above are unaffected.
351
+
352
+ ```ruby
353
+ transcription = client.transcribe("interview.wav", model: OmniAI::Google::Transcribe::Model::GEMINI_3_5_TRANSCRIBE)
354
+ transcription.text
355
+
356
+ # Speaker labels per utterance (experimental, up to 8 speakers)
357
+ transcription = client.transcribe("interview.wav", model: "gemini-3.5-transcribe", diarization: true, format: "verbose_json")
358
+ transcription.segments # => [{ "id" => 0, "text" => "...", "speaker" => "spk:0" }, ...]
359
+
360
+ # Language hints and custom vocabulary (up to 1,000 terms); the language is auto-detected when omitted
361
+ client.transcribe("interview.wav", model: "gemini-3.5-transcribe", language: "es-US", vocabulary: %w[ibuprofeno])
362
+
363
+ # Word times (experimental, off by default; see the caveat below)
364
+ client.transcribe("interview.wav", model: "gemini-3.5-transcribe", word_timestamps: true, format: "verbose_json")
365
+ ```
366
+
367
+ - **Location:** served from `global` and the `us` / `eu` multi-regions, never a single region. A zonal `location_id`
368
+ maps to its multi-region (`us` by default, `eu` for `eu*` / `europe*`); `global` is kept.
369
+ - **Input:** wav, mp3, flac, ogg, webm, m4a or aiff (detected from the bytes), or a `gs://` URI. Files over 10 MB are
370
+ uploaded to the `<project_id>-speech-audio` bucket and deleted afterwards.
371
+ - **Length:** Google caps a file at 15 minutes when diarization or timestamps are on, so split longer recordings.
372
+ - **Word timestamps can drop speech.** In live tests on a two-speaker, 8-second clip (2026-10-15), `word_timestamps: true`
373
+ returned only the first word of the answer, and with `diarization: true` as well it dropped the whole first utterance.
374
+ Google labels both features experimental and warns that word timestamps degrade accuracy. `diarization:` alone
375
+ returned the complete transcript but no times.
376
+
347
377
  #### GCS Setup for Transcription
348
378
 
349
379
  For transcription to work properly with automatic file uploads, you need to set up Google Cloud Storage and configure the appropriate permissions.
@@ -426,6 +456,59 @@ client.transcribe("audio.mp3", bucket_name: "my-custom-audio-bucket")
426
456
  - Audio files are automatically deleted after successful transcription
427
457
  - If transcription fails, temporary files may remain and should be cleaned up manually
428
458
 
459
+ ### Live
460
+
461
+ A Gemini Live session (Vertex `BidiGenerateContent` over a WebSocket): stream microphone audio in, get speech,
462
+ transcripts and tool calls back. Vertex credentials only.
463
+
464
+ ```ruby
465
+ client = OmniAI::Google::Client.new(
466
+ project_id: "...", credentials: "...",
467
+ location_id: "us", host: "https://aiplatform.us.rep.googleapis.com" # gemini-3.8-live: us, eu or us-central1 (not global)
468
+ )
469
+
470
+ session = client.live(
471
+ model: OmniAI::Google::Live::Model::GEMINI_3_8_LIVE,
472
+ system: "You are an intake assistant...",
473
+ tools: [RecordAnswer.new], # OmniAI::Tool; run automatically, results sent back
474
+ tool_behavior: :blocking, # default; or :non_blocking, or { "flag" => :non_blocking }
475
+ voice: "Kore", # optional
476
+ vad: { silence_ms: 800 }, # optional
477
+ compression: { trigger_tokens: 10_000, target_tokens: 5_000 } # optional (or true): see Limits
478
+ ) do |event|
479
+ case event
480
+ when OmniAI::Google::Live::Event::Audio then speaker.write(event.data) # PCM16, 24 kHz, mono
481
+ when OmniAI::Google::Live::Event::Transcript then caption(event.role, event.text) # :user / :model
482
+ when OmniAI::Google::Live::Event::Interrupted then speaker.flush
483
+ when OmniAI::Google::Live::Event::GoAway then reconnect_within(event.time_left)
484
+ when OmniAI::Google::Live::Event::Usage then track(event.usage, event.data) # data has per-modality counts
485
+ when OmniAI::Google::Live::Event::Closed then reconnect_or_finish(event.code, event.error)
486
+ end
487
+ end
488
+
489
+ session.send_audio(pcm16_16khz) # from any thread
490
+ session.send_text("We are at question 12; answers so far: ...") # e.g. to re-seed after reconnecting
491
+ session.close
492
+ ```
493
+
494
+ - **Threads:** each session has one socket and one reader thread. Events are yielded, and tools run, on that thread;
495
+ `send_*` may be called from any thread. A send that blocks longer than `write_timeout:` (default 5 s) closes the
496
+ session and raises `Live::TimeoutError`, as does a connect (TCP + TLS) slower than 10 s. An idle connection is pinged
497
+ every 15 s; with no answer in 10 s it is dropped, so a silently dead path still ends the session. The session always
498
+ ends with exactly one `Event::Closed`, including when the socket drops or your handler raises (`event.error`).
499
+ - **Tools:** a raising tool answers the model with its error class only, and the session carries on.
500
+ - **Limits (Google):** a connection lasts about 10 minutes, and an audio-only session 15 minutes without
501
+ `compression:`. Measured live at `us` (2026-10-15): `GoAway` arrived at 9 min (`time_left` 30 s, not the 60 s
502
+ Google documents) and the server closed with 1000 at 9.5 min. Reconnect when `GoAway` arrives, not on a timer: the documented numbers are off.
503
+ - **Cost:** every turn re-bills the whole context. `compression:` bounds it: in a live test with a 10,000-token trigger
504
+ and 5,000-token target, `promptTokenCount` dropped from about 8,500 to 3,400 whenever the next turn would pass the
505
+ trigger (a sawtooth instead of linear growth; 51% fewer prompt tokens over 12 turns). The oldest turns are dropped,
506
+ so keep anything the model must remember in your own state and re-send it when needed. The gem does not reconnect: open a new session and re-seed it
507
+ (`resumption:` takes Google's resumption handles if you prefer that).
508
+ - **Privacy:** the gem never logs or raises audio, transcripts or tool arguments. `Live::Error#message` carries codes and
509
+ ids only; Google's close reason is on `#reason`, and `Event::Closed#inspect` leaves it out. A server frame that fails
510
+ to parse closes the session with a `Live::Error` naming only the exception class.
511
+
429
512
  ### Embed
430
513
 
431
514
  Text can be converted into a vector embedding for similarity comparison usage via:
@@ -96,8 +96,20 @@ module OmniAI
96
96
  # @param model [String] optional
97
97
  # @param language [String, Array<String>] optional - language codes for transcription
98
98
  # @param format [Symbol] optional - :json or :verbose_json
99
- def transcribe(input, model: Transcribe::DEFAULT_MODEL, language: nil, format: nil)
100
- Transcribe.process!(input, model:, language:, format:, client: self)
99
+ # @param options [Hash] Gemini transcribe models only: `diarization:`, `word_timestamps:`, `vocabulary:`
100
+ def transcribe(input, model: Transcribe::DEFAULT_MODEL, language: nil, format: nil, **)
101
+ Transcribe.process!(input, model:, language:, format:, client: self, **)
102
+ end
103
+
104
+ # Opens a Gemini Live session (Vertex only). See `OmniAI::Google::Live#initialize` for the options.
105
+ #
106
+ # @raise [OmniAI::Google::Live::Error]
107
+ #
108
+ # @yieldparam event [OmniAI::Google::Live::Event::*]
109
+ #
110
+ # @return [OmniAI::Google::Live]
111
+ def live(model: Live::DEFAULT_MODEL, **, &)
112
+ Live.open(client: self, model:, **, &)
101
113
  end
102
114
 
103
115
  # @return [String]
@@ -155,7 +155,7 @@ module OmniAI
155
155
 
156
156
  # @return [String]
157
157
  def path
158
- "/#{@client.path}/models/#{@model}:#{PROCEDURES[endpoint]}"
158
+ "#{@client.path}/models/#{@model}:#{PROCEDURES[endpoint]}"
159
159
  end
160
160
  end
161
161
  end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "websocket/driver"
5
+
6
+ module OmniAI
7
+ module Google
8
+ class Live
9
+ # A WebSocket client over TCP or TLS, driven by websocket-driver with one reader thread per connection.
10
+ #
11
+ # Callbacks run on the reader thread. Writes may come from any thread: they are serialized by a mutex and bounded
12
+ # by `write_timeout`, so a stalled peer can't block the caller indefinitely. An idle connection is pinged, so a
13
+ # silently dropped path is noticed. `on_close` is called exactly once, whether the peer closes, the socket hits
14
+ # EOF or errors, a write or pong times out, or a callback raises.
15
+ class Connection
16
+ CONNECT_TIMEOUT = 10 # seconds, TCP and TLS together
17
+ CLOSE_TIMEOUT = 2 # seconds
18
+ PING_INTERVAL = 15 # seconds without a frame before pinging
19
+ PONG_TIMEOUT = 10 # seconds
20
+ READ_CHUNK = 16_384 # bytes
21
+
22
+ class ConnectTimeout < StandardError; end
23
+ class WriteTimeout < StandardError; end
24
+ class PongTimeout < StandardError; end
25
+
26
+ # @return [String]
27
+ attr_reader :url
28
+
29
+ # @param url [String] a ws:// or wss:// URL
30
+ # @param headers [Hash] sent with the upgrade request
31
+ # @param write_timeout [Numeric] seconds
32
+ # @param on_open [Proc] called with no arguments
33
+ # @param on_message [Proc] called with the payload String
34
+ # @param on_close [Proc] called with (code, reason, error)
35
+ def initialize(url:, headers:, write_timeout:, on_open:, on_message:, on_close:)
36
+ @url = url
37
+ @headers = headers
38
+ @write_timeout = write_timeout
39
+ @on_open = on_open
40
+ @on_message = on_message
41
+ @on_close = on_close
42
+ @mutex = Mutex.new
43
+ @pending = []
44
+ @finished = false
45
+ end
46
+
47
+ # @raise [ConnectTimeout]
48
+ def connect
49
+ @io = open_socket
50
+ @wake_reader, @wake_writer = IO.pipe # for abort!: on Linux, closing a socket doesn't interrupt a select on it
51
+ @driver = WebSocket::Driver.client(self, binary_data_format: :string)
52
+ @headers.each { |name, value| @driver.set_header(name, value) }
53
+ @driver.on(:open) { @pending << [:open] }
54
+ @driver.on(:message) { |event| @pending << [:message, event.data] }
55
+ @driver.on(:close) { |event| @pending << [:close, event.code, event.reason] }
56
+ @mutex.synchronize { @driver.start }
57
+ @reader = Thread.new { read_loop }
58
+ @reader.name = "omniai-google-live"
59
+ @reader.report_on_exception = false
60
+ rescue StandardError
61
+ [@io, @wake_reader, @wake_writer].compact.each { |io| close_quietly(io) } unless @reader
62
+ raise
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def open?
67
+ !@finished && !@aborted
68
+ end
69
+
70
+ # @param payload [String]
71
+ #
72
+ # @raise [WriteTimeout]
73
+ def send_text(payload)
74
+ @mutex.synchronize { @driver.text(payload) }
75
+ rescue WriteTimeout => e
76
+ abort!(e)
77
+ raise
78
+ end
79
+
80
+ # Sends a close frame and waits briefly for the peer's reply before dropping the socket. Called from a callback
81
+ # (the reader thread itself), it only sends the frame: the reader finishes when the reply arrives.
82
+ def close
83
+ return if @finished || @reader.nil?
84
+
85
+ begin
86
+ @mutex.synchronize { @driver.close }
87
+ rescue WriteTimeout, IOError, SystemCallError
88
+ nil
89
+ end
90
+ return if Thread.current == @reader
91
+
92
+ abort! unless @reader.join(CLOSE_TIMEOUT)
93
+ @reader.join(CLOSE_TIMEOUT)
94
+ end
95
+
96
+ # Called by websocket-driver (under the mutex) for every outgoing frame.
97
+ #
98
+ # @param data [String]
99
+ def write(data)
100
+ deadline = monotonic + @write_timeout
101
+ data = data.b
102
+ until data.empty?
103
+ result = @io.write_nonblock(data, exception: false)
104
+ case result
105
+ when :wait_writable then wait!(nil, [@io.to_io], deadline) { write_timeout }
106
+ when :wait_readable then wait!([@io.to_io], nil, deadline) { write_timeout }
107
+ else data = data.byteslice(result..)
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # @return [WriteTimeout]
115
+ def write_timeout
116
+ WriteTimeout.new("write timed out after #{@write_timeout}s")
117
+ end
118
+
119
+ # @return [Socket, OpenSSL::SSL::SSLSocket]
120
+ def open_socket
121
+ uri = URI.parse(@url)
122
+ deadline = monotonic + CONNECT_TIMEOUT
123
+ tcp = begin
124
+ Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT)
125
+ rescue Errno::ETIMEDOUT, IO::TimeoutError
126
+ raise ConnectTimeout, "connect timed out after #{CONNECT_TIMEOUT}s"
127
+ end
128
+ return tcp unless uri.scheme == "wss"
129
+
130
+ handshake(tcp, uri.host, deadline)
131
+ end
132
+
133
+ # @return [OpenSSL::SSL::SSLSocket]
134
+ def handshake(tcp, host, deadline)
135
+ context = OpenSSL::SSL::SSLContext.new
136
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
137
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
138
+ ssl.hostname = host
139
+ ssl.sync_close = true
140
+ timeout = -> { ConnectTimeout.new("connect timed out after #{CONNECT_TIMEOUT}s") }
141
+ loop do
142
+ case ssl.connect_nonblock(exception: false)
143
+ when :wait_readable then wait!([tcp], nil, deadline, &timeout)
144
+ when :wait_writable then wait!(nil, [tcp], deadline, &timeout)
145
+ else return ssl
146
+ end
147
+ end
148
+ rescue StandardError
149
+ tcp.close
150
+ raise
151
+ end
152
+
153
+ def read_loop
154
+ loop do
155
+ data = read_chunk
156
+ @ping_at = nil
157
+ events = @mutex.synchronize do
158
+ @driver.parse(data)
159
+ @pending.slice!(0..)
160
+ end
161
+ events.each { |event| dispatch(*event) }
162
+ break if @finished
163
+ end
164
+ rescue EOFError
165
+ finish(nil, nil, @abort_error)
166
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
167
+ finish(nil, nil, @abort_error || e)
168
+ rescue StandardError => e # a callback raised, or a ping went unanswered
169
+ finish(nil, nil, e)
170
+ ensure
171
+ finish(nil, nil, @abort_error)
172
+ end
173
+
174
+ # Waits for data, pinging when idle for PING_INTERVAL. Any frame (a pong included) counts as an answer.
175
+ #
176
+ # @raise [PongTimeout, EOFError]
177
+ #
178
+ # @return [String]
179
+ def read_chunk
180
+ loop do
181
+ result = @io.read_nonblock(READ_CHUNK, exception: false)
182
+ case result
183
+ when :wait_readable then idle([@io.to_io], nil)
184
+ when :wait_writable then idle(nil, [@io.to_io])
185
+ when nil then raise EOFError
186
+ else return result
187
+ end
188
+ end
189
+ end
190
+
191
+ def idle(readers, writers)
192
+ timeout = @ping_at ? @ping_at + PONG_TIMEOUT - monotonic : PING_INTERVAL
193
+ ready = IO.select([*readers, @wake_reader], writers, nil, timeout) if timeout.positive?
194
+ raise IOError, "aborted" if @aborted
195
+ return if ready
196
+
197
+ raise PongTimeout, "no pong within #{PONG_TIMEOUT}s" if @ping_at
198
+
199
+ @ping_at = monotonic
200
+ @mutex.synchronize { @driver.ping }
201
+ end
202
+
203
+ def dispatch(type, *args)
204
+ case type
205
+ when :open then @on_open.call
206
+ when :message then @on_message.call(args.first)
207
+ when :close then finish(args[0], args[1], nil)
208
+ end
209
+ end
210
+
211
+ # Drops the socket; the reader thread then finishes with `error`.
212
+ def abort!(error = nil)
213
+ @abort_error ||= error
214
+ @aborted = true
215
+ close_quietly(@io)
216
+ @wake_writer.write_nonblock(".", exception: false)
217
+ rescue IOError
218
+ nil # already finished
219
+ end
220
+
221
+ def finish(code, reason, error)
222
+ return if @finished
223
+
224
+ @finished = true
225
+ [@io, @wake_reader, @wake_writer].each { |io| close_quietly(io) }
226
+ begin
227
+ @on_close.call(code, reason, error)
228
+ rescue StandardError
229
+ nil
230
+ end
231
+ end
232
+
233
+ def close_quietly(io)
234
+ io.close
235
+ rescue IOError, SystemCallError
236
+ nil
237
+ end
238
+
239
+ # @yieldreturn [StandardError] raised once the deadline passes
240
+ def wait!(readers, writers, deadline)
241
+ remaining = deadline - monotonic
242
+ return if remaining.positive? && IO.select(readers, writers, nil, remaining)
243
+
244
+ raise yield
245
+ end
246
+
247
+ def monotonic
248
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Google
5
+ class Live
6
+ # Events yielded by a Live session, on its reader thread.
7
+ module Event
8
+ # Model speech: raw PCM16 (24 kHz, mono, little-endian) bytes.
9
+ Audio = Data.define(:data, :mime_type) do
10
+ def inspect = "#<#{self.class.name} bytes=#{data.bytesize} mime_type=#{mime_type}>"
11
+ end
12
+
13
+ # Model text (only when the session asks for text responses).
14
+ Text = Data.define(:text)
15
+
16
+ # A transcript fragment of the user's (`:user`) or the model's (`:model`) speech.
17
+ Transcript = Data.define(:role, :text, :finished)
18
+
19
+ # @!attribute calls [Array<FunctionCall>]
20
+ ToolCall = Data.define(:calls)
21
+ FunctionCall = Data.define(:id, :name, :arguments)
22
+ ToolCallCancellation = Data.define(:ids)
23
+
24
+ # The user spoke over the model; drop any audio still queued for playback.
25
+ Interrupted = Data.define
26
+ GenerationComplete = Data.define
27
+ TurnComplete = Data.define
28
+
29
+ # Speech start/end detected by the server; `offset` is seconds into the session's input audio.
30
+ VoiceActivity = Data.define(:type, :offset)
31
+
32
+ # The server will end the connection in `time_left` seconds.
33
+ GoAway = Data.define(:time_left)
34
+ ResumptionUpdate = Data.define(:handle, :resumable)
35
+
36
+ # @!attribute usage [OmniAI::Chat::Usage] (`output_tokens` includes thinking)
37
+ # @!attribute data [Hash] the raw usageMetadata, with per-modality token details
38
+ Usage = Data.define(:usage, :data)
39
+
40
+ # The session ended. `code` is nil when the connection dropped without a close frame; `error` is set when it
41
+ # ended because of an exception (socket error, write timeout, or a raising handler). Google's close `reason`
42
+ # can echo request content, so `inspect` leaves it out.
43
+ Closed = Data.define(:code, :reason, :error) do
44
+ def inspect = "#<#{self.class.name} code=#{code.inspect} error=#{error&.class.inspect}>"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Google
5
+ # A Gemini Live API session on Vertex (BidiGenerateContent over a WebSocket): stream microphone audio in, receive
6
+ # speech, transcripts and tool calls back.
7
+ #
8
+ # Usage:
9
+ #
10
+ # session = client.live(model: "gemini-3.8-live", system: "...", tools: [RecordAnswer.new]) do |event|
11
+ # case event
12
+ # when OmniAI::Google::Live::Event::Audio then play(event.data)
13
+ # when OmniAI::Google::Live::Event::Closed then reconnect
14
+ # end
15
+ # end
16
+ # session.send_audio(pcm16_16khz)
17
+ # session.close
18
+ #
19
+ # Each session holds one socket and one reader thread. Events are yielded, and tools run, on that thread; the
20
+ # `send_*` methods may be called from any thread. The gem never logs or raises audio, transcripts or tool arguments.
21
+ class Live
22
+ # @!attribute code [Integer, nil] the WebSocket close code
23
+ # @!attribute reason [String, nil] Google's close reason; kept out of the message because it can echo content
24
+ class Error < OmniAI::Error
25
+ attr_reader :code, :reason
26
+
27
+ def initialize(message, code: nil, reason: nil)
28
+ super(message)
29
+ @code = code
30
+ @reason = reason
31
+ end
32
+ end
33
+
34
+ class ClosedError < Error; end
35
+ class TimeoutError < Error; end
36
+
37
+ module Model
38
+ GEMINI_3_8_LIVE = "gemini-3.8-live"
39
+ end
40
+
41
+ DEFAULT_MODEL = Model::GEMINI_3_8_LIVE
42
+ INPUT_MIME_TYPE = "audio/pcm;rate=16000"
43
+ PATH = "/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent"
44
+ SETUP_TIMEOUT = 15 # seconds
45
+ WRITE_TIMEOUT = 5 # seconds
46
+ BEHAVIORS = { blocking: "BLOCKING", non_blocking: "NON_BLOCKING" }.freeze
47
+ ACTIVITY = { "ACTIVITY_START" => :start, "ACTIVITY_END" => :end }.freeze
48
+
49
+ # Opens a session and waits for the server's setupComplete.
50
+ #
51
+ # @raise [Error] if the server rejects the setup or it times out
52
+ #
53
+ # @return [Live]
54
+ def self.open(...)
55
+ new(...).tap(&:open)
56
+ end
57
+
58
+ # @return [String, nil] the server's session id, once open
59
+ attr_reader :session_id
60
+
61
+ # @param client [OmniAI::Google::Client] must use Vertex credentials, a project and a location (e.g. "us")
62
+ # @param model [String]
63
+ # @param system [String, nil] the system instruction
64
+ # @param tools [Array<OmniAI::Tool>, nil] run automatically; each result is sent back as a tool response
65
+ # @param tool_behavior [Symbol, Hash] `:blocking` (default) or `:non_blocking`, or a Hash of tool name to either
66
+ # @param voice [String, nil] a prebuilt voice name, e.g. "Kore"
67
+ # @param transcription [Boolean] transcribe both the user's and the model's speech (default true)
68
+ # @param vad [Hash, nil] automatic activity detection: `disabled:`, `start_sensitivity:` / `end_sensitivity:`
69
+ # (`:high` or `:low`), `prefix_padding_ms:`, `silence_ms:`
70
+ # @param compression [Boolean, Hash] sliding-window context compression, which lifts the 15-minute session cap and
71
+ # bounds the context re-billed every turn. true uses Google's defaults (trigger at 80% of the context window);
72
+ # or pass `trigger_tokens:` / `target_tokens:`
73
+ # @param resumption [Boolean, String, nil] true to receive resumption handles, or a handle to resume
74
+ # @param write_timeout [Numeric] seconds a send may block before the session is closed
75
+ #
76
+ # @yieldparam event [Live::Event::*]
77
+ def initialize(client:, model: DEFAULT_MODEL, system: nil, tools: nil, tool_behavior: :blocking, voice: nil,
78
+ transcription: true, vad: nil, compression: false, resumption: nil, write_timeout: WRITE_TIMEOUT, &handler)
79
+ @client = client
80
+ @model = model
81
+ @system = system
82
+ @tools = tools || []
83
+ @tool_behavior = tool_behavior
84
+ @voice = voice
85
+ @transcription = transcription
86
+ @vad = vad
87
+ @compression = compression
88
+ @resumption = resumption
89
+ @write_timeout = write_timeout
90
+ @handler = handler
91
+ @state = :new
92
+ end
93
+
94
+ # @raise [Error]
95
+ #
96
+ # @return [Live]
97
+ def open
98
+ raise ArgumentError, "#{self.class.name} requires Vertex credentials" unless @client.credentials?
99
+
100
+ @ready = Queue.new
101
+ @connection = Connection.new(
102
+ url:,
103
+ headers: { "Authorization" => @client.send(:auth) }, # a fresh token per connection
104
+ write_timeout: @write_timeout,
105
+ on_open: method(:on_open),
106
+ on_message: method(:on_message),
107
+ on_close: method(:on_close)
108
+ )
109
+ log("LIVE connect #{host} #{@model}")
110
+ begin
111
+ @connection.connect
112
+ rescue Connection::ConnectTimeout => e
113
+ raise TimeoutError, "#{self.class.name} #{e.message} (model=#{@model})"
114
+ end
115
+ await_setup!
116
+ self
117
+ end
118
+
119
+ # @return [Boolean]
120
+ def open?
121
+ @state == :open && @connection.open?
122
+ end
123
+
124
+ # @param audio [String] raw PCM16 audio, 16 kHz, mono, little-endian
125
+ def send_audio(audio)
126
+ deliver(realtimeInput: { audio: { mimeType: INPUT_MIME_TYPE, data: Base64.strict_encode64(audio) } })
127
+ end
128
+
129
+ # Tells the server the microphone paused, so it flushes any buffered audio.
130
+ def end_audio
131
+ deliver(realtimeInput: { audioStreamEnd: true })
132
+ end
133
+
134
+ # Sends a complete user turn of text, e.g. to re-seed context after reconnecting.
135
+ #
136
+ # @param text [String]
137
+ def send_text(text)
138
+ deliver(clientContent: { turns: [{ role: "user", parts: [{ text: }] }], turnComplete: true })
139
+ end
140
+
141
+ def close
142
+ @connection&.close
143
+ end
144
+
145
+ private
146
+
147
+ # @param message [Hash]
148
+ #
149
+ # @raise [ClosedError, TimeoutError]
150
+ def deliver(message)
151
+ raise ClosedError, "#{self.class.name} session is closed (session=#{@session_id})" unless open?
152
+
153
+ @connection.send_text(JSON.generate(message))
154
+ rescue Connection::WriteTimeout
155
+ raise TimeoutError, "#{self.class.name} write timed out after #{@write_timeout}s (session=#{@session_id})"
156
+ end
157
+
158
+ def await_setup!
159
+ result = @ready.pop(timeout: SETUP_TIMEOUT)
160
+ return if result == :ready
161
+
162
+ @connection.close
163
+ raise Error, "#{self.class.name} setup timed out (model=#{@model})" if result.nil?
164
+
165
+ code, reason, error = result
166
+ raise Error.new("#{self.class.name} setup failed (model=#{@model} code=#{code.inspect} " \
167
+ "error=#{error&.class.inspect})", code:, reason:)
168
+ end
169
+
170
+ def on_open
171
+ @connection.send_text(JSON.generate(setup))
172
+ end
173
+
174
+ # @param data [String]
175
+ def on_message(data)
176
+ message, events = decode(data)
177
+ if (complete = message["setupComplete"])
178
+ @session_id = complete["sessionId"]
179
+ @state = :open
180
+ @ready << :ready
181
+ return
182
+ end
183
+
184
+ events.each do |event|
185
+ @handler&.call(event)
186
+ respond(event) if event.is_a?(Event::ToolCall)
187
+ end
188
+ end
189
+
190
+ def on_close(code, reason, error)
191
+ state = @state
192
+ @state = :closed
193
+ log("LIVE close #{code || '-'} #{@session_id || '-'}")
194
+ if state == :open
195
+ @handler&.call(Event::Closed.new(code:, reason:, error:))
196
+ else
197
+ @ready << [code, reason, error]
198
+ end
199
+ end
200
+
201
+ # Runs each called tool and sends the results. A raising tool answers with its error class only (never its
202
+ # message, which may carry arguments), and the session carries on.
203
+ #
204
+ # @param event [Event::ToolCall]
205
+ def respond(event)
206
+ responses = event.calls.map do |call|
207
+ tool = @tools.find { |candidate| candidate.name == call.name }
208
+ response =
209
+ if tool.nil?
210
+ { error: "unknown tool: #{call.name}" }
211
+ else
212
+ begin
213
+ { output: tool.call(call.arguments) }
214
+ rescue StandardError => e
215
+ { error: e.class.name }
216
+ end
217
+ end
218
+ { id: call.id, name: call.name, response: }
219
+ end
220
+ deliver(toolResponse: { functionResponses: responses })
221
+ end
222
+
223
+ # A frame that fails to parse or build closes the session with an error naming only the exception class: the
224
+ # original's message (and so its cause) can quote the frame, which may hold transcripts.
225
+ #
226
+ # @param data [String]
227
+ #
228
+ # @raise [Error]
229
+ #
230
+ # @return [Array(Hash, Array)] the message and its events
231
+ def decode(data)
232
+ message = JSON.parse(data)
233
+ [message, message["setupComplete"] ? [] : events(message)]
234
+ rescue StandardError => e
235
+ raise Error.new("#{self.class.name} could not decode a server message: #{e.class.name} " \
236
+ "(session=#{@session_id || '-'})"), cause: nil
237
+ end
238
+
239
+ # Server message keys and their event builders, in the order events are yielded (usage after content).
240
+ MESSAGE_EVENTS = {
241
+ "serverContent" => :server_content_events,
242
+ "toolCall" => :tool_call_events,
243
+ "toolCallCancellation" => :cancellation_events,
244
+ "voiceActivity" => :voice_activity_events,
245
+ "sessionResumptionUpdate" => :resumption_events,
246
+ "goAway" => :go_away_events,
247
+ "usageMetadata" => :usage_events,
248
+ }.freeze
249
+ private_constant :MESSAGE_EVENTS
250
+
251
+ # @param message [Hash]
252
+ # @return [Array]
253
+ def events(message)
254
+ MESSAGE_EVENTS.flat_map { |key, builder| message[key] ? send(builder, message[key]) : [] }
255
+ end
256
+
257
+ def server_content_events(content)
258
+ [
259
+ *transcript_events(:user, content["inputTranscription"]),
260
+ *model_turn_events(content["modelTurn"]),
261
+ *transcript_events(:model, content["outputTranscription"]),
262
+ (Event::Interrupted.new if content["interrupted"]),
263
+ (Event::GenerationComplete.new if content["generationComplete"]),
264
+ (Event::TurnComplete.new if content["turnComplete"]),
265
+ ].compact
266
+ end
267
+
268
+ def transcript_events(role, transcription)
269
+ return [] unless transcription
270
+
271
+ [Event::Transcript.new(role:, text: transcription["text"], finished: transcription["finished"] == true)]
272
+ end
273
+
274
+ def model_turn_events(turn)
275
+ (turn&.dig("parts") || []).filter_map do |part|
276
+ if (inline = part["inlineData"])
277
+ Event::Audio.new(data: Base64.decode64(inline["data"]), mime_type: inline["mimeType"])
278
+ elsif part["text"] && !part["thought"]
279
+ Event::Text.new(text: part["text"])
280
+ end
281
+ end
282
+ end
283
+
284
+ def tool_call_events(tool_call)
285
+ calls = (tool_call["functionCalls"] || []).map do |call|
286
+ Event::FunctionCall.new(id: call["id"], name: call["name"], arguments: call["args"] || {})
287
+ end
288
+ [Event::ToolCall.new(calls:)]
289
+ end
290
+
291
+ def cancellation_events(cancellation)
292
+ [Event::ToolCallCancellation.new(ids: cancellation["ids"] || [])]
293
+ end
294
+
295
+ def voice_activity_events(activity)
296
+ type = ACTIVITY.fetch(activity["type"], activity["type"])
297
+ [Event::VoiceActivity.new(type:, offset: seconds(activity["audioOffset"]))]
298
+ end
299
+
300
+ def resumption_events(update)
301
+ [Event::ResumptionUpdate.new(handle: update["newHandle"], resumable: update["resumable"] == true)]
302
+ end
303
+
304
+ def go_away_events(go_away)
305
+ [Event::GoAway.new(time_left: seconds(go_away["timeLeft"]))]
306
+ end
307
+
308
+ def usage_events(usage)
309
+ [Event::Usage.new(usage: Chat::UsageSerializer.deserialize(usage), data: usage)]
310
+ end
311
+
312
+ # @return [Hash]
313
+ def setup
314
+ {
315
+ setup: {
316
+ model: "projects/#{project_id}/locations/#{location_id}/publishers/google/models/#{@model}",
317
+ generationConfig: { responseModalities: ["AUDIO"], speechConfig: speech_config }.compact,
318
+ systemInstruction: ({ parts: [{ text: @system }] } if @system),
319
+ tools: ([{ functionDeclarations: @tools.map { |tool| declaration(tool) } }] if @tools.any?),
320
+ inputAudioTranscription: ({} if @transcription),
321
+ outputAudioTranscription: ({} if @transcription),
322
+ realtimeInputConfig: realtime_input_config,
323
+ contextWindowCompression: context_window_compression,
324
+ sessionResumption: session_resumption,
325
+ }.compact,
326
+ }
327
+ end
328
+
329
+ # @param tool [OmniAI::Tool]
330
+ # @return [Hash]
331
+ def declaration(tool)
332
+ behavior = @tool_behavior.is_a?(Hash) ? @tool_behavior.fetch(tool.name, :blocking) : @tool_behavior
333
+ Chat::ToolSerializer.serialize(tool).merge(behavior: BEHAVIORS.fetch(behavior.to_sym))
334
+ end
335
+
336
+ # @return [Hash, nil]
337
+ def speech_config
338
+ { voiceConfig: { prebuiltVoiceConfig: { voiceName: @voice } } } if @voice
339
+ end
340
+
341
+ # @return [Hash, nil]
342
+ def realtime_input_config
343
+ return unless @vad
344
+
345
+ detection = {
346
+ disabled: @vad[:disabled],
347
+ startOfSpeechSensitivity: sensitivity("START", @vad[:start_sensitivity]),
348
+ endOfSpeechSensitivity: sensitivity("END", @vad[:end_sensitivity]),
349
+ prefixPaddingMs: @vad[:prefix_padding_ms],
350
+ silenceDurationMs: @vad[:silence_ms],
351
+ }.compact
352
+ { automaticActivityDetection: detection }
353
+ end
354
+
355
+ # @return [String, nil] e.g. "START_SENSITIVITY_HIGH"
356
+ def sensitivity(edge, level)
357
+ "#{edge}_SENSITIVITY_#{level.to_s.upcase}" if level
358
+ end
359
+
360
+ # @return [Hash, nil]
361
+ def context_window_compression
362
+ return unless @compression
363
+
364
+ options = @compression.is_a?(Hash) ? @compression : {}
365
+ {
366
+ triggerTokens: options[:trigger_tokens],
367
+ slidingWindow: { targetTokens: options[:target_tokens] }.compact,
368
+ }.compact
369
+ end
370
+
371
+ # @return [Hash, nil]
372
+ def session_resumption
373
+ case @resumption
374
+ when true then {}
375
+ when String then { handle: @resumption }
376
+ end
377
+ end
378
+
379
+ # @param value [String, nil] e.g. "0.200s"
380
+ # @return [Float, nil]
381
+ def seconds(value)
382
+ value&.delete_suffix("s")&.to_f
383
+ end
384
+
385
+ # @return [String] e.g. "wss://aiplatform.us.rep.googleapis.com/ws/..."
386
+ def url
387
+ "#{host.sub(/\Ahttp/, 'ws')}#{PATH}"
388
+ end
389
+
390
+ # @return [String]
391
+ def host
392
+ @client.instance_variable_get(:@host)
393
+ end
394
+
395
+ # @return [String]
396
+ def project_id
397
+ @client.instance_variable_get(:@project_id) || raise(ArgumentError, "project_id is required for Live")
398
+ end
399
+
400
+ # @return [String]
401
+ def location_id
402
+ @client.instance_variable_get(:@location_id) || raise(ArgumentError, "location_id is required for Live")
403
+ end
404
+
405
+ # @param message [String] ids, codes and classes only
406
+ def log(message)
407
+ @client.logger&.info(message)
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Google
5
+ class Transcribe
6
+ # Transcribes with a Gemini transcribe model (e.g. `gemini-3.5-transcribe`). Unlike Chirp (Cloud Speech-to-Text),
7
+ # these are served by Vertex `generateContent` with the audio as the only part.
8
+ #
9
+ # Usage:
10
+ #
11
+ # client.transcribe("interview.wav", model: "gemini-3.5-transcribe", diarization: true)
12
+ class Gemini
13
+ include TranscribeHelpers
14
+
15
+ INLINE_LIMIT = 10_000_000 # bytes; larger audio goes through GCS
16
+
17
+ # Leading bytes of the audio containers Gemini accepts. `generateContent` requires a mimeType.
18
+ SIGNATURES = {
19
+ "RIFF" => "audio/wav",
20
+ "ID3" => "audio/mpeg",
21
+ "\xFF\xFB".b => "audio/mpeg",
22
+ "\xFF\xF3".b => "audio/mpeg",
23
+ "\xFF\xF2".b => "audio/mpeg",
24
+ "fLaC" => "audio/flac",
25
+ "OggS" => "audio/ogg",
26
+ "\x1A\x45\xDF\xA3".b => "audio/webm",
27
+ "FORM" => "audio/aiff",
28
+ }.freeze
29
+
30
+ EXTENSIONS = {
31
+ ".wav" => "audio/wav", ".mp3" => "audio/mpeg", ".flac" => "audio/flac", ".ogg" => "audio/ogg",
32
+ ".opus" => "audio/ogg", ".webm" => "audio/webm", ".m4a" => "audio/mp4", ".mp4" => "audio/mp4",
33
+ ".aac" => "audio/aac", ".aif" => "audio/aiff", ".aiff" => "audio/aiff",
34
+ }.freeze
35
+
36
+ # @param model [String]
37
+ # @return [Boolean]
38
+ def self.model?(model)
39
+ String(model).start_with?("gemini-")
40
+ end
41
+
42
+ def self.process!(...)
43
+ new(...).process!
44
+ end
45
+
46
+ # @param io [String, Pathname, IO] a file path, IO or `gs://` URI
47
+ # @param client [OmniAI::Google::Client] must use Vertex credentials
48
+ # @param model [String]
49
+ # @param language [String, Array<String>, nil] optional - auto-detected when omitted
50
+ # @param format [String, nil] optional - `verbose_json` adds per-utterance segments
51
+ # @param diarization [Boolean] optional - speaker labels per segment (experimental)
52
+ # @param word_timestamps [Boolean] optional - word times (experimental; can drop speech, see README)
53
+ # @param vocabulary [Array<String>, nil] optional - custom vocabulary (up to 1,000 terms)
54
+ def initialize(io, client:, model:, language: nil, format: nil, diarization: false, word_timestamps: false,
55
+ vocabulary: nil)
56
+ @io = io
57
+ @client = client
58
+ @model = model
59
+ @language = language
60
+ @format = format || OmniAI::Transcribe::Format::JSON
61
+ @diarization = diarization
62
+ @word_timestamps = word_timestamps
63
+ @vocabulary = vocabulary
64
+ end
65
+
66
+ # @raise [OmniAI::HTTPError, OmniAI::Error]
67
+ #
68
+ # @return [OmniAI::Transcribe::Transcription]
69
+ def process!
70
+ raise ArgumentError, "#{@model} requires Vertex credentials" unless @client.credentials?
71
+
72
+ response = request!
73
+ raise HTTPError, response unless response.status.ok?
74
+
75
+ OmniAI::Transcribe::Transcription.parse(model: @model, format: @format, data: parse(response.parse))
76
+ ensure
77
+ cleanup_gcs_file(@uploaded_gcs_uri) if @uploaded_gcs_uri
78
+ @uploaded_gcs_uri = nil
79
+ end
80
+
81
+ private
82
+
83
+ # @return [HTTP::Response]
84
+ def request!
85
+ HTTP.persistent(host)
86
+ .timeout(http_timeout_options)
87
+ .accept(:json)
88
+ .auth(@client.send(:auth))
89
+ .post(path, json: payload)
90
+ end
91
+
92
+ # Served from the global endpoint and the us/eu multi-regions, never a single region, so a zonal Vertex
93
+ # location_id maps to its multi-region (same rule as Chirp 3); `us` is the default.
94
+ #
95
+ # @return [String] "global", "us" or "eu"
96
+ def gemini_location_id
97
+ case @client.instance_variable_get(:@location_id)
98
+ when "global" then "global"
99
+ when /\A(eu|europe)/i then "eu"
100
+ else "us"
101
+ end
102
+ end
103
+
104
+ # @return [String]
105
+ def host
106
+ location = gemini_location_id
107
+ location == "global" ? "https://aiplatform.googleapis.com" : "https://aiplatform.#{location}.rep.googleapis.com"
108
+ end
109
+
110
+ # @return [String]
111
+ def path
112
+ "/v1/projects/#{project_id}/locations/#{gemini_location_id}/publishers/google/models/" \
113
+ "#{@model}:generateContent"
114
+ end
115
+
116
+ # @return [Hash]
117
+ def payload
118
+ config = {
119
+ languageCodes: language_codes,
120
+ diarization: (true if @diarization),
121
+ wordTimestamp: (true if @word_timestamps),
122
+ customVocabulary: @vocabulary,
123
+ }.compact
124
+
125
+ payload = { contents: [{ role: "user", parts: [audio_part] }] }
126
+ payload[:generationConfig] = { audioTranscriptionConfig: config } if config.any?
127
+ payload
128
+ end
129
+
130
+ # @return [Hash]
131
+ def audio_part
132
+ return file_data(@io, EXTENSIONS.fetch(File.extname(@io).downcase, "audio/wav")) if gcs_uri?
133
+
134
+ bytes = audio_bytes
135
+ mime_type = sniff(bytes)
136
+ return { inlineData: { mimeType: mime_type, data: Base64.strict_encode64(bytes) } } if inline?(bytes)
137
+
138
+ @uploaded_gcs_uri = Bucket.process!(client: @client, io: StringIO.new(bytes))
139
+ file_data(@uploaded_gcs_uri, mime_type)
140
+ end
141
+
142
+ # @param bytes [String]
143
+ # @return [Boolean]
144
+ def inline?(bytes)
145
+ bytes.bytesize <= INLINE_LIMIT
146
+ end
147
+
148
+ # @return [Hash]
149
+ def file_data(uri, mime_type)
150
+ { fileData: { fileUri: uri, mimeType: mime_type } }
151
+ end
152
+
153
+ # @return [Boolean]
154
+ def gcs_uri?
155
+ @io.is_a?(String) && @io.start_with?("gs://")
156
+ end
157
+
158
+ # @return [String]
159
+ def audio_bytes
160
+ case @io
161
+ when String, Pathname then File.binread(@io)
162
+ when IO, StringIO
163
+ @io.rewind if @io.respond_to?(:rewind)
164
+ @io.read.b
165
+ else raise ArgumentError, "unsupported input type: #{@io.class}"
166
+ end
167
+ end
168
+
169
+ # @param bytes [String]
170
+ # @return [String]
171
+ def sniff(bytes)
172
+ return "audio/mp4" if bytes.byteslice(4, 4) == "ftyp"
173
+
174
+ SIGNATURES.each { |signature, mime_type| return mime_type if bytes.start_with?(signature.b) }
175
+ raise ArgumentError, "unrecognised audio format (expected wav, mp3, flac, ogg, webm, m4a or aiff)"
176
+ end
177
+
178
+ # @param data [Hash]
179
+ # @return [Hash]
180
+ def parse(data)
181
+ candidate = data.dig("candidates", 0) || {}
182
+ parts = candidate.dig("content", "parts")
183
+ if parts.nil? || parts.empty?
184
+ raise OmniAI::Error, "#{@model} returned no content (finish reason #{candidate['finishReason']})"
185
+ end
186
+
187
+ segments = parts.map.with_index { |part, index| segment(part, index) }
188
+ result = { "text" => segments.map { |segment| segment["text"] }.join(" ") }
189
+ result["segments"] = segments if @format == OmniAI::Transcribe::Format::VERBOSE_JSON
190
+ result
191
+ end
192
+
193
+ # @param part [Hash]
194
+ # @param index [Integer]
195
+ # @return [Hash]
196
+ def segment(part, index)
197
+ transcription = part["audioTranscription"] || {}
198
+ words = transcription["words"]&.map do |word|
199
+ { "word" => word["word"], "start" => parse_duration(word["startOffset"]),
200
+ "end" => parse_duration(word["endOffset"]), }
201
+ end
202
+
203
+ {
204
+ "id" => index,
205
+ "text" => transcription["text"] || part["text"],
206
+ "speaker" => transcription["speakerLabel"],
207
+ "start" => words&.first&.fetch("start"),
208
+ "end" => words&.last&.fetch("end"),
209
+ "words" => words,
210
+ }.compact
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -12,6 +12,7 @@ module OmniAI
12
12
  include TranscribeHelpers
13
13
 
14
14
  module Model
15
+ GEMINI_3_5_TRANSCRIBE = "gemini-3.5-transcribe"
15
16
  CHIRP_3 = "chirp_3"
16
17
  CHIRP_2 = "chirp_2"
17
18
  CHIRP = "chirp"
@@ -32,6 +33,22 @@ module OmniAI
32
33
  # No custom deserializers needed - let base class handle parsing
33
34
  end
34
35
 
36
+ GEMINI_OPTIONS = %i[diarization word_timestamps vocabulary].freeze
37
+
38
+ # Gemini transcribe models (`gemini-*`) take a separate path; every Chirp / Speech-to-Text model is unchanged.
39
+ #
40
+ # @return [OmniAI::Transcribe::Transcription]
41
+ def self.process!(io, model:, **options)
42
+ return Gemini.process!(io, model:, **options) if Gemini.model?(model)
43
+
44
+ unsupported = options.keys & GEMINI_OPTIONS
45
+ if unsupported.any?
46
+ raise ArgumentError, "#{unsupported.join(', ')} only supported by Gemini transcribe models, not #{model}"
47
+ end
48
+
49
+ super
50
+ end
51
+
35
52
  # @raise [HTTPError]
36
53
  #
37
54
  # @return [OmniAI::Transcribe::Transcription]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAI
4
4
  module Google
5
- VERSION = "3.19.0"
5
+ VERSION = "3.21.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniai-google
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.19.0
4
+ version: 3.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: websocket-driver
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: zeitwerk
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -123,8 +137,12 @@ files:
123
137
  - lib/omniai/google/credentials.rb
124
138
  - lib/omniai/google/embed.rb
125
139
  - lib/omniai/google/incomplete_stream_error.rb
140
+ - lib/omniai/google/live.rb
141
+ - lib/omniai/google/live/connection.rb
142
+ - lib/omniai/google/live/event.rb
126
143
  - lib/omniai/google/stream_error.rb
127
144
  - lib/omniai/google/transcribe.rb
145
+ - lib/omniai/google/transcribe/gemini.rb
128
146
  - lib/omniai/google/transcribe_helpers.rb
129
147
  - lib/omniai/google/upload.rb
130
148
  - lib/omniai/google/upload/file.rb