studio-engine 0.119.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +3 -0
- data/app/controllers/studio/profiles_controller.rb +56 -0
- data/app/controllers/studio/voice_takes_controller.rb +93 -0
- data/app/javascript/studio/controllers/microphone_chip_controller.js +21 -0
- data/app/javascript/studio/controllers/voice_profile_controller.js +20 -0
- data/app/javascript/studio/controllers/voice_recorder_controller.js +29 -0
- data/app/javascript/studio/level_meter.js +167 -0
- data/app/javascript/studio/microphone.js +250 -0
- data/app/javascript/studio/microphone_chip.js +370 -0
- data/app/javascript/studio/stimulus.js +10 -2
- data/app/javascript/studio/voice_profile.js +444 -0
- data/app/javascript/studio/voice_recorder.js +352 -0
- data/app/views/studio/profiles/_voice_section.html.erb +108 -0
- data/app/views/studio/voice/_microphone_chip.html.erb +45 -0
- data/app/views/studio/voice/_microphone_help.html.erb +63 -0
- data/app/views/studio/voice/_recorder.html.erb +34 -0
- data/app/views/studio/voice/_styles.html.erb +90 -0
- data/db/migrate/20261029120000_add_voice_profile_to_users.rb +44 -0
- data/lib/studio/engine.rb +10 -0
- data/lib/studio/profile_sections.rb +11 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_match.rb +238 -0
- data/lib/studio/voice_transcription.rb +404 -0
- data/lib/studio.rb +57 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 161d3e00f32dba8b6c36d769228b39815a79b85a702d76b80f89be4c4365b876
|
|
4
|
+
data.tar.gz: a56e3fe6902119b4e5ecba61c77347a95893c04c3d8a5543833246b0a674c38f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c9506dfec92c4174d85f3d03002d63d222b42c1be0866184a6551faa075471aec3440265e7623378d804e0b7f4dd6eb9e7978ab50f8403ce4edabb8be186df2
|
|
7
|
+
data.tar.gz: 9ec9cfde7907b0c5dd09a0c19cda518da4fc6341c32003bdda71debdd0ff8de4a13c4091936223d646713c98d7e87c7201550ff725f8f8aecedaf6e53563e962
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.121.0 — 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Voice capture layer, ported from Voicebox.** `studio/voice/_microphone_chip`
|
|
12
|
+
names the microphone a page records from (the track's own label, with a live
|
|
13
|
+
level dot) and lists the others; a pick is remembered in `localStorage` under
|
|
14
|
+
`studio.microphone` (`storage_key:` to change it) and opened by exact device
|
|
15
|
+
id, a pick that is gone falls back to the system default, and with nothing
|
|
16
|
+
picked the system default is asked for by id rather than Chrome's per-site
|
|
17
|
+
preference. Its native `<dialog>` help shows Chrome's site-settings steps or a
|
|
18
|
+
generic line, and opens on a switch, a refusal, a second silent take in a row,
|
|
19
|
+
or the help link. `studio/voice/_recorder` (controller `voice-recorder`, tap or
|
|
20
|
+
hold to talk) refuses a take under -40 dBFS and hands a heard take to the page
|
|
21
|
+
as `studio:voice-recorder:take` with the blob; it uploads nothing. A recorder
|
|
22
|
+
that cannot start lets go of the microphone and the meter. New modules:
|
|
23
|
+
`studio/microphone`, `studio/level_meter` (the one meter),
|
|
24
|
+
`studio/voice_recorder`, `studio/microphone_chip`. docs/VOICE_CAPTURE.md is
|
|
25
|
+
the contract.
|
|
26
|
+
- **Voice transcription: a take becomes `{text, speaker, score}`.**
|
|
27
|
+
`Studio::VoiceTranscription.hear(audio:, mime_type:, user:, embedding:, model:)`
|
|
28
|
+
transcribes a take with a mock-first adapter ported from Voicebox (`openai`:
|
|
29
|
+
`gpt-4o-transcribe`, one retry on `whisper-1`, told the language; `mock`
|
|
30
|
+
elsewhere) and names the speaker with `Studio::VoiceMatch` against the user's
|
|
31
|
+
own `users.voice_embedding`, same embedder revision only, else "Someone new".
|
|
32
|
+
Opt-in `POST /voice/takes` (`config.voice_transcription = true`) wraps it for a
|
|
33
|
+
signed-in, CSRF-protected page: 10 MB and 120 s caps, an allow-list of audio
|
|
34
|
+
types, a refused no-speech transcript, and a 503 with a showable line when the
|
|
35
|
+
app has no `OPENAI_API_KEY`. The real adapter raises `LiveCallAttempted`
|
|
36
|
+
before any socket while `VOICE_NO_LIVE_CALLS=1` and in every Rails test
|
|
37
|
+
environment. Nothing is stored; `voice[audio]` and `voice[embedding]` join the
|
|
38
|
+
parameter filter. docs/VOICE_TRANSCRIPTION.md is the contract.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- The voice row on `/profile` (`studio/voice_profile`) runs on the shared meter
|
|
43
|
+
and recording format (`studio/level_meter`, `studio/voice_recorder`) instead
|
|
44
|
+
of its own copy; it still re-exports `SILENCE_RMS`, `meterLevel`,
|
|
45
|
+
`takeWasHeard`, `pickMimeType` and friends. Under reduced motion its ring now
|
|
46
|
+
steps in quarters, as the recorder's does.
|
|
47
|
+
|
|
48
|
+
## 0.120.0 — 2026-10-30
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **Voice profile on `/profile`, opt-in.** `config.voice_profile = true` adds a
|
|
53
|
+
"Your voice" row: the signed-in user reads three phrases while a live level
|
|
54
|
+
meter shows the microphone being heard, silent takes are refused, and each
|
|
55
|
+
take becomes a speaker embedding on the device (WeSpeaker ResNet34 int8 from
|
|
56
|
+
Hugging Face at a pinned revision, through transformers.js 3.7.6 from
|
|
57
|
+
jsDelivr). Save posts only the three vectors (`POST /profile/voice`); the
|
|
58
|
+
server averages them onto the user's own record. Re-record and Delete
|
|
59
|
+
(`DELETE /profile/voice`) are on the row, with a consent line saying what is
|
|
60
|
+
stored. A new host-table migration, `add_voice_profile_to_users`, adds
|
|
61
|
+
`voice_embedding`, `voice_embedding_model` and `voice_embedding_updated_at`.
|
|
62
|
+
`Studio::VoiceMatch.identify(embedding, among:, model:)` returns the closest
|
|
63
|
+
user at or above 0.6 cosine, else nil, comparing only voices from the same
|
|
64
|
+
model. `config.voice_embedder = "fake"` is the test embedder
|
|
65
|
+
(`window.studioFakeVoice`). docs/VOICE_PROFILE.md lists the CSP and privacy
|
|
66
|
+
changes a host makes first.
|
|
67
|
+
|
|
7
68
|
## 0.119.1 — 2026-10-29
|
|
8
69
|
|
|
9
70
|
### Fixed
|
data/README.md
CHANGED
|
@@ -45,6 +45,9 @@ resolved.
|
|
|
45
45
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
46
46
|
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
47
47
|
- **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
|
|
48
|
+
- **Voice profile**: an opt-in row on `/profile` ("Set up your voice"): the person reads three phrases with a live level meter, their device turns each take into a speaker embedding (WeSpeaker via transformers.js), and only the averaged vector is saved on their user, never audio. `Studio::VoiceMatch.identify(embedding, among: User.all, model:)` answers who is speaking. Turn it on with `config.voice_profile = true` after installing the migration. See [`docs/VOICE_PROFILE.md`](docs/VOICE_PROFILE.md).
|
|
49
|
+
- **Voice capture**: the microphone chip (names the microphone a page records from, picks another, Chrome help dialog), one level meter, and a record button that refuses a silent take and hands a heard one to the page as a `studio:voice-recorder:take` event with the blob; it uploads nothing. Render `studio/voice/microphone_chip` and `studio/voice/recorder`. See [`docs/VOICE_CAPTURE.md`](docs/VOICE_CAPTURE.md).
|
|
50
|
+
- **Voice transcription**: `POST /voice/takes` (opt-in, `config.voice_transcription = true`) turns a signed-in user's take into `{text, speaker, score}`: a mock-first transcriber (OpenAI `gpt-4o-transcribe` in production, a mock elsewhere, a no-live-calls trap in every test environment) and `Studio::VoiceMatch` against the user's own saved voice. Nothing is kept. See [`docs/VOICE_TRANSCRIPTION.md`](docs/VOICE_TRANSCRIPTION.md).
|
|
48
51
|
- **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
|
|
49
52
|
|
|
50
53
|
## Tailwind
|
|
@@ -202,8 +202,64 @@ module Studio
|
|
|
202
202
|
end
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
+
# POST /profile/voice — save (or replace) the signed-in user's voice.
|
|
206
|
+
#
|
|
207
|
+
# THE BODY IS VECTORS, NEVER AUDIO. The page records three phrases, turns
|
|
208
|
+
# each into a speaker embedding on the device (studio/voice_profile) and
|
|
209
|
+
# drops the takes; this receives `voice[model]` and `voice[embeddings]` (a
|
|
210
|
+
# JSON array of Studio::VoiceMatch::TAKES vectors) and averages them here.
|
|
211
|
+
#
|
|
212
|
+
# The model must be the one this app runs (Studio.voice_embedder), so a page
|
|
213
|
+
# cannot save vectors from an embedder the matcher would never compare, and
|
|
214
|
+
# the test embedder can never be saved where the real one runs. A take that
|
|
215
|
+
# embedded to all zeros (silence the browser let through) is refused by
|
|
216
|
+
# VoiceMatch.enrol.
|
|
217
|
+
#
|
|
218
|
+
# Answers JSON for the page's fetch and redirects for anything else.
|
|
219
|
+
def save_voice
|
|
220
|
+
return voice_refused("This app has no voice profile to set up.", :not_found) unless row_rendered?(:voice)
|
|
221
|
+
|
|
222
|
+
embedder = Studio.voice_embedder.to_s
|
|
223
|
+
model = params.dig(:voice, :model).to_s
|
|
224
|
+
unless model == Studio::VoiceMatch.model_id(embedder)
|
|
225
|
+
return voice_refused("That voice came from a different model. Reload the page and record again.")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
vector = Studio::VoiceMatch.enrol(params.dig(:voice, :embeddings), size: Studio::VoiceMatch.size(embedder))
|
|
229
|
+
if vector.nil?
|
|
230
|
+
return voice_refused("We couldn't use those recordings. Read all three phrases again.")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
rescue_and_log(target: current_user) do
|
|
234
|
+
current_user.update!(voice_embedding: vector, voice_embedding_model: model,
|
|
235
|
+
voice_embedding_updated_at: Time.current)
|
|
236
|
+
respond_to do |format|
|
|
237
|
+
format.json { render json: { url: profile_path }, status: :ok }
|
|
238
|
+
format.any { redirect_to profile_path, notice: "Your voice is set up.", status: :see_other }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# DELETE /profile/voice — remove the stored voice entirely: the vector, its
|
|
244
|
+
# model and its date. Nothing of it is kept.
|
|
245
|
+
def delete_voice
|
|
246
|
+
return unsupported("voice profile") unless row_rendered?(:voice)
|
|
247
|
+
|
|
248
|
+
rescue_and_log(target: current_user) do
|
|
249
|
+
current_user.update!(voice_embedding: nil, voice_embedding_model: nil, voice_embedding_updated_at: nil)
|
|
250
|
+
redirect_to profile_path, notice: "Your voice was deleted.", status: :see_other
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
205
254
|
private
|
|
206
255
|
|
|
256
|
+
def voice_refused(message, status = :unprocessable_entity)
|
|
257
|
+
respond_to do |format|
|
|
258
|
+
format.json { render json: { error: message }, status: status }
|
|
259
|
+
format.any { redirect_to profile_path, alert: message, status: :see_other }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
207
263
|
# Tell the host, and NEVER let it break the subscription.
|
|
208
264
|
#
|
|
209
265
|
# The subscription is the durable fact; the reaction is not. turf-monster's
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# POST /voice/takes: one recorded take in, {text, speaker, score} out
|
|
5
|
+
# (docs/VOICE_TRANSCRIPTION.md). Drawn by Studio.routes only when the app
|
|
6
|
+
# sets Studio.voice_transcription.
|
|
7
|
+
#
|
|
8
|
+
# The page posts the recorder's take (studio:voice-recorder:take) as
|
|
9
|
+
# multipart/form-data:
|
|
10
|
+
#
|
|
11
|
+
# voice[audio] the blob (required, at most VoiceTranscription::MAX_BYTES)
|
|
12
|
+
# voice[mime_type] its mimeType, e.g. "audio/webm;codecs=opus" (else the
|
|
13
|
+
# part's own Content-Type)
|
|
14
|
+
# voice[duration_ms] durationMs (required, 1..MAX_DURATION_MS)
|
|
15
|
+
# voice[embedding] the take's speaker vector, JSON (optional)
|
|
16
|
+
# voice[model] the embedder id that made it (optional)
|
|
17
|
+
#
|
|
18
|
+
# SIGNED-IN ONLY (the host's require_authentication: a JSON 401 otherwise),
|
|
19
|
+
# CSRF-protected (send the page's token as X-CSRF-Token), JSON, no-store.
|
|
20
|
+
# The speaker is matched only against the signed-in user's own saved voice.
|
|
21
|
+
#
|
|
22
|
+
# NOTHING IS KEPT: the upload is read once, handed to the transcriber, and its
|
|
23
|
+
# tempfile is deleted before the answer; no audio or vector is stored or
|
|
24
|
+
# logged (voice[audio] and voice[embedding] are in the engine's parameter
|
|
25
|
+
# filter).
|
|
26
|
+
class VoiceTakesController < ::ApplicationController
|
|
27
|
+
before_action :require_authentication
|
|
28
|
+
protect_from_forgery with: :exception
|
|
29
|
+
before_action :no_store
|
|
30
|
+
|
|
31
|
+
CANT_HEAR = "I couldn't hear any words in that. Check your microphone and try again."
|
|
32
|
+
UNAVAILABLE = "I couldn't transcribe that just now. Try again in a moment."
|
|
33
|
+
|
|
34
|
+
def create
|
|
35
|
+
voice = params[:voice]
|
|
36
|
+
audio = voice.respond_to?(:[]) ? voice[:audio] : nil
|
|
37
|
+
return refuse("Record something first.") unless audio.respond_to?(:read) && audio.respond_to?(:size)
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
return refuse("That recording is too long to send.", 413) if audio.size > VoiceTranscription::MAX_BYTES
|
|
41
|
+
return refuse("That recording was empty.") if audio.size.zero?
|
|
42
|
+
|
|
43
|
+
duration = voice[:duration_ms].to_s
|
|
44
|
+
unless duration.match?(/\A\d{1,9}\z/) && duration.to_i.between?(1, VoiceTranscription::MAX_DURATION_MS)
|
|
45
|
+
return refuse("That recording is too long, or has no length.")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
mime = VoiceTranscription.mime_type(voice[:mime_type].presence ||
|
|
49
|
+
(audio.content_type if audio.respond_to?(:content_type)))
|
|
50
|
+
return refuse("That kind of recording isn't supported.", 415) if mime.nil?
|
|
51
|
+
|
|
52
|
+
heard = VoiceTranscription.hear(audio: audio, mime_type: mime, user: current_user,
|
|
53
|
+
embedding: voice[:embedding].presence, model: voice[:model].presence)
|
|
54
|
+
reason = VoiceTranscription.no_speech_reason(heard.text)
|
|
55
|
+
return refuse(CANT_HEAR, :unprocessable_entity, reason: "no_speech") if reason
|
|
56
|
+
|
|
57
|
+
render json: heard.to_h.merge("duration_ms" => duration.to_i), status: :ok
|
|
58
|
+
rescue VoiceTranscription::NotConfigured => e
|
|
59
|
+
capture(e)
|
|
60
|
+
refuse(VoiceTranscription::NOT_CONFIGURED_MESSAGE, :service_unavailable, reason: "not_configured")
|
|
61
|
+
rescue VoiceTranscription::HttpError => e
|
|
62
|
+
capture(e)
|
|
63
|
+
refuse(UNAVAILABLE, :service_unavailable, reason: "transcriber_failed", retry: true)
|
|
64
|
+
ensure
|
|
65
|
+
discard(audio)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def refuse(message, status = :unprocessable_entity, **extra)
|
|
72
|
+
render json: { error: message, **extra }, status: status
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def no_store
|
|
76
|
+
response.headers["Cache-Control"] = "no-store"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The upload's tempfile goes now, not at the next GC.
|
|
80
|
+
def discard(audio)
|
|
81
|
+
file = audio.respond_to?(:tempfile) ? audio.tempfile : nil
|
|
82
|
+
file.close! if file.respond_to?(:close!)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def capture(error)
|
|
88
|
+
::ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// microphone-chip: the microphone chip (studio/voice/_microphone_chip), on the
|
|
2
|
+
// engine's Stimulus application (studio/stimulus registers it lazily, the
|
|
3
|
+
// first time a page renders a chip):
|
|
4
|
+
//
|
|
5
|
+
// <div data-studio-controller="microphone-chip" data-microphone-copy='{...}'>
|
|
6
|
+
//
|
|
7
|
+
// The listing, the pick, the live dot and the help dialog are
|
|
8
|
+
// studio/microphone_chip; which device opens is studio/microphone.
|
|
9
|
+
import { Controller } from "@hotwired/stimulus"
|
|
10
|
+
import { mountMicrophoneChip } from "studio/microphone_chip"
|
|
11
|
+
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
connect() {
|
|
14
|
+
this.chip = mountMicrophoneChip(this.element)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
if (this.chip) this.chip.destroy()
|
|
19
|
+
this.chip = null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// voice-profile: the voice row on /profile (studio/profiles/_voice_section), on
|
|
2
|
+
// the engine's Stimulus application (studio/stimulus registers it lazily, the
|
|
3
|
+
// first time a page renders the row). It sits on the row's wrapper:
|
|
4
|
+
//
|
|
5
|
+
// <div data-studio-controller="voice-profile" data-voice-save-url="/profile/voice" ...>
|
|
6
|
+
//
|
|
7
|
+
// The recording, the meter, the embedding and the save are studio/voice_profile.
|
|
8
|
+
import { Controller } from "@hotwired/stimulus"
|
|
9
|
+
import { mountVoiceProfile } from "studio/voice_profile"
|
|
10
|
+
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
connect() {
|
|
13
|
+
this.voice = mountVoiceProfile(this.element)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
if (this.voice) this.voice.destroy()
|
|
18
|
+
this.voice = null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// voice-recorder: one record button (studio/voice/_recorder, or a page's own
|
|
2
|
+
// markup), on the engine's Stimulus application (studio/stimulus registers it
|
|
3
|
+
// lazily, the first time a page renders one):
|
|
4
|
+
//
|
|
5
|
+
// <div data-studio-controller="voice-recorder" data-voice-recorder-mode="toggle">
|
|
6
|
+
//
|
|
7
|
+
// The take, the meter and the events are studio/voice_recorder. A page reads
|
|
8
|
+
// the finished audio from the studio:voice-recorder:take event; this
|
|
9
|
+
// controller uploads nothing. Pages that drive it from their own controls call
|
|
10
|
+
// the actions: data-studio-action="voice-recorder#start" (or #stop, #toggle).
|
|
11
|
+
import { Controller } from "@hotwired/stimulus"
|
|
12
|
+
import { mountVoiceRecorder } from "studio/voice_recorder"
|
|
13
|
+
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
connect() {
|
|
16
|
+
this.recorder = mountVoiceRecorder(this.element)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this.recorder) this.recorder.destroy()
|
|
21
|
+
this.recorder = null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() { if (this.recorder) this.recorder.start() }
|
|
25
|
+
|
|
26
|
+
stop() { if (this.recorder) this.recorder.stop() }
|
|
27
|
+
|
|
28
|
+
toggle() { if (this.recorder) this.recorder.toggle() }
|
|
29
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// studio/level_meter: the ONE level meter every engine recorder uses (the voice
|
|
2
|
+
// recorder, studio/voice_recorder, and the voice row, studio/voice_profile). It
|
|
3
|
+
// reads a live microphone stream through a Web Audio AnalyserNode, keeps the
|
|
4
|
+
// take's loudest frame, and paints a 0..1 level the page draws as a ring or a
|
|
5
|
+
// dot. Voicebox carried two copies of this (its recorder and its voice setup);
|
|
6
|
+
// the engine carries one (docs/VOICE_CAPTURE.md).
|
|
7
|
+
//
|
|
8
|
+
// const meter = createLevelMeter(stream, { onPaint: (level) => ..., onSample: (rms) => ... })
|
|
9
|
+
// ... meter.sample() ... // the recorder also samples on its own timer
|
|
10
|
+
// const heard = meter.heard() // before close: did the take clear SILENCE_RMS?
|
|
11
|
+
// meter.close() // stops frames and the timer, closes the context, paints 0
|
|
12
|
+
//
|
|
13
|
+
// WHY A TAKE IS JUDGED. A muted or wrong microphone records perfect silence,
|
|
14
|
+
// and a speech model handed silence makes a word up. A take whose loudest frame
|
|
15
|
+
// stayed under SILENCE_RMS is refused by the recorder before it goes anywhere.
|
|
16
|
+
//
|
|
17
|
+
// It imports nothing, so test/javascript/level_meter.test.mjs loads it as a
|
|
18
|
+
// data: module.
|
|
19
|
+
|
|
20
|
+
// The loudest frame (RMS of the samples, 0..1) a take must reach to count as
|
|
21
|
+
// heard: 0.01 is -40 dBFS. A muted or absent microphone sits at 0, a quiet room
|
|
22
|
+
// on a laptop microphone near 0.002-0.006, and speech at arm's length peaks
|
|
23
|
+
// well above 0.05, so a real voice clears it many times over. Measured in
|
|
24
|
+
// Voicebox.
|
|
25
|
+
export const SILENCE_RMS = 0.01
|
|
26
|
+
|
|
27
|
+
// How often the meter samples on a timer as well as on animation frames: a
|
|
28
|
+
// background tab stops frames, and a take recorded there is still measured.
|
|
29
|
+
export const SAMPLE_INTERVAL_MS = 100
|
|
30
|
+
|
|
31
|
+
// Reduced motion: the level steps four times a second, in quarters, instead
|
|
32
|
+
// of gliding.
|
|
33
|
+
export const REDUCED_PAINT_MS = 250
|
|
34
|
+
|
|
35
|
+
export function rootMeanSquare(samples) {
|
|
36
|
+
if (!samples || samples.length === 0) return 0
|
|
37
|
+
let sum = 0
|
|
38
|
+
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]
|
|
39
|
+
return Math.sqrt(sum / samples.length)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The loudest window's RMS, 0..1 (800 samples is 50 ms at 16 kHz): the same
|
|
43
|
+
// silence rule applied to decoded samples rather than a live stream.
|
|
44
|
+
export function peakWindowRms(samples, windowSize = 800) {
|
|
45
|
+
let peak = 0
|
|
46
|
+
if (!samples) return peak
|
|
47
|
+
for (let start = 0; start < samples.length; start += windowSize) {
|
|
48
|
+
const end = Math.min(samples.length, start + windowSize)
|
|
49
|
+
let sum = 0
|
|
50
|
+
for (let i = start; i < end; i++) sum += samples[i] * samples[i]
|
|
51
|
+
const rms = Math.sqrt(sum / Math.max(1, end - start))
|
|
52
|
+
if (rms > peak) peak = rms
|
|
53
|
+
}
|
|
54
|
+
return peak
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The meter's reading, 0..1, for an RMS level: speech lives between about
|
|
58
|
+
// 0.01 and 0.3, so the scale is logarithmic from -40 dBFS to -10 dBFS.
|
|
59
|
+
export function meterLevel(rms) {
|
|
60
|
+
if (!(rms > 0)) return 0
|
|
61
|
+
const db = 20 * Math.log10(rms)
|
|
62
|
+
return Math.min(1, Math.max(0, (db + 40) / 30))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Heard, unless the meter measured the take and it stayed under SILENCE_RMS. A
|
|
66
|
+
// take the meter never measured (no Web Audio, a context that never ran) is let
|
|
67
|
+
// through, and whatever receives it (a server, an embedder) decides.
|
|
68
|
+
export function takeWasHeard(peakRms, framesMeasured) {
|
|
69
|
+
if (!framesMeasured) return true
|
|
70
|
+
return peakRms >= SILENCE_RMS
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The level as painted: in quarters under reduced motion.
|
|
74
|
+
export function steppedLevel(level, reduced) {
|
|
75
|
+
return reduced ? Math.round(level * 4) / 4 : level
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function prefersReducedMotion(win = globalThis) {
|
|
79
|
+
try { return !!(win.matchMedia && win.matchMedia("(prefers-reduced-motion: reduce)").matches) } catch (_) { return false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Starts measuring a live stream. Opened after the microphone prompt resolves,
|
|
83
|
+
// so a strict autoplay rule (iOS) can leave the context suspended: those frames
|
|
84
|
+
// read zeros and prove nothing, so they are not counted, and an unmeasured take
|
|
85
|
+
// is let through (takeWasHeard). Without Web Audio the meter measures nothing
|
|
86
|
+
// and paints nothing beyond 0; the take still records.
|
|
87
|
+
//
|
|
88
|
+
// onSample(rms) runs on every counted frame (the recorder tells the chip);
|
|
89
|
+
// onPaint(level) runs with the 0..1 level to draw.
|
|
90
|
+
export function createLevelMeter(stream, { win = globalThis, onSample = () => {}, onPaint = () => {}, intervalMs = SAMPLE_INTERVAL_MS } = {}) {
|
|
91
|
+
let audioContext = null
|
|
92
|
+
let analyser = null
|
|
93
|
+
let buffer = null
|
|
94
|
+
let frame = null
|
|
95
|
+
let timer = null
|
|
96
|
+
let peak = 0
|
|
97
|
+
let frames = 0
|
|
98
|
+
let lastPaint = 0
|
|
99
|
+
let closed = false
|
|
100
|
+
const reduced = prefersReducedMotion(win)
|
|
101
|
+
const now = () => (win.performance && win.performance.now ? win.performance.now() : Date.now())
|
|
102
|
+
|
|
103
|
+
function paint(level) {
|
|
104
|
+
try { onPaint(steppedLevel(level, reduced)) } catch (_) { /* a painter's error never stops the take */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sample() {
|
|
108
|
+
if (closed || !analyser || !audioContext || audioContext.state !== "running") return null
|
|
109
|
+
analyser.getFloatTimeDomainData(buffer)
|
|
110
|
+
const rms = rootMeanSquare(buffer)
|
|
111
|
+
frames += 1
|
|
112
|
+
if (rms > peak) peak = rms
|
|
113
|
+
try { onSample(rms) } catch (_) { /* as above */ }
|
|
114
|
+
const t = now()
|
|
115
|
+
if (!reduced || t - lastPaint >= REDUCED_PAINT_MS) {
|
|
116
|
+
lastPaint = t
|
|
117
|
+
paint(meterLevel(rms))
|
|
118
|
+
}
|
|
119
|
+
return rms
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function close() {
|
|
123
|
+
if (frame != null && win.cancelAnimationFrame) win.cancelAnimationFrame(frame)
|
|
124
|
+
if (timer != null && win.clearInterval) win.clearInterval(timer)
|
|
125
|
+
frame = null
|
|
126
|
+
timer = null
|
|
127
|
+
analyser = null
|
|
128
|
+
if (audioContext && audioContext.state !== "closed" && audioContext.close) {
|
|
129
|
+
try { audioContext.close().catch(() => {}) } catch (_) { /* already gone */ }
|
|
130
|
+
}
|
|
131
|
+
audioContext = null
|
|
132
|
+
closed = true
|
|
133
|
+
paint(0)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
paint(0)
|
|
137
|
+
const AudioContextClass = win.AudioContext || win.webkitAudioContext
|
|
138
|
+
if (AudioContextClass && stream) {
|
|
139
|
+
try {
|
|
140
|
+
audioContext = new AudioContextClass()
|
|
141
|
+
const source = audioContext.createMediaStreamSource(stream)
|
|
142
|
+
analyser = audioContext.createAnalyser()
|
|
143
|
+
analyser.fftSize = 1024
|
|
144
|
+
buffer = new Float32Array(analyser.fftSize)
|
|
145
|
+
source.connect(analyser)
|
|
146
|
+
if (audioContext.state === "suspended" && audioContext.resume) audioContext.resume().catch(() => {})
|
|
147
|
+
if (win.requestAnimationFrame) {
|
|
148
|
+
const loop = () => {
|
|
149
|
+
sample()
|
|
150
|
+
if (!closed) frame = win.requestAnimationFrame(loop)
|
|
151
|
+
}
|
|
152
|
+
frame = win.requestAnimationFrame(loop)
|
|
153
|
+
}
|
|
154
|
+
if (win.setInterval) timer = win.setInterval(sample, intervalMs)
|
|
155
|
+
} catch (_) {
|
|
156
|
+
close() // measured nothing: heard() lets the take through
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
sample,
|
|
162
|
+
close,
|
|
163
|
+
heard: () => takeWasHeard(peak, frames),
|
|
164
|
+
get peak() { return peak },
|
|
165
|
+
get frames() { return frames }
|
|
166
|
+
}
|
|
167
|
+
}
|