studio-engine 0.120.0 → 0.122.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 +84 -0
- data/README.md +2 -0
- data/app/controllers/studio/voice_takes_controller.rb +146 -0
- data/app/javascript/studio/controllers/microphone_chip_controller.js +21 -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 +374 -0
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/javascript/studio/voice_profile.js +38 -113
- data/app/javascript/studio/voice_recorder.js +363 -0
- data/app/views/studio/voice/_microphone_chip.html.erb +45 -0
- data/app/views/studio/voice/_microphone_help.html.erb +65 -0
- data/app/views/studio/voice/_recorder.html.erb +34 -0
- data/app/views/studio/voice/_styles.html.erb +94 -0
- data/lib/studio/engine.rb +3 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_transcription.rb +470 -0
- data/lib/studio.rb +45 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd0f77b33c7833a6cb5061bc17b11baad3db5ad3b2e6ad232b31cc363d124e49
|
|
4
|
+
data.tar.gz: 942b24a25b2dbdb8d7e1af5a918c323718fcb2438eced8b46dd1593f1563d9a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d81f612a81ddb2d5e9ccdf6c85d4e4cea1bd47043ba5e0c9732202f55e39489b44059646e8f42848530525c2c4c8f23ecd299afeb322b43adb3dd3bb513ff1a
|
|
7
|
+
data.tar.gz: 3f6a3214f4a5a80cfc2c73453dd0429161f28879ba3bd41857160ab02c2f70bb633213d317c19d9026e786afb7d99c37ddc5e6e1da2793d8edf495fa976f216d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,90 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.122.0 — 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`POST /voice/takes` is rate-limited per user.** `Studio.voice_takes_rate_limit`
|
|
12
|
+
(default `{ to: 20, within: 600 }`, a fixed window per user id; `store:`
|
|
13
|
+
optional; `false` turns it off). Over it: a JSON `429`
|
|
14
|
+
`{error, reason: "rate_limited", retry_after}` with `Retry-After`, before the
|
|
15
|
+
take is read or transcribed. On a `NullStore` cache it counts in a per-process
|
|
16
|
+
memory store rather than going inert.
|
|
17
|
+
- **One deadline per take at the vendor.** `Studio.voice_transcribe_deadline`
|
|
18
|
+
(25 s) covers both models together; every socket timeout is what is left of
|
|
19
|
+
it, and a hard timeout backs it (the old 10 s + 120 s per attempt could hold a
|
|
20
|
+
Puma thread about 260 s).
|
|
21
|
+
- `Studio::VoiceTranscription::HttpError#reason` (`unauthorized`,
|
|
22
|
+
`rate_limited`, `server_error`, `rejected`, `timeout`, `unreachable`).
|
|
23
|
+
- The recorder's `failed.busy` line: a microphone another app holds
|
|
24
|
+
(`NotReadableError`) reads "Your microphone is busy in another app. Close it
|
|
25
|
+
there, then try again.", not "I couldn't find a microphone".
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Voice reads its own key.** `Studio.voice_openai_api_key`, else
|
|
30
|
+
`STUDIO_VOICE_OPENAI_API_KEY`; the app's shared `OPENAI_API_KEY` is no longer
|
|
31
|
+
read. An app that turned voice on with `OPENAI_API_KEY` alone sets
|
|
32
|
+
`STUDIO_VOICE_OPENAI_API_KEY` (until then a take answers the 503
|
|
33
|
+
`not_configured`).
|
|
34
|
+
- The `whisper-1` fallback no longer runs after a timeout, a vendor 429, or no
|
|
35
|
+
answer at all, nor with under 2 s of the deadline left.
|
|
36
|
+
- `HttpError` messages, and so `ErrorLog` rows, carry the model, the status and
|
|
37
|
+
a reason code, never the vendor's error text. The row's `target` is the user
|
|
38
|
+
who sent the take.
|
|
39
|
+
- Microphone picker: Esc in the open list returns focus to the chip; options
|
|
40
|
+
drop `aria-pressed` (the one in use keeps `aria-current`); options and the
|
|
41
|
+
help link wear the chip's 3px focus ring; the help's address-bar drawing shows
|
|
42
|
+
a drawn bar instead of printing `request.host`.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- A `voice` param that is a string or a list answers `422`, not `500`.
|
|
47
|
+
- The no-live-calls trap reads `URI#hostname`, so an IPv6 loopback endpoint
|
|
48
|
+
(`http://[::1]:…`) counts as this machine.
|
|
49
|
+
|
|
50
|
+
## 0.121.0 — 2026-11-01
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **Voice capture layer, ported from Voicebox.** `studio/voice/_microphone_chip`
|
|
55
|
+
names the microphone a page records from (the track's own label, with a live
|
|
56
|
+
level dot) and lists the others; a pick is remembered in `localStorage` under
|
|
57
|
+
`studio.microphone` (`storage_key:` to change it) and opened by exact device
|
|
58
|
+
id, a pick that is gone falls back to the system default, and with nothing
|
|
59
|
+
picked the system default is asked for by id rather than Chrome's per-site
|
|
60
|
+
preference. Its native `<dialog>` help shows Chrome's site-settings steps or a
|
|
61
|
+
generic line, and opens on a switch, a refusal, a second silent take in a row,
|
|
62
|
+
or the help link. `studio/voice/_recorder` (controller `voice-recorder`, tap or
|
|
63
|
+
hold to talk) refuses a take under -40 dBFS and hands a heard take to the page
|
|
64
|
+
as `studio:voice-recorder:take` with the blob; it uploads nothing. A recorder
|
|
65
|
+
that cannot start lets go of the microphone and the meter. New modules:
|
|
66
|
+
`studio/microphone`, `studio/level_meter` (the one meter),
|
|
67
|
+
`studio/voice_recorder`, `studio/microphone_chip`. docs/VOICE_CAPTURE.md is
|
|
68
|
+
the contract.
|
|
69
|
+
- **Voice transcription: a take becomes `{text, speaker, score}`.**
|
|
70
|
+
`Studio::VoiceTranscription.hear(audio:, mime_type:, user:, embedding:, model:)`
|
|
71
|
+
transcribes a take with a mock-first adapter ported from Voicebox (`openai`:
|
|
72
|
+
`gpt-4o-transcribe`, one retry on `whisper-1`, told the language; `mock`
|
|
73
|
+
elsewhere) and names the speaker with `Studio::VoiceMatch` against the user's
|
|
74
|
+
own `users.voice_embedding`, same embedder revision only, else "Someone new".
|
|
75
|
+
Opt-in `POST /voice/takes` (`config.voice_transcription = true`) wraps it for a
|
|
76
|
+
signed-in, CSRF-protected page: 10 MB and 120 s caps, an allow-list of audio
|
|
77
|
+
types, a refused no-speech transcript, and a 503 with a showable line when the
|
|
78
|
+
app has no `OPENAI_API_KEY`. The real adapter raises `LiveCallAttempted`
|
|
79
|
+
before any socket while `VOICE_NO_LIVE_CALLS=1` and in every Rails test
|
|
80
|
+
environment. Nothing is stored; `voice[audio]` and `voice[embedding]` join the
|
|
81
|
+
parameter filter. docs/VOICE_TRANSCRIPTION.md is the contract.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- The voice row on `/profile` (`studio/voice_profile`) runs on the shared meter
|
|
86
|
+
and recording format (`studio/level_meter`, `studio/voice_recorder`) instead
|
|
87
|
+
of its own copy; it still re-exports `SILENCE_RMS`, `meterLevel`,
|
|
88
|
+
`takeWasHeard`, `pickMimeType` and friends. Under reduced motion its ring now
|
|
89
|
+
steps in quarters, as the recorder's does.
|
|
90
|
+
|
|
7
91
|
## 0.120.0 — 2026-10-30
|
|
8
92
|
|
|
9
93
|
### Added
|
data/README.md
CHANGED
|
@@ -46,6 +46,8 @@ resolved.
|
|
|
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
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).
|
|
49
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).
|
|
50
52
|
|
|
51
53
|
## Tailwind
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
# RATE-LIMITED per user (Studio.voice_takes_rate_limit, 20 takes in 10
|
|
23
|
+
# minutes): over it, a JSON 429 {error, reason: "rate_limited", retry_after}
|
|
24
|
+
# and a Retry-After header, before anything is read or transcribed.
|
|
25
|
+
#
|
|
26
|
+
# NOTHING IS KEPT: the upload is read once, handed to the transcriber, and its
|
|
27
|
+
# tempfile is deleted before the answer; no audio or vector is stored or
|
|
28
|
+
# logged (voice[audio] and voice[embedding] are in the engine's parameter
|
|
29
|
+
# filter).
|
|
30
|
+
class VoiceTakesController < ::ApplicationController
|
|
31
|
+
before_action :require_authentication
|
|
32
|
+
protect_from_forgery with: :exception
|
|
33
|
+
before_action :no_store
|
|
34
|
+
before_action :limit_takes
|
|
35
|
+
|
|
36
|
+
CANT_HEAR = "I couldn't hear any words in that. Check your microphone and try again."
|
|
37
|
+
UNAVAILABLE = "I couldn't transcribe that just now. Try again in a moment."
|
|
38
|
+
TOO_MANY = "That's a lot of recordings in a short time. Wait a moment, then try again."
|
|
39
|
+
|
|
40
|
+
# The counter's fallback when the app's Rails.cache counts nothing (a
|
|
41
|
+
# NullStore, as many apps run in development): per process, so a limit
|
|
42
|
+
# still holds.
|
|
43
|
+
FALLBACK_STORE = ActiveSupport::Cache::MemoryStore.new(size: 4.megabytes)
|
|
44
|
+
|
|
45
|
+
def create
|
|
46
|
+
voice = params[:voice]
|
|
47
|
+
# A string (voice=abc) or an array is no take: refuse it, never index it.
|
|
48
|
+
voice = nil unless voice.is_a?(ActionController::Parameters)
|
|
49
|
+
audio = voice ? voice[:audio] : nil
|
|
50
|
+
return refuse("Record something first.") unless audio.respond_to?(:read) && audio.respond_to?(:size)
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
return refuse("That recording is too long to send.", 413) if audio.size > VoiceTranscription::MAX_BYTES
|
|
54
|
+
return refuse("That recording was empty.") if audio.size.zero?
|
|
55
|
+
|
|
56
|
+
duration = voice[:duration_ms].to_s
|
|
57
|
+
unless duration.match?(/\A\d{1,9}\z/) && duration.to_i.between?(1, VoiceTranscription::MAX_DURATION_MS)
|
|
58
|
+
return refuse("That recording is too long, or has no length.")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
mime = VoiceTranscription.mime_type(voice[:mime_type].presence ||
|
|
62
|
+
(audio.content_type if audio.respond_to?(:content_type)))
|
|
63
|
+
return refuse("That kind of recording isn't supported.", 415) if mime.nil?
|
|
64
|
+
|
|
65
|
+
heard = VoiceTranscription.hear(audio: audio, mime_type: mime, user: current_user,
|
|
66
|
+
embedding: voice[:embedding].presence, model: voice[:model].presence)
|
|
67
|
+
reason = VoiceTranscription.no_speech_reason(heard.text)
|
|
68
|
+
return refuse(CANT_HEAR, :unprocessable_entity, reason: "no_speech") if reason
|
|
69
|
+
|
|
70
|
+
render json: heard.to_h.merge("duration_ms" => duration.to_i), status: :ok
|
|
71
|
+
rescue VoiceTranscription::NotConfigured => e
|
|
72
|
+
capture(e)
|
|
73
|
+
refuse(VoiceTranscription::NOT_CONFIGURED_MESSAGE, :service_unavailable, reason: "not_configured")
|
|
74
|
+
rescue VoiceTranscription::HttpError => e
|
|
75
|
+
capture(e)
|
|
76
|
+
refuse(UNAVAILABLE, :service_unavailable, reason: "transcriber_failed", retry: true)
|
|
77
|
+
ensure
|
|
78
|
+
discard(audio)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# The per-user limit, a fixed window: the window's number is in the key, so
|
|
85
|
+
# its end (retry_after) is known exactly. Rails' own rate_limit macro fixes
|
|
86
|
+
# to:/within: when the class loads and counts nothing on a NullStore; this
|
|
87
|
+
# reads the config per request and never goes inert. It instruments the same
|
|
88
|
+
# rate_limit.action_controller event.
|
|
89
|
+
def limit_takes
|
|
90
|
+
limit = Studio.voice_takes_rate_limit
|
|
91
|
+
return unless limit && current_user
|
|
92
|
+
|
|
93
|
+
limit = limit.to_h.transform_keys(&:to_sym)
|
|
94
|
+
|
|
95
|
+
to = limit[:to].to_i
|
|
96
|
+
within = limit[:within].to_i
|
|
97
|
+
return unless to.positive? && within.positive?
|
|
98
|
+
|
|
99
|
+
epoch = Time.now.to_i
|
|
100
|
+
window = epoch / within
|
|
101
|
+
key = "rate-limit:#{controller_path}:#{current_user.id}:#{window}"
|
|
102
|
+
count = rate_limit_store(limit).increment(key, 1, expires_in: within)
|
|
103
|
+
return if count.nil? || count <= to
|
|
104
|
+
|
|
105
|
+
retry_after = within - (epoch % within)
|
|
106
|
+
ActiveSupport::Notifications.instrument("rate_limit.action_controller",
|
|
107
|
+
request: request, count: count, to: to, within: within,
|
|
108
|
+
by: current_user.id, name: nil, scope: controller_path, cache_key: key) do
|
|
109
|
+
response.headers["Retry-After"] = retry_after.to_s
|
|
110
|
+
refuse(TOO_MANY, :too_many_requests, reason: "rate_limited", retry_after: retry_after)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rate_limit_store(limit)
|
|
115
|
+
store = limit[:store] || (Rails.cache if defined?(Rails.cache))
|
|
116
|
+
store.nil? || store.is_a?(ActiveSupport::Cache::NullStore) ? FALLBACK_STORE : store
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def refuse(message, status = :unprocessable_entity, **extra)
|
|
120
|
+
render json: { error: message, **extra }, status: status
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def no_store
|
|
124
|
+
response.headers["Cache-Control"] = "no-store"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The upload's tempfile goes now, not at the next GC.
|
|
128
|
+
def discard(audio)
|
|
129
|
+
file = audio.respond_to?(:tempfile) ? audio.tempfile : nil
|
|
130
|
+
file.close! if file.respond_to?(:close!)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# An ErrorLog row on the user who sent the take. The error's message is the
|
|
136
|
+
# engine's own words (model, status, reason code), never the vendor's.
|
|
137
|
+
def capture(error)
|
|
138
|
+
return unless defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
139
|
+
|
|
140
|
+
log = ::ErrorLog.capture!(error)
|
|
141
|
+
log.update(target: current_user) if log.respond_to?(:target=) && current_user
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
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,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
|
+
}
|