PageSourceSearch

https://my.broadstreetads.com/assets/controllers/alva_voice_contro…c74351f50920fe96614ec22caa506958bdfe8d1c.js

js broadstreetads.com collected 2026-10-02 02:27:35 UTC 14,083 bytes, 451 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Microphone capture for the Alva PWA.
4//
5// Records with MediaRecorder, feeds live amplitudes to the waveforms, POSTs the
6// blob to /app/transcribe, then drops the returned text into the existing chat
7// composer and submits it. Submitting the real form is deliberate: it reuses the
8// dashboard's entire turbo_stream + ActionCable path, so there is no PWA-specific
9// send code to keep in sync.
10//
11// There can be two waveforms — the orb on the stage and the strip inside the
12// composer — and the composer one is replaced by a turbo stream after every
13// send. They are reached through a Stimulus outlet rather than a cached query so
14// a freshly connected strip is picked up, and told the current mode, on arrival.
15
16// First supported wins. audio/mp4 is the iOS Safari path; everything else gets
17// Opus in WebM.
18const MIME_CANDIDATES = [
19  "audio/webm;codecs=opus",
20  "audio/webm",
21  "audio/mp4",
22  ""
23]
24
25const BARS = 28 // must match alva_waveform_controller
26const MAX_BYTES = 8 * 1024 * 1024
27const TICK_MS = 250
28
29export default class extends Controller {
30  static outlets = ["alva-waveform"]
31  static values = {
32    transcribeUrl: String,
33    maxSeconds: { type: Number, default: 30 }
34  }
35
36  connect() {
37    this.state = "idle"
38    this.starting = false
39    this.chunks = []
40    this.supported = this.detectSupport()
41
42    this.onVisibilityChange = () => this.hidden()
43    document.addEventListener("visibilitychange", this.onVisibilityChange)
44
45    if (!this.supported) {
46      // Typing still works; only the voice controls are pointless. The message
47      // matters — without it the shell had nothing to show and the mic sat there
48      // looking tappable while every tap silently did nothing.
49      this.element.dataset.alvaVoiceUnsupported = "true"
50      this.dispatch("state", {
51        detail: { state: "unsupported", message: "This browser can't record audio. Type instead." }
52      })
53    }
54  }
55
56  disconnect() {
57    document.removeEventListener("visibilitychange", this.onVisibilityChange)
58    this.clearLimit()
59    this.teardownAudio()
60  }
61
62  alvaWaveformOutletConnected(waveform) {
63    waveform.setMode(this.waveformMode())
64  }
65
66  detectSupport() {
67    return !!(navigator.mediaDevices &&
68              navigator.mediaDevices.getUserMedia &&
69              window.MediaRecorder)
70  }
71
72  toggle() {
73    if (!this.supported) return
74    // state stays "idle" across the getUserMedia await, so without this a second
75    // tap during the permission round-trip started a second capture, overwrote
76    // this.stream, and left the first set of tracks running — mic indicator stuck
77    // on, two stop handlers racing.
78    if (this.starting) return
79
80    if (this.state === "listening") {
81      this.stop()
82    } else if (this.state === "idle") {
83      this.start()
84    }
85    // Mid-transcription taps are ignored on purpose — the recording is already
86    // in flight and there is nothing useful to cancel.
87  }
88
89  // Throws the recording away. Nothing is transcribed and nothing is sent — the
90  // one exit from listening that doesn't end in a message, which matters for an
91  // agent that can change live campaigns.
92  cancel() {
93    if (this.state !== "listening") return
94
95    this.discard()
96    this.setState("idle")
97  }
98
99  // A backgrounded PWA can't be trusted to keep recording (iOS suspends the audio
100  // session outright), and sending whatever was captured is the wrong default.
101  hidden() {
102    if (document.visibilityState !== "hidden" || this.state !== "listening") return
103
104    this.discard()
105    this.setState("error", "Recording discarded — Alva went to the background")
106    this.setState("idle")
107  }
108
109  // ---- recording ----
110
111  async start() {
112    // Created before the await, while we're still inside the user gesture. Safari
113    // only allows an AudioContext to start running when constructed during a
114    // gesture, and awaiting getUserMedia ends that window — build it afterwards
115    // and it stays suspended, leaving the waveform flat while recording works
116    // fine. resume() in startAnalyser() covers browsers that suspend it anyway.
117    this.prepareAudioContext()
118
119    // Held for the whole async setup, not just the await — toggle() checks it, so
120    // a second tap can't open a second capture behind this one.
121    this.starting = true
122
123    let stream
124    try {
125      stream = await navigator.mediaDevices.getUserMedia({
126        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
127      })
128    } catch (error) {
129      this.starting = false
130      this.fail(error && error.name === "NotAllowedError"
131        ? "Microphone blocked — allow it in your browser settings"
132        : "Microphone unavailable")
133      return
134    }
135
136    // Assigned only once we own it, so a stream can never be orphaned by being
137    // overwritten before teardownAudio() gets a chance to stop its tracks.
138    this.stream = stream
139    this.chunks = []
140
141    let recorder
142    try {
143      recorder = new MediaRecorder(this.stream, this.recorderOptions())
144    } catch (error) {
145      this.starting = false
146      this.fail("Can't record on this device")
147      return
148    }
149
150    // Both handlers check they still belong to the live recorder. Stopping the
151    // tracks on cancel makes MediaRecorder emit its final dataavailable and stop
152    // regardless, and without the check a cancelled clip would be transcribed
153    // and sent as though the tap had been "stop".
154    recorder.addEventListener("dataavailable", (event) => {
155      if (this.recorder !== recorder) return
156      if (event.data && event.data.size > 0) this.chunks.push(event.data)
157    })
158    recorder.addEventListener("stop", () => {
159      if (this.recorder !== recorder) return
160      this.finish(recorder.mimeType)
161    })
162
163    this.recorder = recorder
164    this.recorder.start()
165    this.startAnalyser()
166    this.starting = false
167    this.setState("listening")
168
169    this.startedAt = performance.now()
170    this.tick()
171    this.ticker = setInterval(() => this.tick(), TICK_MS)
172    this.limit = setTimeout(() => this.stop(), this.maxSecondsValue * 1000)
173  }
174
175  recorderOptions() {
176    const supported = MIME_CANDIDATES.find((type) => (
177      type === "" ||
178      (window.MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(type))
179    ))
180
181    return supported ? { mimeType: supported } : {}
182  }
183
184  stop() {
185    this.clearLimit()
186
187    if (!this.recorder) {
188      this.teardownAudio()
189      this.setState("idle")
190    } else if (this.recorder.state !== "inactive") {
191      this.recorder.stop() // finish() runs from the stop event
192    }
193    // Recorder set but inactive: stop was already requested and the stop event is
194    // still flushing. Tearing down here would null the recorder and make the
195    // identity check skip finish(), silently dropping the clip on a double-tap.
196  }
197
198  discard() {
199    this.clearLimit()
200
201    const recorder = this.recorder
202    // Disowned before the tracks stop, so the stop event that follows is ignored.
203    this.recorder = null
204    this.chunks = []
205    this.teardownAudio()
206
207    if (recorder && recorder.state !== "inactive") {
208      try { recorder.stop() } catch (error) { /* already stopping */ }
209    }
210  }
211
212  async finish(mimeType) {
213    const type = mimeType || "audio/webm"
214    const blob = new Blob(this.chunks, { type })
215    this.chunks = []
216    this.teardownAudio()
217
218    if (blob.size === 0) {
219      this.setState("idle")
220      return
221    }
222
223    if (blob.size > MAX_BYTES) {
224      this.fail("That recording was too long")
225      return
226    }
227
228    this.setState("transcribing")
229
230    let text
231    try {
232      text = await this.transcribe(blob, type)
233    } catch (error) {
234      this.fail(error.userMessage || "Alva couldn't hear that")
235      return
236    }
237
238    if (!text) {
239      // Silence, a mistap, or pocket noise. Say so and send nothing — this is an
240      // agent that can change live campaigns.
241      this.fail("Didn't catch that")
242      return
243    }
244
245    this.send(text)
246    this.setState("idle")
247  }
248
249  async transcribe(blob, type) {
250    const body = new FormData()
251    body.append("audio", blob, `clip.${this.extensionFor(type)}`)
252
253    const response = await fetch(this.transcribeUrlValue, {
254      method: "POST",
255      body,
256      headers: { "X-CSRF-Token": this.csrfToken(), Accept: "application/json" }
257    })
258
259    if (!response.ok) {
260      // The server's reason ("Too many voice requests", "That recording couldn't
261      // be used") is written for the user; keep it.
262      const error = new Error(`transcribe failed: ${response.status}`)
263      error.userMessage = await this.errorMessage(response)
264      throw error
265    }
266
267    const payload = await response.json()
268    return (payload.text || "").trim()
269  }
270
271  async errorMessage(response) {
272    try {
273      const payload = await response.json()
274      return typeof payload.error === "string" ? payload.error : null
275    } catch (error) {
276      return null // an HTML error page, or nothing at all
277    }
278  }
279
280  extensionFor(type) {
281    if (type.includes("mp4") || type.includes("m4a")) return "m4a"
282    if (type.includes("ogg")) return "ogg"
283    return "webm"
284  }
285
286  csrfToken() {
287    const meta = document.querySelector("meta[name='csrf-token']")
288    return meta ? meta.content : ""
289  }
290
291  // ---- sending ----
292
293  // The composer is re-rendered by create.turbo_stream.haml after every send, so
294  // it is re-queried each time rather than cached.
295  send(text) {
296    const wrapper = document.getElementById("agent-chat-form-pwa")
297    const form = wrapper && wrapper.querySelector("form")
298    const input = form && form.querySelector("[name='message']")
299
300    if (!form || !input) return
301
302    input.value = text
303    // The same path as pressing Enter in the dashboard composer, so agent-form's
304    // submit handler and the turbo_stream response both behave identically.
305    form.requestSubmit()
306  }
307
308  // ---- timing ----
309
310  // Elapsed and remaining seconds for the composer's countdown. Its own interval,
311  // not the analyser's rAF: metering is optional and may not be running at all.
312  tick() {
313    const elapsed = (performance.now() - this.startedAt) / 1000
314    this.dispatch("tick", {
315      detail: { elapsed, remaining: Math.max(0, this.maxSecondsValue - elapsed) }
316    })
317  }
318
319  clearLimit() {
320    if (this.limit) {
321      clearTimeout(this.limit)
322      this.limit = null
323    }
324    if (this.ticker) {
325      clearInterval(this.ticker)
326      this.ticker = null
327    }
328  }
329
330  // ---- level metering ----
331
332  prepareAudioContext() {
333    const AudioContextClass = window.AudioContext || window.webkitAudioContext
334    if (!AudioContextClass || this.audioCtx) return
335
336    try {
337      this.audioCtx = new AudioContextClass()
338    } catch (error) {
339      this.audioCtx = null // metering is optional; recording is not
340    }
341  }
342
343  // Level metering is decorative — the recording is the point. Anything thrown in
344  // here (createMediaStreamSource rejects a stream with no audio track, some
345  // browsers throw on a suspended context) must not escape: it runs after
346  // recorder.start(), so an exception would leave `starting` stuck true and the
347  // mic permanently unresponsive while a recording was actually in progress.
348  startAnalyser() {
349    if (!this.audioCtx) return
350
351    try {
352      if (this.audioCtx.state === "suspended") this.audioCtx.resume()
353
354      this.source = this.audioCtx.createMediaStreamSource(this.stream)
355      this.analyser = this.audioCtx.createAnalyser()
356      this.analyser.fftSize = 2048
357      this.source.connect(this.analyser)
358
359      this.buffer = new Uint8Array(this.analyser.fftSize)
360      this.sample = this.sample.bind(this)
361      this.sampleRaf = requestAnimationFrame(this.sample)
362    } catch (error) {
363      // Record without a waveform rather than not at all.
364      this.analyser = null
365      this.source = null
366    }
367  }
368
369  // Time-domain data folded into BARS buckets, peak per bucket rather than RMS —
370  // peak tracks speech transients far more legibly.
371  sample() {
372    if (!this.analyser) return
373
374    this.analyser.getByteTimeDomainData(this.buffer)
375
376    const perBar = Math.floor(this.buffer.length / BARS)
377    const levels = new Array(BARS)
378
379    for (let i = 0; i < BARS; i++) {
380      let peak = 0
381      const start = i * perBar
382
383      for (let j = 0; j < perBar; j++) {
384        const deviation = Math.abs(this.buffer[start + j] - 128) / 128
385        if (deviation > peak) peak = deviation
386      }
387
388      // Lifts quiet speech into a visible range without clipping loud speech.
389      levels[i] = Math.min(1, Math.pow(peak, 0.65) * 1.6)
390    }
391
392    for (const waveform of this.alvaWaveformOutlets) waveform.setLevels(levels)
393
394    this.sampleRaf = requestAnimationFrame(this.sample)
395  }
396
397  // MediaRecorder.stop() alone leaves the browser's recording indicator lit. Every
398  // track has to be stopped and the AudioContext closed, or the user is left
399  // believing we're still listening.
400  teardownAudio() {
401    this.starting = false
402
403    if (this.sampleRaf) {
404      cancelAnimationFrame(this.sampleRaf)
405      this.sampleRaf = null
406    }
407
408    if (this.stream) {
409      this.stream.getTracks().forEach((track) => track.stop())
410      this.stream = null
411    }
412
413    if (this.source) {
414      try { this.source.disconnect() } catch (error) { /* already gone */ }
415      this.source = null
416    }
417
418    if (this.audioCtx) {
419      try { this.audioCtx.close() } catch (error) { /* already closed */ }
420      this.audioCtx = null
421    }
422
423    this.analyser = null
424    this.recorder = null
425  }
426
427  // ---- state ----
428
429  fail(message) {
430    this.teardownAudio()
431    this.setState("error", message)
432    this.setState("idle")
433  }
434
435  setState(state, message) {
436    this.state = state === "error" ? this.state : state
437
438    if (state !== "error") {
439      for (const waveform of this.alvaWaveformOutlets) {
440        if (state === "idle") waveform.reset()
441        else waveform.setMode(state)
442      }
443    }
444
445    this.dispatch("state", { detail: { state, message } })
446  }
447
448  waveformMode() {
449    return this.state === "listening" || this.state === "transcribing" ? this.state : "idle"
450  }
451};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.