UNPKG

biometry-angular-components

Version:

Angular UI component library for capturing biometric data

1 lines 69.6 kB
{"version":3,"file":"biometry-angular-components.mjs","sources":["../../src/lib/services/permissions.service.ts","../../src/lib/components/doc-scan/doc-scan.component.ts","../../src/lib/components/doc-scan/doc-scan.component.html","../../src/lib/components/face-capture/face-capture.component.ts","../../src/lib/components/face-capture/face-capture.component.html","../../src/lib/services/recorder.service.ts","../../src/lib/components/face-recorder/face-recorder.component.ts","../../src/lib/components/face-recorder/face-recorder.component.html","../../src/lib/utils/numbers.ts","../../src/lib/components/voice-recorder/voice-recorder.component.ts","../../src/lib/components/voice-recorder/voice-recorder.component.html","../../src/public-api.ts","../../src/biometry-angular-components.ts"],"sourcesContent":["import { Injectable } from '@angular/core';\n\n@Injectable({ providedIn: 'root' })\nexport class PermissionsService {\n async requestCamera(options: {\n width?: number;\n height?: number;\n audio?: boolean;\n }): Promise<{ stream?: MediaStream; granted: boolean }> {\n const {\n width = 640,\n height = 480,\n audio = false\n } = options;\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n video: { width, height },\n audio\n });\n return { stream, granted: true };\n } catch {\n return { granted: false };\n }\n }\n\n\n async requestMicrophone(): Promise<{ stream?: MediaStream; granted: boolean }> {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n return { stream, granted: true };\n } catch {\n return { granted: false };\n }\n }\n\n stopStream(stream?: MediaStream) {\n if (!stream) return;\n stream.getTracks().forEach(track => track.stop());\n }\n}\n","import { Component, Input, Output, EventEmitter, OnDestroy, NgZone, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { PermissionsService } from '../../services/permissions.service';\n\n@Component({\n selector: 'bio-doc-scan',\n standalone: true,\n imports: [CommonModule],\n templateUrl: './doc-scan.component.html',\n styleUrls: ['./doc-scan.component.scss'],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class DocScanComponent implements OnDestroy {\n @Input() rectWidth = 640;\n @Input() rectHeight = 400;\n @Input() noShadow = false;\n @Output() capture = new EventEmitter<File>();\n\n isConfirming = false;\n capturedUrl: string | null = null;\n capturedFile: File | null = null;\n\n public stream?: MediaStream;\n private readonly QUALITY_MULTIPLIER = 3;\n\n constructor(private perms: PermissionsService, private zone: NgZone, private cdr: ChangeDetectorRef) { }\n\n ngOnInit() {\n this.initStream();\n }\n\n ngOnDestroy() {\n this.stopStream();\n this.revokeBlob();\n }\n\n async initStream() {\n const { stream, granted } = await this.perms.requestCamera({\n width: this.rectWidth,\n height: this.rectHeight,\n });\n\n if (!granted || !stream) return;\n\n this.zone.run(() => {\n this.stream = stream;\n this.cdr.markForCheck();\n });\n }\n\n stopStream() {\n if (!this.stream) return;\n this.perms.stopStream(this.stream);\n this.stream = undefined;\n }\n\n handleCapture(videoEl: HTMLVideoElement) {\n if (!videoEl) return;\n\n const canvas = document.createElement('canvas');\n canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER;\n canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER;\n const ctx = canvas.getContext('2d');\n if (!ctx) return;\n\n const videoAspect = videoEl.videoWidth / videoEl.videoHeight;\n const targetAspect = this.rectWidth / this.rectHeight;\n let sx = 0, sy = 0, sw = videoEl.videoWidth, sh = videoEl.videoHeight;\n if (videoAspect > targetAspect) {\n sw = videoEl.videoHeight * targetAspect;\n sx = (videoEl.videoWidth - sw) / 2;\n } else {\n sh = videoEl.videoWidth / targetAspect;\n sy = (videoEl.videoHeight - sh) / 2;\n }\n\n ctx.drawImage(videoEl, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);\n\n canvas.toBlob(blob => {\n if (!blob) return;\n const file = new File([blob], 'document.jpg', { type: 'image/jpeg' });\n this.zone.run(() => {\n this.capturedFile = file;\n this.capturedUrl = URL.createObjectURL(blob);\n this.isConfirming = true;\n this.cdr.markForCheck();\n });\n }, 'image/jpeg', 0.98);\n }\n\n handleConfirm() {\n if (!this.capturedFile) return;\n this.capture.emit(this.capturedFile);\n }\n\n handleDecline() {\n this.revokeBlob();\n this.capturedFile = null;\n this.isConfirming = false;\n this.initStream();\n }\n\n private revokeBlob() {\n if (!this.capturedUrl) return;\n URL.revokeObjectURL(this.capturedUrl);\n this.capturedUrl = null;\n }\n}","<div class=\"docscan-wrapper\"\n [ngStyle]=\"{ width: rectWidth + 'px', height: rectHeight + 64 + 'px', boxShadow: noShadow ? 'none' : '0 4px 24px rgba(0,0,0,0.12)' }\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"docscan-camera-area\">\n <video #video [width]=\"rectWidth\" [height]=\"rectHeight\" autoplay muted playsinline\n [style.border-radius.px]=\"noShadow ? 0 : 8\" class=\"docscan-video\" [srcObject]=\"stream\">\n </video>\n <div class=\"docscan-dark-overlay\"></div>\n <div class=\"docscan-dashed-area\">\n <span class=\"docscan-guidance-text\">Place your document here</span>\n </div>\n </div>\n <button class=\"docscan-capture-btn\" (click)=\"handleCapture(video)\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"docscan-preview\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured document\" />\n </div>\n <div class=\"docscan-confirm-text\">Do you want to use this photo?</div>\n <div class=\"docscan-confirm-btns\">\n <button class=\"docscan-icon-btn\" (click)=\"handleDecline()\">\n <!-- SVG Retake -->\n </button>\n <button class=\"docscan-icon-btn\" (click)=\"handleConfirm()\">\n <!-- SVG Confirm -->\n </button>\n </div>\n </ng-template>\n</div>","import {\n Component,\n ElementRef,\n EventEmitter,\n Input,\n NgZone,\n OnDestroy,\n OnInit,\n Output,\n ViewChild,\n ChangeDetectorRef,\n ChangeDetectionStrategy\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { PermissionsService } from '../../services/permissions.service';\n\n@Component({\n selector: 'bio-face-capture',\n standalone: true,\n imports: [CommonModule],\n templateUrl: './face-capture.component.html',\n styleUrls: ['./face-capture.component.scss'],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class FaceCaptureComponent implements OnInit, OnDestroy {\n @Input() rectWidth = 360;\n @Input() rectHeight = 576;\n @Input() noShadow = false;\n\n @Output() capture = new EventEmitter<File>();\n\n @ViewChild('videoEl', { static: false }) videoEl!: ElementRef<HTMLVideoElement>;\n\n isConfirming = false;\n capturedUrl: string | null = null;\n capturedFile: File | null = null;\n\n private stream?: MediaStream;\n\n readonly QUALITY_MULTIPLIER = 3;\n\n constructor(\n private perms: PermissionsService,\n private ngZone: NgZone,\n private cdr: ChangeDetectorRef,\n ) { }\n\n async ngOnInit() {\n await this.initStream();\n }\n\n ngOnDestroy() {\n this.stopStream();\n if (this.capturedUrl) URL.revokeObjectURL(this.capturedUrl);\n }\n\n private async initStream() {\n try {\n const { stream, granted } = await this.perms.requestCamera({\n width: this.rectWidth,\n height: this.rectHeight,\n });\n if (!granted) {\n return;\n }\n this.stream = stream;\n console.log('this.stream = stream');\n this.ngZone.runOutsideAngular(() => {\n if (this.videoEl?.nativeElement) {\n console.log('nativeElement');\n this.videoEl.nativeElement.srcObject = this.stream!;\n this.videoEl.nativeElement.onloadedmetadata = () =>\n this.videoEl.nativeElement.play();\n }\n });\n } catch (err) {\n console.error('Camera access error:', err);\n }\n }\n\n private stopStream() {\n this.stream?.getTracks().forEach(track => track.stop());\n this.stream = undefined;\n }\n\n handleCapture() {\n const video = this.videoEl?.nativeElement;\n if (!video) return;\n\n const canvas = document.createElement('canvas');\n canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER;\n canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER;\n const ctx = canvas.getContext('2d');\n\n if (ctx) {\n ctx.drawImage(\n video,\n 0,\n 0,\n this.rectWidth,\n this.rectHeight,\n 0,\n 0,\n this.rectWidth * this.QUALITY_MULTIPLIER,\n this.rectHeight * this.QUALITY_MULTIPLIER,\n );\n canvas.toBlob(blob => {\n if (blob) {\n const file = new File([blob], 'face.jpg', { type: 'image/jpeg' });\n this.capturedFile = file;\n this.capturedUrl = URL.createObjectURL(blob);\n this.isConfirming = true;\n this.stopStream();\n this.cdr.markForCheck();\n }\n }, 'image/jpeg', 0.98);\n }\n }\n\n handleConfirm() {\n if (this.capturedFile) {\n this.capture.emit(this.capturedFile);\n }\n }\n\n handleDecline() {\n if (this.capturedUrl) {\n URL.revokeObjectURL(this.capturedUrl);\n }\n this.capturedUrl = null;\n this.capturedFile = null;\n this.isConfirming = false;\n this.initStream();\n this.cdr.markForCheck();\n }\n}","<div class=\"facecapture-wrapper\" [ngClass]=\"{ 'no-shadow': noShadow }\" [style.width.px]=\"rectWidth\"\n [style.height.px]=\"rectHeight + 64\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"facecapture-camera-area\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\">\n <video #videoEl class=\"facecapture-video\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\"\n playsinline muted autoplay></video>\n <div class=\"facecapture-dark-overlay\" [ngClass]=\"{ 'no-shadow': noShadow }\"></div>\n <div class=\"facecapture-oval-area\">\n <span class=\"facecapture-guidance-text\">Place your face in the oval</span>\n <div class=\"facecapture-oval-dashed\"></div>\n </div>\n </div>\n <button type=\"button\" class=\"facecapture-capture-btn\" (click)=\"handleCapture()\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"facecapture-preview\" [style.width.px]=\"rectWidth - 25\" [style.height.px]=\"rectHeight - 25\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured face\" class=\"facecapture-preview-img\" />\n </div>\n <div class=\"facecapture-confirm-text\">Do you want to use this photo?</div>\n <div class=\"facecapture-confirm-btns\">\n <button type=\"button\" aria-label=\"Retake\" class=\"facecapture-icon-btn decline\" (click)=\"handleDecline()\">\n ✖\n </button>\n <button type=\"button\" aria-label=\"Confirm\" class=\"facecapture-icon-btn confirm\" (click)=\"handleConfirm()\">\n ✔\n </button>\n </div>\n </ng-template>\n</div>","import { Injectable } from '@angular/core';\n\n@Injectable({ providedIn: 'root' })\nexport class RecorderService {\n private chunks: BlobPart[] = [];\n private mediaRecorder?: MediaRecorder;\n\n start(stream: MediaStream, mimeType: string = 'video/webm') {\n this.chunks = [];\n this.mediaRecorder = new MediaRecorder(stream, { mimeType });\n this.mediaRecorder.ondataavailable = e => { if (e.data.size) this.chunks.push(e.data); };\n this.mediaRecorder.start();\n }\n\n stop(): Promise<Blob> {\n return new Promise(resolve => {\n if (!this.mediaRecorder) return resolve(new Blob());\n this.mediaRecorder.onstop = () => resolve(new Blob(this.chunks, { type: this.mediaRecorder?.mimeType }));\n this.mediaRecorder.stop();\n });\n }\n\n cancel() {\n if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {\n this.mediaRecorder.stop();\n }\n this.chunks = [];\n this.mediaRecorder = undefined;\n }\n}\n","import {\n Component,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n ElementRef,\n EventEmitter,\n Input,\n OnDestroy,\n OnInit,\n Output,\n ViewChild,\n AfterViewInit,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { PermissionsService } from '../../services/permissions.service';\nimport { RecorderService } from '../../services/recorder.service';\n\ntype FaceState = 'preparation' | 'countdown' | 'recording' | 'processing' | 'result';\n\nfunction numbersToPhrase(digits: number[]): string {\n const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];\n return digits.map(d => words[d]).join(' ');\n}\n\nfunction pickSupportedMimeType(): string {\n const candidates = [\n 'video/webm;codecs=vp9,opus',\n 'video/webm;codecs=vp8,opus',\n 'video/webm',\n 'video/mp4;codecs=h264,aac',\n 'video/mp4',\n ];\n for (const type of candidates) {\n // @ts-ignore - isTypeSupported exists in browsers that implement MediaRecorder\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(type)) {\n return type;\n }\n }\n\n return 'video/webm';\n}\n\n\n@Component({\n selector: 'bio-face-recorder',\n standalone: true,\n imports: [CommonModule],\n templateUrl: './face-recorder.component.html',\n styleUrls: ['./face-recorder.component.scss'],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class FaceRecorderComponent implements OnInit, AfterViewInit, OnDestroy {\n @ViewChild('videoEl', { static: true }) videoRef!: ElementRef<HTMLVideoElement>;\n\n @Input() countdownSeconds = 3;\n @Input() recordingSeconds = 10;\n\n /** Prefer vertical 9:16; request 720x1280 from camera */\n @Input() videoWidth = 720;\n @Input() videoHeight = 1280;\n\n @Output() capture = new EventEmitter<{ file: File; phrase: string }>();\n\n state: FaceState = 'preparation';\n digits: number[] = [];\n maskedDigits: string[] = [];\n\n countdownLeft = 0;\n recordLeft = 0;\n\n private countdownTimer: any;\n private recordTimer: any;\n\n private stream?: MediaStream;\n videoBlob: Blob | null = null;\n videoUrl: string | null = null;\n\n isConfirming = false;\n permissionsGranted: boolean | null = null;\n\n private mimeType = pickSupportedMimeType();\n\n constructor(\n private permissions: PermissionsService,\n private recorder: RecorderService,\n private cdr: ChangeDetectorRef\n ) { }\n\n ngOnInit(): void {\n this.generateDigits();\n this.requestPermissions();\n }\n\n ngAfterViewInit(): void {\n if (this.stream) {\n this.attachStream();\n }\n }\n\n ngOnDestroy(): void {\n this.clearAllTimers();\n this.cleanupPlayback();\n this.stopTracks();\n this.recorder.cancel();\n }\n\n /** === Flow control === */\n\n async requestPermissions() {\n this.permissionsGranted = null;\n this.cdr.markForCheck();\n\n const { stream, granted } = await this.permissions.requestCamera({\n width: this.videoWidth,\n height: this.videoHeight,\n audio: true,\n });\n\n this.permissionsGranted = granted;\n\n if (granted && stream) {\n this.stream = stream;\n this.attachStream();\n } else {\n this.stream = undefined;\n }\n\n this.cdr.markForCheck();\n }\n\n private attachStream() {\n const video = this.videoRef.nativeElement;\n video.srcObject = this.stream ?? null;\n video.src = '';\n video.controls = false;\n video.muted = true;\n video.play().catch(() => {/* ignore autoplay race */ });\n }\n\n startFlow() {\n if (!this.stream || this.state !== 'preparation') return;\n this.startCountdown();\n }\n\n private startCountdown() {\n this.state = 'countdown';\n this.countdownLeft = this.countdownSeconds;\n this.cdr.markForCheck();\n\n this.clearCountdown();\n this.countdownTimer = setInterval(() => {\n this.countdownLeft -= 1;\n this.cdr.markForCheck();\n if (this.countdownLeft <= 0) {\n this.clearCountdown();\n this.startRecording();\n }\n }, 1000);\n }\n\n private startRecording() {\n if (!this.stream) return;\n\n // Keep preview running; MediaRecorder records from the same stream\n this.state = 'recording';\n this.recordLeft = this.recordingSeconds;\n\n try {\n this.recorder.start(this.stream, this.mimeType);\n } catch (e) {\n console.error('Failed to start recorder', e);\n this.cancelRecording();\n return;\n }\n\n this.clearRecordTimer();\n this.recordTimer = setInterval(async () => {\n this.recordLeft -= 1;\n this.cdr.markForCheck();\n if (this.recordLeft <= 0) {\n await this.finishRecording();\n }\n }, 1000);\n this.cdr.markForCheck();\n }\n\n async finishRecording() {\n if (this.state !== 'recording') return;\n\n this.state = 'processing';\n this.clearRecordTimer();\n this.cdr.markForCheck();\n\n try {\n this.videoBlob = await this.recorder.stop();\n } catch {\n this.videoBlob = null;\n }\n\n if (this.videoBlob && this.videoBlob.size) {\n if (this.videoUrl) URL.revokeObjectURL(this.videoUrl);\n this.videoUrl = URL.createObjectURL(this.videoBlob);\n // Switch the element from live preview to the recorded video\n const video = this.videoRef.nativeElement;\n video.srcObject = null;\n video.src = this.videoUrl;\n video.controls = true;\n video.muted = false;\n await video.play().catch(() => { });\n this.state = 'result';\n } else {\n // No data recorded: return to preparation with live preview\n this.state = 'preparation';\n this.attachStream();\n }\n\n this.cdr.markForCheck();\n }\n\n cancelRecording() {\n if (this.state !== 'countdown' && this.state !== 'recording') return;\n\n this.clearCountdown();\n this.clearRecordTimer();\n this.recorder.cancel();\n\n if (this.videoUrl) URL.revokeObjectURL(this.videoUrl);\n this.videoUrl = null;\n this.videoBlob = null;\n\n // Return to preparation and keep preview alive\n this.state = 'preparation';\n this.attachStream();\n this.cdr.markForCheck();\n }\n\n handleDecline() {\n if (this.videoUrl) URL.revokeObjectURL(this.videoUrl);\n this.videoUrl = null;\n this.videoBlob = null;\n\n this.state = 'preparation';\n this.generateDigits();\n this.requestPermissions();\n this.cdr.markForCheck();\n }\n\n handleConfirm() {\n if (!this.videoBlob) return;\n\n const file = new File([this.videoBlob], 'face-video.webm', {\n type: this.videoBlob.type,\n });\n\n this.capture.emit({ file, phrase: numbersToPhrase(this.digits) });\n\n if (this.videoUrl) URL.revokeObjectURL(this.videoUrl);\n this.videoUrl = null;\n this.videoBlob = null;\n\n this.isConfirming = false;\n this.state = 'preparation';\n this.requestPermissions();\n this.cdr.markForCheck();\n }\n\n /** === Helpers === */\n\n private generateDigits() {\n this.digits = Array.from({ length: 10 }, () => Math.floor(Math.random() * 10));\n this.maskedDigits = Array(this.digits.length).fill('*');\n }\n\n private clearCountdown() {\n if (this.countdownTimer) {\n clearInterval(this.countdownTimer);\n this.countdownTimer = null;\n }\n }\n\n private clearRecordTimer() {\n if (this.recordTimer) {\n clearInterval(this.recordTimer);\n this.recordTimer = null;\n }\n }\n\n private clearAllTimers() {\n this.clearCountdown();\n this.clearRecordTimer();\n }\n\n private cleanupPlayback() {\n const v = this.videoRef?.nativeElement;\n if (v) {\n v.pause();\n v.srcObject = null;\n v.src = '';\n v.removeAttribute('src');\n }\n if (this.videoUrl) URL.revokeObjectURL(this.videoUrl);\n this.videoUrl = null;\n this.videoBlob = null;\n }\n\n private stopTracks() {\n this.stream?.getTracks().forEach(t => t.stop());\n this.stream = undefined;\n }\n\n get displayDigits(): string[] {\n return (this.state === 'recording' ? this.digits : this.maskedDigits).map(d => d.toString());\n }\n}\n","<div class=\"recorder-wrapper\" [class.result]=\"state === 'result'\">\n <!-- Video frame (9:16) -->\n <div class=\"frame\">\n <video #videoEl class=\"preview\" autoplay playsinline muted preload=\"metadata\"></video>\n\n <!-- Oval dashed overlay -->\n <div class=\"oval-area\" *ngIf=\"state !== 'result' && state !== 'processing'\">\n <span class=\"guidance-text\">Place your face in the oval</span>\n <div class=\"oval-dashed\"></div>\n </div>\n\n <!-- HUD: digits to read -->\n <!-- <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div> -->\n\n <!-- HUD: countdown -->\n <div class=\"overlay center\" *ngIf=\"state === 'countdown'\">\n <div class=\"countdown\">{{ countdownLeft }}</div>\n <button class=\"btn ghost\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: recording timer + cancel -->\n <div class=\"overlay top\" *ngIf=\"state === 'recording'\">\n <div class=\"timer\">\n <span class=\"dot\"></span>\n <span>{{ recordLeft }}s</span>\n </div>\n <button class=\"btn danger\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: processing -->\n <div class=\"overlay center\" *ngIf=\"state === 'processing'\">\n <div class=\"spinner\"></div>\n <div class=\"processing-label\">Processing…</div>\n </div>\n </div>\n\n <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div>\n\n <!-- Controls / CTA -->\n <div class=\"controls\">\n <!-- Preparation -->\n <ng-container *ngIf=\"state === 'preparation'\">\n <div class=\"hint\" *ngIf=\"permissionsGranted === false\">\n Camera access denied. Please allow camera & audio and try again.\n </div>\n <button class=\"btn primary\" type=\"button\" (click)=\"startFlow()\" [disabled]=\"!permissionsGranted\">\n Start recording\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"requestPermissions()\">Retry camera</button>\n </ng-container>\n\n <!-- Result -->\n <ng-container *ngIf=\"state === 'result'\">\n <div class=\"result-actions\">\n <button class=\"btn success\" type=\"button\" [disabled]=\"isConfirming\" (click)=\"handleConfirm()\">\n {{ isConfirming ? 'Confirming…' : 'Use this video' }}\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"handleDecline()\">Retake</button>\n </div>\n </ng-container>\n </div>\n</div>","export function numbersToPhrase(digits: number[]): string {\n const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];\n return digits.map(d => (d >= 0 && d <= 9 ? words[d] : String(d))).join(' ');\n}\n\nexport function generateRandomDigits(count: number) {\n const digits = Array.from({ length: 10 }, (_, i) => i);\n for (let i = digits.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [digits[i], digits[j]] = [digits[j], digits[i]];\n }\n return digits.slice(0, count);\n}","import {\n Component,\n EventEmitter,\n Input,\n Output,\n OnInit,\n OnDestroy,\n ChangeDetectionStrategy,\n ElementRef,\n ViewChild,\n NgZone,\n ChangeDetectorRef\n} from '@angular/core';\nimport { PermissionsService } from '../../services/permissions.service';\nimport { RecorderService } from '../../services/recorder.service';\nimport { generateRandomDigits, numbersToPhrase } from '../../utils/numbers';\nimport { CommonModule } from '@angular/common';\n\ntype VRState = 'preparation' | 'countdown' | 'recording' | 'processing' | 'result';\n\nconst RECORD_SECONDS = 10;\nconst PREP_SECONDS = 3;\n\n@Component({\n selector: 'bio-voice-recorder',\n imports: [CommonModule],\n standalone: true,\n templateUrl: './voice-recorder.component.html',\n styleUrls: ['./voice-recorder.component.scss'],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class VoiceRecorderComponent implements OnInit, OnDestroy {\n @Input() rectWidth = 360;\n @Input() rectHeight?: number;\n @Input() noShadow = false;\n @Input() className?: string;\n @Input() style?: { [k: string]: any };\n\n @Output() capture = new EventEmitter<{ file: File; phrase: string }>();\n\n @ViewChild('audioRef', { static: false }) audioRef?: ElementRef<HTMLAudioElement>;\n\n permission = false;\n isLoading = true;\n stream?: MediaStream;\n\n state: VRState = 'preparation';\n prepTimer = PREP_SECONDS;\n recordTimer = RECORD_SECONDS;\n\n // digits & phrase\n digits: number[] = generateRandomDigits(10);\n maskedDigits = Array(10).fill('*');\n\n // visualization\n audioLevel = 0;\n waveformData: number[] = [];\n\n // blobs/urls\n private audioBlob: Blob | null = null;\n audioUrl: string | null = null;\n isConfirming = false;\n\n // timers / raf\n private countdownTimeout?: any;\n private recordInterval?: any;\n private rafId?: number;\n\n // audio context nodes\n private audioCtx?: AudioContext;\n private analyser?: AnalyserNode;\n private sourceNode?: MediaStreamAudioSourceNode;\n\n constructor(\n private perms: PermissionsService,\n private recorder: RecorderService,\n private ngZone: NgZone,\n private cdr: ChangeDetectorRef\n ) { }\n\n async ngOnInit() {\n try {\n const status = await (navigator.permissions?.query?.({ name: 'microphone' as PermissionName }) ?? Promise.reject());\n this.ngZone.run(() => {\n this.permission = status.state === 'granted';\n this.isLoading = false;\n this.cdr.detectChanges();\n });\n\n if (this.permission) await this.requestPermissions();\n\n status.onchange = async () => {\n this.ngZone.run(async () => {\n this.permission = status.state === 'granted';\n if (this.permission && !this.stream) await this.requestPermissions();\n this.cdr.detectChanges();\n });\n };\n } catch {\n this.ngZone.run(() => {\n this.permission = false;\n this.isLoading = false;\n this.cdr.detectChanges();\n });\n }\n }\n\n ngOnDestroy() {\n this.clearAllTimers();\n this.teardownVisualizer();\n this.perms.stopStream(this.stream);\n if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);\n }\n\n async requestPermissions() {\n this.ngZone.run(() => {\n this.isLoading = true;\n this.cdr.detectChanges();\n });\n\n const { stream, granted } = await this.perms.requestMicrophone();\n\n this.ngZone.run(() => {\n this.permission = !!granted;\n this.isLoading = false;\n if (granted && stream) this.stream = stream;\n this.cdr.detectChanges();\n });\n }\n\n startRecording() {\n this.digits = generateRandomDigits(10);\n this.recordTimer = RECORD_SECONDS;\n this.prepTimer = PREP_SECONDS;\n this.state = 'countdown';\n\n this.tickCountdown();\n }\n\n private tickCountdown() {\n if (this.prepTimer > 0) {\n this.countdownTimeout = setTimeout(() => {\n this.ngZone.run(() => {\n this.prepTimer -= 1;\n this.cdr.detectChanges();\n });\n this.tickCountdown();\n }, 1000);\n return;\n }\n\n this.ngZone.run(() => {\n this.state = 'recording';\n this.prepTimer = PREP_SECONDS;\n this.cdr.detectChanges();\n });\n this.beginRecording();\n }\n\n private async beginRecording() {\n if (!this.stream) {\n const { stream, granted } = await this.perms.requestMicrophone();\n if (!granted || !stream) return;\n this.stream = stream;\n }\n\n this.recorder.start(this.stream, 'audio/webm');\n this.setupVisualizer();\n\n this.recordInterval = setInterval(async () => {\n this.ngZone.run(async () => {\n this.recordTimer -= 1;\n this.cdr.detectChanges();\n if (this.recordTimer <= 0) {\n clearInterval(this.recordInterval);\n await this.finishRecording();\n }\n });\n }, 1000);\n }\n\n private async finishRecording() {\n this.state = 'processing';\n\n const blob = await this.recorder.stop();\n\n // stop devices visualizer\n this.teardownVisualizer();\n this.perms.stopStream(this.stream);\n this.stream = undefined;\n\n // produce url and move to confirm\n const finalBlob = blob && blob.size > 0 ? blob : new Blob(['dummy audio data'], { type: 'audio/webm' });\n this.audioBlob = finalBlob;\n this.audioUrl = URL.createObjectURL(finalBlob);\n this.state = 'result';\n this.isConfirming = true;\n }\n\n async handleCancel() {\n this.clearAllTimers();\n\n try {\n await this.recorder.stop();\n } catch { }\n\n this.teardownVisualizer();\n this.perms.stopStream(this.stream);\n this.stream = undefined;\n\n // reset UI\n this.state = 'preparation';\n this.isConfirming = false;\n if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);\n this.audioUrl = null;\n this.audioBlob = null;\n this.audioLevel = 0;\n this.waveformData = [];\n\n this.requestPermissions();\n }\n\n handleConfirm() {\n if (!this.audioBlob) return;\n const file = new File([this.audioBlob], 'voice-recording.webm', { type: this.audioBlob.type });\n this.capture.emit({ file, phrase: numbersToPhrase(this.digits) });\n\n if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);\n this.audioUrl = null;\n this.audioBlob = null;\n this.isConfirming = false;\n this.state = 'preparation';\n }\n\n handleDecline() {\n if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);\n this.audioUrl = null;\n this.audioBlob = null;\n this.isConfirming = false;\n this.state = 'preparation';\n this.requestPermissions();\n }\n\n // ----- Visualization -----\n private setupVisualizer() {\n if (!this.stream) return;\n this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();\n this.analyser = this.audioCtx.createAnalyser();\n this.sourceNode = this.audioCtx.createMediaStreamSource(this.stream);\n this.analyser.fftSize = 256;\n this.analyser.smoothingTimeConstant = 0.8;\n this.sourceNode.connect(this.analyser);\n\n const data = new Uint8Array(this.analyser.frequencyBinCount);\n const loop = () => {\n if (!this.analyser || this.state !== 'recording') return;\n this.analyser.getByteFrequencyData(data);\n const avg = data.reduce((s, v) => s + v, 0) / data.length;\n\n this.ngZone.run(() => {\n this.audioLevel = avg / 255;\n this.waveformData = Array.from(data).slice(0, 64);\n this.cdr.detectChanges();\n });\n\n this.rafId = requestAnimationFrame(loop);\n };\n loop();\n }\n\n private teardownVisualizer() {\n if (this.rafId) cancelAnimationFrame(this.rafId);\n this.rafId = undefined;\n\n try { this.sourceNode?.disconnect(); } catch { }\n try { this.analyser?.disconnect(); } catch { }\n try { this.audioCtx?.close(); } catch { }\n\n this.sourceNode = undefined;\n this.analyser = undefined;\n this.audioCtx = undefined;\n }\n\n private clearAllTimers() {\n if (this.countdownTimeout) clearTimeout(this.countdownTimeout);\n this.countdownTimeout = undefined;\n if (this.recordInterval) clearInterval(this.recordInterval);\n this.recordInterval = undefined;\n }\n\n getVolumeHeight(v: number): number {\n return Math.max(2, (v / 255) * 100);\n }\n}\n","<div class=\"vr-wrapper\" [class.no-shadow]=\"noShadow\" [style.width.px]=\"rectWidth\" [style.height.px]=\"rectHeight || null\"\n [ngStyle]=\"style\">\n <!-- LOADING -->\n <div *ngIf=\"isLoading\" class=\"vr-center\">\n <div class=\"vr-spinner\"></div>\n <h3>Initializing Microphone...</h3>\n <p>Please wait while we set up your microphone.</p>\n </div>\n\n <!-- PERMISSION -->\n <div *ngIf=\"!isLoading && !permission\" class=\"vr-center\">\n <h3>Microphone Permission Required</h3>\n <p>Please allow microphone access to use this component.</p>\n <button type=\"button\" class=\"vr-btn\" (click)=\"requestPermissions()\">Grant Permissions</button>\n </div>\n\n <!-- MAIN FLOW -->\n <ng-container *ngIf=\"!isLoading && permission\">\n <!-- Not in result/processing state -->\n <ng-container *ngIf=\"state !== 'result' && state !== 'processing' && !isConfirming\">\n <div class=\"vr-audio-area\">\n <div class=\"vr-audio-dim\"></div>\n\n <div class=\"vr-overlay\">\n <!-- PREPARATION -->\n <div *ngIf=\"state === 'preparation'\" class=\"vr-center-col\">\n <div class=\"vr-guidance\">Click Start to begin voice recording</div>\n </div>\n\n <!-- COUNTDOWN -->\n <div *ngIf=\"state === 'countdown'\" class=\"vr-center-col\">\n <div class=\"vr-guidance\">Get ready to speak clearly</div>\n <div class=\"vr-prep-timer\">{{ prepTimer }}</div>\n </div>\n\n <!-- RECORDING -->\n <div *ngIf=\"state === 'recording'\" class=\"vr-center-col\">\n <div class=\"vr-waveform\">\n <div class=\"vr-bars\">\n <div *ngFor=\"let v of waveformData; let i = index\" class=\"vr-bar\"\n [style.height.%]=\"this.getVolumeHeight(v)\" [class.active]=\"state === 'recording'\">\n </div>\n </div>\n </div>\n <div class=\"vr-timer\">{{ recordTimer }}</div>\n <div class=\"vr-guidance\">Speak clearly into your microphone</div>\n </div>\n </div>\n </div>\n\n <!-- DIGIT ROW -->\n <div class=\"vr-number-row\">\n <div class=\"vr-number-box\" *ngFor=\"let n of (state === 'recording' ? digits : maskedDigits)\">\n {{ n }}\n </div>\n </div>\n\n <!-- ACTIONS -->\n <div class=\"vr-actions\">\n <button *ngIf=\"state === 'preparation'\" class=\"vr-btn\" type=\"button\" (click)=\"startRecording()\">Start\n recording</button>\n <button *ngIf=\"state === 'recording'\" class=\"vr-btn danger\" type=\"button\"\n (click)=\"handleCancel()\">Cancel</button>\n </div>\n </ng-container>\n\n <!-- PROCESSING -->\n <ng-container *ngIf=\"state === 'processing'\">\n <div class=\"vr-preview\">\n <div class=\"vr-center-col\" style=\"color:#fff;\">\n <div class=\"vr-spinner light\"></div>\n <div class=\"vr-processing-title\">Processing audio...</div>\n <div class=\"vr-processing-sub\">Please wait while we prepare your recording</div>\n </div>\n </div>\n </ng-container>\n\n <!-- RESULT + CONFIRM -->\n <ng-container *ngIf=\"state === 'result' && isConfirming\">\n <div class=\"vr-result\">\n <audio #audioRef *ngIf=\"audioUrl\" [src]=\"audioUrl\" controls\n style=\"width:100%;max-width:320px;margin-bottom:16px;\"></audio>\n <div class=\"vr-confirm-text\">Do you want to use this recording?</div>\n <div class=\"vr-confirm-btns\">\n <button type=\"button\" class=\"vr-icon-btn\" aria-label=\"Retake\" (click)=\"handleDecline()\">\n <svg width=\"36\" height=\"36\" viewBox=\"0 0 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <circle cx=\"18\" cy=\"18\" r=\"18\" fill=\"#fff\" fill-opacity=\"0.15\" />\n <path d=\"M12 12L24 24\" stroke=\"#d32f2f\" stroke-width=\"3\" stroke-linecap=\"round\" />\n <path d=\"M24 12L12 24\" stroke=\"#d32f2f\" stroke-width=\"3\" stroke-linecap=\"round\" />\n </svg>\n </button>\n <button type=\"button\" class=\"vr-icon-btn\" aria-label=\"Confirm\" (click)=\"handleConfirm()\">\n <svg width=\"36\" height=\"36\" viewBox=\"0 0 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <circle cx=\"18\" cy=\"18\" r=\"18\" fill=\"#fff\" fill-opacity=\"0.15\" />\n <path d=\"M10 19L16 25L26 13\" stroke=\"#388e3c\" stroke-width=\"3\" stroke-linecap=\"round\"\n stroke-linejoin=\"round\" />\n </svg>\n </button>\n </div>\n </div>\n </ng-container>\n </ng-container>\n</div>","/*\n * Public API Surface of biometry-angular-components\n */\n\nexport * from './lib/components/doc-scan/doc-scan.component';\nexport * from './lib/components/face-capture/face-capture.component';\nexport * from './lib/components/face-recorder/face-recorder.component';\nexport * from './lib/components/voice-recorder/voice-recorder.component';\n\nexport * from './lib/services/permissions.service';\nexport * from './lib/services/recorder.service';\n\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":["i1.PermissionsService","i2","numbersToPhrase","i2.RecorderService"],"mappings":"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