All files / element-ng/chat-messages si-voice-waveform.component.ts

96.36% Statements 53/55
96.77% Branches 30/31
92.85% Functions 13/14
97.72% Lines 43/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112                                          40x 88x 1675x   3608x 3608x     40x         128x 40x 40x 40x 40x 40x 40x           40x               40x   40x 1640x       40x 40x 40x     42x 40x 40x 40x     40x 50x 17x   33x 35x 35x 35x   33x     40x 85x 85x 17x   68x             85x         13x 4x 4x       13x 13x      
import { DOCUMENT } from '@angular/common';
import {
  afterRenderEffect,
  booleanAttribute,
  Component,
  ElementRef,
  inject,
  input,
  signal,
  viewChild
} from '@angular/core';
 
/**
 * Decorative voice activity visualization. Audio capture and level measurement belong to the consumer.
 * Provide an accessible recording status alongside the waveform.
 *
 * @experimental
 */
@Component({
  selector: 'si-voice-waveform',
  template: `
    <div #track class="waveform-track">
      @for (bar of bars(); track bar.id) {
        <span
          class="waveform-bar"
          [class.silent]="bar.height === 2"
          [style.block-size.px]="bar.height"
        ></span>
      }
    </div>
  `,
  styleUrl: './si-voice-waveform.component.scss',
  host: { 'aria-hidden': 'true' }
})
export class SiVoiceWaveformComponent {
  private readonly document = inject(DOCUMENT);
  private readonly track = viewChild<ElementRef<HTMLElement>>('track');
  private readonly reducedMotion = signal(false);
  private nextId = 41;
  private speech = false;
  private remainingSamples = 0;
 
  /**
   * Whether to generate samples. Inactive waveforms retain their last samples.
   * @defaultValue false
   */
  readonly active = input(false, { transform: booleanAttribute });
 
  /**
   * Latest audio level from 0 (silence) to 1 (maximum volume), sampled every 60 ms.
   * Values outside the range are clamped; non-finite values are treated as silence.
   * Omit to simulate speech bursts while active. Zero represents measured silence, not fallback.
   * @defaultValue undefined
   */
  readonly level = input<number>();
 
  protected readonly bars = signal(
    Array.from({ length: 41 }, (_, index) => ({ id: index, height: 2 }))
  );
 
  constructor() {
    afterRenderEffect(onCleanup => {
      const media = this.document.defaultView?.matchMedia('(prefers-reduced-motion: reduce)');
      Iif (!media) {
        return;
      }
      const update = (): void => this.reducedMotion.set(media.matches);
      update();
      media.addEventListener('change', update);
      onCleanup(() => media.removeEventListener('change', update));
    });
 
    afterRenderEffect(onCleanup => {
      if (!this.active() || this.reducedMotion()) {
        return;
      }
      const timer = setInterval(() => {
        const level = this.level() ?? this.simulateLevel();
        const height = 2 + 14 * (Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
        this.bars.update(bars => [...bars.slice(1), { id: this.nextId++, height }]);
      }, 60);
      onCleanup(() => clearInterval(timer));
    });
 
    afterRenderEffect(onCleanup => {
      this.bars();
      if (!this.active() || this.reducedMotion()) {
        return;
      }
      const animation = this.track()?.nativeElement.animate(
        [
          { transform: 'translateX(calc(1px + var(--si-sys-sizing-spacing-x-10)))' },
          { transform: 'translateX(0)' }
        ],
        { duration: 60, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }
      );
      onCleanup(() => animation?.cancel());
    });
  }
 
  private simulateLevel(): number {
    if (!this.remainingSamples) {
      this.speech = !this.speech;
      this.remainingSamples = this.speech
        ? 3 + Math.floor(Math.random() * 6)
        : 2 + Math.floor(Math.random() * 4);
    }
    this.remainingSamples--;
    return this.speech ? (5 + Math.random() * 9) / 14 : Math.random() / 14;
  }
}