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 +4 -4
- data/README.md +83 -0
- data/lib/omniai/google/client.rb +14 -2
- data/lib/omniai/google/embed.rb +1 -1
- data/lib/omniai/google/live/connection.rb +253 -0
- data/lib/omniai/google/live/event.rb +49 -0
- data/lib/omniai/google/live.rb +411 -0
- data/lib/omniai/google/transcribe/gemini.rb +215 -0
- data/lib/omniai/google/transcribe.rb +17 -0
- data/lib/omniai/google/version.rb +1 -1
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce2434a17917341984b99049670ef65083443c2c04b8937d2b8d4af688a5b631
|
|
4
|
+
data.tar.gz: 6c22fa11d66efc7df6ca393bc7f167c5f682ac0398a934a6b25a8674920a6905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/omniai/google/client.rb
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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]
|
data/lib/omniai/google/embed.rb
CHANGED
|
@@ -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]
|
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.
|
|
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
|