studio-engine 0.120.0 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The words on the engine's voice capture layer: the microphone chip and its
4
+ # help dialog (studio/voice/_microphone_chip, _microphone_help), and the record
5
+ # button (studio/voice/_recorder). Ported from Voicebox's MicrophoneHelper and
6
+ # RecordingsHelper; the contract is docs/VOICE_CAPTURE.md.
7
+ #
8
+ # The chip names the microphone the page records from, so a switched-off
9
+ # headset Chrome picked on its own is plain to see, and lets the person pick
10
+ # another. A host passes `copy:` to either partial to change any line; its
11
+ # keys merge over these, one level deep per section.
12
+ module Studio
13
+ module VoiceCapture
14
+ # Where the chip remembers the pick (localStorage), unless a partial is given
15
+ # storage_key:. Mirrors STORAGE_KEY in app/javascript/studio/microphone.js.
16
+ STORAGE_KEY = "studio.microphone"
17
+
18
+ MICROPHONE_COPY = {
19
+ # Before the person allows the microphone the browser gives no names.
20
+ "prompt" => { "chip" => "Microphone: tap Record to allow",
21
+ "note" => "Tap Record once and allow the microphone. Then you can pick one here." },
22
+ # The microphone icon says what the name is, so the name gets the whole
23
+ # width at 390px; a screen reader hears "aria" in full.
24
+ "ready" => { "chip" => "%{microphone}", "aria" => "Microphone: %{microphone}. Tap to pick another.",
25
+ "note" => "Pick the microphone to record with. Your next take uses it." },
26
+ "denied" => { "chip" => "Microphone not allowed",
27
+ "note" => "This site isn't allowed to use the microphone. Tap the icon beside the web address, " \
28
+ "set Microphone to Allow, then reload this page." },
29
+ "unsupported" => { "chip" => "No microphone here",
30
+ "note" => "This browser can't list microphones. Try Safari or Chrome." },
31
+ "fell_back" => "That microphone isn't connected, so I'm using %{microphone}.",
32
+ "empty" => "No microphones found. Plug one in and it shows up here.",
33
+ "in_use" => "In use",
34
+ # Shown only when the chip's controller failed to load.
35
+ "unavailable" => "Picking a microphone didn't load. Reload to try again.",
36
+ # The help dialog. Switching happens on the page, by device id, so the
37
+ # dialog never sends anyone to Chrome to switch: Chrome's settings are
38
+ # where the microphone is ALLOWED, and where a person checks it when a
39
+ # take stays silent.
40
+ "help" => {
41
+ "link" => "Help with microphones",
42
+ "title_chrome" => "How to change your microphone in Chrome",
43
+ "title_other" => "How to change your microphone",
44
+ "steps_chrome" => [
45
+ "Click the icon left of the address.",
46
+ "Turn Microphone on.",
47
+ "Make sure your microphone is allowed.",
48
+ "Reload the page."
49
+ ],
50
+ "other" => "Allow the microphone when your browser asks, or in its settings for this site, " \
51
+ "then pick your microphone here and reload.",
52
+ "unnamed" => "the microphone",
53
+ "switched" => "Switched to %{microphone} ✓",
54
+ "switched_tip_chrome" => "If Chrome asks or it stays silent, here's how to check Chrome's microphone.",
55
+ "switched_tip_other" => "If your browser asks or it stays silent, here's how to check its microphone.",
56
+ "silent" => "I couldn't hear anything from %{microphone}.",
57
+ "silent_tip" => "Pick another microphone in the list, or check that the browser may use it:",
58
+ "denied" => "This site isn't allowed to use the microphone yet.",
59
+ "denied_tip" => "Allow it, then reload:",
60
+ "close" => "Close"
61
+ }
62
+ }.freeze
63
+
64
+ # The record button's words per state. Mirrors COPY in
65
+ # app/javascript/studio/voice_recorder.js, which fills any state a page
66
+ # leaves out; the partial renders the idle words so the button reads right
67
+ # before the controller connects.
68
+ RECORDER_COPY = {
69
+ "idle" => { "button" => "Record", "status" => "" },
70
+ "recording" => { "button" => "Stop", "status" => "Listening..." },
71
+ "unavailable" => "Recording didn't load on this page. Reload to try again."
72
+ }.freeze
73
+
74
+ module_function
75
+
76
+ # The copy with a host's overrides merged in: a Hash section merges key by
77
+ # key, anything else replaces.
78
+ def microphone_copy(overrides = nil)
79
+ merge(MICROPHONE_COPY, overrides)
80
+ end
81
+
82
+ def recorder_copy(overrides = nil)
83
+ merge(RECORDER_COPY, overrides)
84
+ end
85
+
86
+ def merge(base, overrides)
87
+ return base if overrides.nil? || overrides.empty?
88
+
89
+ overrides.to_h.each_with_object(base.dup) do |(key, value), merged|
90
+ key = key.to_s
91
+ merged[key] = merged[key].is_a?(Hash) && value.is_a?(Hash) ? merged[key].merge(value.transform_keys(&:to_s)) : value
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "stringio"
8
+ require "uri"
9
+
10
+ # Understand: one finished take (the recorder's blob and its mime type, plus the
11
+ # speaker embedding the browser made of it) becomes {text, speaker, score}.
12
+ #
13
+ # The server half of the voice layer (docs/VOICE_TRANSCRIPTION.md). The capture
14
+ # half (docs/VOICE_CAPTURE.md) hands the page a take; the page posts it here,
15
+ # through Studio::VoiceTakesController (POST /voice/takes) or by calling
16
+ # Studio::VoiceTranscription.hear itself.
17
+ #
18
+ # heard = Studio::VoiceTranscription.hear(audio: io, mime_type: "audio/webm",
19
+ # user: current_user,
20
+ # embedding: vector, model: model_id)
21
+ # heard.to_h # => {"text"=>"...", "speaker"=>"Pat Studio", "score"=>0.88,
22
+ # # "identified"=>true, "check"=>"matched"}
23
+ #
24
+ # MOCK-FIRST, as Voicebox runs it (config/initializers/voicebox.rb, Adapters):
25
+ # the transcriber is chosen by name, production defaults to the real one and
26
+ # everywhere else to the mock, and a real adapter raises LiveCallAttempted
27
+ # before it opens a socket while VOICE_NO_LIVE_CALLS is armed (always, in a test
28
+ # environment). Ported from Voicebox's Transcription::OpenAI and
29
+ # Transcription::Mock.
30
+ #
31
+ # WHO SPOKE is Studio::VoiceMatch against the SIGNED-IN USER'S OWN saved voice
32
+ # (users.voice_embedding), compared only when both vectors came from the same
33
+ # embedder revision. Named people without an account are a later card.
34
+ #
35
+ # NOTHING IS KEPT. The audio is read once, sent to the transcriber and dropped;
36
+ # no vector or audio is logged (the engine's parameter filter masks
37
+ # voice[embedding] and voice[audio]). Pure Ruby apart from the optional Rails
38
+ # logger and environment, so the unit suite runs it without an app.
39
+ module Studio
40
+ module VoiceTranscription
41
+ # A transcriber call that failed: the message carries the vendor's words.
42
+ class Error < StandardError; end
43
+
44
+ # The vendor answered a non-2xx; the status rides along so a caller can tell
45
+ # an auth failure from a model that fell over.
46
+ class HttpError < Error
47
+ attr_reader :status
48
+
49
+ def initialize(message, status:)
50
+ super(message)
51
+ @status = status
52
+ end
53
+ end
54
+
55
+ # A real transcriber chosen without its credential, or a name that is no
56
+ # adapter. The page shows NOT_CONFIGURED_MESSAGE; the cause is in the log.
57
+ class NotConfigured < Error; end
58
+
59
+ # DELIBERATELY OUTSIDE StandardError (Voicebox's Adapters::LiveCallAttempted):
60
+ # every caller turns a StandardError into a friendly failure, so a trap that
61
+ # was one would be swallowed by the code it guards and a test that reached
62
+ # a real vendor would pass quietly.
63
+ class LiveCallAttempted < Exception; end # rubocop:disable Lint/InheritException
64
+
65
+ NO_LIVE_CALLS_ENV = "VOICE_NO_LIVE_CALLS"
66
+ TRANSCRIBER_ENV = "STUDIO_VOICE_TRANSCRIBER"
67
+ API_KEY_ENV = "OPENAI_API_KEY"
68
+
69
+ # What a page shows when the app has no transcriber key. Says what is wrong
70
+ # without naming the variable to a visitor.
71
+ NOT_CONFIGURED_MESSAGE = "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now."
72
+
73
+ # The bounds on one take. The recorder stops itself at 60 seconds by default
74
+ # (studio/voice_recorder max_ms); a minute of opus is about half a megabyte,
75
+ # so 10 MB is far above any honest take and well under the vendor's 25 MB.
76
+ MAX_BYTES = 10 * 1024 * 1024
77
+ MAX_DURATION_MS = 120_000
78
+
79
+ # The audio types a take may be, as MediaRecorder names them (without the
80
+ # ";codecs=" parameter), and the extension the vendor reads the format from.
81
+ EXTENSIONS = {
82
+ "audio/webm" => "webm",
83
+ "video/webm" => "webm",
84
+ "audio/ogg" => "ogg",
85
+ "audio/mp4" => "mp4",
86
+ "audio/m4a" => "m4a",
87
+ "audio/x-m4a" => "m4a",
88
+ "audio/mpeg" => "mp3",
89
+ "audio/wav" => "wav",
90
+ "audio/x-wav" => "wav"
91
+ }.freeze
92
+
93
+ # The label a take gets when it is not the signed-in user's saved voice. The
94
+ # agent is told, and the message still sends (epic default).
95
+ SOMEONE_NEW = "Someone new"
96
+
97
+ # Why the speaker is (or is not) named, in Heard#check:
98
+ # matched the user's own voice, at or above VoiceMatch::THRESHOLD
99
+ # below_threshold compared, and the closest score fell short
100
+ # no_profile the user has no saved voice to compare with
101
+ # embedder_mismatch the take and the saved voice came from different
102
+ # embedders or revisions, so they were not compared
103
+ # no_embedding the take carried no usable speaker embedding
104
+ CHECKS = %w[matched below_threshold no_profile embedder_mismatch no_embedding].freeze
105
+
106
+ # Languages written in the Latin alphabet (Voicebox's RecordingPipeline).
107
+ LATIN_LANGUAGES = %w[en es fr de it pt nl].freeze
108
+ # What speech models write for silence, whole, lowercased, unpunctuated.
109
+ SILENCE_PHRASES = [
110
+ "thank you", "thanks", "thank you for watching", "thanks for watching",
111
+ "thank you for listening", "thanks for listening", "bye", "bye bye", "you"
112
+ ].freeze
113
+ FILLERS = %w[um umm uh uhh hmm mm mhm ah er erm].freeze
114
+
115
+ Heard = Struct.new(:text, :speaker, :score, :identified, :check, keyword_init: true) do
116
+ def identified? = identified
117
+
118
+ def to_h
119
+ { "text" => text, "speaker" => speaker, "score" => score, "identified" => identified, "check" => check }
120
+ end
121
+ end
122
+
123
+ # The real speech-to-text: OpenAI's POST /v1/audio/transcriptions, one
124
+ # multipart file. Ported from Voicebox's Transcription::OpenAI: the model is
125
+ # told the language (left to guess, a model handed near-silence answers a
126
+ # stock word in any language), and a failure other than auth is retried once
127
+ # on the fallback model from the start of the audio.
128
+ class OpenAI
129
+ ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
130
+ OPEN_TIMEOUT = 10
131
+ READ_TIMEOUT = 120
132
+ AUTH_FAILURES = [401, 403].freeze
133
+
134
+ attr_reader :model, :fallback_model, :language, :endpoint
135
+
136
+ def initialize(api_key: VoiceTranscription.api_key, model: Studio.voice_transcribe_model,
137
+ fallback_model: Studio.voice_transcribe_fallback_model,
138
+ language: Studio.voice_transcribe_language, endpoint: ENDPOINT)
139
+ @api_key = api_key
140
+ @model = model.to_s
141
+ @fallback_model = fallback_model.to_s.strip.empty? ? nil : fallback_model.to_s
142
+ @language = language.to_s.strip.empty? ? nil : language.to_s
143
+ @endpoint = endpoint
144
+ end
145
+
146
+ def configured? = !@api_key.to_s.strip.empty?
147
+
148
+ # io: the audio, at its start. Returns the transcript, stripped.
149
+ def transcribe(io:, filename:, content_type:)
150
+ raise NotConfigured, "#{API_KEY_ENV} is not set, so this app cannot transcribe voice" unless configured?
151
+
152
+ request_text(model, io, filename, content_type)
153
+ rescue HttpError => e
154
+ raise if fallback_model.nil? || fallback_model == model || AUTH_FAILURES.include?(e.status)
155
+
156
+ VoiceTranscription.log_warn("#{model} transcription failed (#{e.message}); retrying on #{fallback_model}")
157
+ io.rewind
158
+ request_text(fallback_model, io, filename, content_type)
159
+ end
160
+
161
+ # The multipart fields, apart from the socket so a test reads them.
162
+ def form_fields(model_name, io, filename, content_type)
163
+ fields = [
164
+ ["file", io, { filename: filename, content_type: content_type }],
165
+ ["model", model_name],
166
+ ["response_format", "json"]
167
+ ]
168
+ fields << ["language", language] if language
169
+ fields
170
+ end
171
+
172
+ # The request, built but not sent: a multipart body with a Content-Length
173
+ # (not chunked), the bearer key, JSON accepted.
174
+ def build_request(model_name, io, filename, content_type)
175
+ uri = URI.parse(endpoint)
176
+ boundary = "studio-voice-#{SecureRandom.hex(12)}"
177
+ request = Net::HTTP::Post.new(uri)
178
+ request["Authorization"] = "Bearer #{@api_key}"
179
+ request["Accept"] = "application/json"
180
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
181
+ request.body = VoiceTranscription.multipart(form_fields(model_name, io, filename, content_type), boundary)
182
+ request
183
+ end
184
+
185
+ private
186
+
187
+ def request_text(model_name, io, filename, content_type)
188
+ perform(model_name, io, filename, content_type).fetch("text", "").to_s.strip
189
+ end
190
+
191
+ # The only place a socket opens, so the only place the trap sits.
192
+ def perform(model_name, io, filename, content_type)
193
+ uri = URI.parse(endpoint)
194
+ VoiceTranscription.guard_live_call!("OpenAI", host: uri.host)
195
+
196
+ request = build_request(model_name, io, filename, content_type)
197
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
198
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
199
+ http.request(request)
200
+ end
201
+ interpret(response, model_name)
202
+ rescue Timeout::Error, SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => e
203
+ raise HttpError.new("OpenAI #{model_name} could not be reached (#{e.class})", status: 0)
204
+ end
205
+
206
+ def interpret(response, model_name)
207
+ parsed = begin
208
+ JSON.parse(response.body.to_s)
209
+ rescue JSON::ParserError
210
+ {}
211
+ end
212
+ parsed = {} unless parsed.is_a?(Hash)
213
+ return parsed if response.is_a?(Net::HTTPSuccess)
214
+
215
+ detail = parsed.dig("error", "message") || response.body.to_s[0, 200]
216
+ raise HttpError.new("OpenAI #{model_name} answered HTTP #{response.code}: #{detail}", status: response.code.to_i)
217
+ end
218
+ end
219
+
220
+ # The default everywhere but production: no network, a fixed sentence. A
221
+ # test swaps the answer with Mock.reply = ->(**) { "..." } and resets with
222
+ # Mock.reset!. It records what it was handed (sizes, never the bytes).
223
+ class Mock
224
+ DEFAULT_TEXT = "This is a mock transcript of your voice."
225
+
226
+ class << self
227
+ attr_accessor :reply
228
+ attr_writer :calls
229
+
230
+ def calls = (@calls ||= [])
231
+
232
+ def reset!
233
+ self.reply = nil
234
+ self.calls = []
235
+ end
236
+ end
237
+
238
+ def configured? = true
239
+
240
+ def transcribe(io:, filename:, content_type:)
241
+ bytes = io.read.to_s.bytesize
242
+ self.class.calls << { filename: filename, content_type: content_type, bytes: bytes }
243
+ reply = self.class.reply
244
+ reply.nil? ? DEFAULT_TEXT : reply.call(filename: filename, content_type: content_type, bytes: bytes).to_s
245
+ end
246
+ end
247
+
248
+ ADAPTERS = { "openai" => OpenAI, "mock" => Mock }.freeze
249
+
250
+ module_function
251
+
252
+ # The transcriber's name: Studio.voice_transcriber when the host set one,
253
+ # else STUDIO_VOICE_TRANSCRIBER, else "openai" in production and "mock"
254
+ # everywhere else.
255
+ def adapter_name
256
+ configured = Studio.voice_transcriber.to_s.strip
257
+ return configured unless configured.empty?
258
+
259
+ from_env = ENV[TRANSCRIBER_ENV].to_s.strip
260
+ return from_env unless from_env.empty?
261
+
262
+ rails_env == "production" ? "openai" : "mock"
263
+ end
264
+
265
+ def transcriber(name = adapter_name)
266
+ klass = ADAPTERS.fetch(name.to_s) do
267
+ raise NotConfigured, "Studio.voice_transcriber=#{name.inspect} names no adapter; one of: #{ADAPTERS.keys.join(', ')}"
268
+ end
269
+ klass.new
270
+ end
271
+
272
+ # Studio.voice_openai_api_key, else OPENAI_API_KEY (Voicebox's name).
273
+ def api_key
274
+ key = Studio.voice_openai_api_key.to_s.strip
275
+ key.empty? ? ENV[API_KEY_ENV].to_s.strip : key
276
+ end
277
+
278
+ # Armed when VOICE_NO_LIVE_CALLS=1, and in any Rails test environment unless
279
+ # it is set to 0. A call to this machine (a stub server) is not live.
280
+ def live_calls_blocked?
281
+ flag = ENV[NO_LIVE_CALLS_ENV].to_s.strip
282
+ return true if flag == "1"
283
+ return false if flag == "0"
284
+
285
+ rails_env == "test"
286
+ end
287
+
288
+ def guard_live_call!(vendor, host:)
289
+ return unless live_calls_blocked?
290
+ return if %w[127.0.0.1 localhost ::1].include?(host.to_s)
291
+
292
+ raise LiveCallAttempted, "A live #{vendor} call was attempted with #{NO_LIVE_CALLS_ENV} armed. " \
293
+ "Tests drive the mock transcriber (Studio.voice_transcriber = \"mock\")."
294
+ end
295
+
296
+ # "audio/webm;codecs=opus" -> "audio/webm"; nil for a type a take cannot be.
297
+ def mime_type(raw)
298
+ base = raw.to_s.split(";").first.to_s.strip.downcase
299
+ EXTENSIONS.key?(base) ? base : nil
300
+ end
301
+
302
+ def filename_for(mime)
303
+ "take.#{EXTENSIONS.fetch(mime_type(mime) || 'audio/webm')}"
304
+ end
305
+
306
+ # Why `text` cannot be what someone said, or nil when it can (Voicebox's
307
+ # RecordingPipeline.no_speech_reason): a model handed near-silence writes a
308
+ # stock phrase, a filler, or a word in another alphabet.
309
+ def no_speech_reason(text, language: Studio.voice_transcribe_language)
310
+ text = text.to_s.strip
311
+ return "the transcript was empty" if text.empty?
312
+ if LATIN_LANGUAGES.include?(language.to_s) && !text.match?(/\p{Latin}/)
313
+ return "the transcript has no #{language} letters, so the model was guessing"
314
+ end
315
+
316
+ words = text.scan(/[\p{L}\p{N}']+/)
317
+ return "the transcript has no words" if words.empty?
318
+ return "the transcript is a stock phrase speech models write for silence" if SILENCE_PHRASES.include?(words.join(" ").downcase)
319
+ return "the transcript is only a filler (#{words.first})" if words.size == 1 && FILLERS.include?(words.first.downcase)
320
+
321
+ nil
322
+ end
323
+
324
+ # One take, understood. audio: an IO (or String) of the take's bytes;
325
+ # mime_type: the recorder's mimeType; user: the signed-in user (or nil);
326
+ # embedding: the take's speaker vector from the browser (Array or JSON
327
+ # String), model: the embedder id that made it. Raises NotConfigured,
328
+ # HttpError, ArgumentError (a type a take cannot be); never logs the audio
329
+ # or the vector.
330
+ def hear(audio:, mime_type:, user:, embedding: nil, model: nil, transcriber: self.transcriber)
331
+ mime = self.mime_type(mime_type)
332
+ raise ArgumentError, "unsupported audio type #{mime_type.to_s[0, 60].inspect}" if mime.nil?
333
+
334
+ io = audio.is_a?(String) ? StringIO.new(audio) : audio
335
+ text = transcriber.transcribe(io: io, filename: filename_for(mime), content_type: mime).to_s.strip
336
+ speaker, score, check = who_spoke(user, embedding, model)
337
+ Heard.new(text: text, speaker: speaker, score: score, identified: check == "matched", check: check)
338
+ end
339
+
340
+ # [speaker label, score or nil, check] for one take against the user's own
341
+ # saved voice. Same embedder revision only: the take's model must be the
342
+ # app's embedder (Studio.voice_embedder) AND the one the user's voice was
343
+ # saved with.
344
+ def who_spoke(user, embedding, model)
345
+ embedder = Studio.voice_embedder.to_s
346
+ probe = embedding.nil? ? nil : Studio::VoiceMatch.parse(embedding, size: Studio::VoiceMatch.size(embedder))
347
+ probe = nil if probe && Studio::VoiceMatch.normalize(probe).nil?
348
+ return [SOMEONE_NEW, nil, "no_embedding"] if probe.nil?
349
+
350
+ saved_model = user.respond_to?(:voice_embedding_model) ? user.voice_embedding_model.to_s : ""
351
+ saved = user.respond_to?(:voice_embedding) ? Studio::VoiceMatch.vector(user.voice_embedding) : nil
352
+ return [SOMEONE_NEW, nil, "no_profile"] if saved.nil? || saved_model.empty?
353
+
354
+ app_model = Studio::VoiceMatch.model_id(embedder)
355
+ return [SOMEONE_NEW, nil, "embedder_mismatch"] unless model.to_s == app_model && saved_model == app_model
356
+
357
+ result = Studio::VoiceMatch.match(probe, among: [user], model: app_model)
358
+ return [SOMEONE_NEW, nil, "embedder_mismatch"] if result.score.nil?
359
+ return [SOMEONE_NEW, result.score, "below_threshold"] if result.unknown?
360
+
361
+ [speaker_name(user), result.score, "matched"]
362
+ end
363
+
364
+ def speaker_name(user)
365
+ %i[display_name name email].each do |attr|
366
+ next unless user.respond_to?(attr)
367
+
368
+ value = user.public_send(attr).to_s.strip
369
+ return value unless value.empty?
370
+ end
371
+ "You"
372
+ end
373
+
374
+ # A multipart/form-data body. fields: [name, value] or [name, io, {filename:, content_type:}].
375
+ def multipart(fields, boundary)
376
+ body = +"".b
377
+ fields.each do |name, value, file|
378
+ body << "--#{boundary}\r\n".b
379
+ if file
380
+ body << %(Content-Disposition: form-data; name="#{name}"; filename="#{file[:filename]}"\r\n).b
381
+ body << "Content-Type: #{file[:content_type]}\r\n\r\n".b
382
+ body << value.read.to_s.b
383
+ else
384
+ body << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n).b
385
+ body << value.to_s.b
386
+ end
387
+ body << "\r\n".b
388
+ end
389
+ body << "--#{boundary}--\r\n".b
390
+ body
391
+ end
392
+
393
+ def rails_env
394
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:env)
395
+
396
+ ::Rails.env.to_s
397
+ end
398
+
399
+ def log_warn(message)
400
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
401
+ logger&.warn("[studio][voice] #{message}")
402
+ end
403
+ end
404
+ end
data/lib/studio.rb CHANGED
@@ -47,6 +47,8 @@ require "studio/auth_labels"
47
47
  require "studio/profile_sections"
48
48
  require "studio/profile_image"
49
49
  require "studio/voice_match"
50
+ require "studio/voice_capture"
51
+ require "studio/voice_transcription"
50
52
  require "studio/oauth_identity"
51
53
  require "studio/google_sign_in"
52
54
  require "studio/newsletter"
@@ -576,6 +578,27 @@ module Studio
576
578
  # refuses a voice saved by any other embedder, so a page cannot pick its own.
577
579
  mattr_accessor :voice_embedder, default: "wespeaker"
578
580
 
581
+ # Understand (docs/VOICE_TRANSCRIPTION.md): POST /voice/takes turns a signed-in
582
+ # user's take into {text, speaker, score}. OFF BY DEFAULT: every take costs a
583
+ # speech-to-text call, so an app turns it on when a page posts takes.
584
+ mattr_accessor :voice_transcription, default: false
585
+
586
+ # Which transcriber answers: "openai" or "mock". nil means
587
+ # STUDIO_VOICE_TRANSCRIBER, else "openai" in production and "mock" elsewhere
588
+ # (Studio::VoiceTranscription.adapter_name). Tests never reach the vendor:
589
+ # VOICE_NO_LIVE_CALLS is armed in every test environment.
590
+ mattr_accessor :voice_transcriber, default: nil
591
+
592
+ # The OpenAI key for the real transcriber. nil reads OPENAI_API_KEY. Missing,
593
+ # the endpoint answers 503 with a line the page can show; nothing crashes.
594
+ mattr_accessor :voice_openai_api_key, default: nil
595
+
596
+ # The speech-to-text models (Voicebox's measured pair) and the language both
597
+ # are told to hear. A blank fallback turns the one retry off.
598
+ mattr_accessor :voice_transcribe_model, default: "gpt-4o-transcribe"
599
+ mattr_accessor :voice_transcribe_fallback_model, default: "whisper-1"
600
+ mattr_accessor :voice_transcribe_language, default: "en"
601
+
579
602
  # How long a freshly minted magic link stays live.
580
603
  mattr_accessor :magic_link_ttl, default: 15.minutes
581
604
 
@@ -1955,6 +1978,13 @@ module Studio
1955
1978
  delete "profile/voice", to: "studio/profiles#delete_voice"
1956
1979
  end
1957
1980
 
1981
+ # Understand: one signed-in take in, {text, speaker, score} out
1982
+ # (Studio::VoiceTakesController, docs/VOICE_TRANSCRIPTION.md). Drawn only
1983
+ # for an app that turns it on, because every take is a paid vendor call.
1984
+ if Studio.voice_transcription
1985
+ post "voice/takes", to: "studio/voice_takes#create", as: :studio_voice_takes
1986
+ end
1987
+
1958
1988
  resources :error_logs, only: [:index, :show]
1959
1989
 
1960
1990
  # Admin
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.120.0
4
+ version: 0.121.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-30 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -473,6 +473,7 @@ files:
473
473
  - app/controllers/studio/session_states_controller.rb
474
474
  - app/controllers/studio/site_identities_controller.rb
475
475
  - app/controllers/studio/surveys_controller.rb
476
+ - app/controllers/studio/voice_takes_controller.rb
476
477
  - app/controllers/style_controller.rb
477
478
  - app/controllers/theme_settings_controller.rb
478
479
  - app/helpers/studio/admin_models_table_helper.rb
@@ -507,12 +508,14 @@ files:
507
508
  - app/javascript/studio/controllers/knowledge_transcript_controller.js
508
509
  - app/javascript/studio/controllers/link_preview_card_controller.js
509
510
  - app/javascript/studio/controllers/link_sidebar_controller.js
511
+ - app/javascript/studio/controllers/microphone_chip_controller.js
510
512
  - app/javascript/studio/controllers/modal_host_controller.js
511
513
  - app/javascript/studio/controllers/nav_collapse_controller.js
512
514
  - app/javascript/studio/controllers/style_modals_controller.js
513
515
  - app/javascript/studio/controllers/survey_controller.js
514
516
  - app/javascript/studio/controllers/toast_controller.js
515
517
  - app/javascript/studio/controllers/voice_profile_controller.js
518
+ - app/javascript/studio/controllers/voice_recorder_controller.js
516
519
  - app/javascript/studio/cropper.js
517
520
  - app/javascript/studio/email_banner.js
518
521
  - app/javascript/studio/email_banner_scale.js
@@ -526,11 +529,14 @@ files:
526
529
  - app/javascript/studio/image_upload.js
527
530
  - app/javascript/studio/knowledge_transcript.js
528
531
  - app/javascript/studio/lazy_controllers.js
532
+ - app/javascript/studio/level_meter.js
529
533
  - app/javascript/studio/leveling_activity.js
530
534
  - app/javascript/studio/link_preview_card.js
531
535
  - app/javascript/studio/link_sidebar.js
532
536
  - app/javascript/studio/loader.js
533
537
  - app/javascript/studio/local_path.js
538
+ - app/javascript/studio/microphone.js
539
+ - app/javascript/studio/microphone_chip.js
534
540
  - app/javascript/studio/modal_countdown.js
535
541
  - app/javascript/studio/modal_host.js
536
542
  - app/javascript/studio/nav_collapse.js
@@ -547,6 +553,7 @@ files:
547
553
  - app/javascript/studio/toast.js
548
554
  - app/javascript/studio/vendor/stimulus.js
549
555
  - app/javascript/studio/voice_profile.js
556
+ - app/javascript/studio/voice_recorder.js
550
557
  - app/jobs/error_log_cleanup_job.rb
551
558
  - app/jobs/studio/email_delivery_job.rb
552
559
  - app/jobs/studio/health_report_job.rb
@@ -759,6 +766,10 @@ files:
759
766
  - app/views/studio/surveys/show.html.erb
760
767
  - app/views/studio/surveys/sign_in_required.html.erb
761
768
  - app/views/studio/surveys/thanks.html.erb
769
+ - app/views/studio/voice/_microphone_chip.html.erb
770
+ - app/views/studio/voice/_microphone_help.html.erb
771
+ - app/views/studio/voice/_recorder.html.erb
772
+ - app/views/studio/voice/_styles.html.erb
762
773
  - app/views/style/_components.html.erb
763
774
  - app/views/style/_host.html.erb
764
775
  - app/views/style/_modal_specimen.html.erb
@@ -893,7 +904,9 @@ files:
893
904
  - lib/studio/ui_primitives.rb
894
905
  - lib/studio/username_generator.rb
895
906
  - lib/studio/version.rb
907
+ - lib/studio/voice_capture.rb
896
908
  - lib/studio/voice_match.rb
909
+ - lib/studio/voice_transcription.rb
897
910
  - lib/tasks/studio_email.rake
898
911
  - lib/tasks/studio_knowledge.rake
899
912
  - lib/tasks/studio_node.rake