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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 327664450f0c0b1de0a01b75231b2f2fa470ab66d865f3b585eb55281e3318d3
4
- data.tar.gz: 0634a4d5e519e0a808558cd3341d192ab7c0162373ad46d376e6cd6753a1f432
3
+ metadata.gz: 161d3e00f32dba8b6c36d769228b39815a79b85a702d76b80f89be4c4365b876
4
+ data.tar.gz: a56e3fe6902119b4e5ecba61c77347a95893c04c3d8a5543833246b0a674c38f
5
5
  SHA512:
6
- metadata.gz: 75317c813644a660eb548ddd2ee7727371b7df3520867ef0037449a7d68bb57a72c5e0c87eb767b56426459f627809f17dd2dacd8d56baf9ffd41226337c7368
7
- data.tar.gz: ba9ee2c20c1edf6659186b89a30a44a80c5cc3e4c4afd3b44910bdc934922c82d0250995996746b8ecc12976690d6ca5f8880dcae6f1caab7a149471a8808307
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
+ }