markupr 2.1.8
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/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- package/assets/tray-complete.png +0 -0
- package/assets/tray-complete@2x.png +0 -0
- package/assets/tray-completeTemplate.png +0 -0
- package/assets/tray-completeTemplate@2x.png +0 -0
- package/assets/tray-error.png +0 -0
- package/assets/tray-error@2x.png +0 -0
- package/assets/tray-errorTemplate.png +0 -0
- package/assets/tray-errorTemplate@2x.png +0 -0
- package/assets/tray-icon-processing.png +0 -0
- package/assets/tray-icon-processing@2x.png +0 -0
- package/assets/tray-icon-processingTemplate.png +0 -0
- package/assets/tray-icon-processingTemplate@2x.png +0 -0
- package/assets/tray-icon-recording.png +0 -0
- package/assets/tray-icon-recording@2x.png +0 -0
- package/assets/tray-icon-recordingTemplate.png +0 -0
- package/assets/tray-icon-recordingTemplate@2x.png +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/assets/tray-idle.png +0 -0
- package/assets/tray-idle@2x.png +0 -0
- package/assets/tray-idleTemplate.png +0 -0
- package/assets/tray-idleTemplate@2x.png +0 -0
- package/assets/tray-processing-0.png +0 -0
- package/assets/tray-processing-0@2x.png +0 -0
- package/assets/tray-processing-0Template.png +0 -0
- package/assets/tray-processing-0Template@2x.png +0 -0
- package/assets/tray-processing-1.png +0 -0
- package/assets/tray-processing-1@2x.png +0 -0
- package/assets/tray-processing-1Template.png +0 -0
- package/assets/tray-processing-1Template@2x.png +0 -0
- package/assets/tray-processing-2.png +0 -0
- package/assets/tray-processing-2@2x.png +0 -0
- package/assets/tray-processing-2Template.png +0 -0
- package/assets/tray-processing-2Template@2x.png +0 -0
- package/assets/tray-processing-3.png +0 -0
- package/assets/tray-processing-3@2x.png +0 -0
- package/assets/tray-processing-3Template.png +0 -0
- package/assets/tray-processing-3Template@2x.png +0 -0
- package/assets/tray-processing.png +0 -0
- package/assets/tray-processing@2x.png +0 -0
- package/assets/tray-processingTemplate.png +0 -0
- package/assets/tray-processingTemplate@2x.png +0 -0
- package/assets/tray-recording.png +0 -0
- package/assets/tray-recording@2x.png +0 -0
- package/assets/tray-recordingTemplate.png +0 -0
- package/assets/tray-recordingTemplate@2x.png +0 -0
- package/build/DMG_BACKGROUND_SPEC.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- package/build/favicon-16.png +0 -0
- package/build/favicon-180.png +0 -0
- package/build/favicon-192.png +0 -0
- package/build/favicon-32.png +0 -0
- package/build/favicon-48.png +0 -0
- package/build/favicon-512.png +0 -0
- package/build/favicon-64.png +0 -0
- package/build/icon-128.png +0 -0
- package/build/icon-16.png +0 -0
- package/build/icon-24.png +0 -0
- package/build/icon-256.png +0 -0
- package/build/icon-32.png +0 -0
- package/build/icon-48.png +0 -0
- package/build/icon-64.png +0 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.iconset/icon_128x128.png +0 -0
- package/build/icon.iconset/icon_128x128@2x.png +0 -0
- package/build/icon.iconset/icon_16x16.png +0 -0
- package/build/icon.iconset/icon_16x16@2x.png +0 -0
- package/build/icon.iconset/icon_256x256.png +0 -0
- package/build/icon.iconset/icon_256x256@2x.png +0 -0
- package/build/icon.iconset/icon_32x32.png +0 -0
- package/build/icon.iconset/icon_32x32@2x.png +0 -0
- package/build/icon.iconset/icon_512x512.png +0 -0
- package/build/icon.iconset/icon_512x512@2x.png +0 -0
- package/build/icon.png +0 -0
- package/build/installer-header.bmp +0 -0
- package/build/installer-header.png +0 -0
- package/build/installer-sidebar.bmp +0 -0
- package/build/installer-sidebar.png +0 -0
- package/build/installer.nsh +45 -0
- package/build/overlay-processing.png +0 -0
- package/build/overlay-recording.png +0 -0
- package/build/toolbar-record.png +0 -0
- package/build/toolbar-screenshot.png +0 -0
- package/build/toolbar-settings.png +0 -0
- package/build/toolbar-stop.png +0 -0
- package/dist/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Waveform Visualization Component
|
|
3
|
+
*
|
|
4
|
+
* Professional audio tool quality visualization featuring:
|
|
5
|
+
* - Real-time waveform display (bars, wave, line styles)
|
|
6
|
+
* - Voice activity detection indicator
|
|
7
|
+
* - Smooth 60fps requestAnimationFrame-based animation
|
|
8
|
+
* - Peak level detection with decay
|
|
9
|
+
* - Compact mode for RecordingOverlay integration
|
|
10
|
+
*
|
|
11
|
+
* Designed to feel like a premium DAW or podcast recording app
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
15
|
+
import { useTheme } from '../hooks/useTheme';
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
export interface AudioWaveformProps {
|
|
22
|
+
/** Current audio level normalized 0-1 */
|
|
23
|
+
audioLevel: number;
|
|
24
|
+
/** Whether voice/speech is currently detected */
|
|
25
|
+
isVoiceActive: boolean;
|
|
26
|
+
/** Visualization style */
|
|
27
|
+
style?: 'bars' | 'wave' | 'line';
|
|
28
|
+
/** Component size preset */
|
|
29
|
+
size?: 'compact' | 'normal' | 'large';
|
|
30
|
+
/** Accent color for active state (CSS color string) */
|
|
31
|
+
accentColor?: string;
|
|
32
|
+
/** Secondary color for inactive state */
|
|
33
|
+
inactiveColor?: string;
|
|
34
|
+
/** Number of bars/samples to display */
|
|
35
|
+
resolution?: number;
|
|
36
|
+
/** Show peak hold indicator */
|
|
37
|
+
showPeak?: boolean;
|
|
38
|
+
/** Custom className for container */
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WaveformStyleProps {
|
|
43
|
+
levels: number[];
|
|
44
|
+
isVoiceActive: boolean;
|
|
45
|
+
accentColor: string;
|
|
46
|
+
inactiveColor: string;
|
|
47
|
+
peakLevel: number;
|
|
48
|
+
showPeak: boolean;
|
|
49
|
+
peakColor: string;
|
|
50
|
+
peakWarningColor: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Constants
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hook that returns waveform-specific colors from the theme system.
|
|
59
|
+
* Replaces the former module-level COLORS constant.
|
|
60
|
+
*/
|
|
61
|
+
function useWaveformColors() {
|
|
62
|
+
const { colors } = useTheme();
|
|
63
|
+
return useMemo(
|
|
64
|
+
() => ({
|
|
65
|
+
active: colors.status.success,
|
|
66
|
+
inactive: colors.text.tertiary,
|
|
67
|
+
peak: colors.status.error,
|
|
68
|
+
peakWarning: colors.status.warning,
|
|
69
|
+
background: 'rgba(31, 41, 55, 0.5)',
|
|
70
|
+
textSecondary: colors.text.secondary,
|
|
71
|
+
}),
|
|
72
|
+
[colors]
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SIZE_CONFIG = {
|
|
77
|
+
compact: { height: 16, width: 64, barCount: 8, barWidth: 2, gap: 2 },
|
|
78
|
+
normal: { height: 48, width: 192, barCount: 32, barWidth: 4, gap: 2 },
|
|
79
|
+
large: { height: 96, width: 320, barCount: 64, barWidth: 3, gap: 2 },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Smoothing factor for level transitions (higher = smoother but slower)
|
|
83
|
+
const SMOOTHING_FACTOR = 0.15;
|
|
84
|
+
// Peak hold time in milliseconds
|
|
85
|
+
const PEAK_HOLD_TIME = 1500;
|
|
86
|
+
// Peak decay rate per frame
|
|
87
|
+
const PEAK_DECAY_RATE = 0.02;
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Utility Functions
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generates a bell curve distribution for bar heights
|
|
95
|
+
* Creates natural-looking center-weighted visualization
|
|
96
|
+
*/
|
|
97
|
+
function generateBellCurve(index: number, total: number): number {
|
|
98
|
+
const center = total / 2;
|
|
99
|
+
const distance = Math.abs(index - center) / center;
|
|
100
|
+
// Gaussian-like falloff
|
|
101
|
+
return Math.exp(-2 * distance * distance);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Applies smoothing between current and target values
|
|
106
|
+
*/
|
|
107
|
+
function smoothValue(current: number, target: number, factor: number): number {
|
|
108
|
+
return current + (target - current) * factor;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Sub-Components
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bars Style Waveform
|
|
117
|
+
* Classic audio meter with vertical bars, center-weighted distribution
|
|
118
|
+
*/
|
|
119
|
+
function BarsWaveform({
|
|
120
|
+
levels,
|
|
121
|
+
isVoiceActive,
|
|
122
|
+
accentColor,
|
|
123
|
+
inactiveColor,
|
|
124
|
+
peakLevel,
|
|
125
|
+
showPeak,
|
|
126
|
+
peakColor,
|
|
127
|
+
peakWarningColor,
|
|
128
|
+
}: WaveformStyleProps) {
|
|
129
|
+
const barCount = levels.length;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
style={{
|
|
134
|
+
display: 'flex',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
justifyContent: 'center',
|
|
137
|
+
gap: 2,
|
|
138
|
+
height: '100%',
|
|
139
|
+
width: '100%',
|
|
140
|
+
padding: '0 4px',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{levels.map((level, i) => {
|
|
144
|
+
// Apply bell curve distribution for natural look
|
|
145
|
+
const bellWeight = generateBellCurve(i, barCount);
|
|
146
|
+
const adjustedLevel = level * bellWeight;
|
|
147
|
+
|
|
148
|
+
// Determine color based on level and voice activity
|
|
149
|
+
let barColor = inactiveColor;
|
|
150
|
+
if (isVoiceActive) {
|
|
151
|
+
if (adjustedLevel > 0.9) {
|
|
152
|
+
barColor = peakColor;
|
|
153
|
+
} else if (adjustedLevel > 0.75) {
|
|
154
|
+
barColor = peakWarningColor;
|
|
155
|
+
} else {
|
|
156
|
+
barColor = accentColor;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div
|
|
162
|
+
key={i}
|
|
163
|
+
style={{
|
|
164
|
+
width: 4,
|
|
165
|
+
minHeight: 4,
|
|
166
|
+
height: `${Math.max(8, adjustedLevel * 100)}%`,
|
|
167
|
+
backgroundColor: barColor,
|
|
168
|
+
borderRadius: 2,
|
|
169
|
+
transition: 'height 50ms ease-out, background-color 100ms ease',
|
|
170
|
+
opacity: isVoiceActive ? 1 : 0.6,
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
|
|
176
|
+
{/* Peak indicator line */}
|
|
177
|
+
{showPeak && peakLevel > 0.1 && (
|
|
178
|
+
<div
|
|
179
|
+
style={{
|
|
180
|
+
position: 'absolute',
|
|
181
|
+
left: 4,
|
|
182
|
+
right: 4,
|
|
183
|
+
height: 2,
|
|
184
|
+
bottom: `${peakLevel * 100}%`,
|
|
185
|
+
backgroundColor: peakLevel > 0.9 ? peakColor : peakWarningColor,
|
|
186
|
+
borderRadius: 1,
|
|
187
|
+
opacity: 0.8,
|
|
188
|
+
transition: 'bottom 50ms ease-out',
|
|
189
|
+
pointerEvents: 'none',
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Wave Style Waveform
|
|
199
|
+
* Canvas-based smooth waveform with fill gradient
|
|
200
|
+
*/
|
|
201
|
+
function WaveWaveform({
|
|
202
|
+
levels,
|
|
203
|
+
isVoiceActive,
|
|
204
|
+
accentColor,
|
|
205
|
+
inactiveColor,
|
|
206
|
+
}: WaveformStyleProps) {
|
|
207
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
208
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const canvas = canvasRef.current;
|
|
212
|
+
const container = containerRef.current;
|
|
213
|
+
if (!canvas || !container) return;
|
|
214
|
+
|
|
215
|
+
// Handle high-DPI displays
|
|
216
|
+
const dpr = window.devicePixelRatio || 1;
|
|
217
|
+
const rect = container.getBoundingClientRect();
|
|
218
|
+
canvas.width = rect.width * dpr;
|
|
219
|
+
canvas.height = rect.height * dpr;
|
|
220
|
+
canvas.style.width = `${rect.width}px`;
|
|
221
|
+
canvas.style.height = `${rect.height}px`;
|
|
222
|
+
|
|
223
|
+
const ctx = canvas.getContext('2d');
|
|
224
|
+
if (!ctx) return;
|
|
225
|
+
|
|
226
|
+
ctx.scale(dpr, dpr);
|
|
227
|
+
|
|
228
|
+
const width = rect.width;
|
|
229
|
+
const height = rect.height;
|
|
230
|
+
const centerY = height / 2;
|
|
231
|
+
|
|
232
|
+
// Clear canvas
|
|
233
|
+
ctx.clearRect(0, 0, width, height);
|
|
234
|
+
|
|
235
|
+
// Draw waveform path
|
|
236
|
+
ctx.beginPath();
|
|
237
|
+
ctx.moveTo(0, centerY);
|
|
238
|
+
|
|
239
|
+
const sliceWidth = width / (levels.length - 1);
|
|
240
|
+
|
|
241
|
+
// Upper wave (positive values)
|
|
242
|
+
levels.forEach((value, i) => {
|
|
243
|
+
const x = i * sliceWidth;
|
|
244
|
+
const amplitude = value * (height / 2) * 0.9; // Leave margin
|
|
245
|
+
const y = centerY - amplitude;
|
|
246
|
+
|
|
247
|
+
if (i === 0) {
|
|
248
|
+
ctx.moveTo(x, y);
|
|
249
|
+
} else {
|
|
250
|
+
// Use quadratic curves for smoothness
|
|
251
|
+
const prevX = (i - 1) * sliceWidth;
|
|
252
|
+
ctx.quadraticCurveTo(prevX + sliceWidth / 2, centerY - levels[i - 1] * (height / 2) * 0.9, x, y);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Mirror for lower wave (creates symmetric waveform)
|
|
257
|
+
for (let i = levels.length - 1; i >= 0; i--) {
|
|
258
|
+
const x = i * sliceWidth;
|
|
259
|
+
const amplitude = levels[i] * (height / 2) * 0.9;
|
|
260
|
+
const y = centerY + amplitude;
|
|
261
|
+
|
|
262
|
+
if (i === levels.length - 1) {
|
|
263
|
+
ctx.lineTo(x, y);
|
|
264
|
+
} else {
|
|
265
|
+
const nextX = (i + 1) * sliceWidth;
|
|
266
|
+
ctx.quadraticCurveTo(nextX - sliceWidth / 2, centerY + levels[i + 1] * (height / 2) * 0.9, x, y);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.closePath();
|
|
271
|
+
|
|
272
|
+
// Create gradient fill
|
|
273
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
274
|
+
const baseColor = isVoiceActive ? accentColor : inactiveColor;
|
|
275
|
+
gradient.addColorStop(0, `${baseColor}40`);
|
|
276
|
+
gradient.addColorStop(0.5, `${baseColor}60`);
|
|
277
|
+
gradient.addColorStop(1, `${baseColor}40`);
|
|
278
|
+
|
|
279
|
+
ctx.fillStyle = gradient;
|
|
280
|
+
ctx.fill();
|
|
281
|
+
|
|
282
|
+
// Draw center line
|
|
283
|
+
ctx.strokeStyle = isVoiceActive ? accentColor : inactiveColor;
|
|
284
|
+
ctx.lineWidth = 2;
|
|
285
|
+
|
|
286
|
+
ctx.beginPath();
|
|
287
|
+
ctx.moveTo(0, centerY);
|
|
288
|
+
levels.forEach((value, i) => {
|
|
289
|
+
const x = i * sliceWidth;
|
|
290
|
+
const amplitude = value * (height / 2) * 0.8;
|
|
291
|
+
|
|
292
|
+
if (i === 0) {
|
|
293
|
+
ctx.moveTo(x, centerY);
|
|
294
|
+
} else {
|
|
295
|
+
// Create slight variance for organic feel
|
|
296
|
+
const variance = (Math.random() > 0.5 ? 1 : -1) * 0.5;
|
|
297
|
+
ctx.lineTo(x, centerY - amplitude * variance);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
}, [levels, isVoiceActive, accentColor, inactiveColor]);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div ref={containerRef} style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
305
|
+
<canvas
|
|
306
|
+
ref={canvasRef}
|
|
307
|
+
style={{
|
|
308
|
+
width: '100%',
|
|
309
|
+
height: '100%',
|
|
310
|
+
display: 'block',
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Line Style Waveform
|
|
319
|
+
* Simple animated line with glow effect - good for minimal UIs
|
|
320
|
+
*/
|
|
321
|
+
function LineWaveform({
|
|
322
|
+
levels,
|
|
323
|
+
isVoiceActive,
|
|
324
|
+
accentColor,
|
|
325
|
+
inactiveColor,
|
|
326
|
+
peakLevel,
|
|
327
|
+
peakColor,
|
|
328
|
+
}: WaveformStyleProps) {
|
|
329
|
+
const currentLevel = levels[levels.length - 1] || 0;
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
style={{
|
|
334
|
+
display: 'flex',
|
|
335
|
+
alignItems: 'center',
|
|
336
|
+
justifyContent: 'center',
|
|
337
|
+
height: '100%',
|
|
338
|
+
width: '100%',
|
|
339
|
+
padding: '0 8px',
|
|
340
|
+
position: 'relative',
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
{/* Background track */}
|
|
344
|
+
<div
|
|
345
|
+
style={{
|
|
346
|
+
position: 'absolute',
|
|
347
|
+
left: 8,
|
|
348
|
+
right: 8,
|
|
349
|
+
height: 4,
|
|
350
|
+
backgroundColor: 'rgba(75, 85, 99, 0.3)',
|
|
351
|
+
borderRadius: 2,
|
|
352
|
+
}}
|
|
353
|
+
/>
|
|
354
|
+
|
|
355
|
+
{/* Active level bar */}
|
|
356
|
+
<div
|
|
357
|
+
style={{
|
|
358
|
+
position: 'absolute',
|
|
359
|
+
left: 8,
|
|
360
|
+
height: 4,
|
|
361
|
+
width: `${Math.max(0, Math.min(100, currentLevel * 100))}%`,
|
|
362
|
+
maxWidth: 'calc(100% - 16px)',
|
|
363
|
+
backgroundColor: isVoiceActive ? accentColor : inactiveColor,
|
|
364
|
+
borderRadius: 2,
|
|
365
|
+
transition: 'width 50ms ease-out',
|
|
366
|
+
boxShadow: isVoiceActive ? `0 0 8px ${accentColor}60` : 'none',
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
|
|
370
|
+
{/* Peak marker */}
|
|
371
|
+
{peakLevel > 0.1 && (
|
|
372
|
+
<div
|
|
373
|
+
style={{
|
|
374
|
+
position: 'absolute',
|
|
375
|
+
left: `calc(8px + min(${peakLevel * 100}%, calc(100% - 18px)))`,
|
|
376
|
+
width: 4,
|
|
377
|
+
height: 8,
|
|
378
|
+
backgroundColor: peakLevel > 0.9 ? peakColor : accentColor,
|
|
379
|
+
borderRadius: 1,
|
|
380
|
+
opacity: 0.9,
|
|
381
|
+
transition: 'left 50ms ease-out',
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// =============================================================================
|
|
390
|
+
// Main Component
|
|
391
|
+
// =============================================================================
|
|
392
|
+
|
|
393
|
+
export function AudioWaveform({
|
|
394
|
+
audioLevel,
|
|
395
|
+
isVoiceActive,
|
|
396
|
+
style = 'bars',
|
|
397
|
+
size = 'normal',
|
|
398
|
+
accentColor: accentColorProp,
|
|
399
|
+
inactiveColor: inactiveColorProp,
|
|
400
|
+
resolution,
|
|
401
|
+
showPeak = true,
|
|
402
|
+
className,
|
|
403
|
+
}: AudioWaveformProps) {
|
|
404
|
+
const waveformColors = useWaveformColors();
|
|
405
|
+
const accentColor = accentColorProp ?? waveformColors.active;
|
|
406
|
+
const inactiveColor = inactiveColorProp ?? waveformColors.inactive;
|
|
407
|
+
|
|
408
|
+
const config = SIZE_CONFIG[size];
|
|
409
|
+
const barCount = resolution || config.barCount;
|
|
410
|
+
|
|
411
|
+
// State for accumulated levels (creates trailing effect)
|
|
412
|
+
const [levels, setLevels] = useState<number[]>(() => new Array(barCount).fill(0));
|
|
413
|
+
// Peak level with hold and decay
|
|
414
|
+
const [peakLevel, setPeakLevel] = useState(0);
|
|
415
|
+
// Smoothed current level for fluid animation
|
|
416
|
+
const smoothedLevelRef = useRef(0);
|
|
417
|
+
|
|
418
|
+
// Animation frame reference
|
|
419
|
+
const animationRef = useRef<number>();
|
|
420
|
+
const lastUpdateRef = useRef<number>(0);
|
|
421
|
+
const peakHoldTimeRef = useRef<number>(0);
|
|
422
|
+
|
|
423
|
+
// Smooth level updates at 60fps
|
|
424
|
+
const updateLevels = useCallback((timestamp: number) => {
|
|
425
|
+
// Throttle to ~60fps
|
|
426
|
+
if (timestamp - lastUpdateRef.current < 16) {
|
|
427
|
+
animationRef.current = requestAnimationFrame(updateLevels);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
lastUpdateRef.current = timestamp;
|
|
431
|
+
|
|
432
|
+
// Smooth the incoming audio level
|
|
433
|
+
smoothedLevelRef.current = smoothValue(smoothedLevelRef.current, audioLevel, SMOOTHING_FACTOR);
|
|
434
|
+
|
|
435
|
+
setLevels((prev) => {
|
|
436
|
+
const newLevels = [...prev.slice(1), smoothedLevelRef.current];
|
|
437
|
+
return newLevels;
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Update peak with hold and decay
|
|
441
|
+
setPeakLevel((prevPeak) => {
|
|
442
|
+
if (smoothedLevelRef.current > prevPeak) {
|
|
443
|
+
peakHoldTimeRef.current = timestamp;
|
|
444
|
+
return smoothedLevelRef.current;
|
|
445
|
+
} else if (timestamp - peakHoldTimeRef.current > PEAK_HOLD_TIME) {
|
|
446
|
+
// Decay peak after hold time
|
|
447
|
+
return Math.max(0, prevPeak - PEAK_DECAY_RATE);
|
|
448
|
+
}
|
|
449
|
+
return prevPeak;
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
animationRef.current = requestAnimationFrame(updateLevels);
|
|
453
|
+
}, [audioLevel]);
|
|
454
|
+
|
|
455
|
+
// Start animation loop
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
animationRef.current = requestAnimationFrame(updateLevels);
|
|
458
|
+
return () => {
|
|
459
|
+
if (animationRef.current) {
|
|
460
|
+
cancelAnimationFrame(animationRef.current);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}, [updateLevels]);
|
|
464
|
+
|
|
465
|
+
// Style props passed to sub-components
|
|
466
|
+
const styleProps: WaveformStyleProps = useMemo(
|
|
467
|
+
() => ({
|
|
468
|
+
levels,
|
|
469
|
+
isVoiceActive,
|
|
470
|
+
accentColor,
|
|
471
|
+
inactiveColor,
|
|
472
|
+
peakLevel,
|
|
473
|
+
showPeak,
|
|
474
|
+
peakColor: waveformColors.peak,
|
|
475
|
+
peakWarningColor: waveformColors.peakWarning,
|
|
476
|
+
}),
|
|
477
|
+
[levels, isVoiceActive, accentColor, inactiveColor, peakLevel, showPeak, waveformColors.peak, waveformColors.peakWarning]
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Container styles
|
|
481
|
+
const containerStyle: React.CSSProperties = {
|
|
482
|
+
width: size === 'large' ? '100%' : config.width,
|
|
483
|
+
height: config.height,
|
|
484
|
+
backgroundColor: waveformColors.background,
|
|
485
|
+
borderRadius: size === 'compact' ? 8 : 12,
|
|
486
|
+
overflow: 'hidden',
|
|
487
|
+
position: 'relative',
|
|
488
|
+
transition: 'box-shadow 200ms ease',
|
|
489
|
+
boxShadow: isVoiceActive
|
|
490
|
+
? `0 0 0 1px ${accentColor}30, 0 0 12px ${accentColor}20`
|
|
491
|
+
: '0 0 0 1px rgba(75, 85, 99, 0.3)',
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<div style={containerStyle} className={className}>
|
|
496
|
+
{/* Waveform visualization */}
|
|
497
|
+
{style === 'bars' && <BarsWaveform {...styleProps} />}
|
|
498
|
+
{style === 'wave' && <WaveWaveform {...styleProps} />}
|
|
499
|
+
{style === 'line' && <LineWaveform {...styleProps} />}
|
|
500
|
+
|
|
501
|
+
{/* Voice activity indicator dot */}
|
|
502
|
+
{isVoiceActive && (
|
|
503
|
+
<div
|
|
504
|
+
style={{
|
|
505
|
+
position: 'absolute',
|
|
506
|
+
top: size === 'compact' ? 2 : 4,
|
|
507
|
+
right: size === 'compact' ? 2 : 4,
|
|
508
|
+
width: size === 'compact' ? 4 : 6,
|
|
509
|
+
height: size === 'compact' ? 4 : 6,
|
|
510
|
+
borderRadius: '50%',
|
|
511
|
+
backgroundColor: waveformColors.active,
|
|
512
|
+
boxShadow: `0 0 4px ${waveformColors.active}`,
|
|
513
|
+
animation: 'pulseScale 1.5s ease-in-out infinite',
|
|
514
|
+
}}
|
|
515
|
+
/>
|
|
516
|
+
)}
|
|
517
|
+
|
|
518
|
+
{/* pulseScale keyframe provided by animations.css */}
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// =============================================================================
|
|
524
|
+
// Compact Audio Indicator
|
|
525
|
+
// =============================================================================
|
|
526
|
+
|
|
527
|
+
interface CompactAudioIndicatorProps {
|
|
528
|
+
/** Current audio level normalized 0-1 */
|
|
529
|
+
audioLevel: number;
|
|
530
|
+
/** Whether voice/speech is currently detected */
|
|
531
|
+
isVoiceActive: boolean;
|
|
532
|
+
/** Accent color for active state */
|
|
533
|
+
accentColor?: string;
|
|
534
|
+
/** Color for inactive bars */
|
|
535
|
+
inactiveColor?: string;
|
|
536
|
+
/** Number of bars */
|
|
537
|
+
barCount?: number;
|
|
538
|
+
/** Width of each bar in pixels */
|
|
539
|
+
barWidth?: number;
|
|
540
|
+
/** Gap between bars in pixels */
|
|
541
|
+
barGap?: number;
|
|
542
|
+
/** Container height in pixels */
|
|
543
|
+
meterHeight?: number;
|
|
544
|
+
/** Minimum bar height in pixels */
|
|
545
|
+
minBarHeight?: number;
|
|
546
|
+
/** Maximum bar height in pixels */
|
|
547
|
+
maxBarHeight?: number;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Compact Audio Indicator
|
|
552
|
+
* Minimal 5-bar indicator designed for RecordingOverlay integration
|
|
553
|
+
* Shows real-time audio level with voice activity feedback
|
|
554
|
+
*/
|
|
555
|
+
export function CompactAudioIndicator({
|
|
556
|
+
audioLevel,
|
|
557
|
+
isVoiceActive,
|
|
558
|
+
accentColor: accentColorProp,
|
|
559
|
+
inactiveColor: inactiveColorProp,
|
|
560
|
+
barCount = 5,
|
|
561
|
+
barWidth = 2,
|
|
562
|
+
barGap = 1,
|
|
563
|
+
meterHeight = 16,
|
|
564
|
+
minBarHeight = 4,
|
|
565
|
+
maxBarHeight = 16,
|
|
566
|
+
}: CompactAudioIndicatorProps) {
|
|
567
|
+
const waveformColors = useWaveformColors();
|
|
568
|
+
const accentColor = accentColorProp ?? waveformColors.active;
|
|
569
|
+
const inactiveColor = inactiveColorProp ?? waveformColors.inactive;
|
|
570
|
+
const [smoothedLevel, setSmoothedLevel] = useState(0);
|
|
571
|
+
const [phase, setPhase] = useState(0);
|
|
572
|
+
const animationRef = useRef<number>();
|
|
573
|
+
const levelRef = useRef(0);
|
|
574
|
+
|
|
575
|
+
// Smooth level updates
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
levelRef.current = audioLevel;
|
|
578
|
+
}, [audioLevel]);
|
|
579
|
+
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const animate = () => {
|
|
582
|
+
setSmoothedLevel((prev) => smoothValue(prev, levelRef.current, 0.2));
|
|
583
|
+
setPhase((prev) => prev + 0.22);
|
|
584
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
585
|
+
};
|
|
586
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
587
|
+
return () => {
|
|
588
|
+
if (animationRef.current) {
|
|
589
|
+
cancelAnimationFrame(animationRef.current);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}, []);
|
|
593
|
+
|
|
594
|
+
// Generate bars with staggered thresholds
|
|
595
|
+
const bars = useMemo(() => {
|
|
596
|
+
return Array.from({ length: barCount }, (_, i) => {
|
|
597
|
+
const motionBoost = isVoiceActive
|
|
598
|
+
? Math.max(0, Math.sin(phase + i * 0.92) * 0.16 + 0.18)
|
|
599
|
+
: Math.max(0, Math.sin(phase + i * 0.74) * 0.045 + 0.03);
|
|
600
|
+
const effectiveLevel = Math.min(
|
|
601
|
+
1,
|
|
602
|
+
Math.max(isVoiceActive ? 0.05 : 0.015, smoothedLevel + motionBoost + (isVoiceActive ? 0.06 : 0))
|
|
603
|
+
);
|
|
604
|
+
const threshold = i / barCount;
|
|
605
|
+
const isActive = effectiveLevel > threshold;
|
|
606
|
+
const intensity = isActive ? Math.min(1, (effectiveLevel - threshold) * barCount) : 0;
|
|
607
|
+
|
|
608
|
+
// Height varies by position (taller in center)
|
|
609
|
+
const centerRadius = Math.max(1, (barCount - 1) / 2);
|
|
610
|
+
const centerBonus = 1 - Math.abs(i - (barCount - 1) / 2) / centerRadius;
|
|
611
|
+
const centerMaxHeight = minBarHeight + centerBonus * (maxBarHeight - minBarHeight);
|
|
612
|
+
const height = minBarHeight + intensity * (centerMaxHeight - minBarHeight);
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
height,
|
|
616
|
+
isActive,
|
|
617
|
+
intensity,
|
|
618
|
+
};
|
|
619
|
+
});
|
|
620
|
+
}, [
|
|
621
|
+
barCount,
|
|
622
|
+
isVoiceActive,
|
|
623
|
+
maxBarHeight,
|
|
624
|
+
minBarHeight,
|
|
625
|
+
phase,
|
|
626
|
+
smoothedLevel,
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<div
|
|
631
|
+
style={{
|
|
632
|
+
display: 'flex',
|
|
633
|
+
alignItems: 'center',
|
|
634
|
+
gap: barGap,
|
|
635
|
+
height: meterHeight,
|
|
636
|
+
padding: '0 2px',
|
|
637
|
+
}}
|
|
638
|
+
>
|
|
639
|
+
{bars.map((bar, i) => (
|
|
640
|
+
<div
|
|
641
|
+
key={i}
|
|
642
|
+
style={{
|
|
643
|
+
width: barWidth,
|
|
644
|
+
height: bar.height,
|
|
645
|
+
borderRadius: 1,
|
|
646
|
+
backgroundColor: isVoiceActive && bar.isActive ? accentColor : inactiveColor,
|
|
647
|
+
opacity: bar.isActive ? 0.9 + bar.intensity * 0.1 : 0.4,
|
|
648
|
+
transition: 'height 50ms ease-out, background-color 100ms ease, opacity 100ms ease',
|
|
649
|
+
}}
|
|
650
|
+
/>
|
|
651
|
+
))}
|
|
652
|
+
</div>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// =============================================================================
|
|
657
|
+
// Audio Level Meter (VU-style)
|
|
658
|
+
// =============================================================================
|
|
659
|
+
|
|
660
|
+
interface AudioLevelMeterProps {
|
|
661
|
+
/** Current audio level normalized 0-1 */
|
|
662
|
+
level: number;
|
|
663
|
+
/** Whether voice is active */
|
|
664
|
+
isVoiceActive: boolean;
|
|
665
|
+
/** Orientation */
|
|
666
|
+
orientation?: 'horizontal' | 'vertical';
|
|
667
|
+
/** Show dB labels */
|
|
668
|
+
showLabels?: boolean;
|
|
669
|
+
/** Size preset */
|
|
670
|
+
size?: 'small' | 'medium' | 'large';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Professional VU-style level meter
|
|
675
|
+
* Shows current level, peak hold, and dB scale
|
|
676
|
+
*/
|
|
677
|
+
export function AudioLevelMeter({
|
|
678
|
+
level,
|
|
679
|
+
isVoiceActive,
|
|
680
|
+
orientation = 'horizontal',
|
|
681
|
+
showLabels = false,
|
|
682
|
+
size = 'medium',
|
|
683
|
+
}: AudioLevelMeterProps) {
|
|
684
|
+
const waveformColors = useWaveformColors();
|
|
685
|
+
const [smoothedLevel, setSmoothedLevel] = useState(0);
|
|
686
|
+
const [peakLevel, setPeakLevel] = useState(0);
|
|
687
|
+
const peakHoldTimeRef = useRef(0);
|
|
688
|
+
const animationRef = useRef<number>();
|
|
689
|
+
|
|
690
|
+
// Convert linear to dB (clamped range)
|
|
691
|
+
const levelToDb = (l: number): number => {
|
|
692
|
+
if (l <= 0) return -60;
|
|
693
|
+
return Math.max(-60, Math.min(0, 20 * Math.log10(l)));
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Animation loop
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
const animate = (timestamp: number) => {
|
|
699
|
+
// Smooth level
|
|
700
|
+
setSmoothedLevel((prev) => smoothValue(prev, level, 0.15));
|
|
701
|
+
|
|
702
|
+
// Peak with hold
|
|
703
|
+
setPeakLevel((prevPeak) => {
|
|
704
|
+
if (level > prevPeak) {
|
|
705
|
+
peakHoldTimeRef.current = timestamp;
|
|
706
|
+
return level;
|
|
707
|
+
} else if (timestamp - peakHoldTimeRef.current > PEAK_HOLD_TIME) {
|
|
708
|
+
return Math.max(0, prevPeak - PEAK_DECAY_RATE);
|
|
709
|
+
}
|
|
710
|
+
return prevPeak;
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
714
|
+
};
|
|
715
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
716
|
+
return () => {
|
|
717
|
+
if (animationRef.current) {
|
|
718
|
+
cancelAnimationFrame(animationRef.current);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}, [level]);
|
|
722
|
+
|
|
723
|
+
// Size configuration
|
|
724
|
+
const sizeConfig = {
|
|
725
|
+
small: { width: 80, height: 8, labelSize: 8 },
|
|
726
|
+
medium: { width: 120, height: 12, labelSize: 10 },
|
|
727
|
+
large: { width: 200, height: 16, labelSize: 12 },
|
|
728
|
+
};
|
|
729
|
+
const config = sizeConfig[size];
|
|
730
|
+
|
|
731
|
+
const isVertical = orientation === 'vertical';
|
|
732
|
+
const meterWidth = isVertical ? config.height : config.width;
|
|
733
|
+
const meterHeight = isVertical ? config.width : config.height;
|
|
734
|
+
|
|
735
|
+
return (
|
|
736
|
+
<div
|
|
737
|
+
style={{
|
|
738
|
+
display: 'flex',
|
|
739
|
+
flexDirection: isVertical ? 'column' : 'row',
|
|
740
|
+
alignItems: 'center',
|
|
741
|
+
gap: 4,
|
|
742
|
+
}}
|
|
743
|
+
>
|
|
744
|
+
{/* Meter track */}
|
|
745
|
+
<div
|
|
746
|
+
style={{
|
|
747
|
+
position: 'relative',
|
|
748
|
+
width: meterWidth,
|
|
749
|
+
height: meterHeight,
|
|
750
|
+
backgroundColor: 'rgba(31, 41, 55, 0.6)',
|
|
751
|
+
borderRadius: 2,
|
|
752
|
+
overflow: 'hidden',
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
755
|
+
{/* Level segments (gradient effect) */}
|
|
756
|
+
<div
|
|
757
|
+
style={{
|
|
758
|
+
position: 'absolute',
|
|
759
|
+
[isVertical ? 'bottom' : 'left']: 0,
|
|
760
|
+
[isVertical ? 'left' : 'top']: 0,
|
|
761
|
+
[isVertical ? 'width' : 'height']: '100%',
|
|
762
|
+
[isVertical ? 'height' : 'width']: `${smoothedLevel * 100}%`,
|
|
763
|
+
background: isVoiceActive
|
|
764
|
+
? `linear-gradient(${isVertical ? 'to top' : 'to right'},
|
|
765
|
+
${waveformColors.active} 0%,
|
|
766
|
+
${waveformColors.active} 75%,
|
|
767
|
+
${waveformColors.peakWarning} 90%,
|
|
768
|
+
${waveformColors.peak} 100%)`
|
|
769
|
+
: waveformColors.inactive,
|
|
770
|
+
transition: `${isVertical ? 'height' : 'width'} 50ms ease-out`,
|
|
771
|
+
}}
|
|
772
|
+
/>
|
|
773
|
+
|
|
774
|
+
{/* Peak indicator */}
|
|
775
|
+
<div
|
|
776
|
+
style={{
|
|
777
|
+
position: 'absolute',
|
|
778
|
+
[isVertical ? 'bottom' : 'left']: `calc(${peakLevel * 100}% - 2px)`,
|
|
779
|
+
[isVertical ? 'left' : 'top']: 0,
|
|
780
|
+
[isVertical ? 'width' : 'height']: '100%',
|
|
781
|
+
[isVertical ? 'height' : 'width']: 2,
|
|
782
|
+
backgroundColor: peakLevel > 0.9 ? waveformColors.peak : waveformColors.peakWarning,
|
|
783
|
+
opacity: peakLevel > 0.1 ? 0.9 : 0,
|
|
784
|
+
transition: `${isVertical ? 'bottom' : 'left'} 50ms ease-out, opacity 100ms ease`,
|
|
785
|
+
}}
|
|
786
|
+
/>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
{/* dB labels */}
|
|
790
|
+
{showLabels && (
|
|
791
|
+
<span
|
|
792
|
+
style={{
|
|
793
|
+
fontSize: config.labelSize,
|
|
794
|
+
fontFamily: 'ui-monospace, monospace',
|
|
795
|
+
color: smoothedLevel > 0.9 ? waveformColors.peak : waveformColors.textSecondary,
|
|
796
|
+
minWidth: 36,
|
|
797
|
+
textAlign: 'right',
|
|
798
|
+
}}
|
|
799
|
+
>
|
|
800
|
+
{levelToDb(smoothedLevel).toFixed(1)} dB
|
|
801
|
+
</span>
|
|
802
|
+
)}
|
|
803
|
+
</div>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// =============================================================================
|
|
808
|
+
// Default Export
|
|
809
|
+
// =============================================================================
|
|
810
|
+
|
|
811
|
+
export default AudioWaveform;
|