import { getAttributeOrDefault, getAttributeOrThrow, parseInteger, } from "../lib/attribute"; import { encodeAnnotatedBuffer, encodePcmAsWav } from "../lib/codec"; const dropClasses = ["bg-yellow-100", "border-yellow-300"]; /** * A hook for client-preprocessed audio input. * * ## Configuration * * * `data-id` - a unique id * * * `data-phx-target` - the component to send the `"change"` event to * * * `data-format` - the desired audio format * * * `data-sampling-rate` - the audio sampling rate for * * * `data-endianness` - the server endianness, either `"little"` or `"big"` * * * `data-audio-url` - the URL to audio file to use for the current preview * */ const AudioInput = { mounted() { this.props = this.getProps(); this.inputEl = this.el.querySelector(`[data-input]`); this.audioEl = this.el.querySelector(`[data-preview]`); this.uploadButton = this.el.querySelector(`[data-btn-upload]`); this.recordButton = this.el.querySelector(`[data-btn-record]`); this.stopButton = this.el.querySelector(`[data-btn-stop]`); this.cancelButton = this.el.querySelector(`[data-btn-cancel]`); this.mediaRecorder = null; // Set the current value URL this.audioEl.src = this.props.audioUrl; // File selection this.uploadButton.addEventListener("click", (event) => { this.inputEl.click(); }); this.inputEl.addEventListener("change", (event) => { const [file] = event.target.files; file && this.loadFile(file); }); // Drag and drop this.el.addEventListener("dragover", (event) => { event.stopPropagation(); event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }); this.el.addEventListener("drop", (event) => { event.stopPropagation(); event.preventDefault(); const [file] = event.dataTransfer.files; file && !this.isRecording() && this.loadFile(file); }); this.el.addEventListener("dragenter", (event) => { this.el.classList.add(...dropClasses); }); this.el.addEventListener("dragleave", (event) => { if (!this.el.contains(event.relatedTarget)) { this.el.classList.remove(...dropClasses); } }); this.el.addEventListener("drop", (event) => { this.el.classList.remove(...dropClasses); }); // Microphone capture this.recordButton.addEventListener("click", (event) => { this.startRecording(); }); this.stopButton.addEventListener("click", (event) => { this.stopRecording(); }); this.cancelButton.addEventListener("click", (event) => { this.stopRecording(false); }); }, updated() { this.props = this.getProps(); this.audioEl.src = this.props.audioUrl; }, getProps() { return { id: getAttributeOrThrow(this.el, "data-id"), phxTarget: getAttributeOrThrow(this.el, "data-phx-target", parseInteger), samplingRate: getAttributeOrThrow( this.el, "data-sampling-rate", parseInteger ), endianness: getAttributeOrThrow(this.el, "data-endianness"), format: getAttributeOrThrow(this.el, "data-format"), audioUrl: getAttributeOrDefault(this.el, "data-audio-url", null), }; }, startRecording() { this.audioEl.classList.add("hidden"); this.uploadButton.classList.add("hidden"); this.recordButton.classList.add("hidden"); this.stopButton.classList.remove("hidden"); this.cancelButton.classList.remove("hidden"); this.audioChunks = []; navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { this.mediaRecorder = new MediaRecorder(stream); this.mediaRecorder.addEventListener("dataavailable", (event) => { this.audioChunks.push(event.data); }); this.mediaRecorder.start(); }); }, stopRecording(load = true) { this.audioEl.classList.remove("hidden"); this.uploadButton.classList.remove("hidden"); this.recordButton.classList.remove("hidden"); this.stopButton.classList.add("hidden"); this.cancelButton.classList.add("hidden"); if (load) { this.mediaRecorder.addEventListener("stop", (event) => { const audioBlob = new Blob(this.audioChunks); audioBlob.arrayBuffer().then((buffer) => { this.loadEncodedAudio(buffer); }); }); } this.mediaRecorder.stop(); }, isRecording() { return this.mediaRecorder && this.mediaRecorder.state === "recording"; }, loadFile(file) { const reader = new FileReader(); reader.onload = (readerEvent) => { this.loadEncodedAudio(readerEvent.target.result); }; reader.readAsArrayBuffer(file); }, loadEncodedAudio(buffer) { this.pushEventTo(this.props.phxTarget, "decoding", {}); const context = new AudioContext({ sampleRate: this.props.samplingRate }); context.decodeAudioData(buffer, (audioBuffer) => { const audioInfo = audioBufferToAudioInfo(audioBuffer); this.pushAudio(audioInfo); }); }, pushAudio(audioInfo) { const meta = { num_channels: audioInfo.numChannels, sampling_rate: audioInfo.samplingRate, }; const buffer = this.encodeAudio(audioInfo); const blob = new Blob([buffer]); blob.meta = () => meta; this.uploadTo(this.props.phxTarget, "file", [blob]); }, encodeAudio(audioInfo) { if (this.props.format === "pcm_f32") { return convertEndianness32(audioInfo.data, this.props.endianness); } else if (this.props.format === "wav") { return encodePcmAsWav( audioInfo.data, audioInfo.numChannels, audioInfo.samplingRate ); } }, }; function audioBufferToAudioInfo(audioBuffer) { const numChannels = audioBuffer.numberOfChannels; const samplingRate = audioBuffer.sampleRate; const length = audioBuffer.length; const size = 4 * numChannels * length; const buffer = new ArrayBuffer(size); const pcmArray = new Float32Array(buffer); for (let channelIdx = 0; channelIdx < numChannels; channelIdx++) { const channelArray = audioBuffer.getChannelData(channelIdx); for (let i = 0; i < channelArray.length; i++) { pcmArray[numChannels * i + channelIdx] = channelArray[i]; } } return { data: pcmArray.buffer, numChannels, samplingRate }; } function convertEndianness32(buffer, targetEndianness) { if (getEndianness() === targetEndianness) { return buffer; } // If the server uses different endianness, we swap bytes accordingly for (let i = 0; i < buffer.byteLength / 4; i++) { const b1 = buffer[i]; const b2 = buffer[i + 1]; const b3 = buffer[i + 2]; const b4 = buffer[i + 3]; buffer[i] = b4; buffer[i + 1] = b3; buffer[i + 2] = b2; buffer[i + 3] = b1; } return buffer; } function getEndianness() { const buffer = new ArrayBuffer(2); const int16Array = new Uint16Array(buffer); const int8Array = new Uint8Array(buffer); int16Array[0] = 1; if (int8Array[0] === 1) { return "little"; } else { return "big"; } } export default AudioInput;