UNPKG

angular-voice

Version:

Angular Voice Recorder, a standalone Angular component for recording audio with live preview and glassy UI.

343 lines (338 loc) 22.4 kB
import * as i0 from '@angular/core'; import { Injectable, inject, model, input, signal, computed, EventEmitter, Output, Component } from '@angular/core'; /** * AudioRecorderService * -------------------- * This service lets you record audio from the user's microphone. * It supports two formats: * - WEBM (default, smaller file size, better quality) * - WAV (fallback, bigger files but works everywhere) * * How it works: * 1. Ask the browser for microphone access. * 2. Start recording using MediaRecorder (WEBM) or AudioWorklet (WAV). * 3. Stop recording and return the audio as File + Blob. */ class AudioRecorderService { // MediaRecorder path (for webm) mediaStream; mediaRecorder; chunks = []; // WAV path (AudioWorklet) audioCtx; sourceNode; workletNode; pcmBuffers = []; sampleRate = 48000; startTs = 0; /** Quick check: are we in a browser with mic support? */ get isBrowser() { return typeof window !== 'undefined' && !!navigator?.mediaDevices; } /** Ask the user for microphone permission (runs once) */ async prepare() { if (!this.isBrowser) return; if (this.mediaStream) return; // already prepared this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } /** * Start recording * @param format 'webm' (default) or 'wav' * @param desiredSampleRate optional sample rate (e.g., 44100) */ async start(format = 'webm', desiredSampleRate) { if (!this.isBrowser) throw new Error('Recording only works in browser.'); await this.prepare(); this.chunks = []; this.pcmBuffers = []; this.startTs = performance.now(); // --- Case 1: WEBM (MediaRecorder, smaller, modern) --- if (format === 'webm' && this.supportsMediaRecorder('audio/webm')) { const mimeType = this.pickWebmMime(); this.mediaRecorder = new MediaRecorder(this.mediaStream, { mimeType }); this.mediaRecorder.ondataavailable = (e) => { if (e.data?.size) this.chunks.push(e.data); }; this.mediaRecorder.start(); return; } // --- Case 2: WAV (AudioWorklet, fallback) --- this.audioCtx = new AudioContext({ sampleRate: desiredSampleRate ?? undefined }); this.sampleRate = this.audioCtx.sampleRate; // Build a tiny recorder processor dynamically const workletCode = ` class RecorderProcessor extends AudioWorkletProcessor { process(inputs) { const input = inputs[0]; if (input && input[0]) { this.port.postMessage(input[0]); // send raw PCM data } return true; } } registerProcessor('recorder-processor', RecorderProcessor); `; const blob = new Blob([workletCode], { type: 'application/javascript' }); const url = URL.createObjectURL(blob); await this.audioCtx.audioWorklet.addModule(url); this.sourceNode = this.audioCtx.createMediaStreamSource(this.mediaStream); this.workletNode = new AudioWorkletNode(this.audioCtx, 'recorder-processor'); this.workletNode.port.onmessage = (event) => { // Copy the audio data so it’s safe this.pcmBuffers.push(new Float32Array(event.data)); }; this.sourceNode.connect(this.workletNode); this.workletNode.connect(this.audioCtx.destination); } /** * Stop recording and return the result */ async stop(format = 'webm') { const durationMs = performance.now() - this.startTs; // --- WEBM path --- if (format === 'webm' && this.mediaRecorder) { const recorder = this.mediaRecorder; const stopPromise = new Promise((resolve) => { recorder.onstop = () => resolve(); }); if (recorder.state !== 'inactive') recorder.stop(); await stopPromise; const blob = new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' }); const file = new File([blob], `recording-${Date.now()}.webm`, { type: blob.type }); this.cleanupMediaRecorder(); return { blob, file, durationMs, mimeType: blob.type }; } // --- WAV path --- const wavBlob = this.encodeWavFromPcm(this.pcmBuffers, this.sampleRate); const file = new File([wavBlob], `recording-${Date.now()}.wav`, { type: 'audio/wav' }); this.cleanupWav(); return { blob: wavBlob, file, durationMs, mimeType: 'audio/wav' }; } /** Pause recording (if supported) */ pause() { if (this.mediaRecorder && this.mediaRecorder.state === 'recording') { this.mediaRecorder.pause?.(); } if (this.workletNode) { this.workletNode.port.onmessage = null; // stop collecting } } /** Resume recording (if supported) */ resume() { if (this.mediaRecorder && this.mediaRecorder.state === 'paused') { this.mediaRecorder.resume?.(); } if (this.workletNode) { this.workletNode.port.onmessage = (event) => { this.pcmBuffers.push(new Float32Array(event.data)); }; } } /** --- Helpers below --- */ /** Check if MediaRecorder supports a mime type */ supportsMediaRecorder(mime) { return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(mime); } /** Pick the best WEBM mime type available */ pickWebmMime() { const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/webm;codecs=pcm', ]; for (const c of candidates) if (this.supportsMediaRecorder(c)) return c; return 'audio/webm'; } /** Cleanup for webm recorder */ cleanupMediaRecorder() { this.mediaRecorder = undefined; this.chunks = []; } /** Cleanup for wav recorder */ cleanupWav() { try { this.workletNode?.disconnect(); this.sourceNode?.disconnect(); this.audioCtx?.close(); } catch { } this.workletNode = undefined; this.sourceNode = undefined; this.audioCtx = undefined; this.pcmBuffers = []; } /** * Convert PCM float audio to a WAV Blob (mono, 16-bit PCM) */ encodeWavFromPcm(buffers, sampleRate) { // Join all audio chunks into one array const totalLength = buffers.reduce((acc, b) => acc + b.length, 0); const interleaved = new Float32Array(totalLength); let offset = 0; for (const b of buffers) { interleaved.set(b, offset); offset += b.length; } // Convert to 16-bit PCM const pcm16 = new Int16Array(interleaved.length); for (let i = 0; i < interleaved.length; i++) { const s = Math.max(-1, Math.min(1, interleaved[i])); pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } // Build WAV file header const blockAlign = 1 * 16 / 8; const byteRate = sampleRate * blockAlign; const dataSize = pcm16.length * 2; const buffer = new ArrayBuffer(44 + dataSize); const view = new DataView(buffer); let p = 0; const writeStr = (s) => { for (let i = 0; i < s.length; i++) view.setUint8(p++, s.charCodeAt(i)); }; const write16 = (v) => { view.setUint16(p, v, true); p += 2; }; const write32 = (v) => { view.setUint32(p, v, true); p += 4; }; writeStr('RIFF'); write32(36 + dataSize); writeStr('WAVE'); writeStr('fmt '); write32(16); write16(1); write16(1); write32(sampleRate); write32(byteRate); write16(blockAlign); write16(16); writeStr('data'); write32(dataSize); // PCM samples let idx = 44; const u8 = new Uint8Array(buffer); for (let i = 0; i < pcm16.length; i++, idx += 2) { u8[idx] = pcm16[i] & 0xff; u8[idx + 1] = (pcm16[i] >> 8) & 0xff; } return new Blob([buffer], { type: 'audio/wav' }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AudioRecorderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AudioRecorderService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AudioRecorderService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * AngularVoice Component * ---------------------- * A reusable audio recorder UI + logic component. * Handles recording, stopping, previewing, and sending audio files. */ class AngularVoice { // Service that actually records audio recorder = inject(AudioRecorderService); // Keeps track of the last blob URL we created (so we can clean it up) previousUrl = null; // The recorded file once we have one activeRecordingFile = null; // Whether preview mode is shown previewRecord = model(false, ...(ngDevMode ? [{ debugName: "previewRecord" }] : [])); // Whether recording is active recording = model(false, ...(ngDevMode ? [{ debugName: "recording" }] : [])); // Inputs to customize the component displayBtnsLabels = input(true, ...(ngDevMode ? [{ debugName: "displayBtnsLabels" }] : [])); // show/hide button labels startRecordingBtnLabel = input('Start recording', ...(ngDevMode ? [{ debugName: "startRecordingBtnLabel" }] : [])); recordingBtnLabel = input('Recording...', ...(ngDevMode ? [{ debugName: "recordingBtnLabel" }] : [])); startBtnClass = input('', ...(ngDevMode ? [{ debugName: "startBtnClass" }] : [])); // extra CSS classes for "start" button recordingBtnClass = input('', ...(ngDevMode ? [{ debugName: "recordingBtnClass" }] : [])); // extra CSS classes for "recording" button // Holds the current recording result (blob + file) result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : [])); // Computed signal: returns an object URL for the current recording blob audioUrl = computed(() => { const blob = this.result()?.blob; // If there’s no blob, revoke old URL and return null if (!blob) { if (this.previousUrl) { URL.revokeObjectURL(this.previousUrl); this.previousUrl = null; } return null; } // Revoke old URL and create a new one for the new blob if (this.previousUrl) { URL.revokeObjectURL(this.previousUrl); } this.previousUrl = URL.createObjectURL(blob); return this.previousUrl; }, ...(ngDevMode ? [{ debugName: "audioUrl" }] : [])); // Event emitter: lets parent components know when recording is done recordingCompleted = new EventEmitter(); /** * Starts or stops recording depending on the current state. */ async toggleRecord() { // Case 1: Not recording yet → start recording if (!this.recording() && !this.result()) { this.result.set(null); await this.recorder.start('webm'); this.recording.set(true); this.recordingCompleted.emit(null); // notify parent that recording started } // Case 2: Already recording → stop and save else if (this.recording()) { const result = await this.recorder.stop('webm'); this.recording.set(false); if (result) { this.result.set(result); await this.send(); } } } /** * Sends the recorded file to parent component via event emitter. */ async send() { const result = this.result(); if (!result) return; this.recording.set(false); this.activeRecordingFile = result.file; // Notify parent that recording finished with a file this.recordingCompleted.emit(result.file); } /** * Cancels recording and resets the component state. */ discard() { // If recording is still running, stop it if (this.recording()) { this.toggleRecord(); } // Clean up blob URL to prevent memory leaks const url = this.audioUrl(); if (url) { URL.revokeObjectURL(url); } // Reset everything this.result.set(null); this.previewRecord.set(false); this.activeRecordingFile = null; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AngularVoice, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: AngularVoice, isStandalone: true, selector: "angular-voice", inputs: { previewRecord: { classPropertyName: "previewRecord", publicName: "previewRecord", isSignal: true, isRequired: false, transformFunction: null }, recording: { classPropertyName: "recording", publicName: "recording", isSignal: true, isRequired: false, transformFunction: null }, displayBtnsLabels: { classPropertyName: "displayBtnsLabels", publicName: "displayBtnsLabels", isSignal: true, isRequired: false, transformFunction: null }, startRecordingBtnLabel: { classPropertyName: "startRecordingBtnLabel", publicName: "startRecordingBtnLabel", isSignal: true, isRequired: false, transformFunction: null }, recordingBtnLabel: { classPropertyName: "recordingBtnLabel", publicName: "recordingBtnLabel", isSignal: true, isRequired: false, transformFunction: null }, startBtnClass: { classPropertyName: "startBtnClass", publicName: "startBtnClass", isSignal: true, isRequired: false, transformFunction: null }, recordingBtnClass: { classPropertyName: "recordingBtnClass", publicName: "recordingBtnClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { previewRecord: "previewRecordChange", recording: "recordingChange", recordingCompleted: "recordingCompleted" }, ngImport: i0, template: "@if (!recording() && !result()) {\n<button [class]=\"startBtnClass() ? startBtnClass() : 'glassy-btn'\" (click)=\"toggleRecord()\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-mic\"\n viewBox=\"0 0 16 16\">\n <path\n d=\"M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5\" />\n <path d=\"M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3\" />\n </svg>\n\n @if(displayBtnsLabels()){\n {{startRecordingBtnLabel()}}\n }\n</button>\n} @else if(recording()) {\n<button [class]=\"recordingBtnClass() ? recordingBtnClass() : 'glassy-btn recording'\" (click)=\"toggleRecord()\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-soundwave\"\n viewBox=\"0 0 16 16\">\n <path fill-rule=\"evenodd\"\n d=\"M8.5 2a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-1 0v-11a.5.5 0 0 1 .5-.5m-2 2a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5m4 0a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5m-6 1.5A.5.5 0 0 1 5 6v4a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m8 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m-10 1A.5.5 0 0 1 3 7v2a.5.5 0 0 1-1 0V7a.5.5 0 0 1 .5-.5m12 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0V7a.5.5 0 0 1 .5-.5\" />\n </svg>\n\n @if(displayBtnsLabels()){\n {{recordingBtnLabel()}}\n }\n</button>\n} @else if(!recording() && result()) {\n<div class=\"preview-record glassy-card\">\n @if (audioUrl()) {\n <div class=\"record-info\">\n <span class=\"file-name\">{{ activeRecordingFile?.name }}</span>\n <audio controls class=\"record-player\">\n <source [src]=\"audioUrl()\" type=\"audio/wav\" />\n Your browser does not support the audio element.\n </audio>\n </div>\n }\n</div>\n}", styles: [".preview-record{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px;border-radius:14px;background:#ffffff0f;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1.5px solid rgba(255,255,255,.1);margin-top:12px}.preview-record .record-info{flex:1;display:flex;flex-direction:column;gap:6px}.preview-record .file-name{font-size:14px;font-weight:500;color:#e0e0e0}.preview-record .record-player{width:100%;max-width:320px}.preview-record .record-actions{display:flex;align-items:center;gap:10px}.glassy-btn{font-size:15px;font-weight:500;position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border-radius:12px;background:#ffffff14;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1.5px solid transparent;background-image:linear-gradient(#ffffff1a,#ffffff1a),linear-gradient(90deg,#00f5ff,#ff00d4);background-origin:border-box;background-clip:content-box,border-box;color:#fff;cursor:pointer;transition:all .3s ease;box-shadow:0 2px 10px #00000040}.glassy-btn:hover{background:#ffffff26;background-image:linear-gradient(#ffffff26,#ffffff26),linear-gradient(90deg,#00f5ff,#ff00d4);transform:translateY(-2px) scale(1.02);box-shadow:0 6px 20px #00000059,0 0 12px #00f5ff66}.glassy-btn:active{transform:scale(.96);background:#ffffff2e}.glassy-btn.recording{background:#ff325a26;background-image:linear-gradient(#ff325a40,#ff325a40),linear-gradient(90deg,#ff4d6d,#ff00d4);box-shadow:0 0 18px #ff4d6d99;animation:pulse-red 1.6s infinite}.glassy-btn.danger{background:#ff00001f;background-image:linear-gradient(#f003,#f003),linear-gradient(90deg,#ff6b6b,red);box-shadow:0 0 14px #f006}@keyframes pulse-red{0%{box-shadow:0 0 12px #ff4d6d99}50%{box-shadow:0 0 24px #ff4d6de6}to{box-shadow:0 0 12px #ff4d6d99}}\n"] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AngularVoice, decorators: [{ type: Component, args: [{ selector: 'angular-voice', imports: [], template: "@if (!recording() && !result()) {\n<button [class]=\"startBtnClass() ? startBtnClass() : 'glassy-btn'\" (click)=\"toggleRecord()\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-mic\"\n viewBox=\"0 0 16 16\">\n <path\n d=\"M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5\" />\n <path d=\"M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3\" />\n </svg>\n\n @if(displayBtnsLabels()){\n {{startRecordingBtnLabel()}}\n }\n</button>\n} @else if(recording()) {\n<button [class]=\"recordingBtnClass() ? recordingBtnClass() : 'glassy-btn recording'\" (click)=\"toggleRecord()\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-soundwave\"\n viewBox=\"0 0 16 16\">\n <path fill-rule=\"evenodd\"\n d=\"M8.5 2a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-1 0v-11a.5.5 0 0 1 .5-.5m-2 2a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5m4 0a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5m-6 1.5A.5.5 0 0 1 5 6v4a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m8 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m-10 1A.5.5 0 0 1 3 7v2a.5.5 0 0 1-1 0V7a.5.5 0 0 1 .5-.5m12 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0V7a.5.5 0 0 1 .5-.5\" />\n </svg>\n\n @if(displayBtnsLabels()){\n {{recordingBtnLabel()}}\n }\n</button>\n} @else if(!recording() && result()) {\n<div class=\"preview-record glassy-card\">\n @if (audioUrl()) {\n <div class=\"record-info\">\n <span class=\"file-name\">{{ activeRecordingFile?.name }}</span>\n <audio controls class=\"record-player\">\n <source [src]=\"audioUrl()\" type=\"audio/wav\" />\n Your browser does not support the audio element.\n </audio>\n </div>\n }\n</div>\n}", styles: [".preview-record{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px;border-radius:14px;background:#ffffff0f;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1.5px solid rgba(255,255,255,.1);margin-top:12px}.preview-record .record-info{flex:1;display:flex;flex-direction:column;gap:6px}.preview-record .file-name{font-size:14px;font-weight:500;color:#e0e0e0}.preview-record .record-player{width:100%;max-width:320px}.preview-record .record-actions{display:flex;align-items:center;gap:10px}.glassy-btn{font-size:15px;font-weight:500;position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border-radius:12px;background:#ffffff14;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1.5px solid transparent;background-image:linear-gradient(#ffffff1a,#ffffff1a),linear-gradient(90deg,#00f5ff,#ff00d4);background-origin:border-box;background-clip:content-box,border-box;color:#fff;cursor:pointer;transition:all .3s ease;box-shadow:0 2px 10px #00000040}.glassy-btn:hover{background:#ffffff26;background-image:linear-gradient(#ffffff26,#ffffff26),linear-gradient(90deg,#00f5ff,#ff00d4);transform:translateY(-2px) scale(1.02);box-shadow:0 6px 20px #00000059,0 0 12px #00f5ff66}.glassy-btn:active{transform:scale(.96);background:#ffffff2e}.glassy-btn.recording{background:#ff325a26;background-image:linear-gradient(#ff325a40,#ff325a40),linear-gradient(90deg,#ff4d6d,#ff00d4);box-shadow:0 0 18px #ff4d6d99;animation:pulse-red 1.6s infinite}.glassy-btn.danger{background:#ff00001f;background-image:linear-gradient(#f003,#f003),linear-gradient(90deg,#ff6b6b,red);box-shadow:0 0 14px #f006}@keyframes pulse-red{0%{box-shadow:0 0 12px #ff4d6d99}50%{box-shadow:0 0 24px #ff4d6de6}to{box-shadow:0 0 12px #ff4d6d99}}\n"] }] }], propDecorators: { recordingCompleted: [{ type: Output }] } }); /** * Generated bundle index. Do not edit. */ export { AngularVoice, AudioRecorderService }; //# sourceMappingURL=angular-voice.mjs.map