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;
}
}
|