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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 602092c1d8ddd3ce61cb6f192162ea1cae13af1313769742fa88d5f954327232
4
- data.tar.gz: 6a2ca70d13097ebfe23e52c7b14d551b430f92ef07da51bed9c1b80f1baa522c
3
+ metadata.gz: cd0f77b33c7833a6cb5061bc17b11baad3db5ad3b2e6ad232b31cc363d124e49
4
+ data.tar.gz: 942b24a25b2dbdb8d7e1af5a918c323718fcb2438eced8b46dd1593f1563d9a0
5
5
  SHA512:
6
- metadata.gz: 5e8a55b6e21888c67df80712062478ae4b635a4a95d2e062771ab00570f5cd9ba417601e05ab39f14791be16619c3780799d965d2c02491952386001dd6b6f18
7
- data.tar.gz: 7abef1323bfc65d590af8c5cef094d7136471e6f0356703b6a1fd73c1bb3137d76b70051b500f6ddab24c4d092d3812f3ae82e766123dbf728c15b28dbd410a4
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
+ }