@ngbase/adk 0.1.16 → 0.1.18
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.
- package/fesm2022/ngbase-adk-a11y.mjs +42 -42
- package/fesm2022/ngbase-adk-a11y.mjs.map +1 -1
- package/fesm2022/ngbase-adk-accordion.mjs +20 -26
- package/fesm2022/ngbase-adk-accordion.mjs.map +1 -1
- package/fesm2022/ngbase-adk-autocomplete.mjs +11 -11
- package/fesm2022/ngbase-adk-autocomplete.mjs.map +1 -1
- package/fesm2022/ngbase-adk-avatar.mjs +13 -13
- package/fesm2022/ngbase-adk-avatar.mjs.map +1 -1
- package/fesm2022/ngbase-adk-bidi.mjs +3 -3
- package/fesm2022/ngbase-adk-bidi.mjs.map +1 -1
- package/fesm2022/ngbase-adk-breadcrumb.mjs +14 -14
- package/fesm2022/ngbase-adk-breadcrumb.mjs.map +1 -1
- package/fesm2022/ngbase-adk-cache.mjs +3 -3
- package/fesm2022/ngbase-adk-cache.mjs.map +1 -1
- package/fesm2022/ngbase-adk-carousel.mjs +18 -18
- package/fesm2022/ngbase-adk-carousel.mjs.map +1 -1
- package/fesm2022/ngbase-adk-checkbox.mjs +15 -21
- package/fesm2022/ngbase-adk-checkbox.mjs.map +1 -1
- package/fesm2022/ngbase-adk-chip.mjs +12 -12
- package/fesm2022/ngbase-adk-chip.mjs.map +1 -1
- package/fesm2022/ngbase-adk-clipboard.mjs +7 -5
- package/fesm2022/ngbase-adk-clipboard.mjs.map +1 -1
- package/fesm2022/ngbase-adk-collections.mjs.map +1 -1
- package/fesm2022/ngbase-adk-color-picker.mjs +44 -53
- package/fesm2022/ngbase-adk-color-picker.mjs.map +1 -1
- package/fesm2022/ngbase-adk-cookies.mjs +3 -3
- package/fesm2022/ngbase-adk-cookies.mjs.map +1 -1
- package/fesm2022/ngbase-adk-datepicker.mjs +70 -89
- package/fesm2022/ngbase-adk-datepicker.mjs.map +1 -1
- package/fesm2022/ngbase-adk-dialog.mjs +17 -39
- package/fesm2022/ngbase-adk-dialog.mjs.map +1 -1
- package/fesm2022/ngbase-adk-drag.mjs +20 -20
- package/fesm2022/ngbase-adk-drag.mjs.map +1 -1
- package/fesm2022/ngbase-adk-form-field.mjs +65 -118
- package/fesm2022/ngbase-adk-form-field.mjs.map +1 -1
- package/fesm2022/ngbase-adk-hover-card.mjs +5 -5
- package/fesm2022/ngbase-adk-hover-card.mjs.map +1 -1
- package/fesm2022/ngbase-adk-icon.mjs +9 -11
- package/fesm2022/ngbase-adk-icon.mjs.map +1 -1
- package/fesm2022/ngbase-adk-inline-edit.mjs +27 -35
- package/fesm2022/ngbase-adk-inline-edit.mjs.map +1 -1
- package/fesm2022/ngbase-adk-jwt.mjs +319 -41
- package/fesm2022/ngbase-adk-jwt.mjs.map +1 -1
- package/fesm2022/ngbase-adk-keys.mjs +6 -6
- package/fesm2022/ngbase-adk-keys.mjs.map +1 -1
- package/fesm2022/ngbase-adk-layout.mjs.map +1 -1
- package/fesm2022/ngbase-adk-list.mjs +10 -10
- package/fesm2022/ngbase-adk-list.mjs.map +1 -1
- package/fesm2022/ngbase-adk-mask.mjs +8 -8
- package/fesm2022/ngbase-adk-mask.mjs.map +1 -1
- package/fesm2022/ngbase-adk-menu.mjs +69 -79
- package/fesm2022/ngbase-adk-menu.mjs.map +1 -1
- package/fesm2022/ngbase-adk-network.mjs +3 -3
- package/fesm2022/ngbase-adk-network.mjs.map +1 -1
- package/fesm2022/ngbase-adk-otp.mjs +24 -45
- package/fesm2022/ngbase-adk-otp.mjs.map +1 -1
- package/fesm2022/ngbase-adk-pagination.mjs +9 -9
- package/fesm2022/ngbase-adk-pagination.mjs.map +1 -1
- package/fesm2022/ngbase-adk-popover.mjs +120 -89
- package/fesm2022/ngbase-adk-popover.mjs.map +1 -1
- package/fesm2022/ngbase-adk-portal.mjs +134 -47
- package/fesm2022/ngbase-adk-portal.mjs.map +1 -1
- package/fesm2022/ngbase-adk-progress.mjs +7 -7
- package/fesm2022/ngbase-adk-progress.mjs.map +1 -1
- package/fesm2022/ngbase-adk-radio.mjs +20 -27
- package/fesm2022/ngbase-adk-radio.mjs.map +1 -1
- package/fesm2022/ngbase-adk-resizable.mjs +138 -48
- package/fesm2022/ngbase-adk-resizable.mjs.map +1 -1
- package/fesm2022/ngbase-adk-scroll-area.mjs +28 -20
- package/fesm2022/ngbase-adk-scroll-area.mjs.map +1 -1
- package/fesm2022/ngbase-adk-select.mjs +58 -80
- package/fesm2022/ngbase-adk-select.mjs.map +1 -1
- package/fesm2022/ngbase-adk-selectable.mjs +19 -30
- package/fesm2022/ngbase-adk-selectable.mjs.map +1 -1
- package/fesm2022/ngbase-adk-sheet.mjs +6 -20
- package/fesm2022/ngbase-adk-sheet.mjs.map +1 -1
- package/fesm2022/ngbase-adk-sidenav.mjs +65 -46
- package/fesm2022/ngbase-adk-sidenav.mjs.map +1 -1
- package/fesm2022/ngbase-adk-slider.mjs +40 -53
- package/fesm2022/ngbase-adk-slider.mjs.map +1 -1
- package/fesm2022/ngbase-adk-sonner.mjs +12 -19
- package/fesm2022/ngbase-adk-sonner.mjs.map +1 -1
- package/fesm2022/ngbase-adk-stepper.mjs +17 -25
- package/fesm2022/ngbase-adk-stepper.mjs.map +1 -1
- package/fesm2022/ngbase-adk-switch.mjs +25 -32
- package/fesm2022/ngbase-adk-switch.mjs.map +1 -1
- package/fesm2022/ngbase-adk-table.mjs +581 -83
- package/fesm2022/ngbase-adk-table.mjs.map +1 -1
- package/fesm2022/ngbase-adk-tabs.mjs +37 -35
- package/fesm2022/ngbase-adk-tabs.mjs.map +1 -1
- package/fesm2022/ngbase-adk-test.mjs.map +1 -1
- package/fesm2022/ngbase-adk-toggle-group.mjs +20 -34
- package/fesm2022/ngbase-adk-toggle-group.mjs.map +1 -1
- package/fesm2022/ngbase-adk-toggle.mjs +14 -19
- package/fesm2022/ngbase-adk-toggle.mjs.map +1 -1
- package/fesm2022/ngbase-adk-tooltip.mjs +12 -19
- package/fesm2022/ngbase-adk-tooltip.mjs.map +1 -1
- package/fesm2022/ngbase-adk-tour.mjs +52 -52
- package/fesm2022/ngbase-adk-tour.mjs.map +1 -1
- package/fesm2022/ngbase-adk-translate.mjs +8 -10
- package/fesm2022/ngbase-adk-translate.mjs.map +1 -1
- package/fesm2022/ngbase-adk-tree.mjs +20 -20
- package/fesm2022/ngbase-adk-tree.mjs.map +1 -1
- package/fesm2022/ngbase-adk-utils.mjs +30 -43
- package/fesm2022/ngbase-adk-utils.mjs.map +1 -1
- package/fesm2022/ngbase-adk-virtualizer.mjs +9 -9
- package/fesm2022/ngbase-adk-virtualizer.mjs.map +1 -1
- package/package.json +91 -91
- package/schematics/components/files/accordion/accordion.ts.template +9 -6
- package/schematics/components/files/audio/AudioPlayer.ts.template +245 -0
- package/schematics/components/files/audio/AudioRecorder.ts.template +377 -0
- package/schematics/components/files/audio/AudioVisualizer.ts.template +175 -0
- package/schematics/components/files/audio/index.ts.template +3 -0
- package/schematics/components/files/button/button-llm.md.template +3 -2
- package/schematics/components/files/charts/area-chart.component.ts.template +278 -0
- package/schematics/components/files/charts/bar-chart.component.ts.template +262 -0
- package/schematics/components/files/charts/chart-tooltip.component.ts.template +168 -0
- package/schematics/components/files/charts/index.ts.template +4 -0
- package/schematics/components/files/charts/line-chart.component.ts.template +238 -0
- package/schematics/components/files/charts/pie-chart.component.ts.template +283 -0
- package/schematics/components/files/checkbox/checkbox.ts.template +2 -2
- package/schematics/components/files/color-picker/color-picker.ts.template +2 -2
- package/schematics/components/files/dialog/dialog.ts.template +18 -14
- package/schematics/components/files/drawer/drawer.ts.template +30 -27
- package/schematics/components/files/form-field/form-field.ts.template +51 -23
- package/schematics/components/files/pagination/pagination.ts.template +4 -4
- package/schematics/components/files/picasa/picasa-base.component.ts.template +15 -30
- package/schematics/components/files/popover/popover.ts.template +15 -4
- package/schematics/components/files/select/list-selection.ts.template +0 -2
- package/schematics/components/files/select/option.ts.template +1 -1
- package/schematics/components/files/selectable/selectable.ts.template +2 -2
- package/schematics/components/files/sheet/sheet.ts.template +26 -14
- package/schematics/components/files/sidenav/sidenav.ts.template +7 -5
- package/schematics/components/files/sonner/sonner.ts.template +1 -2
- package/schematics/components/files/stepper/stepper.ts.template +2 -4
- package/schematics/components/files/switch/switch.ts.template +2 -2
- package/schematics/components/files/table/table.ts.template +43 -3
- package/schematics/components/files/tabs/tab.ts.template +3 -3
- package/schematics/components/files/theme/theme.service.ts.template +3 -3
- package/schematics/components/files/toggle/toggle.ts.template +1 -1
- package/schematics/components/files/toggle-group/toggle-group.ts.template +1 -1
- package/schematics/components/files/tooltip/tooltip.ts.template +2 -3
- package/schematics/components/schema.json +2 -0
- package/{accordion/index.d.ts → types/ngbase-adk-accordion.d.ts} +1 -3
- package/{autocomplete/index.d.ts → types/ngbase-adk-autocomplete.d.ts} +2 -7
- package/{checkbox/index.d.ts → types/ngbase-adk-checkbox.d.ts} +8 -14
- package/types/ngbase-adk-clipboard.d.ts +12 -0
- package/{color-picker/index.d.ts → types/ngbase-adk-color-picker.d.ts} +14 -26
- package/{datepicker/index.d.ts → types/ngbase-adk-datepicker.d.ts} +9 -18
- package/{dialog/index.d.ts → types/ngbase-adk-dialog.d.ts} +3 -8
- package/types/ngbase-adk-form-field.d.ts +88 -0
- package/{inline-edit/index.d.ts → types/ngbase-adk-inline-edit.d.ts} +8 -16
- package/types/ngbase-adk-jwt.d.ts +64 -0
- package/{menu/index.d.ts → types/ngbase-adk-menu.d.ts} +6 -5
- package/{otp/index.d.ts → types/ngbase-adk-otp.d.ts} +8 -16
- package/{popover/index.d.ts → types/ngbase-adk-popover.d.ts} +14 -2
- package/{portal/index.d.ts → types/ngbase-adk-portal.d.ts} +29 -8
- package/{radio/index.d.ts → types/ngbase-adk-radio.d.ts} +9 -12
- package/{resizable/index.d.ts → types/ngbase-adk-resizable.d.ts} +4 -4
- package/{scroll-area/index.d.ts → types/ngbase-adk-scroll-area.d.ts} +2 -1
- package/{select/index.d.ts → types/ngbase-adk-select.d.ts} +8 -22
- package/{selectable/index.d.ts → types/ngbase-adk-selectable.d.ts} +6 -10
- package/{sheet/index.d.ts → types/ngbase-adk-sheet.d.ts} +4 -3
- package/{sidenav/index.d.ts → types/ngbase-adk-sidenav.d.ts} +6 -6
- package/{slider/index.d.ts → types/ngbase-adk-slider.d.ts} +8 -17
- package/{sonner/index.d.ts → types/ngbase-adk-sonner.d.ts} +1 -3
- package/{stepper/index.d.ts → types/ngbase-adk-stepper.d.ts} +1 -4
- package/{switch/index.d.ts → types/ngbase-adk-switch.d.ts} +7 -14
- package/{table/index.d.ts → types/ngbase-adk-table.d.ts} +126 -3
- package/{test/index.d.ts → types/ngbase-adk-test.d.ts} +1 -1
- package/{toggle-group/index.d.ts → types/ngbase-adk-toggle-group.d.ts} +5 -10
- package/types/ngbase-adk-toggle.d.ts +14 -0
- package/{tooltip/index.d.ts → types/ngbase-adk-tooltip.d.ts} +9 -11
- package/{tour/index.d.ts → types/ngbase-adk-tour.d.ts} +4 -6
- package/{utils/index.d.ts → types/ngbase-adk-utils.d.ts} +15 -11
- package/clipboard/index.d.ts +0 -11
- package/form-field/index.d.ts +0 -97
- package/jwt/index.d.ts +0 -20
- package/toggle/index.d.ts +0 -16
- /package/{a11y/index.d.ts → types/ngbase-adk-a11y.d.ts} +0 -0
- /package/{avatar/index.d.ts → types/ngbase-adk-avatar.d.ts} +0 -0
- /package/{bidi/index.d.ts → types/ngbase-adk-bidi.d.ts} +0 -0
- /package/{breadcrumb/index.d.ts → types/ngbase-adk-breadcrumb.d.ts} +0 -0
- /package/{cache/index.d.ts → types/ngbase-adk-cache.d.ts} +0 -0
- /package/{carousel/index.d.ts → types/ngbase-adk-carousel.d.ts} +0 -0
- /package/{chip/index.d.ts → types/ngbase-adk-chip.d.ts} +0 -0
- /package/{collections/index.d.ts → types/ngbase-adk-collections.d.ts} +0 -0
- /package/{cookies/index.d.ts → types/ngbase-adk-cookies.d.ts} +0 -0
- /package/{drag/index.d.ts → types/ngbase-adk-drag.d.ts} +0 -0
- /package/{hover-card/index.d.ts → types/ngbase-adk-hover-card.d.ts} +0 -0
- /package/{icon/index.d.ts → types/ngbase-adk-icon.d.ts} +0 -0
- /package/{keys/index.d.ts → types/ngbase-adk-keys.d.ts} +0 -0
- /package/{layout/index.d.ts → types/ngbase-adk-layout.d.ts} +0 -0
- /package/{list/index.d.ts → types/ngbase-adk-list.d.ts} +0 -0
- /package/{mask/index.d.ts → types/ngbase-adk-mask.d.ts} +0 -0
- /package/{network/index.d.ts → types/ngbase-adk-network.d.ts} +0 -0
- /package/{pagination/index.d.ts → types/ngbase-adk-pagination.d.ts} +0 -0
- /package/{progress/index.d.ts → types/ngbase-adk-progress.d.ts} +0 -0
- /package/{tabs/index.d.ts → types/ngbase-adk-tabs.d.ts} +0 -0
- /package/{translate/index.d.ts → types/ngbase-adk-translate.d.ts} +0 -0
- /package/{tree/index.d.ts → types/ngbase-adk-tree.d.ts} +0 -0
- /package/{virtualizer/index.d.ts → types/ngbase-adk-virtualizer.d.ts} +0 -0
- /package/{index.d.ts → types/ngbase-adk.d.ts} +0 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
signal,
|
|
6
|
+
output,
|
|
7
|
+
OnDestroy,
|
|
8
|
+
effect,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { AudioVisualizer } from './AudioVisualizer';
|
|
11
|
+
import { provideIcons } from '@ng-icons/core';
|
|
12
|
+
import { lucideMic, lucideSquare, lucidePlay, lucidePause } from '@ng-icons/lucide';
|
|
13
|
+
import { Icon } from '<%= basepath %>/icon';
|
|
14
|
+
import { Button } from '<%= basepath %>/button';
|
|
15
|
+
|
|
16
|
+
export type RecorderState = 'idle' | 'recording' | 'stopped' | 'playing' | 'paused';
|
|
17
|
+
|
|
18
|
+
@Component({
|
|
19
|
+
selector: '<%= name %>-audio-recorder',
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
viewProviders: [provideIcons({ lucideMic, lucideSquare, lucidePlay, lucidePause })],
|
|
22
|
+
imports: [AudioVisualizer, Icon, Button],
|
|
23
|
+
template: `
|
|
24
|
+
<div class="space-y-3">
|
|
25
|
+
<!-- Visualizer and Controls -->
|
|
26
|
+
<div class="bg-card flex items-center rounded-xl border p-1">
|
|
27
|
+
<!-- Record/Stop Button -->
|
|
28
|
+
<button
|
|
29
|
+
(click)="handleRecordClick()"
|
|
30
|
+
<%= name %>Button="secondary"
|
|
31
|
+
[disabled]="state() === 'playing'"
|
|
32
|
+
[attr.aria-label]="state() === 'recording' ? 'Stop Recording' : 'Start Recording'"
|
|
33
|
+
class="aspect-square !px-2"
|
|
34
|
+
>
|
|
35
|
+
<<%= name %>-icon [name]="state() === 'recording' ? 'lucideSquare' : 'lucideMic'" />
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
<!-- Visualizer -->
|
|
39
|
+
<div class="relative h-8 flex-grow overflow-hidden">
|
|
40
|
+
<app-audio-visualizer
|
|
41
|
+
[audioBuffer]="state() === 'recording' ? liveAudioBuffer() : audioBuffer()"
|
|
42
|
+
[progress]="state() === 'recording' ? 1 : playbackProgress()"
|
|
43
|
+
[useFixedBarWidth]="state() === 'recording'"
|
|
44
|
+
/>
|
|
45
|
+
@if (state() === 'recording') {
|
|
46
|
+
<div class="absolute top-2 right-2 flex items-center gap-2">
|
|
47
|
+
<div class="h-2 w-2 animate-pulse rounded-full bg-red-500"></div>
|
|
48
|
+
<p class="text-xs font-medium text-red-600">{{ formattedTime() }}</p>
|
|
49
|
+
</div>
|
|
50
|
+
}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- Play/Pause Button -->
|
|
54
|
+
@if (state() === 'stopped' || state() === 'playing' || state() === 'paused') {
|
|
55
|
+
<button
|
|
56
|
+
(click)="handlePlayClick()"
|
|
57
|
+
<%= name %>Button="secondary"
|
|
58
|
+
[attr.aria-label]="state() === 'playing' ? 'Pause Audio' : 'Play Audio'"
|
|
59
|
+
class="aspect-square !px-2"
|
|
60
|
+
>
|
|
61
|
+
<<%= name %>-icon [name]="state() === 'playing' ? 'lucidePause' : 'lucidePlay'" />
|
|
62
|
+
</button>
|
|
63
|
+
}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Status Text -->
|
|
67
|
+
<div class="text-muted-foreground text-center text-sm">
|
|
68
|
+
@if (state() === 'idle') {
|
|
69
|
+
<p>Click the microphone to start recording</p>
|
|
70
|
+
} @else if (state() === 'recording') {
|
|
71
|
+
<p>Recording in progress...</p>
|
|
72
|
+
} @else if (state() === 'stopped' || state() === 'playing' || state() === 'paused') {
|
|
73
|
+
<p>Recording complete • {{ formattedTime() }}</p>
|
|
74
|
+
}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
`,
|
|
78
|
+
host: {
|
|
79
|
+
class: 'block w-full',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
export class AudioRecorder implements OnDestroy {
|
|
83
|
+
readonly state = signal<RecorderState>('idle');
|
|
84
|
+
readonly audioBuffer = signal<AudioBuffer | null>(null);
|
|
85
|
+
readonly audioBlob = signal<Blob | null>(null);
|
|
86
|
+
readonly recordingTime = signal(0);
|
|
87
|
+
readonly currentTime = signal(0);
|
|
88
|
+
readonly duration = signal(0);
|
|
89
|
+
readonly error = signal<string | null>(null);
|
|
90
|
+
readonly liveAudioBuffer = signal<AudioBuffer | null>(null);
|
|
91
|
+
|
|
92
|
+
// Outputs
|
|
93
|
+
readonly recordingComplete = output<Blob>();
|
|
94
|
+
readonly errorChange = output<string | null>();
|
|
95
|
+
|
|
96
|
+
private mediaRecorder: MediaRecorder | null = null;
|
|
97
|
+
private audioChunks: Blob[] = [];
|
|
98
|
+
private recordingTimer: number | null = null;
|
|
99
|
+
private audioElement: HTMLAudioElement | null = null;
|
|
100
|
+
private animationFrameId: number | null = null;
|
|
101
|
+
private audioContext: AudioContext | null = null;
|
|
102
|
+
private analyser: AnalyserNode | null = null;
|
|
103
|
+
private dataArray: Uint8Array<ArrayBuffer> | null = null;
|
|
104
|
+
private amplitudeHistory: number[] = [];
|
|
105
|
+
private lastSampleTime = 0;
|
|
106
|
+
private readonly sampleInterval = 100; // Sample every 100ms
|
|
107
|
+
|
|
108
|
+
readonly playbackProgress = computed(() =>
|
|
109
|
+
this.duration() > 0 ? this.currentTime() / this.duration() : 0,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
readonly formattedTime = computed(() => {
|
|
113
|
+
const seconds = this.state() === 'recording' ? this.recordingTime() : this.duration();
|
|
114
|
+
return this.formatTime(seconds);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
constructor() {
|
|
118
|
+
effect(() => {
|
|
119
|
+
const currentState = this.state();
|
|
120
|
+
if (currentState === 'stopped' && this.audioBlob()) {
|
|
121
|
+
this.decodeAudioBuffer();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async handleRecordClick(): Promise<void> {
|
|
127
|
+
if (this.state() === 'recording') {
|
|
128
|
+
this.stopRecording();
|
|
129
|
+
} else {
|
|
130
|
+
await this.startRecording();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async handlePlayClick(): Promise<void> {
|
|
135
|
+
if (this.state() === 'playing') {
|
|
136
|
+
this.pausePlayback();
|
|
137
|
+
} else {
|
|
138
|
+
await this.startPlayback();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ngOnDestroy(): void {
|
|
143
|
+
this.cleanup();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async startRecording(): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
149
|
+
|
|
150
|
+
// Initialize audio context for live visualization
|
|
151
|
+
if (!this.audioContext) {
|
|
152
|
+
this.audioContext = new AudioContext();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Set up analyser for live waveform
|
|
156
|
+
this.analyser = this.audioContext.createAnalyser();
|
|
157
|
+
this.analyser.fftSize = 256;
|
|
158
|
+
const bufferLength = this.analyser.frequencyBinCount;
|
|
159
|
+
this.dataArray = new Uint8Array(bufferLength);
|
|
160
|
+
|
|
161
|
+
// Connect microphone to analyser
|
|
162
|
+
const source = this.audioContext.createMediaStreamSource(stream);
|
|
163
|
+
source.connect(this.analyser);
|
|
164
|
+
|
|
165
|
+
// Start MediaRecorder
|
|
166
|
+
this.mediaRecorder = new MediaRecorder(stream);
|
|
167
|
+
this.audioChunks = [];
|
|
168
|
+
this.recordingTime.set(0);
|
|
169
|
+
this.amplitudeHistory = [];
|
|
170
|
+
this.lastSampleTime = performance.now();
|
|
171
|
+
|
|
172
|
+
this.mediaRecorder.ondataavailable = event => {
|
|
173
|
+
if (event.data.size > 0) {
|
|
174
|
+
this.audioChunks.push(event.data);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.mediaRecorder.onstop = () => {
|
|
179
|
+
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
|
180
|
+
this.audioBlob.set(blob);
|
|
181
|
+
this.recordingComplete.emit(blob);
|
|
182
|
+
this.state.set('stopped');
|
|
183
|
+
this.stopLiveVisualization();
|
|
184
|
+
|
|
185
|
+
// Stop all tracks
|
|
186
|
+
stream.getTracks().forEach(track => track.stop());
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.mediaRecorder.start();
|
|
190
|
+
this.state.set('recording');
|
|
191
|
+
|
|
192
|
+
// Start live visualization
|
|
193
|
+
this.startLiveVisualization();
|
|
194
|
+
|
|
195
|
+
// Start recording timer
|
|
196
|
+
this.recordingTimer = window.setInterval(() => {
|
|
197
|
+
this.recordingTime.update(time => time + 1);
|
|
198
|
+
}, 1000);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('Failed to start recording:', error);
|
|
201
|
+
const errorMsg = 'Failed to access microphone. Please grant permission.';
|
|
202
|
+
this.error.set(errorMsg);
|
|
203
|
+
this.errorChange.emit(errorMsg);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private stopRecording(): void {
|
|
208
|
+
if (this.mediaRecorder && this.state() === 'recording') {
|
|
209
|
+
this.mediaRecorder.stop();
|
|
210
|
+
if (this.recordingTimer) {
|
|
211
|
+
clearInterval(this.recordingTimer);
|
|
212
|
+
this.recordingTimer = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async decodeAudioBuffer(): Promise<void> {
|
|
218
|
+
const blob = this.audioBlob();
|
|
219
|
+
if (!blob) return;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (!this.audioContext) {
|
|
223
|
+
this.audioContext = new AudioContext();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
227
|
+
const buffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
|
228
|
+
this.audioBuffer.set(buffer);
|
|
229
|
+
this.duration.set(buffer.duration);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error('Failed to decode audio:', error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async startPlayback(): Promise<void> {
|
|
236
|
+
const blob = this.audioBlob();
|
|
237
|
+
if (!blob) return;
|
|
238
|
+
|
|
239
|
+
if (!this.audioElement) {
|
|
240
|
+
this.audioElement = new Audio();
|
|
241
|
+
this.audioElement.src = URL.createObjectURL(blob);
|
|
242
|
+
|
|
243
|
+
this.audioElement.onended = () => {
|
|
244
|
+
this.state.set('stopped');
|
|
245
|
+
this.stopAnimationLoop();
|
|
246
|
+
this.currentTime.set(0);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await this.audioElement.play();
|
|
252
|
+
this.state.set('playing');
|
|
253
|
+
this.startAnimationLoop();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.error('Playback failed:', error);
|
|
256
|
+
const errorMsg = 'Could not play audio.';
|
|
257
|
+
this.error.set(errorMsg);
|
|
258
|
+
this.errorChange.emit(errorMsg);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private pausePlayback(): void {
|
|
263
|
+
if (this.audioElement && this.state() === 'playing') {
|
|
264
|
+
this.audioElement.pause();
|
|
265
|
+
this.state.set('paused');
|
|
266
|
+
this.stopAnimationLoop();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private startAnimationLoop(): void {
|
|
271
|
+
if (!this.audioElement) return;
|
|
272
|
+
|
|
273
|
+
const animate = () => {
|
|
274
|
+
if (this.audioElement) {
|
|
275
|
+
this.currentTime.set(this.audioElement.currentTime);
|
|
276
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
animate();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private stopAnimationLoop(): void {
|
|
283
|
+
if (this.animationFrameId) {
|
|
284
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
285
|
+
this.animationFrameId = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private startLiveVisualization(): void {
|
|
290
|
+
if (!this.analyser || !this.dataArray || !this.audioContext) return;
|
|
291
|
+
|
|
292
|
+
const visualize = () => {
|
|
293
|
+
if (this.state() !== 'recording' || !this.analyser || !this.dataArray) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const currentTime = performance.now();
|
|
298
|
+
|
|
299
|
+
// Sample amplitude at regular intervals
|
|
300
|
+
if (currentTime - this.lastSampleTime >= this.sampleInterval) {
|
|
301
|
+
// Get time domain data from analyser
|
|
302
|
+
this.analyser.getByteTimeDomainData(this.dataArray);
|
|
303
|
+
|
|
304
|
+
// Calculate average amplitude for this sample
|
|
305
|
+
let sum = 0;
|
|
306
|
+
for (let i = 0; i < this.dataArray.length; i++) {
|
|
307
|
+
const normalized = (this.dataArray[i] - 128) / 128.0;
|
|
308
|
+
sum += Math.abs(normalized);
|
|
309
|
+
}
|
|
310
|
+
const avgAmplitude = sum / this.dataArray.length;
|
|
311
|
+
|
|
312
|
+
// Add to history
|
|
313
|
+
this.amplitudeHistory.push(avgAmplitude);
|
|
314
|
+
this.lastSampleTime = currentTime;
|
|
315
|
+
|
|
316
|
+
// Create an AudioBuffer with all amplitude values
|
|
317
|
+
// The visualizer will handle showing only what fits in the canvas width
|
|
318
|
+
const sampleRate = this.audioContext!.sampleRate;
|
|
319
|
+
const samplesPerBar = Math.floor((sampleRate * this.sampleInterval) / 1000);
|
|
320
|
+
const totalSamples = this.amplitudeHistory.length * samplesPerBar;
|
|
321
|
+
|
|
322
|
+
const buffer = this.audioContext!.createBuffer(1, totalSamples, sampleRate);
|
|
323
|
+
const channelData = buffer.getChannelData(0);
|
|
324
|
+
|
|
325
|
+
// Fill the buffer with amplitude values, repeating each for the bar duration
|
|
326
|
+
for (let i = 0; i < this.amplitudeHistory.length; i++) {
|
|
327
|
+
const amplitude = this.amplitudeHistory[i];
|
|
328
|
+
const startIdx = i * samplesPerBar;
|
|
329
|
+
for (let j = 0; j < samplesPerBar && startIdx + j < totalSamples; j++) {
|
|
330
|
+
channelData[startIdx + j] = amplitude;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.liveAudioBuffer.set(buffer);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.animationFrameId = requestAnimationFrame(visualize);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
visualize();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private stopLiveVisualization(): void {
|
|
344
|
+
if (this.animationFrameId) {
|
|
345
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
346
|
+
this.animationFrameId = null;
|
|
347
|
+
}
|
|
348
|
+
this.liveAudioBuffer.set(null);
|
|
349
|
+
this.amplitudeHistory = [];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private cleanup(): void {
|
|
353
|
+
this.stopRecording();
|
|
354
|
+
this.stopAnimationLoop();
|
|
355
|
+
this.stopLiveVisualization();
|
|
356
|
+
|
|
357
|
+
if (this.audioElement) {
|
|
358
|
+
this.audioElement.pause();
|
|
359
|
+
if (this.audioElement.src) {
|
|
360
|
+
URL.revokeObjectURL(this.audioElement.src);
|
|
361
|
+
}
|
|
362
|
+
this.audioElement = null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (this.recordingTimer) {
|
|
366
|
+
clearInterval(this.recordingTimer);
|
|
367
|
+
this.recordingTimer = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private formatTime(seconds: number): string {
|
|
372
|
+
if (isNaN(seconds) || seconds === 0) return '0:00';
|
|
373
|
+
const mins = Math.floor(seconds / 60);
|
|
374
|
+
const secs = Math.floor(seconds % 60);
|
|
375
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
ElementRef,
|
|
5
|
+
viewChild,
|
|
6
|
+
input,
|
|
7
|
+
OnDestroy,
|
|
8
|
+
computed,
|
|
9
|
+
afterRenderEffect,
|
|
10
|
+
} from '@angular/core';
|
|
11
|
+
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'app-audio-visualizer',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
template: `<canvas #canvas class="h-full w-full"></canvas>`,
|
|
16
|
+
host: {
|
|
17
|
+
class: 'block w-full h-full',
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
export class AudioVisualizer implements OnDestroy {
|
|
21
|
+
audioBuffer = input<AudioBuffer | null>(null);
|
|
22
|
+
progress = input(0); // 0 to 1
|
|
23
|
+
useFixedBarWidth = input(false); // Whether to use fixed bar width (for recording) or fit all bars (for playback)
|
|
24
|
+
canvasEl = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
|
25
|
+
|
|
26
|
+
private canvasCtx: CanvasRenderingContext2D | null = null;
|
|
27
|
+
|
|
28
|
+
private readonly fixedBarWidth = 3; // Fixed width for each bar in pixels
|
|
29
|
+
private readonly barGap = 2; // Gap between bars
|
|
30
|
+
|
|
31
|
+
private readonly waveformData = computed(() => {
|
|
32
|
+
const buffer = this.audioBuffer();
|
|
33
|
+
if (!buffer) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rawData = buffer.getChannelData(0);
|
|
38
|
+
// Calculate number of bars based on the buffer duration and sample interval
|
|
39
|
+
// Assuming each bar represents 100ms of audio
|
|
40
|
+
const numBars = Math.ceil(rawData.length / (buffer.sampleRate * 0.1));
|
|
41
|
+
|
|
42
|
+
if (numBars <= 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const samplesPerBar = Math.floor(rawData.length / numBars);
|
|
47
|
+
const data: number[] = [];
|
|
48
|
+
for (let i = 0; i < numBars; i++) {
|
|
49
|
+
let sum = 0;
|
|
50
|
+
const start = i * samplesPerBar;
|
|
51
|
+
for (let j = 0; j < samplesPerBar; j++) {
|
|
52
|
+
sum += Math.abs(rawData[start + j]);
|
|
53
|
+
}
|
|
54
|
+
data.push(sum / samplesPerBar);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const maxAmp = Math.max(...data);
|
|
58
|
+
if (maxAmp === 0) {
|
|
59
|
+
return data.map(() => 0.05); // Draw a flat line for silence
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return data.map(d => d / maxAmp);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
constructor() {
|
|
66
|
+
afterRenderEffect(() => {
|
|
67
|
+
// Establish dependency on waveformData, progress, and useFixedBarWidth.
|
|
68
|
+
// This effect will run whenever they change.
|
|
69
|
+
this.waveformData();
|
|
70
|
+
this.progress();
|
|
71
|
+
this.useFixedBarWidth();
|
|
72
|
+
|
|
73
|
+
// Only attempt to draw if the canvas context is ready.
|
|
74
|
+
const canvas = this.canvasEl().nativeElement;
|
|
75
|
+
this.canvasCtx = canvas.getContext('2d');
|
|
76
|
+
this.resizeCanvas();
|
|
77
|
+
window.addEventListener('resize', this.resizeCanvas);
|
|
78
|
+
this.draw();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ngOnDestroy(): void {
|
|
83
|
+
// window.removeEventListener('resize', this.resizeCanvas);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private resizeCanvas = (): void => {
|
|
87
|
+
const canvas = this.canvasEl()?.nativeElement;
|
|
88
|
+
if (canvas && this.canvasCtx) {
|
|
89
|
+
const parent = canvas.parentElement;
|
|
90
|
+
if (parent) {
|
|
91
|
+
const dpr = window.devicePixelRatio || 1;
|
|
92
|
+
const rect = parent.getBoundingClientRect();
|
|
93
|
+
|
|
94
|
+
// Setting width/height resets canvas state, so we must do this every time.
|
|
95
|
+
canvas.width = rect.width * dpr;
|
|
96
|
+
canvas.height = rect.height * dpr;
|
|
97
|
+
this.canvasCtx.scale(dpr, dpr);
|
|
98
|
+
|
|
99
|
+
// Redraw with the new dimensions.
|
|
100
|
+
this.draw();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
private draw(): void {
|
|
106
|
+
if (!this.canvasCtx) return;
|
|
107
|
+
|
|
108
|
+
const canvas = this.canvasEl().nativeElement;
|
|
109
|
+
const ctx = this.canvasCtx;
|
|
110
|
+
// Use parent's client dimensions for drawing, as the context is scaled.
|
|
111
|
+
const { clientWidth: width, clientHeight: height } = canvas.parentElement!;
|
|
112
|
+
|
|
113
|
+
ctx.clearRect(0, 0, width, height);
|
|
114
|
+
|
|
115
|
+
const data = this.waveformData();
|
|
116
|
+
if (!data) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Use constant colors for canvas compatibility
|
|
121
|
+
const primaryColor = '#667eea';
|
|
122
|
+
const mutedForegroundColor = '#9ca3af';
|
|
123
|
+
|
|
124
|
+
const totalBars = data.length;
|
|
125
|
+
|
|
126
|
+
if (this.useFixedBarWidth()) {
|
|
127
|
+
// Fixed bar width mode (for recording)
|
|
128
|
+
const barSpacing = this.fixedBarWidth + this.barGap;
|
|
129
|
+
|
|
130
|
+
// Calculate how many bars can fit in the canvas width
|
|
131
|
+
const maxVisibleBars = Math.floor(width / barSpacing);
|
|
132
|
+
|
|
133
|
+
// Show only the most recent bars (from the right)
|
|
134
|
+
const startBarIndex = Math.max(0, totalBars - maxVisibleBars);
|
|
135
|
+
const visibleBars = data.slice(startBarIndex);
|
|
136
|
+
|
|
137
|
+
const progressIndex = Math.floor(this.progress() * totalBars);
|
|
138
|
+
|
|
139
|
+
// Draw bars from left to right
|
|
140
|
+
for (let i = 0; i < visibleBars.length; i++) {
|
|
141
|
+
const actualBarIndex = startBarIndex + i;
|
|
142
|
+
const barHeight = Math.max(1, visibleBars[i] * height * 0.9);
|
|
143
|
+
|
|
144
|
+
const x = i * barSpacing;
|
|
145
|
+
const y = (height - barHeight) / 2;
|
|
146
|
+
|
|
147
|
+
// Use primary color for progress, muted color for remaining
|
|
148
|
+
ctx.fillStyle = actualBarIndex < progressIndex ? primaryColor : mutedForegroundColor;
|
|
149
|
+
|
|
150
|
+
ctx.beginPath();
|
|
151
|
+
ctx.roundRect(x, y, this.fixedBarWidth, barHeight, 2);
|
|
152
|
+
ctx.fill();
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
// Fit all bars mode (for playback)
|
|
156
|
+
const barWidth = width / totalBars;
|
|
157
|
+
const progressIndex = Math.floor(this.progress() * totalBars);
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < totalBars; i++) {
|
|
160
|
+
const barHeight = Math.max(1, data[i] * height * 0.9);
|
|
161
|
+
|
|
162
|
+
const actualBarWidth = barWidth * 0.6;
|
|
163
|
+
const x = i * barWidth + barWidth * 0.2;
|
|
164
|
+
const y = (height - barHeight) / 2;
|
|
165
|
+
|
|
166
|
+
// Use primary color for progress, muted color for remaining
|
|
167
|
+
ctx.fillStyle = i < progressIndex ? primaryColor : mutedForegroundColor;
|
|
168
|
+
|
|
169
|
+
ctx.beginPath();
|
|
170
|
+
ctx.roundRect(x, y, actualBarWidth, barHeight, 2);
|
|
171
|
+
ctx.fill();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -11,14 +11,15 @@ import { Button } from '@/ui/button';
|
|
|
11
11
|
- ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'icon'
|
|
12
12
|
- **Inputs:**
|
|
13
13
|
|
|
14
|
-
-
|
|
14
|
+
- `<%= name %>Button`: ButtonVariant = 'primary'
|
|
15
15
|
|
|
16
16
|
- **Export:** `#button="<%= name %>Button"` - Template reference
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
20
20
|
```html
|
|
21
|
-
<button <%= name %>Button>Button</button>
|
|
21
|
+
<button <%= name %>Button>Button</button>
|
|
22
|
+
<button <%= name %>Button="ghost">Button</button>
|
|
22
23
|
<button <%= name %>Button="icon">
|
|
23
24
|
<<%= name %>-icon name="lucideHouse" />
|
|
24
25
|
</button>
|