wally-ui 1.14.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/package.json +1 -1
  2. package/playground/showcase/public/sitemap.xml +15 -0
  3. package/playground/showcase/src/app/app.routes.server.ts +4 -0
  4. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
  5. package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
  6. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
  7. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
  8. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
  9. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
  10. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
  11. package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
  12. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
  13. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
  14. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
  15. package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
  16. package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
  17. package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +4 -0
  18. package/playground/showcase/src/app/pages/home/home.html +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wally-ui",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "About Where’s Wally? Right here — bringing you ready-to-use Angular components with Wally-UI. Stop searching, start building.",
5
5
  "bin": {
6
6
  "wally": "dist/cli.js"
@@ -68,6 +68,21 @@
68
68
  <priority>0.8</priority>
69
69
  </url>
70
70
 
71
+ <url>
72
+ <loc>https://wally-ui.com/documentation/components/selection-popover</loc>
73
+ <lastmod>2025-10-18</lastmod>
74
+ <changefreq>monthly</changefreq>
75
+ <priority>0.8</priority>
76
+ </url>
77
+
78
+ <!-- Audio Waveform Documentation -->
79
+ <url>
80
+ <loc>https://wally-ui.com/documentation/components/audio-waveform</loc>
81
+ <lastmod>2025-10-18</lastmod>
82
+ <changefreq>monthly</changefreq>
83
+ <priority>0.8</priority>
84
+ </url>
85
+
71
86
  <!-- Chat SDK Documentation -->
72
87
  <url>
73
88
  <loc>https://wally-ui.com/documentation/chat-sdk</loc>
@@ -37,6 +37,10 @@ export const serverRoutes: ServerRoute[] = [
37
37
  path: 'documentation/components/selection-popover',
38
38
  renderMode: RenderMode.Prerender,
39
39
  },
40
+ {
41
+ path: 'documentation/components/audio-waveform',
42
+ renderMode: RenderMode.Prerender,
43
+ },
40
44
  {
41
45
  path: 'documentation/chat-sdk',
42
46
  renderMode: RenderMode.Prerender,
@@ -35,10 +35,18 @@
35
35
  </div>
36
36
  }
37
37
 
38
- <div class="w-full px-4 pt-4">
38
+ <div class="w-full px-4 pt-4 transition-all duration-1000 ease-in-out overflow-hidden" [class.opacity-0]="isStartRecoding()" [class.opacity-100]="!isStartRecoding()"
39
+ [class.max-h-0]="isStartRecoding()" [class.max-h-96]="!isStartRecoding()">
39
40
  <wally-ai-prompt-input></wally-ai-prompt-input>
40
41
  </div>
41
42
 
43
+ <div class="w-full transition-all duration-700 ease-in-out overflow-hidden" [class.opacity-0]="!isStartRecoding()"
44
+ [class.opacity-100]="isStartRecoding()" [class.scale-95]="!isStartRecoding()"
45
+ [class.scale-100]="isStartRecoding()" [class.max-h-0]="!isStartRecoding()" [class.max-h-32]="isStartRecoding()">
46
+ <wally-audio-waveform [isStartRecording]="isStartRecoding()" [isStopRecording]="isStopRecoding()">
47
+ </wally-audio-waveform>
48
+ </div>
49
+
42
50
  <!-- Action Buttons -->
43
51
  <div class="w-full flex items-center justify-between p-1" role="toolbar" aria-label="Composer actions">
44
52
  <div class="w-full flex items-center gap-1">
@@ -186,7 +194,8 @@
186
194
  <div class="w-full size-10 flex gap-2 justify-end">
187
195
  <div>
188
196
  <wally-tooltip [text]="'Use voice input'" position="bottom">
189
- <wally-button [variant]="'ghost'" [rounded]="true" [ariaLabel]="'Send message'">
197
+ <wally-button (buttonClick)="toggleInputVoice()" [variant]="'ghost'" [rounded]="true"
198
+ [ariaLabel]="'Send message'">
190
199
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
191
200
  stroke="currentColor" class="size-6">
192
201
  <path stroke-linecap="round" stroke-linejoin="round"
@@ -1,4 +1,4 @@
1
- import { Component, input } from '@angular/core';
1
+ import { Component, input, InputSignal, signal, WritableSignal } from '@angular/core';
2
2
 
3
3
  import { DropdownMenuSubTrigger } from '../../dropdown-menu/dropdown-menu-sub-trigger/dropdown-menu-sub-trigger';
4
4
  import { DropdownMenuSubContent } from '../../dropdown-menu/dropdown-menu-sub-content/dropdown-menu-sub-content';
@@ -9,6 +9,7 @@ import { DropdownMenuLabel } from '../../dropdown-menu/dropdown-menu-label/dropd
9
9
  import { DropdownMenuGroup } from '../../dropdown-menu/dropdown-menu-group/dropdown-menu-group';
10
10
  import { DropdownMenuItem } from '../../dropdown-menu/dropdown-menu-item/dropdown-menu-item';
11
11
  import { DropdownMenuSub } from '../../dropdown-menu/dropdown-menu-sub/dropdown-menu-sub';
12
+ import { AudioWaveform } from '../../audio-waveform/audio-waveform';
12
13
  import { AiPromptInput } from '../ai-prompt-input/ai-prompt-input';
13
14
  import { DropdownMenu } from '../../dropdown-menu/dropdown-menu';
14
15
  import { Tooltip } from '../../tooltip/tooltip';
@@ -29,15 +30,24 @@ import { Button } from '../../button/button';
29
30
  DropdownMenuGroup,
30
31
  DropdownMenuSub,
31
32
  DropdownMenuSubTrigger,
32
- DropdownMenuSubContent
33
+ DropdownMenuSubContent,
34
+ AudioWaveform
33
35
  ],
34
36
  templateUrl: './ai-composer.html',
35
37
  styleUrl: './ai-composer.css'
36
38
  })
37
39
  export class AiComposer {
38
- textSelected = input<string>('');
40
+ textSelected: InputSignal<string> = input<string>('');
41
+
42
+ isStartRecoding: WritableSignal<boolean> = signal<boolean>(false);
43
+ isStopRecoding: WritableSignal<boolean> = signal<boolean>(true);
39
44
 
40
45
  onItemClick(): void {
41
46
  console.log('Item clicked');
42
47
  }
48
+
49
+ toggleInputVoice(): void {
50
+ this.isStartRecoding.set(!this.isStartRecoding());
51
+ this.isStopRecoding.set(!this.isStartRecoding());
52
+ }
43
53
  }
@@ -0,0 +1,41 @@
1
+ <div class="w-full flex flex-col items-center gap-4 p-3">
2
+
3
+ <!-- Waveform Bars Container -->
4
+ @if (isStartRecording()) {
5
+ <div class="flex items-center justify-center gap-1 h-11 w-full">
6
+ @if (audioWaveformService.isRecording()) {
7
+ <!-- Active Recording: Animated Bars -->
8
+ @for (barHeight of audioWaveformService.audioData(); track $index) {
9
+ <div class="flex-1 min-w-[2px] max-w-[7px] bg-[#0a0a0a] dark:bg-white rounded-full transition-all duration-200 ease-out"
10
+ [style.height.%]="barHeight || 5" [style.min-height.px]="4">
11
+ </div>
12
+ }
13
+ }
14
+
15
+ </div>
16
+ }
17
+
18
+ <!-- Recording Timer -->
19
+ @if (showTimer() && audioWaveformService.isRecording()) {
20
+ <div class="text-sm text-[#0a0a0a] dark:text-white">
21
+ {{ formattedTime }}
22
+ </div>
23
+ }
24
+
25
+ <!-- Controls -->
26
+ <!-- <div class="flex gap-2">
27
+ @if (!audioWaveformService.isRecording()) {
28
+ <button (click)="startRecording()"
29
+ class="px-4 py-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
30
+ aria-label="Start recording">
31
+ Start Recording
32
+ </button>
33
+ } @else {
34
+ <button (click)="stopRecording()"
35
+ class="px-4 py-2 bg-neutral-500 text-white rounded-full hover:bg-neutral-600 transition-colors"
36
+ aria-label="Stop recording">
37
+ Stop Recording
38
+ </button>
39
+ }
40
+ </div> -->
41
+ </div>
@@ -0,0 +1,16 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { AudioWaveformService } from './audio-waveform.service';
4
+
5
+ describe('AudioWaveformService', () => {
6
+ let service: AudioWaveformService;
7
+
8
+ beforeEach(() => {
9
+ TestBed.configureTestingModule({});
10
+ service = TestBed.inject(AudioWaveformService);
11
+ });
12
+
13
+ it('should be created', () => {
14
+ expect(service).toBeTruthy();
15
+ });
16
+ });
@@ -0,0 +1,175 @@
1
+ import { Injectable, signal, WritableSignal } from '@angular/core';
2
+
3
+ @Injectable()
4
+ export class AudioWaveformService {
5
+ isRecording: WritableSignal<boolean> = signal<boolean>(false);
6
+ audioData: WritableSignal<number[]> = signal<number[]>([]); // Frequency values for bars
7
+
8
+ // Recorded audio signals
9
+ recordedAudioBlob: WritableSignal<Blob | null> = signal<Blob | null>(null);
10
+ recordedAudioUrl: WritableSignal<string | null> = signal<string | null>(null);
11
+
12
+ // Audio API references
13
+ private audioContext: AudioContext | null = null;
14
+ private analyser: AnalyserNode | null = null;
15
+ private microphone: MediaStreamAudioSourceNode | null = null;
16
+ private animationFrame: number | null = null;
17
+ private stream: MediaStream | null = null;
18
+
19
+ // Configuration
20
+ private readonly FFT_SIZE = 256; // Higher = more frequency detail
21
+ private readonly SMOOTHING = 0.8; // 0 (no smoothing) to 1 (max smoothing)
22
+
23
+ // Responsive bar count based on screen width
24
+ private get BAR_COUNT(): number {
25
+ if (typeof window === 'undefined') return 30; // SSR fallback
26
+ return window.innerWidth < 768 ? 30 : 65; // Mobile: 30 bars, Desktop: 65 bars
27
+ }
28
+
29
+ // MediaRecorded
30
+ private mediaRecorder: MediaRecorder | null = null;
31
+ private audioChunks: Blob[] = [];
32
+
33
+ async startRecording(): Promise<void> {
34
+ try {
35
+ // Request microphone access
36
+ this.stream = await navigator.mediaDevices.getUserMedia({
37
+ audio: {
38
+ echoCancellation: true,
39
+ noiseSuppression: true,
40
+ autoGainControl: true
41
+ }
42
+ });
43
+
44
+ // Create audio context
45
+ this.audioContext = new AudioContext();
46
+
47
+ // Create analyzer
48
+ this.analyser = this.audioContext.createAnalyser();
49
+ this.analyser.fftSize = this.FFT_SIZE;
50
+ this.analyser.smoothingTimeConstant = this.SMOOTHING;
51
+
52
+ // Connect microphone to analyzer
53
+ this.microphone = this.audioContext.createMediaStreamSource(this.stream);
54
+ this.microphone?.connect(this.analyser);
55
+
56
+ // Init MediaRecorded
57
+ this.audioChunks = [];
58
+ this.mediaRecorder = new MediaRecorder(this.stream);
59
+
60
+ this.mediaRecorder.ondataavailable = (event) => {
61
+ if (event.data.size > 0) {
62
+ this.audioChunks.push(event.data);
63
+ }
64
+ };
65
+
66
+ this.mediaRecorder.onstop = () => {
67
+ const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
68
+ this.recordedAudioBlob.set(audioBlob);
69
+
70
+ const audioUrl = URL.createObjectURL(audioBlob);
71
+ this.recordedAudioUrl.set(audioUrl);
72
+ };
73
+
74
+ this.mediaRecorder.start();
75
+
76
+ // Start animation loop
77
+ this.isRecording.set(true);
78
+ this.updateWaveForm();
79
+
80
+ } catch (error) {
81
+ console.error('Microphone access denied:', error);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ stopRecording(): void {
87
+ // Stop animation
88
+ if (this.animationFrame) {
89
+ cancelAnimationFrame(this.animationFrame);
90
+ }
91
+
92
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
93
+ this.mediaRecorder.stop();
94
+ }
95
+
96
+ // Disconnect audio nodes
97
+ this.microphone?.disconnect();
98
+ this.audioContext?.close();
99
+
100
+ // Stop microphone strem
101
+ this.stream?.getTracks().forEach(track => track.stop());
102
+
103
+ // Reset state
104
+ this.isRecording.set(false);
105
+ this.audioData.set([]);
106
+
107
+ // Clean up references
108
+ this.audioContext = null;
109
+ this.analyser = null;
110
+ this.microphone = null;
111
+ this.stream = null;
112
+ this.mediaRecorder = null;
113
+ }
114
+
115
+ // Download recorded audio
116
+ downloadRecording(filename: string = `recording-${Date.now()}.webm`): void {
117
+ const url = this.recordedAudioUrl();
118
+
119
+ if (!url) {
120
+ console.error('No recorded audio available');
121
+ return;
122
+ }
123
+
124
+ const a = document.createElement('a');
125
+ a.href = url;
126
+ a.download = filename;
127
+ a.click();
128
+ }
129
+
130
+ // Clear recorded audio and free memory
131
+ clearRecording(): void {
132
+ // Revoke URL to free memory
133
+ const url = this.recordedAudioUrl();
134
+ if (url) {
135
+ URL.revokeObjectURL(url);
136
+ }
137
+
138
+ this.recordedAudioBlob.set(null);
139
+ this.recordedAudioUrl.set(null);
140
+ this.audioChunks = [];
141
+ }
142
+
143
+ private updateWaveForm(): void {
144
+ if (!this.analyser || !this.isRecording()) return;
145
+
146
+ // Get frequency data
147
+ const bufferLength: number = this.analyser.frequencyBinCount; // 128 (FFT_SIZE/2)
148
+ const dataArray: Uint8Array<ArrayBuffer> = new Uint8Array(bufferLength);
149
+ this.analyser.getByteFrequencyData(dataArray); // Values 0-255
150
+
151
+ // Reduce to BAR_COUNT bars (average frequencies)
152
+ const bars: number[] = [];
153
+ const samplesPerBar: number = Math.floor(bufferLength / this.BAR_COUNT);
154
+
155
+ for (let i = 0; i < this.BAR_COUNT; i++) {
156
+ const start = i * samplesPerBar;
157
+ const end = start + samplesPerBar;
158
+
159
+ // Average frequency values for this bar
160
+ let sum = 0;
161
+ for (let j = start; j < end; j++) {
162
+ sum += dataArray[j];
163
+ }
164
+ const average = sum / samplesPerBar;
165
+
166
+ // Normalize to 0-100 range for height percentage
167
+ const normalized = (average / 255) * 100;
168
+ bars.push(normalized);
169
+ }
170
+
171
+ this.audioData.set(bars);
172
+
173
+ this.animationFrame = requestAnimationFrame(() => this.updateWaveForm());
174
+ }
175
+ }
@@ -0,0 +1,23 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { AudioWaveform } from './audio-waveform';
4
+
5
+ describe('AudioWaveform', () => {
6
+ let component: AudioWaveform;
7
+ let fixture: ComponentFixture<AudioWaveform>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [AudioWaveform]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(AudioWaveform);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,64 @@
1
+ import { Component, effect, input, InputSignal, signal, WritableSignal } from '@angular/core';
2
+
3
+ import { AudioWaveformService } from './audio-waveform.service';
4
+
5
+ @Component({
6
+ selector: 'wally-audio-waveform',
7
+ imports: [],
8
+ providers: [AudioWaveformService],
9
+ templateUrl: './audio-waveform.html',
10
+ styleUrl: './audio-waveform.css'
11
+ })
12
+ export class AudioWaveform {
13
+ isStartRecording: InputSignal<boolean> = input<boolean>(false);
14
+ isStopRecording: InputSignal<boolean> = input<boolean>(false);
15
+ showTimer: InputSignal<boolean> = input<boolean>(false);
16
+
17
+ recordingTime: WritableSignal<number> = signal<number>(0);
18
+ private timerInterval: any = null;
19
+
20
+ constructor(
21
+ public audioWaveformService: AudioWaveformService
22
+ ) {
23
+ effect(() => {
24
+ if (this.isStartRecording()) {
25
+ this.startRecording();
26
+ }
27
+ });
28
+ effect(() => {
29
+ if (this.isStopRecording()) {
30
+ this.stopRecording();
31
+ }
32
+ });
33
+ }
34
+
35
+ async startRecording(): Promise<void> {
36
+ try {
37
+ await this.audioWaveformService.startRecording();
38
+
39
+ this.recordingTime.set(0);
40
+ this.timerInterval = setInterval(() => {
41
+ this.recordingTime.update(time => time + 1);
42
+ }, 1000);
43
+ } catch (error) {
44
+ console.error('Failed to start recording:', error);
45
+ }
46
+ }
47
+
48
+ stopRecording(): void {
49
+ this.audioWaveformService.stopRecording();
50
+
51
+ // Stop timer
52
+ if (this.timerInterval) {
53
+ clearInterval(this.timerInterval);
54
+ }
55
+ }
56
+
57
+ // Format seconds to MM:SS
58
+ get formattedTime(): string {
59
+ const time = this.recordingTime();
60
+ const minutes = Math.floor(time / 60);
61
+ const seconds = time % 60;
62
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
63
+ }
64
+ }
@@ -0,0 +1,146 @@
1
+ // Audio Waveform documentation code examples
2
+ export const AudioWaveformCodeExamples = {
3
+ // Installation
4
+ installation: `npx wally-ui add audio-waveform`,
5
+
6
+ // Import examples
7
+ import: `import { AudioWaveform } from './components/wally-ui/audio-waveform/audio-waveform';
8
+ import { AudioWaveformService } from './components/wally-ui/audio-waveform/audio-waveform.service';`,
9
+
10
+ componentImport: `@Component({
11
+ selector: 'app-example',
12
+ imports: [AudioWaveform],
13
+ templateUrl: './example.html'
14
+ })`,
15
+
16
+ // Basic usage
17
+ basicUsage: `<wally-audio-waveform
18
+ [isStartRecording]="isRecording()"
19
+ [isStopRecording]="!isRecording()">
20
+ </wally-audio-waveform>`,
21
+
22
+ // With timer
23
+ withTimer: `<wally-audio-waveform
24
+ [isStartRecording]="isRecording()"
25
+ [isStopRecording]="!isRecording()"
26
+ [showTimer]="true">
27
+ </wally-audio-waveform>`,
28
+
29
+ // Complete Example with Controls
30
+ completeExample: `<div class="flex flex-col gap-4">
31
+ <!-- Waveform Visualizer -->
32
+ <wally-audio-waveform
33
+ [isStartRecording]="isRecording()"
34
+ [isStopRecording]="!isRecording()"
35
+ [showTimer]="true">
36
+ </wally-audio-waveform>
37
+
38
+ <!-- Recording Controls -->
39
+ <div class="flex gap-2">
40
+ @if (!isRecording()) {
41
+ <button (click)="startRecording()"
42
+ class="px-4 py-2 bg-red-500 text-white rounded-full">
43
+ Start Recording
44
+ </button>
45
+ } @else {
46
+ <button (click)="stopRecording()"
47
+ class="px-4 py-2 bg-neutral-500 text-white rounded-full">
48
+ Stop Recording
49
+ </button>
50
+ }
51
+ </div>
52
+ </div>`,
53
+
54
+ completeExampleTs: `export class RecordingComponent {
55
+ isRecording = signal(false);
56
+
57
+ startRecording() {
58
+ this.isRecording.set(true);
59
+ }
60
+
61
+ stopRecording() {
62
+ this.isRecording.set(false);
63
+ }
64
+ }`,
65
+
66
+ // Service Integration
67
+ serviceIntegration: `// Access the service to get recorded audio
68
+ export class RecordingComponent {
69
+ @ViewChild(AudioWaveform) audioWaveform!: AudioWaveform;
70
+
71
+ downloadRecording() {
72
+ const service = this.audioWaveform.audioWaveformService;
73
+ service.downloadRecording('my-recording.webm');
74
+ }
75
+
76
+ getRecordedBlob() {
77
+ const service = this.audioWaveform.audioWaveformService;
78
+ const blob = service.recordedAudioBlob();
79
+ return blob;
80
+ }
81
+
82
+ clearRecording() {
83
+ const service = this.audioWaveform.audioWaveformService;
84
+ service.clearRecording();
85
+ }
86
+ }`,
87
+
88
+ // Responsive Configuration
89
+ responsiveConfig: `// Service Configuration (auto-responsive)
90
+ // Mobile (< 768px): 30 bars
91
+ // Desktop (>= 768px): 65 bars
92
+ // Automatically adjusts based on screen width`,
93
+
94
+ // Future: Audio Transcription
95
+ futureTranscription: `// COMING SOON: Audio Transcription
96
+ // The component will support automatic transcription in a future release
97
+
98
+ <wally-audio-waveform
99
+ [isStartRecording]="isRecording()"
100
+ [isStopRecording]="!isRecording()"
101
+ [enableTranscription]="true"
102
+ (transcriptionComplete)="onTranscription($event)">
103
+ </wally-audio-waveform>`,
104
+
105
+ // Web Audio API Concepts
106
+ webAudioConcepts: `// How it works under the hood:
107
+ // 1. getUserMedia() - Request microphone access
108
+ // 2. AudioContext - Process audio in real-time
109
+ // 3. AnalyserNode - FFT analysis (256 samples → 128 frequencies)
110
+ // 4. getByteFrequencyData() - Get frequency values (0-255)
111
+ // 5. Normalize to 0-100% for bar heights
112
+ // 6. requestAnimationFrame - Smooth 60fps animation`,
113
+
114
+ // Properties: Inputs
115
+ propertyIsStartRecording: `isStartRecording: InputSignal<boolean> = input<boolean>(false);`,
116
+ propertyIsStopRecording: `isStopRecording: InputSignal<boolean> = input<boolean>(false);`,
117
+ propertyShowTimer: `showTimer: InputSignal<boolean> = input<boolean>(false);`,
118
+
119
+ // Properties: Service Signals
120
+ servicePropertyIsRecording: `isRecording: WritableSignal<boolean>`,
121
+ servicePropertyAudioData: `audioData: WritableSignal<number[]> // Bar heights (0-100%)`,
122
+ servicePropertyRecordedAudioBlob: `recordedAudioBlob: WritableSignal<Blob | null>`,
123
+ servicePropertyRecordedAudioUrl: `recordedAudioUrl: WritableSignal<string | null>`,
124
+
125
+ // Methods
126
+ methodDownloadRecording: `downloadRecording(filename?: string): void`,
127
+ methodClearRecording: `clearRecording(): void`,
128
+
129
+ // Configuration constants
130
+ configFFTSize: `FFT_SIZE = 256 // Higher = more frequency detail`,
131
+ configBarCount: `BAR_COUNT = 30 (mobile) | 65 (desktop) // Auto-responsive`,
132
+ configSmoothing: `SMOOTHING = 0.8 // 0 (no smoothing) to 1 (max smoothing)`,
133
+
134
+ // Accessibility
135
+ accessibilityExample: `<!-- The component includes built-in accessibility -->
136
+ <!-- Timer updates announced via aria-live region (when showTimer=true) -->
137
+ <!-- Waveform visualizer is decorative (aria-hidden) -->`,
138
+
139
+ // Browser Support
140
+ browserSupport: `// Requires modern browsers with:
141
+ // - Web Audio API
142
+ // - MediaStream API
143
+ // - MediaRecorder API
144
+ //
145
+ // Supported: Chrome 60+, Firefox 55+, Safari 14+, Edge 79+`,
146
+ };