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.