@refraction-ui/astro 0.4.12 → 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.
- package/dist/astro-voice-pill/VoicePill.astro +159 -0
- package/dist/astro-voice-pill/index.ts +27 -0
- package/dist/astro-waveform/Waveform.astro +273 -0
- package/dist/astro-waveform/index.ts +29 -0
- package/dist/index.ts +2 -0
- package/dist/slide-viewer/slide-viewer.styles.ts +3 -1
- package/dist/status-indicator/status-indicator.ts +8 -5
- package/dist/version-selector/version-selector.styles.ts +3 -1
- package/dist/voice-pill/index.ts +31 -0
- package/dist/voice-pill/voice-pill.styles.ts +77 -0
- package/dist/voice-pill/voice-pill.ts +234 -0
- package/dist/waveform/index.ts +39 -0
- package/dist/waveform/waveform-renderer.ts +174 -0
- package/dist/waveform/waveform.styles.ts +19 -0
- package/dist/waveform/waveform.ts +249 -0
- package/package.json +3 -1
|
@@ -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
|
+
}
|