tempest-react-sdk 0.52.0 → 0.54.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/README.md +58 -7
- package/dist/access/Can.cjs +1 -1
- package/dist/access/Can.cjs.map +1 -1
- package/dist/access/Can.js +3 -3
- package/dist/access/Can.js.map +1 -1
- package/dist/audio/audio-bus.cjs +2 -0
- package/dist/audio/audio-bus.cjs.map +1 -0
- package/dist/audio/audio-bus.js +116 -0
- package/dist/audio/audio-bus.js.map +1 -0
- package/dist/audio/level-meter.cjs +1 -1
- package/dist/audio/level-meter.cjs.map +1 -1
- package/dist/audio/level-meter.js +15 -15
- package/dist/audio/level-meter.js.map +1 -1
- package/dist/audio/noise-floor.cjs +2 -0
- package/dist/audio/noise-floor.cjs.map +1 -0
- package/dist/audio/noise-floor.js +27 -0
- package/dist/audio/noise-floor.js.map +1 -0
- package/dist/audio/use-audio-bus.cjs +2 -0
- package/dist/audio/use-audio-bus.cjs.map +1 -0
- package/dist/audio/use-audio-bus.js +11 -0
- package/dist/audio/use-audio-bus.js.map +1 -0
- package/dist/audio/voice-activity.cjs +2 -0
- package/dist/audio/voice-activity.cjs.map +1 -0
- package/dist/audio/voice-activity.js +26 -0
- package/dist/audio/voice-activity.js.map +1 -0
- package/dist/audio/voice-chain-stages.cjs +2 -0
- package/dist/audio/voice-chain-stages.cjs.map +1 -0
- package/dist/audio/voice-chain-stages.js +82 -0
- package/dist/audio/voice-chain-stages.js.map +1 -0
- package/dist/audio/voice-chain.cjs +2 -0
- package/dist/audio/voice-chain.cjs.map +1 -0
- package/dist/audio/voice-chain.js +74 -0
- package/dist/audio/voice-chain.js.map +1 -0
- package/dist/auth/lazy-with-retry.cjs.map +1 -1
- package/dist/auth/lazy-with-retry.js.map +1 -1
- package/dist/auth/passkey.cjs.map +1 -1
- package/dist/auth/passkey.js.map +1 -1
- package/dist/br/BrazilStateCitySelect.cjs +1 -1
- package/dist/br/BrazilStateCitySelect.cjs.map +1 -1
- package/dist/br/BrazilStateCitySelect.js +36 -28
- package/dist/br/BrazilStateCitySelect.js.map +1 -1
- package/dist/br/data/br-centroids.cjs +1 -1
- package/dist/br/data/br-centroids.cjs.map +1 -1
- package/dist/br/data/br-centroids.js +11105 -11045
- package/dist/br/data/br-centroids.js.map +1 -1
- package/dist/br/data/br-locations.cjs +1 -1
- package/dist/br/data/br-locations.cjs.map +1 -1
- package/dist/br/data/br-locations.js +5803 -137
- package/dist/br/data/br-locations.js.map +1 -1
- package/dist/br/data/br-uf-geo.cjs +1 -1
- package/dist/br/data/br-uf-geo.cjs.map +1 -1
- package/dist/br/data/br-uf-geo.js +5721 -6777
- package/dist/br/data/br-uf-geo.js.map +1 -1
- package/dist/br/data/mun/AC.cjs +1 -1
- package/dist/br/data/mun/AC.cjs.map +1 -1
- package/dist/br/data/mun/AC.js +681 -689
- package/dist/br/data/mun/AC.js.map +1 -1
- package/dist/br/data/mun/AL.cjs +1 -1
- package/dist/br/data/mun/AL.cjs.map +1 -1
- package/dist/br/data/mun/AL.js +949 -844
- package/dist/br/data/mun/AL.js.map +1 -1
- package/dist/br/data/mun/AM.cjs +1 -1
- package/dist/br/data/mun/AM.cjs.map +1 -1
- package/dist/br/data/mun/AM.js +4337 -4218
- package/dist/br/data/mun/AM.js.map +1 -1
- package/dist/br/data/mun/AP.cjs +1 -1
- package/dist/br/data/mun/AP.cjs.map +1 -1
- package/dist/br/data/mun/AP.js +786 -780
- package/dist/br/data/mun/AP.js.map +1 -1
- package/dist/br/data/mun/BA.cjs +1 -1
- package/dist/br/data/mun/BA.cjs.map +1 -1
- package/dist/br/data/mun/BA.js +6329 -5633
- package/dist/br/data/mun/BA.js.map +1 -1
- package/dist/br/data/mun/CE.cjs +1 -1
- package/dist/br/data/mun/CE.cjs.map +1 -1
- package/dist/br/data/mun/CE.js +2544 -2487
- package/dist/br/data/mun/CE.js.map +1 -1
- package/dist/br/data/mun/DF.cjs +1 -1
- package/dist/br/data/mun/DF.cjs.map +1 -1
- package/dist/br/data/mun/DF.js +17 -16
- package/dist/br/data/mun/DF.js.map +1 -1
- package/dist/br/data/mun/ES.cjs +1 -1
- package/dist/br/data/mun/ES.cjs.map +1 -1
- package/dist/br/data/mun/ES.js +1101 -1070
- package/dist/br/data/mun/ES.js.map +1 -1
- package/dist/br/data/mun/GO.cjs +1 -1
- package/dist/br/data/mun/GO.cjs.map +1 -1
- package/dist/br/data/mun/GO.js +4632 -4636
- package/dist/br/data/mun/GO.js.map +1 -1
- package/dist/br/data/mun/MA.cjs +1 -1
- package/dist/br/data/mun/MA.cjs.map +1 -1
- package/dist/br/data/mun/MA.js +3603 -3457
- package/dist/br/data/mun/MA.js.map +1 -1
- package/dist/br/data/mun/MG.cjs +1 -1
- package/dist/br/data/mun/MG.cjs.map +1 -1
- package/dist/br/data/mun/MG.js +12716 -11739
- package/dist/br/data/mun/MG.js.map +1 -1
- package/dist/br/data/mun/MS.cjs +1 -1
- package/dist/br/data/mun/MS.cjs.map +1 -1
- package/dist/br/data/mun/MS.js +2182 -2335
- package/dist/br/data/mun/MS.js.map +1 -1
- package/dist/br/data/mun/MT.cjs +1 -1
- package/dist/br/data/mun/MT.cjs.map +1 -1
- package/dist/br/data/mun/MT.js +4808 -4896
- package/dist/br/data/mun/MT.js.map +1 -1
- package/dist/br/data/mun/PA.cjs +1 -1
- package/dist/br/data/mun/PA.cjs.map +1 -1
- package/dist/br/data/mun/PA.js +4981 -4901
- package/dist/br/data/mun/PA.js.map +1 -1
- package/dist/br/data/mun/PB.cjs +1 -1
- package/dist/br/data/mun/PB.cjs.map +1 -1
- package/dist/br/data/mun/PB.js +2418 -1956
- package/dist/br/data/mun/PB.js.map +1 -1
- package/dist/br/data/mun/PE.cjs +1 -1
- package/dist/br/data/mun/PE.cjs.map +1 -1
- package/dist/br/data/mun/PE.js +2164 -1987
- package/dist/br/data/mun/PE.js.map +1 -1
- package/dist/br/data/mun/PI.cjs +1 -1
- package/dist/br/data/mun/PI.cjs.map +1 -1
- package/dist/br/data/mun/PI.js +2982 -2844
- package/dist/br/data/mun/PI.js.map +1 -1
- package/dist/br/data/mun/PR.cjs +1 -1
- package/dist/br/data/mun/PR.cjs.map +1 -1
- package/dist/br/data/mun/PR.js +5425 -5240
- package/dist/br/data/mun/PR.js.map +1 -1
- package/dist/br/data/mun/RJ.cjs +1 -1
- package/dist/br/data/mun/RJ.cjs.map +1 -1
- package/dist/br/data/mun/RJ.js +1395 -1178
- package/dist/br/data/mun/RJ.js.map +1 -1
- package/dist/br/data/mun/RN.cjs +1 -1
- package/dist/br/data/mun/RN.cjs.map +1 -1
- package/dist/br/data/mun/RN.js +1608 -1382
- package/dist/br/data/mun/RN.js.map +1 -1
- package/dist/br/data/mun/RO.cjs +1 -1
- package/dist/br/data/mun/RO.cjs.map +1 -1
- package/dist/br/data/mun/RO.js +1392 -1470
- package/dist/br/data/mun/RO.js.map +1 -1
- package/dist/br/data/mun/RR.cjs +1 -1
- package/dist/br/data/mun/RR.cjs.map +1 -1
- package/dist/br/data/mun/RR.js +795 -778
- package/dist/br/data/mun/RR.js.map +1 -1
- package/dist/br/data/mun/RS.cjs +1 -1
- package/dist/br/data/mun/RS.cjs.map +1 -1
- package/dist/br/data/mun/RS.js +7579 -6424
- package/dist/br/data/mun/RS.js.map +1 -1
- package/dist/br/data/mun/SC.cjs +1 -1
- package/dist/br/data/mun/SC.cjs.map +1 -1
- package/dist/br/data/mun/SC.js +3979 -3357
- package/dist/br/data/mun/SC.js.map +1 -1
- package/dist/br/data/mun/SE.cjs +1 -1
- package/dist/br/data/mun/SE.cjs.map +1 -1
- package/dist/br/data/mun/SE.js +812 -625
- package/dist/br/data/mun/SE.js.map +1 -1
- package/dist/br/data/mun/SP.cjs +1 -1
- package/dist/br/data/mun/SP.cjs.map +1 -1
- package/dist/br/data/mun/SP.js +7866 -6803
- package/dist/br/data/mun/SP.js.map +1 -1
- package/dist/br/data/mun/TO.cjs +1 -1
- package/dist/br/data/mun/TO.cjs.map +1 -1
- package/dist/br/data/mun/TO.js +2763 -2817
- package/dist/br/data/mun/TO.js.map +1 -1
- package/dist/br/locations.cjs +1 -1
- package/dist/br/locations.cjs.map +1 -1
- package/dist/br/locations.js +97 -24
- package/dist/br/locations.js.map +1 -1
- package/dist/br/scales.cjs +1 -1
- package/dist/br/scales.cjs.map +1 -1
- package/dist/br/scales.js +8 -4
- package/dist/br/scales.js.map +1 -1
- package/dist/br/whatsapp.cjs +2 -0
- package/dist/br/whatsapp.cjs.map +1 -0
- package/dist/br/whatsapp.js +21 -0
- package/dist/br/whatsapp.js.map +1 -0
- package/dist/br.cjs +1 -1
- package/dist/br.cjs.map +1 -1
- package/dist/br.d.ts +188 -10
- package/dist/br.js +18 -17
- package/dist/br.js.map +1 -1
- package/dist/components/BottomSheet/BottomSheet.cjs +1 -1
- package/dist/components/BottomSheet/BottomSheet.cjs.map +1 -1
- package/dist/components/BottomSheet/BottomSheet.js +30 -27
- package/dist/components/BottomSheet/BottomSheet.js.map +1 -1
- package/dist/components/Command/Command.cjs +1 -1
- package/dist/components/Command/Command.cjs.map +1 -1
- package/dist/components/Command/Command.js +78 -75
- package/dist/components/Command/Command.js.map +1 -1
- package/dist/components/Drawer/Drawer.cjs +1 -1
- package/dist/components/Drawer/Drawer.cjs.map +1 -1
- package/dist/components/Drawer/Drawer.js +42 -39
- package/dist/components/Drawer/Drawer.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.js +111 -67
- package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
- package/dist/components/Modal/Modal.cjs +1 -1
- package/dist/components/Modal/Modal.cjs.map +1 -1
- package/dist/components/Modal/Modal.js +45 -42
- package/dist/components/Modal/Modal.js.map +1 -1
- package/dist/components/Money/Money.cjs +1 -1
- package/dist/components/Money/Money.cjs.map +1 -1
- package/dist/components/Money/Money.js +10 -9
- package/dist/components/Money/Money.js.map +1 -1
- package/dist/components/Pagination/Pagination.cjs +1 -1
- package/dist/components/Pagination/Pagination.cjs.map +1 -1
- package/dist/components/Pagination/Pagination.js +38 -31
- package/dist/components/Pagination/Pagination.js.map +1 -1
- package/dist/components/Pagination/Pagination.module.cjs +1 -1
- package/dist/components/Pagination/Pagination.module.cjs.map +1 -1
- package/dist/components/Pagination/Pagination.module.js +4 -4
- package/dist/components/Pagination/Pagination.module.js.map +1 -1
- package/dist/components/Portal/Portal.cjs +1 -1
- package/dist/components/Portal/Portal.cjs.map +1 -1
- package/dist/components/Portal/Portal.js +9 -6
- package/dist/components/Portal/Portal.js.map +1 -1
- package/dist/components/Portal/portal-host.cjs +2 -0
- package/dist/components/Portal/portal-host.cjs.map +1 -0
- package/dist/components/Portal/portal-host.js +11 -0
- package/dist/components/Portal/portal-host.js.map +1 -0
- package/dist/components/RangeSlider/RangeSlider.cjs +1 -1
- package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.js +23 -23
- package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.cjs +1 -1
- package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.js +83 -82
- package/dist/components/Scheduler/Scheduler.js.map +1 -1
- package/dist/components/Slider/Slider.cjs +1 -1
- package/dist/components/Slider/Slider.cjs.map +1 -1
- package/dist/components/Slider/Slider.js +9 -9
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +55 -53
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js +41 -31
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.js +78 -68
- package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
- package/dist/forms/FormField.cjs +1 -1
- package/dist/forms/FormField.cjs.map +1 -1
- package/dist/forms/FormField.js +23 -21
- package/dist/forms/FormField.js.map +1 -1
- package/dist/forms/masked-inputs.cjs +1 -1
- package/dist/forms/masked-inputs.cjs.map +1 -1
- package/dist/forms/masked-inputs.js +20 -19
- package/dist/forms/masked-inputs.js.map +1 -1
- package/dist/hooks/use-fullscreen-element.cjs +2 -0
- package/dist/hooks/use-fullscreen-element.cjs.map +1 -0
- package/dist/hooks/use-fullscreen-element.js +20 -0
- package/dist/hooks/use-fullscreen-element.js.map +1 -0
- package/dist/hooks/use-fullscreen.cjs +2 -0
- package/dist/hooks/use-fullscreen.cjs.map +1 -0
- package/dist/hooks/use-fullscreen.js +66 -0
- package/dist/hooks/use-fullscreen.js.map +1 -0
- package/dist/hooks/use-push-to-talk.cjs +2 -0
- package/dist/hooks/use-push-to-talk.cjs.map +1 -0
- package/dist/hooks/use-push-to-talk.js +61 -0
- package/dist/hooks/use-push-to-talk.js.map +1 -0
- package/dist/http/describe-api-error.cjs.map +1 -1
- package/dist/http/describe-api-error.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +34 -16
- package/dist/http/errors.js.map +1 -1
- package/dist/http/parse-response.cjs +2 -2
- package/dist/http/parse-response.cjs.map +1 -1
- package/dist/http/parse-response.js +9 -8
- package/dist/http/parse-response.js.map +1 -1
- package/dist/i18n/create-i18n.cjs +1 -1
- package/dist/i18n/create-i18n.cjs.map +1 -1
- package/dist/i18n/create-i18n.js +28 -27
- package/dist/i18n/create-i18n.js.map +1 -1
- package/dist/query/QueryProvider.cjs +1 -1
- package/dist/query/QueryProvider.cjs.map +1 -1
- package/dist/query/QueryProvider.js +13 -11
- package/dist/query/QueryProvider.js.map +1 -1
- package/dist/query/foreign-client-warning.cjs +2 -0
- package/dist/query/foreign-client-warning.cjs.map +1 -0
- package/dist/query/foreign-client-warning.js +12 -0
- package/dist/query/foreign-client-warning.js.map +1 -0
- package/dist/router/AppRouter.cjs +1 -1
- package/dist/router/AppRouter.cjs.map +1 -1
- package/dist/router/AppRouter.js +28 -21
- package/dist/router/AppRouter.js.map +1 -1
- package/dist/router/RouteGuard.cjs +1 -1
- package/dist/router/RouteGuard.cjs.map +1 -1
- package/dist/router/RouteGuard.js +4 -4
- package/dist/router/RouteGuard.js.map +1 -1
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +34 -38
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/styles/AIChat.css +79 -0
- package/dist/styles/Accordion.css +12 -0
- package/dist/styles/Alert.css +32 -0
- package/dist/styles/AppBar.css +17 -0
- package/dist/styles/AppShell.css +8 -0
- package/dist/styles/AspectRatio.css +3 -0
- package/dist/styles/AudioPlayer.css +13 -0
- package/dist/styles/AudioRecorder.css +20 -0
- package/dist/styles/Avatar.css +13 -0
- package/dist/styles/AvatarGroup.css +18 -0
- package/dist/styles/Badge.css +25 -0
- package/dist/styles/Banner.css +13 -0
- package/dist/styles/BarList.css +10 -0
- package/dist/styles/BarcodeScanner.css +21 -0
- package/dist/styles/BottomNavigation.css +10 -0
- package/dist/styles/BottomSheet.css +11 -0
- package/dist/styles/BrazilMap.css +20 -0
- package/dist/styles/Breadcrumbs.css +8 -0
- package/dist/styles/Button.css +42 -0
- package/dist/styles/Calendar.css +16 -0
- package/dist/styles/Card.css +15 -0
- package/dist/styles/Carousel.css +17 -0
- package/dist/styles/Center.css +5 -0
- package/dist/styles/Chat.css +35 -0
- package/dist/styles/Checkbox.css +11 -0
- package/dist/styles/ChipInput.css +13 -0
- package/dist/styles/CodeBlock.css +25 -0
- package/dist/styles/Collapsible.css +6 -0
- package/dist/styles/Combobox.css +18 -0
- package/dist/styles/Command.css +20 -0
- package/dist/styles/ContextMenu.css +11 -0
- package/dist/styles/CopyButton.css +4 -0
- package/dist/styles/DataList.css +3 -0
- package/dist/styles/DataTable.css +19 -0
- package/dist/styles/DateRangePicker.css +22 -0
- package/dist/styles/DescriptionList.css +4 -0
- package/dist/styles/Divider.css +11 -0
- package/dist/styles/Drawer.css +22 -0
- package/dist/styles/DropdownMenu.css +16 -0
- package/dist/styles/Dropzone.css +7 -0
- package/dist/styles/EmptyState.css +6 -0
- package/dist/styles/ErrorState.css +6 -0
- package/dist/styles/ErrorText.css +2 -0
- package/dist/styles/FileUpload.css +17 -0
- package/dist/styles/FilterBar.css +19 -0
- package/dist/styles/FloatingActionButton.css +23 -0
- package/dist/styles/Form.css +21 -0
- package/dist/styles/HoverCard.css +11 -0
- package/dist/styles/IconPicker.css +6 -0
- package/dist/styles/Image.css +2 -0
- package/dist/styles/ImageCropper.css +16 -0
- package/dist/styles/Input.css +24 -0
- package/dist/styles/InstallBanner.css +8 -0
- package/dist/styles/Kanban.css +15 -0
- package/dist/styles/Kbd.css +5 -0
- package/dist/styles/Label.css +3 -0
- package/dist/styles/Layout.css +22 -0
- package/dist/styles/Lightbox.css +21 -0
- package/dist/styles/ListTile.css +14 -0
- package/dist/styles/MapLegend.css +9 -0
- package/dist/styles/Markdown.css +24 -0
- package/dist/styles/Masonry.css +4 -0
- package/dist/styles/Menubar.css +17 -0
- package/dist/styles/Modal.css +21 -0
- package/dist/styles/ModalsManager.css +2 -0
- package/dist/styles/MultiSelect.css +21 -0
- package/dist/styles/MunicipalitySearch.css +6 -0
- package/dist/styles/NProgress.css +2 -0
- package/dist/styles/Navbar.css +10 -0
- package/dist/styles/NavigationMenu.css +12 -0
- package/dist/styles/NavigationRail.css +14 -0
- package/dist/styles/NotificationCenter.css +21 -0
- package/dist/styles/OfflineIndicator.css +7 -0
- package/dist/styles/Page.css +13 -0
- package/dist/styles/Pagination.css +12 -0
- package/dist/styles/PasswordInput.css +21 -0
- package/dist/styles/PinInput.css +13 -0
- package/dist/styles/PixQRCode.css +9 -0
- package/dist/styles/Popover.css +10 -0
- package/dist/styles/Progress.css +11 -0
- package/dist/styles/QRCode.css +3 -0
- package/dist/styles/Radio.css +14 -0
- package/dist/styles/RangeSlider.css +15 -0
- package/dist/styles/RatingStars.css +11 -0
- package/dist/styles/RefreshIndicator.css +5 -0
- package/dist/styles/Resizable.css +13 -0
- package/dist/styles/RichTextEditor.css +19 -0
- package/dist/styles/SafeArea.css +7 -0
- package/dist/styles/Scheduler.css +24 -0
- package/dist/styles/ScrollArea.css +7 -0
- package/dist/styles/SearchBar.css +11 -0
- package/dist/styles/SegmentedControl.css +12 -0
- package/dist/styles/Select.css +14 -0
- package/dist/styles/Sidebar.css +19 -0
- package/dist/styles/SignaturePad.css +6 -0
- package/dist/styles/Skeleton.css +7 -0
- package/dist/styles/Slider.css +15 -0
- package/dist/styles/Spacer.css +4 -0
- package/dist/styles/Sparkline.css +4 -0
- package/dist/styles/Spinner.css +12 -0
- package/dist/styles/Stat.css +12 -0
- package/dist/styles/Stepper.css +17 -0
- package/dist/styles/StepperInput.css +15 -0
- package/dist/styles/Switch.css +11 -0
- package/dist/styles/SyncStatusBadge.css +13 -0
- package/dist/styles/Table.css +16 -0
- package/dist/styles/Tabs.css +14 -0
- package/dist/styles/Tag.css +14 -0
- package/dist/styles/Textarea.css +13 -0
- package/dist/styles/TimePicker.css +14 -0
- package/dist/styles/Timeline.css +17 -0
- package/dist/styles/Toast.css +21 -0
- package/dist/styles/Toggle.css +12 -0
- package/dist/styles/ToggleGroup.css +9 -0
- package/dist/styles/Tooltip.css +10 -0
- package/dist/styles/Tour.css +20 -0
- package/dist/styles/TrajectoryMap.css +16 -0
- package/dist/styles/Transfer.css +21 -0
- package/dist/styles/TreeView.css +17 -0
- package/dist/styles/TruncateText.css +2 -0
- package/dist/styles/UpdatePrompt.css +10 -0
- package/dist/styles/VirtualList.css +5 -0
- package/dist/styles/VirtualTable.css +17 -0
- package/dist/styles/VisuallyHidden.css +2 -0
- package/dist/styles/Wizard.css +4 -0
- package/dist/styles/actions.css +104 -0
- package/dist/styles/advanced.css +66 -0
- package/dist/styles/br.css +41 -0
- package/dist/styles/chat.css +113 -0
- package/dist/styles/core.css +40 -0
- package/dist/styles/data.css +222 -0
- package/dist/styles/editor.css +19 -0
- package/dist/styles/feedback.css +201 -0
- package/dist/styles/forms.css +321 -0
- package/dist/styles/geo.css +16 -0
- package/dist/styles/icons.css +6 -0
- package/dist/styles/identity.css +48 -0
- package/dist/styles/layout.css +122 -0
- package/dist/styles/media.css +52 -0
- package/dist/styles/navigation.css +167 -0
- package/dist/styles/overlay.css +73 -0
- package/dist/styles/utility.css +39 -0
- package/dist/styles.css +1 -1
- package/dist/sw/register-service-worker.cjs +1 -1
- package/dist/sw/register-service-worker.cjs.map +1 -1
- package/dist/sw/register-service-worker.js +21 -17
- package/dist/sw/register-service-worker.js.map +1 -1
- package/dist/sw.d.ts +17 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +1382 -76
- package/dist/tempest-react-sdk.js +293 -281
- package/dist/utils/dev-mode.cjs.map +1 -1
- package/dist/utils/dev-mode.js.map +1 -1
- package/dist/utils/duplicate-instance.cjs +2 -0
- package/dist/utils/duplicate-instance.cjs.map +1 -0
- package/dist/utils/duplicate-instance.js +6 -0
- package/dist/utils/duplicate-instance.js.map +1 -0
- package/dist/utils/format.cjs +1 -1
- package/dist/utils/format.cjs.map +1 -1
- package/dist/utils/format.js +22 -14
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/intl-cache.cjs +2 -0
- package/dist/utils/intl-cache.cjs.map +1 -0
- package/dist/utils/intl-cache.js +23 -0
- package/dist/utils/intl-cache.js.map +1 -0
- package/dist/utils/json-frame.cjs +2 -0
- package/dist/utils/json-frame.cjs.map +1 -0
- package/dist/utils/json-frame.js +30 -0
- package/dist/utils/json-frame.js.map +1 -0
- package/dist/utils/numbers.cjs +1 -1
- package/dist/utils/numbers.cjs.map +1 -1
- package/dist/utils/numbers.js +8 -7
- package/dist/utils/numbers.js.map +1 -1
- package/dist/vision/results.cjs +1 -1
- package/dist/vision/results.cjs.map +1 -1
- package/dist/vision/results.js +26 -17
- package/dist/vision/results.js.map +1 -1
- package/dist/vision.d.ts +29 -0
- package/dist/vite/tempest-icons.cjs +1 -1
- package/dist/vite/tempest-icons.cjs.map +1 -1
- package/dist/vite/tempest-icons.js +1 -1
- package/dist/vite/tempest-icons.js.map +1 -1
- package/dist/webrtc/link-stats.cjs +2 -0
- package/dist/webrtc/link-stats.cjs.map +1 -0
- package/dist/webrtc/link-stats.js +74 -0
- package/dist/webrtc/link-stats.js.map +1 -0
- package/dist/webrtc/opus-sdp.cjs +3 -0
- package/dist/webrtc/opus-sdp.cjs.map +1 -0
- package/dist/webrtc/opus-sdp.js +94 -0
- package/dist/webrtc/opus-sdp.js.map +1 -0
- package/dist/webrtc/sender-bitrate.cjs +2 -0
- package/dist/webrtc/sender-bitrate.cjs.map +1 -0
- package/dist/webrtc/sender-bitrate.js +15 -0
- package/dist/webrtc/sender-bitrate.js.map +1 -0
- package/dist/webrtc/use-link-stats.cjs +2 -0
- package/dist/webrtc/use-link-stats.cjs.map +1 -0
- package/dist/webrtc/use-link-stats.js +22 -0
- package/dist/webrtc/use-link-stats.js.map +1 -0
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +130 -58
- package/dist/ws/create-web-socket.js.map +1 -1
- package/dist/ws/resilience.cjs +2 -0
- package/dist/ws/resilience.cjs.map +1 -0
- package/dist/ws/resilience.js +21 -0
- package/dist/ws/resilience.js.map +1 -0
- package/dist/ws/use-web-socket.cjs +1 -1
- package/dist/ws/use-web-socket.cjs.map +1 -1
- package/dist/ws/use-web-socket.js +47 -39
- package/dist/ws/use-web-socket.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ The goal is to start every new React frontend with the same opinionated foundati
|
|
|
51
51
|
- [BR validators & masked inputs](#br-validators--masked-inputs-recipe)
|
|
52
52
|
- [ViaCEP lookup](#viacep-lookup-recipe)
|
|
53
53
|
- [WebSocket](#websocket-recipe)
|
|
54
|
+
- [WebRTC (Opus SDP tuning)](#webrtc-opus-sdp-tuning-recipe)
|
|
54
55
|
- [Server-Sent Events (SSE)](#server-sent-events-sse-recipe)
|
|
55
56
|
- [Web Push](#web-push-recipe)
|
|
56
57
|
- [Service Worker helpers](#service-worker-helpers-recipe)
|
|
@@ -201,6 +202,16 @@ This injects the design tokens (`--tempest-primary`, `--tempest-radius-md`, ...)
|
|
|
201
202
|
|
|
202
203
|
The styles ship hashed under the `tempest_` namespace — they do **not** collide with Tailwind, Stitches, Linaria, or app-level CSS Modules.
|
|
203
204
|
|
|
205
|
+
**Importing less.** The JavaScript you import is tree-shaken; the CSS is not, so `styles.css` carries all ~150 components whether you mount thirteen or all of them. Pay for what you use by taking the foundation plus the sheets you need:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import "tempest-react-sdk/styles/core.css"; // reset + tokens — always required
|
|
209
|
+
import "tempest-react-sdk/styles/Button.css";
|
|
210
|
+
import "tempest-react-sdk/styles/forms.css"; // or a whole family
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Measured in a real Vite app mounting twelve components: **236.71 kB raw / 35.38 kB gzip** with `styles.css`, **38.94 kB / 7.70 kB** with `core.css` plus those twelve — 78% less. See [Styles](https://mauriciobenjamin700.github.io/tempest-react-sdk/styles/) for the full list of groups.
|
|
214
|
+
|
|
204
215
|
**Rebranding.** The `--tempest-*` tokens are the only theming API, and `createTheme` writes them for you — `primary` alone yields the ten-step scale for **both** color schemes (dark inverts the ramp), plus the status families, the radius scale and the focus ring:
|
|
205
216
|
|
|
206
217
|
```ts
|
|
@@ -239,15 +250,16 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
239
250
|
| `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
|
|
240
251
|
| `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
|
|
241
252
|
| `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
|
|
242
|
-
| `sse` | `createEventStream`, `useEventStream`
|
|
243
|
-
| `ws` | `createWebSocket
|
|
253
|
+
| `sse` | `createEventStream`, `useEventStream` (`onParseError` para frame que não é JSON) |
|
|
254
|
+
| `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `onParseError` (frame que não é JSON: descarta em vez de entregar `string` como `T`), `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
|
|
255
|
+
| `webrtc` | `tuneOpus` (Opus `fmtp` per audio m-line: per-key merge, `stereo` + `sprop-stereo`, inserts a missing `fmtp`), `setTunedLocalDescription` (falls back to the untouched SDP when the browser refuses the edit), `setSenderBitrate`, `createLinkStatsSampler` / `useLinkStats` / `readRoundTripMs` (throughput as a delta, round trip from the **selected** candidate pair) |
|
|
244
256
|
| `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
|
|
245
257
|
| `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
|
|
246
258
|
| `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
|
|
247
259
|
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
|
|
248
260
|
| `charts` _(subpath `tempest-react-sdk/charts`, opt. peer `recharts`)_ | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DEFAULT_CHART_COLORS` — themed recharts wrappers (recharts externalized; install it only if you use charts) |
|
|
249
261
|
| `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2024 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one chunk per initial letter** (25 max) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces. Unknown slug renders `fallback` and never throws; lucide's 257 deprecated aliases keep resolving |
|
|
250
|
-
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`
|
|
262
|
+
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
|
|
251
263
|
| `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
|
|
252
264
|
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
|
|
253
265
|
| `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
|
|
@@ -273,8 +285,9 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
273
285
|
| `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
|
|
274
286
|
| `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
|
|
275
287
|
| `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
|
|
276
|
-
| `sse` | `createEventStream`, `useEventStream`
|
|
277
|
-
| `ws` | `createWebSocket
|
|
288
|
+
| `sse` | `createEventStream`, `useEventStream` (`onParseError` para frame que não é JSON) |
|
|
289
|
+
| `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `onParseError` (frame que não é JSON: descarta em vez de entregar `string` como `T`), `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
|
|
290
|
+
| `webrtc` | `tuneOpus` (Opus `fmtp` per audio m-line: per-key merge, `stereo` + `sprop-stereo`, inserts a missing `fmtp`), `setTunedLocalDescription` (falls back to the untouched SDP when the browser refuses the edit), `setSenderBitrate`, `createLinkStatsSampler` / `useLinkStats` / `readRoundTripMs` (throughput as a delta, round trip from the **selected** candidate pair) |
|
|
278
291
|
| `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
|
|
279
292
|
| `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
|
|
280
293
|
| `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2024 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one chunk per initial letter** (25 max) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces. Unknown slug renders `fallback` and never throws; lucide's 257 deprecated aliases keep resolving |
|
|
@@ -284,7 +297,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
284
297
|
| `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter`, `DetectClassify` (fused detect→classify pipeline) + `warmup()`, `raiseOnEmpty`/`NoDetectionsError`, `LetterboxPipeline`, `readFusionSpec` and results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
|
|
285
298
|
| `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
|
|
286
299
|
| `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one |
|
|
287
|
-
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`
|
|
300
|
+
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
|
|
288
301
|
| `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
|
|
289
302
|
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
|
|
290
303
|
| `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
|
|
@@ -1022,7 +1035,9 @@ function AddressFields({ form }: { form: UseFormReturn<Address> }) {
|
|
|
1022
1035
|
|
|
1023
1036
|
### WebSocket recipe
|
|
1024
1037
|
|
|
1025
|
-
Wrapper around `WebSocket` with exponential reconnect (up to 10 attempts), optional ping heartbeat, JSON parsing, and `send` that no-ops while the socket isn't open.
|
|
1038
|
+
Wrapper around `WebSocket` with exponential reconnect (up to 10 attempts, jittered), optional ping heartbeat, JSON parsing, and `send` that no-ops while the socket isn't open.
|
|
1039
|
+
|
|
1040
|
+
It also covers the three failures that fire no event at all, which is where a retry chain built on `open`/`close`/`error` gets stuck: a handshake that hangs in `CONNECTING` instead of failing (`handshakeTimeout`, 8 s by default), a link that dies mid-flight leaving the socket `OPEN` with nothing arriving (`silenceTimeout` + `setSilenceTimeout`), and a device with its radio off burning the retry budget (`waitForOnline`). Whether a close is retried comes from its code, not from `wasClean` alone — 4400–4499 is a refusal, minus `4408`, which is the `tempest-fastapi-sdk` heartbeat timeout and does reconnect. See [the WebSocket guide](https://mauriciobenjamin700.github.io/tempest-react-sdk/websocket/).
|
|
1026
1041
|
|
|
1027
1042
|
```tsx
|
|
1028
1043
|
import { useWebSocket } from "tempest-react-sdk";
|
|
@@ -1058,6 +1073,39 @@ socket.send("hello");
|
|
|
1058
1073
|
socket.close();
|
|
1059
1074
|
```
|
|
1060
1075
|
|
|
1076
|
+
### WebRTC (Opus SDP tuning) recipe
|
|
1077
|
+
|
|
1078
|
+
Audio over WebRTC is mono and narrow by default, and the only place that gets corrected is the SDP — which is why shared-screen audio famously sounds like a telephone: music and video inherit the speech profile and no high-level API lets you change it.
|
|
1079
|
+
|
|
1080
|
+
```ts
|
|
1081
|
+
import { setSenderBitrate, setTunedLocalDescription, tuneOpus } from "tempest-react-sdk";
|
|
1082
|
+
|
|
1083
|
+
// one profile per audio m-line (by index or by mid), or one for all of them
|
|
1084
|
+
const tuned = tuneOpus(offer.sdp!, {
|
|
1085
|
+
0: { maxAverageBitrate: 48_000, stereo: false, fec: true, dtx: true },
|
|
1086
|
+
1: { maxAverageBitrate: 192_000, stereo: true, fec: false, dtx: false },
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// applies the tuned SDP, falling back to the untouched one if the browser refuses it
|
|
1090
|
+
const applied = await setTunedLocalDescription(pc, await pc.createOffer(), profiles);
|
|
1091
|
+
|
|
1092
|
+
// the fmtp above says what we want to *receive*; this limits what we *send*
|
|
1093
|
+
const sender = pc.getSenders().find((s) => s.track?.kind === "audio");
|
|
1094
|
+
if (sender) await setSenderBitrate(sender, 48_000);
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
No preset table on purpose — which values to use is the consumer's call. What the module owns is the parsing: per-key `fmtp` merge (so the browser's `minptime` survives), `stereo` written together with `sprop-stereo` (they point in opposite directions), payload types read from the `rtpmap`, an `fmtp` inserted when absent, and video m-lines left alone. Full guide: [WebRTC](https://mauriciobenjamin700.github.io/tempest-react-sdk/webrtc/).
|
|
1098
|
+
|
|
1099
|
+
The same module reduces `getStats()` into the badge a call actually shows:
|
|
1100
|
+
|
|
1101
|
+
```ts
|
|
1102
|
+
import { useLinkStats } from "tempest-react-sdk";
|
|
1103
|
+
|
|
1104
|
+
const stats = useLinkStats(pc); // { kbps, width, height, fps, rttMs }
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
Two things every hand-rolled copy gets wrong: the round trip has to come from the pair the transport names in `selectedCandidatePairId` (a connection keeps idle host pairs around, and reading those makes the number jump between paths nobody is using), and the throughput has to be a **delta** — `bytesSent` is cumulative, so dividing it by the session length reports a historical average that only ever falls. Sampling pauses while the tab is hidden and resets its baseline on the way back, so the first sample after a background stretch is not the average of it.
|
|
1108
|
+
|
|
1061
1109
|
### Server-Sent Events (SSE) recipe
|
|
1062
1110
|
|
|
1063
1111
|
Stream with exponential reconnect (up to 10 attempts), `ping` heartbeat, JSON parsing by default. For cookie-auth endpoints, pass `withCredentials: true`.
|
|
@@ -1203,6 +1251,8 @@ The SDK does **not** ship the worker file or the bundler config — pair it with
|
|
|
1203
1251
|
|
|
1204
1252
|
`playAudio` for one-shot sounds (notification chime, payment success), `createAudioPlayer` for isolated channels, `useAudio` for a per-component player.
|
|
1205
1253
|
|
|
1254
|
+
To turn a source **up** rather than down, use `createAudioBus` / `useAudioBus`: `element.volume` is clamped at `1`, so a quiet participant could only ever be attenuated. The bus mixes several streams through a Web Audio graph with a limiter after the sum (clipping is a property of the mix, not of one source), routes the result with `setSinkId`, and carries the muted anchor element Chrome needs to pull samples from a remote WebRTC stream at all ([crbug.com/687574](https://crbug.com/687574)). Guide: [Audio](https://mauriciobenjamin700.github.io/tempest-react-sdk/audio/).
|
|
1255
|
+
|
|
1206
1256
|
```ts
|
|
1207
1257
|
import { playAudio } from "tempest-react-sdk";
|
|
1208
1258
|
|
|
@@ -1739,6 +1789,7 @@ if (result.unsupported) {
|
|
|
1739
1789
|
| `useKeyboardShortcut(shortcut, handler)` | `"Meta+K"` / `"Ctrl+Shift+P"` patterns. |
|
|
1740
1790
|
| `useBeforeInstallPrompt()` | Capture the PWA install prompt and trigger it later. |
|
|
1741
1791
|
| `useIdle(timeout)` | `true` after `timeout` ms of no input. |
|
|
1792
|
+
| `useFullscreen(ref?)` | Immersive mode driven by `fullscreenchange`, so `Esc`/F11 stay in sync. |
|
|
1742
1793
|
| `useGeolocation()` | One-shot or watch position with permission state. |
|
|
1743
1794
|
| `useScrollLock(active)` | Lock body scroll while a modal is open. |
|
|
1744
1795
|
| `useFocusTrap(ref, active)` | Trap focus inside a container. |
|
package/dist/access/Can.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("./use-can.cjs");let t=require("react/jsx-runtime");function n({action:n,resource:r,params:i,children:a,fallback:o}){let{allowed:
|
|
1
|
+
const e=require("./use-can.cjs");let t=require("react/jsx-runtime");function n({action:n,resource:r,params:i,children:a,fallback:o,pending:s}){let{allowed:c,isLoading:l}=e.useCan({action:n,resource:r,params:i});return l?(0,t.jsx)(t.Fragment,{children:s===void 0?o??null:s}):(0,t.jsx)(t.Fragment,{children:c?a:o??null})}exports.Can=n;
|
|
2
2
|
//# sourceMappingURL=Can.cjs.map
|
package/dist/access/Can.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Can.cjs","names":[],"sources":["../../src/access/Can.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCan } from \"./use-can\";\nimport type { CanParams } from \"./types\";\n\nexport interface CanProps extends CanParams {\n /** Rendered when the action is allowed. */\n children: ReactNode;\n /** Rendered when the action is denied. Defaults to nothing. */\n fallback?: ReactNode;\n}\n\n/**\n * Conditionally render based on an access check. Renders `children` when the\n * action is allowed
|
|
1
|
+
{"version":3,"file":"Can.cjs","names":[],"sources":["../../src/access/Can.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCan } from \"./use-can\";\nimport type { CanParams } from \"./types\";\n\nexport interface CanProps extends CanParams {\n /** Rendered when the action is allowed. */\n children: ReactNode;\n /** Rendered when the action is denied. Defaults to nothing. */\n fallback?: ReactNode;\n /**\n * Rendered while an async check is still in flight. Defaults to\n * {@link fallback}, which keeps the pre-existing behaviour.\n *\n * Worth setting whenever `fallback` says something denial-shaped: without\n * it, a permission that resolves over the network flashes \"no access\" to\n * someone who has it. `pending={null}` renders nothing until the answer\n * lands; `pending={<Spinner />}` says why the screen is waiting.\n */\n pending?: ReactNode;\n}\n\n/**\n * Conditionally render based on an access check. Renders `children` when the\n * action is allowed and `fallback` when it is denied. While an async check is\n * pending it renders `pending`, which defaults to `fallback`.\n *\n * @example\n * ```tsx\n * <Can action=\"create\" resource=\"posts\" fallback={<p>No access</p>}>\n * <NewPostButton />\n * </Can>\n * ```\n *\n * @example\n * ```tsx\n * <Can\n * action=\"read\"\n * resource=\"finance\"\n * fallback={<p>No access</p>}\n * pending={<Spinner />}\n * >\n * <FinancePanel />\n * </Can>\n * ```\n */\nexport function Can({ action, resource, params, children, fallback, pending }: CanProps) {\n const { allowed, isLoading } = useCan({ action, resource, params });\n if (isLoading) return <>{pending === undefined ? (fallback ?? null) : pending}</>;\n return <>{allowed ? children : (fallback ?? null)}</>;\n}\n"],"mappings":"oEA6CA,SAAgB,EAAI,CAAE,SAAQ,WAAU,SAAQ,WAAU,WAAU,WAAqB,CACrF,GAAM,CAAE,UAAS,aAAc,EAAA,OAAO,CAAE,SAAQ,WAAU,QAAO,CAAC,EAElE,OADI,GAAkB,EAAA,EAAA,IAAA,CAAA,EAAA,SAAA,CAAA,SAAG,IAAY,IAAA,GAAa,GAAY,KAAQ,CAAU,CAAA,GACzE,EAAA,EAAA,IAAA,CAAA,EAAA,SAAA,CAAA,SAAG,EAAU,EAAY,GAAY,IAAQ,CAAA,CACxD"}
|
package/dist/access/Can.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useCan as e } from "./use-can.js";
|
|
2
2
|
import { Fragment as t, jsx as n } from "react/jsx-runtime";
|
|
3
3
|
//#region src/access/Can.tsx
|
|
4
|
-
function r({ action: r, resource: i, params: a, children: o, fallback: s }) {
|
|
5
|
-
let { allowed:
|
|
4
|
+
function r({ action: r, resource: i, params: a, children: o, fallback: s, pending: c }) {
|
|
5
|
+
let { allowed: l, isLoading: u } = e({
|
|
6
6
|
action: r,
|
|
7
7
|
resource: i,
|
|
8
8
|
params: a
|
|
9
9
|
});
|
|
10
|
-
return
|
|
10
|
+
return u ? /* @__PURE__ */ n(t, { children: c === void 0 ? s ?? null : c }) : /* @__PURE__ */ n(t, { children: l ? o : s ?? null });
|
|
11
11
|
}
|
|
12
12
|
//#endregion
|
|
13
13
|
export { r as Can };
|
package/dist/access/Can.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Can.js","names":[],"sources":["../../src/access/Can.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCan } from \"./use-can\";\nimport type { CanParams } from \"./types\";\n\nexport interface CanProps extends CanParams {\n /** Rendered when the action is allowed. */\n children: ReactNode;\n /** Rendered when the action is denied. Defaults to nothing. */\n fallback?: ReactNode;\n}\n\n/**\n * Conditionally render based on an access check. Renders `children` when the\n * action is allowed
|
|
1
|
+
{"version":3,"file":"Can.js","names":[],"sources":["../../src/access/Can.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { useCan } from \"./use-can\";\nimport type { CanParams } from \"./types\";\n\nexport interface CanProps extends CanParams {\n /** Rendered when the action is allowed. */\n children: ReactNode;\n /** Rendered when the action is denied. Defaults to nothing. */\n fallback?: ReactNode;\n /**\n * Rendered while an async check is still in flight. Defaults to\n * {@link fallback}, which keeps the pre-existing behaviour.\n *\n * Worth setting whenever `fallback` says something denial-shaped: without\n * it, a permission that resolves over the network flashes \"no access\" to\n * someone who has it. `pending={null}` renders nothing until the answer\n * lands; `pending={<Spinner />}` says why the screen is waiting.\n */\n pending?: ReactNode;\n}\n\n/**\n * Conditionally render based on an access check. Renders `children` when the\n * action is allowed and `fallback` when it is denied. While an async check is\n * pending it renders `pending`, which defaults to `fallback`.\n *\n * @example\n * ```tsx\n * <Can action=\"create\" resource=\"posts\" fallback={<p>No access</p>}>\n * <NewPostButton />\n * </Can>\n * ```\n *\n * @example\n * ```tsx\n * <Can\n * action=\"read\"\n * resource=\"finance\"\n * fallback={<p>No access</p>}\n * pending={<Spinner />}\n * >\n * <FinancePanel />\n * </Can>\n * ```\n */\nexport function Can({ action, resource, params, children, fallback, pending }: CanProps) {\n const { allowed, isLoading } = useCan({ action, resource, params });\n if (isLoading) return <>{pending === undefined ? (fallback ?? null) : pending}</>;\n return <>{allowed ? children : (fallback ?? null)}</>;\n}\n"],"mappings":";;;AA6CA,SAAgB,EAAI,EAAE,WAAQ,aAAU,WAAQ,aAAU,aAAU,cAAqB;CACrF,IAAM,EAAE,YAAS,iBAAc,EAAO;EAAE;EAAQ;EAAU;CAAO,CAAC;CAElE,OADI,IAAkB,kBAAA,GAAA,EAAA,UAAG,MAAY,KAAA,IAAa,KAAY,OAAQ,EAAU,CAAA,IACzE,kBAAA,GAAA,EAAA,UAAG,IAAU,IAAY,KAAY,KAAQ,CAAA;AACxD"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./audio-output.cjs");var t=3,n={threshold:-8,knee:0,ratio:20,attack:.003,release:.25};function r(e,t){return Number.isFinite(e)?Math.min(t,Math.max(0,e)):1}function i(){return typeof AudioContext<`u`?AudioContext:globalThis.webkitAudioContext}function a(e,t){return typeof MediaStream>`u`?e:new MediaStream([t])}function o(e){try{e.play?.()?.catch(()=>void 0)}catch{}}function s({maxGain:t=3,limiter:s,context:c}={}){let l=i(),u=c??(l?new l:null);if(!u)return{attach:()=>({setGain:()=>void 0,gain:1,stop:()=>void 0}),setMasterGain:()=>void 0,masterGain:1,setOutputDevice:async()=>!1,outputDevice:``,resume:async()=>void 0,close:()=>void 0,supported:!1,canSelectOutput:!1};let d=u,f=!c,p=d.createGain(),m=d.createMediaStreamDestination(),h=null;if(s===!1)p.connect(m);else{let e=d.createDynamicsCompressor();h=e;let t={...n,...s};e.threshold.value=t.threshold,e.knee.value=t.knee,e.ratio.value=t.ratio,e.attack.value=t.attack,e.release.value=t.release,p.connect(e),e.connect(m)}let g=new Audio;g.autoplay=!0,g.srcObject=m.stream,o(g);let _=``,v=1,y=!1,b=new Set;function x(){return{setGain:()=>void 0,gain:1,stop:()=>void 0}}function S(e,{gain:n=1}={}){let i=e.getAudioTracks()[0];if(y||!i)return x();let s=new Audio;s.muted=!0,s.autoplay=!0,s.srcObject=e,o(s);let c=d.createMediaStreamSource(a(e,i)),l=d.createGain(),u=r(n,t);l.gain.value=u,c.connect(l),l.connect(p);let f={setGain(e){u=r(e,t),l.gain.value=u},get gain(){return u},stop(){b.has(f)&&(b.delete(f),c.disconnect(),l.disconnect(),s.srcObject=null,s.pause())}};return b.add(f),f}return{attach:S,setMasterGain(e){y||(v=r(e,t),p.gain.value=v)},get masterGain(){return v},async setOutputDevice(t){let n=await e.setAudioOutput(g,t);return n&&(_=t),n},get outputDevice(){return _},async resume(){await d.resume?.().catch(()=>void 0)},close(){if(!y){y=!0;for(let e of[...b])e.stop();g.srcObject=null,g.pause(),p.disconnect(),h?.disconnect(),f&&d.close().catch(()=>void 0)}},supported:!0,get canSelectOutput(){return e.isAudioOutputSelectionSupported()}}}exports.DEFAULT_MAX_GAIN=t,exports.createAudioBus=s;
|
|
2
|
+
//# sourceMappingURL=audio-bus.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio-bus.cjs","names":[],"sources":["../../src/audio/audio-bus.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mixing graph, the per-source handle and the\n * output route are one object's lifetime: the limiter only makes sense after the\n * sum, and the element that carries `setSinkId` is the same one the mix leaves\n * through. Splitting them would hand the caller two halves that are useless apart.\n */\nimport { isAudioOutputSelectionSupported, setAudioOutput } from \"./audio-output\";\n\n/** Default ceiling for a per-source or master gain, as a multiplier. */\nexport const DEFAULT_MAX_GAIN = 3;\n\n/** Shape of the master limiter. Matches `DynamicsCompressorNode`'s params. */\nexport interface LimiterSettings {\n /** dBFS above which the compressor starts working. */\n threshold: number;\n /** dB range over which the curve bends. `0` is a hard knee. */\n knee: number;\n /** Input/output ratio above the threshold. 20 is limiting, not compression. */\n ratio: number;\n /** Seconds to clamp a peak. */\n attack: number;\n /** Seconds to let go. */\n release: number;\n}\n\n/**\n * A limiter, not a compressor: a hard knee at a 20:1 ratio with a 3 ms attack\n * catches the sum before it clips without audibly ducking anything below it.\n */\nconst DEFAULT_LIMITER: LimiterSettings = {\n threshold: -8,\n knee: 0,\n ratio: 20,\n attack: 0.003,\n release: 0.25,\n};\n\n/** Options for {@link createAudioBus}. */\nexport interface AudioBusOptions {\n /** Ceiling for every gain on this bus. Default {@link DEFAULT_MAX_GAIN}. */\n maxGain?: number;\n /** Override the master limiter, or pass `false` to run without one. */\n limiter?: Partial<LimiterSettings> | false;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap the number of live contexts (Chrome allows around six), so a\n * page that already has one — a level meter, a player — should hand it over\n * rather than open a second.\n */\n context?: AudioContext;\n}\n\n/** One source attached to the bus. */\nexport interface AudioBusHandle {\n /**\n * Set this source's gain, where `1` is the level it arrived at.\n *\n * Values above `1` are the point of the whole graph: `element.volume` is\n * clamped at `1`, so a quiet talker could only ever be attenuated — the one\n * correction nobody needs.\n */\n setGain: (gain: number) => void;\n /** Current gain, after clamping. */\n readonly gain: number;\n /** Detach this source and release its nodes. The bus stays up. */\n stop: () => void;\n}\n\n/** A running mix. */\nexport interface AudioBus {\n /**\n * Play a stream through the shared mix.\n *\n * @param stream - The stream to play. Only its first audio track is used.\n * @param options - `gain` is the initial multiplier, default `1`.\n * @returns A handle to adjust or detach this source.\n */\n attach: (stream: MediaStream, options?: { gain?: number }) => AudioBusHandle;\n /** Scale every source, on top of its own gain. */\n setMasterGain: (gain: number) => void;\n /** Current master gain, after clamping. */\n readonly masterGain: number;\n /**\n * Route the whole mix to one output device.\n *\n * @param deviceId - Device id from `useMediaDevices().audioOutputs`, or `\"\"`\n * for the system default.\n * @returns `false` when the engine cannot route audio, or the device is gone.\n */\n setOutputDevice: (deviceId: string) => Promise<boolean>;\n /** Device id the mix is routed to. `\"\"` is the system default. */\n readonly outputDevice: string;\n /**\n * Resume a context the browser started suspended.\n *\n * Autoplay policy suspends a context created outside a user gesture, and a\n * suspended context is silent with no error anywhere. Call this from the\n * click that starts playback.\n */\n resume: () => Promise<void>;\n /**\n * Detach everything and close the context this bus created.\n *\n * Idempotent, and the bus stays callable afterwards: `attach` hands back an\n * inert handle instead of throwing. That is not politeness — creating a node\n * on a closed `AudioContext` throws `InvalidStateError`, and the stream that\n * arrives late is the normal case (a WebRTC `ontrack` firing after the\n * component that owned the bus went away).\n */\n close: () => void;\n /** Whether this browser gave us a Web Audio graph at all. */\n readonly supported: boolean;\n /** Whether this browser can route the mix to a chosen output device. */\n readonly canSelectOutput: boolean;\n}\n\n/**\n * Clamp a gain into `0..max`, treating a non-finite value as unity.\n *\n * `NaN` reaches here from an empty input or a failed parse, and assigning it to\n * an `AudioParam` throws — losing the audio for a typo in a number field.\n */\nfunction clampGain(gain: number, max: number): number {\n if (!Number.isFinite(gain)) return 1;\n return Math.min(max, Math.max(0, gain));\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n if (typeof AudioContext !== \"undefined\") return AudioContext;\n return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/** A media element that may support output routing. */\ntype SinkCapableElement = HTMLAudioElement & { setSinkId?: (sinkId: string) => Promise<void> };\n\n/**\n * The stream to feed the graph: just the audio track, when this engine can build\n * one.\n *\n * Isolating the track keeps a camera's video out of the source node. `MediaStream`\n * is missing outside a browser (jsdom, a worker), and there the original stream is\n * the right answer anyway — `createMediaStreamSource` reads its first audio track.\n */\nfunction audioOnly(stream: MediaStream, track: MediaStreamTrack): MediaStream {\n if (typeof MediaStream === \"undefined\") return stream;\n return new MediaStream([track]);\n}\n\n/** Start playback without letting an environment that has no media pipeline throw. */\nfunction playQuietly(element: HTMLAudioElement): void {\n try {\n void element.play?.()?.catch(() => undefined);\n } catch {\n /* jsdom and any engine without a media pipeline — nothing to play anyway */\n }\n}\n\n/**\n * Mix several streams into one output, with gain above 100% and a master limiter.\n *\n * Three things make this a graph instead of a few `<audio>` elements:\n *\n * 1. **`element.volume` is clamped at `1`.** A participant who speaks too quietly\n * can only be turned *down* — the one correction nobody needs. A `GainNode`\n * has no ceiling, so this bus takes one (`maxGain`, default 3).\n * 2. **Clipping is a property of the sum.** Three sources at 200% each are clean\n * alone and distort the instant they play together. A per-source limiter\n * cannot see that; the one after the mix can, which is where this puts it.\n * 3. **`setSinkId` lives on the element, not on the context.** Sending the mix to\n * a headset while the rest of the system keeps the speakers is only reachable\n * by leaving through a `MediaStreamAudioDestinationNode` into a real\n * `<audio>`. `AudioContext.setSinkId` has far thinner support.\n *\n * @param options - See {@link AudioBusOptions}.\n * @returns The bus. On an engine with no Web Audio it is inert but callable, and\n * `supported` is `false` — a page without sound beats a page that throws.\n *\n * @example\n * const bus = createAudioBus({ maxGain: 3 });\n * const handle = bus.attach(remoteStream, { gain: 1 });\n * handle.setGain(2.4); // above 1 — the point of the whole thing\n * await bus.setOutputDevice(headsetId);\n */\nexport function createAudioBus({\n maxGain = DEFAULT_MAX_GAIN,\n limiter: limiterOptions,\n context: injectedContext,\n}: AudioBusOptions = {}): AudioBus {\n const Ctor = audioContextConstructor();\n const context = injectedContext ?? (Ctor ? new Ctor() : null);\n\n if (!context) {\n return {\n attach: () => ({ setGain: () => undefined, gain: 1, stop: () => undefined }),\n setMasterGain: () => undefined,\n masterGain: 1,\n setOutputDevice: async () => false,\n outputDevice: \"\",\n resume: async () => undefined,\n close: () => undefined,\n supported: false,\n canSelectOutput: false,\n };\n }\n\n const graph = context;\n const ownsContext = !injectedContext;\n const master = graph.createGain();\n const destination = graph.createMediaStreamDestination();\n\n let limiterNode: DynamicsCompressorNode | null = null;\n if (limiterOptions === false) {\n master.connect(destination);\n } else {\n const limiter = graph.createDynamicsCompressor();\n limiterNode = limiter;\n const settings = { ...DEFAULT_LIMITER, ...limiterOptions };\n limiter.threshold.value = settings.threshold;\n limiter.knee.value = settings.knee;\n limiter.ratio.value = settings.ratio;\n limiter.attack.value = settings.attack;\n limiter.release.value = settings.release;\n master.connect(limiter);\n limiter.connect(destination);\n }\n\n const element = new Audio();\n element.autoplay = true;\n element.srcObject = destination.stream;\n playQuietly(element);\n\n let sinkId = \"\";\n let masterGain = 1;\n let closed = false;\n const handles = new Set<AudioBusHandle>();\n\n /** The handle a bus hands out when there is nothing to play through. */\n function inertHandle(): AudioBusHandle {\n return { setGain: () => undefined, gain: 1, stop: () => undefined };\n }\n\n /**\n * Play one stream through the mix.\n *\n * The muted `<audio>` anchor is not dead weight, and deleting it as unused is\n * the mistake this comment exists to prevent: Chrome will not pull samples\n * from a `MediaStreamAudioSourceNode` built over a **remote** WebRTC stream\n * unless that same stream is also attached to a media element\n * (crbug.com/687574). Without it the graph is visibly correct and completely\n * silent — a day of debugging for anyone who does not know the bug. It is\n * muted because the audible copy is the one leaving the bus.\n */\n function attach(stream: MediaStream, { gain = 1 }: { gain?: number } = {}): AudioBusHandle {\n const track = stream.getAudioTracks()[0];\n if (closed || !track) return inertHandle();\n\n const anchor = new Audio();\n anchor.muted = true;\n anchor.autoplay = true;\n anchor.srcObject = stream;\n playQuietly(anchor);\n\n const source = graph.createMediaStreamSource(audioOnly(stream, track));\n const node = graph.createGain();\n let current = clampGain(gain, maxGain);\n node.gain.value = current;\n source.connect(node);\n node.connect(master);\n\n const handle: AudioBusHandle = {\n setGain(next: number): void {\n current = clampGain(next, maxGain);\n node.gain.value = current;\n },\n get gain(): number {\n return current;\n },\n stop(): void {\n if (!handles.has(handle)) return;\n handles.delete(handle);\n source.disconnect();\n node.disconnect();\n anchor.srcObject = null;\n anchor.pause();\n },\n };\n handles.add(handle);\n return handle;\n }\n\n return {\n attach,\n setMasterGain(gain: number): void {\n if (closed) return;\n masterGain = clampGain(gain, maxGain);\n master.gain.value = masterGain;\n },\n get masterGain(): number {\n return masterGain;\n },\n async setOutputDevice(deviceId: string): Promise<boolean> {\n const applied = await setAudioOutput(element as SinkCapableElement, deviceId);\n if (applied) sinkId = deviceId;\n return applied;\n },\n get outputDevice(): string {\n return sinkId;\n },\n async resume(): Promise<void> {\n await graph.resume?.().catch(() => undefined);\n },\n close(): void {\n if (closed) return;\n closed = true;\n for (const handle of [...handles]) handle.stop();\n element.srcObject = null;\n element.pause();\n master.disconnect();\n limiterNode?.disconnect();\n if (ownsContext) void graph.close().catch(() => undefined);\n },\n supported: true,\n get canSelectOutput(): boolean {\n return isAudioOutputSelectionSupported();\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isAudioOutputSelectionSupported as e, setAudioOutput as t } from "./audio-output.js";
|
|
2
|
+
//#region src/audio/audio-bus.ts
|
|
3
|
+
var n = 3, r = {
|
|
4
|
+
threshold: -8,
|
|
5
|
+
knee: 0,
|
|
6
|
+
ratio: 20,
|
|
7
|
+
attack: .003,
|
|
8
|
+
release: .25
|
|
9
|
+
};
|
|
10
|
+
function i(e, t) {
|
|
11
|
+
return Number.isFinite(e) ? Math.min(t, Math.max(0, e)) : 1;
|
|
12
|
+
}
|
|
13
|
+
function a() {
|
|
14
|
+
return typeof AudioContext < "u" ? AudioContext : globalThis.webkitAudioContext;
|
|
15
|
+
}
|
|
16
|
+
function o(e, t) {
|
|
17
|
+
return typeof MediaStream > "u" ? e : new MediaStream([t]);
|
|
18
|
+
}
|
|
19
|
+
function s(e) {
|
|
20
|
+
try {
|
|
21
|
+
e.play?.()?.catch(() => void 0);
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
function c({ maxGain: n = 3, limiter: c, context: l } = {}) {
|
|
25
|
+
let u = a(), d = l ?? (u ? new u() : null);
|
|
26
|
+
if (!d) return {
|
|
27
|
+
attach: () => ({
|
|
28
|
+
setGain: () => void 0,
|
|
29
|
+
gain: 1,
|
|
30
|
+
stop: () => void 0
|
|
31
|
+
}),
|
|
32
|
+
setMasterGain: () => void 0,
|
|
33
|
+
masterGain: 1,
|
|
34
|
+
setOutputDevice: async () => !1,
|
|
35
|
+
outputDevice: "",
|
|
36
|
+
resume: async () => void 0,
|
|
37
|
+
close: () => void 0,
|
|
38
|
+
supported: !1,
|
|
39
|
+
canSelectOutput: !1
|
|
40
|
+
};
|
|
41
|
+
let f = d, p = !l, m = f.createGain(), h = f.createMediaStreamDestination(), g = null;
|
|
42
|
+
if (c === !1) m.connect(h);
|
|
43
|
+
else {
|
|
44
|
+
let e = f.createDynamicsCompressor();
|
|
45
|
+
g = e;
|
|
46
|
+
let t = {
|
|
47
|
+
...r,
|
|
48
|
+
...c
|
|
49
|
+
};
|
|
50
|
+
e.threshold.value = t.threshold, e.knee.value = t.knee, e.ratio.value = t.ratio, e.attack.value = t.attack, e.release.value = t.release, m.connect(e), e.connect(h);
|
|
51
|
+
}
|
|
52
|
+
let _ = new Audio();
|
|
53
|
+
_.autoplay = !0, _.srcObject = h.stream, s(_);
|
|
54
|
+
let v = "", y = 1, b = !1, x = /* @__PURE__ */ new Set();
|
|
55
|
+
function S() {
|
|
56
|
+
return {
|
|
57
|
+
setGain: () => void 0,
|
|
58
|
+
gain: 1,
|
|
59
|
+
stop: () => void 0
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function C(e, { gain: t = 1 } = {}) {
|
|
63
|
+
let r = e.getAudioTracks()[0];
|
|
64
|
+
if (b || !r) return S();
|
|
65
|
+
let a = new Audio();
|
|
66
|
+
a.muted = !0, a.autoplay = !0, a.srcObject = e, s(a);
|
|
67
|
+
let c = f.createMediaStreamSource(o(e, r)), l = f.createGain(), u = i(t, n);
|
|
68
|
+
l.gain.value = u, c.connect(l), l.connect(m);
|
|
69
|
+
let d = {
|
|
70
|
+
setGain(e) {
|
|
71
|
+
u = i(e, n), l.gain.value = u;
|
|
72
|
+
},
|
|
73
|
+
get gain() {
|
|
74
|
+
return u;
|
|
75
|
+
},
|
|
76
|
+
stop() {
|
|
77
|
+
x.has(d) && (x.delete(d), c.disconnect(), l.disconnect(), a.srcObject = null, a.pause());
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return x.add(d), d;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
attach: C,
|
|
84
|
+
setMasterGain(e) {
|
|
85
|
+
b || (y = i(e, n), m.gain.value = y);
|
|
86
|
+
},
|
|
87
|
+
get masterGain() {
|
|
88
|
+
return y;
|
|
89
|
+
},
|
|
90
|
+
async setOutputDevice(e) {
|
|
91
|
+
let n = await t(_, e);
|
|
92
|
+
return n && (v = e), n;
|
|
93
|
+
},
|
|
94
|
+
get outputDevice() {
|
|
95
|
+
return v;
|
|
96
|
+
},
|
|
97
|
+
async resume() {
|
|
98
|
+
await f.resume?.().catch(() => void 0);
|
|
99
|
+
},
|
|
100
|
+
close() {
|
|
101
|
+
if (!b) {
|
|
102
|
+
b = !0;
|
|
103
|
+
for (let e of [...x]) e.stop();
|
|
104
|
+
_.srcObject = null, _.pause(), m.disconnect(), g?.disconnect(), p && f.close().catch(() => void 0);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
supported: !0,
|
|
108
|
+
get canSelectOutput() {
|
|
109
|
+
return e();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
export { n as DEFAULT_MAX_GAIN, c as createAudioBus };
|
|
115
|
+
|
|
116
|
+
//# sourceMappingURL=audio-bus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio-bus.js","names":[],"sources":["../../src/audio/audio-bus.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mixing graph, the per-source handle and the\n * output route are one object's lifetime: the limiter only makes sense after the\n * sum, and the element that carries `setSinkId` is the same one the mix leaves\n * through. Splitting them would hand the caller two halves that are useless apart.\n */\nimport { isAudioOutputSelectionSupported, setAudioOutput } from \"./audio-output\";\n\n/** Default ceiling for a per-source or master gain, as a multiplier. */\nexport const DEFAULT_MAX_GAIN = 3;\n\n/** Shape of the master limiter. Matches `DynamicsCompressorNode`'s params. */\nexport interface LimiterSettings {\n /** dBFS above which the compressor starts working. */\n threshold: number;\n /** dB range over which the curve bends. `0` is a hard knee. */\n knee: number;\n /** Input/output ratio above the threshold. 20 is limiting, not compression. */\n ratio: number;\n /** Seconds to clamp a peak. */\n attack: number;\n /** Seconds to let go. */\n release: number;\n}\n\n/**\n * A limiter, not a compressor: a hard knee at a 20:1 ratio with a 3 ms attack\n * catches the sum before it clips without audibly ducking anything below it.\n */\nconst DEFAULT_LIMITER: LimiterSettings = {\n threshold: -8,\n knee: 0,\n ratio: 20,\n attack: 0.003,\n release: 0.25,\n};\n\n/** Options for {@link createAudioBus}. */\nexport interface AudioBusOptions {\n /** Ceiling for every gain on this bus. Default {@link DEFAULT_MAX_GAIN}. */\n maxGain?: number;\n /** Override the master limiter, or pass `false` to run without one. */\n limiter?: Partial<LimiterSettings> | false;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap the number of live contexts (Chrome allows around six), so a\n * page that already has one — a level meter, a player — should hand it over\n * rather than open a second.\n */\n context?: AudioContext;\n}\n\n/** One source attached to the bus. */\nexport interface AudioBusHandle {\n /**\n * Set this source's gain, where `1` is the level it arrived at.\n *\n * Values above `1` are the point of the whole graph: `element.volume` is\n * clamped at `1`, so a quiet talker could only ever be attenuated — the one\n * correction nobody needs.\n */\n setGain: (gain: number) => void;\n /** Current gain, after clamping. */\n readonly gain: number;\n /** Detach this source and release its nodes. The bus stays up. */\n stop: () => void;\n}\n\n/** A running mix. */\nexport interface AudioBus {\n /**\n * Play a stream through the shared mix.\n *\n * @param stream - The stream to play. Only its first audio track is used.\n * @param options - `gain` is the initial multiplier, default `1`.\n * @returns A handle to adjust or detach this source.\n */\n attach: (stream: MediaStream, options?: { gain?: number }) => AudioBusHandle;\n /** Scale every source, on top of its own gain. */\n setMasterGain: (gain: number) => void;\n /** Current master gain, after clamping. */\n readonly masterGain: number;\n /**\n * Route the whole mix to one output device.\n *\n * @param deviceId - Device id from `useMediaDevices().audioOutputs`, or `\"\"`\n * for the system default.\n * @returns `false` when the engine cannot route audio, or the device is gone.\n */\n setOutputDevice: (deviceId: string) => Promise<boolean>;\n /** Device id the mix is routed to. `\"\"` is the system default. */\n readonly outputDevice: string;\n /**\n * Resume a context the browser started suspended.\n *\n * Autoplay policy suspends a context created outside a user gesture, and a\n * suspended context is silent with no error anywhere. Call this from the\n * click that starts playback.\n */\n resume: () => Promise<void>;\n /**\n * Detach everything and close the context this bus created.\n *\n * Idempotent, and the bus stays callable afterwards: `attach` hands back an\n * inert handle instead of throwing. That is not politeness — creating a node\n * on a closed `AudioContext` throws `InvalidStateError`, and the stream that\n * arrives late is the normal case (a WebRTC `ontrack` firing after the\n * component that owned the bus went away).\n */\n close: () => void;\n /** Whether this browser gave us a Web Audio graph at all. */\n readonly supported: boolean;\n /** Whether this browser can route the mix to a chosen output device. */\n readonly canSelectOutput: boolean;\n}\n\n/**\n * Clamp a gain into `0..max`, treating a non-finite value as unity.\n *\n * `NaN` reaches here from an empty input or a failed parse, and assigning it to\n * an `AudioParam` throws — losing the audio for a typo in a number field.\n */\nfunction clampGain(gain: number, max: number): number {\n if (!Number.isFinite(gain)) return 1;\n return Math.min(max, Math.max(0, gain));\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n if (typeof AudioContext !== \"undefined\") return AudioContext;\n return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/** A media element that may support output routing. */\ntype SinkCapableElement = HTMLAudioElement & { setSinkId?: (sinkId: string) => Promise<void> };\n\n/**\n * The stream to feed the graph: just the audio track, when this engine can build\n * one.\n *\n * Isolating the track keeps a camera's video out of the source node. `MediaStream`\n * is missing outside a browser (jsdom, a worker), and there the original stream is\n * the right answer anyway — `createMediaStreamSource` reads its first audio track.\n */\nfunction audioOnly(stream: MediaStream, track: MediaStreamTrack): MediaStream {\n if (typeof MediaStream === \"undefined\") return stream;\n return new MediaStream([track]);\n}\n\n/** Start playback without letting an environment that has no media pipeline throw. */\nfunction playQuietly(element: HTMLAudioElement): void {\n try {\n void element.play?.()?.catch(() => undefined);\n } catch {\n /* jsdom and any engine without a media pipeline — nothing to play anyway */\n }\n}\n\n/**\n * Mix several streams into one output, with gain above 100% and a master limiter.\n *\n * Three things make this a graph instead of a few `<audio>` elements:\n *\n * 1. **`element.volume` is clamped at `1`.** A participant who speaks too quietly\n * can only be turned *down* — the one correction nobody needs. A `GainNode`\n * has no ceiling, so this bus takes one (`maxGain`, default 3).\n * 2. **Clipping is a property of the sum.** Three sources at 200% each are clean\n * alone and distort the instant they play together. A per-source limiter\n * cannot see that; the one after the mix can, which is where this puts it.\n * 3. **`setSinkId` lives on the element, not on the context.** Sending the mix to\n * a headset while the rest of the system keeps the speakers is only reachable\n * by leaving through a `MediaStreamAudioDestinationNode` into a real\n * `<audio>`. `AudioContext.setSinkId` has far thinner support.\n *\n * @param options - See {@link AudioBusOptions}.\n * @returns The bus. On an engine with no Web Audio it is inert but callable, and\n * `supported` is `false` — a page without sound beats a page that throws.\n *\n * @example\n * const bus = createAudioBus({ maxGain: 3 });\n * const handle = bus.attach(remoteStream, { gain: 1 });\n * handle.setGain(2.4); // above 1 — the point of the whole thing\n * await bus.setOutputDevice(headsetId);\n */\nexport function createAudioBus({\n maxGain = DEFAULT_MAX_GAIN,\n limiter: limiterOptions,\n context: injectedContext,\n}: AudioBusOptions = {}): AudioBus {\n const Ctor = audioContextConstructor();\n const context = injectedContext ?? (Ctor ? new Ctor() : null);\n\n if (!context) {\n return {\n attach: () => ({ setGain: () => undefined, gain: 1, stop: () => undefined }),\n setMasterGain: () => undefined,\n masterGain: 1,\n setOutputDevice: async () => false,\n outputDevice: \"\",\n resume: async () => undefined,\n close: () => undefined,\n supported: false,\n canSelectOutput: false,\n };\n }\n\n const graph = context;\n const ownsContext = !injectedContext;\n const master = graph.createGain();\n const destination = graph.createMediaStreamDestination();\n\n let limiterNode: DynamicsCompressorNode | null = null;\n if (limiterOptions === false) {\n master.connect(destination);\n } else {\n const limiter = graph.createDynamicsCompressor();\n limiterNode = limiter;\n const settings = { ...DEFAULT_LIMITER, ...limiterOptions };\n limiter.threshold.value = settings.threshold;\n limiter.knee.value = settings.knee;\n limiter.ratio.value = settings.ratio;\n limiter.attack.value = settings.attack;\n limiter.release.value = settings.release;\n master.connect(limiter);\n limiter.connect(destination);\n }\n\n const element = new Audio();\n element.autoplay = true;\n element.srcObject = destination.stream;\n playQuietly(element);\n\n let sinkId = \"\";\n let masterGain = 1;\n let closed = false;\n const handles = new Set<AudioBusHandle>();\n\n /** The handle a bus hands out when there is nothing to play through. */\n function inertHandle(): AudioBusHandle {\n return { setGain: () => undefined, gain: 1, stop: () => undefined };\n }\n\n /**\n * Play one stream through the mix.\n *\n * The muted `<audio>` anchor is not dead weight, and deleting it as unused is\n * the mistake this comment exists to prevent: Chrome will not pull samples\n * from a `MediaStreamAudioSourceNode` built over a **remote** WebRTC stream\n * unless that same stream is also attached to a media element\n * (crbug.com/687574). Without it the graph is visibly correct and completely\n * silent — a day of debugging for anyone who does not know the bug. It is\n * muted because the audible copy is the one leaving the bus.\n */\n function attach(stream: MediaStream, { gain = 1 }: { gain?: number } = {}): AudioBusHandle {\n const track = stream.getAudioTracks()[0];\n if (closed || !track) return inertHandle();\n\n const anchor = new Audio();\n anchor.muted = true;\n anchor.autoplay = true;\n anchor.srcObject = stream;\n playQuietly(anchor);\n\n const source = graph.createMediaStreamSource(audioOnly(stream, track));\n const node = graph.createGain();\n let current = clampGain(gain, maxGain);\n node.gain.value = current;\n source.connect(node);\n node.connect(master);\n\n const handle: AudioBusHandle = {\n setGain(next: number): void {\n current = clampGain(next, maxGain);\n node.gain.value = current;\n },\n get gain(): number {\n return current;\n },\n stop(): void {\n if (!handles.has(handle)) return;\n handles.delete(handle);\n source.disconnect();\n node.disconnect();\n anchor.srcObject = null;\n anchor.pause();\n },\n };\n handles.add(handle);\n return handle;\n }\n\n return {\n attach,\n setMasterGain(gain: number): void {\n if (closed) return;\n masterGain = clampGain(gain, maxGain);\n master.gain.value = masterGain;\n },\n get masterGain(): number {\n return masterGain;\n },\n async setOutputDevice(deviceId: string): Promise<boolean> {\n const applied = await setAudioOutput(element as SinkCapableElement, deviceId);\n if (applied) sinkId = deviceId;\n return applied;\n },\n get outputDevice(): string {\n return sinkId;\n },\n async resume(): Promise<void> {\n await graph.resume?.().catch(() => undefined);\n },\n close(): void {\n if (closed) return;\n closed = true;\n for (const handle of [...handles]) handle.stop();\n element.srcObject = null;\n element.pause();\n master.disconnect();\n limiterNode?.disconnect();\n if (ownsContext) void graph.close().catch(() => undefined);\n },\n supported: true,\n get canSelectOutput(): boolean {\n return isAudioOutputSelectionSupported();\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function e(e,{fftSize:t=1024,decay:n=.7}={}){let
|
|
1
|
+
function e(e,{fftSize:t=1024,decay:n=.7,context:r}={}){let i=typeof AudioContext<`u`?AudioContext:globalThis.webkitAudioContext;if(!r&&!i)return{level:()=>0,stop:()=>void 0};let a=r??new i,o=a.createMediaStreamSource(e),s=a.createAnalyser();s.fftSize=t,o.connect(s);let c=new Float32Array(s.fftSize),l=0,u=!1;return{level:()=>{if(u)return 0;s.getFloatTimeDomainData(c);let e=0;for(let t=0;t<c.length;t+=1)e+=c[t]*c[t];let t=Math.sqrt(e/c.length);return l=t>l?t:l*n+t*(1-n),Math.min(1,l)},stop:()=>{if(!u){u=!0;try{o.disconnect(),s.disconnect()}catch{}r||a.close().catch(()=>void 0)}}}}exports.createLevelMeter=e;
|
|
2
2
|
//# sourceMappingURL=level-meter.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"level-meter.cjs","names":[],"sources":["../../src/audio/level-meter.ts"],"sourcesContent":["/** Options for {@link createLevelMeter}. */\nexport interface LevelMeterOptions {\n /**\n * FFT window. 1024 is a good default: ~21 ms at 48 kHz, long enough to be stable\n * and short enough to react to a syllable.\n */\n fftSize?: number;\n /**\n * Extra smoothing on the way down, 0–1. Default `0.7`.\n *\n * A raw RMS reading is jittery enough to make a meter look broken. Attack is left\n * instant — a meter that lags the voice going *up* reads as \"not recording\" —\n * while the decay is eased, which is how every hardware meter behaves.\n */\n decay?: number;\n}\n\n/** A running level meter. */\nexport interface LevelMeter {\n /** Latest level, 0–1. Read it on your own frame loop. */\n level: () => number;\n /** Stop sampling and release the Web Audio graph. */\n stop: () => void;\n}\n\n/**\n * Sample the loudness of a live stream, 0–1.\n *\n * This exists because a recorder with no visible level is indistinguishable from a\n * broken one. A muted OS input, a headset whose mic arm is switched off, the wrong\n * device selected — all three produce a perfectly successful recording of silence,\n * and the user only finds out after they finish talking.\n *\n * The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks\n * perceived loudness. It is deliberately **not** React state — updating state per\n * frame would re-render the tree 60 times a second. Poll `level()` from a\n * `requestAnimationFrame` loop and write to the DOM, or read it inside an existing\n * animation.\n *\n * The `AudioContext` is created here and closed by `stop()`. Browsers cap the number\n * of live contexts (Chrome allows around six), so a meter left running on unmount\n * eventually breaks every later one on the page.\n *\n * `level()` applies instant attack and eased release: a value above the current one\n * is taken as is, a lower one decays by `decay`. A meter that fell as fast as it rose\n * reads as noise instead of as loudness.\n *\n * @tempest-limits empty-catch — `stop()` disconnects nodes whose context may already\n * be closed (the tab was backgrounded, the stream's track ended, `stop()` raced an\n * unmount), and a disconnect on a dead graph throws. There is nothing to report and\n * nothing to retry: the resource this call would have released is already gone, and\n * the `context.close()` right after it is what actually matters.\n *\n * @param stream - A live audio stream.\n * @param options - See {@link LevelMeterOptions}.\n * @returns A `level()` reader and a `stop()`.\n *\n * @example\n * const meter = createLevelMeter(stream);\n * const tick = () => { bar.style.transform = `scaleX(${meter.level()})`; raf = requestAnimationFrame(tick); };\n */\nexport function createLevelMeter(\n stream: MediaStream,\n { fftSize = 1024, decay = 0.7 }: LevelMeterOptions = {},\n): LevelMeter {\n const Ctor: typeof AudioContext | undefined =\n typeof AudioContext !== \"undefined\"\n ? AudioContext\n : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n\n if (!Ctor) {\n return { level: () => 0, stop: () => undefined };\n }\n\n const context = new Ctor();\n const source = context.createMediaStreamSource(stream);\n const analyser = context.createAnalyser();\n analyser.fftSize = fftSize;\n source.connect(analyser);\n\n const samples = new Float32Array(analyser.fftSize);\n let smoothed = 0;\n let closed = false;\n\n const read = (): number => {\n if (closed) return 0;\n analyser.getFloatTimeDomainData(samples);\n let sum = 0;\n for (let index = 0; index < samples.length; index += 1) {\n sum += samples[index] * samples[index];\n }\n const rms = Math.sqrt(sum / samples.length);\n smoothed = rms > smoothed ? rms : smoothed * decay + rms * (1 - decay);\n return Math.min(1, smoothed);\n };\n\n return {\n level: read,\n stop: (): void => {\n if (closed) return;\n closed = true;\n try {\n source.disconnect();\n analyser.disconnect();\n } catch {\n /* empty */\n }\n void context.close().catch(() => undefined);\n },\n };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"level-meter.cjs","names":[],"sources":["../../src/audio/level-meter.ts"],"sourcesContent":["/** Options for {@link createLevelMeter}. */\nexport interface LevelMeterOptions {\n /**\n * FFT window. 1024 is a good default: ~21 ms at 48 kHz, long enough to be stable\n * and short enough to react to a syllable.\n */\n fftSize?: number;\n /**\n * Extra smoothing on the way down, 0–1. Default `0.7`.\n *\n * A raw RMS reading is jittery enough to make a meter look broken. Attack is left\n * instant — a meter that lags the voice going *up* reads as \"not recording\" —\n * while the decay is eased, which is how every hardware meter behaves.\n *\n * `0` turns the smoothing off and reports the window's RMS as measured, which is\n * what a detector wants: {@link monitorVoiceActivity} applies its own release,\n * and two of them stacked would hold the indicator open long past the speech.\n */\n decay?: number;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap live contexts (Chrome allows around six), and a meter per remote\n * participant reaches that in a five-person call. A caller measuring several\n * streams should open one context and hand it to every meter. `stop()` closes\n * only a context this meter created — an injected one belongs to the caller.\n */\n context?: AudioContext;\n}\n\n/** A running level meter. */\nexport interface LevelMeter {\n /** Latest level, 0–1. Read it on your own frame loop. */\n level: () => number;\n /** Stop sampling and release the Web Audio graph. */\n stop: () => void;\n}\n\n/**\n * Sample the loudness of a live stream, 0–1.\n *\n * This exists because a recorder with no visible level is indistinguishable from a\n * broken one. A muted OS input, a headset whose mic arm is switched off, the wrong\n * device selected — all three produce a perfectly successful recording of silence,\n * and the user only finds out after they finish talking.\n *\n * The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks\n * perceived loudness. It is deliberately **not** React state — updating state per\n * frame would re-render the tree 60 times a second. Poll `level()` from a\n * `requestAnimationFrame` loop and write to the DOM, or read it inside an existing\n * animation.\n *\n * The `AudioContext` is created here and closed by `stop()`. Browsers cap the number\n * of live contexts (Chrome allows around six), so a meter left running on unmount\n * eventually breaks every later one on the page.\n *\n * `level()` applies instant attack and eased release: a value above the current one\n * is taken as is, a lower one decays by `decay`. A meter that fell as fast as it rose\n * reads as noise instead of as loudness.\n *\n * @tempest-limits empty-catch — `stop()` disconnects nodes whose context may already\n * be closed (the tab was backgrounded, the stream's track ended, `stop()` raced an\n * unmount), and a disconnect on a dead graph throws. There is nothing to report and\n * nothing to retry: the resource this call would have released is already gone, and\n * the `context.close()` right after it is what actually matters.\n *\n * @param stream - A live audio stream.\n * @param options - See {@link LevelMeterOptions}.\n * @returns A `level()` reader and a `stop()`.\n *\n * @example\n * const meter = createLevelMeter(stream);\n * const tick = () => { bar.style.transform = `scaleX(${meter.level()})`; raf = requestAnimationFrame(tick); };\n */\nexport function createLevelMeter(\n stream: MediaStream,\n { fftSize = 1024, decay = 0.7, context: injectedContext }: LevelMeterOptions = {},\n): LevelMeter {\n const Ctor: typeof AudioContext | undefined =\n typeof AudioContext !== \"undefined\"\n ? AudioContext\n : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n\n if (!injectedContext && !Ctor) {\n return { level: () => 0, stop: () => undefined };\n }\n\n const context = injectedContext ?? new (Ctor as typeof AudioContext)();\n const source = context.createMediaStreamSource(stream);\n const analyser = context.createAnalyser();\n analyser.fftSize = fftSize;\n source.connect(analyser);\n\n const samples = new Float32Array(analyser.fftSize);\n let smoothed = 0;\n let closed = false;\n\n const read = (): number => {\n if (closed) return 0;\n analyser.getFloatTimeDomainData(samples);\n let sum = 0;\n for (let index = 0; index < samples.length; index += 1) {\n sum += samples[index] * samples[index];\n }\n const rms = Math.sqrt(sum / samples.length);\n smoothed = rms > smoothed ? rms : smoothed * decay + rms * (1 - decay);\n return Math.min(1, smoothed);\n };\n\n return {\n level: read,\n stop: (): void => {\n if (closed) return;\n closed = true;\n try {\n source.disconnect();\n analyser.disconnect();\n } catch {\n /* empty */\n }\n if (!injectedContext) void context.close().catch(() => undefined);\n },\n };\n}\n"],"mappings":"AA0EA,SAAgB,EACZ,EACA,CAAE,UAAU,KAAM,QAAQ,GAAK,QAAS,GAAuC,CAAC,EACtE,CACV,IAAM,EACF,OAAO,aAAiB,IAClB,aACC,WAA4D,mBAEvE,GAAI,CAAC,GAAmB,CAAC,EACrB,MAAO,CAAE,UAAa,EAAG,SAAY,IAAA,EAAU,EAGnD,IAAM,EAAU,GAAmB,IAAK,EAClC,EAAS,EAAQ,wBAAwB,CAAM,EAC/C,EAAW,EAAQ,eAAe,EACxC,EAAS,QAAU,EACnB,EAAO,QAAQ,CAAQ,EAEvB,IAAM,EAAU,IAAI,aAAa,EAAS,OAAO,EAC7C,EAAW,EACX,EAAS,GAcb,MAAO,CACH,UAbuB,CACvB,GAAI,EAAQ,MAAO,GACnB,EAAS,uBAAuB,CAAO,EACvC,IAAI,EAAM,EACV,IAAK,IAAI,EAAQ,EAAG,EAAQ,EAAQ,OAAQ,GAAS,EACjD,GAAO,EAAQ,GAAS,EAAQ,GAEpC,IAAM,EAAM,KAAK,KAAK,EAAM,EAAQ,MAAM,EAE1C,MADA,GAAW,EAAM,EAAW,EAAM,EAAW,EAAQ,GAAO,EAAI,GACzD,KAAK,IAAI,EAAG,CAAQ,CAC/B,EAII,SAAkB,CACV,MACJ,GAAS,GACT,GAAI,CACA,EAAO,WAAW,EAClB,EAAS,WAAW,CACxB,MAAQ,CAER,CACK,GAAiB,EAAa,MAAM,CAAC,CAAC,UAAY,IAAA,EAAS,CAPvD,CAQb,CACJ,CACJ"}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
//#region src/audio/level-meter.ts
|
|
2
|
-
function e(e, { fftSize: t = 1024, decay: n = .7 } = {}) {
|
|
3
|
-
let
|
|
4
|
-
if (!r) return {
|
|
2
|
+
function e(e, { fftSize: t = 1024, decay: n = .7, context: r } = {}) {
|
|
3
|
+
let i = typeof AudioContext < "u" ? AudioContext : globalThis.webkitAudioContext;
|
|
4
|
+
if (!r && !i) return {
|
|
5
5
|
level: () => 0,
|
|
6
6
|
stop: () => void 0
|
|
7
7
|
};
|
|
8
|
-
let
|
|
9
|
-
|
|
10
|
-
let
|
|
8
|
+
let a = r ?? new i(), o = a.createMediaStreamSource(e), s = a.createAnalyser();
|
|
9
|
+
s.fftSize = t, o.connect(s);
|
|
10
|
+
let c = new Float32Array(s.fftSize), l = 0, u = !1;
|
|
11
11
|
return {
|
|
12
12
|
level: () => {
|
|
13
|
-
if (
|
|
14
|
-
|
|
13
|
+
if (u) return 0;
|
|
14
|
+
s.getFloatTimeDomainData(c);
|
|
15
15
|
let e = 0;
|
|
16
|
-
for (let t = 0; t <
|
|
17
|
-
let t = Math.sqrt(e /
|
|
18
|
-
return
|
|
16
|
+
for (let t = 0; t < c.length; t += 1) e += c[t] * c[t];
|
|
17
|
+
let t = Math.sqrt(e / c.length);
|
|
18
|
+
return l = t > l ? t : l * n + t * (1 - n), Math.min(1, l);
|
|
19
19
|
},
|
|
20
20
|
stop: () => {
|
|
21
|
-
if (!
|
|
22
|
-
|
|
21
|
+
if (!u) {
|
|
22
|
+
u = !0;
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
o.disconnect(), s.disconnect();
|
|
25
25
|
} catch {}
|
|
26
|
-
|
|
26
|
+
r || a.close().catch(() => void 0);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"level-meter.js","names":[],"sources":["../../src/audio/level-meter.ts"],"sourcesContent":["/** Options for {@link createLevelMeter}. */\nexport interface LevelMeterOptions {\n /**\n * FFT window. 1024 is a good default: ~21 ms at 48 kHz, long enough to be stable\n * and short enough to react to a syllable.\n */\n fftSize?: number;\n /**\n * Extra smoothing on the way down, 0–1. Default `0.7`.\n *\n * A raw RMS reading is jittery enough to make a meter look broken. Attack is left\n * instant — a meter that lags the voice going *up* reads as \"not recording\" —\n * while the decay is eased, which is how every hardware meter behaves.\n */\n decay?: number;\n}\n\n/** A running level meter. */\nexport interface LevelMeter {\n /** Latest level, 0–1. Read it on your own frame loop. */\n level: () => number;\n /** Stop sampling and release the Web Audio graph. */\n stop: () => void;\n}\n\n/**\n * Sample the loudness of a live stream, 0–1.\n *\n * This exists because a recorder with no visible level is indistinguishable from a\n * broken one. A muted OS input, a headset whose mic arm is switched off, the wrong\n * device selected — all three produce a perfectly successful recording of silence,\n * and the user only finds out after they finish talking.\n *\n * The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks\n * perceived loudness. It is deliberately **not** React state — updating state per\n * frame would re-render the tree 60 times a second. Poll `level()` from a\n * `requestAnimationFrame` loop and write to the DOM, or read it inside an existing\n * animation.\n *\n * The `AudioContext` is created here and closed by `stop()`. Browsers cap the number\n * of live contexts (Chrome allows around six), so a meter left running on unmount\n * eventually breaks every later one on the page.\n *\n * `level()` applies instant attack and eased release: a value above the current one\n * is taken as is, a lower one decays by `decay`. A meter that fell as fast as it rose\n * reads as noise instead of as loudness.\n *\n * @tempest-limits empty-catch — `stop()` disconnects nodes whose context may already\n * be closed (the tab was backgrounded, the stream's track ended, `stop()` raced an\n * unmount), and a disconnect on a dead graph throws. There is nothing to report and\n * nothing to retry: the resource this call would have released is already gone, and\n * the `context.close()` right after it is what actually matters.\n *\n * @param stream - A live audio stream.\n * @param options - See {@link LevelMeterOptions}.\n * @returns A `level()` reader and a `stop()`.\n *\n * @example\n * const meter = createLevelMeter(stream);\n * const tick = () => { bar.style.transform = `scaleX(${meter.level()})`; raf = requestAnimationFrame(tick); };\n */\nexport function createLevelMeter(\n stream: MediaStream,\n { fftSize = 1024, decay = 0.7 }: LevelMeterOptions = {},\n): LevelMeter {\n const Ctor: typeof AudioContext | undefined =\n typeof AudioContext !== \"undefined\"\n ? AudioContext\n : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n\n if (!Ctor) {\n return { level: () => 0, stop: () => undefined };\n }\n\n const context = new Ctor();\n const source = context.createMediaStreamSource(stream);\n const analyser = context.createAnalyser();\n analyser.fftSize = fftSize;\n source.connect(analyser);\n\n const samples = new Float32Array(analyser.fftSize);\n let smoothed = 0;\n let closed = false;\n\n const read = (): number => {\n if (closed) return 0;\n analyser.getFloatTimeDomainData(samples);\n let sum = 0;\n for (let index = 0; index < samples.length; index += 1) {\n sum += samples[index] * samples[index];\n }\n const rms = Math.sqrt(sum / samples.length);\n smoothed = rms > smoothed ? rms : smoothed * decay + rms * (1 - decay);\n return Math.min(1, smoothed);\n };\n\n return {\n level: read,\n stop: (): void => {\n if (closed) return;\n closed = true;\n try {\n source.disconnect();\n analyser.disconnect();\n } catch {\n /* empty */\n }\n void context.close().catch(() => undefined);\n },\n };\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"level-meter.js","names":[],"sources":["../../src/audio/level-meter.ts"],"sourcesContent":["/** Options for {@link createLevelMeter}. */\nexport interface LevelMeterOptions {\n /**\n * FFT window. 1024 is a good default: ~21 ms at 48 kHz, long enough to be stable\n * and short enough to react to a syllable.\n */\n fftSize?: number;\n /**\n * Extra smoothing on the way down, 0–1. Default `0.7`.\n *\n * A raw RMS reading is jittery enough to make a meter look broken. Attack is left\n * instant — a meter that lags the voice going *up* reads as \"not recording\" —\n * while the decay is eased, which is how every hardware meter behaves.\n *\n * `0` turns the smoothing off and reports the window's RMS as measured, which is\n * what a detector wants: {@link monitorVoiceActivity} applies its own release,\n * and two of them stacked would hold the indicator open long past the speech.\n */\n decay?: number;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap live contexts (Chrome allows around six), and a meter per remote\n * participant reaches that in a five-person call. A caller measuring several\n * streams should open one context and hand it to every meter. `stop()` closes\n * only a context this meter created — an injected one belongs to the caller.\n */\n context?: AudioContext;\n}\n\n/** A running level meter. */\nexport interface LevelMeter {\n /** Latest level, 0–1. Read it on your own frame loop. */\n level: () => number;\n /** Stop sampling and release the Web Audio graph. */\n stop: () => void;\n}\n\n/**\n * Sample the loudness of a live stream, 0–1.\n *\n * This exists because a recorder with no visible level is indistinguishable from a\n * broken one. A muted OS input, a headset whose mic arm is switched off, the wrong\n * device selected — all three produce a perfectly successful recording of silence,\n * and the user only finds out after they finish talking.\n *\n * The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks\n * perceived loudness. It is deliberately **not** React state — updating state per\n * frame would re-render the tree 60 times a second. Poll `level()` from a\n * `requestAnimationFrame` loop and write to the DOM, or read it inside an existing\n * animation.\n *\n * The `AudioContext` is created here and closed by `stop()`. Browsers cap the number\n * of live contexts (Chrome allows around six), so a meter left running on unmount\n * eventually breaks every later one on the page.\n *\n * `level()` applies instant attack and eased release: a value above the current one\n * is taken as is, a lower one decays by `decay`. A meter that fell as fast as it rose\n * reads as noise instead of as loudness.\n *\n * @tempest-limits empty-catch — `stop()` disconnects nodes whose context may already\n * be closed (the tab was backgrounded, the stream's track ended, `stop()` raced an\n * unmount), and a disconnect on a dead graph throws. There is nothing to report and\n * nothing to retry: the resource this call would have released is already gone, and\n * the `context.close()` right after it is what actually matters.\n *\n * @param stream - A live audio stream.\n * @param options - See {@link LevelMeterOptions}.\n * @returns A `level()` reader and a `stop()`.\n *\n * @example\n * const meter = createLevelMeter(stream);\n * const tick = () => { bar.style.transform = `scaleX(${meter.level()})`; raf = requestAnimationFrame(tick); };\n */\nexport function createLevelMeter(\n stream: MediaStream,\n { fftSize = 1024, decay = 0.7, context: injectedContext }: LevelMeterOptions = {},\n): LevelMeter {\n const Ctor: typeof AudioContext | undefined =\n typeof AudioContext !== \"undefined\"\n ? AudioContext\n : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n\n if (!injectedContext && !Ctor) {\n return { level: () => 0, stop: () => undefined };\n }\n\n const context = injectedContext ?? new (Ctor as typeof AudioContext)();\n const source = context.createMediaStreamSource(stream);\n const analyser = context.createAnalyser();\n analyser.fftSize = fftSize;\n source.connect(analyser);\n\n const samples = new Float32Array(analyser.fftSize);\n let smoothed = 0;\n let closed = false;\n\n const read = (): number => {\n if (closed) return 0;\n analyser.getFloatTimeDomainData(samples);\n let sum = 0;\n for (let index = 0; index < samples.length; index += 1) {\n sum += samples[index] * samples[index];\n }\n const rms = Math.sqrt(sum / samples.length);\n smoothed = rms > smoothed ? rms : smoothed * decay + rms * (1 - decay);\n return Math.min(1, smoothed);\n };\n\n return {\n level: read,\n stop: (): void => {\n if (closed) return;\n closed = true;\n try {\n source.disconnect();\n analyser.disconnect();\n } catch {\n /* empty */\n }\n if (!injectedContext) void context.close().catch(() => undefined);\n },\n };\n}\n"],"mappings":";AA0EA,SAAgB,EACZ,GACA,EAAE,aAAU,MAAM,WAAQ,IAAK,SAAS,MAAuC,CAAC,GACtE;CACV,IAAM,IACF,OAAO,eAAiB,MAClB,eACC,WAA4D;CAEvE,IAAI,CAAC,KAAmB,CAAC,GACrB,OAAO;EAAE,aAAa;EAAG,YAAY,KAAA;CAAU;CAGnD,IAAM,IAAU,KAAmB,IAAK,EAA6B,GAC/D,IAAS,EAAQ,wBAAwB,CAAM,GAC/C,IAAW,EAAQ,eAAe;CAExC,AADA,EAAS,UAAU,GACnB,EAAO,QAAQ,CAAQ;CAEvB,IAAM,IAAU,IAAI,aAAa,EAAS,OAAO,GAC7C,IAAW,GACX,IAAS;CAcb,OAAO;EACH,aAbuB;GACvB,IAAI,GAAQ,OAAO;GACnB,EAAS,uBAAuB,CAAO;GACvC,IAAI,IAAM;GACV,KAAK,IAAI,IAAQ,GAAG,IAAQ,EAAQ,QAAQ,KAAS,GACjD,KAAO,EAAQ,KAAS,EAAQ;GAEpC,IAAM,IAAM,KAAK,KAAK,IAAM,EAAQ,MAAM;GAE1C,OADA,IAAW,IAAM,IAAW,IAAM,IAAW,IAAQ,KAAO,IAAI,IACzD,KAAK,IAAI,GAAG,CAAQ;EAC/B;EAII,YAAkB;GACV,QACJ;QAAS;IACT,IAAI;KAEA,AADA,EAAO,WAAW,GAClB,EAAS,WAAW;IACxB,QAAQ,CAER;IACA,AAAK,KAAiB,EAAa,MAAM,CAAC,CAAC,YAAY,KAAA,CAAS;GAPvD;EAQb;CACJ;AACJ"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./voice-chain-stages.cjs");var t=3e3,n=50,r=2.4,i=.004,a=.12;function o(){return typeof AudioContext<`u`?AudioContext:globalThis.webkitAudioContext}async function s(r,{onProgress:i,durationMs:a=t,context:s}={}){let c=o(),l=s??(c?new c:null),u=r.getAudioTracks()[0];if(!l||!u)return 0;let d=l.createMediaStreamSource(r),f=l.createAnalyser();f.fftSize=2048,d.connect(f);let p=new Float32Array(f.fftSize),m=performance.now(),h=0;return await new Promise(t=>{let r=setInterval(()=>{f.getFloatTimeDomainData(p),h=Math.max(h,e.windowRms(p));let n=performance.now()-m;i?.(Math.min(1,n/a)),!(n<a)&&(clearInterval(r),t())},n)}),d.disconnect(),f.disconnect(),h}function c(e){return Number.isFinite(e)?Math.min(a,Math.max(i,e*r)):i}exports.measureNoiseFloor=s,exports.suggestGateThreshold=c;
|
|
2
|
+
//# sourceMappingURL=noise-floor.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"noise-floor.cjs","names":[],"sources":["../../src/audio/noise-floor.ts"],"sourcesContent":["import { windowRms } from \"./voice-chain-stages\";\n\n/** How long the measurement listens by default, in milliseconds. */\nconst DEFAULT_DURATION_MS = 3000;\n\n/** How often it samples the room, in milliseconds. */\nconst SAMPLE_MS = 50;\n\n/**\n * Headroom over the measured floor, so ordinary speech clears the gate.\n *\n * A gate set exactly at the noise level chatters, opening and closing on the\n * room's own variation. Ported from `tempest-mirror-screen`'s `voiceChain.ts`,\n * where it was arrived at by listening.\n */\nconst GATE_MARGIN = 2.4;\n\n/** The narrowest and widest thresholds worth suggesting. */\nconst GATE_MIN = 0.004;\nconst GATE_MAX = 0.12;\n\n/** Options for {@link measureNoiseFloor}. */\nexport interface NoiseFloorOptions {\n /** Called with `0..1` so a dialog can show the wait. */\n onProgress?: (fraction: number) => void;\n /** How long to listen. Default `3000`. */\n durationMs?: number;\n /** Reuse an existing `AudioContext` rather than opening one. */\n context?: AudioContext;\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n if (typeof AudioContext !== \"undefined\") return AudioContext;\n return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/**\n * Listen to a room at rest and report how loud its silence is.\n *\n * The number a person cannot guess. Setting a gate by ear means switching it on\n * and hearing what disappeared, which only works if you already know what should\n * have disappeared — so the usual outcome is leaving it at a default that is\n * wrong for that room. Measuring replaces the guess.\n *\n * The result is the **peak** RMS window observed, not the average: a threshold\n * under the loudest thing the quiet room did will be opened by that thing.\n *\n * @param stream - A live capture of the room while nobody speaks.\n * @param options - See {@link NoiseFloorOptions}.\n * @returns The peak RMS observed, `0..1`. `0` when this engine has no Web Audio\n * or the stream carries no audio track — which {@link suggestGateThreshold}\n * turns into its own floor rather than into a gate that never opens.\n *\n * @example\n * const floor = await measureNoiseFloor(stream, { onProgress: setProgress });\n * setThreshold(suggestGateThreshold(floor));\n */\nexport async function measureNoiseFloor(\n stream: MediaStream,\n {\n onProgress,\n durationMs = DEFAULT_DURATION_MS,\n context: injectedContext,\n }: NoiseFloorOptions = {},\n): Promise<number> {\n const Ctor = audioContextConstructor();\n const context = injectedContext ?? (Ctor ? new Ctor() : null);\n const track = stream.getAudioTracks()[0];\n if (!context || !track) return 0;\n\n const source = context.createMediaStreamSource(stream);\n const analyser = context.createAnalyser();\n analyser.fftSize = 2048;\n source.connect(analyser);\n\n const buffer = new Float32Array(analyser.fftSize);\n const started = performance.now();\n let peak = 0;\n\n await new Promise<void>((resolve) => {\n const timer = setInterval(() => {\n analyser.getFloatTimeDomainData(buffer);\n peak = Math.max(peak, windowRms(buffer));\n\n const elapsed = performance.now() - started;\n onProgress?.(Math.min(1, elapsed / durationMs));\n if (elapsed < durationMs) return;\n clearInterval(timer);\n resolve();\n }, SAMPLE_MS);\n });\n\n source.disconnect();\n analyser.disconnect();\n return peak;\n}\n\n/**\n * Turn a measured noise floor into a threshold worth using.\n *\n * Sits above the floor by a margin rather than on it, and is clamped at both\n * ends: a room measured as perfectly silent (or not measured at all) would\n * otherwise get a threshold of zero, which is a gate that never closes, and a\n * room measured while somebody talked would get one nothing opens.\n *\n * @param noiseFloor - Peak RMS measured while nobody spoke.\n * @returns A threshold for {@link VoiceChainSettings.gateThreshold}, `0.004..0.12`.\n */\nexport function suggestGateThreshold(noiseFloor: number): number {\n if (!Number.isFinite(noiseFloor)) return GATE_MIN;\n return Math.min(GATE_MAX, Math.max(GATE_MIN, noiseFloor * GATE_MARGIN));\n}\n"],"mappings":"4CAGA,IAAM,EAAsB,IAGtB,EAAY,GASZ,EAAc,IAGd,EAAW,KACX,EAAW,IAajB,SAAS,GAA2D,CAEhE,OADI,OAAO,aAAiB,IAAoB,aACxC,WAA4D,kBACxE,CAuBA,eAAsB,EAClB,EACA,CACI,aACA,aAAa,EACb,QAAS,GACU,CAAC,EACT,CACf,IAAM,EAAO,EAAwB,EAC/B,EAAU,IAAoB,EAAO,IAAI,EAAS,MAClD,EAAQ,EAAO,eAAe,CAAC,CAAC,GACtC,GAAI,CAAC,GAAW,CAAC,EAAO,MAAO,GAE/B,IAAM,EAAS,EAAQ,wBAAwB,CAAM,EAC/C,EAAW,EAAQ,eAAe,EACxC,EAAS,QAAU,KACnB,EAAO,QAAQ,CAAQ,EAEvB,IAAM,EAAS,IAAI,aAAa,EAAS,OAAO,EAC1C,EAAU,YAAY,IAAI,EAC5B,EAAO,EAiBX,OAfA,MAAM,IAAI,QAAe,GAAY,CACjC,IAAM,EAAQ,gBAAkB,CAC5B,EAAS,uBAAuB,CAAM,EACtC,EAAO,KAAK,IAAI,EAAM,EAAA,UAAU,CAAM,CAAC,EAEvC,IAAM,EAAU,YAAY,IAAI,EAAI,EACpC,IAAa,KAAK,IAAI,EAAG,EAAU,CAAU,CAAC,EAC1C,IAAU,KACd,cAAc,CAAK,EACnB,EAAQ,EACZ,EAAG,CAAS,CAChB,CAAC,EAED,EAAO,WAAW,EAClB,EAAS,WAAW,EACb,CACX,CAaA,SAAgB,EAAqB,EAA4B,CAE7D,OADK,OAAO,SAAS,CAAU,EACxB,KAAK,IAAI,EAAU,KAAK,IAAI,EAAU,EAAa,CAAW,CAAC,EAD7B,CAE7C"}
|