@refraction-ui/astro 0.4.13 → 0.5.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.
@@ -0,0 +1,234 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ export type VoicePillSpeaker = 'ai' | 'user' | (string & {})
4
+
5
+ export type VoicePillPosition =
6
+ | 'top-start'
7
+ | 'top-center'
8
+ | 'top-end'
9
+ | 'bottom-start'
10
+ | 'bottom-center'
11
+ | 'bottom-end'
12
+ | 'left-start'
13
+ | 'left-center'
14
+ | 'left-end'
15
+ | 'right-start'
16
+ | 'right-center'
17
+ | 'right-end'
18
+
19
+ export interface VoicePillProps {
20
+ /** Speaker identity used for labels and data-speaker theming */
21
+ speaker?: VoicePillSpeaker
22
+ /** Primary voice status text */
23
+ label: string
24
+ /** Optional secondary status text */
25
+ sub?: string
26
+ /** Voice activity intensity, clamped from 0 to 1 */
27
+ intensity?: number
28
+ /** Whether voice output/input is muted */
29
+ muted?: boolean
30
+ /** Callback for the optional mute toggle */
31
+ onToggleMute?: () => void
32
+ /** Fixed viewport position */
33
+ position?: VoicePillPosition
34
+ }
35
+
36
+ export interface VoicePillStyleVars {
37
+ [key: `--${string}`]: string
38
+ }
39
+
40
+ export interface VoicePillAPI {
41
+ /** Display speaker label */
42
+ speaker: string
43
+ /** Normalized key for data-speaker */
44
+ speakerKey: string
45
+ /** Primary label */
46
+ label: string
47
+ /** Secondary label */
48
+ sub?: string
49
+ /** Clamped raw intensity */
50
+ intensity: number
51
+ /** Intensity used for visual animation after muted state is applied */
52
+ visualIntensity: number
53
+ /** Muted state */
54
+ muted: boolean
55
+ /** Fixed viewport position */
56
+ position: VoicePillPosition
57
+ /** Fallback initials for the speaker avatar */
58
+ initials: string
59
+ /** Invoke the mute callback when provided */
60
+ toggleMute(): void
61
+ /** ARIA props for the live status pill */
62
+ ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
63
+ /** ARIA props for the mute toggle */
64
+ toggleMuteAriaProps: Record<string, unknown>
65
+ /** Data attributes reflecting speaker/state */
66
+ dataAttributes: Record<string, string>
67
+ /** CSS custom properties that drive pulse visuals */
68
+ style: VoicePillStyleVars
69
+ }
70
+
71
+ export const DEFAULT_VOICE_PILL_SPEAKER = 'ai'
72
+ export const DEFAULT_VOICE_PILL_POSITION: VoicePillPosition = 'bottom-center'
73
+
74
+ function formatNumber(value: number): string {
75
+ return Number(value.toFixed(3)).toString()
76
+ }
77
+
78
+ function getRawSpeaker(speaker?: VoicePillSpeaker): string {
79
+ const value = String(speaker ?? DEFAULT_VOICE_PILL_SPEAKER).trim()
80
+ return value.length > 0 ? value : DEFAULT_VOICE_PILL_SPEAKER
81
+ }
82
+
83
+ export function clampVoicePillIntensity(intensity = 0): number {
84
+ const value = Number(intensity)
85
+
86
+ if (!Number.isFinite(value)) {
87
+ return 0
88
+ }
89
+
90
+ return Math.min(1, Math.max(0, value))
91
+ }
92
+
93
+ export function getVoicePillSpeakerKey(speaker?: VoicePillSpeaker): string {
94
+ const normalized = getRawSpeaker(speaker)
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, '-')
97
+ .replace(/^-+|-+$/g, '')
98
+
99
+ return normalized.length > 0 ? normalized : DEFAULT_VOICE_PILL_SPEAKER
100
+ }
101
+
102
+ export function getVoicePillSpeakerLabel(speaker?: VoicePillSpeaker): string {
103
+ const raw = getRawSpeaker(speaker)
104
+ const key = getVoicePillSpeakerKey(raw)
105
+
106
+ if (key === 'ai') return 'AI'
107
+ if (key === 'user') return 'User'
108
+
109
+ return raw
110
+ }
111
+
112
+ export function getVoicePillInitials(speaker?: VoicePillSpeaker): string {
113
+ const key = getVoicePillSpeakerKey(speaker)
114
+
115
+ if (key === 'ai') return 'AI'
116
+ if (key === 'user') return 'U'
117
+
118
+ const initials = getVoicePillSpeakerLabel(speaker)
119
+ .split(/[\s_-]+/)
120
+ .map((part) => part.charAt(0))
121
+ .filter(Boolean)
122
+ .join('')
123
+ .slice(0, 2)
124
+ .toUpperCase()
125
+
126
+ return initials.length > 0 ? initials : 'V'
127
+ }
128
+
129
+ export function getVoicePillPosition(
130
+ position?: VoicePillPosition,
131
+ ): VoicePillPosition {
132
+ return position ?? DEFAULT_VOICE_PILL_POSITION
133
+ }
134
+
135
+ export function getVoicePillAriaLabel(props: {
136
+ speaker?: VoicePillSpeaker
137
+ label: string
138
+ sub?: string
139
+ muted?: boolean
140
+ }): string {
141
+ const parts = [`${getVoicePillSpeakerLabel(props.speaker)}: ${props.label}`]
142
+
143
+ if (props.sub) {
144
+ parts.push(props.sub)
145
+ }
146
+
147
+ if (props.muted) {
148
+ parts.push('muted')
149
+ }
150
+
151
+ return parts.join(', ')
152
+ }
153
+
154
+ export function getVoicePillPulseStyle(
155
+ intensity?: number,
156
+ muted = false,
157
+ ): VoicePillStyleVars {
158
+ const clamped = clampVoicePillIntensity(intensity)
159
+ const visualIntensity = muted ? 0 : clamped
160
+ const ringOpacity = visualIntensity === 0 ? 0 : 0.12 + visualIntensity * 0.42
161
+ const ringScale = 1 + visualIntensity * 0.35
162
+ const duration = visualIntensity === 0
163
+ ? 1800
164
+ : Math.round(1700 - visualIntensity * 700)
165
+
166
+ return {
167
+ '--rfr-voice-pill-intensity': formatNumber(clamped),
168
+ '--rfr-voice-pill-visual-intensity': formatNumber(visualIntensity),
169
+ '--rfr-voice-pill-ring-opacity': formatNumber(ringOpacity),
170
+ '--rfr-voice-pill-ring-scale': formatNumber(ringScale),
171
+ '--rfr-voice-pill-pulse-duration': `${duration}ms`,
172
+ '--rfr-voice-pill-pulse-delay': `-${Math.round(duration / 2)}ms`,
173
+ }
174
+ }
175
+
176
+ export function createVoicePill(props: VoicePillProps): VoicePillAPI {
177
+ const {
178
+ speaker: speakerProp,
179
+ label,
180
+ sub,
181
+ intensity: intensityProp,
182
+ muted: mutedProp = false,
183
+ onToggleMute,
184
+ position: positionProp,
185
+ } = props
186
+
187
+ const speaker = getVoicePillSpeakerLabel(speakerProp)
188
+ const speakerKey = getVoicePillSpeakerKey(speakerProp)
189
+ const intensity = clampVoicePillIntensity(intensityProp)
190
+ const muted = Boolean(mutedProp)
191
+ const visualIntensity = muted ? 0 : intensity
192
+ const position = getVoicePillPosition(positionProp)
193
+
194
+ function toggleMute(): void {
195
+ onToggleMute?.()
196
+ }
197
+
198
+ const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
199
+ role: 'status',
200
+ 'aria-live': 'polite',
201
+ 'aria-atomic': true,
202
+ 'aria-label': getVoicePillAriaLabel({ speaker: speakerProp, label, sub, muted }),
203
+ }
204
+
205
+ const toggleMuteAriaProps: Record<string, unknown> = {
206
+ 'aria-label': muted ? 'Unmute voice' : 'Mute voice',
207
+ 'aria-pressed': muted,
208
+ }
209
+
210
+ const dataAttributes: Record<string, string> = {
211
+ 'data-speaker': speakerKey,
212
+ 'data-muted': String(muted),
213
+ 'data-position': position,
214
+ 'data-intensity': formatNumber(intensity),
215
+ 'data-active': String(visualIntensity > 0),
216
+ }
217
+
218
+ return {
219
+ speaker,
220
+ speakerKey,
221
+ label,
222
+ sub,
223
+ intensity,
224
+ visualIntensity,
225
+ muted,
226
+ position,
227
+ initials: getVoicePillInitials(speakerProp),
228
+ toggleMute,
229
+ ariaProps,
230
+ toggleMuteAriaProps,
231
+ dataAttributes,
232
+ style: getVoicePillPulseStyle(intensity, muted),
233
+ }
234
+ }
@@ -0,0 +1,39 @@
1
+ export {
2
+ DEFAULT_WAVEFORM_BAR_COUNT,
3
+ DEFAULT_WAVEFORM_COLOR,
4
+ DEFAULT_WAVEFORM_HEIGHT,
5
+ DEFAULT_WAVEFORM_INTENSITY,
6
+ DEFAULT_WAVEFORM_SMOOTHING,
7
+ createSilentSamples,
8
+ createIntensitySamples,
9
+ createWaveform,
10
+ getWaveformPeak,
11
+ isWaveformSampleInput,
12
+ normalizeBarCount,
13
+ normalizeIntensity,
14
+ normalizeSmoothing,
15
+ normalizeWaveformConfig,
16
+ normalizeWaveformSamples,
17
+ resampleWaveformSamples,
18
+ scaleWaveformSamples,
19
+ smoothWaveformSamples,
20
+ toCssDimension,
21
+ type NormalizedWaveformConfig,
22
+ type WaveformAPI,
23
+ type WaveformProps,
24
+ type WaveformSampleInput,
25
+ type WaveformSource,
26
+ type WaveformVariant,
27
+ } from './waveform.js'
28
+
29
+ export {
30
+ drawWaveform,
31
+ prepareWaveformCanvas,
32
+ type WaveformCanvasSize,
33
+ type WaveformRenderOptions,
34
+ } from './waveform-renderer.js'
35
+
36
+ export {
37
+ waveformCanvasVariants,
38
+ waveformVariants,
39
+ } from './waveform.styles.js'
@@ -0,0 +1,174 @@
1
+ import {
2
+ DEFAULT_WAVEFORM_COLOR,
3
+ DEFAULT_WAVEFORM_INTENSITY,
4
+ getWaveformPeak,
5
+ normalizeBarCount,
6
+ normalizeIntensity,
7
+ resampleWaveformSamples,
8
+ scaleWaveformSamples,
9
+ type WaveformSampleInput,
10
+ type WaveformVariant,
11
+ } from './waveform.js'
12
+
13
+ export interface WaveformCanvasSize {
14
+ width: number
15
+ height: number
16
+ pixelRatio?: number
17
+ }
18
+
19
+ export interface WaveformRenderOptions {
20
+ variant?: WaveformVariant
21
+ color?: string
22
+ intensity?: number
23
+ barCount?: number
24
+ }
25
+
26
+ export function prepareWaveformCanvas(
27
+ canvas: HTMLCanvasElement,
28
+ size: WaveformCanvasSize,
29
+ ): CanvasRenderingContext2D | null {
30
+ const width = Math.max(1, Math.floor(size.width))
31
+ const height = Math.max(1, Math.floor(size.height))
32
+ const pixelRatio = Math.max(1, size.pixelRatio ?? 1)
33
+ const pixelWidth = Math.floor(width * pixelRatio)
34
+ const pixelHeight = Math.floor(height * pixelRatio)
35
+
36
+ if (canvas.width !== pixelWidth) {
37
+ canvas.width = pixelWidth
38
+ }
39
+
40
+ if (canvas.height !== pixelHeight) {
41
+ canvas.height = pixelHeight
42
+ }
43
+
44
+ const context = canvas.getContext('2d')
45
+ if (!context) return null
46
+
47
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)
48
+
49
+ return context
50
+ }
51
+
52
+ export function drawWaveform(
53
+ context: CanvasRenderingContext2D,
54
+ samples: WaveformSampleInput,
55
+ size: WaveformCanvasSize,
56
+ options: WaveformRenderOptions = {},
57
+ ): void {
58
+ const width = Math.max(1, Math.floor(size.width))
59
+ const height = Math.max(1, Math.floor(size.height))
60
+ const variant = options.variant ?? 'bars'
61
+ const color = options.color ?? DEFAULT_WAVEFORM_COLOR
62
+ const intensity = normalizeIntensity(options.intensity ?? DEFAULT_WAVEFORM_INTENSITY)
63
+ const barCount = normalizeBarCount(options.barCount)
64
+
65
+ context.clearRect(0, 0, width, height)
66
+ applyCanvasColor(context, color)
67
+
68
+ if (variant === 'line') {
69
+ drawLine(context, samples, width, height, intensity)
70
+ return
71
+ }
72
+
73
+ if (variant === 'rings') {
74
+ drawRings(context, samples, width, height, intensity)
75
+ return
76
+ }
77
+
78
+ drawBars(context, samples, width, height, intensity, barCount)
79
+ }
80
+
81
+ function drawBars(
82
+ context: CanvasRenderingContext2D,
83
+ samples: WaveformSampleInput,
84
+ width: number,
85
+ height: number,
86
+ intensity: number,
87
+ barCount: number,
88
+ ) {
89
+ const bars = scaleWaveformSamples(resampleWaveformSamples(samples, barCount), intensity)
90
+ const slotWidth = width / bars.length
91
+ const barWidth = Math.max(1, slotWidth * 0.64)
92
+ const center = height / 2
93
+
94
+ for (let i = 0; i < bars.length; i += 1) {
95
+ const value = Math.abs(bars[i])
96
+ const barHeight = Math.max(value * height, value > 0 ? 1 : 0)
97
+ const x = i * slotWidth + (slotWidth - barWidth) / 2
98
+ const y = center - barHeight / 2
99
+
100
+ context.fillRect(x, y, barWidth, barHeight)
101
+ }
102
+ }
103
+
104
+ function drawLine(
105
+ context: CanvasRenderingContext2D,
106
+ samples: WaveformSampleInput,
107
+ width: number,
108
+ height: number,
109
+ intensity: number,
110
+ ) {
111
+ const lineSamples = scaleWaveformSamples(samples, intensity)
112
+ const center = height / 2
113
+ const amplitude = height * 0.45
114
+
115
+ context.beginPath()
116
+
117
+ for (let i = 0; i < lineSamples.length; i += 1) {
118
+ const x = lineSamples.length <= 1 ? width / 2 : (i / (lineSamples.length - 1)) * width
119
+ const y = center - Number(lineSamples[i]) * amplitude
120
+
121
+ if (i === 0) {
122
+ context.moveTo(x, y)
123
+ } else {
124
+ context.lineTo(x, y)
125
+ }
126
+ }
127
+
128
+ context.lineWidth = Math.max(1.5, Math.min(width, height) * 0.025)
129
+ context.lineCap = 'round'
130
+ context.lineJoin = 'round'
131
+ context.stroke()
132
+ }
133
+
134
+ function drawRings(
135
+ context: CanvasRenderingContext2D,
136
+ samples: WaveformSampleInput,
137
+ width: number,
138
+ height: number,
139
+ intensity: number,
140
+ ) {
141
+ const peak = getWaveformPeak(scaleWaveformSamples(samples, intensity))
142
+ const minDimension = Math.min(width, height)
143
+ const centerX = width / 2
144
+ const centerY = height / 2
145
+ const ringCount = 3
146
+ const baseRadius = minDimension * 0.16
147
+ const radiusStep = minDimension * 0.13
148
+
149
+ context.save()
150
+ context.lineWidth = Math.max(1.5, minDimension * 0.018)
151
+
152
+ for (let i = 0; i < ringCount; i += 1) {
153
+ const progress = (i + 1) / ringCount
154
+ const pulse = peak * minDimension * 0.1 * progress
155
+ const radius = baseRadius + radiusStep * i + pulse
156
+
157
+ context.globalAlpha = 0.28 + progress * 0.34
158
+ context.beginPath()
159
+ context.arc(centerX, centerY, radius, 0, Math.PI * 2)
160
+ context.stroke()
161
+ }
162
+
163
+ context.restore()
164
+ }
165
+
166
+ function applyCanvasColor(context: CanvasRenderingContext2D, color: string) {
167
+ try {
168
+ context.fillStyle = color
169
+ context.strokeStyle = color
170
+ } catch {
171
+ context.fillStyle = '#000'
172
+ context.strokeStyle = '#000'
173
+ }
174
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ export const waveformVariants = cva({
4
+ base: 'relative block overflow-hidden',
5
+ variants: {
6
+ variant: {
7
+ bars: '',
8
+ line: '',
9
+ rings: '',
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ variant: 'bars',
14
+ },
15
+ })
16
+
17
+ export const waveformCanvasVariants = cva({
18
+ base: 'block h-full w-full',
19
+ })
@@ -0,0 +1,249 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ export type WaveformVariant = 'bars' | 'line' | 'rings'
4
+ export type WaveformSampleInput = Float32Array | number[]
5
+ export type WaveformSource = AnalyserNode | MediaStream | WaveformSampleInput
6
+
7
+ export interface WaveformProps {
8
+ source?: WaveformSource
9
+ samples?: WaveformSampleInput
10
+ intensity?: number
11
+ variant?: WaveformVariant
12
+ height?: number | string
13
+ width?: number | string
14
+ barCount?: number
15
+ smoothing?: number
16
+ color?: string
17
+ paused?: boolean
18
+ }
19
+
20
+ export interface NormalizedWaveformConfig {
21
+ variant: WaveformVariant
22
+ intensity: number
23
+ height: number | string
24
+ width: number | string
25
+ barCount: number
26
+ smoothing: number
27
+ color: string
28
+ paused: boolean
29
+ }
30
+
31
+ export interface WaveformAPI {
32
+ config: NormalizedWaveformConfig
33
+ samples: Float32Array
34
+ ariaProps: Partial<AccessibilityProps>
35
+ dataAttributes: Record<'data-variant' | 'data-paused', string>
36
+ getSamples: (barCount?: number) => Float32Array
37
+ }
38
+
39
+ export const DEFAULT_WAVEFORM_BAR_COUNT = 48
40
+ export const DEFAULT_WAVEFORM_COLOR = 'var(--accent-2)'
41
+ export const DEFAULT_WAVEFORM_HEIGHT = 80
42
+ export const DEFAULT_WAVEFORM_INTENSITY = 1
43
+ export const DEFAULT_WAVEFORM_SMOOTHING = 0.8
44
+
45
+ const MAX_BAR_COUNT = 1024
46
+ const MAX_INTENSITY = 1
47
+
48
+ export function normalizeBarCount(value: number | undefined): number {
49
+ if (value == null || !Number.isFinite(value)) return DEFAULT_WAVEFORM_BAR_COUNT
50
+
51
+ return Math.min(MAX_BAR_COUNT, Math.max(1, Math.round(value)))
52
+ }
53
+
54
+ export function normalizeSmoothing(value: number | undefined): number {
55
+ if (value == null || !Number.isFinite(value)) return DEFAULT_WAVEFORM_SMOOTHING
56
+
57
+ return Math.min(0.99, Math.max(0, value))
58
+ }
59
+
60
+ export function normalizeIntensity(value: number | undefined): number {
61
+ if (value == null || !Number.isFinite(value)) return DEFAULT_WAVEFORM_INTENSITY
62
+
63
+ return Math.min(MAX_INTENSITY, Math.max(0, value))
64
+ }
65
+
66
+ export function normalizeWaveformConfig(props: WaveformProps = {}): NormalizedWaveformConfig {
67
+ return {
68
+ variant: props.variant ?? 'bars',
69
+ intensity: normalizeIntensity(props.intensity),
70
+ height: props.height ?? DEFAULT_WAVEFORM_HEIGHT,
71
+ width: props.width ?? '100%',
72
+ barCount: normalizeBarCount(props.barCount),
73
+ smoothing: normalizeSmoothing(props.smoothing),
74
+ color: props.color ?? DEFAULT_WAVEFORM_COLOR,
75
+ paused: props.paused ?? false,
76
+ }
77
+ }
78
+
79
+ export function isWaveformSampleInput(value: unknown): value is WaveformSampleInput {
80
+ return value instanceof Float32Array || Array.isArray(value)
81
+ }
82
+
83
+ export function createSilentSamples(count = DEFAULT_WAVEFORM_BAR_COUNT): Float32Array {
84
+ return new Float32Array(normalizeBarCount(count))
85
+ }
86
+
87
+ export function createIntensitySamples(
88
+ intensity = DEFAULT_WAVEFORM_INTENSITY,
89
+ count = DEFAULT_WAVEFORM_BAR_COUNT,
90
+ ): Float32Array {
91
+ const normalizedIntensity = normalizeIntensity(intensity)
92
+ const normalizedCount = normalizeBarCount(count)
93
+ const samples = new Float32Array(normalizedCount)
94
+
95
+ if (normalizedIntensity === 0) return samples
96
+
97
+ for (let i = 0; i < normalizedCount; i += 1) {
98
+ const progress = normalizedCount <= 1 ? 0.5 : i / (normalizedCount - 1)
99
+ const envelope = 0.35 + Math.sin(progress * Math.PI) * 0.65
100
+ const carrier = Math.sin(progress * Math.PI * 6)
101
+ samples[i] = carrier * envelope * normalizedIntensity
102
+ }
103
+
104
+ return samples
105
+ }
106
+
107
+ export function normalizeWaveformSamples(
108
+ input: WaveformSampleInput | undefined,
109
+ targetCount?: number,
110
+ ): Float32Array {
111
+ const sourceLength = input?.length ?? 0
112
+
113
+ if (sourceLength === 0) {
114
+ return targetCount == null ? new Float32Array() : createSilentSamples(targetCount)
115
+ }
116
+
117
+ const normalized = new Float32Array(sourceLength)
118
+ let maxAbs = 0
119
+
120
+ for (let i = 0; i < sourceLength; i += 1) {
121
+ const value = Number(input?.[i])
122
+ const sample = Number.isFinite(value) ? value : 0
123
+ normalized[i] = sample
124
+ maxAbs = Math.max(maxAbs, Math.abs(sample))
125
+ }
126
+
127
+ const divisor = maxAbs > 1 ? maxAbs : 1
128
+
129
+ for (let i = 0; i < normalized.length; i += 1) {
130
+ normalized[i] = clamp(normalized[i] / divisor, -1, 1)
131
+ }
132
+
133
+ if (targetCount == null || targetCount === normalized.length) {
134
+ return normalized
135
+ }
136
+
137
+ return resampleWaveformSamples(normalized, targetCount)
138
+ }
139
+
140
+ export function resampleWaveformSamples(
141
+ samples: WaveformSampleInput,
142
+ targetCount: number,
143
+ ): Float32Array {
144
+ const count = normalizeBarCount(targetCount)
145
+ const sourceLength = samples.length
146
+
147
+ if (sourceLength === 0) return createSilentSamples(count)
148
+ if (sourceLength === count) return Float32Array.from(samples)
149
+
150
+ const output = new Float32Array(count)
151
+
152
+ for (let i = 0; i < count; i += 1) {
153
+ const start = (i * sourceLength) / count
154
+ const end = ((i + 1) * sourceLength) / count
155
+ const firstIndex = Math.floor(start)
156
+ const lastIndex = Math.max(firstIndex + 1, Math.ceil(end))
157
+ let total = 0
158
+ let values = 0
159
+
160
+ for (let j = firstIndex; j < lastIndex && j < sourceLength; j += 1) {
161
+ total += Number(samples[j])
162
+ values += 1
163
+ }
164
+
165
+ output[i] = values > 0 ? total / values : Number(samples[Math.min(firstIndex, sourceLength - 1)])
166
+ }
167
+
168
+ return output
169
+ }
170
+
171
+ export function smoothWaveformSamples(
172
+ previous: WaveformSampleInput | undefined,
173
+ next: WaveformSampleInput,
174
+ smoothing = DEFAULT_WAVEFORM_SMOOTHING,
175
+ ): Float32Array {
176
+ if (previous == null || previous.length === 0) {
177
+ return Float32Array.from(next)
178
+ }
179
+
180
+ const amount = normalizeSmoothing(smoothing)
181
+ const output = new Float32Array(next.length)
182
+
183
+ for (let i = 0; i < next.length; i += 1) {
184
+ const previousValue = Number(previous[i] ?? next[i])
185
+ output[i] = previousValue * amount + Number(next[i]) * (1 - amount)
186
+ }
187
+
188
+ return output
189
+ }
190
+
191
+ export function scaleWaveformSamples(
192
+ samples: WaveformSampleInput,
193
+ intensity = DEFAULT_WAVEFORM_INTENSITY,
194
+ ): Float32Array {
195
+ const amount = normalizeIntensity(intensity)
196
+ const output = new Float32Array(samples.length)
197
+
198
+ for (let i = 0; i < samples.length; i += 1) {
199
+ output[i] = clamp(Number(samples[i]) * amount, -1, 1)
200
+ }
201
+
202
+ return output
203
+ }
204
+
205
+ export function getWaveformPeak(samples: WaveformSampleInput): number {
206
+ let peak = 0
207
+
208
+ for (let i = 0; i < samples.length; i += 1) {
209
+ peak = Math.max(peak, Math.abs(Number(samples[i]) || 0))
210
+ }
211
+
212
+ return Math.min(1, peak)
213
+ }
214
+
215
+ export function toCssDimension(value: number | string | undefined): string | undefined {
216
+ if (value == null) return undefined
217
+
218
+ return typeof value === 'number' ? `${value}px` : value
219
+ }
220
+
221
+ export function createWaveform(props: WaveformProps = {}): WaveformAPI {
222
+ const config = normalizeWaveformConfig(props)
223
+ const inputSamples = props.samples ?? (isWaveformSampleInput(props.source) ? props.source : undefined)
224
+ const samples = inputSamples == null
225
+ ? createIntensitySamples(config.intensity, config.barCount)
226
+ : normalizeWaveformSamples(inputSamples, config.barCount)
227
+
228
+ return {
229
+ config,
230
+ samples,
231
+ ariaProps: {
232
+ role: 'img',
233
+ 'aria-label': 'Audio waveform',
234
+ },
235
+ dataAttributes: {
236
+ 'data-variant': config.variant,
237
+ 'data-paused': String(config.paused),
238
+ },
239
+ getSamples(barCount = config.barCount) {
240
+ return inputSamples == null
241
+ ? createIntensitySamples(config.intensity, barCount)
242
+ : normalizeWaveformSamples(inputSamples, barCount)
243
+ },
244
+ }
245
+ }
246
+
247
+ function clamp(value: number, min: number, max: number): number {
248
+ return Math.min(max, Math.max(min, value))
249
+ }