Files
elixirAI/assets/js/voice_control.js
Alex Mickelson 6fc4a686f8
Some checks failed
CI/CD Pipeline / build (push) Failing after 4s
can transcribe in the ui
2026-03-20 14:49:59 -06:00

165 lines
4.9 KiB
JavaScript

const VoiceControl = {
mounted() {
this._mediaRecorder = null;
this._chunks = [];
this._recording = false;
this._audioCtx = null;
this._analyser = null;
this._animFrame = null;
this._onKeyDown = (e) => {
// Ctrl+Space → start
if (e.ctrlKey && e.code === "Space" && !this._recording) {
e.preventDefault();
this.startRecording();
// Space alone → stop (prevent page scroll while recording)
} else if (
e.code === "Space" &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
this._recording
) {
e.preventDefault();
this.stopRecording();
}
};
window.addEventListener("keydown", this._onKeyDown);
// Button clicks dispatch DOM events to avoid a server round-trip
this.el.addEventListener("voice:start", () => this.startRecording());
this.el.addEventListener("voice:stop", () => this.stopRecording());
},
destroyed() {
window.removeEventListener("keydown", this._onKeyDown);
this._stopVisualization();
if (this._mediaRecorder && this._recording) {
this._mediaRecorder.stop();
}
},
_startVisualization(stream) {
this._audioCtx = new AudioContext();
this._analyser = this._audioCtx.createAnalyser();
// 64 bins gives a clean bar chart without being too dense
this._analyser.fftSize = 64;
this._analyser.smoothingTimeConstant = 0.75;
const source = this._audioCtx.createMediaStreamSource(stream);
source.connect(this._analyser);
const bufferLength = this._analyser.frequencyBinCount; // 32
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
this._animFrame = requestAnimationFrame(draw);
const canvas = document.getElementById("voice-viz-canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
// Sync pixel buffer to CSS display size
const displayWidth = canvas.offsetWidth;
const displayHeight = canvas.offsetHeight;
if (canvas.width !== displayWidth) canvas.width = displayWidth;
if (canvas.height !== displayHeight) canvas.height = displayHeight;
this._analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const totalBars = bufferLength;
const barWidth = (canvas.width / totalBars) * 0.7;
const gap = canvas.width / totalBars - barWidth;
const radius = Math.max(2, barWidth / 4);
for (let i = 0; i < totalBars; i++) {
const value = dataArray[i] / 255;
const barHeight = Math.max(4, value * canvas.height);
const x = i * (barWidth + gap) + gap / 2;
const y = canvas.height - barHeight;
// Cyan at low amplitude → teal → green at high amplitude
const hue = 185 - value * 80;
const lightness = 40 + value * 25;
ctx.fillStyle = `hsl(${hue}, 90%, ${lightness}%)`;
ctx.beginPath();
ctx.roundRect(x, y, barWidth, barHeight, radius);
ctx.fill();
}
};
draw();
},
_stopVisualization() {
if (this._animFrame) {
cancelAnimationFrame(this._animFrame);
this._animFrame = null;
}
if (this._audioCtx) {
this._audioCtx.close();
this._audioCtx = null;
this._analyser = null;
}
},
async startRecording() {
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
console.error(
"VoiceControl: microphone access denied or unavailable",
err,
);
this.pushEvent("recording_error", { reason: err.message });
return;
}
this._chunks = [];
this._mediaRecorder = new MediaRecorder(stream);
this._mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this._chunks.push(e.data);
};
this._mediaRecorder.onstop = () => {
const mimeType = this._mediaRecorder.mimeType;
const blob = new Blob(this._chunks, { type: mimeType });
const reader = new FileReader();
reader.onloadend = () => {
// reader.result is "data:<mime>;base64,<data>" — strip the prefix
const base64 = reader.result.split(",")[1];
this.pushEvent("audio_recorded", { data: base64, mime_type: mimeType });
};
reader.readAsDataURL(blob);
// Release the microphone indicator in the OS browser tab
stream.getTracks().forEach((t) => t.stop());
this._stopVisualization();
this._recording = false;
};
this._mediaRecorder.start();
this._recording = true;
this.pushEvent("recording_started", {});
// Defer visualization start by one tick so LiveView has rendered the canvas
setTimeout(() => this._startVisualization(stream), 50);
},
stopRecording() {
if (this._mediaRecorder && this._mediaRecorder.state !== "inactive") {
this._mediaRecorder.stop();
// _recording flipped to false inside onstop after blob is ready
}
},
};
export { VoiceControl };