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
JavaScript
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